Merge main into fix/inspect-library-ids-whole

This commit is contained in:
dotCooCoo
2026-11-11 20:32:17 -08:00
5 changed files with 103 additions and 5 deletions
+3 -3
View File
@@ -740,17 +740,17 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
// Inflate's eye is placed for every shape together, as the solids are.
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
} else {
let mut added = vec![];
for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) {
for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) {
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
}
added
};
for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) {
for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) {
if ink.opacity <= 0.0 {
continue;
}
+6 -2
View File
@@ -673,10 +673,14 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
/// document.
/// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own
/// commit applies it; when it fails, the interaction is canceled.
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
s.commit_interaction()?;
match s.unapplied_interaction_preview().filter(|_| !s.previewing) {
Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?,
_ => s.commit_interaction()?,
}
if typing {
s.set_tool_option("endTyping", &Value::Bool(true));
}
+57
View File
@@ -3784,3 +3784,60 @@ fn a_filter_that_calls_commands_is_given_the_edit_in_progress() {
assert_eq!(x, json!(17.0), "undoing the filter brings back the moved rectangle");
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// Installs a filter `id` that calls commands and hands back what it is given, with `commands` (a
/// JSON list of command specs) in its manifest.
fn install_echo_calling(s: &mut Session, id: &str, commands: &str) {
let grant = json!(["doc.read", "doc.write", "commands"]);
let manifest = format!(
r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{grant},"commandAllowlist":["select.all"],"commands":{commands}}}"#
);
let wasm = wat::parse_str(tpl::module(&manifest, tpl::ECHO, "", 1)).unwrap();
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant, "replace": true}));
}
/// With Real-time Drawing off, a 50 × 50 rectangle at (10, 10) whose move by 7 pt is still being
/// dragged: the tool has stored the move, which the document does not hold yet.
fn deferred_move(s: &mut Session) -> NodeId {
let r = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());
run(s, "prefs.set", json!({"key": "realTimeDrawing", "value": false}));
let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0}));
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap();
assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 10.0, "the move is not applied while it is dragged");
r
}
/// The rectangle a run named `label` during [`deferred_move`] left: moved, with the interaction
/// ended and the move an undo step of its own before the run's.
fn assert_moved_once(s: &Session, r: NodeId, label: &str) {
assert!(!s.in_interaction());
assert_eq!(node(s, r).geometric_bounds().unwrap().x0, 17.0, "the filter was given the moved rectangle");
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, [label, "Move"]);
}
/// With Real-time Drawing off, `plugin.run` of a filter that calls commands during a drag applies
/// the dragged move first and gives the filter the moved rectangle, which it hands back as it was.
#[test]
fn a_filter_that_calls_commands_is_given_a_deferred_move() {
let id = "test.engine.callsdeferred";
let mut s = session();
install_echo_calling(&mut s, id, "[]");
let r = deferred_move(&mut s);
run(&mut s, "plugin.run", json!({"id": id}));
assert_moved_once(&s, r, &format!("Test {id}"));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A plug-in command that calls commands, run with Real-time Drawing off during a drag, is given
/// the moved rectangle as `plugin.run` is.
#[test]
fn a_plugin_command_that_calls_commands_is_given_a_deferred_move() {
let id = "test.engine.callsdeferredcmd";
let mut s = session();
install_echo_calling(&mut s, id, r#"[{"id":"same","label":"Same"}]"#);
let r = deferred_move(&mut s);
run(&mut s, &format!("plugin.{id}.same"), json!({}));
assert_moved_once(&s, r, "Same");
run(&mut s, "plugin.remove", json!({"id": id}));
}
+36
View File
@@ -2047,6 +2047,42 @@ fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() {
assert_eq!(paths(&s), 1, "the deferred rectangle is committed");
}
/// With Real-time Drawing off, Undo during a drag keeps the dragged move as its own step and takes
/// back only that: the rectangle drawn before stays, and Redo brings the move back.
#[test]
fn undo_during_a_deferred_drag_takes_back_only_the_drag() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
set_pref(&mut s, "realTimeDrawing", json!(false));
let x = |s: &Session| s.doc().unwrap().doc.node(r).and_then(vectorcraft_doc::Node::geometric_bounds).map(|b| b.x0);
let move_by = vectorcraft_tools::Action::Preview("object.move".into(), json!({"dx": 7, "dy": 0}));
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Move".into()), move_by]).unwrap();
assert_eq!(x(&s), Some(10.0));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.in_interaction());
assert_eq!(x(&s), Some(10.0), "the rectangle stays");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(x(&s), Some(17.0), "Redo brings the move back");
}
/// Undo during a drag whose stored preview fails cancels the drag, as its release would, and then
/// takes back the step before it.
#[test]
fn undo_during_a_deferred_drag_that_fails_cancels_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
set_pref(&mut s, "realTimeDrawing", json!(false));
let bad = vectorcraft_tools::Action::Preview("no.such.command".into(), json!({}));
s.apply_actions(vec![vectorcraft_tools::Action::Begin("Bad".into()), bad]).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.in_interaction());
let d = s.doc().unwrap();
assert_eq!(d.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum::<usize>(), 0, "the rectangle is undone");
assert!(d.history.undo.iter().all(|e| e.label != "Bad"));
}
/// Repeated overlay redraws with Real-time Drawing off reuse the cached deferred feedback.
#[test]
fn deferred_preview_overlays_are_cached_across_redraws() {
+1
View File
@@ -1683,6 +1683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.