Merge remote-tracking branch 'origin/main' into fix/ruler-double-click-guide

# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
This commit is contained in:
Taha-Mahmoodi
2026-11-11 15:01:34 +04:30
68 changed files with 3132 additions and 192 deletions
+148 -20
View File
@@ -272,9 +272,9 @@ impl EndedLink {
}
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
/// measured. A command that replaces art calls this before it changes anything, since replacing
/// the art a dimension measures would otherwise leave the dimension unlinked, and then
/// [`restore_unchanged`].
/// measured. A command that replaces art calls this before it changes anything and
/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a
/// plain group.
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
let mut ended = Vec::new();
for id in reached(doc, roots) {
@@ -287,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink>
ended
}
/// The dimensions in `ended` whose art is as it was are live again.
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the
/// command changed from `before` so that the art no longer fits its link stays where it was, no
/// longer linked, as [`refresh`] leaves a dimension whose art was deleted.
pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec<EndedLink>) {
for e in ended {
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
&& let Some(n) = doc.node_mut(e.id)
{
n.extras_mut().dimension = Some(Box::new(e.recipe));
if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) {
continue;
}
let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) };
if let Some(n) = doc.node_mut(e.id) {
n.extras_mut().dimension = Some(Box::new(recipe));
doc.dimensions = true;
}
}
}
/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when
/// the moved dimension is valid.
fn unlinked(old: &Dimension) -> Dimension {
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = old.moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
unlinked
}
/// The whole dimensions under `roots` that are selected together with art they measure (art under
/// `roots`), each one a root or held by groups without fills, strokes or effects of their own.
/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their
/// lines and label as they are. A group with an appearance of its own changes everything it holds
/// when it expands, its dimensions included.
pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
if !doc.dimensions {
return Vec::new();
}
let mut found = Vec::new();
for root in roots {
let Some(n) = doc.node(*root) else { continue };
n.walk(&mut |c| {
if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) {
found.push((*root, c.id, source.nodes()));
}
});
}
let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a)));
found
.into_iter()
.filter(|(root, id, _)| {
let Some(ancestry) = doc.ancestry(*id) else { return false };
let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len());
ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group))
})
.filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m)))
.map(|(_, id, _)| id)
.collect()
}
/// A group or layer without fills, strokes or effects of its own.
fn plain_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty()
}
/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or
/// its parts (with their places in the asset list) and its parts' variable bindings.
pub(crate) struct Lifted {
parent: Option<NodeId>,
index: usize,
node: Arc<Node>,
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
}
impl Lifted {
pub(crate) fn id(&self) -> NodeId {
self.node.id
}
}
/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces
/// art does not reach them; [`put_back`] puts them back.
pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec<Lifted> {
let mut lifted = Vec::new();
for id in with_their_art(doc, roots) {
let Some((parent, index, _)) = doc.position(id) else { continue };
let mut parts = std::collections::BTreeSet::new();
if let Some(n) = doc.node(id) {
n.walk(&mut |c| {
parts.insert(c.id);
});
}
let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect();
let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect();
if let Ok(node) = doc.remove(id) {
lifted.push(Lifted { parent, index, node, assets, bindings });
}
}
lifted
}
/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in
/// the export assets that listed them and their variable bindings back: edits made while it was out
/// let go of them. A dimension whose group is gone goes on top of the first layer.
pub(crate) fn put_back(doc: &mut Document, lifted: Vec<Lifted>) {
for l in lifted.into_iter().rev() {
let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) };
let Ok(ch) = doc.children_mut(parent) else { continue };
let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() };
ch.insert(i, l.node);
doc.dimensions = true;
for (at, saved) in l.assets {
let there: Vec<NodeId> = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect();
if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) {
for (k, n) in there.iter().enumerate() {
if !a.nodes.contains(n) {
let k = k.min(a.nodes.len());
a.nodes.insert(k, *n);
}
}
} else if !there.is_empty() {
let at = at.min(doc.assets.len());
doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved });
}
}
for (n, b) in l.bindings {
doc.variables.bindings.entry(n).or_insert(b);
}
}
}
/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves
/// them alone would change: `doc` itself when there are none.
pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> {
let ids = with_their_art(doc, roots);
if ids.is_empty() {
return std::borrow::Cow::Borrowed(doc);
}
let mut view = doc.clone();
for id in ids {
// A dimension already gone (inside another one removed) needs nothing more.
let _ = view.remove(id);
}
std::borrow::Cow::Owned(view)
}
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
/// measure. Returns at once in documents without dimensions.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
@@ -320,17 +458,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
// same undo step, rather than attaching to a different edge.
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
if replace(doc, id, unlinked).is_err()
if replace(doc, id, unlinked(&old)).is_err()
&& let Some(n) = doc.node_mut(id)
{
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
+25 -10
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -674,11 +674,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
/// fills or strokes of its own?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
/// [`expandable`], leaving out the objects `skip` lists and what they hold.
pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
if skip.contains(&n.id) {
return false;
}
match &n.kind {
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
@@ -688,9 +696,11 @@ pub(crate) fn expandable(n: &Node) -> bool {
fn can_expand(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let d = &s.doc().map_err(|e| e.to_string())?.doc;
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
let d = &s.doc().map_err(|e| e.to_string())?.doc;
// A dimension selected with the art it measures is left as it is.
let kept = super::dimension::with_their_art(d, &ids);
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
Ok(())
} else {
Err("select (or target) objects whose appearance isn't basic".into())
@@ -975,7 +985,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
// geometry it hasn't changed.
if vectorcraft_plugins::safe_mode::active() {
let d = &s.doc()?.doc;
// A dimension selected with the art it measures is left as it is.
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
let mut seen = std::collections::HashSet::new();
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
@@ -985,20 +996,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let ids = s.edit("Expand Appearance", |d, _| {
let before = d.clone();
let mut ended = super::dimension::end_links(d, &roots);
// A dimension selected with the art it measures keeps its own lines and label: it is out
// of the document while the art expands.
let lifted = super::dimension::lift(d, &roots);
let kept = !lifted.is_empty();
let ended = super::dimension::end_links(d, &roots);
let brushes = vectorcraft_brush::library(d);
let mut out = vec![];
let threaded = super::threads::frames(d);
for id in &roots {
expand_node(d, *id, &brushes, &mut out);
}
// A dimension whose source was replaced stays plain: its link no longer fits that art.
ended.retain(|e| e.source_kept(&before, d));
super::dimension::restore_unchanged(d, ended);
super::dimension::put_back(d, lifted);
// A dimension whose source was replaced stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
// Outlined frames of threaded text leave their threads with the text they show; type that
// stays type under a new id takes its frame's place.
super::threads::leave_threads(d, &threaded);
if out.is_empty() {
if out.is_empty() || (kept && d.layers == before.layers) {
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
}
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?;
+35 -11
View File
@@ -5,7 +5,8 @@
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art
//! Expand changes becomes a plain group of that art.
//! Expand changes becomes a plain group of that art, except one selected with art it measures,
//! which Expand leaves as it is.
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -43,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}",
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
expand
),
@@ -55,7 +56,8 @@ pub fn specs() -> Vec<CommandSpec> {
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
has_selection,
|s, _| {
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
let roots = selected_roots(s)?;
let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots);
let mut info =
json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)});
if e.symbols {
@@ -74,7 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
/// (#3089); `object.expand` itself still bakes them.
pub fn has_appearance(s: &Session) -> bool {
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
// A dimension selected with the art it measures is left as it is.
let kept = super::dimension::with_their_art(&st.doc, &roots);
roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept))
}
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
@@ -241,9 +245,18 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = switch_or(p, "stroke", true, C)?;
let to = GradientTo::parse(p)?;
let from = s.doc()?.history.undo.len();
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Before anything expands: outlining the art a dimension measures would leave the dimension unlinked.
let before = s.doc()?.doc.clone();
let selected = s.doc()?.selection.objects.clone();
// A dimension selected with the art it measures keeps its own lines and label: it is out of the
// document while the art expands, so no step below reaches it.
let roots = selected_roots(s)?;
let mut lifted = vec![];
if !super::dimension::with_their_art(&before, &roots).is_empty() {
lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?;
}
let kept: Vec<NodeId> = lifted.iter().map(super::dimension::Lifted::id).collect();
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
let mut ended = vec![];
end_dimension_links(s, &mut ended)?;
let rev0 = s.doc()?.revision;
@@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.revision == rev0 {
return Err(EngineError::Other("Expand: nothing to expand".into()));
}
if !ended.is_empty() {
// A dimension whose source Expand replaced stays plain: its link no longer fits that art.
let after = s.doc()?.doc.clone();
ended.retain(|e| e.source_kept(&before, &after));
s.edit("Expand", |d, _| {
super::dimension::restore_unchanged(d, std::mem::take(&mut ended));
if !ended.is_empty() || !lifted.is_empty() {
s.edit("Expand", |d, sel| {
if !lifted.is_empty() {
super::dimension::put_back(d, lifted);
// The dimensions put back follow what Expand made of the art they measure: a
// dimension whose art no longer fits its link stays where it was, no longer linked.
super::dimension::refresh(&before, d)?;
let mut ids = sel.objects.clone();
let again: Vec<NodeId> = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect();
ids.extend(again);
sel.set(ids);
}
// A dimension whose source Expand replaced stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
Ok(())
})?;
if !kept.is_empty() && s.doc()?.doc.layers == before.layers {
return Err(EngineError::Other("Expand: nothing to expand".into()));
}
}
squash(s, from, "Expand");
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
+10 -6
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Outline Stroke",
["Object", "Path"],
None,
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}",
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
outline_stroke
),
@@ -1074,7 +1074,11 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Outline Stroke", |d, sel| {
let before = d.clone();
let mut ended = super::dimension::end_links(d, &roots);
// A dimension selected with the art it measures keeps its own lines: it is out of the
// document while the art is outlined.
let lifted = super::dimension::lift(d, &roots);
let kept = !lifted.is_empty();
let ended = super::dimension::end_links(d, &roots);
let brushes = vectorcraft_brush::library(d);
let mut new_sel = vec![];
let mut changed = 0;
@@ -1083,10 +1087,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
new_sel.push(id);
changed += n;
}
// A dimension whose source was outlined stays plain: its link no longer fits that art.
ended.retain(|e| e.source_kept(&before, d));
super::dimension::restore_unchanged(d, ended);
if changed == 0 {
super::dimension::put_back(d, lifted);
// A dimension whose source was outlined stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
if changed == 0 || (kept && d.layers == before.layers) {
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
}
sel.set(new_sel.iter().copied());
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -596,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
if texts.is_empty() {
return Err(EngineError::Other("Create Outlines: select text objects".into()));
}
let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone());
let ended = super::dimension::end_links(d, &roots);
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
let mut out = vec![];
@@ -691,7 +692,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
new_sel.push(gid);
}
}
super::dimension::restore_unchanged(d, ended);
if let Some(before) = &before {
super::dimension::restore_unchanged(before, d, ended);
}
// Outlined frames of threaded text leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
sel.set(new_sel);
+1 -1
View File
@@ -207,7 +207,7 @@ impl Console {
/// `c` as the console shows it: a control character as its Control Picture, a line or paragraph
/// separator as U+2424, anything else as it is.
fn shown(c: char) -> char {
pub(crate) fn shown(c: char) -> char {
match c {
'\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'),
'\u{7f}' => '\u{2421}',
+364
View File
@@ -0,0 +1,364 @@
//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and
//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs.
//!
//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless
//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the
//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and
//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel.
//!
//! In the app the run waits at the call for the person's answer, and spends no fuel while it
//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with
//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no
//! answer left, or with the next answer for another kind of dialog, ends the run. The report's
//! `dialogs` lists each dialog and its answer.
//!
//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`],
//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and
//! [`MAX_RECORD_BYTES`].
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use serde_json::{Map, Value, json};
use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError};
use super::{Host, console};
/// The most bytes (UTF-8) of a dialog's message.
pub const MAX_MESSAGE_BYTES: usize = 16 << 10;
/// The most bytes (UTF-8) of a dialog's title.
pub const MAX_TITLE_BYTES: usize = 256;
/// The most bytes (UTF-8) of a prompt's preset and of the text it returns.
pub const MAX_PROMPT_BYTES: usize = 16 << 10;
/// The most answers `script.run` takes.
pub const MAX_ANSWERS: usize = 10_000;
/// The most bytes of `answers`, as JSON.
pub const MAX_ANSWERS_BYTES: usize = 1 << 20;
/// The most dialogs a report lists.
pub const MAX_RECORDS: usize = 10_000;
/// The most bytes of the dialogs a report lists, as JSON.
pub const MAX_RECORD_BYTES: usize = 1 << 20;
/// A dialog's title when the script gives none, in English; the app translates it when it shows it.
pub const DEFAULT_TITLE: &str = "Script Alert";
/// Which dialog a script opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogKind {
Alert,
Confirm,
Prompt,
}
impl DialogKind {
/// The function's name, which is also the `kind` of an answer and of a report's dialog.
pub fn id(self) -> &'static str {
match self {
Self::Alert => "alert",
Self::Confirm => "confirm",
Self::Prompt => "prompt",
}
}
}
/// A dialog a script opened, which waits for its answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptDialog {
pub kind: DialogKind,
pub message: String,
/// `None`: the script gave no title ([`DEFAULT_TITLE`]).
pub title: Option<String>,
/// `alert`: show the error icon.
pub error_icon: bool,
/// `confirm`: No is the default button.
pub no_as_default: bool,
/// `prompt`: the text its field starts with.
pub preset: String,
}
/// The answer to a [`ScriptDialog`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DialogAnswer {
/// OK, for `alert`.
Ok,
/// Yes (`true`) or No, for `confirm`.
Confirm(bool),
/// OK with the field's text, or Cancel (`None`), for `prompt`.
Prompt(Option<String>),
}
impl DialogAnswer {
pub fn kind(&self) -> DialogKind {
match self {
Self::Ok => DialogKind::Alert,
Self::Confirm(_) => DialogKind::Confirm,
Self::Prompt(_) => DialogKind::Prompt,
}
}
/// What the function returns.
fn data(&self) -> Data {
match self {
Self::Ok => Data::Undefined,
Self::Confirm(b) => Data::Bool(*b),
Self::Prompt(Some(t)) => Data::String(t.clone()),
Self::Prompt(None) => Data::Null,
}
}
/// The answer as a report gives it: `"ok"`, a boolean, a string or `null`.
fn json(&self) -> Value {
match self {
Self::Ok => json!("ok"),
Self::Confirm(b) => json!(b),
Self::Prompt(t) => json!(t),
}
}
}
/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together
/// is a line break; every other control character, and with `lines` false every line break too,
/// shows as its Unicode Control Picture, as the Script Console shows it.
pub fn shown_text(text: &str, lines: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' if lines => {
chars.next_if_eq(&'\n');
out.push('\n');
}
'\n' if lines => out.push('\n'),
c => out.push(console::shown(c)),
}
}
out
}
/// The longest start of `text` that is at most `max` bytes, cut at a character boundary.
pub fn cut_to(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or_default()
}
/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value:
/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter
/// took before dialogs existed, is an empty list.
pub(crate) fn parse_answers(v: Option<&Value>) -> Result<Vec<DialogAnswer>, String> {
let list = match v {
None => return Ok(Vec::new()),
Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err("`answers` must be a list of answers".into()),
};
if list.len() > MAX_ANSWERS {
return Err(format!("`answers` has more than {MAX_ANSWERS} answers"));
}
if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) {
return Err("`answers` is larger than 1 MiB".into());
}
list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect()
}
fn answer(a: &Value) -> Result<DialogAnswer, &'static str> {
let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") };
if o.len() != 2 || !o.contains_key("value") {
return Err("must have exactly `kind` and `value`");
}
match (o.get("kind").and_then(Value::as_str), &o["value"]) {
(Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok),
(Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""),
(Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)),
(Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"),
(Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)),
(Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))),
(Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"),
(Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"),
_ => Err("must have `kind` alert, confirm or prompt"),
}
}
/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits
/// for the person's answer, and the list the report gives.
pub(crate) struct Dialogs {
/// Answers come from the person, not from `answers`.
framed: bool,
/// The realm's [`Realm::wait_flag`].
can_wait: Rc<Cell<bool>>,
answers: RefCell<VecDeque<DialogAnswer>>,
waiting: RefCell<Option<ScriptDialog>>,
/// The error that ended the run when an answer could not reach the script.
failed: RefCell<Option<RunError>>,
records: RefCell<Vec<Value>>,
record_bytes: Cell<usize>,
count: Cell<u64>,
truncated: Cell<bool>,
}
impl Dialogs {
pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec<DialogAnswer>) -> Self {
Self {
framed,
can_wait: realm.wait_flag(),
answers: RefCell::new(answers.into()),
waiting: RefCell::default(),
failed: RefCell::default(),
records: RefCell::default(),
record_bytes: Cell::new(0),
count: Cell::new(0),
truncated: Cell::new(false),
}
}
/// The dialog that waits for its answer.
pub(crate) fn waiting(&self) -> Option<ScriptDialog> {
self.waiting.borrow().clone()
}
pub(crate) fn is_waiting(&self) -> bool {
self.waiting.borrow().is_some()
}
/// The error that ended the run when its answer could not reach the script.
pub(crate) fn take_failure(&self) -> Option<RunError> {
self.failed.borrow_mut().take()
}
/// How many of `answers` the run did not use.
pub(crate) fn unused(&self) -> usize {
self.answers.borrow().len()
}
/// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most
/// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits.
pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool {
let fits = match &answer {
DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES,
_ => true,
};
let mut waiting = self.waiting.borrow_mut();
let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false };
self.record(d, answer.json());
*waiting = None;
if let Err(e) = realm.answer(answer.data()) {
*self.failed.borrow_mut() = Some(e);
}
true
}
/// The run ends while a dialog waits: the report lists it with the answer `"stop"`.
pub(crate) fn stopped(&self) {
if let Some(d) = self.waiting.borrow_mut().take() {
self.record(&d, json!("stop"));
}
}
fn record(&self, d: &ScriptDialog, answer: Value) {
self.count.set(self.count.get().saturating_add(1));
let mut r = Map::new();
r.insert("kind".into(), json!(d.kind.id()));
r.insert("message".into(), json!(d.message));
r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE)));
match d.kind {
DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)),
DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)),
DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)),
};
r.insert("answer".into(), answer);
let r = Value::Object(r);
let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len());
let mut records = self.records.borrow_mut();
if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES {
self.truncated.set(true);
return;
}
self.record_bytes.set(self.record_bytes.get() + bytes);
records.push(r);
}
/// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report.
pub(crate) fn report(&self, report: &mut Map<String, Value>) {
report.insert("dialogs".into(), Value::Array(self.records.borrow().clone()));
report.insert("dialogCount".into(), json!(self.count.get()));
report.insert("dialogsTruncated".into(), json!(self.truncated.get()));
}
}
/// Adds `alert`, `confirm` and `prompt` to the realm.
pub(super) fn install(realm: &mut Realm, host: &Rc<Host>) -> Result<(), RunError> {
use Arg::{Bool, Text, TextOrUndefined};
let call = |kind: DialogKind| -> HostFunction {
let h = host.clone();
Rc::new(move |args| open(&h, kind, &args))
};
realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?;
realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?;
realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt))
}
fn text(args: &[Data], i: usize) -> Option<String> {
match args.get(i) {
Some(Data::String(s)) => Some(s.clone()),
_ => None,
}
}
fn flag(args: &[Data], i: usize) -> bool {
matches!(args.get(i), Some(Data::Bool(true)))
}
/// A call of `alert`, `confirm` or `prompt`.
fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result<Data, HostError> {
let name = kind.id();
host.charge_call(args.iter().map(super::data_size).sum())?;
let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) };
let (title, error_icon, no_as_default, preset) = match kind {
DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()),
DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()),
DialogKind::Prompt => {
let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) };
(text(args, 2), false, false, preset)
}
};
if message.len() > MAX_MESSAGE_BYTES {
return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB")));
}
if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) {
return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes")));
}
if preset.len() > MAX_PROMPT_BYTES {
return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB")));
}
let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset };
let d = &host.dialogs;
if d.framed {
if !d.can_wait.get() {
return Err(HostError::Error(format!(
"{name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
)));
}
*d.waiting.borrow_mut() = Some(dialog);
return Err(HostError::Wait);
}
let next = d.answers.borrow_mut().pop_front();
match next {
Some(a) if a.kind() == kind => {
d.record(&dialog, a.json());
Ok(a.data())
}
Some(a) => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id())))
}
None => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog")))
}
}
}
+52 -6
View File
@@ -32,10 +32,16 @@
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
//! line and a report line; the run's report holds its own output and error, its time, its host
//! calls by kind and its slowest commands.
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
pub mod console;
pub mod dialogs;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
@@ -88,7 +94,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30;
/// The version `vc.api.version` gives.
pub const API_VERSION: &str = "1.0";
/// The features `vc.api.has` knows.
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console"];
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs"];
/// The most commands a run's report lists in `slowestCommands`.
pub const SLOWEST_COMMANDS: usize = 5;
@@ -147,6 +153,8 @@ pub(crate) struct Request {
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
thread_local! {
@@ -480,6 +488,8 @@ struct Host {
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// `alert`, `confirm` and `prompt`: their answers and the list the report gives.
dialogs: dialogs::Dialogs,
/// The files the run reads: its includes and `$.evalFile`'s.
files: RefCell<include::Files>,
/// Where the lines of the script's expanded source came from.
@@ -1061,7 +1071,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str {
/// `message`, with what to do about a failure that comes from a script written for another program.
fn explained(kind: &RunErrorKind, message: &str) -> String {
const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
let names_host_object =
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
if *kind == RunErrorKind::Thrown && names_host_object {
@@ -1163,6 +1173,23 @@ impl Session {
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten()
}
/// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`]
/// goes on from the call. → false when nothing waits, the answer is for another kind of
/// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`].
pub fn script_answer(&mut self, answer: DialogAnswer) -> bool {
let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false };
with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false)
}
/// The name of the running script.
pub fn script_name(&self) -> Option<String> {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone()))
@@ -1270,6 +1297,7 @@ impl Session {
counts: Counts::default(),
commands: RefCell::default(),
run_nanos: Cell::new(0),
dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
@@ -1286,6 +1314,7 @@ impl Session {
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.and_then(|()| dialogs::install(&mut realm, &host))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), &e))
}
@@ -1312,11 +1341,20 @@ impl Session {
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if job.host.waiting.get() && !self.script_stop.is_set() {
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
self.script_key = Some(key);
return None;
}
// A stop while a dialog waits ends the run there; the interpreter cannot go on from the call.
if job.realm.waits() {
let kind = LimitKind::Interrupted;
let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 };
return Some(self.script_failed(job, &e));
}
if vectorcraft_doc::cancel::check().is_err() {
self.script_stop.set();
}
@@ -1333,6 +1371,12 @@ impl Session {
self.script_key = Some(key);
None
}
Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => {
let n = job.host.dialogs.unused();
let kind = LimitKind::Host;
let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" });
Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 }))
}
Ok(Ok(Progress::Done(_))) => {
let r = self.script_commit(job);
self.trim_histories();
@@ -1342,6 +1386,7 @@ impl Session {
}
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, job.realm.error_source(), e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
@@ -1438,7 +1483,7 @@ impl Session {
if let Some(result) = self.script_pump() {
return result;
}
if self.script_waiting() {
if self.script_waiting() || self.script_dialog().is_some() {
self.script_stop.set();
}
}
@@ -1504,8 +1549,8 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands and
/// console to `report`.
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
/// console and dialogs to `report`.
fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) {
let mut pending = std::mem::take(&mut *host.pending.borrow_mut());
if !pending.is_empty() {
@@ -1539,6 +1584,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
o.insert("hostCallCounts".into(), host.counts.json());
o.insert("slowestCommands".into(), Value::Array(slow_json));
o.insert("console".into(), host.console.borrow().report());
host.dialogs.report(o);
}
}
+1
View File
@@ -151,6 +151,7 @@ mod save;
mod saveoptions;
mod scalestrokes;
mod script_console;
mod script_dialogs;
mod script_folder;
mod script_include;
mod script_run;
+289
View File
@@ -0,0 +1,289 @@
//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with
//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`.
use serde_json::{Value, json};
use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs};
use crate::*;
/// One open document with one rectangle.
fn one_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s
}
fn art(s: &Session) -> (String, usize) {
let d = &s.documents()[0];
(format!("{:?}", d.doc.layers), d.history.undo.len())
}
fn headless(s: &mut Session, source: &str, answers: Value) -> Result<Value> {
s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers}))
}
/// A run in the app of `source`, one slice in.
fn framed(s: &mut Session, source: &str) {
s.script_frames = true;
assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true);
assert!(s.script_pump().is_none());
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
if s.script_dialog().is_some() {
return None;
}
if let Some(r) = s.script_pump() {
return Some(r);
}
}
panic!("the run neither ended nor waited");
}
fn answers(list: &[Value]) -> Value {
Value::Array(list.to_vec())
}
#[test]
fn notification_signatures_and_returns() {
let mut s = one_doc();
let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\
vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\
vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));";
let r = headless(
&mut s,
source,
answers(&[
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "confirm", "value": true}),
json!({"kind": "confirm", "value": false}),
json!({"kind": "prompt", "value": "typed"}),
json!({"kind": "prompt", "value": null}),
]),
)
.unwrap();
assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}");
assert_eq!(
r["dialogs"],
json!([
{"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"},
{"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"},
{"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true},
{"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false},
{"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"},
{"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null},
])
);
assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false)));
let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap();
assert_eq!(has["log"], json!([true]));
}
#[test]
fn headless_answers_follow_call_order() {
let mut s = one_doc();
let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());";
let r = headless(
&mut s,
source,
answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]),
)
.unwrap();
assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}");
assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list");
}
#[test]
fn missing_or_mismatched_answer_rolls_back() {
let mut s = one_doc();
let before = art(&s);
let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
for (source, given, says) in [
(format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"),
] {
let e = headless(&mut s, &source, given).unwrap_err().to_string();
assert!(e.contains(says), "{e}");
assert_eq!(art(&s), before, "{source}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "limit", "{report}");
assert_eq!(report["log"], json!([]), "a missing answer cannot be caught");
assert_eq!(report["dialogCount"], 1);
}
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok");
headless(&mut s, "alert('x')", json!([])).unwrap_err();
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing");
}
#[test]
fn invalid_answers_are_rejected_before_run() {
let mut s = one_doc();
let before = art(&s);
let many: Vec<Value> = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let big: Vec<Value> = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect();
for given in [
json!({"a": 1}),
json!("ok"),
json!([1]),
json!([{"kind": "alert"}]),
json!([{"kind": "alert", "value": true}]),
json!([{"kind": "confirm", "value": "yes"}]),
json!([{"kind": "prompt", "value": 3}]),
json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]),
json!([{"kind": "other", "value": "ok"}]),
json!([{"kind": "alert", "value": "ok", "extra": 1}]),
Value::Array(many),
Value::Array(big),
] {
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
let r = s.execute("script.run", &json!({"source": source, "answers": given}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
assert_eq!(art(&s), before);
}
s.script_frames = true;
let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}");
assert!(!s.script_running());
}
#[test]
fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() {
let mut s = one_doc();
framed(
&mut s,
"vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);",
);
let want = ScriptDialog {
kind: DialogKind::Confirm,
message: "Go?".into(),
title: Some("Ask".into()),
error_icon: false,
no_as_default: true,
preset: String::new(),
};
assert_eq!(s.script_dialog(), Some(want));
assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused");
assert!(s.script_answer(DialogAnswer::Confirm(true)));
assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on");
assert!(pump(&mut s).is_none());
assert_eq!(s.script_dialog().unwrap().preset, "n");
assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1)))));
assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into()))));
let r = pump(&mut s).unwrap().unwrap();
assert_eq!(r["log"], json!([[true, "Ann"]]));
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step");
assert_eq!(r["dialogs"][1]["answer"], "Ann");
}
#[test]
fn prompt_cancel_returns_null() {
let mut s = one_doc();
framed(&mut s, "vc.log(prompt('Name', '') === null);");
assert!(s.script_answer(DialogAnswer::Prompt(None)));
assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true]));
}
#[test]
fn dialog_loop_stops_or_exhausts() {
let mut s = one_doc();
let before = art(&s);
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }";
let three: Vec<Value> = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string();
assert!(e.contains("no answer left"), "{e}");
assert_eq!(s.script_report().unwrap()["dialogCount"], 4);
assert_eq!(art(&s), before);
framed(&mut s, source);
for _ in 0..3 {
assert!(s.script_answer(DialogAnswer::Ok));
assert!(pump(&mut s).is_none());
}
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits");
assert!(!s.script_answer(DialogAnswer::Ok));
assert!(s.script_pump().unwrap().is_err());
let report = s.script_report().unwrap();
assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}");
assert_eq!(report["dialogs"][3]["answer"], "stop");
assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step");
framed(&mut s, source);
assert!(s.script_abandon(), "quitting while a dialog waits abandons the run");
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop");
assert_eq!(art(&s), before);
}
#[test]
fn dialog_text_limits_and_controls() {
let mut s = one_doc();
let source = format!(
"var out = [];\
function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\
t(function () {{ alert('x'); }});\
t(function () {{ alert(new Array({m}).join('m')); }});\
t(function () {{ alert('m', new Array({t}).join('t')); }});\
t(function () {{ prompt('m', new Array({m}).join('p')); }});\
t(function () {{ alert(); }});\
t(function () {{ prompt('m'); }});\
t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\
t(function () {{ alert(undefined); }});\
vc.log(out.join());",
m = dialogs::MAX_MESSAGE_BYTES + 2,
t = dialogs::MAX_TITLE_BYTES + 2,
);
let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}");
assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message");
assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}");
assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line");
assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters");
}
#[test]
fn dialog_transcript_on_success_and_failure() {
let mut s = one_doc();
let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err();
assert!(r.to_string().contains("later"));
let report = s.script_report().unwrap();
assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}]));
let ok = headless(&mut s, "1", json!([])).unwrap();
assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0)));
}
#[test]
fn dialog_wait_does_not_spend_fuel() {
let mut s = one_doc();
framed(&mut s, "alert('x'); 1");
for _ in 0..20 {
assert!(s.script_pump().is_none(), "the run waits");
}
assert!(s.script_answer(DialogAnswer::Ok));
let r = pump(&mut s).unwrap().unwrap();
let mut t = one_doc();
let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}");
}
#[test]
fn second_run_is_busy_while_dialog_waits() {
let mut s = one_doc();
framed(&mut s, "alert('x');");
assert!(s.execute("script.run", &json!({"source": "1"})).is_err());
assert!(s.script_dialog().is_some(), "the first run still waits");
assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits");
}
#[test]
fn a_dialog_in_a_callback_throws_in_the_app() {
let mut s = one_doc();
s.script_frames = true;
let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);";
s.execute("script.run", &json!({"source": source})).unwrap();
let r = pump(&mut s).unwrap().unwrap();
assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}");
assert_eq!(r["dialogCount"], 0);
}
+56
View File
@@ -332,3 +332,59 @@ fn script_included_error_has_local_file_and_line() {
assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}");
let _ = std::fs::remove_dir_all(base);
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
if s.script_dialog().is_some() {
return None;
}
if let Some(r) = s.script_pump() {
return Some(r);
}
}
panic!("the run neither ended nor waited");
}
#[test]
fn script_dialogs_in_included_and_evaluated_files() {
let base = folder("dialogs");
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\n");
write(&base.join("main/ev.jsx"), "alert('ev'); 7");
let text = "#include \"ask.jsxinc\"\nvar m = '';\ntry { m = $.evalFile('ev.jsx'); } catch (e) { m = e.message; }\nvc.log([a, m]);";
let main = base.join("main/main.jsx");
write(&main, text);
let path = main.to_string_lossy().to_string();
// In the app, an included file's dialog waits as the script's own does; one in a file
// $.evalFile runs cannot wait there and throws an error the script can catch.
let mut s = Session::new();
s.script_frames = true;
assert_eq!(s.execute("script.run", &json!({"path": path})).unwrap()["running"], true);
assert!(pump(&mut s).is_none());
assert_eq!(s.script_dialog().unwrap().message, "inc");
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
let r = pump(&mut s).unwrap().unwrap();
let m = r["log"][0][1].as_str().unwrap_or_default();
assert_eq!(r["log"][0][0], true, "{r}");
assert!(m.contains("alert") && m.contains("$.evalFile"), "the error names the call and why: {r}");
assert_eq!(r["dialogCount"], 1, "{r}");
// A run with nobody to ask takes both answers in order.
let mut s = Session::new();
let answers = json!([{"kind": "confirm", "value": false}, {"kind": "alert", "value": "ok"}]);
let r = s.execute("script.run", &json!({"path": path, "answers": answers})).unwrap();
assert_eq!((r["log"].clone(), r["dialogCount"].clone()), (json!([[false, 7]]), json!(2)), "{r}");
// An error in an included file after its dialog is at that file's line, and the report keeps
// both the file and the dialog.
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\nif (a) throw new Error('after');\n");
let mut s = Session::new();
s.script_frames = true;
s.execute("script.run", &json!({"path": path})).unwrap();
assert!(pump(&mut s).is_none());
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
assert!(pump(&mut s).unwrap().is_err());
let report = s.script_report().unwrap();
assert_eq!(report["line"], 2, "{report}");
assert!(report["file"].as_str().unwrap().ends_with("ask.jsxinc"), "{report}");
assert_eq!(report["dialogs"][0]["answer"], true, "{report}");
let _ = std::fs::remove_dir_all(base);
}
+11 -1
View File
@@ -41,8 +41,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
let mut s = session();
let list = run(&mut s, "graphicStyle.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert!(libs.len() >= 7 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}");
assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}");
for l in libs {
let id = l["id"].as_str().unwrap();
let g = run(&mut s, "graphicStyle.library", json!({"library": id}));
@@ -60,6 +61,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"}));
let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"}));
let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]}));
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow")));
@@ -67,6 +71,12 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap()));
assert_eq!(node.appearance.stroke_paint(), Paint::None);
assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow"]);
let id2 = rect(&mut s, 60.0, 20.0, 40.0, 30.0);
let r2 = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "status-effects", "name": "Poison Tint", "apply": true, "ids": [id2.0]}));
assert_eq!((names(&r2["added"]), r2["applied"].as_str()), (vec!["Poison Tint"], Some("Poison Tint")));
let node2 = doc(&s).node(id2).unwrap();
assert_eq!(node2.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#2ecc71").unwrap()));
assert_eq!(node2.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow"]);
// By name in any case.
assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows");
assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err());