mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
Merge remote-tracking branch 'upstream/main' into fix/aligned-lines-trailing-tracking
# Conflicts: # docs/file-format-parity.md
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
'"' => """.to_string(),
|
||||
'&' => "&".to_string(),
|
||||
'<' => "<".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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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("")}))),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user