Merge remote-tracking branch 'upstream/main' into fix/aligned-lines-trailing-tracking

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
jmverges
2026-11-11 16:38:59 +00:00
17 changed files with 740 additions and 38 deletions
+15 -7
View File
@@ -275,23 +275,30 @@ impl Scope {
}
}
/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a
/// blending mode other than Normal?
fn composites(scope: &Scope) -> bool {
scope.mask.is_some() || scope.blend != BlendMode::Normal
}
/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity
/// masks of those scopes, so each mask applies to the faces of the members it holds. The members'
/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque.
/// masks and blending modes of those scopes, so each applies to the faces of the members it holds.
/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are
/// opaque.
fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
let masks: Vec<(Vec<Scope>, Arc<Node>)> = surfaces
.into_iter()
.map(|(scopes, s)| {
let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect();
let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect();
(masks, s)
})
.collect();
crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0)
}
/// Does any of `shapes` sit in a scope with an opacity mask?
/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal?
fn has_masks(shapes: &[Profile]) -> bool {
shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some()))
shapes.iter().any(|s| s.scopes.iter().any(composites))
}
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
@@ -550,8 +557,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of
/// a group it is in, masks the surfaces of the members it holds ([`masked`]).
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]).
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
@@ -523,6 +523,52 @@ fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids()
}
}
/// The leaf faces of `art` that draw something, each with its visual bounds and whether it is drawn
/// inside a group with blending mode `mode`.
fn faces_in_mode(art: &Node, mode: BlendMode) -> Vec<(Rect, bool)> {
fn walk(n: &Node, inside: bool, mode: BlendMode, out: &mut Vec<(Rect, bool)>) {
let inside = inside || (n.children().is_some() && n.blend == mode);
match n.children() {
Some(children) => children.iter().for_each(|c| walk(c, inside, mode, out)),
None => out.extend(n.visual_bounds().map(|b| (b, inside))),
}
}
let mut out = Vec::new();
walk(art, false, mode, &mut out);
out
}
#[test]
fn a_blending_mode_on_a_member_or_a_group_in_a_three_d_group_applies_to_its_solids() {
let d = document();
let mode = |mut n: Node, mode: BlendMode| {
n.blend = mode;
n
};
let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0));
let member = || rect(1, 100.0, 100.0, 150.0, 200.0, 2.0);
let cases = [
("a multiplied member", Node::group(NodeId(90), vec![Arc::new(mode(member(), BlendMode::Multiply)), other()])),
(
"a member of a multiplied group",
Node::group(NodeId(90), vec![Arc::new(mode(Node::group(NodeId(80), vec![Arc::new(member())]), BlendMode::Multiply)), other()]),
),
];
for (what, g) in cases {
for id in TYPES {
let g = with(g.clone(), id, json!({}));
// The member spans y 100 to 200 and the other member y 600 to 650, and every type keeps
// its faces near those heights (Revolve turns them around a vertical axis): a face whose
// center is above y 400 belongs to the member.
let (mine, others): (Vec<_>, Vec<_>) =
faces_in_mode(&art(&d, &g, id).expect("drawn"), BlendMode::Multiply).into_iter().partition(|(b, _)| b.center().y < 400.0);
assert!(!mine.is_empty() && !others.is_empty(), "{id}: both members draw faces ({} and {})", mine.len(), others.len());
assert!(mine.iter().all(|(_, m)| *m), "{id}: every face of {what} is drawn in Multiply");
assert!(others.iter().all(|(_, m)| !m), "{id}: no face of the other member is drawn in Multiply");
}
}
}
#[test]
fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() {
let mut d = document();
+9 -3
View File
@@ -1239,7 +1239,7 @@ impl Clipboard {
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
/// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would
/// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else
/// a free letter or digit; the filter's id is always its name. An error when no free name fits.
/// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits.
fn filter_links(&self, d: &Document, same_doc: bool) -> Result<BTreeMap<(String, String), (String, String)>> {
let mut found = BTreeSet::new();
for n in &self.nodes {
@@ -1281,12 +1281,18 @@ impl Clipboard {
}
}
// Free names made from the starts of the name, longest first, then free letters and
// digits: the first under which the filter still draws.
// digits, then other single printable ASCII characters: the first under which the
// filter still draws.
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
ends.push(name.len());
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n));
let single = ('a'..='z')
.chain('0'..='9')
.chain('A'..='Z')
.chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric()))
.map(String::from)
.filter(|n| !taken.contains(n));
let renamed = shorter.chain(single).find_map(|new| {
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?;
+12 -3
View File
@@ -80,19 +80,28 @@ fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Whose gradient a selected stop belongs to: the active document, its first selected object
/// (None: the default paint), the proxy in front and the Appearance panel's active item.
/// Selecting other art, toggling the proxy or picking another item leaves no stop selected.
/// (None: the default paint), whether a part of a symbol instance's art is picked, the proxy in
/// front and the Appearance panel's active item. Selecting other art, picking a part of the
/// instance, toggling the proxy or picking another item leaves no stop selected.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct StopOwner {
doc: Option<usize>,
object: Option<NodeId>,
part: bool,
fill: bool,
item: Option<usize>,
}
impl StopOwner {
pub(crate) fn of(s: &Session) -> Self {
Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active, item: s.appearance_item() }
let st = s.active();
Self {
doc: s.active,
object: st.and_then(|d| d.selection.objects.first().copied()),
part: st.is_some_and(|d| d.selection.symbol_part.is_some()),
fill: s.fill_active,
item: s.appearance_item(),
}
}
}
@@ -903,6 +903,29 @@ fn gradients_and_patterns_on_a_picked_object_are_taken() {
assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes");
}
/// A stop selected on the whole instance's gradient is not selected once a part of its art is
/// picked, so the Gradient tool does not take Delete for it, and is selected again when the whole
/// instance is.
#[test]
fn picking_a_part_leaves_no_gradient_stop_selected() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]});
s.execute("select.set", &json!({"ids": [inst.0]})).unwrap();
s.execute("paint.setFill", &json!({"gradient": g})).unwrap();
s.fill_active = true;
let view = ViewInfo::default();
s.select_tool("gradient", view).unwrap();
s.execute("gradient.selectStop", &json!({"index": 1})).unwrap();
assert_eq!(s.selected_stop(), Some(1));
assert!(s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view));
s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap();
assert_eq!(s.selected_stop(), None);
assert!(!s.tool_claims_key(vectorcraft_tools::ToolKey::Delete, view));
s.execute("select.set", &json!({"ids": [inst.0]})).unwrap();
assert_eq!(s.selected_stop(), Some(1));
}
#[test]
fn opacity_and_blend_on_a_picked_object_go_through_transparency_set() {
let mut s = session();
+71 -6
View File
@@ -324,13 +324,13 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() {
assert_eq!(names, ["Shift 3"]);
}
/// A pasted filter at the size limit with a one-letter name, when every free name it could take is
/// in use, is refused rather than pasted under the name of the document's filter.
/// A pasted filter at the size limit with a one-letter name takes a free capital letter when every
/// lowercase letter and digit is in use.
#[test]
fn a_paste_with_no_free_name_for_its_filter_is_refused() {
fn a_renamed_paste_takes_a_free_capital_letter() {
let mut s = session();
for n in ('a'..='z').chain('0'..='9') {
run(&mut s, "svgFilter.define", json!({"xml": format!(r#"<filter id="{n}"><feOffset dx="1" dy="1"/></filter>"#)}));
run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}));
}
run(&mut s, "svgFilter.define", json!({"xml": r#"<filter id="S"><feOffset dx="3" dy="3"/></filter>"#}));
let (open, close) = (r#"<filter id="S"><feOffset dx="9" dy="9" result=""#, r#""/></filter>"#);
@@ -339,9 +339,74 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() {
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#S)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
let before = steps(&s);
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, "A");
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("A".to_string()));
assert!(pasted[0].1.contains("dx=\"9\""), "its own filter");
}
/// A filter named `n`, with the characters a quoted attribute value cannot hold escaped.
fn one_char_filter(n: char) -> String {
let id = match n {
'"' => "&quot;".to_string(),
'&' => "&amp;".to_string(),
'<' => "&lt;".to_string(),
c => c.to_string(),
};
format!(r#"<filter id="{id}"><feOffset dx="1" dy="1"/></filter>"#)
}
/// A pasted filter at the size limit with a one-letter name takes a free punctuation character when
/// every letter and digit is in use.
#[test]
fn a_renamed_paste_takes_a_free_punctuation_character() {
let mut s = session();
for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') {
run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}));
}
let (open, close) = (r#"<filter id="S"><feOffset dx="9" dy="9" result=""#, r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#S)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
run(&mut s, "edit.pasteInPlace", json!({}));
let pasted = filter_effects(&s);
assert_eq!(pasted.len(), 1);
assert_eq!(pasted[0].0, "!");
assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("!".to_string()));
assert!(pasted[0].1.contains("dx=\"9\""), "its own filter");
}
/// A pasted filter at the size limit with a one-letter name, when every free name it could take is
/// in use, is refused rather than pasted under the name of the document's filter.
#[test]
fn a_paste_with_no_free_name_for_its_filter_is_refused() {
let mut s = session();
for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') {
run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}));
}
// A document holds at most 64 filters: the other one-character names are taken by effects
// whose filters were deleted.
for n in ('!'..='~').filter(|c| !c.is_ascii_alphanumeric()) {
let a = rect(&mut s, 10.0);
let name = run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}))["name"].as_str().unwrap().to_string();
assert_eq!(name, n.to_string());
run(&mut s, "svgFilter.apply", json!({"name": name, "ids": [a.0]}));
run(&mut s, "svgFilter.delete", json!({"name": name}));
}
let (open, close) = (r#"<filter id="S"><feOffset dx="9" dy="9" result=""#, r#""/></filter>"#);
let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{big}</defs><rect width="30" height="30" filter="url(#S)"/></svg>"#
);
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
let (before, effects) = (steps(&s), filter_effects(&s).len());
assert_eq!(effects, 32);
assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name"));
assert!(filter_effects(&s).is_empty(), "nothing pasted");
assert_eq!(filter_effects(&s).len(), effects, "nothing pasted");
assert_eq!(steps(&s), before);
}
+61 -6
View File
@@ -19,10 +19,12 @@
//! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then
//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the
//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group
//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its
//! one-patch mesh. A warp's mesh is a grid of its shape: a warp of type whose own warp doesn't come
//! out as drawn opens as a mesh envelope on that grid where that does. An envelope whose parts don't
//! read, or that doesn't come out as drawn, keeps its drawn art.
//! holds the contents and the dictionary the mesh, which in some files lies away from the drawn art
//! by a move, as if it hadn't followed the envelope: such a mesh or top object whose grid draws its
//! contents as large as the drawn art but elsewhere is tried again moved onto that art. A top
//! object's outline is the edge of its one-patch mesh. A warp's mesh is a grid of its shape: a warp
//! of type whose own warp doesn't come out as drawn opens as a mesh envelope on that grid where that
//! does. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its drawn art.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
@@ -62,6 +64,10 @@ const MAX_GRID: u32 = 50;
/// How far, in points over width and height together, a saved size may lie from the contents'
/// bounds with their strokes and still count as that box.
const SAVED_SIZE_SLACK: f64 = 0.5;
/// How far, in points over width and height together, what an envelope draws may differ in size
/// from its drawn art and still count as that art moved, and how far it must lie from it to count
/// as moved at all ([`moved_onto`]).
const MOVED_SLACK: f64 = 0.5;
/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort
/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask).
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
@@ -183,6 +189,40 @@ fn with_strokes(kind: &NodeKind) -> Option<NodeKind> {
Some(kind)
}
/// The kind of `envelope`, a mesh or a top object in Edit Contents, with its grid moved onto
/// `drawn`, the drawn art the file saves for it, where what it draws is as large as that art (within
/// [`MOVED_SLACK`]) but lies elsewhere (further than that): moved by as far as the drawn art's box
/// lies from the box of what it draws. While the contents are edited, the grid is kept in the
/// envelope's dictionary, and in some files it lies away from the drawn art by a move, the contents
/// with the drawn art, as if the grid hadn't followed the envelope when it moved. `None` outside
/// Edit Contents, for a warp (it maps from its contents' box, which lies with them), and where
/// nothing is drawn. Symbol instances and pattern fills in the contents are measured as they are,
/// not drawn out: where that makes the sizes differ, no move is tried.
pub(super) fn moved_onto(envelope: &Node, drawn: &Node) -> Option<NodeKind> {
let NodeKind::Envelope { kind, editing: true, .. } = &envelope.kind else { return None };
if matches!(**kind, EnvelopeKind::Warp { .. }) {
return None;
}
// What it draws, measured as its drawn art is: the pieces in a group with its own appearance.
let mut drew = envelope.clone();
drew.kind = NodeKind::Group { children: vectorcraft_render::expand_live(envelope).into_iter().map(Arc::new).collect(), clip: false };
let (from, to) = (drew.visual_bounds()?, drawn.visual_bounds()?);
let by = to.center() - from.center();
let resized = (from.width() - to.width()).abs() + (from.height() - to.height()).abs();
if !(resized <= MOVED_SLACK && by.x.abs() + by.y.abs() > MOVED_SLACK && by.x.is_finite() && by.y.is_finite()) {
return None;
}
let mut moved = envelope.kind.clone();
if let NodeKind::Envelope { kind, .. } = &mut moved {
match &mut **kind {
EnvelopeKind::Mesh { points, .. } => points.iter_mut().for_each(|p| *p += by),
EnvelopeKind::TopObject { path, .. } => path.transform(Affine::translate(by)),
EnvelopeKind::Warp { .. } => return None,
}
}
Some(moved)
}
/// A warp: its style, bend, distortions and orientation.
fn warp(o: &Obj) -> Option<EnvelopeKind> {
let style = whole(o, "DeformStyle")??;
@@ -552,7 +592,8 @@ impl WarpedType {
/// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays
/// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each
/// is also checked mapping from its type's glyphs grown by half their strokes' weight
/// ([`with_strokes`]). Each
/// ([`with_strokes`]). In Edit Contents, a mesh or top object whose grid draws the type
/// elsewhere than its drawn art is checked last moved onto that art ([`moved_onto`]). Each
/// check uses one of the import's checks and, for an envelope that shows, adds to what the page
/// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't
/// available, the drawn art and the hidden type stay.
@@ -634,7 +675,17 @@ impl WarpedType {
(plain.visible, plain.opacity) = (true, 1.0);
let page = self.page.filter(|_| shown.contains(&drawn));
let mut alike = None;
for (i, kind) in kinds.into_iter().enumerate() {
// In Edit Contents, a grid that draws the type elsewhere than its drawn art is tried again
// moved onto that art ([`moved_onto`]), after every other way.
let (mut tries, mut moved, mut again, mut i) = (kinds.into_iter(), vec![], false, 0);
loop {
let Some(kind) = tries.next() else {
if again || moved.is_empty() {
break;
}
(tries, again) = (std::mem::take(&mut moved).into_iter(), true);
continue;
};
// A second try is only made while more checks are left than there are envelopes of
// type and warped text objects still to come (some of which may need none): one
// envelope's tries don't use up the checks of the others.
@@ -642,6 +693,7 @@ impl WarpedType {
return Err(TypeKept::Budget);
}
self.checks -= 1;
i += 1;
let mut envelope = art.clone();
(envelope.id, envelope.kind) = (id, kind);
let shared = Shared { patterns: &doc.patterns, images: &doc.images };
@@ -651,6 +703,9 @@ impl WarpedType {
alike = Some(envelope);
break;
}
if !again && let Some(kind) = moved_onto(&live_one, &plain) {
moved.push(kind);
}
}
let envelope = alike.ok_or(TypeKept::Differs)?;
if doc.node(drawn).is_none() {
+12 -1
View File
@@ -913,7 +913,18 @@ impl<'a> Reader<'a> {
let mut plain = drawn.clone();
(plain.visible, plain.opacity) = (true, 1.0);
let page = self.page_in_doc();
if !crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent) {
let mut alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent);
// In Edit Contents, a grid that draws the contents elsewhere than the drawn art is checked
// again moved onto that art.
if !alike
&& self.envelope_checks > 0
&& let Some(kind) = crate::import::ai::envelope::moved_onto(&shown, &plain)
{
self.envelope_checks -= 1;
(envelope.kind, shown.kind) = (kind.clone(), kind);
alike = crate::import::ai::envelope::drawn_alike(&shown, &plain, layer, &shared, page, &mut self.envelope_spent);
}
if !alike {
self.warn(crate::import::ai::envelope::DRAWN);
return;
}
+57
View File
@@ -7880,6 +7880,63 @@ fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() {
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files it
/// lies away from the drawn art by a move, the contents with the drawn art. Where the mesh draws the
/// contents as large as the drawn art but elsewhere, the envelope opens live with its mesh moved
/// onto that art; where it draws them at another size, it keeps its drawn art.
#[test]
fn an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art() {
use kurbo::Shape as _;
use vectorcraft_doc::live::EnvelopeKind;
// The top object of `an_envelope_whose_contents_are_edited_opens_live_in_edit_contents`.
let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) {
((0, 1), (1, 1)) => (70.0, 95.0),
((1, 1), (0, 1)) => (130.0, 95.0),
_ => {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
}
};
let mesh = mesh_text(1, 1, &at, &handle, None);
let mut bp = kurbo::BezPath::new();
bp.move_to((40.0, 20.0));
bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0));
bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0));
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
bp.close_path();
let shaped = |a: Affine| EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&(a * bp.clone())), corners: None };
let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)");
// The contents and the drawn art lie 30 points right and 15 down of where the mesh in the
// dictionary draws them.
let moved = Affine::translate((30.0, 15.0));
let contents = Rect::new(70.0, 35.0, 190.0, 95.0);
let read_with = |drawn: &EnvelopeKind| {
let block = format!("0 g\n{mesh}");
let red = format!("{RED}{}f\n", rect_path(contents.x0, 100.0 - contents.y1, contents.x1, 100.0 - contents.y0));
read_ok(&layer(&envelope_parts(&red, &keys, &block, 3, &drawn_box(drawn, contents, contents))))
};
let r = read_with(&shaped(moved));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, _, editing, _) = live_envelope(&a[0]);
assert!(editing);
let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") };
// Moved as far as the drawn art's box lies from the box of what the mesh draws.
let (got, want) = (path.to_bezpath().bounding_box(), (moved * bp.clone()).bounding_box());
assert!((got.origin() - want.origin()).hypot() < 0.01 && (got.size() - want.size()).to_vec2().hypot() < 1e-6, "{got:?} {want:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert_eq!(content[0].geometric_bounds(), Some(contents));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// Drawn art moved and a tenth larger isn't the mesh's art moved: it stays.
let r = read_with(&shaped(moved * Affine::scale_about(1.1, (100.0, 50.0))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape
/// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art
/// round it is read as ever.
+61
View File
@@ -5846,6 +5846,67 @@ fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() {
assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings);
}
/// [`type_envelope_on`] while its contents are edited: the text object in the group before its
/// dictionary, and `shape` in its contents' block, where the editing data then saves the mesh.
fn type_envelope_editing(keys: &str, shape: &str, drawn: &str) -> String {
let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() };
format!(
"{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
hidden(&format!("u\n{}U\n", text_object(0))),
hidden(&format!("/ArtDictionary :\n{}", keys.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)"))),
hidden(&format!("0 g\n{shape}")),
)
}
/// While the contents of an envelope of type are edited, its grid is kept in its dictionary, and in
/// some files it lies away from the drawn art by a move, the type with the drawn art. A warp of type
/// whose saved grid draws the type as large as its drawn art but elsewhere opens live as a mesh
/// envelope on that grid moved onto the drawn art (#3179).
#[test]
fn a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let mut text = made_envelope_type(&doc);
text.visible = true;
// The drawn art is the grid's moved 10 points left and 25 down.
let by = kurbo::Vec2::new(-10.0, 25.0);
let EnvelopeKind::Mesh { rows, cols, points, handles } = rising_kind() else { panic!("a mesh") };
let want: Vec<kurbo::Point> = points.iter().map(|p| *p + by).collect();
let kind = EnvelopeKind::Mesh { rows, cols, points: want.clone(), handles };
let options = vectorcraft_doc::live::EnvelopeOptions::NEW;
let shaped = Node::new(
vectorcraft_doc::NodeId(1_000_000),
NodeKind::Envelope {
content: vec![std::sync::Arc::new(text)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options,
frame: Box::default(),
},
);
let (drawn, page) = drawn_envelope(&shaped);
let art = native_with_text(
&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_editing(&arc_keys(0.3), &rising_grid(), &drawn))),
&doc,
);
let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
assert!(loose.is_empty(), "{loose:?}");
assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings);
let mut live = None;
r.document.walk(|n| {
if let NodeKind::Envelope { kind, editing, .. } = &n.kind {
live = Some(((**kind).clone(), *editing));
}
});
let Some((EnvelopeKind::Mesh { points, .. }, true)) = live else { panic!("not a mesh envelope in Edit Contents: {live:?}") };
assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 0.05), "{points:?}");
}
/// A warp of type tried again on its saved grid leaves a check for each envelope of type still to
/// come: with one check too few for both, the next envelope, whose first try opens it, gets it.
#[test]
+178 -5
View File
@@ -249,8 +249,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// the layout.
if let Some(fields) = ui.as_object_mut() {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
}
json!({
let mut inspected = json!({
"scriptRunning": app.session.script_running(),
"scriptReport": app.session.script_report(),
"tool": app.session.tool_id(),
@@ -294,8 +295,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"nativeMenuBar": app.services.native_menu.is_some(),
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(),
// Documents still being read off the UI thread (File › Open, Open Recent): their paths, without
// the ones outside the read root.
"opening": app.opening.pending.iter().map(|p| p.path.as_str()).filter(|p| readable(p)).collect::<Vec<_>>(),
// Files still being read off the UI thread for Place (the Place dialog's OK, drops): their
// names, in the order they are placed.
"placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
@@ -305,7 +307,173 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
})
});
let ids = unreadable_library_ids(app);
hide_library_ids(&mut inspected, &ids);
inspected
}
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels
/// shown as icons or collapsed, and the older floating panels' record.
const PANEL_FIELDS: [&str; 7] =
["open_panel", "docking", "docking_hidden", "docking_hidden_groups", "docking_icons", "docking_collapsed", "floating_panels"];
/// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the
/// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of
/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a
/// workspace's name) is not read. When another of those ids already shows that name, the next free one
/// of `loaded/<file name> (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that
/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
let mut ids = std::collections::BTreeSet::new();
let ui = serde_json::to_value(&app.ui).unwrap_or_default();
for field in PANEL_FIELDS {
collect_library_panel_ids(&ui[field], &mut ids);
}
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
collect_library_panel_ids(layout, &mut ids);
}
}
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids);
for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) {
add_library_id(id, &mut ids);
}
let mut hidden = Vec::new();
let mut taken = std::collections::BTreeSet::new();
for id in ids {
let path = &id["loaded/".len()..];
let name = vectorcraft_engine::cmd::fileio::file_name(path);
if name != path && !readable(path) {
hidden.push((id, name));
} else {
taken.insert(id);
}
}
let mut hidden = hidden
.into_iter()
.map(|(id, name)| {
let mut shown = format!("loaded/{name}");
let mut n = 2;
while taken.contains(&shown) {
shown = format!("loaded/{name} ({n})");
n += 1;
}
taken.insert(shown.clone());
(id, shown)
})
.collect::<Vec<_>>();
// The longest first, so that an id that begins another one does not replace part of it.
hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len()));
hidden
}
/// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`).
fn add_library_id(id: &str, ids: &mut std::collections::BTreeSet<String>) {
if id.len() > "loaded/".len() && id.starts_with("loaded/") {
ids.insert(id.to_string());
}
}
/// Adds to `ids` the loaded library id of every library panel id (`library:<kind>:loaded/<path>`)
/// that `v` holds as a string or a key.
fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<String>) {
let add = |s: &str, ids: &mut std::collections::BTreeSet<String>| {
if let Some((_, id)) = s.strip_prefix(crate::panels::library_panel::PREFIX).and_then(|rest| rest.split_once(':')) {
add_library_id(id, ids);
}
};
match v {
Value::String(s) => add(s, ids),
Value::Array(items) => items.iter().for_each(|item| collect_library_panel_ids(item, ids)),
Value::Object(fields) => {
for (key, item) in fields {
add(key, ids);
collect_library_panel_ids(item, ids);
}
}
_ => {}
}
}
/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's
/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
if ids.is_empty() {
return;
}
let hide = |s: &mut String| {
for (id, shown) in ids {
if s.contains(id.as_str()) {
*s = s.replace(id.as_str(), shown);
}
}
};
match v {
Value::String(s) => hide(s),
Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)),
Value::Object(fields) => {
for (mut key, mut item) in std::mem::take(fields) {
hide(&mut key);
hide_library_ids(&mut item, ids);
fields.insert(key, item);
}
}
_ => {}
}
}
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
/// The scratch disk choices that are not paths.
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
/// May the request being served read `path`? Under an automation read root (a control client's
/// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent
/// file or a listed script outside it.
fn readable(path: &str) -> bool {
vectorcraft_engine::file_access::check_read(path).is_ok()
}
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside
/// the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
}
if let Some(Value::Array(panels)) = ui.get_mut("library_panels") {
for panel in panels.iter_mut().filter_map(Value::as_object_mut) {
if panel.get("path").and_then(Value::as_str).is_some_and(|p| !readable(p)) {
panel.remove("path");
}
}
}
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
leave_out_unreadable_folders(prefs);
}
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
&& dialog.get("kind").and_then(Value::as_str) == Some("preferences")
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
{
leave_out_unreadable_folders(fields);
}
}
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) {
for key in PREF_FOLDERS {
let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue };
if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) {
continue;
}
prefs.remove(key);
}
}
fn key_from(name: &str) -> Option<egui::Key> {
@@ -348,7 +516,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.docking" => ok(crate::panel_docking::inspect(app, ctx)),
"ui.docking" => {
let mut docking = crate::panel_docking::inspect(app, ctx);
let ids = unreadable_library_ids(app);
hide_library_ids(&mut docking, &ids);
ok(docking)
}
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
+175
View File
@@ -360,6 +360,181 @@ fn recent_files_outside_the_read_root_are_not_listed_or_opened() {
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` leaves out the paths outside the read root that the person's work put in it: files
/// still opening, recent files, a library panel's file and folder preferences, saved and in the
/// Preferences dialog.
#[test]
fn ui_inspect_leaves_out_paths_outside_the_read_root() {
let (mut app, tx, base) = confined("inspect");
std::fs::copy(base.join("in/a.svg"), base.join("in/c.svg")).unwrap();
let (a, b, c) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")), s(base.join("in/c.svg")));
let svg = std::fs::read(&a).unwrap();
for path in [&a, &b, &c] {
assert!(crate::opening::start(&mut app, path, svg.clone()).is_ok(), "{path} opens off the UI thread");
}
app.ui.recent_files = vec![a.clone(), b.clone(), c.clone()];
let panel = |id: &str, path: &str| -> crate::panels::library_panel::LibraryPanel {
serde_json::from_value(json!({"kind": "swatches", "id": id, "path": path})).unwrap()
};
let theirs = format!("loaded/{b}");
app.ui.library_panels = vec![panel("mine", &a), panel(&theirs, &b)];
app.ui.open_panel = Some(format!("library:swatches:{theirs}"));
let (inside, outside) = (s(base.join("in")), s(base.join("outside")));
app.ui.engine_prefs = json!({
"fontsFolder": inside, "templatesFolder": outside, "recoveryFolder": outside, "scratchPrimary": "Startup",
"scratchSecondary": "../private", "pluginsFolder": "",
});
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"fontsFolder": inside, "templatesFolder": outside})));
// The person sees every path.
let ctx = egui::Context::default();
let person = crate::control::inspect(&app, &ctx);
assert_eq!(person["opening"], json!([a, b, c]));
assert_eq!(person["ui"]["recent_files"], json!([a, b, c]));
assert_eq!(person["ui"]["library_panels"][1]["path"], b);
assert_eq!(person["ui"]["engine_prefs"]["templatesFolder"], outside);
// The client sees the ones inside the read root, in the same order.
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
assert_eq!(r["result"]["opening"], json!([a, c]), "{r}");
assert_eq!(ui["recent_files"], json!([a, c]));
assert_eq!(ui["library_panels"][0]["path"], a);
assert_eq!((ui["library_panels"][1]["id"].as_str(), ui["library_panels"][1].get("path")), (Some("loaded/b.svg"), None), "the panel stays");
assert_eq!(ui["open_panel"], "library:swatches:loaded/b.svg");
let prefs = ui["engine_prefs"].as_object().unwrap();
assert_eq!(
(prefs["fontsFolder"].as_str(), prefs["scratchPrimary"].as_str(), prefs["pluginsFolder"].as_str()),
(Some(inside.as_str()), Some("Startup"), Some(""))
);
assert!(["templatesFolder", "recoveryFolder", "scratchSecondary"].iter().all(|k| !prefs.contains_key(*k)), "{prefs:?}");
let fields = ui["dialog"]["fields"].as_object().unwrap();
assert_eq!((fields["fontsFolder"].as_str(), fields.get("templatesFolder")), (Some(inside.as_str()), None), "the Preferences dialog's copy too");
let hidden = json!(outside).to_string();
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
let r = request(&mut app, &tx, "ui.docking", json!({}));
assert_eq!(r["result"]["flyout"], "library:swatches:loaded/b.svg", "{r}");
assert_eq!(crate::panel_docking::inspect(&app, &ctx)["flyout"], json!(format!("library:swatches:{theirs}")), "the person's");
assert_eq!(app.ui.recent_files, [a.clone(), b.clone(), c.clone()], "the UI state is not changed");
assert_eq!(app.opening.pending.len(), 3, "and no open is canceled");
// A write root alone lets the client read none of them.
let write_only = AutomationRoots::new(&[], &[&base.join("out")]).unwrap();
let listed = vectorcraft_engine::file_access::confine(write_only.as_ref(), || crate::control::inspect(&app, &ctx));
assert_eq!((&listed["opening"], &listed["ui"]["recent_files"]), (&json!([]), &json!([])), "{listed}");
let _ = std::fs::remove_dir_all(base);
}
/// A closed panel's former place, as `ui.docking_hidden` keeps it.
fn hidden_place() -> craft_ui::docking::Location<String> {
serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap()
}
/// `ui.inspect` hides the path of a library loaded from outside the read root wherever the reply holds
/// its id, also when its panel is closed: here only the Color Guide's limit and a closed panel's place
/// name it.
#[test]
fn ui_inspect_hides_a_closed_librarys_path_outside_the_read_root() {
let (mut app, tx, base) = confined("inspect-closed-lib");
let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
app.ui.color_guide_limit = theirs.clone();
app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place());
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
assert_eq!(ui["color_guide_limit"], "loaded/b.svg", "{r}");
assert!(ui["docking_hidden"].as_object().unwrap().contains_key("library:swatches:loaded/b.svg"), "{r}");
let hidden = json!(s(base.join("outside"))).to_string();
assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
assert_eq!(crate::control::inspect(&app, &egui::Context::default())["ui"]["color_guide_limit"], json!(theirs), "the person's");
let _ = std::fs::remove_dir_all(base);
}
/// Two libraries loaded from outside the read root with the same file name stay two in `ui.inspect`:
/// the second one shows as `loaded/<file name> (2)`, the same in every place the reply names it.
#[test]
fn ui_inspect_keeps_two_hidden_libraries_with_one_file_name_apart() {
let (mut app, tx, base) = confined("inspect-same-name");
let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg"))));
let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() };
app.ui.library_panels = vec![panel(&x), panel(&y)];
for id in [&x, &y] {
app.ui.docking_hidden.insert(format!("library:swatches:{id}"), hidden_place());
}
app.ui.color_guide_limit = y.clone();
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
let ids: Vec<_> = ui["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids, [json!("loaded/b.svg"), json!("loaded/b.svg (2)")], "{r}");
let keys: Vec<_> = ui["docking_hidden"].as_object().unwrap().keys().cloned().collect();
assert_eq!(keys, ["library:swatches:loaded/b.svg", "library:swatches:loaded/b.svg (2)"], "no entry is dropped: {r}");
assert_eq!(ui["color_guide_limit"], "loaded/b.svg (2)", "the same id everywhere");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` and `ui.docking` show a hidden library under the same id, also when another hidden
/// library with the same file name is in only one of the replies (here as the Color Guide's limit).
#[test]
fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() {
let (mut app, tx, base) = confined("inspect-docking-same-id");
let (x, y) = (format!("loaded/{}", s(base.join("outside/x/b.svg"))), format!("loaded/{}", s(base.join("outside/y/b.svg"))));
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": y})).unwrap();
app.ui.library_panels = vec![panel];
app.ui.open_panel = Some(format!("library:swatches:{y}"));
app.ui.color_guide_limit = x.clone();
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
assert_eq!(
(ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()),
(Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")),
"{r}"
);
let d = request(&mut app, &tx, "ui.docking", json!({}));
assert_eq!(d["result"]["flyout"], ui["open_panel"], "{d}");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` rewrites only the ids of libraries the UI state refers to: a script's report line and
/// a command palette query that look like a library id, with no such library, stay as written, and
/// the query does not take the file name of a real library outside the root.
#[test]
fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() {
let (mut app, tx, base) = confined("inspect-lookalike");
let real = format!("loaded/{}", s(base.join("outside/x/d.svg")));
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": real})).unwrap();
app.ui.library_panels = vec![panel];
let query = format!("library:swatches:loaded/{}", s(base.join("outside/d.svg")));
app.ui.palette_query = query.clone();
let text = format!("loaded/{}", s(base.join("outside/c.svg")));
let source = format!("vc.log({})", json!(text));
app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
for _ in 0..10_000 {
if app.session.script_pump().is_some() {
break;
}
}
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}");
assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}");
assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/d.svg", "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// A client without automation roots sees every loaded library's full id in `ui.inspect`.
#[test]
fn ui_inspect_shows_full_library_ids_without_a_read_root() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
let theirs = format!("loaded/{}", s(std::env::temp_dir().join("vc-ui-roots-unconfined").join("b.svg")));
let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": theirs})).unwrap();
app.ui.library_panels = vec![panel];
app.ui.color_guide_limit = theirs.clone();
app.ui.docking_hidden.insert(format!("library:swatches:{theirs}"), hidden_place());
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ui = &r["result"]["ui"];
assert_eq!((&ui["library_panels"][0]["id"], &ui["color_guide_limit"]), (&json!(theirs), &json!(theirs)), "{r}");
assert!(ui["docking_hidden"].as_object().unwrap().contains_key(&format!("library:swatches:{theirs}")), "{r}");
}
/// Hidden logic-only ticks between the injection and its UI pass must not drain another step:
/// one step per settled pass, not per hidden tick.
#[test]