mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
Merge remote-tracking branch 'upstream/main' into fix-6437-outline-select-behind
# Conflicts: # docs/ui-parity.md
This commit is contained in:
@@ -395,7 +395,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
if !kept {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx, Some(d));
|
||||
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct GeomContext<'a> {
|
||||
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
|
||||
/// pieces are looked up in. Without one, a result that names any is refused.
|
||||
pub doc: Option<&'a Document>,
|
||||
/// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by
|
||||
/// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and
|
||||
/// the effect's index in the list being applied.
|
||||
pub stage: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
}
|
||||
|
||||
impl<'a> GeomContext<'a> {
|
||||
@@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> {
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None }
|
||||
}
|
||||
|
||||
/// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect
|
||||
/// plug-in there is listed by `e`'s index in `list`.
|
||||
pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self {
|
||||
let stage = self.stage.or_else(|| {
|
||||
let effect = list.iter().position(|x| std::ptr::eq(x, e))?;
|
||||
Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item })
|
||||
});
|
||||
Self { stage, ..self }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
@@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within(
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) {
|
||||
if let Some(c) = carry.as_deref_mut() {
|
||||
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
|
||||
}
|
||||
@@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool {
|
||||
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
|
||||
use util::{join, num};
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
|
||||
@@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
|
||||
Some(area) => Some(reshaped(&area, n, doc)),
|
||||
None => {
|
||||
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
|
||||
// The group's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
@@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
|
||||
let art = h(n).filter(|art| nodes.charge(art))?;
|
||||
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
|
||||
crate::reshape::outline_nested_text(doc, &mut art, false);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
|
||||
// The art's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&art.appearance) {
|
||||
carry.apply(&mut art.appearance);
|
||||
@@ -738,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
|
||||
// The group's own fills and strokes paint the members as a whole: their gradients follow
|
||||
// the effects as the members' do.
|
||||
if paint && carry.moves(&m.appearance) {
|
||||
|
||||
@@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool {
|
||||
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
|
||||
}
|
||||
|
||||
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
|
||||
/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred
|
||||
/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]).
|
||||
pub fn plugin_revision() -> u64 {
|
||||
vectorcraft_plugins::registry::revision()
|
||||
vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32)
|
||||
}
|
||||
|
||||
@@ -573,7 +573,7 @@ fn as_art_opts(
|
||||
}
|
||||
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
|
||||
};
|
||||
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
|
||||
reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc);
|
||||
if apart {
|
||||
hoist_copies(&mut art);
|
||||
}
|
||||
@@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
|
||||
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
|
||||
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
|
||||
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
|
||||
/// ([`reshape_leaves_apart`]).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
|
||||
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
|
||||
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) {
|
||||
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc);
|
||||
}
|
||||
|
||||
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
|
||||
/// strokes) follow the effects ([`crate::Carry`]).
|
||||
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
|
||||
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc)
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_apart(art, fx, budget, false, doc)
|
||||
reshape_leaves_apart(art, fx, &[], budget, false, doc)
|
||||
}
|
||||
|
||||
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
|
||||
@@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
|
||||
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with
|
||||
/// `apart`, a Transform effect's copies of each path are objects
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
|
||||
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
|
||||
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
|
||||
/// Returns how gradients that paint the art as a whole follow the effects.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
|
||||
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
|
||||
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
|
||||
/// `owner` does not hold it.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
|
||||
let mut whole = crate::Carry::default();
|
||||
if fx.is_empty() {
|
||||
return whole;
|
||||
@@ -693,6 +697,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
});
|
||||
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
let style_safe = leaves_safe && !art_stroke;
|
||||
let listed = fx;
|
||||
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
|
||||
let fx = opt_fx.as_slice();
|
||||
let seed = art.id.0;
|
||||
@@ -703,12 +708,15 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
|
||||
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
|
||||
if let Some(plugin) = painted_plugin(e) {
|
||||
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, _)) = leaf_geometry(leaf) else { return };
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) {
|
||||
if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) {
|
||||
used = used.minus(before).plus(made);
|
||||
}
|
||||
});
|
||||
@@ -778,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
|
||||
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
|
||||
kept_ids.insert(leaf.id);
|
||||
return;
|
||||
};
|
||||
@@ -1056,10 +1065,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
|
||||
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
|
||||
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
|
||||
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
|
||||
/// refused, or they would pass `budget`.
|
||||
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
|
||||
/// refused, or they would pass `budget`. A deferred run is listed by `at`.
|
||||
fn paint_leaf(
|
||||
leaf: &mut Node,
|
||||
plugin: &str,
|
||||
params: &serde_json::Value,
|
||||
bounds: Rect,
|
||||
budget: Budget,
|
||||
doc: Option<&Document>,
|
||||
at: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
) -> Option<Budget> {
|
||||
let (path, rule) = leaf_geometry(leaf)?;
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at };
|
||||
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
|
||||
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
|
||||
if !budget.fits(made) {
|
||||
@@ -1097,18 +1114,18 @@ fn piece_cost(path: &PathData) -> Budget {
|
||||
/// other fills and strokes; one that gives none keeps the leaf's. Returns
|
||||
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
|
||||
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
|
||||
/// `budget`.
|
||||
/// `budget`. A deferred run is listed by `at`.
|
||||
fn paint_piece(
|
||||
leaf: &mut Node,
|
||||
item: &AppearanceItem,
|
||||
plugin: &str,
|
||||
params: &serde_json::Value,
|
||||
(plugin, params): (&str, &serde_json::Value),
|
||||
bounds: Rect,
|
||||
budget: Budget,
|
||||
doc: Option<&Document>,
|
||||
at: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
) -> Option<Budget> {
|
||||
let (path, rule) = leaf_geometry(leaf)?;
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at };
|
||||
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
|
||||
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
|
||||
if !budget.fits(made) {
|
||||
@@ -1241,6 +1258,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
};
|
||||
let Some(tb) = g.bounds() else { continue };
|
||||
let params = merged_params(&fx[at].id, &fx[at].params);
|
||||
// Where each effect the piece keeps sits among the item's effects.
|
||||
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
|
||||
if let Some(plugin) = painted_plugin(&fx[at]) {
|
||||
let mut it = item.clone();
|
||||
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
@@ -1250,7 +1269,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
carry.apply(&mut one);
|
||||
}
|
||||
let it = one.items.pop().unwrap_or(it);
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
|
||||
let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) };
|
||||
let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc)
|
||||
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
|
||||
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
|
||||
@@ -1264,7 +1284,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
group.push(Arc::new(p));
|
||||
}
|
||||
let mut p = Node::group(leaf.id, group);
|
||||
transform_piece(&mut p, ctx.seed, limit, used, doc);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
continue;
|
||||
}
|
||||
@@ -1281,7 +1301,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
set_geometry(&mut p, g.clone(), rule);
|
||||
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
|
||||
*used = used.plus(made);
|
||||
transform_piece(&mut p, ctx.seed, limit, used, doc);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
}
|
||||
if !plain.is_empty() {
|
||||
@@ -1297,14 +1317,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
|
||||
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
|
||||
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
|
||||
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
|
||||
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
|
||||
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
|
||||
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
|
||||
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
|
||||
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
|
||||
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
|
||||
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
|
||||
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
|
||||
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
|
||||
/// piece was made from, and the index among that item's effects of each effect the piece kept,
|
||||
/// which a deferred live-effect plug-in is listed by.
|
||||
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
let mut count = 0;
|
||||
for_each_leaf(p, &mut |l| {
|
||||
if let Some(item) = l.appearance.items.first() {
|
||||
@@ -1320,8 +1343,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
|
||||
// What the other paths leave of the budget.
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
|
||||
if let Some(plugin) = painted_plugin(&e) {
|
||||
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
|
||||
if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) {
|
||||
*used = used.minus(before).plus(made);
|
||||
}
|
||||
return;
|
||||
@@ -1340,7 +1364,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
|
||||
AppearanceItem::Stroke(s) => Some(s),
|
||||
AppearanceItem::Fill(_) => None,
|
||||
};
|
||||
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
|
||||
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) };
|
||||
let mut carry = crate::Carry::default();
|
||||
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
|
||||
*used = used.minus(before).plus(Budget::of(&out));
|
||||
|
||||
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
|
||||
/// no open document defines them.
|
||||
#[test]
|
||||
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
|
||||
let a = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let first = text(&s, a).plain_text();
|
||||
assert!(!first.is_empty() && first.len() < story.len());
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
// A new front document without the font: the font database no longer holds it.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
|
||||
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
assert_eq!(text(&s, a).plain_text(), first);
|
||||
}
|
||||
|
||||
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
|
||||
/// when it comes to the front, not in the front document's.
|
||||
#[test]
|
||||
|
||||
@@ -109,9 +109,155 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
cmd!(
|
||||
query "effect.deferredStatus",
|
||||
"Deferred Effects",
|
||||
[],
|
||||
None,
|
||||
"{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)",
|
||||
has_doc,
|
||||
deferred_status
|
||||
),
|
||||
cmd!(
|
||||
"effect.refreshDeferred",
|
||||
"Refresh Deferred Effects",
|
||||
["Effect"],
|
||||
None,
|
||||
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
has_deferred,
|
||||
refresh_deferred
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The active document has deferred live-effect plug-in stages ([`deferred_status`]).
|
||||
fn has_deferred(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers.
|
||||
/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect
|
||||
/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks.
|
||||
fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let mut rest = Vec::new();
|
||||
for object in fx.list().into_iter().map(|x| x.stage.object) {
|
||||
match d.node_arc(NodeId(object)) {
|
||||
Some(n) => {
|
||||
out.insert(object, (n.clone(), true));
|
||||
}
|
||||
None => rest.push(object),
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let symbols = d.symbols.iter().map(|s| &*s.art);
|
||||
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
|
||||
let layers = d.layers.iter().map(|a| &**a);
|
||||
let mut look = |n: &Node| {
|
||||
if rest.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
|
||||
}
|
||||
};
|
||||
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
|
||||
vectorcraft_doc::swatches::walk_with_masks(a, &mut look);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible,
|
||||
/// on a visible fill or stroke when it is one's?
|
||||
fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool {
|
||||
let Some(n) = n else { return false };
|
||||
let list = match stage.item {
|
||||
None => Some(n.appearance.effects.as_slice()),
|
||||
Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()),
|
||||
};
|
||||
list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin))
|
||||
}
|
||||
|
||||
/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place
|
||||
/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]).
|
||||
fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let objects = stage_objects(d, fx);
|
||||
fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin));
|
||||
objects
|
||||
}
|
||||
|
||||
/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose
|
||||
/// object or plug-in effect is no longer at their place in `d` are dropped first.
|
||||
fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec<Value> {
|
||||
prune(d, fx);
|
||||
fx.list()
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()});
|
||||
if let Some(i) = x.stage.item {
|
||||
v["item"] = json!(i);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `effect.deferredStatus`.
|
||||
fn deferred_status(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)}))
|
||||
}
|
||||
|
||||
/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that
|
||||
/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left
|
||||
/// out for its size?
|
||||
fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc<Node>) -> bool {
|
||||
let Some(b) = n.geometric_bounds() else { return false };
|
||||
let scale = 64.0 / b.width().max(b.height()).max(1.0);
|
||||
let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0));
|
||||
renderer.stats = Default::default();
|
||||
renderer.render_node(doc, n, 66, 66, view);
|
||||
renderer.stats.culled == 0
|
||||
}
|
||||
|
||||
/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small,
|
||||
/// inside the document's view, so its stages are run with the input they were deferred with; a
|
||||
/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a
|
||||
/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are
|
||||
/// drawn then. A stage input left out of a small drawing for its size stays deferred.
|
||||
fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
|
||||
let ids = super::named_ids(p)?;
|
||||
let objects = prune(&doc, &fx);
|
||||
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
|
||||
let counts = fx.refresh(ids.as_deref(), |targets| {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut whole = Vec::new();
|
||||
fx.view(|| {
|
||||
let mut elsewhere = false;
|
||||
for id in targets {
|
||||
match objects.get(id) {
|
||||
Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id),
|
||||
Some((_, false)) => elsewhere = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if elsewhere {
|
||||
for layer in doc.layers.iter().filter(|l| l.visible) {
|
||||
draw_small(&mut renderer, &doc, layer);
|
||||
}
|
||||
}
|
||||
});
|
||||
whole
|
||||
});
|
||||
Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed}))
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
@@ -506,7 +506,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// document keeps its text as saved), and every frame of a thread takes its first frame's
|
||||
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
|
||||
// frame of a restored document, the story flows through that frame's thread again. Threaded
|
||||
// frames keep their size: Auto Size is off for them.
|
||||
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
|
||||
// own composite fonts.
|
||||
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
|
||||
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
@@ -517,6 +520,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// paragraph's first-line indent and list marker.
|
||||
crate::cmd::threads::mark_continued(&mut doc);
|
||||
}
|
||||
drop(lent);
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -670,7 +670,9 @@ fn render_pixels(
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
// No side longer than the renderer draws: it would cut the image off there.
|
||||
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE);
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > side || region.height() * scale > side {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"edit.findNext",
|
||||
"edit.findReplace",
|
||||
"effect.apply",
|
||||
"effect.deferredStatus",
|
||||
"effect.duplicate",
|
||||
"effect.expandAppearance",
|
||||
"effect.list",
|
||||
@@ -662,11 +663,13 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
|
||||
/// preferences, a tool's options, a drag's state or the font names the whole app knows.
|
||||
/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run
|
||||
/// plug-ins again.
|
||||
pub(super) const DENIED: &[&str] = &[
|
||||
"addon.cleanup.splitText",
|
||||
"addon.pathtools.movePointsTogether",
|
||||
"addon.pen.nextSegment",
|
||||
"effect.refreshDeferred",
|
||||
"graphicStyle.setOptions",
|
||||
"object.blend.options",
|
||||
"object.liquify",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -110,6 +110,9 @@ pub struct DocState {
|
||||
/// paths the document links to are looked at within them later too, when the Links panel
|
||||
/// asks about a link outside any caller's scope.
|
||||
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// The live-effect plug-in stages deferred while the document is drawn on screen, and the time
|
||||
/// budget of its first view ([`vectorcraft_plugins::deferred`]). Not saved.
|
||||
pub plugin_effects: vectorcraft_plugins::deferred::Deferrals,
|
||||
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
|
||||
empty_artboards: std::cell::RefCell<EmptyArtboards>,
|
||||
/// What [`DocState::any_art_to_fit`] last measured. Not saved.
|
||||
@@ -326,6 +329,7 @@ impl DocState {
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
empty_artboards: Default::default(),
|
||||
plugin_effects: Default::default(),
|
||||
art_to_fit: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! A script's preprocessor lines, read before it runs, as the public guide lists them
|
||||
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
|
||||
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
|
||||
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
|
||||
//! are accepted. The same lines are read in a file `$.evalFile` runs.
|
||||
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
|
||||
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
|
||||
//! a write to a read-only property is a runtime error, and objects that take no new properties
|
||||
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
|
||||
//! `$.evalFile` runs.
|
||||
//!
|
||||
//! VectorCraft's own rules:
|
||||
//!
|
||||
@@ -13,9 +16,15 @@
|
||||
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
|
||||
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
|
||||
//! `JSINCLUDE` folder outside the scope, which is passed over.
|
||||
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
|
||||
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
|
||||
//! comment.
|
||||
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
|
||||
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
|
||||
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
|
||||
//! for the whole run; without one they are off. A write to a property that has only a getter is
|
||||
//! checked as a read-only one, the objects that take no new properties are those that are not
|
||||
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
|
||||
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
|
||||
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
|
||||
//! it has one, and otherwise the caller's.
|
||||
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
|
||||
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
|
||||
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
|
||||
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
|
||||
/// name its `#script` line gave.
|
||||
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
|
||||
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
|
||||
pub(crate) struct Expanded {
|
||||
pub source: String,
|
||||
pub map: SourceMap,
|
||||
pub name: Option<String>,
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
|
||||
/// A file being run: its name for people and the folder its relative paths start from (none for a
|
||||
@@ -130,13 +140,14 @@ pub(crate) struct Files {
|
||||
}
|
||||
|
||||
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
|
||||
/// and whether its last unit is a carriage return.
|
||||
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
|
||||
struct Out {
|
||||
units: Vec<u16>,
|
||||
line: u32,
|
||||
column: u32,
|
||||
after_cr: bool,
|
||||
map: SourceMap,
|
||||
strict: Option<bool>,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
@@ -330,7 +341,7 @@ impl Files {
|
||||
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
|
||||
/// its `#script` line names the run.
|
||||
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
|
||||
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
|
||||
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
|
||||
let mut name = None;
|
||||
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
|
||||
let source = String::from_utf16(&out.units).map_err(|_| Failure {
|
||||
@@ -341,7 +352,7 @@ impl Files {
|
||||
line: 0,
|
||||
column: 0,
|
||||
})?;
|
||||
Ok(Expanded { source, map: out.map, name })
|
||||
Ok(Expanded { source, map: out.map, name, strict: out.strict })
|
||||
}
|
||||
|
||||
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
|
||||
@@ -414,8 +425,8 @@ impl Files {
|
||||
}
|
||||
}
|
||||
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
|
||||
"off" => {}
|
||||
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
|
||||
"off" => out.strict = Some(false),
|
||||
"on" => out.strict = Some(true),
|
||||
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
|
||||
},
|
||||
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
|
||||
|
||||
@@ -499,6 +499,8 @@ struct Host {
|
||||
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
|
||||
/// column that error is at.
|
||||
eval_failure: RefCell<Option<EvalFailure>>,
|
||||
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
|
||||
eval_strict: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
|
||||
@@ -957,7 +959,12 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
|
||||
let eval_file = {
|
||||
let h = host.clone();
|
||||
let end = host.clone();
|
||||
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
|
||||
let strict = host.clone();
|
||||
HostItem::Evaluator {
|
||||
source: function(move |a| eval_file(&h, a)),
|
||||
end: Rc::new(move || end.files.borrow_mut().end_eval()),
|
||||
strict_writes: Rc::new(move || strict.eval_strict.get()),
|
||||
}
|
||||
};
|
||||
vec![
|
||||
("write".into(), writer(false)),
|
||||
@@ -1006,6 +1013,7 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
|
||||
return Err(HostError::Syntax(message));
|
||||
}
|
||||
host.eval_maps.borrow_mut().push(expanded.map);
|
||||
host.eval_strict.set(expanded.strict);
|
||||
Ok(Data::String(expanded.source))
|
||||
}
|
||||
|
||||
@@ -1302,6 +1310,7 @@ impl Session {
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
eval_failure: RefCell::default(),
|
||||
eval_strict: Cell::new(None),
|
||||
});
|
||||
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
|
||||
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
|
||||
@@ -1311,6 +1320,7 @@ impl Session {
|
||||
Err(f) => Err(preprocessor_error(f)),
|
||||
Ok(expanded) => {
|
||||
*host.script_map.borrow_mut() = expanded.map;
|
||||
realm.set_strict_writes(expanded.strict == Some(true));
|
||||
realm
|
||||
.define_host_object("vc", vc_object(&host, args))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
|
||||
|
||||
@@ -1461,6 +1461,26 @@ fn rasterize_replaces_selection_with_image() {
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
/// Rasterize refuses art that would be longer than the renderer draws (32,768 pixels a side), and
|
||||
/// the art stays as it was; at a resolution that fits, the image covers all of it.
|
||||
#[test]
|
||||
fn rasterize_refuses_an_image_longer_than_the_renderer_draws() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 100.0, 5569.0);
|
||||
sel(&mut s, &[a]);
|
||||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||||
let before = (undo_len(&s), node(&s, a));
|
||||
let e = s.execute("object.rasterize", &json!({"ppi": 600, "padding": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert_eq!((undo_len(&s), node(&s, a)), before);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 400, "padding": 0})).unwrap());
|
||||
let n = node(&s, img);
|
||||
let NodeKind::Image(im) = &n.kind else { panic!() };
|
||||
assert!(im.height <= vectorcraft_render::MAX_RASTER_SIDE, "{} pixels", im.height);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(close(b.y0, vb.y0) && b.y1 >= vb.y1, "{b:?} covers {vb:?}");
|
||||
}
|
||||
|
||||
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
|
||||
/// selected art stays as it was.
|
||||
#[test]
|
||||
|
||||
@@ -980,6 +980,174 @@ fn painted_pieces_keep_to_the_effects_budget() {
|
||||
assert!((350..=100_000).contains(&paths), "{paths} paths");
|
||||
}
|
||||
|
||||
/// A clock for a document's deferrals that reads `step` milliseconds later each time it is read;
|
||||
/// the test changes the step as it goes.
|
||||
fn stepping_clock(step: &std::sync::Arc<std::sync::atomic::AtomicU64>) -> vectorcraft_plugins::deferred::Clock {
|
||||
let (now, step) = (std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), step.clone());
|
||||
std::sync::Arc::new(move || Some(now.fetch_add(step.load(std::sync::atomic::Ordering::Relaxed), std::sync::atomic::Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// While a document is drawn on screen, the plug-in effects of its first view share a time budget.
|
||||
/// A stage left out draws its input art, is listed by `effect.deferredStatus`, and stays left out
|
||||
/// on later drawings; Effect › Refresh Deferred Effects runs it again with a fresh budget, and an
|
||||
/// export draws every stage. Another document has a budget of its own.
|
||||
#[test]
|
||||
fn the_first_view_defers_plug_in_effects_past_its_budget() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 30.0, "#ff0000");
|
||||
for (id, x) in [("test.engine.deferred.a", 100.0), ("test.engine.deferred.b", 140.0)] {
|
||||
let out = json!({"objects": [{"path": square_at(x)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest(id, "effect", "{}"), &out));
|
||||
}
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.a"}));
|
||||
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "plugin.test.engine.deferred.b"}));
|
||||
// Each reading is 3 s later: the first run's result comes back after the budget, and the
|
||||
// second run doesn't start.
|
||||
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
|
||||
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
|
||||
s.doc_mut().unwrap().plugin_effects = fx.clone();
|
||||
let img = fx.view(|| drawn(&s));
|
||||
fx.first_view_done();
|
||||
assert_eq!((img.pixel(15, 15), img.pixel(35, 15), img.pixel(110, 50), img.pixel(150, 50)), (RED, RED, WHITE, WHITE), "the input art");
|
||||
assert_eq!(fx.take_report(), Some(2));
|
||||
let status = run(&mut s, "effect.deferredStatus", json!({}));
|
||||
let listed = |id: NodeId, plugin: &str| json!({"objectId": id.0, "effectIndex": 0, "pluginId": plugin, "reason": "budget"});
|
||||
assert_eq!(status, json!({"budgetMs": 2000, "effects": [listed(a, "test.engine.deferred.a"), listed(b, "test.engine.deferred.b")]}));
|
||||
assert_eq!(fx.view(|| drawn(&s)).pixel(110, 50), WHITE, "a later drawing leaves it deferred");
|
||||
|
||||
// Refreshed with a fresh budget, in which the clock stands still.
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({"ids": ["a"]})).is_err());
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({"ids": [999_999]})).is_err(), "not an object of the document");
|
||||
step.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0]}));
|
||||
assert_eq!(refreshed, json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
|
||||
let img = fx.view(|| drawn(&s));
|
||||
assert_eq!((img.pixel(110, 50), img.pixel(150, 50)), (RED, WHITE));
|
||||
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([listed(b, "test.engine.deferred.b")]));
|
||||
|
||||
// An export draws every stage; the view then uses the result.
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(svg.contains(r#"<rect x="140" y="40" width="20" height="20""#), "the square at 140: {svg}");
|
||||
assert_eq!(fx.view(|| drawn(&s)).pixel(150, 50), RED);
|
||||
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
|
||||
|
||||
// Another document: its first view has a budget of its own.
|
||||
let mut other = session();
|
||||
let c = rect(&mut other, 50.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(170.0)}]}).to_string();
|
||||
install_wat(&mut other, &tpl::returning(&tpl::manifest("test.engine.deferred.c", "effect", "{}"), &out));
|
||||
run(&mut other, "effect.apply", json!({"ids": [c.0], "effect": "plugin.test.engine.deferred.c"}));
|
||||
let still = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let fx_c = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&still));
|
||||
other.doc_mut().unwrap().plugin_effects = fx_c.clone();
|
||||
assert_eq!(fx_c.view(|| drawn(&other)).pixel(180, 50), RED);
|
||||
fx_c.first_view_done();
|
||||
assert_eq!(fx_c.take_report(), Some(0));
|
||||
}
|
||||
|
||||
/// Deferred stages are listed where their effects are: after an effect that changes no geometry,
|
||||
/// and on a fill after a Transform effect that reaches its appearance, each of two instances of
|
||||
/// one plug-in at its own place.
|
||||
#[test]
|
||||
fn deferred_stages_are_listed_at_their_effects_places() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 60.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
|
||||
// An effect that paints its pieces: the object is drawn from its reshaped art.
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.at", true), &out));
|
||||
const FX: &str = "plugin.test.engine.deferred.at";
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "stylize.dropShadow"}));
|
||||
for _ in 0..2 {
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": FX}));
|
||||
}
|
||||
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": "distort.transform", "params": {"strokes": true}, "item": 0}));
|
||||
for _ in 0..2 {
|
||||
run(&mut s, "effect.apply", json!({"ids": [b.0], "effect": FX, "item": 0}));
|
||||
}
|
||||
// No run has time to give a result.
|
||||
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
|
||||
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
|
||||
s.doc_mut().unwrap().plugin_effects = fx.clone();
|
||||
fx.view(|| drawn(&s));
|
||||
let places = run(&mut s, "effect.deferredStatus", json!({}))["effects"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| (e["objectId"].as_u64().unwrap(), e["item"].as_u64(), e["effectIndex"].as_u64().unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(places, [(a.0, None, 1), (a.0, None, 2), (b.0, Some(0), 1), (b.0, Some(0), 2)]);
|
||||
}
|
||||
|
||||
/// A refresh that does not draw a stage (its object, or a group holding it, is hidden) leaves it
|
||||
/// deferred, and a stage whose effect has been removed or hidden, or whose object has been removed,
|
||||
/// is no longer listed and does not enable Effect › Refresh Deferred Effects.
|
||||
#[test]
|
||||
fn refresh_keeps_stages_it_did_not_draw_and_drops_removed_ones() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 30.0, "#ff0000");
|
||||
let c = rect(&mut s, 50.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.kept", "effect", "{}"), &out));
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0, b.0, c.0], "effect": "plugin.test.engine.deferred.kept"}));
|
||||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||||
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
|
||||
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
|
||||
s.doc_mut().unwrap().plugin_effects = fx.clone();
|
||||
fx.view(|| drawn(&s));
|
||||
fx.first_view_done();
|
||||
let listed = |s: &mut Session| {
|
||||
run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
|
||||
|
||||
s.execute("object.hide", &json!({"ids": [a.0, g.0]})).unwrap();
|
||||
step.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
let refreshed = run(&mut s, "effect.refreshDeferred", json!({"ids": [a.0, c.0]}));
|
||||
assert_eq!(refreshed, json!({"refreshed": 0, "stillDeferred": 2, "failed": 0}), "a hidden object is not drawn");
|
||||
assert_eq!(listed(&mut s), [a.0, b.0, c.0]);
|
||||
|
||||
run(&mut s, "effect.remove", json!({"ids": [a.0], "index": 0}));
|
||||
assert_eq!(listed(&mut s), [b.0, c.0], "its effect was removed");
|
||||
run(&mut s, "effect.setParams", json!({"ids": [b.0], "index": 0, "visible": false}));
|
||||
assert_eq!(listed(&mut s), [c.0], "its effect was hidden");
|
||||
s.execute("edit.clear", &json!({"ids": [b.0, g.0]})).unwrap();
|
||||
assert_eq!(listed(&mut s), Vec::<u64>::new(), "its object was deleted");
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({})).is_err(), "nothing is deferred");
|
||||
}
|
||||
|
||||
/// A deferred stage in a symbol's art is listed by the object in that art, and a refresh runs it
|
||||
/// where the symbol is placed.
|
||||
#[test]
|
||||
fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.symbol", "effect", "{}"), &out));
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.deferred.symbol"}));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
let instance = run(&mut s, "symbol.new", json!({"name": "Deferred"}))["id"].as_u64().unwrap();
|
||||
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
|
||||
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
|
||||
s.doc_mut().unwrap().plugin_effects = fx.clone();
|
||||
fx.view(|| drawn(&s));
|
||||
fx.first_view_done();
|
||||
assert_eq!(fx.take_report(), Some(1));
|
||||
let effects = run(&mut s, "effect.deferredStatus", json!({}))["effects"].clone();
|
||||
let object = effects[0]["objectId"].as_u64().unwrap();
|
||||
assert_eq!(effects.as_array().map(Vec::len), Some(1), "{effects}");
|
||||
assert!(object != instance && s.doc().unwrap().doc.node(NodeId(object)).is_none(), "an object of the symbol's art: {effects}");
|
||||
|
||||
step.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
|
||||
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
|
||||
}
|
||||
|
||||
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
|
||||
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
|
||||
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
|
||||
|
||||
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
|
||||
("#target\nvc.log(1);", "#target needs a value"),
|
||||
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
|
||||
("#engine \"main\"", "unsupported script directive: #engine"),
|
||||
("#strict on", "unsupported strict checking: #strict on"),
|
||||
("#strict maybe", "#strict takes on or off"),
|
||||
("#frobnicate 1", "unknown script directive: #frobnicate"),
|
||||
("#include \"x.jsxinc", "unterminated quote in #include"),
|
||||
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The script's `ro`, with a read-only `x`.
|
||||
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
|
||||
|
||||
#[test]
|
||||
fn script_strict_on_is_refused() {
|
||||
fn strict_on_runs_and_read_only_write_throws() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
|
||||
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
|
||||
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
|
||||
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
|
||||
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
|
||||
let report = s.script_report().unwrap();
|
||||
assert_eq!(report["kind"], "thrown", "{head}");
|
||||
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
|
||||
let line = head.lines().count() as u64 + 4;
|
||||
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
|
||||
}
|
||||
// The error can be caught, and every kind of assignment is checked.
|
||||
let source = format!(
|
||||
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
|
||||
);
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
|
||||
// Without the line, or with `#strict off` last, the write is ignored as before.
|
||||
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
|
||||
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
|
||||
assert_eq!(r["log"], json!([1]), "{head}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
|
||||
let mut s = Session::new();
|
||||
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
|
||||
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
|
||||
t(function () { g.v = 2; });\n\
|
||||
t(function () { Object.preventExtensions({}).n = 1; });\n\
|
||||
t(function () { Object.seal({a: 1}).n = 1; });\n\
|
||||
t(function () { Object.freeze({a: 1}).a = 2; });\n\
|
||||
t(function () { vc.extra = 1; });\n\
|
||||
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
|
||||
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(
|
||||
r["log"],
|
||||
json!([
|
||||
[
|
||||
"TypeError: cannot assign to property 'v': it has only a getter",
|
||||
"TypeError: cannot assign to property 'n': the object is not extensible",
|
||||
"TypeError: cannot assign to property 'n': the object is not extensible",
|
||||
"TypeError: cannot assign to property 'a': it is read-only",
|
||||
"TypeError: cannot assign to property 'extra': the object is not extensible",
|
||||
"wrote",
|
||||
"wrote",
|
||||
"wrote"
|
||||
],
|
||||
1,
|
||||
3
|
||||
])
|
||||
);
|
||||
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
|
||||
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_does_not_enable_es5_early_errors() {
|
||||
let mut s = Session::new();
|
||||
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
|
||||
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
|
||||
let r = s.execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
|
||||
// 'use strict' still makes the code strict mode code.
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
|
||||
assert!(e.contains("ReferenceError"), "{e}");
|
||||
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
|
||||
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_on_in_include_controls_expanded_run() {
|
||||
let base = folder("strict");
|
||||
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
|
||||
write(&base.join("main/off.jsxinc"), "//@strict off\n");
|
||||
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
|
||||
// An included file's line sets the checks for the script's own code too.
|
||||
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert_eq!(report["line"], 4, "{report}");
|
||||
assert!(report.get("file").is_none(), "{report}");
|
||||
// A write in an included file is reported at its own line.
|
||||
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
|
||||
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
|
||||
// The last line of the expanded run wins, wherever it is.
|
||||
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
|
||||
assert_eq!(r["log"], json!([1]));
|
||||
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_mode_in_eval_file_is_scoped() {
|
||||
let base = folder("strict-eval");
|
||||
write(
|
||||
&base.join("main/on.jsx"),
|
||||
&format!(
|
||||
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
|
||||
),
|
||||
);
|
||||
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
|
||||
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
|
||||
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
|
||||
let r = run(
|
||||
&base,
|
||||
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
|
||||
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
|
||||
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
|
||||
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
|
||||
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
|
||||
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
|
||||
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
|
||||
assert_eq!(report["line"], 3, "{report}");
|
||||
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -708,6 +708,14 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The artboard a guide made from a ruler belongs to: with the Artboard tool, its active
|
||||
/// artboard (an artboard guide); with any other tool, none (a canvas guide).
|
||||
pub fn ruler_guide_artboard(&self) -> Option<usize> {
|
||||
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
|
||||
active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards)
|
||||
}
|
||||
|
||||
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
|
||||
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
|
||||
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
|
||||
@@ -722,11 +730,9 @@ impl Session {
|
||||
let g = match self.ruler_guide.take() {
|
||||
Some(g) => Some(g),
|
||||
None => {
|
||||
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let artboard_tool = self.tool.id() == "artboard";
|
||||
let swap = self.prefs.cmd_ruler_drag_artboard_guides && ev.mods.cmd;
|
||||
let active = artboard_tool.then(|| self.tool.options()["active"].as_u64()).flatten();
|
||||
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
|
||||
let artboard = self.ruler_guide_artboard();
|
||||
let view_board = view.artboard;
|
||||
self.with_tool_cx(view, |_, cx| {
|
||||
Some(match (swap, artboard_tool) {
|
||||
|
||||
@@ -9,10 +9,11 @@ use vectorcraft_engine::{Session, command_specs};
|
||||
use vectorcraft_testkit::catch_quiet;
|
||||
use vectorcraft_testkit::fixtures::Fixture;
|
||||
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
|
||||
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
|
||||
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value};
|
||||
|
||||
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
|
||||
/// few need their paths redirected into a temp dir (handled by `junk_params`).
|
||||
/// few need their file and folder params redirected into a temp dir (`safe_value`, which
|
||||
/// `junk_params` applies).
|
||||
fn safe_path() -> String {
|
||||
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
|
||||
}
|
||||
@@ -230,6 +231,54 @@ fn tool_edit_state(s: &Session) -> ToolEditState {
|
||||
(docs, s.active_index(), s.paint.clone())
|
||||
}
|
||||
|
||||
/// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and
|
||||
/// off, with a string under a file or folder key replaced by a location inside `path`'s folder.
|
||||
fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec<Value> {
|
||||
let mut cases = junk_params(c.params, path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for k in param_keys(c.params) {
|
||||
for v in [json!(true), json!(false), json!(1), json!("on")] {
|
||||
cases.push(json!({ k.as_str(): safe_value(&k, &v, path) }));
|
||||
}
|
||||
}
|
||||
// Find Objects selects only with a filter, which one key at a time does not give it.
|
||||
if c.id == "document.find" {
|
||||
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
/// Every string the sweeps send under a key that names a file or folder to write is inside the
|
||||
/// sweep's temp folder, so no run writes into the working directory.
|
||||
#[test]
|
||||
fn sweep_cases_name_files_only_inside_the_temp_folder() {
|
||||
let path = safe_path();
|
||||
let dir = vectorcraft_testkit::temp_dir("sweep");
|
||||
let keys = ["path", "diffPath", "folder", "destinationFolder"];
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut outside = std::collections::BTreeSet::new();
|
||||
for c in command_specs() {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
cases.extend(tool_edit_cases(c, &path));
|
||||
for p in &cases {
|
||||
let Some(o) = p.as_object() else { continue };
|
||||
for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) {
|
||||
seen.insert(k.clone());
|
||||
if let Some(s) = v.as_str()
|
||||
&& !std::path::Path::new(s).starts_with(&dir)
|
||||
{
|
||||
outside.insert(format!("{} {{\"{k}\": {v}}}", c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}");
|
||||
let outside: Vec<String> = outside.into_iter().collect();
|
||||
assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n"));
|
||||
}
|
||||
|
||||
/// Every command that runs at once while the active tool computes an edit on another thread (a
|
||||
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
|
||||
/// documents' revisions and selections, the active document and the current paint as they were,
|
||||
@@ -241,19 +290,7 @@ fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
|
||||
let mut failures = vec![];
|
||||
for fx in [Fixture::Single, Fixture::Multi] {
|
||||
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for k in param_keys(c.params) {
|
||||
for v in [json!(true), json!(false), json!(1), json!("on")] {
|
||||
cases.push(json!({ k.as_str(): v }));
|
||||
}
|
||||
}
|
||||
// Find Objects selects only with a filter, which one key at a time does not give it.
|
||||
if c.id == "document.find" {
|
||||
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
|
||||
}
|
||||
let cases = tool_edit_cases(c, &path);
|
||||
for p in &cases {
|
||||
let mut s = fx.session();
|
||||
let before = tool_edit_state(&s);
|
||||
|
||||
@@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
|
||||
};
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() });
|
||||
let code = match compile_constructed_function(&program, &source) {
|
||||
Ok(Some(code)) => code,
|
||||
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
|
||||
|
||||
@@ -173,6 +173,9 @@ pub(crate) struct Source {
|
||||
pub(crate) text: Rc<[u16]>,
|
||||
/// Which source this is, as [`crate::Realm::error_source`] gives it.
|
||||
pub(crate) tag: u32,
|
||||
/// Writes to read-only and getter-only properties, and new properties on objects that are
|
||||
/// not extensible, throw in this source's code even where it is not strict mode code.
|
||||
pub(crate) strict_writes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -184,7 +184,7 @@ pub(crate) enum Func {
|
||||
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
|
||||
Host { f: Rc<HostFn>, name: Rc<str> },
|
||||
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
|
||||
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
|
||||
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
|
||||
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
|
||||
}
|
||||
|
||||
|
||||
+18
-6
@@ -111,10 +111,12 @@ pub enum HostItem {
|
||||
/// the call gives that code's completion value, and its declarations become globals. A result
|
||||
/// that is not a string is the call's result. `end` is called when the source has run,
|
||||
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
|
||||
/// from 1.
|
||||
/// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`]
|
||||
/// for that source alone; with `None` it takes the calling code's.
|
||||
Evaluator {
|
||||
source: HostFunction,
|
||||
end: Rc<dyn Fn()>,
|
||||
strict_writes: Rc<dyn Fn() -> Option<bool>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -343,17 +345,26 @@ impl Interp {
|
||||
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
|
||||
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
|
||||
/// value. A result that is not a string is given to the script as it is. `end` is called once
|
||||
/// the source has run, normally or not.
|
||||
pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
|
||||
/// the source has run, normally or not. `strict_writes` gives the source's
|
||||
/// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's.
|
||||
pub(crate) fn call_evaluator(
|
||||
&mut self,
|
||||
f: &crate::heap::DataFn,
|
||||
end: &Rc<dyn Fn()>,
|
||||
strict_writes: &Rc<dyn Fn() -> Option<bool>>,
|
||||
name: &Rc<str>,
|
||||
args: &[Value],
|
||||
) -> JsResult<Value> {
|
||||
let data = self.read_args(name, args, &ArgRead::Data)?;
|
||||
let source = match f(data) {
|
||||
Ok(Data::String(source)) => source,
|
||||
Ok(d) => return self.write_data(d, 0, false),
|
||||
Err(e) => return Err(self.host_error(e)?),
|
||||
};
|
||||
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
|
||||
self.evaluated = self.evaluated.saturating_add(1);
|
||||
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
|
||||
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
|
||||
let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval));
|
||||
self.source_tag = tag;
|
||||
let r = match code {
|
||||
Ok(code) => self.run_global(code, true),
|
||||
@@ -469,9 +480,10 @@ impl Interp {
|
||||
HostItem::Converted(list, f) => {
|
||||
Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE }
|
||||
}
|
||||
HostItem::Evaluator { source, end } => {
|
||||
HostItem::Evaluator { source, end, strict_writes } => {
|
||||
let proto = self.intrinsics.function_proto;
|
||||
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
|
||||
let func = Func::Eval { f: source, end, strict_writes, name: full.into() };
|
||||
let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto)));
|
||||
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
|
||||
}
|
||||
};
|
||||
|
||||
+35
-6
@@ -197,6 +197,8 @@ pub(crate) struct Interp {
|
||||
pub(crate) evaluated: u32,
|
||||
/// The tag of the source the last run's error was raised in.
|
||||
pub(crate) error_tag: u32,
|
||||
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
|
||||
pub(crate) strict_writes: bool,
|
||||
}
|
||||
|
||||
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
|
||||
@@ -394,6 +396,7 @@ impl Interp {
|
||||
source_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
evaluated: 0,
|
||||
error_tag: crate::realm::UNKNOWN_SOURCE,
|
||||
strict_writes: false,
|
||||
};
|
||||
crate::builtins::install(&mut interp);
|
||||
interp.fuel = limits.fuel;
|
||||
@@ -700,6 +703,18 @@ impl Interp {
|
||||
self.frames.last().is_some_and(|f| f.code.strict)
|
||||
}
|
||||
|
||||
/// Does a property write the running code makes throw when it cannot be made? It does in
|
||||
/// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`].
|
||||
fn strict_writes(&self) -> bool {
|
||||
self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes)
|
||||
}
|
||||
|
||||
/// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's,
|
||||
/// or the realm's between runs.
|
||||
pub(crate) fn writes_mode(&self) -> bool {
|
||||
self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes)
|
||||
}
|
||||
|
||||
fn exec(&mut self, op: Op) -> JsResult<()> {
|
||||
match op {
|
||||
Op::Undefined => self.push(Value::Undefined),
|
||||
@@ -918,11 +933,14 @@ impl Interp {
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict),
|
||||
Resolved::Obj { object, .. } => {
|
||||
let throw = self.strict_writes();
|
||||
self.put(object, name.clone(), v, throw)
|
||||
}
|
||||
Resolved::Unresolved if strict => Err(self.not_defined(name)),
|
||||
Resolved::Unresolved => {
|
||||
let global = self.global;
|
||||
self.put(global, name.clone(), v, false)
|
||||
let (global, throw) = (self.global, self.strict_writes());
|
||||
self.put(global, name.clone(), v, throw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1471,10 @@ impl Interp {
|
||||
fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> {
|
||||
let strict = self.strict();
|
||||
match base {
|
||||
Value::Object(o) => self.put(*o, k, v, strict),
|
||||
Value::Object(o) => {
|
||||
let throw = self.strict_writes();
|
||||
self.put(*o, k, v, throw)
|
||||
}
|
||||
Value::Undefined | Value::Null => {
|
||||
let message = format!(
|
||||
"cannot set property '{}' of {}",
|
||||
@@ -1869,7 +1890,9 @@ impl Interp {
|
||||
// keeps itself reachable.
|
||||
return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?));
|
||||
}
|
||||
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
|
||||
Func::Eval { f, end, strict_writes, name } => {
|
||||
return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?));
|
||||
}
|
||||
Func::Host { f: host, .. } => {
|
||||
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
|
||||
return match host(&js_args) {
|
||||
@@ -2138,11 +2161,17 @@ impl Interp {
|
||||
/// Parses and compiles source for eval code or a script, counting it against the source
|
||||
/// limit.
|
||||
pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
|
||||
let writes = self.writes_mode();
|
||||
self.compile_source_writes(units, strict, writes, kind)
|
||||
}
|
||||
|
||||
/// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`.
|
||||
pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
|
||||
self.source_units = self.source_units.saturating_add(units.len());
|
||||
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
|
||||
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
|
||||
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
|
||||
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes });
|
||||
compile_program(&program, &source, kind).map_err(|e| match e {
|
||||
CompileFail::Syntax(s) => CompileError::Syntax(s),
|
||||
CompileFail::Internal(m) => CompileError::Internal(m),
|
||||
|
||||
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
|
||||
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_writes_are_separate_from_es5_strict() {
|
||||
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
|
||||
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
realm.run(setup).unwrap();
|
||||
for (src, why) in [
|
||||
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
|
||||
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
|
||||
("ro.x++", "cannot assign to property 'x': it is read-only"),
|
||||
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
|
||||
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
|
||||
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
|
||||
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
|
||||
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
|
||||
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
|
||||
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
|
||||
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
|
||||
] {
|
||||
let e = realm.run(src).unwrap_err();
|
||||
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
|
||||
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
|
||||
}
|
||||
// Writes that can be made, and the rest of strict mode, are as before.
|
||||
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
|
||||
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
|
||||
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
|
||||
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
|
||||
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
|
||||
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
|
||||
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
|
||||
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
|
||||
// 'use strict' keeps all of ES5 strict mode.
|
||||
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
|
||||
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
|
||||
// Off, the same writes are ignored, as in sloppy mode code.
|
||||
realm.set_strict_writes(false);
|
||||
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
|
||||
// A function compiled with the writes on keeps them when called from code without.
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
|
||||
realm.set_strict_writes(false);
|
||||
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
|
||||
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
|
||||
// An undeclared name added to a global object that is not extensible.
|
||||
let mut realm = Realm::new();
|
||||
realm.set_strict_writes(true);
|
||||
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
|
||||
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
|
||||
let mut realm = Realm::new();
|
||||
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
|
||||
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
|
||||
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
|
||||
// ---- the verifier ----
|
||||
|
||||
fn code(ops: Vec<Op>) -> Code {
|
||||
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
|
||||
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
|
||||
Code {
|
||||
positions: vec![0; ops.len()],
|
||||
ops,
|
||||
|
||||
@@ -174,6 +174,14 @@ impl Realm {
|
||||
self.unbroken_fuel = fuel;
|
||||
}
|
||||
|
||||
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
|
||||
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
|
||||
/// an object that is not extensible, as strict mode code does, without the rest of strict
|
||||
/// mode. Off by default.
|
||||
pub fn set_strict_writes(&mut self, on: bool) {
|
||||
self.interp.strict_writes = on;
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> &Limits {
|
||||
&self.interp.limits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
//! Live-effect plug-ins while a document opens: the plug-in effects drawn for its first view share
|
||||
//! a time budget of [`OPENING_BUDGET_MS`], and a stage that is not run for want of time, or that
|
||||
//! gives no result, is deferred: it draws its input art and is listed with the reason
|
||||
//! ([`Deferrals::list`]) until it runs again.
|
||||
//!
|
||||
//! One [`Deferrals`] belongs to each open document. Drawing that document on screen runs inside
|
||||
//! [`Deferrals::view`], which applies its budget and its deferred stages to the plug-in effects
|
||||
//! evaluated on that thread ([`crate::effect`]):
|
||||
//!
|
||||
//! - until [`Deferrals::first_view_done`], every run takes the lesser of its per-call limit and
|
||||
//! what is left of the budget, no run starts once the budget is spent, and a result that comes
|
||||
//! back after the budget ran out is not used;
|
||||
//! - afterwards a deferred stage is not run again while its input and parameters stay the same; a
|
||||
//! stage whose input or parameters changed runs under the per-call limit;
|
||||
//! - [`Deferrals::refresh`] runs deferred stages again with a fresh budget.
|
||||
//!
|
||||
//! Work outside a view (exports, commands, drawing without a screen) runs every stage under the
|
||||
//! per-call limit. When such a run gives a result for a stage some document has deferred,
|
||||
//! [`revision`] changes, so that drawings made with the deferred stage are drawn again.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The time the plug-in effects drawn for a document's first view share, in milliseconds.
|
||||
pub const OPENING_BUDGET_MS: u64 = 2_000;
|
||||
|
||||
/// Why a stage is deferred.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Reason {
|
||||
/// It was not run, or its result came too late, because the budget was spent.
|
||||
Budget,
|
||||
/// It ran to its per-call time limit.
|
||||
Timeout,
|
||||
/// Its plug-in isn't installed as a live effect.
|
||||
Missing,
|
||||
/// It trapped, panicked, returned art that was refused, or its parameters were refused.
|
||||
Failed,
|
||||
/// Safe Mode is on: no plug-in runs.
|
||||
SafeMode,
|
||||
}
|
||||
|
||||
impl Reason {
|
||||
/// The reason as `effect.deferredStatus` names it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Reason::Budget => "budget",
|
||||
Reason::Timeout => "timeout",
|
||||
Reason::Missing => "missing",
|
||||
Reason::Failed => "failed",
|
||||
Reason::SafeMode => "safeMode",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a plug-in effect sits: the object, the index of the effect in its list, and the fill or
|
||||
/// stroke whose list that is (`None`: the object's own effects).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Stage {
|
||||
pub object: u64,
|
||||
pub effect: usize,
|
||||
pub item: Option<usize>,
|
||||
}
|
||||
|
||||
/// A deferred stage.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Deferred {
|
||||
pub stage: Stage,
|
||||
/// The plug-in's id.
|
||||
pub plugin: String,
|
||||
pub reason: Reason,
|
||||
/// The input and parameters the stage was deferred with (the effect cache's key), 0 when the
|
||||
/// plug-in was not looked at (Safe Mode, a missing plug-in).
|
||||
pub(crate) key: u64,
|
||||
}
|
||||
|
||||
/// What [`Deferrals::refresh`] did with the stages it ran again.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Refreshed {
|
||||
/// Stages that gave a result.
|
||||
pub refreshed: usize,
|
||||
/// Stages left deferred for want of time, or with input the refresh did not run.
|
||||
pub still_deferred: usize,
|
||||
/// Stages that ran out of their per-call time, failed or had no plug-in again.
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Milliseconds on a monotonic clock, `None` where there is none (the web build).
|
||||
pub type Clock = Arc<dyn Fn() -> Option<u64> + Send + Sync>;
|
||||
|
||||
/// Milliseconds since the first reading in this process (native builds); `None` on the web build,
|
||||
/// whose `std` has no monotonic clock.
|
||||
fn monotonic() -> Option<u64> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let start = START.get_or_init(std::time::Instant::now);
|
||||
Some(u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Phase {
|
||||
/// Runs share a budget that started at `started` (`None`: no run yet). `first`: the first
|
||||
/// view, which reports what it deferred when it ends.
|
||||
Budgeted { started: Option<u64>, first: bool },
|
||||
/// Deferred stages are left as they are while their input stays the same.
|
||||
Open,
|
||||
}
|
||||
|
||||
struct State {
|
||||
phase: Phase,
|
||||
deferred: Vec<Deferred>,
|
||||
/// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it.
|
||||
report: Option<usize>,
|
||||
/// The refresh running, if one is ([`Deferrals::refresh`]).
|
||||
refresh: Option<Refresh>,
|
||||
clock: Clock,
|
||||
}
|
||||
|
||||
/// A [`Deferrals::refresh`] while it runs. It applies only to the thread that runs it: drawings
|
||||
/// of the document on other threads go on as if no refresh ran.
|
||||
struct Refresh {
|
||||
thread: std::thread::ThreadId,
|
||||
/// When its budget started (`None`: no run yet).
|
||||
started: Option<u64>,
|
||||
/// The objects whose stages run again; the others' stay deferred.
|
||||
ids: Vec<u64>,
|
||||
/// The cache keys whose cached failures have been run again.
|
||||
retried: Vec<u64>,
|
||||
/// The stages of `ids` that were drawn.
|
||||
visited: Vec<Stage>,
|
||||
/// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again.
|
||||
touched: Vec<(Stage, u64)>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// The refresh running on this thread.
|
||||
fn refresh_here(&mut self) -> Option<&mut Refresh> {
|
||||
let here = std::thread::current().id();
|
||||
self.refresh.as_mut().filter(|r| r.thread == here)
|
||||
}
|
||||
|
||||
/// The phase that applies on this thread: a refresh running here has a budget of its own.
|
||||
fn phase_here(&mut self) -> Phase {
|
||||
match self.refresh_here() {
|
||||
Some(r) => Phase::Budgeted { started: r.started, first: false },
|
||||
None => self.phase,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a refresh running here, and does it run the stages of `object`?
|
||||
fn refreshes(&mut self, object: u64) -> Option<bool> {
|
||||
self.refresh_here().map(|r| r.ids.contains(&object))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
fn drop(&mut self) {
|
||||
for d in &self.deferred {
|
||||
unmark(d.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The deferred stages and the opening budget of one open document. Clones share them.
|
||||
#[derive(Clone)]
|
||||
pub struct Deferrals(Arc<Mutex<State>>);
|
||||
|
||||
impl Default for Deferrals {
|
||||
fn default() -> Self {
|
||||
Self::with_clock(Arc::new(monotonic))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Deferrals {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Deferrals").field("deferred", &self.list()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a budgeted run may take.
|
||||
pub(crate) enum Allowance {
|
||||
/// No budget applies: the per-call limit.
|
||||
Unlimited,
|
||||
/// At most this many milliseconds, which end the budget at `until`.
|
||||
Until { ms: u64, until: u64 },
|
||||
/// The budget is spent: the stage is deferred without running.
|
||||
Spent,
|
||||
}
|
||||
|
||||
impl Deferrals {
|
||||
/// A document's deferrals before its first view, timed by the monotonic clock.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// [`Deferrals::new`] timed by `clock` (tests give a clock of their own).
|
||||
pub fn with_clock(clock: Clock) -> Self {
|
||||
Self(Arc::new(Mutex::new(State {
|
||||
phase: Phase::Budgeted { started: None, first: true },
|
||||
deferred: Vec::new(),
|
||||
report: None,
|
||||
refresh: None,
|
||||
clock,
|
||||
})))
|
||||
}
|
||||
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, State> {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The deferred stages, by object and effect, each once (a stage drawn with several inputs,
|
||||
/// such as the paths of a group, is listed with the first one's reason).
|
||||
pub fn list(&self) -> Vec<Deferred> {
|
||||
let mut out = self.state().deferred.clone();
|
||||
out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect));
|
||||
out.dedup_by_key(|d| d.stage);
|
||||
out
|
||||
}
|
||||
|
||||
/// Is the first view still being drawn ([`Deferrals::first_view_done`] not called yet)?
|
||||
pub fn opening(&self) -> bool {
|
||||
matches!(self.state().phase, Phase::Budgeted { first: true, .. })
|
||||
}
|
||||
|
||||
/// Runs `f` with these deferrals applied to the plug-in effects evaluated on this thread.
|
||||
pub fn view<T>(&self, f: impl FnOnce() -> T) -> T {
|
||||
let previous = VIEW.with(|v| v.borrow_mut().replace(self.clone()));
|
||||
let _ = PASSES.try_with(|p| p.borrow_mut().push(Pass::default()));
|
||||
let _restore = Restore { previous, current: self.clone() };
|
||||
f()
|
||||
}
|
||||
|
||||
/// One drawing inside [`Deferrals::view`] has ended: a stage that ran after the first view,
|
||||
/// whether it gave a result or was deferred again, keeps only the records of the inputs that
|
||||
/// drawing left deferred. The records of its other inputs were made with input it no longer
|
||||
/// has.
|
||||
fn settle(&self, pass: Pass) {
|
||||
if pass.ran.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.state().deferred.retain(|d| {
|
||||
let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key));
|
||||
if !keep {
|
||||
unmark(d.key);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// The first view has been drawn: later drawings leave deferred stages deferred, and
|
||||
/// [`Deferrals::take_report`] gives how many there are. Does nothing after the first call.
|
||||
pub fn first_view_done(&self) {
|
||||
let mut s = self.state();
|
||||
if let Phase::Budgeted { first: true, .. } = s.phase {
|
||||
s.phase = Phase::Open;
|
||||
s.report = Some(s.stages().len());
|
||||
}
|
||||
}
|
||||
|
||||
/// How many stages the first view left deferred, once, after it was drawn.
|
||||
pub fn take_report(&self) -> Option<usize> {
|
||||
self.state().report.take()
|
||||
}
|
||||
|
||||
/// Runs the deferred stages of the objects in `objects` (all of them when `None`) again with a
|
||||
/// fresh budget: `run` is given those objects, draws them inside [`Deferrals::view`] on this
|
||||
/// thread, and returns the objects it drew whole, every part of them reached. A stage the
|
||||
/// drawing does not reach (that of a hidden object, say) stays deferred as it was, and so does
|
||||
/// a stage input it does not reach, unless the object was drawn whole: the input is then one
|
||||
/// the stage no longer has. The refresh and its budget apply only to this thread.
|
||||
pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec<u64>) -> Refreshed {
|
||||
let (targets, ids) = {
|
||||
let mut s = self.state();
|
||||
let mut targets: Vec<Stage> = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect();
|
||||
targets.sort_by_key(|t| (t.object, t.item, t.effect));
|
||||
targets.dedup();
|
||||
let mut ids: Vec<u64> = targets.iter().map(|t| t.object).collect();
|
||||
ids.dedup();
|
||||
let thread = std::thread::current().id();
|
||||
let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new());
|
||||
s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched });
|
||||
(targets, ids)
|
||||
};
|
||||
// The refresh ends when `run` returns, also when it unwinds.
|
||||
struct Reopen<'a>(&'a Deferrals);
|
||||
impl Drop for Reopen<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.state().refresh = None;
|
||||
}
|
||||
}
|
||||
let reopen = Reopen(self);
|
||||
let whole = run(&ids);
|
||||
let mut s = self.state();
|
||||
let touched = match s.refresh.take() {
|
||||
Some(r) => {
|
||||
s.deferred.retain(|d| {
|
||||
let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key));
|
||||
if gone {
|
||||
unmark(d.key);
|
||||
}
|
||||
!gone
|
||||
});
|
||||
r.touched
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
drop(s);
|
||||
drop(reopen);
|
||||
// A stage fails again when an input it ran with this time is deferred for a reason other
|
||||
// than time; one with records left that this refresh did not run is still deferred.
|
||||
let s = self.state();
|
||||
let mut out = Refreshed::default();
|
||||
for t in &targets {
|
||||
let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable();
|
||||
if left.peek().is_none() {
|
||||
out.refreshed += 1;
|
||||
} else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) {
|
||||
out.failed += 1;
|
||||
} else {
|
||||
out.still_deferred += 1;
|
||||
}
|
||||
}
|
||||
drop(s);
|
||||
if out.refreshed > 0 {
|
||||
REVISION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Records `stage` with cache key `key` (of plug-in `plugin`) as deferred for `reason`, in
|
||||
/// place of what was recorded for that stage and key. After the first view, when the drawing
|
||||
/// ends, the stage's records of inputs that drawing did not leave deferred are dropped
|
||||
/// ([`Deferrals::settle`]).
|
||||
pub(crate) fn defer(&self, stage: Stage, plugin: &str, reason: Reason, key: u64) {
|
||||
seen(stage, key);
|
||||
let mut s = self.state();
|
||||
s.remove(stage, key);
|
||||
mark(key);
|
||||
s.deferred.push(Deferred { stage, plugin: plugin.to_string(), reason, key });
|
||||
s.ran_here(stage, key);
|
||||
}
|
||||
|
||||
/// `stage` gave a result for cache key `key`, from a run or from the cache: it is no longer
|
||||
/// deferred with that input. After the first view, when the drawing ends, the stage's records
|
||||
/// of inputs that drawing did not leave deferred are dropped as well ([`Deferrals::settle`]).
|
||||
pub(crate) fn ran(&self, stage: Stage, key: u64) {
|
||||
let mut s = self.state();
|
||||
s.remove(stage, key);
|
||||
s.ran_here(stage, key);
|
||||
}
|
||||
|
||||
/// Should `stage`, with cache key `key`, be left deferred without running? After the first
|
||||
/// view, a stage deferred with the same input and parameters is, and so is one of an object a
|
||||
/// refresh does not run.
|
||||
pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool {
|
||||
let mut s = self.state();
|
||||
let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false);
|
||||
let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key);
|
||||
drop(s);
|
||||
if held {
|
||||
seen(stage, key);
|
||||
}
|
||||
held
|
||||
}
|
||||
|
||||
/// `stage` is being drawn: a refresh running here that runs its object's stages notes that it
|
||||
/// reached the stage.
|
||||
pub(crate) fn visit(&self, stage: Stage) {
|
||||
let mut s = self.state();
|
||||
if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) {
|
||||
r.visited.push(stage);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is
|
||||
/// false, such as those of effects or objects removed from the document.
|
||||
pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) {
|
||||
self.state().deferred.retain(|d| {
|
||||
let k = keep(d.stage, &d.plugin);
|
||||
if !k {
|
||||
unmark(d.key);
|
||||
}
|
||||
k
|
||||
});
|
||||
}
|
||||
|
||||
/// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key
|
||||
/// while a refresh running here runs the stages of `stage`'s object.
|
||||
pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool {
|
||||
let mut s = self.state();
|
||||
let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false };
|
||||
r.retried.push(key);
|
||||
true
|
||||
}
|
||||
|
||||
/// What a run starting now may take of the budget: the budget starts with the first run.
|
||||
/// Without a clock, the first view runs nothing and a refresh runs every stage under its
|
||||
/// per-call limit.
|
||||
pub(crate) fn allowance(&self) -> Allowance {
|
||||
let mut s = self.state();
|
||||
let Phase::Budgeted { started, first } = s.phase_here() else { return Allowance::Unlimited };
|
||||
let Some(now) = (s.clock)() else { return if first { Allowance::Spent } else { Allowance::Unlimited } };
|
||||
let start = started.unwrap_or(now);
|
||||
match s.refresh_here() {
|
||||
Some(r) => r.started = Some(start),
|
||||
None => s.phase = Phase::Budgeted { started: Some(start), first },
|
||||
}
|
||||
let until = start.saturating_add(OPENING_BUDGET_MS);
|
||||
match until.checked_sub(now).filter(|ms| *ms > 0) {
|
||||
Some(ms) => Allowance::Until { ms, until },
|
||||
None => Allowance::Spent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has the clock passed `until` (a result that came back now is too late)? Without a clock,
|
||||
/// yes.
|
||||
pub(crate) fn passed(&self, until: u64) -> bool {
|
||||
let clock = self.state().clock.clone();
|
||||
clock().is_none_or(|now| now > until)
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Removes what is recorded for `stage` with cache key `key`. A record without a key (the
|
||||
/// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0
|
||||
/// removes every record of the stage.
|
||||
fn remove(&mut self, stage: Stage, key: u64) {
|
||||
self.deferred.retain(|d| {
|
||||
let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0);
|
||||
if !keep {
|
||||
unmark(d.key);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this
|
||||
/// thread: a refresh running here notes the input ([`Deferrals::refresh`]), and after the first
|
||||
/// view [`Deferrals::settle`] looks at the stage when that drawing ends.
|
||||
fn ran_here(&mut self, stage: Stage, key: u64) {
|
||||
if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) {
|
||||
r.touched.push((stage, key));
|
||||
}
|
||||
if self.phase_here() == Phase::Open {
|
||||
let _ = PASSES.try_with(|p| {
|
||||
if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) {
|
||||
pass.ran.push(stage);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The deferred stages, each once.
|
||||
fn stages(&self) -> Vec<Stage> {
|
||||
let mut out: Vec<Stage> = self.deferred.iter().map(|d| d.stage).collect();
|
||||
out.sort_by_key(|t| (t.object, t.item, t.effect));
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static VIEW: RefCell<Option<Deferrals>> = const { RefCell::new(None) };
|
||||
/// One entry for each [`Deferrals::view`] running on this thread, innermost last.
|
||||
static PASSES: RefCell<Vec<Pass>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// What one drawing inside a view did: the stages that ran after the first view (with a result or
|
||||
/// deferred again), and the stage inputs it left deferred.
|
||||
#[derive(Default)]
|
||||
struct Pass {
|
||||
ran: Vec<Stage>,
|
||||
seen: Vec<(Stage, u64)>,
|
||||
}
|
||||
|
||||
/// The drawing on this thread left `stage` deferred with cache key `key`.
|
||||
fn seen(stage: Stage, key: u64) {
|
||||
let _ = PASSES.try_with(|p| {
|
||||
if let Some(pass) = p.borrow_mut().last_mut() {
|
||||
pass.seen.push((stage, key));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends the drawing and puts the previous view back, also when the work inside unwinds.
|
||||
struct Restore {
|
||||
previous: Option<Deferrals>,
|
||||
current: Deferrals,
|
||||
}
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pass) = PASSES.try_with(|p| p.borrow_mut().pop()).ok().flatten() {
|
||||
self.current.settle(pass);
|
||||
}
|
||||
let previous = self.previous.take();
|
||||
let _ = VIEW.try_with(|v| *v.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// The deferrals of the view being drawn on this thread ([`Deferrals::view`]).
|
||||
pub fn current() -> Option<Deferrals> {
|
||||
VIEW.try_with(|v| v.borrow().clone()).ok().flatten()
|
||||
}
|
||||
|
||||
/// Changes when deferred stages give results: [`Deferrals::refresh`] ran some, or a run outside a
|
||||
/// view gave a result for a stage some document has deferred.
|
||||
pub fn revision() -> u64 {
|
||||
REVISION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The cache keys some document's deferred stages were deferred with, and how many stages hold each.
|
||||
static MARKED: Mutex<Option<HashMap<u64, usize>>> = Mutex::new(None);
|
||||
|
||||
fn mark(key: u64) {
|
||||
if key != 0 {
|
||||
*MARKED.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).entry(key).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn unmark(key: u64) {
|
||||
let mut marked = MARKED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(m) = marked.as_mut() else { return };
|
||||
if let Some(n) = m.get_mut(&key) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
m.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run gave a result for cache key `key`: when some document deferred a stage with that key,
|
||||
/// [`revision`] changes.
|
||||
pub(crate) fn resolved(key: u64) {
|
||||
let marked = MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key));
|
||||
if marked {
|
||||
REVISION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A clock that reads `step` milliseconds later each time it is read.
|
||||
fn stepping(step: u64) -> Clock {
|
||||
let t = Arc::new(AtomicU64::new(0));
|
||||
Arc::new(move || Some(t.fetch_add(step, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_starts_with_the_first_run_and_ends_after_it() {
|
||||
let d = Deferrals::with_clock(stepping(700));
|
||||
assert!(matches!(d.allowance(), Allowance::Until { ms: 2_000, until: 2_000 }));
|
||||
assert!(matches!(d.allowance(), Allowance::Until { ms: 1_300, until: 2_000 }));
|
||||
assert!(matches!(d.allowance(), Allowance::Until { ms: 600, until: 2_000 }));
|
||||
assert!(matches!(d.allowance(), Allowance::Spent));
|
||||
d.first_view_done();
|
||||
assert!(matches!(d.allowance(), Allowance::Unlimited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_clock_the_first_view_runs_nothing_and_a_refresh_everything() {
|
||||
let d = Deferrals::with_clock(Arc::new(|| None));
|
||||
assert!(matches!(d.allowance(), Allowance::Spent));
|
||||
assert!(d.passed(u64::MAX));
|
||||
d.first_view_done();
|
||||
d.defer(Stage::default(), "a", Reason::Budget, 31);
|
||||
d.refresh(None, |_| {
|
||||
assert!(matches!(d.allowance(), Allowance::Unlimited));
|
||||
vec![]
|
||||
});
|
||||
}
|
||||
|
||||
/// A stage drawn with several inputs (the paths of a group) holds one record for each, and is
|
||||
/// listed and counted once.
|
||||
#[test]
|
||||
fn a_stage_is_listed_and_counted_once() {
|
||||
let d = Deferrals::with_clock(stepping(1));
|
||||
let s = Stage { object: 7, effect: 1, item: None };
|
||||
d.defer(s, "a", Reason::Budget, 11);
|
||||
d.defer(s, "a", Reason::Budget, 12);
|
||||
d.defer(s, "a", Reason::Timeout, 12);
|
||||
d.defer(Stage { item: Some(0), ..s }, "a", Reason::Failed, 13);
|
||||
assert_eq!(d.list().len(), 2);
|
||||
assert!(!d.holds(s, 12), "the first view runs deferred stages");
|
||||
d.first_view_done();
|
||||
assert!(d.holds(s, 11) && d.holds(s, 12));
|
||||
assert!(!d.holds(s, 14), "other input");
|
||||
assert_eq!(d.take_report(), Some(2));
|
||||
assert_eq!(d.take_report(), None);
|
||||
d.first_view_done();
|
||||
assert_eq!(d.take_report(), None, "the first view ends once");
|
||||
d.ran(s, 11);
|
||||
assert_eq!(d.list().len(), 2, "the stage is still deferred with its other input");
|
||||
d.ran(s, 12);
|
||||
assert_eq!(d.list().len(), 1);
|
||||
d.defer(s, "a", Reason::Missing, 0);
|
||||
d.ran(s, 15);
|
||||
assert_eq!(d.list().len(), 1, "a record without a key goes with any result");
|
||||
}
|
||||
|
||||
/// A refresh runs the deferred stages of the objects asked for with a fresh budget, and counts
|
||||
/// what became of them; the stages its drawing did not reach stay as they were. Another
|
||||
/// thread draws the document as if no refresh ran.
|
||||
#[test]
|
||||
fn a_refresh_counts_what_became_of_each_stage() {
|
||||
let d = Deferrals::with_clock(stepping(1));
|
||||
d.first_view_done();
|
||||
let st = |object, effect| Stage { object, effect, item: None };
|
||||
let stages = [
|
||||
(st(1, 0), Reason::Budget),
|
||||
(st(1, 1), Reason::Budget),
|
||||
(st(1, 2), Reason::Budget),
|
||||
(st(1, 3), Reason::Timeout),
|
||||
(st(2, 0), Reason::Budget),
|
||||
(st(3, 0), Reason::Failed),
|
||||
];
|
||||
for (stage, reason) in stages {
|
||||
d.defer(stage, "a", reason, 40 + stage.object * 4 + stage.effect as u64);
|
||||
}
|
||||
let seen = std::cell::RefCell::new(vec![]);
|
||||
let counts = d.refresh(Some(&[1, 3, 9]), |ids| {
|
||||
seen.borrow_mut().extend_from_slice(ids);
|
||||
assert!(matches!(d.allowance(), Allowance::Until { .. }), "a fresh budget");
|
||||
assert!(d.holds(st(2, 0), 48), "an object not asked for stays deferred");
|
||||
d.visit(st(2, 0));
|
||||
d.visit(st(1, 0));
|
||||
d.visit(st(1, 1));
|
||||
d.defer(st(1, 1), "a", Reason::Budget, 45);
|
||||
std::thread::scope(|scope| {
|
||||
let other = scope.spawn(|| (matches!(d.allowance(), Allowance::Unlimited), d.holds(st(1, 2), 46), d.retries(st(3, 0), 52)));
|
||||
assert_eq!(other.join().unwrap(), (true, true, false), "another thread");
|
||||
});
|
||||
d.visit(st(1, 1));
|
||||
assert_eq!(d.list().iter().filter(|x| x.stage == st(1, 1)).count(), 1, "a stage is reached once");
|
||||
d.visit(st(3, 0));
|
||||
d.defer(st(3, 0), "a", Reason::Failed, 52);
|
||||
ids.to_vec()
|
||||
});
|
||||
assert_eq!(*seen.borrow(), [1, 3]);
|
||||
// st(1, 3) timed out before and was not reached: still deferred, not failed again.
|
||||
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 3, failed: 1 });
|
||||
assert!(matches!(d.allowance(), Allowance::Unlimited), "the budget ends with the refresh");
|
||||
let left = d.list().iter().map(|x| x.stage).collect::<Vec<_>>();
|
||||
assert_eq!(left, [st(1, 1), st(1, 2), st(1, 3), st(2, 0), st(3, 0)]);
|
||||
|
||||
// An object the drawing did not reach whole keeps the inputs it did not reach: a reached
|
||||
// stage of it gives a result only for the inputs that ran.
|
||||
d.defer(st(1, 1), "a", Reason::Budget, 70);
|
||||
let counts = d.refresh(Some(&[1]), |_| {
|
||||
d.visit(st(1, 1));
|
||||
d.ran(st(1, 1), 45);
|
||||
vec![]
|
||||
});
|
||||
assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 3, failed: 0 });
|
||||
let keys = |stage| d.state().deferred.iter().filter(|x| x.stage == stage).map(|x| x.key).collect::<Vec<_>>();
|
||||
assert_eq!(keys(st(1, 1)), [70]);
|
||||
let counts = d.refresh(Some(&[1]), |_| {
|
||||
d.visit(st(1, 1));
|
||||
vec![1]
|
||||
});
|
||||
assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 0 }, "drawn whole: an input it no longer has");
|
||||
assert_eq!(keys(st(1, 1)), Vec::<u64>::new());
|
||||
d.retain(|stage, _| stage.object != 1);
|
||||
assert_eq!(d.list().iter().map(|x| x.stage).collect::<Vec<_>>(), [st(2, 0), st(3, 0)]);
|
||||
}
|
||||
|
||||
/// After the first view, a stage drawn with several inputs (the paths of a group) whose one
|
||||
/// changed input runs keeps the records of the inputs the same drawing left deferred, and drops
|
||||
/// the record of the input it no longer has.
|
||||
#[test]
|
||||
fn a_stage_that_runs_keeps_the_inputs_its_drawing_left_deferred() {
|
||||
let d = Deferrals::with_clock(stepping(1));
|
||||
let s = Stage { object: 4, effect: 0, item: None };
|
||||
let other = Stage { object: 5, ..s };
|
||||
for key in [61, 62] {
|
||||
d.defer(s, "a", Reason::Budget, key);
|
||||
}
|
||||
d.defer(other, "a", Reason::Budget, 63);
|
||||
d.first_view_done();
|
||||
d.view(|| {
|
||||
assert!(d.holds(s, 61), "an unchanged path");
|
||||
assert!(!d.holds(s, 64), "the changed path");
|
||||
d.ran(s, 64);
|
||||
});
|
||||
let keys = d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::<Vec<_>>();
|
||||
assert_eq!(keys, [(4, 61), (5, 63)]);
|
||||
|
||||
// A changed input that fails again replaces the record of the input it no longer has.
|
||||
d.view(|| {
|
||||
assert!(!d.holds(other, 65));
|
||||
d.defer(other, "a", Reason::Failed, 65);
|
||||
});
|
||||
let left = d.state().deferred.iter().map(|x| (x.stage.object, x.key, x.reason)).collect::<Vec<_>>();
|
||||
assert_eq!(left, [(4, 61, Reason::Budget), (5, 65, Reason::Failed)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_for_a_deferred_key_changes_the_revision() {
|
||||
const KEY: u64 = 0xdef0_0001;
|
||||
let marked = || MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&KEY));
|
||||
let d = Deferrals::with_clock(stepping(1));
|
||||
assert!(!marked());
|
||||
d.defer(Stage::default(), "a", Reason::Budget, KEY);
|
||||
let other = d.clone();
|
||||
other.defer(Stage { object: 2, ..Stage::default() }, "a", Reason::Budget, KEY);
|
||||
assert!(marked());
|
||||
let before = revision();
|
||||
resolved(KEY);
|
||||
assert!(revision() > before);
|
||||
other.ran(Stage::default(), KEY);
|
||||
assert!(marked(), "another stage holds the key");
|
||||
drop((d, other));
|
||||
assert!(!marked(), "the document closed");
|
||||
}
|
||||
}
|
||||
+256
-23
@@ -12,7 +12,8 @@
|
||||
//! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when
|
||||
//! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and
|
||||
//! reported by [`last_error`]. The swatches and patterns a painted result names are checked
|
||||
//! against the document each time it is used.
|
||||
//! against the document each time it is used. While a document is drawn on screen, its first view
|
||||
//! shares a time budget and stages without a result are deferred ([`crate::deferred`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -22,6 +23,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use crate::deferred::{Allowance, Reason, Stage};
|
||||
use crate::objects::{decode, output_geometry, paint_lists};
|
||||
use crate::painted::{self, Piece, Scope};
|
||||
use crate::{Error, Kind, Limits, Result, registry};
|
||||
@@ -69,6 +71,9 @@ pub struct Paints<'a> {
|
||||
/// The index in `appearance.items` of the fill or stroke whose own effects hold this one;
|
||||
/// `None`: one of the object's effects.
|
||||
pub item: Option<usize>,
|
||||
/// The object and the effect's place in its list, which a deferred stage is listed by
|
||||
/// ([`crate::deferred`]); `None`: not known, listed as object 0.
|
||||
pub at: Option<Stage>,
|
||||
}
|
||||
|
||||
/// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and
|
||||
@@ -151,7 +156,8 @@ impl Out {
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
entries: HashMap<u64, (Key, Option<Out>)>,
|
||||
/// Each result, or why the run gave none ([`Reason::Timeout`] or [`Reason::Failed`]).
|
||||
entries: HashMap<u64, (Key, std::result::Result<Out, Reason>)>,
|
||||
/// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]).
|
||||
anchors: usize,
|
||||
}
|
||||
@@ -294,9 +300,10 @@ pub fn paints_output(id: &str) -> bool {
|
||||
/// For an effect that returns painted pieces, the geometry is every piece's path in order, and
|
||||
/// `None` when the swatches and patterns they name are refused as [`apply_painted`] refuses them.
|
||||
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option<PathData> {
|
||||
let at = paints.at;
|
||||
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
|
||||
if let Out::Pieces(pieces) = &out {
|
||||
references_found(id, &plugin, pieces, doc)?;
|
||||
references_found(id, &plugin, pieces, doc, at)?;
|
||||
}
|
||||
Some(out.geometry())
|
||||
}
|
||||
@@ -318,9 +325,10 @@ pub fn apply_painted(
|
||||
paints: Paints,
|
||||
doc: Option<&Document>,
|
||||
) -> Option<Arc<Vec<Piece>>> {
|
||||
let at = paints.at;
|
||||
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
|
||||
let Out::Pieces(pieces) = out else { return None };
|
||||
references_found(id, &plugin, &pieces, doc)?;
|
||||
references_found(id, &plugin, &pieces, doc, at)?;
|
||||
Some(match doc {
|
||||
Some(d) => painted::with_swatch_colors(pieces, d),
|
||||
None => pieces,
|
||||
@@ -329,11 +337,17 @@ pub fn apply_painted(
|
||||
|
||||
/// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]),
|
||||
/// recording the outcome for [`last_error`] when there is a document. The document's swatches and
|
||||
/// patterns can change while a result stays cached, so each use checks them.
|
||||
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
|
||||
/// patterns can change while a result stays cached, so each use checks them. Inside a document's
|
||||
/// view, a refusal defers stage `at` as failed.
|
||||
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>, at: Option<Stage>) -> Option<()> {
|
||||
let checked = painted::check_references(pieces, doc);
|
||||
if doc.is_some() {
|
||||
note(id, plugin, Origin::References, checked.as_ref().map(|_| ()));
|
||||
if checked.is_err()
|
||||
&& let Some(v) = crate::deferred::current()
|
||||
{
|
||||
v.defer(at.unwrap_or_default(), id, Reason::Failed, 0);
|
||||
}
|
||||
}
|
||||
checked.ok()
|
||||
}
|
||||
@@ -341,12 +355,36 @@ fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Opt
|
||||
/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what
|
||||
/// it gave. The launch marker names the plug-in during its first run in this process, and until
|
||||
/// every run that started while it ran has ended too ([`crate::safe_mode::loading`]).
|
||||
///
|
||||
/// Inside a document's view ([`crate::deferred`]), a stage without a result is recorded as
|
||||
/// deferred with the reason, and one with a result is no longer deferred. During the view's
|
||||
/// budget a run takes at most what is left of it, none starts once it is spent, and a result
|
||||
/// that comes back after it ran out is not used; neither is cached or reported by
|
||||
/// [`last_error`]. After the first view, a stage deferred with the same input is not run again.
|
||||
fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc<crate::Plugin>, Out)> {
|
||||
let view = crate::deferred::current();
|
||||
let stage = paints.at.unwrap_or_default();
|
||||
if let Some(v) = &view {
|
||||
v.visit(stage);
|
||||
}
|
||||
let defer = |reason, key| {
|
||||
if let Some(v) = &view {
|
||||
v.defer(stage, id, reason, key);
|
||||
}
|
||||
};
|
||||
if crate::safe_mode::active() {
|
||||
defer(Reason::SafeMode, 0);
|
||||
return None;
|
||||
}
|
||||
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
|
||||
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
|
||||
let Some(plugin) = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect) else {
|
||||
defer(Reason::Missing, 0);
|
||||
return None;
|
||||
};
|
||||
let Ok(resolved) = plugin.manifest().resolve_params(params) else {
|
||||
defer(Reason::Failed, 0);
|
||||
return None;
|
||||
};
|
||||
let resolved = Value::Object(resolved);
|
||||
let given = paints.given();
|
||||
let key = Key {
|
||||
serial: plugin.serial(),
|
||||
@@ -357,14 +395,44 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
|
||||
path: path.clone(),
|
||||
};
|
||||
let h = key.hash64();
|
||||
if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h))
|
||||
&& *k == key
|
||||
{
|
||||
return out.clone().map(|o| (plugin, o));
|
||||
let cached = CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|c| c.entries.get(&h))
|
||||
.filter(|(k, _)| *k == key)
|
||||
.map(|(_, out)| out.clone());
|
||||
match cached {
|
||||
// A result from the cache counts as a run that gave it.
|
||||
Some(Ok(o)) => {
|
||||
if let Some(v) = &view {
|
||||
v.ran(stage, h);
|
||||
}
|
||||
return Some((plugin, o));
|
||||
}
|
||||
// A refresh runs a stage that failed again.
|
||||
Some(Err(reason)) if !view.as_ref().is_some_and(|v| v.retries(stage, h)) => {
|
||||
defer(reason, h);
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let allowance = match &view {
|
||||
Some(v) if v.holds(stage, h) => return None,
|
||||
Some(v) => v.allowance(),
|
||||
None => Allowance::Unlimited,
|
||||
};
|
||||
let (wall_time_ms, until) = match allowance {
|
||||
Allowance::Unlimited => (WALL_TIME_MS, None),
|
||||
Allowance::Until { ms, until } => (ms.min(WALL_TIME_MS), Some(until)),
|
||||
Allowance::Spent => {
|
||||
defer(Reason::Budget, h);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let scope = paints.scope();
|
||||
let guarded = begin_run(&plugin);
|
||||
let r = run(&plugin, resolved, path, bounds, rule, given, scope);
|
||||
let r = run(&plugin, resolved, path, (bounds, rule), given, scope, wall_time_ms);
|
||||
let cancelled = matches!(r, Err(Error::Cancelled));
|
||||
if guarded {
|
||||
end_run(plugin.serial(), cancelled);
|
||||
@@ -373,11 +441,28 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
|
||||
if cancelled {
|
||||
return None;
|
||||
}
|
||||
let timed_out = matches!(&r, Err(Error::Limit(l)) if l == crate::host::TIME_BUDGET);
|
||||
// A run the budget cut short, or whose result came after the budget ran out, says nothing
|
||||
// about the plug-in either: the stage waits for a later run.
|
||||
let late = until.is_some_and(|u| view.as_ref().is_some_and(|v| v.passed(u)));
|
||||
if (timed_out && wall_time_ms < WALL_TIME_MS) || late {
|
||||
defer(Reason::Budget, h);
|
||||
return None;
|
||||
}
|
||||
note(id, &plugin, Origin::Run, r.as_ref().map(|_| ()));
|
||||
let out = r.ok();
|
||||
let size = weight(&key, out.as_ref());
|
||||
let out = r.map_err(|_| if timed_out { Reason::Timeout } else { Reason::Failed });
|
||||
match &out {
|
||||
Ok(_) => {
|
||||
if let Some(v) = &view {
|
||||
v.ran(stage, h);
|
||||
}
|
||||
crate::deferred::resolved(h);
|
||||
}
|
||||
Err(reason) => defer(*reason, h),
|
||||
}
|
||||
let size = weight(&key, out.as_ref().ok());
|
||||
if size > CACHE_ANCHORS / 4 {
|
||||
return out.map(|o| (plugin, o));
|
||||
return out.ok().map(|o| (plugin, o));
|
||||
}
|
||||
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = cache.get_or_insert_with(Cache::default);
|
||||
@@ -385,15 +470,24 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR
|
||||
*cache = Cache::default();
|
||||
}
|
||||
if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) {
|
||||
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref()));
|
||||
cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref().ok()));
|
||||
}
|
||||
cache.anchors += size;
|
||||
out.map(|o| (plugin, o))
|
||||
out.ok().map(|o| (plugin, o))
|
||||
}
|
||||
|
||||
/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect
|
||||
/// that declares `paintOutput` its pieces, checked for an effect in `scope`.
|
||||
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result<Out> {
|
||||
/// One run of `plugin` on `path` (reference box and fill rule `(bounds, rule)`), with the object's
|
||||
/// paints as `given`, stopped after `wall_time_ms`: geometry, or for an effect that declares
|
||||
/// `paintOutput` its pieces, checked for an effect in `scope`.
|
||||
fn run(
|
||||
plugin: &crate::Plugin,
|
||||
mut params: Value,
|
||||
path: &PathData,
|
||||
(bounds, rule): (Rect, FillRule),
|
||||
given: Given,
|
||||
scope: Scope,
|
||||
wall_time_ms: u64,
|
||||
) -> Result<Out> {
|
||||
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
|
||||
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
|
||||
let rule_name = match rule {
|
||||
@@ -404,7 +498,9 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect,
|
||||
json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
|
||||
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms));
|
||||
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
|
||||
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
|
||||
let limits = Limits { wall_time_ms, ..plugin.limits().clone() };
|
||||
#[cfg(test)]
|
||||
tests::RUNS_STARTED.with(|n| n.set(n.get() + 1));
|
||||
let output = plugin.run_limited(&input, ¶ms, &limits)?;
|
||||
if plugin.manifest().paint_output {
|
||||
return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?)));
|
||||
@@ -420,6 +516,11 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
/// How many plug-in runs this thread started ([`run`]).
|
||||
pub(super) static RUNS_STARTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Fills and strokes come in paint order, and the item an effect sits on is counted among the
|
||||
/// fills or among the strokes.
|
||||
#[test]
|
||||
@@ -427,7 +528,7 @@ mod tests {
|
||||
let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
|
||||
let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w));
|
||||
let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() };
|
||||
let given = |item| Paints { appearance: Some(&ap), item }.given();
|
||||
let given = |item| Paints { appearance: Some(&ap), item, at: None }.given();
|
||||
let g = given(None);
|
||||
assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object")));
|
||||
assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::<Vec<_>>(), [json!(2.0), json!(5.0)]);
|
||||
@@ -494,9 +595,141 @@ mod tests {
|
||||
assert!(last_error(ID).unwrap().contains("trapped"));
|
||||
assert!(draw(&short).is_none(), "from the cache, refused again");
|
||||
assert!(last_error(ID).unwrap().contains("swatch"));
|
||||
// Inside a document's view, the refused stage is deferred as failed, also from the cache.
|
||||
let view = crate::deferred::Deferrals::with_clock(Arc::new(|| Some(0)));
|
||||
let at = Stage { object: 3, effect: 1, item: None };
|
||||
let in_view = || {
|
||||
view.view(|| {
|
||||
apply_painted(
|
||||
ID,
|
||||
&json!({}),
|
||||
&short,
|
||||
Rect::new(0.0, 0.0, 1.0, 1.0),
|
||||
FillRule::NonZero,
|
||||
Paints { at: Some(at), ..Paints::default() },
|
||||
Some(&doc),
|
||||
)
|
||||
})
|
||||
};
|
||||
assert!(in_view().is_none());
|
||||
let listed = || view.list().iter().map(|d| (d.stage, d.reason)).collect::<Vec<_>>();
|
||||
assert_eq!(listed(), [(at, Reason::Failed)]);
|
||||
view.first_view_done();
|
||||
assert!(in_view().is_none());
|
||||
assert_eq!(listed(), [(at, Reason::Failed)], "a later drawing leaves it listed");
|
||||
registry::remove(ID);
|
||||
}
|
||||
|
||||
/// A clock that reads `step` milliseconds later each time it is read.
|
||||
fn stepping(step: u64) -> crate::deferred::Clock {
|
||||
let t = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
Arc::new(move || Some(t.fetch_add(step, std::sync::atomic::Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// A line of `n` points: a distinct input for each `n`.
|
||||
fn line(n: usize) -> PathData {
|
||||
PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::<Vec<_>>(), false))
|
||||
}
|
||||
|
||||
/// Plug-in `id` drawn on `path` as effect `effect` of object 1: whether it gave a result.
|
||||
fn drawn(id: &str, path: &PathData, effect: usize) -> bool {
|
||||
let at = Some(Stage { object: 1, effect, item: None });
|
||||
apply(id, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints { at, ..Paints::default() }, None).is_some()
|
||||
}
|
||||
|
||||
/// The effects of a first view share its budget: once it is spent no run starts, and a result
|
||||
/// that comes back after it ran out is not used. Later drawings leave the deferred stages
|
||||
/// deferred; a drawing outside the view (an export) runs them, and the view then uses the
|
||||
/// result.
|
||||
#[test]
|
||||
fn the_first_view_shares_the_budget_and_defers_the_rest() {
|
||||
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::safe_mode::set_active(None);
|
||||
const ID: &str = "test.effect.budget";
|
||||
let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#);
|
||||
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
|
||||
// Each run takes 900 ms by this clock (it is read before and after each run).
|
||||
let view = crate::deferred::Deferrals::with_clock(stepping(450));
|
||||
let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::<Vec<_>>());
|
||||
assert_eq!(ran, [true, true, false, false], "the third result comes back at 2,250 ms, the fourth doesn't start");
|
||||
let listed = view.list().iter().map(|d| (d.stage.effect, d.reason, d.plugin.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(listed, [(2, Reason::Budget, ID.to_string()), (3, Reason::Budget, ID.to_string())]);
|
||||
view.first_view_done();
|
||||
assert_eq!(view.take_report(), Some(2));
|
||||
assert!(!view.view(|| drawn(ID, &line(4), 2)), "a later drawing leaves it deferred");
|
||||
assert!(view.view(|| drawn(ID, &line(9), 3)), "an edit to its input runs it");
|
||||
assert_eq!(view.list().len(), 1);
|
||||
assert!(drawn(ID, &line(4), 2), "outside the view it runs");
|
||||
assert_eq!(view.list().len(), 1, "until the view draws it again");
|
||||
assert!(view.view(|| drawn(ID, &line(9), 2)), "an edit to an input whose result is cached");
|
||||
assert!(view.list().is_empty(), "the record of the input it no longer has goes");
|
||||
assert!(view.view(|| drawn(ID, &line(4), 2)), "from the cache");
|
||||
assert!(view.list().is_empty());
|
||||
registry::remove(ID);
|
||||
}
|
||||
|
||||
/// A stage that fails, or whose plug-in is missing, is deferred with that reason, from a run or
|
||||
/// from the cache; a failure is cached and reported as before.
|
||||
#[test]
|
||||
fn failed_and_missing_stages_are_deferred_with_their_reason() {
|
||||
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::safe_mode::set_active(None);
|
||||
const ID: &str = "test.effect.deferfailed";
|
||||
// Runs to its instruction budget.
|
||||
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
|
||||
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
|
||||
let view = crate::deferred::Deferrals::with_clock(stepping(1));
|
||||
assert!(!view.view(|| drawn(ID, &line(2), 0)));
|
||||
assert!(last_error(ID).is_some());
|
||||
assert!(!view.view(|| drawn("test.effect.notinstalled", &line(2), 1)));
|
||||
let reasons = |v: &crate::deferred::Deferrals| v.list().iter().map(|d| d.reason).collect::<Vec<_>>();
|
||||
assert_eq!(reasons(&view), [Reason::Failed, Reason::Missing]);
|
||||
let other = crate::deferred::Deferrals::with_clock(stepping(1));
|
||||
assert!(!other.view(|| drawn(ID, &line(2), 0)));
|
||||
assert_eq!(reasons(&other), [Reason::Failed], "from the cache, in another document");
|
||||
// A refresh runs a stage whose failure is cached again, once, and later drawings take the
|
||||
// failure from the cache.
|
||||
other.first_view_done();
|
||||
let runs = || RUNS_STARTED.with(std::cell::Cell::get);
|
||||
let before = runs();
|
||||
let counts = other.refresh(None, |_| {
|
||||
other.view(|| (drawn(ID, &line(2), 0), drawn(ID, &line(2), 0)));
|
||||
vec![]
|
||||
});
|
||||
assert_eq!((counts.failed, runs() - before), (1, 1));
|
||||
assert!(!other.view(|| drawn(ID, &line(2), 0)));
|
||||
assert_eq!((reasons(&other), runs() - before), (vec![Reason::Failed], 1));
|
||||
registry::remove(ID);
|
||||
}
|
||||
|
||||
/// A run the budget cuts short is deferred for want of time: it is not cached, and the plug-in
|
||||
/// reports no error.
|
||||
#[test]
|
||||
fn a_run_the_budget_cuts_short_is_deferred_for_time() {
|
||||
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::safe_mode::set_active(None);
|
||||
const ID: &str = "test.effect.cutshort";
|
||||
const FAST: &str = "test.effect.cutshort.fast";
|
||||
let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#);
|
||||
let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() };
|
||||
registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap());
|
||||
let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#);
|
||||
registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap());
|
||||
// The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and
|
||||
// ten million steps take longer.
|
||||
let reads = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let clock: crate::deferred::Clock =
|
||||
Arc::new(move || Some(if reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 { 0 } else { 1_999 }));
|
||||
let view = crate::deferred::Deferrals::with_clock(clock);
|
||||
assert!(view.view(|| drawn(FAST, &line(2), 0)));
|
||||
assert!(!view.view(|| drawn(ID, &line(2), 1)));
|
||||
assert_eq!(view.list().iter().map(|d| (d.stage.effect, d.reason)).collect::<Vec<_>>(), [(1, Reason::Budget)]);
|
||||
assert_eq!(last_error(ID), None);
|
||||
assert!(drawn(ID, &line(2), 1), "not cached: outside the view it runs to the end");
|
||||
registry::remove(ID);
|
||||
registry::remove(FAST);
|
||||
}
|
||||
|
||||
/// A first run that is cancelled leaves the next run to be the first: the launch marker names
|
||||
/// the plug-in during that one.
|
||||
#[test]
|
||||
|
||||
@@ -318,6 +318,9 @@ pub struct Report {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The limit a run past its wall-clock deadline exceeded ([`Error::Limit`]).
|
||||
pub(crate) const TIME_BUDGET: &str = "time budget";
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Deadline {
|
||||
@@ -344,7 +347,7 @@ impl Deadline {
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<()> {
|
||||
if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) }
|
||||
if self.passed() { Err(Error::Limit(TIME_BUDGET.into())) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
//! runs through `vc_run` with the command's id in `_context`.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod deferred;
|
||||
pub mod dialog;
|
||||
pub mod effect;
|
||||
pub mod export;
|
||||
|
||||
@@ -502,10 +502,26 @@ pub fn param_keys(doc: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
|
||||
/// write files anywhere, so callers substitute a temp path.
|
||||
/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
|
||||
/// arbitrary strings could write files anywhere, so callers substitute a temp path.
|
||||
pub fn is_path_key(k: &str) -> bool {
|
||||
k == "path"
|
||||
matches!(k, "path" | "diffPath") || is_folder_key(k)
|
||||
}
|
||||
|
||||
/// Path keys that name a folder a command creates or writes files into.
|
||||
pub fn is_folder_key(k: &str) -> bool {
|
||||
matches!(k, "folder" | "destinationFolder")
|
||||
}
|
||||
|
||||
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
|
||||
/// a folder key.
|
||||
pub fn safe_location(k: &str, safe_path: &str) -> String {
|
||||
if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() }
|
||||
}
|
||||
|
||||
/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string.
|
||||
pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value {
|
||||
if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() }
|
||||
}
|
||||
|
||||
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
|
||||
@@ -516,7 +532,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
||||
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
|
||||
for k in &keys {
|
||||
for (i, v) in junk.iter().enumerate() {
|
||||
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
|
||||
let v = safe_value(k, v, safe_path);
|
||||
// Sparse: one key at a time with every junk value, plus a few combos.
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(k.clone(), v.clone());
|
||||
@@ -524,7 +540,7 @@ pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
||||
if i % 5 == 0 {
|
||||
let mut all = serde_json::Map::new();
|
||||
for k2 in &keys {
|
||||
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
|
||||
let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() };
|
||||
all.insert(k2.clone(), v2);
|
||||
}
|
||||
out.push(Value::Object(all));
|
||||
|
||||
@@ -123,9 +123,9 @@ pub struct CacheKey {
|
||||
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
|
||||
pub placed: u64,
|
||||
/// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in
|
||||
/// install or removal after which the document drew a plug-in effect
|
||||
/// ([`vectorcraft_render::draws_plugin_effects`]): a frame drawn before it draws those effects
|
||||
/// as the plug-ins installed then did. 0 until then.
|
||||
/// install or removal, or of the last time deferred plug-in effect stages gave results, after
|
||||
/// which the document drew a plug-in effect ([`vectorcraft_render::draws_plugin_effects`]): a
|
||||
/// frame drawn before it draws those effects as the plug-ins installed then did. 0 until then.
|
||||
pub plugins: u64,
|
||||
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
|
||||
/// with hard edges. None: the art is rendered for the screen.
|
||||
|
||||
@@ -293,6 +293,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
let effects = st.plugin_effects.clone();
|
||||
let mask_view = st.shown_mask();
|
||||
|
||||
paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view()));
|
||||
@@ -349,8 +350,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) {
|
||||
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
|
||||
let opts = frame_options(app, &key, mask_view, smooth_images);
|
||||
draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel });
|
||||
draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel });
|
||||
}
|
||||
report_deferred(app, &effects);
|
||||
// The status bar reports each image that a renderer (this thread's or the worker's) could not
|
||||
// decode, once per image in the process.
|
||||
let undecoded = vectorcraft_render::newly_undecodable(doc.images.values());
|
||||
@@ -606,7 +608,7 @@ fn cache_key(
|
||||
|
||||
/// [`CacheKey::plugins`] of document `st`, one of the documents open in `session`. `keys` holds each
|
||||
/// document's value and the plug-in revision it was checked at, so a document is looked through only
|
||||
/// once after each plug-in install or removal.
|
||||
/// once after each change of the plug-in revision.
|
||||
fn plugin_key(
|
||||
keys: &mut std::collections::HashMap<u64, (u64, u64)>,
|
||||
session: &vectorcraft_engine::Session,
|
||||
@@ -624,6 +626,19 @@ fn plugin_key(
|
||||
}
|
||||
}
|
||||
|
||||
/// Once the first view of a document has been drawn, the status bar says how many plug-in effect
|
||||
/// stages it left deferred, if any.
|
||||
fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) {
|
||||
if let Some(n) = effects.take_report().filter(|n| *n > 0) {
|
||||
app.status(crate::i18n::msg_n(
|
||||
n as u64,
|
||||
"Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.",
|
||||
"Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them.",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the render worker, and upload the frame it finished.
|
||||
fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
|
||||
if !app.canvas.worker_started {
|
||||
@@ -647,6 +662,9 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate:
|
||||
return;
|
||||
}
|
||||
upload(app, ctx, done.img, done.key.pixel.is_some());
|
||||
if let Some(d) = app.session.documents().iter().find(|d| d.uid as usize == done.key.doc) {
|
||||
d.plugin_effects.first_view_done();
|
||||
}
|
||||
app.canvas.key = Some(done.key);
|
||||
app.canvas.shown = Some((done.doc, done.opts));
|
||||
app.perf.render_ms = done.ms;
|
||||
@@ -729,6 +747,8 @@ fn frame_options(
|
||||
struct Frame<'a> {
|
||||
key: &'a CacheKey,
|
||||
doc: &'a std::sync::Arc<vectorcraft_doc::Document>,
|
||||
/// The document's deferred plug-in effect stages: the frame renders inside their view.
|
||||
effects: &'a vectorcraft_plugins::deferred::Deferrals,
|
||||
size: (u32, u32),
|
||||
view: Affine,
|
||||
opts: vectorcraft_render::RenderOptions,
|
||||
@@ -740,7 +760,7 @@ struct Frame<'a> {
|
||||
pub(crate) const FIRST_FRAME_GRACE: std::time::Duration = std::time::Duration::from_millis(16);
|
||||
|
||||
fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
let Frame { key, doc, size: (w, h), view, opts, pixel } = frame;
|
||||
let Frame { key, doc, effects, size: (w, h), view, opts, pixel } = frame;
|
||||
// Frames that would be slow on this thread (the last one took over 8 ms) go
|
||||
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
|
||||
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
|
||||
@@ -768,7 +788,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
app.canvas.renderer.let_go_of_measured();
|
||||
}
|
||||
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
|
||||
let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
|
||||
let renderer = &mut app.canvas.renderer;
|
||||
let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts));
|
||||
let color = color_image(&part);
|
||||
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
|
||||
tex.set_partial([x0 as usize, y0 as usize], color, options);
|
||||
@@ -790,6 +811,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
effects: effects.clone(),
|
||||
may_copy,
|
||||
may_keep: true,
|
||||
});
|
||||
@@ -801,7 +823,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
}
|
||||
_ => {
|
||||
let t0 = now_ms();
|
||||
let img = app.canvas.renderer.render(doc, w, h, view, &opts);
|
||||
let renderer = &mut app.canvas.renderer;
|
||||
let img = effects.view(|| renderer.render(doc, w, h, view, &opts));
|
||||
effects.first_view_done();
|
||||
upload(app, ui.ctx(), img, pixel.is_some());
|
||||
app.canvas.key = Some(key.clone());
|
||||
app.canvas.shown = Some((doc.clone(), opts.clone()));
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(super) fn fill_uncovered(
|
||||
let (_, _, view) = fill_view(&done.key);
|
||||
app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex });
|
||||
}
|
||||
let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default();
|
||||
let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return };
|
||||
if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) {
|
||||
let (w, h, view) = fill_view(key);
|
||||
@@ -106,6 +107,7 @@ pub(super) fn fill_uncovered(
|
||||
view,
|
||||
opts,
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
effects,
|
||||
may_copy: false,
|
||||
may_keep: false,
|
||||
});
|
||||
|
||||
@@ -178,7 +178,11 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||||
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
|
||||
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
|
||||
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
|
||||
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
|
||||
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
|
||||
/// double-click.
|
||||
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
@@ -189,6 +193,40 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
// (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it.
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG);
|
||||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
// egui counts a double-click by position and time alone, so a click on the canvas right
|
||||
// at the ruler and one on the ruler would pass for one: the pair counts only when the
|
||||
// ruler's own click before it came within the double-click delay.
|
||||
let last_click = egui::Id::unique(if vertical { "ruler-left-last-click" } else { "ruler-top-last-click" });
|
||||
let now = ui.input(|i| i.time);
|
||||
let first_here =
|
||||
ui.data(|d| d.get_temp::<f64>(last_click)).is_some_and(|t| now - t <= ui.ctx().options(|o| o.input_options.max_double_click_delay));
|
||||
if resp.clicked_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(last_click, now));
|
||||
}
|
||||
if resp.double_clicked_by(egui::PointerButton::Primary)
|
||||
&& first_here
|
||||
&& let Some(p) = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))
|
||||
{
|
||||
// The same document point the drag path reads. A ruler reads the canvas's coordinates
|
||||
// turned with the view, so the guide is the one whose line crosses the ruler's axis:
|
||||
// the vertical one when that axis (the screen's x for the top ruler, y for the left,
|
||||
// in document directions) is nearer the document's x, else the horizontal one. At 0°
|
||||
// that is a vertical guide from the top ruler; at 90° a horizontal one; between, the
|
||||
// nearer axis, whose line still runs through the point and never along the ruler.
|
||||
let at = xf.to_doc(p);
|
||||
let (sn, cs) = xf.rot.sin_cos();
|
||||
let axis = if vertical { (sn, cs) } else { (cs, -sn) };
|
||||
let across = axis.0.abs() >= axis.1.abs();
|
||||
let mut params = json!({"vertical": across, "pos": if across { at.x } else { at.y }});
|
||||
if let Some(ab) = app.session.ruler_guide_artboard() {
|
||||
params["artboard"] = json!(ab);
|
||||
}
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", params) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
} else if resp.dragged() {
|
||||
|
||||
@@ -2395,6 +2395,52 @@ fn undecodable_images_are_marked_and_reported_once() {
|
||||
assert_eq!(app.ui.status, "", "said once");
|
||||
}
|
||||
|
||||
/// The plug-in effects a document's first view leaves out past its budget are reported in the
|
||||
/// status bar once, in the singular or the plural, and Effect › Refresh Deferred Effects is
|
||||
/// enabled until it has run them.
|
||||
#[test]
|
||||
fn the_first_view_reports_the_plug_in_effects_it_deferred() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let module = tpl::module(&tpl::manifest("test.ui.deferred", "effect", "{}"), tpl::ECHO, "", 1);
|
||||
app.session.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(module).unwrap())})).unwrap();
|
||||
// Each reading of the clock is 3 s later: the first run's result comes back after the budget,
|
||||
// and no later run starts.
|
||||
let step = std::sync::Arc::new(AtomicU64::new(3_000));
|
||||
let ctx = egui::Context::default();
|
||||
for (count, said) in [
|
||||
(2, "Opened with 2 plug-in effects deferred. Effect › Refresh Deferred Effects runs them."),
|
||||
(1, "Opened with 1 plug-in effect deferred. Effect › Refresh Deferred Effects runs it."),
|
||||
] {
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
// Art the plug-in has not been given before: a result it gave is kept and drawn at once.
|
||||
for i in 0..count {
|
||||
let x = 31.0 + 97.0 * f64::from(i) + 13.0 * f64::from(count);
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 47, "width": 23, "height": 29})).unwrap()["id"].clone();
|
||||
app.session.execute("effect.apply", &json!({"ids": [id], "effect": "plugin.test.ui.deferred"})).unwrap();
|
||||
}
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let (now, by) = (std::sync::Arc::new(AtomicU64::new(0)), step.clone());
|
||||
let clock: vectorcraft_plugins::deferred::Clock =
|
||||
std::sync::Arc::new(move || Some(now.fetch_add(by.load(Ordering::Relaxed), Ordering::Relaxed)));
|
||||
app.session.doc_mut().unwrap().plugin_effects = vectorcraft_plugins::deferred::Deferrals::with_clock(clock);
|
||||
step.store(3_000, Ordering::Relaxed);
|
||||
app.status("");
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.ui.status, said);
|
||||
assert!(crate::menus::enabled(&app, "effect.refreshDeferred"));
|
||||
app.status("");
|
||||
app.session.doc_mut().unwrap().revision += 1;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.ui.status, "", "said once");
|
||||
step.store(0, Ordering::Relaxed);
|
||||
app.session.execute("effect.refreshDeferred", &json!({})).unwrap();
|
||||
assert!(!crate::menus::enabled(&app, "effect.refreshDeferred"), "nothing is left deferred");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag of an object with raster effects (#1257): the drag's frames render while
|
||||
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
|
||||
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
|
||||
@@ -10701,6 +10747,7 @@ fn pan_job(doc: &std::sync::Arc<vectorcraft_doc::Document>, key: &CacheKey, (dx,
|
||||
view: Affine::translate((dx, dy)),
|
||||
opts: vectorcraft_render::RenderOptions::default(),
|
||||
scope: vectorcraft_engine::file_access::ScopeHandle::capture(),
|
||||
effects: Default::default(),
|
||||
may_copy: true,
|
||||
may_keep: true,
|
||||
}
|
||||
@@ -11318,6 +11365,151 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
|
||||
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
|
||||
}
|
||||
|
||||
/// #5239: a double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler
|
||||
/// at its x, a horizontal one on the left ruler at its y, as one undo step, shown even when
|
||||
/// guides were hidden. The first click of the pair adds nothing.
|
||||
#[test]
|
||||
fn double_clicking_a_ruler_adds_a_guide_at_that_position() {
|
||||
// The ruler to click, the guide's orientation and its position, the pointer at that position.
|
||||
for (top, vertical, pos) in [(true, true, 120.0), (false, false, 80.0)] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 2.0;
|
||||
view.rotation = 0.0;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let at = if top {
|
||||
pos2(xf.to_screen(Point::new(pos, 0.0)).x, rect.top() - RULER / 2.0)
|
||||
} else {
|
||||
pos2(rect.left() - RULER / 2.0, xf.to_screen(Point::new(0.0, pos)).y)
|
||||
};
|
||||
let guides = |app: &mut VectorcraftApp| {
|
||||
let list = app.session.execute("guide.list", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect::<Vec<_>>()
|
||||
};
|
||||
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
let before = steps(&app);
|
||||
let click = |app: &mut VectorcraftApp| {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(true)]);
|
||||
frame(app, &ctx, vec![button(false)]);
|
||||
};
|
||||
click(&mut app);
|
||||
assert_eq!(guides(&mut app), vec![], "a single click adds none");
|
||||
click(&mut app);
|
||||
let got = guides(&mut app);
|
||||
assert_eq!(got.len(), 1, "top ruler: {top}, {got:?}");
|
||||
assert!(got[0].0 == vertical && (got[0].1 - pos).abs() < 0.01, "top ruler: {top}, want vertical {vertical} at {pos}: {got:?}");
|
||||
assert!(app.ui.view.guides, "the new guide is shown");
|
||||
assert_eq!(steps(&app), before + 1, "one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(guides(&mut app), vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh app with a 400 x 300 document, rulers on, the view at zoom 2 turned `rotation` degrees,
|
||||
/// and its first frame drawn.
|
||||
fn ruler_guide_app(rotation: f64) -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let view = app.view_mut().unwrap();
|
||||
view.zoom = 2.0;
|
||||
view.rotation = rotation;
|
||||
view.center = Point::new(200.0, 150.0);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
/// One primary click at `at`, as three frames (move, press, release).
|
||||
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, ctx, vec![button(true)]);
|
||||
frame(app, ctx, vec![button(false)]);
|
||||
}
|
||||
|
||||
/// The ruler guides as (vertical, position).
|
||||
fn guide_list(app: &mut VectorcraftApp) -> Vec<(bool, f64)> {
|
||||
let list = app.session.execute("guide.list", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|g| (g["vertical"].as_bool().unwrap(), g["pos"].as_f64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// #5239 review: egui counts a double-click by position and time, not by widget, so a click on
|
||||
/// the canvas right at a ruler followed at once by a click on the ruler is not a double-click on
|
||||
/// the ruler. No guide; two clicks on the ruler still make one.
|
||||
#[test]
|
||||
fn a_canvas_click_then_a_ruler_click_adds_no_guide() {
|
||||
for (top, canvas_first) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let (mut app, ctx) = ruler_guide_app(0.0);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
// Two points less than 6 pt apart, either side of the ruler's inner edge.
|
||||
let (on_canvas, on_ruler) = if top {
|
||||
(pos2(rect.center().x, rect.top() + 1.0), pos2(rect.center().x, rect.top() - 1.0))
|
||||
} else {
|
||||
(pos2(rect.left() + 1.0, rect.center().y), pos2(rect.left() - 1.0, rect.center().y))
|
||||
};
|
||||
click_at(&mut app, &ctx, if canvas_first { on_canvas } else { on_ruler });
|
||||
click_at(&mut app, &ctx, on_ruler);
|
||||
let want = usize::from(!canvas_first);
|
||||
assert_eq!(guide_list(&mut app).len(), want, "top ruler: {top}, first click on the canvas: {canvas_first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5239 review: the rulers read the canvas's coordinates turned with the view, so a guide made
|
||||
/// by a double-click runs across the ruler's axis in any view: it passes through the clicked
|
||||
/// point and crosses the ruler's edge at it, never along it. At 90° the top ruler reads the
|
||||
/// document's y, so its guide is horizontal; the left ruler reads x, so its guide is vertical.
|
||||
/// A view turned part of the way takes the document axis nearer to the ruler's.
|
||||
#[test]
|
||||
fn a_ruler_double_click_follows_a_rotated_view() {
|
||||
// Rotation (degrees), top ruler's guide is vertical, left ruler's guide is vertical.
|
||||
for (rotation, top_vertical, left_vertical) in [
|
||||
(0.0, true, false),
|
||||
(90.0, false, true),
|
||||
(-90.0, false, true),
|
||||
(180.0, true, false),
|
||||
(270.0, false, true),
|
||||
(30.0, true, false),
|
||||
(60.0, false, true),
|
||||
] {
|
||||
for top in [true, false] {
|
||||
let (mut app, ctx) = ruler_guide_app(rotation);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let at =
|
||||
if top { pos2(rect.center().x + 37.0, rect.top() - RULER / 2.0) } else { pos2(rect.left() - RULER / 2.0, rect.center().y + 23.0) };
|
||||
click_at(&mut app, &ctx, at);
|
||||
click_at(&mut app, &ctx, at);
|
||||
let got = guide_list(&mut app);
|
||||
let label = format!("rotation {rotation}, top ruler: {top}: {got:?}");
|
||||
assert_eq!(got.len(), 1, "{label}");
|
||||
let (vertical, pos) = got[0];
|
||||
assert_eq!(vertical, if top { top_vertical } else { left_vertical }, "{label}");
|
||||
// Through the clicked point.
|
||||
let doc = xf.to_doc(at);
|
||||
assert!((pos - if vertical { doc.x } else { doc.y }).abs() < 0.01, "{label}: through {doc:?}");
|
||||
// Its direction on screen is not along the ruler: the line crosses the ruler's edge.
|
||||
let along = if vertical { Point::new(doc.x, doc.y + 1.0) } else { Point::new(doc.x + 1.0, doc.y) };
|
||||
let dir = (xf.to_screen(along) - xf.to_screen(doc)).normalized();
|
||||
let across = if top { dir.y.abs() } else { dir.x.abs() };
|
||||
assert!(across > 0.5, "{label}: the guide runs along the ruler ({dir:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer
|
||||
/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with
|
||||
/// the 3D art it made to measure it.
|
||||
|
||||
@@ -6523,3 +6523,7 @@ breakText Glyphs Глифи
|
||||
Ended by an internal fault Прекратен поради вътрешна грешка
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} извиквания към хоста
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} по-ранни реда премахнати, {cut} реда съкратени
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Обновяване на отложените ефекти
|
||||
Deferred Effects Отложени ефекти
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6574,3 +6574,7 @@ shape Open Otvoren
|
||||
Ended by an internal fault Završeno zbog interne greške
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} poziva hosta
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} ranijih redova uklonjeno, {cut} redova skraćeno
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Osvježi odgođene efekte
|
||||
Deferred Effects Odgođeni efekti
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6779,3 +6779,9 @@ breakText Glyphs Glifs
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} crides a l’amfitrió
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} línies anteriors descartades, {cut} línies tallades
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Actualitza els efectes ajornats
|
||||
Deferred Effects Efectes ajornats
|
||||
@msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6798,3 +6798,9 @@ breakText Glyphs Glyfy
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} volání hostitele
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} starších řádků zahozeno, {cut} řádků zkráceno
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Obnovit odložené efekty
|
||||
Deferred Effects Odložené efekty
|
||||
@msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6537,3 +6537,7 @@ breakText Glyphs Glyffer
|
||||
Ended by an internal fault Afsluttet af en intern fejl
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} værtskald
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} tidligere linjer fjernet, {cut} linjer afkortet
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Opdatér udskudte effekter
|
||||
Deferred Effects Udskudte effekter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6385,3 +6385,9 @@ breakText Glyphs Glyphen
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} Host-Aufrufe
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} ältere Zeilen verworfen, {cut} Zeilen gekürzt
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Zurückgestellte Effekte aktualisieren
|
||||
Deferred Effects Zurückgestellte Effekte
|
||||
@msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6750,3 +6750,9 @@ breakText Glyphs Γλύφοι
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} κλήσεις προς το κεντρικό σύστημα
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} παλαιότερες γραμμές απορρίφθηκαν, {cut} γραμμές περικόπηκαν
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Ανανέωση εφέ σε αναμονή
|
||||
Deferred Effects Εφέ σε αναμονή
|
||||
@msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί.
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6741,3 +6741,9 @@ breakText Glyphs Glifos
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} llamadas al host
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} líneas anteriores descartadas, {cut} líneas recortadas
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Actualizar efectos aplazados
|
||||
Deferred Effects Efectos aplazados
|
||||
@msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6833,3 +6833,9 @@ breakText Glyphs Merkit
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} isäntäkutsua
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} aiempaa riviä poistettu, {cut} riviä katkaistu
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Päivitä lykätyt tehosteet
|
||||
Deferred Effects Lykätyt tehosteet
|
||||
@msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6793,3 +6793,9 @@ breakText Glyphs Glyphes
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} appels à l’hôte
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} lignes antérieures supprimées, {cut} lignes coupées
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Actualiser les effets différés
|
||||
Deferred Effects Effets différés
|
||||
@msg no plug-in effect is deferred aucun effet de plug-in n’est différé
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -8239,3 +8239,9 @@ theme Appearance अपीयरेंस
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} होस्ट कॉल
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} पुरानी पंक्तियाँ हटाई गईं, {cut} पंक्तियाँ काटी गईं
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें
|
||||
Deferred Effects स्थगित इफ़ेक्ट्स
|
||||
@msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है।
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6782,3 +6782,9 @@ breakText Glyphs Glifák
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} gazdahívás
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} korábbi sor elvetve, {cut} sor levágva
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Elhalasztott effektusok frissítése
|
||||
Deferred Effects Elhalasztott effektusok
|
||||
@msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6537,3 +6537,7 @@ breakText Glyphs Glif
|
||||
Ended by an internal fault Berakhir karena galat internal
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} panggilan host
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} baris lama dibuang, {cut} baris dipotong
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Segarkan efek yang ditunda
|
||||
Deferred Effects Efek yang ditunda
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2327 and column 5.
|
@@ -6419,3 +6419,9 @@ breakText Glyphs Glifi
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} chiamate all'host
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} righe precedenti scartate, {cut} righe tagliate
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Aggiorna effetti rimandati
|
||||
Deferred Effects Effetti rimandati
|
||||
@msg no plug-in effect is deferred nessun effetto plug-in è rimandato
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6484,3 +6484,9 @@ breakText Glyphs 字形
|
||||
{ms} ms, {calls} host calls {ms} ms、ホスト呼び出し {calls} 回
|
||||
{dropped} earlier lines dropped, {cut} lines cut 古い行 {dropped} 行を破棄、{cut} 行を切り詰め
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects 保留中の効果を更新
|
||||
Deferred Effects 保留中の効果
|
||||
@msg no plug-in effect is deferred 保留中のプラグイン効果はありません
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6859,3 +6859,9 @@ font-script Japanese იაპონური
|
||||
{ms} ms, {calls} host calls {ms} ms, ჰოსტის {calls} გამოძახება
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} ძველი სტრიქონი წაიშალა, {cut} სტრიქონი შემოკლდა
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects გადადებული ეფექტების განახლება
|
||||
Deferred Effects გადადებული ეფექტები
|
||||
@msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს.
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6380,3 +6380,9 @@ breakText Glyphs 글리프
|
||||
{ms} ms, {calls} host calls {ms} ms, 호스트 호출 {calls}회
|
||||
{dropped} earlier lines dropped, {cut} lines cut 이전 줄 {dropped}개 삭제됨, 줄 {cut}개 잘림
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects 보류된 효과 새로 고침
|
||||
Deferred Effects 보류된 효과
|
||||
@msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6588,3 +6588,7 @@ breakText Glyphs Glyphs
|
||||
Ended by an internal fault Beëindigd door een interne fout
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} host-aanroepen
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} eerdere regels verwijderd, {cut} regels ingekort
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Uitgestelde effecten vernieuwen
|
||||
Deferred Effects Uitgestelde effecten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6654,3 +6654,9 @@ breakText Glyphs Glify
|
||||
{ms} ms, {calls} host calls {ms} ms, wywołania hosta: {calls}
|
||||
{dropped} earlier lines dropped, {cut} lines cut Odrzucone wcześniejsze wiersze: {dropped}, skrócone wiersze: {cut}
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Odśwież odroczone efekty
|
||||
Deferred Effects Odroczone efekty
|
||||
@msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6871,3 +6871,9 @@ swatch Yellow Green Verde-amarelado
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} chamadas ao host
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} linhas anteriores descartadas, {cut} linhas cortadas
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Atualizar Efeitos Adiados
|
||||
Deferred Effects Efeitos Adiados
|
||||
@msg no plug-in effect is deferred nenhum efeito de plug-in está adiado
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -6887,3 +6887,9 @@ shape Open Открытый
|
||||
{ms} ms, {calls} host calls {ms} мс, вызовов хоста: {calls}
|
||||
{dropped} earlier lines dropped, {cut} lines cut Отброшено прежних строк: {dropped}, обрезано строк: {cut}
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Обновить отложенные эффекты
|
||||
Deferred Effects Отложенные эффекты
|
||||
@msg no plug-in effect is deferred нет отложенных эффектов модулей
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -8283,3 +8283,9 @@ digits Hindi Hindi
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} värdanrop
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} tidigare rader borttagna, {cut} rader avkortade
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Uppdatera uppskjutna effekter
|
||||
Deferred Effects Uppskjutna effekter
|
||||
@msg no plug-in effect is deferred ingen plugineffekt är uppskjuten
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6754,3 +6754,9 @@ breakText Glyphs Glifler
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} ana bilgisayar çağrısı
|
||||
{dropped} earlier lines dropped, {cut} lines cut {dropped} eski satır atıldı, {cut} satır kesildi
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Ertelenen Efektleri Yenile
|
||||
Deferred Effects Ertelenen Efektler
|
||||
@msg no plug-in effect is deferred ertelenen eklenti efekti yok
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6399,3 +6399,9 @@ breakText Glyphs Гліфи
|
||||
{ms} ms, {calls} host calls {ms} мс, викликів хоста: {calls}
|
||||
{dropped} earlier lines dropped, {cut} lines cut Відкинуто попередніх рядків: {dropped}, обрізано рядків: {cut}
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Оновити відкладені ефекти
|
||||
Deferred Effects Відкладені ефекти
|
||||
@msg no plug-in effect is deferred немає відкладених ефектів модулів
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -6400,3 +6400,9 @@ breakText Glyphs Glyph
|
||||
{ms} ms, {calls} host calls {ms} ms, {calls} lệnh gọi máy chủ
|
||||
{dropped} earlier lines dropped, {cut} lines cut Đã bỏ {dropped} dòng cũ, đã cắt {cut} dòng
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last`
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects Làm mới hiệu ứng bị hoãn
|
||||
Deferred Effects Hiệu ứng bị hoãn
|
||||
@msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -6636,3 +6636,9 @@ breakText Glyphs 字形
|
||||
{ms} ms, {calls} host calls {ms} 毫秒,宿主调用 {calls} 次
|
||||
{dropped} earlier lines dropped, {cut} lines cut 已丢弃 {dropped} 行较早内容,已截断 {cut} 行
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects 刷新延迟的效果
|
||||
Deferred Effects 延迟的效果
|
||||
@msg no plug-in effect is deferred 没有延迟的增效工具效果
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6434,3 +6434,9 @@ breakText Glyphs 字符
|
||||
{ms} ms, {calls} host calls {ms} 毫秒,主機呼叫 {calls} 次
|
||||
{dropped} earlier lines dropped, {cut} lines cut 已捨棄 {dropped} 行較早內容,已截斷 {cut} 行
|
||||
@msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後
|
||||
|
||||
# Deferred plug-in effects
|
||||
Refresh Deferred Effects 重新整理延後的效果
|
||||
Deferred Effects 延後的效果
|
||||
@msg no plug-in effect is deferred 沒有延後的增效模組效果
|
||||
@msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -136,6 +136,7 @@ pub(super) fn effect_menu() -> Vec<Item> {
|
||||
c("Last Effect…", "effect.last"),
|
||||
Sep,
|
||||
c("Document Raster Effects Settings…", "document.rasterEffectsSettings"),
|
||||
c("Refresh Deferred Effects", "effect.refreshDeferred"),
|
||||
Sep,
|
||||
Item::Header("Vector Effects"),
|
||||
];
|
||||
|
||||
@@ -24,6 +24,9 @@ pub struct Job {
|
||||
/// The automation roots in force where the job was made: the render reads linked files under
|
||||
/// them, as it would on that thread.
|
||||
pub scope: ScopeHandle,
|
||||
/// The document's deferred live-effect plug-in stages and first-view budget: the render runs
|
||||
/// inside their view ([`vectorcraft_plugins::deferred::Deferrals::view`]).
|
||||
pub effects: vectorcraft_plugins::deferred::Deferrals,
|
||||
/// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]).
|
||||
/// Off: it renders the whole frame.
|
||||
pub may_copy: bool,
|
||||
@@ -71,7 +74,7 @@ impl Worker {
|
||||
job = newer;
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job));
|
||||
let frame = job.scope.clone().run(|| job.effects.view(|| crate::canvas::render_job(&mut r, &mut kept, &job)));
|
||||
let ms = crate::now_ms() - t0;
|
||||
let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused };
|
||||
if done_tx.send(done).is_err() {
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. |
|
||||
@@ -667,6 +668,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
- **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate.
|
||||
|
||||
- **Done (#5239):** a double-click on a ruler adds a guide at the pointer (vertical on the top ruler, horizontal on the left one, swapped in a view turned 90°; both clicks on that ruler) through `guide.add`, unsnapped and in one undo step; no scored gap or estimate reassessed.
|
||||
- **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen.
|
||||
- **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged.
|
||||
- **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged.
|
||||
@@ -685,7 +687,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
- **Missing:** the document object model for scripts and script directives (#1183 is
|
||||
- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is
|
||||
open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry,
|
||||
File › Scripts lists the scripts of the Scripts folder, and
|
||||
Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and
|
||||
|
||||
+36
-5
@@ -1595,9 +1595,20 @@ VectorCraft reads them by these rules of its own:
|
||||
folder of the file that includes. A later `#includepath` replaces the list.
|
||||
- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an
|
||||
included file does not rename the run.
|
||||
- `#target` takes any value and does not change how the script runs. `#strict off` is taken.
|
||||
- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name`
|
||||
with an unknown-directive error. A `//@` line with another name is a comment.
|
||||
- `#target` takes any value and does not change how the script runs.
|
||||
- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only
|
||||
property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's
|
||||
own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take
|
||||
no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or
|
||||
`Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5
|
||||
strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with
|
||||
`'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line.
|
||||
- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval`
|
||||
and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of
|
||||
its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the
|
||||
caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`.
|
||||
- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an
|
||||
unknown-directive error. A `//@` line with another name is a comment.
|
||||
- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and
|
||||
the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a
|
||||
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
|
||||
@@ -5232,7 +5243,8 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o
|
||||
When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image
|
||||
without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame
|
||||
of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see
|
||||
Threading text from the in and out ports), and the other frames keep the text they hold.
|
||||
Threading text from the in and out ports), and the other frames keep the text they hold. `object.rasterize`
|
||||
refuses an image larger than 64 million pixels or longer than 32,768 pixels a side, and the art stays as it was.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}
|
||||
@@ -7301,7 +7313,26 @@ id `plugin.<id>`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc
|
||||
`effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api`
|
||||
and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them
|
||||
as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s
|
||||
`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
`lastError` names the plug-in and the refused field. While the app draws a document on screen, the live-effect plug-ins
|
||||
of its first view share a 2,000 ms budget (VectorCraft's own value), and each run also keeps to its own 5,000 ms limit:
|
||||
once the budget is spent no run starts, and a result that comes back after it is not used. A stage left out for want
|
||||
of time, or one that times out, fails or has no plug-in, draws its input art unchanged and is deferred; the status bar
|
||||
then says how many (`Opened with 3 plug-in effects deferred. Effect › Refresh Deferred Effects runs them.`). Later
|
||||
drawings leave a deferred stage as it is until its input or parameters change, which runs it under the per-call limit.
|
||||
`effect.deferredStatus {}` returns `{budgetMs: 2000, effects: [{objectId, effectIndex, item?, pluginId, reason}]}` for
|
||||
the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke
|
||||
whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list
|
||||
when none is deferred). A stage whose object is deleted, or whose plug-in effect is
|
||||
removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh
|
||||
Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them)
|
||||
again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left
|
||||
out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn,
|
||||
and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that
|
||||
timed out, failed or have no plug-in. `id` names one object in place of
|
||||
`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand
|
||||
Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only
|
||||
deferred for time, and the canvas then draws the result. The web build has no clock for the budget: there the first
|
||||
view runs no plug-in effect, and Refresh Deferred Effects runs them. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
|
||||
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
|
||||
that no built-in format has. An importer adds a read-only file format `plugin.<id>` (listed by `document.formats`
|
||||
with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?:
|
||||
|
||||
@@ -137,6 +137,19 @@ An export runs where the export runs: with Background Export on, on the export's
|
||||
plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the
|
||||
open it runs in is cancelled, and nothing opens then.
|
||||
|
||||
### Live effects while a document opens
|
||||
|
||||
While the app draws a document on screen, the live-effect runs of its first view share a 2,000 ms budget
|
||||
(`vectorcraft_plugins::deferred::OPENING_BUDGET_MS`, VectorCraft's own value), measured from the first run. Each run
|
||||
takes the lesser of its 5 s limit and what is left of the budget; once the budget is spent no run starts, and a result
|
||||
that comes back after it is not used. A stage left out this way, or one that times out, traps, returns output the host
|
||||
refuses or has no plug-in, draws its input art unchanged and is deferred. The status bar then says how many stages
|
||||
are deferred, and later drawings leave them as they are until their input or parameters change. Effect › Refresh
|
||||
Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget, and `effect.deferredStatus` lists
|
||||
them with the reason (`budget`, `timeout`, `missing`, `failed`, `safeMode`). Each document has its own budget.
|
||||
Exports, Expand Appearance and commands run every stage under the 5 s limit. The web build has no clock for the
|
||||
budget, so there the first view runs no live effect and Refresh Deferred Effects runs them.
|
||||
|
||||
### Heavy filters
|
||||
|
||||
Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
>
|
||||
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
|
||||
>
|
||||
@@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
@@ -237,6 +238,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Snapping panel/popover consolidating the snap options (30.0) | partial | Control bar › Snapping provides quick access through existing view commands and prefs.set, including Snap to Glyph and Alignment Guides (#6164); no View › Snapping submenu. Floating-panel parity remains unverified. |
|
||||
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
|
||||
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides |
|
||||
| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` |
|
||||
| Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) |
|
||||
| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute |
|
||||
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
|
||||
@@ -509,6 +511,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). |
|
||||
| 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Symbol libraries: Inventory Icons joins Basic Shapes, UI Frames and Map Markers as a fourth built-in library (Sword, Shield, Potion, Key, Gem; no default 9-slice). |
|
||||
| 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user