Merge pull request #6557 from dotCooCoo/feat/script-alert-confirm-prompt

Scripts: alert, confirm and prompt open dialogs with Stop, and headless runs take their answers from script.run
This commit is contained in:
BFlat
2026-11-11 17:13:46 +07:00
committed by GitHub
48 changed files with 1594 additions and 57 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -207,7 +207,7 @@ impl Console {
/// `c` as the console shows it: a control character as its Control Picture, a line or paragraph
/// separator as U+2424, anything else as it is.
fn shown(c: char) -> char {
pub(crate) fn shown(c: char) -> char {
match c {
'\u{0}'..='\u{1f}' => char::from_u32(0x2400 + c as u32).unwrap_or('\u{fffd}'),
'\u{7f}' => '\u{2421}',
+364
View File
@@ -0,0 +1,364 @@
//! `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and
//! `prompt(message, preset[, title])`: the dialogs a script opens while it runs.
//!
//! A message and a preset arrive as the string `String(value)` gives, a title the same way unless
//! it is left out or `undefined` ([`DEFAULT_TITLE`]), and `errorIcon` and `noAsDflt` as the
//! boolean `Boolean(value)` gives. `alert` returns `undefined`, `confirm` `true` for Yes and
//! `false` for No, and `prompt` the text in its field for OK and `null` for Cancel.
//!
//! In the app the run waits at the call for the person's answer, and spends no fuel while it
//! waits; every dialog also has Stop, which ends the run and puts everything back. A run with
//! nobody to ask takes its answers, in order, from `script.run`'s `answers`; a call with no
//! answer left, or with the next answer for another kind of dialog, ends the run. The report's
//! `dialogs` lists each dialog and its answer.
//!
//! The values here are VectorCraft's own limits: [`MAX_MESSAGE_BYTES`], [`MAX_TITLE_BYTES`],
//! [`MAX_PROMPT_BYTES`], [`MAX_ANSWERS`], [`MAX_ANSWERS_BYTES`], [`MAX_RECORDS`] and
//! [`MAX_RECORD_BYTES`].
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use serde_json::{Map, Value, json};
use vectorcraft_js::{Arg, Data, HostError, HostFunction, Realm, RunError};
use super::{Host, console};
/// The most bytes (UTF-8) of a dialog's message.
pub const MAX_MESSAGE_BYTES: usize = 16 << 10;
/// The most bytes (UTF-8) of a dialog's title.
pub const MAX_TITLE_BYTES: usize = 256;
/// The most bytes (UTF-8) of a prompt's preset and of the text it returns.
pub const MAX_PROMPT_BYTES: usize = 16 << 10;
/// The most answers `script.run` takes.
pub const MAX_ANSWERS: usize = 10_000;
/// The most bytes of `answers`, as JSON.
pub const MAX_ANSWERS_BYTES: usize = 1 << 20;
/// The most dialogs a report lists.
pub const MAX_RECORDS: usize = 10_000;
/// The most bytes of the dialogs a report lists, as JSON.
pub const MAX_RECORD_BYTES: usize = 1 << 20;
/// A dialog's title when the script gives none, in English; the app translates it when it shows it.
pub const DEFAULT_TITLE: &str = "Script Alert";
/// Which dialog a script opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogKind {
Alert,
Confirm,
Prompt,
}
impl DialogKind {
/// The function's name, which is also the `kind` of an answer and of a report's dialog.
pub fn id(self) -> &'static str {
match self {
Self::Alert => "alert",
Self::Confirm => "confirm",
Self::Prompt => "prompt",
}
}
}
/// A dialog a script opened, which waits for its answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptDialog {
pub kind: DialogKind,
pub message: String,
/// `None`: the script gave no title ([`DEFAULT_TITLE`]).
pub title: Option<String>,
/// `alert`: show the error icon.
pub error_icon: bool,
/// `confirm`: No is the default button.
pub no_as_default: bool,
/// `prompt`: the text its field starts with.
pub preset: String,
}
/// The answer to a [`ScriptDialog`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DialogAnswer {
/// OK, for `alert`.
Ok,
/// Yes (`true`) or No, for `confirm`.
Confirm(bool),
/// OK with the field's text, or Cancel (`None`), for `prompt`.
Prompt(Option<String>),
}
impl DialogAnswer {
pub fn kind(&self) -> DialogKind {
match self {
Self::Ok => DialogKind::Alert,
Self::Confirm(_) => DialogKind::Confirm,
Self::Prompt(_) => DialogKind::Prompt,
}
}
/// What the function returns.
fn data(&self) -> Data {
match self {
Self::Ok => Data::Undefined,
Self::Confirm(b) => Data::Bool(*b),
Self::Prompt(Some(t)) => Data::String(t.clone()),
Self::Prompt(None) => Data::Null,
}
}
/// The answer as a report gives it: `"ok"`, a boolean, a string or `null`.
fn json(&self) -> Value {
match self {
Self::Ok => json!("ok"),
Self::Confirm(b) => json!(b),
Self::Prompt(t) => json!(t),
}
}
}
/// `text` as a dialog shows it. With `lines`, a line feed, a carriage return or the two together
/// is a line break; every other control character, and with `lines` false every line break too,
/// shows as its Unicode Control Picture, as the Script Console shows it.
pub fn shown_text(text: &str, lines: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' if lines => {
chars.next_if_eq(&'\n');
out.push('\n');
}
'\n' if lines => out.push('\n'),
c => out.push(console::shown(c)),
}
}
out
}
/// The longest start of `text` that is at most `max` bytes, cut at a character boundary.
pub fn cut_to(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or_default()
}
/// `script.run`'s `answers`: a list of `{kind: "alert", value: "ok"}`, `{kind: "confirm", value:
/// true | false}` and `{kind: "prompt", value: text | null}`. An empty object, which the parameter
/// took before dialogs existed, is an empty list.
pub(crate) fn parse_answers(v: Option<&Value>) -> Result<Vec<DialogAnswer>, String> {
let list = match v {
None => return Ok(Vec::new()),
Some(Value::Object(o)) if o.is_empty() => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err("`answers` must be a list of answers".into()),
};
if list.len() > MAX_ANSWERS {
return Err(format!("`answers` has more than {MAX_ANSWERS} answers"));
}
if serde_json::to_vec(list).map_or(true, |b| b.len() > MAX_ANSWERS_BYTES) {
return Err("`answers` is larger than 1 MiB".into());
}
list.iter().enumerate().map(|(i, a)| answer(a).map_err(|e| format!("`answers[{i}]` {e}"))).collect()
}
fn answer(a: &Value) -> Result<DialogAnswer, &'static str> {
let Some(o) = a.as_object() else { return Err("must be an object with `kind` and `value`") };
if o.len() != 2 || !o.contains_key("value") {
return Err("must have exactly `kind` and `value`");
}
match (o.get("kind").and_then(Value::as_str), &o["value"]) {
(Some("alert"), Value::String(s)) if s == "ok" => Ok(DialogAnswer::Ok),
(Some("alert"), _) => Err("is an alert's answer, whose `value` must be \"ok\""),
(Some("confirm"), Value::Bool(b)) => Ok(DialogAnswer::Confirm(*b)),
(Some("confirm"), _) => Err("is a confirm's answer, whose `value` must be true or false"),
(Some("prompt"), Value::Null) => Ok(DialogAnswer::Prompt(None)),
(Some("prompt"), Value::String(s)) if s.len() <= MAX_PROMPT_BYTES => Ok(DialogAnswer::Prompt(Some(s.clone()))),
(Some("prompt"), Value::String(_)) => Err("is longer than 16 KiB"),
(Some("prompt"), _) => Err("is a prompt's answer, whose `value` must be a string or null"),
_ => Err("must have `kind` alert, confirm or prompt"),
}
}
/// The dialogs of one run: the answers left for a run with nobody to ask, the dialog that waits
/// for the person's answer, and the list the report gives.
pub(crate) struct Dialogs {
/// Answers come from the person, not from `answers`.
framed: bool,
/// The realm's [`Realm::wait_flag`].
can_wait: Rc<Cell<bool>>,
answers: RefCell<VecDeque<DialogAnswer>>,
waiting: RefCell<Option<ScriptDialog>>,
/// The error that ended the run when an answer could not reach the script.
failed: RefCell<Option<RunError>>,
records: RefCell<Vec<Value>>,
record_bytes: Cell<usize>,
count: Cell<u64>,
truncated: Cell<bool>,
}
impl Dialogs {
pub(crate) fn new(framed: bool, realm: &Realm, answers: Vec<DialogAnswer>) -> Self {
Self {
framed,
can_wait: realm.wait_flag(),
answers: RefCell::new(answers.into()),
waiting: RefCell::default(),
failed: RefCell::default(),
records: RefCell::default(),
record_bytes: Cell::new(0),
count: Cell::new(0),
truncated: Cell::new(false),
}
}
/// The dialog that waits for its answer.
pub(crate) fn waiting(&self) -> Option<ScriptDialog> {
self.waiting.borrow().clone()
}
pub(crate) fn is_waiting(&self) -> bool {
self.waiting.borrow().is_some()
}
/// The error that ended the run when its answer could not reach the script.
pub(crate) fn take_failure(&self) -> Option<RunError> {
self.failed.borrow_mut().take()
}
/// How many of `answers` the run did not use.
pub(crate) fn unused(&self) -> usize {
self.answers.borrow().len()
}
/// Gives the waiting dialog `answer`, which must be for its kind and, for a prompt, at most
/// [`MAX_PROMPT_BYTES`]. → false when it is not, or nothing waits.
pub(crate) fn answer(&self, realm: &mut Realm, answer: DialogAnswer) -> bool {
let fits = match &answer {
DialogAnswer::Prompt(Some(t)) => t.len() <= MAX_PROMPT_BYTES,
_ => true,
};
let mut waiting = self.waiting.borrow_mut();
let Some(d) = waiting.as_ref().filter(|d| d.kind == answer.kind() && fits) else { return false };
self.record(d, answer.json());
*waiting = None;
if let Err(e) = realm.answer(answer.data()) {
*self.failed.borrow_mut() = Some(e);
}
true
}
/// The run ends while a dialog waits: the report lists it with the answer `"stop"`.
pub(crate) fn stopped(&self) {
if let Some(d) = self.waiting.borrow_mut().take() {
self.record(&d, json!("stop"));
}
}
fn record(&self, d: &ScriptDialog, answer: Value) {
self.count.set(self.count.get().saturating_add(1));
let mut r = Map::new();
r.insert("kind".into(), json!(d.kind.id()));
r.insert("message".into(), json!(d.message));
r.insert("title".into(), json!(d.title.as_deref().unwrap_or(DEFAULT_TITLE)));
match d.kind {
DialogKind::Alert => r.insert("errorIcon".into(), json!(d.error_icon)),
DialogKind::Confirm => r.insert("noAsDefault".into(), json!(d.no_as_default)),
DialogKind::Prompt => r.insert("preset".into(), json!(d.preset)),
};
r.insert("answer".into(), answer);
let r = Value::Object(r);
let bytes = serde_json::to_vec(&r).map_or(usize::MAX, |b| b.len());
let mut records = self.records.borrow_mut();
if self.truncated.get() || records.len() >= MAX_RECORDS || self.record_bytes.get().saturating_add(bytes) > MAX_RECORD_BYTES {
self.truncated.set(true);
return;
}
self.record_bytes.set(self.record_bytes.get() + bytes);
records.push(r);
}
/// Adds `dialogs`, `dialogCount` and `dialogsTruncated` to a report.
pub(crate) fn report(&self, report: &mut Map<String, Value>) {
report.insert("dialogs".into(), Value::Array(self.records.borrow().clone()));
report.insert("dialogCount".into(), json!(self.count.get()));
report.insert("dialogsTruncated".into(), json!(self.truncated.get()));
}
}
/// Adds `alert`, `confirm` and `prompt` to the realm.
pub(super) fn install(realm: &mut Realm, host: &Rc<Host>) -> Result<(), RunError> {
use Arg::{Bool, Text, TextOrUndefined};
let call = |kind: DialogKind| -> HostFunction {
let h = host.clone();
Rc::new(move |args| open(&h, kind, &args))
};
realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?;
realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?;
realm.define_host_function("prompt", vec![Text, Text, TextOrUndefined], call(DialogKind::Prompt))
}
fn text(args: &[Data], i: usize) -> Option<String> {
match args.get(i) {
Some(Data::String(s)) => Some(s.clone()),
_ => None,
}
}
fn flag(args: &[Data], i: usize) -> bool {
matches!(args.get(i), Some(Data::Bool(true)))
}
/// A call of `alert`, `confirm` or `prompt`.
fn open(host: &Host, kind: DialogKind, args: &[Data]) -> Result<Data, HostError> {
let name = kind.id();
host.charge_call(args.iter().map(super::data_size).sum())?;
let Some(message) = text(args, 0) else { return Err(HostError::Type(format!("{name}: give a message"))) };
let (title, error_icon, no_as_default, preset) = match kind {
DialogKind::Alert => (text(args, 1), flag(args, 2), false, String::new()),
DialogKind::Confirm => (text(args, 2), false, flag(args, 1), String::new()),
DialogKind::Prompt => {
let Some(preset) = text(args, 1) else { return Err(HostError::Type(format!("{name}: give the text the field starts with"))) };
(text(args, 2), false, false, preset)
}
};
if message.len() > MAX_MESSAGE_BYTES {
return Err(HostError::Range(format!("{name}: the message is longer than 16 KiB")));
}
if title.as_ref().is_some_and(|t| t.len() > MAX_TITLE_BYTES) {
return Err(HostError::Range(format!("{name}: the title is longer than 256 bytes")));
}
if preset.len() > MAX_PROMPT_BYTES {
return Err(HostError::Range(format!("{name}: the text the field starts with is longer than 16 KiB")));
}
let dialog = ScriptDialog { kind, message, title, error_icon, no_as_default, preset };
let d = &host.dialogs;
if d.framed {
if !d.can_wait.get() {
return Err(HostError::Error(format!(
"{name}: a dialog cannot open inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"
)));
}
*d.waiting.borrow_mut() = Some(dialog);
return Err(HostError::Wait);
}
let next = d.answers.borrow_mut().pop_front();
match next {
Some(a) if a.kind() == kind => {
d.record(&dialog, a.json());
Ok(a.data())
}
Some(a) => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: the next answer in `answers` is for {}, not {name}", a.kind().id())))
}
None => {
d.record(&dialog, json!("missing"));
Err(HostError::Abort(format!("{name}: `answers` has no answer left for this dialog")))
}
}
}
+52 -6
View File
@@ -32,10 +32,16 @@
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
//! line and a report line; the run's report holds its own output and error, its time, its host
//! calls by kind and its slowest commands.
//!
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
pub mod console;
pub mod dialogs;
pub mod include;
pub use dialogs::{DialogAnswer, DialogKind, ScriptDialog};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
@@ -88,7 +94,7 @@ const MAX_BRIDGE_BYTES: u64 = 4 << 30;
/// The version `vc.api.version` gives.
pub const API_VERSION: &str = "1.0";
/// The features `vc.api.has` knows.
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console"];
pub const API_FEATURES: &[&str] = &["run", "document", "commands", "log", "args", "console", "dialogs"];
/// The most commands a run's report lists in `slowestCommands`.
pub const SLOWEST_COMMANDS: usize = 5;
@@ -147,6 +153,8 @@ pub(crate) struct Request {
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
/// The answers to the dialogs of a run with nobody to ask, in order.
pub answers: Vec<DialogAnswer>,
}
thread_local! {
@@ -480,6 +488,8 @@ struct Host {
commands: RefCell<HashMap<&'static str, (u64, u128, u128)>>,
/// The time the run's slices took, in nanoseconds.
run_nanos: Cell<u128>,
/// `alert`, `confirm` and `prompt`: their answers and the list the report gives.
dialogs: dialogs::Dialogs,
/// The files the run reads: its includes and `$.evalFile`'s.
files: RefCell<include::Files>,
/// Where the lines of the script's expanded source came from.
@@ -1061,7 +1071,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str {
/// `message`, with what to do about a failure that comes from a script written for another program.
fn explained(kind: &RunErrorKind, message: &str) -> String {
const HOST_OBJECTS: &[&str] = &["app", "alert", "confirm", "prompt", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
let names_host_object =
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
if *kind == RunErrorKind::Thrown && names_host_object {
@@ -1163,6 +1173,23 @@ impl Session {
!self.script_stop.is_set() && self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.waiting.get())).unwrap_or(false)
}
/// The dialog (`alert`, `confirm` or `prompt`) the running script waits at for the person's
/// answer. A run asked to stop no longer waits: the next [`Session::script_pump`] ends it.
pub fn script_dialog(&self) -> Option<ScriptDialog> {
if self.script_stop.is_set() {
return None;
}
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.host.dialogs.waiting())).flatten()
}
/// Gives the dialog the running script waits at its answer; the next [`Session::script_pump`]
/// goes on from the call. → false when nothing waits, the answer is for another kind of
/// dialog, or a prompt's text is longer than [`dialogs::MAX_PROMPT_BYTES`].
pub fn script_answer(&mut self, answer: DialogAnswer) -> bool {
let Some(k) = self.script_key.as_ref().filter(|_| !self.script_stop.is_set()) else { return false };
with_job(k, |j| j.host.dialogs.answer(&mut j.realm, answer)).unwrap_or(false)
}
/// The name of the running script.
pub fn script_name(&self) -> Option<String> {
self.script_key.as_ref().and_then(|k| with_job(k, |j| j.name.clone()))
@@ -1270,6 +1297,7 @@ impl Session {
counts: Counts::default(),
commands: RefCell::default(),
run_nanos: Cell::new(0),
dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers),
files: RefCell::new(files),
script_map: RefCell::default(),
eval_maps: RefCell::default(),
@@ -1286,6 +1314,7 @@ impl Session {
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
.and_then(|()| dialogs::install(&mut realm, &host))
.and_then(|()| realm.start(&expanded.source))
.map_err(|e| located(&host, realm.error_source(), &e))
}
@@ -1312,11 +1341,20 @@ impl Session {
let Some(mut job) = JOBS.with(|jobs| jobs.borrow_mut().remove(&key.0)) else {
return Some(Err(EngineError::Other(JOB_GONE.to_string())));
};
if job.host.waiting.get() && !self.script_stop.is_set() {
if let Some(e) = job.host.dialogs.take_failure() {
return Some(self.script_failed(job, &e));
}
if (job.host.waiting.get() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() {
JOBS.with(|jobs| jobs.borrow_mut().insert(key.0, job));
self.script_key = Some(key);
return None;
}
// A stop while a dialog waits ends the run there; the interpreter cannot go on from the call.
if job.realm.waits() {
let kind = LimitKind::Interrupted;
let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 };
return Some(self.script_failed(job, &e));
}
if vectorcraft_doc::cancel::check().is_err() {
self.script_stop.set();
}
@@ -1333,6 +1371,12 @@ impl Session {
self.script_key = Some(key);
None
}
Ok(Ok(Progress::Done(_))) if job.host.dialogs.unused() > 0 => {
let n = job.host.dialogs.unused();
let kind = LimitKind::Host;
let message = format!("`answers` has {n} answer{} the script did not use", if n == 1 { "" } else { "s" });
Some(self.script_failed(job, &RunError { kind: RunErrorKind::Limit(kind), message, line: 0, column: 0 }))
}
Ok(Ok(Progress::Done(_))) => {
let r = self.script_commit(job);
self.trim_histories();
@@ -1342,6 +1386,7 @@ impl Session {
}
fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> {
job.host.dialogs.stopped();
let fuel_left = job.realm.fuel_left();
let (e, file) = located(&job.host, job.realm.error_source(), e);
let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left));
@@ -1438,7 +1483,7 @@ impl Session {
if let Some(result) = self.script_pump() {
return result;
}
if self.script_waiting() {
if self.script_waiting() || self.script_dialog().is_some() {
self.script_stop.set();
}
}
@@ -1504,8 +1549,8 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands and
/// console to `report`.
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
/// console and dialogs to `report`.
fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::Ending) {
let mut pending = std::mem::take(&mut *host.pending.borrow_mut());
if !pending.is_empty() {
@@ -1539,6 +1584,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
o.insert("hostCallCounts".into(), host.counts.json());
o.insert("slowestCommands".into(), Value::Array(slow_json));
o.insert("console".into(), host.console.borrow().report());
host.dialogs.report(o);
}
}
+1
View File
@@ -151,6 +151,7 @@ mod save;
mod saveoptions;
mod scalestrokes;
mod script_console;
mod script_dialogs;
mod script_folder;
mod script_include;
mod script_run;
+289
View File
@@ -0,0 +1,289 @@
//! `alert`, `confirm` and `prompt` in a script: their arguments and results, the answers a run with
//! nobody to ask takes from `answers`, the wait in the app, Stop, and the report's `dialogs`.
use serde_json::{Value, json};
use crate::script::{DialogAnswer, DialogKind, ScriptDialog, dialogs};
use crate::*;
/// One open document with one rectangle.
fn one_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s
}
fn art(s: &Session) -> (String, usize) {
let d = &s.documents()[0];
(format!("{:?}", d.doc.layers), d.history.undo.len())
}
fn headless(s: &mut Session, source: &str, answers: Value) -> Result<Value> {
s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": answers}))
}
/// A run in the app of `source`, one slice in.
fn framed(s: &mut Session, source: &str) {
s.script_frames = true;
assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true);
assert!(s.script_pump().is_none());
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
if s.script_dialog().is_some() {
return None;
}
if let Some(r) = s.script_pump() {
return Some(r);
}
}
panic!("the run neither ended nor waited");
}
fn answers(list: &[Value]) -> Value {
Value::Array(list.to_vec())
}
#[test]
fn notification_signatures_and_returns() {
let mut s = one_doc();
let source = "vc.log(typeof alert('a')); alert(42, 'Title', 1);\
vc.log(confirm('c')); vc.log(confirm('n', true, undefined));\
vc.log(prompt('p', 'pre')); vc.log(prompt({toString: function () { return 'own'; }}, '', 'T'));";
let r = headless(
&mut s,
source,
answers(&[
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "alert", "value": "ok"}),
json!({"kind": "confirm", "value": true}),
json!({"kind": "confirm", "value": false}),
json!({"kind": "prompt", "value": "typed"}),
json!({"kind": "prompt", "value": null}),
]),
)
.unwrap();
assert_eq!(r["log"], json!(["undefined", true, false, "typed", null]), "{r}");
assert_eq!(
r["dialogs"],
json!([
{"kind": "alert", "message": "a", "title": "Script Alert", "errorIcon": false, "answer": "ok"},
{"kind": "alert", "message": "42", "title": "Title", "errorIcon": true, "answer": "ok"},
{"kind": "confirm", "message": "c", "title": "Script Alert", "noAsDefault": false, "answer": true},
{"kind": "confirm", "message": "n", "title": "Script Alert", "noAsDefault": true, "answer": false},
{"kind": "prompt", "message": "p", "title": "Script Alert", "preset": "pre", "answer": "typed"},
{"kind": "prompt", "message": "own", "title": "T", "preset": "", "answer": null},
])
);
assert_eq!((r["dialogCount"].clone(), r["dialogsTruncated"].clone()), (json!(6), json!(false)));
let has = headless(&mut s, "vc.log(vc.api.has('dialogs'));", json!([])).unwrap();
assert_eq!(has["log"], json!([true]));
}
#[test]
fn headless_answers_follow_call_order() {
let mut s = one_doc();
let source = "var out = []; [1, 2].forEach(function (i) { out.push(confirm('q' + i)); }); out.push(prompt('r', 'x')); vc.log(out.join());";
let r = headless(
&mut s,
source,
answers(&[json!({"kind": "confirm", "value": false}), json!({"kind": "confirm", "value": true}), json!({"kind": "prompt", "value": "z"})]),
)
.unwrap();
assert_eq!(r["log"], json!(["false,true,z"]), "a callback of a built-in takes its answer too: {r}");
assert_eq!(headless(&mut s, "1", json!({})).unwrap()["dialogCount"], 0, "an empty object is an empty list");
}
#[test]
fn missing_or_mismatched_answer_rolls_back() {
let mut s = one_doc();
let before = art(&s);
let edit = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
for (source, given, says) in [
(format!("{edit} try {{ alert('x'); }} catch (e) {{ vc.log('caught'); }}"), json!([]), "no answer left"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "confirm", "value": true})]), "is for confirm, not alert"),
(format!("{edit} alert('x');"), answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})]), "1 answer"),
] {
let e = headless(&mut s, &source, given).unwrap_err().to_string();
assert!(e.contains(says), "{e}");
assert_eq!(art(&s), before, "{source}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "limit", "{report}");
assert_eq!(report["log"], json!([]), "a missing answer cannot be caught");
assert_eq!(report["dialogCount"], 1);
}
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "ok");
headless(&mut s, "alert('x')", json!([])).unwrap_err();
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "missing");
}
#[test]
fn invalid_answers_are_rejected_before_run() {
let mut s = one_doc();
let before = art(&s);
let many: Vec<Value> = (0..=dialogs::MAX_ANSWERS).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let big: Vec<Value> = (0..80).map(|_| json!({"kind": "prompt", "value": "x".repeat(16_000)})).collect();
for given in [
json!({"a": 1}),
json!("ok"),
json!([1]),
json!([{"kind": "alert"}]),
json!([{"kind": "alert", "value": true}]),
json!([{"kind": "confirm", "value": "yes"}]),
json!([{"kind": "prompt", "value": 3}]),
json!([{"kind": "prompt", "value": "x".repeat(dialogs::MAX_PROMPT_BYTES + 1)}]),
json!([{"kind": "other", "value": "ok"}]),
json!([{"kind": "alert", "value": "ok", "extra": 1}]),
Value::Array(many),
Value::Array(big),
] {
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5});";
let r = s.execute("script.run", &json!({"source": source, "answers": given}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
assert_eq!(art(&s), before);
}
s.script_frames = true;
let r = s.execute("script.run", &json!({"source": "1", "answers": [{"kind": "alert", "value": "ok"}]}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "a run in the app takes no answers: {r:?}");
assert!(!s.script_running());
}
#[test]
fn a_framed_run_waits_at_each_dialog_and_takes_the_persons_answer() {
let mut s = one_doc();
framed(
&mut s,
"vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); var a = confirm('Go?', true, 'Ask'); var b = prompt('Name', 'n'); vc.log([a, b]);",
);
let want = ScriptDialog {
kind: DialogKind::Confirm,
message: "Go?".into(),
title: Some("Ask".into()),
error_icon: false,
no_as_default: true,
preset: String::new(),
};
assert_eq!(s.script_dialog(), Some(want));
assert!(!s.script_answer(DialogAnswer::Ok), "an answer for another kind of dialog is refused");
assert!(s.script_answer(DialogAnswer::Confirm(true)));
assert!(!s.script_answer(DialogAnswer::Confirm(true)), "nothing waits until the run goes on");
assert!(pump(&mut s).is_none());
assert_eq!(s.script_dialog().unwrap().preset, "n");
assert!(!s.script_answer(DialogAnswer::Prompt(Some("x".repeat(dialogs::MAX_PROMPT_BYTES + 1)))));
assert!(s.script_answer(DialogAnswer::Prompt(Some("Ann".into()))));
let r = pump(&mut s).unwrap().unwrap();
assert_eq!(r["log"], json!([[true, "Ann"]]));
assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script", "a finished run is one undo step");
assert_eq!(r["dialogs"][1]["answer"], "Ann");
}
#[test]
fn prompt_cancel_returns_null() {
let mut s = one_doc();
framed(&mut s, "vc.log(prompt('Name', '') === null);");
assert!(s.script_answer(DialogAnswer::Prompt(None)));
assert_eq!(pump(&mut s).unwrap().unwrap()["log"], json!([true]));
}
#[test]
fn dialog_loop_stops_or_exhausts() {
let mut s = one_doc();
let before = art(&s);
let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); while (1) { alert('x'); }";
let three: Vec<Value> = (0..3).map(|_| json!({"kind": "alert", "value": "ok"})).collect();
let e = headless(&mut s, source, Value::Array(three)).unwrap_err().to_string();
assert!(e.contains("no answer left"), "{e}");
assert_eq!(s.script_report().unwrap()["dialogCount"], 4);
assert_eq!(art(&s), before);
framed(&mut s, source);
for _ in 0..3 {
assert!(s.script_answer(DialogAnswer::Ok));
assert!(pump(&mut s).is_none());
}
assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true);
assert_eq!(s.script_dialog(), None, "a run asked to stop no longer waits");
assert!(!s.script_answer(DialogAnswer::Ok));
assert!(s.script_pump().unwrap().is_err());
let report = s.script_report().unwrap();
assert_eq!((report["kind"].as_str(), report["dialogCount"].as_u64()), (Some("stopped"), Some(4)), "{report}");
assert_eq!(report["dialogs"][3]["answer"], "stop");
assert_eq!(art(&s), before, "Stop at the first or any later dialog leaves no undo step");
framed(&mut s, source);
assert!(s.script_abandon(), "quitting while a dialog waits abandons the run");
assert_eq!(s.script_report().unwrap()["dialogs"][0]["answer"], "stop");
assert_eq!(art(&s), before);
}
#[test]
fn dialog_text_limits_and_controls() {
let mut s = one_doc();
let source = format!(
"var out = [];\
function t(f) {{ try {{ f(); out.push('opened'); }} catch (e) {{ out.push(e.name); }} }}\
t(function () {{ alert('x'); }});\
t(function () {{ alert(new Array({m}).join('m')); }});\
t(function () {{ alert('m', new Array({t}).join('t')); }});\
t(function () {{ prompt('m', new Array({m}).join('p')); }});\
t(function () {{ alert(); }});\
t(function () {{ prompt('m'); }});\
t(function () {{ alert({{toString: function () {{ throw new Error('no'); }}}}); }});\
t(function () {{ alert(undefined); }});\
vc.log(out.join());",
m = dialogs::MAX_MESSAGE_BYTES + 2,
t = dialogs::MAX_TITLE_BYTES + 2,
);
let r = headless(&mut s, &source, answers(&[json!({"kind": "alert", "value": "ok"}), json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!(r["log"], json!(["opened,RangeError,RangeError,RangeError,TypeError,TypeError,Error,opened"]), "{r}");
assert_eq!(r["dialogs"][1]["message"], "undefined", "an explicit undefined message is a message");
assert_eq!(dialogs::shown_text("a\r\nb\rc\nd\te\u{7}\u{7f}", true), "a\nb\nc\nd\u{2409}e\u{2407}\u{2421}");
assert_eq!(dialogs::shown_text("a\nb", false), "a\u{240a}b", "a title stays on one line");
assert_eq!(dialogs::cut_to("aé", 2), "a", "a cut keeps whole characters");
}
#[test]
fn dialog_transcript_on_success_and_failure() {
let mut s = one_doc();
let r = headless(&mut s, "alert('one'); throw new Error('later');", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap_err();
assert!(r.to_string().contains("later"));
let report = s.script_report().unwrap();
assert_eq!(report["dialogs"], json!([{"kind": "alert", "message": "one", "title": "Script Alert", "errorIcon": false, "answer": "ok"}]));
let ok = headless(&mut s, "1", json!([])).unwrap();
assert_eq!((ok["dialogs"].clone(), ok["dialogCount"].clone()), (json!([]), json!(0)));
}
#[test]
fn dialog_wait_does_not_spend_fuel() {
let mut s = one_doc();
framed(&mut s, "alert('x'); 1");
for _ in 0..20 {
assert!(s.script_pump().is_none(), "the run waits");
}
assert!(s.script_answer(DialogAnswer::Ok));
let r = pump(&mut s).unwrap().unwrap();
let mut t = one_doc();
let plain = headless(&mut t, "alert('x'); 1", answers(&[json!({"kind": "alert", "value": "ok"})])).unwrap();
assert_eq!((r["fuelUsed"].clone(), r["hostCalls"].clone()), (plain["fuelUsed"].clone(), plain["hostCalls"].clone()), "{r}");
}
#[test]
fn second_run_is_busy_while_dialog_waits() {
let mut s = one_doc();
framed(&mut s, "alert('x');");
assert!(s.execute("script.run", &json!({"source": "1"})).is_err());
assert!(s.script_dialog().is_some(), "the first run still waits");
assert!(s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 1, "height": 1})).is_err(), "edits are refused while it waits");
}
#[test]
fn a_dialog_in_a_callback_throws_in_the_app() {
let mut s = one_doc();
s.script_frames = true;
let source = "var m = ''; [1].forEach(function () { try { alert('x'); } catch (e) { m = e.message; } }); vc.log(m);";
s.execute("script.run", &json!({"source": source})).unwrap();
let r = pump(&mut s).unwrap().unwrap();
assert!(r["log"][0].as_str().unwrap().contains("callback"), "{r}");
assert_eq!(r["dialogCount"], 0);
}
+56
View File
@@ -332,3 +332,59 @@ fn script_included_error_has_local_file_and_line() {
assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}");
let _ = std::fs::remove_dir_all(base);
}
/// Pumps the run in the app until it ends or waits at a dialog (`None`).
fn pump(s: &mut Session) -> Option<Result<Value>> {
for _ in 0..10_000 {
if s.script_dialog().is_some() {
return None;
}
if let Some(r) = s.script_pump() {
return Some(r);
}
}
panic!("the run neither ended nor waited");
}
#[test]
fn script_dialogs_in_included_and_evaluated_files() {
let base = folder("dialogs");
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\n");
write(&base.join("main/ev.jsx"), "alert('ev'); 7");
let text = "#include \"ask.jsxinc\"\nvar m = '';\ntry { m = $.evalFile('ev.jsx'); } catch (e) { m = e.message; }\nvc.log([a, m]);";
let main = base.join("main/main.jsx");
write(&main, text);
let path = main.to_string_lossy().to_string();
// In the app, an included file's dialog waits as the script's own does; one in a file
// $.evalFile runs cannot wait there and throws an error the script can catch.
let mut s = Session::new();
s.script_frames = true;
assert_eq!(s.execute("script.run", &json!({"path": path})).unwrap()["running"], true);
assert!(pump(&mut s).is_none());
assert_eq!(s.script_dialog().unwrap().message, "inc");
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
let r = pump(&mut s).unwrap().unwrap();
let m = r["log"][0][1].as_str().unwrap_or_default();
assert_eq!(r["log"][0][0], true, "{r}");
assert!(m.contains("alert") && m.contains("$.evalFile"), "the error names the call and why: {r}");
assert_eq!(r["dialogCount"], 1, "{r}");
// A run with nobody to ask takes both answers in order.
let mut s = Session::new();
let answers = json!([{"kind": "confirm", "value": false}, {"kind": "alert", "value": "ok"}]);
let r = s.execute("script.run", &json!({"path": path, "answers": answers})).unwrap();
assert_eq!((r["log"].clone(), r["dialogCount"].clone()), (json!([[false, 7]]), json!(2)), "{r}");
// An error in an included file after its dialog is at that file's line, and the report keeps
// both the file and the dialog.
write(&base.join("main/ask.jsxinc"), "var a = confirm('inc');\nif (a) throw new Error('after');\n");
let mut s = Session::new();
s.script_frames = true;
s.execute("script.run", &json!({"path": path})).unwrap();
assert!(pump(&mut s).is_none());
assert!(s.script_answer(crate::script::DialogAnswer::Confirm(true)));
assert!(pump(&mut s).unwrap().is_err());
let report = s.script_report().unwrap();
assert_eq!(report["line"], 2, "{report}");
assert!(report["file"].as_str().unwrap().ends_with("ask.jsxinc"), "{report}");
assert_eq!(report["dialogs"][0]["answer"], true, "{report}");
let _ = std::fs::remove_dir_all(base);
}
+4 -4
View File
@@ -175,15 +175,15 @@ pub(crate) enum NativeKind {
/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section
/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given.
/// `Data` is a host function given [`crate::Data`]; with `text`, each argument arrives as the
/// string `String(value)` gives. `Eval` is a host function that gives source to run as global code
/// ([`crate::HostItem::Evaluator`]); `end` is called when that code ends.
/// `Data` is a host function given [`crate::Data`], its arguments read as `read` says. `Eval` is a
/// host function that gives source to run as global code ([`crate::HostItem::Evaluator`]); `end`
/// is called when that code ends.
#[derive(Clone)]
pub(crate) enum Func {
Closure { code: Rc<Code>, env: EnvId },
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, text: bool },
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+83 -17
View File
@@ -59,11 +59,38 @@ pub enum HostError {
Syntax(String),
/// Ends the run with [`LimitKind::Host`] and this message. The script cannot catch it.
Abort(String),
/// Pauses the run at this call: [`crate::Realm::advance`] returns [`crate::Progress::Paused`],
/// and the call's result is the value [`crate::Realm::answer`] gives it. A function may return
/// it only while [`crate::Realm::wait_flag`] is set; otherwise the run ends with an internal
/// error.
Wait,
}
/// A function the host gives a script, with its arguments converted.
pub type HostFunction = Rc<dyn Fn(Vec<Data>) -> Result<Data, HostError>>;
/// How one argument of a [`HostItem::Converted`] function is read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Arg {
/// The string `String(value)` gives.
Text,
/// `undefined` as [`Data::Undefined`], any other value as the string `String(value)` gives.
TextOrUndefined,
/// The boolean `Boolean(value)` gives.
Bool,
}
/// How the arguments of a host function are read.
#[derive(Clone)]
pub(crate) enum ArgRead {
/// Each as [`Data`].
Data,
/// Each as the string `String(value)` gives.
Text,
/// One by one as the list says; those past its end are not read.
Each(Rc<[Arg]>),
}
/// A member of an object the host adds to the global environment.
#[derive(Clone)]
pub enum HostItem {
@@ -72,6 +99,9 @@ pub enum HostItem {
Function(HostFunction),
/// A function whose arguments arrive as strings, each the string `String(value)` gives.
TextFunction(HostFunction),
/// A function whose arguments arrive converted one by one as the list says. Arguments past the
/// end of the list are not read and do not reach the function.
Converted(Vec<Arg>, HostFunction),
/// A property whose value the function gives each time it is read.
Getter(HostFunction),
/// A frozen object with these members.
@@ -289,12 +319,23 @@ impl Interp {
Ok(Value::Object(id))
}
/// Calls host function `f`: reads its arguments (with `text`, each as the string `String(value)`
/// gives), runs it, and gives its result to the script.
pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc<str>, args: &[Value], text: bool) -> JsResult<Value> {
let data = self.read_args(name, args, text)?;
match f(data) {
/// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to
/// the script.
pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc<str>, args: &[Value], how: &ArgRead) -> JsResult<Value> {
let data = self.read_args(name, args, how)?;
// Only the script's own code, in a run that can pause, can wait for a result.
let can_wait = self.in_slice && self.reentry == 0;
self.can_wait.set(can_wait);
let result = f(data);
self.can_wait.set(false);
match result {
Ok(d) => self.write_data(d, 0, false),
Err(HostError::Wait) if can_wait => {
self.waiting = true;
self.pause.set(true);
Ok(Value::Undefined)
}
Err(HostError::Wait) => Err(Abrupt::Internal(format!("{name} waited for its result where the run cannot pause"))),
Err(e) => Err(self.host_error(e)?),
}
}
@@ -304,7 +345,7 @@ impl Interp {
/// 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> {
let data = self.read_args(name, args, false)?;
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),
@@ -322,20 +363,31 @@ impl Interp {
r
}
/// The arguments of host function `name` as [`Data`] (with `text`, each as the string
/// `String(value)` gives).
fn read_args(&mut self, name: &Rc<str>, args: &[Value], text: bool) -> JsResult<Vec<Data>> {
/// The arguments of host function `name` as [`Data`], read as `how` says.
fn read_args(&mut self, name: &Rc<str>, args: &[Value], how: &ArgRead) -> JsResult<Vec<Data>> {
let mut reading = Reading { limits: self.data_limits, bytes: 0, entries: 0, ancestors: Vec::new(), path: Vec::new(), function: name.clone() };
let args = match how {
ArgRead::Each(list) => args.get(..list.len().min(args.len())).unwrap_or(args),
_ => args,
};
let mut data = Vec::with_capacity(args.len());
// A conversion can run the script's own `toString`, which may collect garbage.
let mark = self.temp_roots.len();
if text {
if !matches!(how, ArgRead::Data) {
self.temp_roots.extend(args.iter().cloned());
}
let mut read = Ok(());
for (i, a) in args.iter().enumerate() {
reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX)));
let d = if text { self.read_text(a, &mut reading) } else { self.read_data(a, &mut reading) };
let d = match how {
ArgRead::Data => self.read_data(a, &mut reading),
ArgRead::Text => self.read_text(a, &mut reading),
ArgRead::Each(list) => match list.get(i) {
Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined),
Some(Arg::Bool) => Ok(Data::Bool(a.truthy())),
_ => self.read_text(a, &mut reading),
},
};
match d {
Ok(d) => data.push(d),
Err(e) => {
@@ -350,9 +402,12 @@ impl Interp {
Ok(data)
}
/// What host error `e` throws, or the limit it ends the run with.
/// What host error `e` throws, or the limit it ends the run with. A function that waits
/// ([`HostError::Wait`]) where its caller does not handle the wait ends the run with an internal
/// error.
fn host_error(&mut self, e: HostError) -> JsResult<Abrupt> {
match e {
HostError::Wait => Ok(Abrupt::Internal("a host function waited for its result where the run cannot pause".into())),
HostError::Abort(message) => {
self.host_abort = Some(message);
Ok(Abrupt::Limit(LimitKind::Host))
@@ -407,10 +462,13 @@ impl Interp {
let full = format!("{path}.{name}");
let prop = match item {
HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE },
HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, false)), flags: ENUMERABLE },
HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, true)), flags: ENUMERABLE },
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, false)), set: None, flags: ENUMERABLE },
HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE },
HostItem::Object(items) => Prop::Data { value: Value::Object(self.host_object(&full, items)?), flags: ENUMERABLE },
HostItem::Function(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Data)), flags: ENUMERABLE },
HostItem::TextFunction(f) => Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Text)), flags: ENUMERABLE },
HostItem::Converted(list, f) => {
Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE }
}
HostItem::Evaluator { source, end } => {
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)));
@@ -420,9 +478,17 @@ impl Interp {
self.define(o, key(&name), prop)
}
fn host_function(&mut self, f: HostFunction, name: &str, text: bool) -> ObjId {
/// Adds the global function `name` with its arguments converted as `args` says. A script may
/// replace or delete it, as it may a built-in function.
pub(crate) fn install_host_function(&mut self, name: &str, args: Vec<Arg>, f: HostFunction) -> JsResult<()> {
let o = self.host_function(f, name, ArgRead::Each(args.into()));
let global = self.global;
self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: crate::heap::HIDDEN })
}
fn host_function(&mut self, f: HostFunction, name: &str, read: ArgRead) -> ObjId {
let proto = self.intrinsics.function_proto;
self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), text }), Some(proto)))
self.heap.alloc_object(Object::new(Class::Function(Func::Data { f, name: name.into(), read }), Some(proto)))
}
}
+78
View File
@@ -277,6 +277,84 @@ fn advancing_without_a_run_is_an_error() {
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "no run to advance"));
}
/// A realm with the global `ask(question)`, which waits for its answer where it can and throws an
/// `Error` where it cannot. → how many times it was called.
fn with_ask(r: &mut Realm) -> Rc<Cell<u32>> {
let calls: Rc<Cell<u32>> = Rc::default();
let (count, can_wait) = (calls.clone(), r.wait_flag());
let f: HostFunction = Rc::new(move |_| {
count.set(count.get() + 1);
if can_wait.get() { Err(HostError::Wait) } else { Err(HostError::Error("cannot wait here".into())) }
});
r.define_host_function("ask", vec![Arg::Text], f).unwrap();
calls
}
#[test]
fn a_waiting_call_pauses_at_the_call_and_takes_its_answer_once() {
let mut r = realm(1_000_000);
let calls = with_ask(&mut r);
r.start("var a = ask('first'); var b = ask('second'); a + '|' + b").unwrap();
assert_eq!(r.advance(1_000).unwrap(), Progress::Paused);
assert!(r.waits());
let fuel = r.fuel_left();
let e = r.advance(1_000).unwrap_err();
assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Internal, "the run waits for a host function's result"));
assert_eq!(r.fuel_left(), fuel, "no fuel is spent while the call waits");
r.answer(Data::String("one".into())).unwrap();
assert!(!r.waits());
assert_eq!(r.advance(1_000).unwrap(), Progress::Paused);
assert!(r.waits());
r.answer(Data::Null).unwrap();
assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("one|null".into()));
assert_eq!(calls.get(), 2, "each call ran once");
let e = r.answer(Data::Null).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Internal);
}
#[test]
fn only_the_scripts_own_code_in_a_resumable_run_can_wait() {
let mut r = realm(1_000_000);
with_ask(&mut r);
let caught = "var out = []; [1].forEach(function () { try { ask('x'); } catch (e) { out.push(e.message); } }); \
var o = {}; Object.defineProperty(o, 'g', {get: function () { try { ask('y'); } catch (e) { out.push(e.message); } }}); o.g; \
try { String({toString: function () { return ask('z'); }}); } catch (e) { out.push(e.message); } out.join()";
r.start(caught).unwrap();
assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("cannot wait here,cannot wait here,cannot wait here".into()));
let e = r.run("ask('q')").unwrap_err();
assert!(e.message.contains("cannot wait here"), "a run that cannot pause cannot wait: {}", e.message);
r.start("var f = ask; ask.call(null, 'q'); f.apply(null, ['r'])").unwrap();
assert_eq!(r.advance(1_000).unwrap(), Progress::Paused);
assert!(r.waits(), "call and apply from the script's own code wait");
}
#[test]
fn a_host_function_that_waits_where_it_cannot_ends_the_run() {
let mut r = realm(1_000_000);
let f: HostFunction = Rc::new(|_| Err(HostError::Wait));
r.define_host_function("ask", vec![], f).unwrap();
let e = r.run("try { ask(); } catch (e) { 'caught' }").unwrap_err();
assert_eq!(e.kind, RunErrorKind::Internal);
}
#[test]
fn a_converted_function_reads_each_argument_as_its_list_says() {
let mut r = realm(1_000_000);
let seen: Rc<RefCell<Vec<Vec<Data>>>> = Rc::default();
let log = seen.clone();
let f: HostFunction = Rc::new(move |args| {
log.borrow_mut().push(args);
Ok(Data::Undefined)
});
r.define_host_function("conv", vec![Arg::Text, Arg::TextOrUndefined, Arg::Bool], f).unwrap();
r.run("conv(42, undefined, 'x', function () {}); conv(undefined, null, 0); conv({toString: function () { return 'own'; }}); conv()").unwrap();
let s = |t: &str| Data::String(t.into());
let want = vec![vec![s("42"), Data::Undefined, Data::Bool(true)], vec![s("undefined"), s("null"), Data::Bool(false)], vec![s("own")], vec![]];
assert_eq!(*seen.borrow(), want, "arguments past the list are not read");
let out = r.run("var a = typeof conv; conv = 1; a + ' ' + conv").unwrap();
assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function");
}
proptest::proptest! {
/// A slice size does not change what a script computes.
#[test]
+12 -3
View File
@@ -181,6 +181,12 @@ pub(crate) struct Interp {
pub(crate) held: Rc<HeldCount>,
/// Set by the host to end the current slice of a resumable run at the next safe point.
pub(crate) pause: Rc<std::cell::Cell<bool>>,
/// The outermost loop of a resumable run is running ([`crate::Realm::advance`]).
pub(crate) in_slice: bool,
/// True while a host function runs where it may wait ([`crate::HostError::Wait`]).
pub(crate) can_wait: Rc<std::cell::Cell<bool>>,
/// A host function's call waits for its result: the value on top of the stack stands in for it.
pub(crate) waiting: bool,
/// The message of the uncatchable error a host function ended the run with.
pub(crate) host_abort: Option<String>,
/// How much a value passed to or from a host function may carry.
@@ -380,6 +386,9 @@ impl Interp {
date_host: crate::date_builtins::DateHost::default(),
held: Rc::default(),
pause: Rc::default(),
in_slice: false,
can_wait: Rc::default(),
waiting: false,
host_abort: None,
data_limits: crate::DataLimits::default(),
source_tag: crate::realm::UNKNOWN_SOURCE,
@@ -423,7 +432,7 @@ impl Interp {
// ---- the operand stack ----
fn push(&mut self, v: Value) -> JsResult<()> {
pub(crate) fn push(&mut self, v: Value) -> JsResult<()> {
self.stack.push(v);
Ok(())
}
@@ -1855,10 +1864,10 @@ impl Interp {
self.temp_roots.truncate(mark);
return Ok(Prepared::Done(r?));
}
Func::Data { f, name, text } => {
Func::Data { f, name, read } => {
// The arguments are read before the function runs, and a result being built
// keeps itself reachable.
return Ok(Prepared::Done(self.call_data(&f, &name, &args, text)?));
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::Host { f: host, .. } => {
+1 -1
View File
@@ -66,7 +66,7 @@ pub use ast::{
PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl,
};
pub use error::{SyntaxError, TOO_DEEP, TOO_LONG};
pub use host::{Data, DataLimits, HostError, HostFunction, HostItem};
pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem};
pub use lexer::{Goal, Lexer};
pub use number::to_string as number_to_string;
pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE};
+51 -1
View File
@@ -222,6 +222,7 @@ impl Realm {
interp.throw_site = None;
interp.last_thrown = Value::Undefined;
interp.host_abort = None;
interp.waiting = false;
interp.error_tag = UNKNOWN_SOURCE;
let strict = interp.options.strict;
interp.source_tag = 0;
@@ -247,6 +248,7 @@ impl Realm {
interp.frames.clear();
interp.temp_roots.clear();
interp.join_stack.clear();
interp.waiting = false;
match result {
Ok(v) => Ok(interp.to_js_value(&v)),
Err(Abrupt::Throw(v)) => {
@@ -276,16 +278,22 @@ impl Realm {
/// instruction), and pauses where it can resume: between two instructions of the script's own
/// code. A getter, a built-in's callback or another nested run has native frames under it, so
/// it runs on to its end, for at most [`Realm::set_unbroken_fuel`] more. Once this has returned
/// [`Progress::Done`] or an error, the run is over.
/// [`Progress::Done`] or an error, the run is over. While a host function's call waits for its
/// result ([`Realm::waits`]), the run does not advance: this gives an internal error.
pub fn advance(&mut self, slice: u64) -> Result<Progress, RunError> {
let interp = &mut self.interp;
if interp.frames.is_empty() {
return Err(RunError { kind: RunErrorKind::Internal, message: "no run to advance".into(), line: 0, column: 0 });
}
if interp.waiting {
return Err(RunError { kind: RunErrorKind::Internal, message: "the run waits for a host function's result".into(), line: 0, column: 0 });
}
let total = interp.fuel;
let cap = slice.saturating_add(self.unbroken_fuel).min(total);
interp.fuel = cap;
interp.in_slice = true;
let r = interp.execute_slice(cap - slice.min(cap));
interp.in_slice = false;
let used = cap - interp.fuel;
interp.fuel = total - used;
match r {
@@ -307,6 +315,38 @@ impl Realm {
self.interp.pause.clone()
}
/// The flag that is set while a host function runs where it may return
/// [`crate::HostError::Wait`]: called by the script's own code in a run [`Realm::advance`]
/// carries on, and not from a getter, a conversion or a built-in's callback.
pub fn wait_flag(&self) -> std::rc::Rc<std::cell::Cell<bool>> {
self.interp.can_wait.clone()
}
/// Does a host function's call wait for the result [`Realm::answer`] gives it?
pub fn waits(&self) -> bool {
self.interp.waiting
}
/// Gives the call that waits ([`crate::HostError::Wait`]) its result `d`; the next
/// [`Realm::advance`] goes on from there. A result past the heap limit ends the run with that
/// limit, and with no call waiting this gives an internal error.
pub fn answer(&mut self, d: crate::Data) -> Result<(), RunError> {
let interp = &mut self.interp;
if !interp.waiting {
return Err(RunError { kind: RunErrorKind::Internal, message: "no host function waits for a result".into(), line: 0, column: 0 });
}
interp.waiting = false;
// The value on top of the stack stood in for the result.
let r = interp.write_data(d, 0, false).and_then(|v| {
interp.stack.pop();
interp.push(v)
});
match r {
Ok(()) => Ok(()),
Err(e) => self.outcome(Err(e)).map(drop),
}
}
/// Has this realm stop when `interrupt` is set, in place of its own flag.
pub fn share_interrupt(&mut self, interrupt: &Interrupt) {
self.interp.interrupt = interrupt.0.clone();
@@ -339,6 +379,16 @@ impl Realm {
}
}
/// Defines the global function `name`, whose arguments arrive converted as `args` says
/// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as
/// it may a built-in function.
pub fn define_host_function(&mut self, name: &str, args: Vec<crate::Arg>, f: crate::HostFunction) -> Result<(), RunError> {
match self.interp.install_host_function(name, args, f) {
Ok(()) => Ok(()),
Err(e) => self.outcome(Err(e)).map(drop),
}
}
/// Defines the global variable `name` holding the exception the last run did not catch
/// (`undefined` if there was none).
pub fn bind_last_thrown(&mut self, name: &str) {
+2 -4
View File
@@ -342,7 +342,7 @@ impl VectorcraftApp {
self.poll_finished_work();
crate::script_runs::poll(self);
crate::scripts_menu::poll(self, ctx);
if self.session.script_running() && !self.session.script_waiting() {
if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() {
ctx.request_repaint();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -371,9 +371,7 @@ impl VectorcraftApp {
unsaved::finish_pending(self);
if self.session.script_running() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if self.ui.dialog.is_none() {
self.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::CLOSE_KIND, serde_json::json!({})));
}
dialogs::script_run::ask_to_close(self);
} else if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
+4
View File
@@ -410,6 +410,7 @@ registry! {
ScriptRun: [script_run::KIND] => script_run::SPEC,
ScriptGrowth: [script_run::GROWTH_KIND] => script_run::GROWTH_SPEC,
ScriptClose: [script_run::CLOSE_KIND] => script_run::CLOSE_SPEC,
ScriptNotice: [script_run::NOTICE_KIND] => script_run::NOTICE_SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC,
@@ -588,6 +589,9 @@ pub fn cancel(app: &mut VectorcraftApp) {
if d.kind == script_run::GROWTH_KIND {
app.session.script_request_stop();
}
if d.kind == script_run::NOTICE_KIND {
script_run::notice_cancel(app, &d);
}
// Toolbar-only Flare options start no preview and must not cancel a gesture
// that was already active before opening them. The generic parameter dialog has a spec
// without a preview, yet previews some commands (Grid Repeat Options) when asked to.
+192 -3
View File
@@ -1,16 +1,25 @@
//! File › Scripts › Other Script…: choosing a JavaScript file and the warning before it runs
//! (`script.run` runs it), the question a run that keeps adding art asks, and the question quitting
//! asks while a script runs.
//! (`script.run` runs it), the question a run that keeps adding art asks, the question quitting
//! asks while a script runs, and the dialogs a script opens with `alert`, `confirm` and `prompt`.
//!
//! `ui.runScript {path? | source?, name?}` shows the warning for that script; without either it
//! asks for a `.js` or `.jsx` file (the web opens the browser's file picker). With Preferences ›
//! General › Show External Script Warning off, the chosen script runs at once. Warning fields:
//! `name`, `script` (`script.run`'s params) and `dontShow` (true: turn the warning off).
//!
//! A script's dialog (kind `scriptDialog`) has the fields `kind` (alert, confirm or prompt),
//! `message`, `title` (null: Script Alert), `errorIcon`, `noAsDefault` and `value` (the prompt's
//! text), and takes `answer`: `ok` (alert, prompt), `yes` or `no` (confirm), `cancel` (prompt) or
//! `stop`. OK, Enter or `ui.dialog.confirm` without an `answer` gives the default button's answer.
//! Escape or `ui.dialog.cancel` gives Cancel for a prompt, No for a confirm and Stop for an alert.
//! Stop ends the run and puts back everything it did.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::scripts::{RUN_EXTS, TOO_LARGE, runs};
use vectorcraft_engine::script::dialogs::{MAX_PROMPT_BYTES, cut_to, shown_text};
use vectorcraft_engine::script::{DialogAnswer, ScriptDialog};
use super::DialogSpec;
use super::{DialogAction, DialogSpec};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{FilePick, VectorcraftApp, widgets};
@@ -21,6 +30,19 @@ pub const KIND: &str = "scriptRun";
pub const GROWTH_KIND: &str = "scriptGrowth";
/// The dialog kind of the question quitting asks while a script runs.
pub const CLOSE_KIND: &str = "scriptClose";
/// The dialog kind of a script's `alert`, `confirm` and `prompt`.
pub const NOTICE_KIND: &str = "scriptDialog";
pub(super) const NOTICE_SPEC: DialogSpec = DialogSpec {
heading: notice_heading,
body_with_actions: Some(notice_body),
confirm: notice_confirm,
ok: None,
inputs: |_| &["answer"],
min_width: 360.0,
max_width: Some(560.0),
..DialogSpec::FORM
};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Run Script").into(),
@@ -145,3 +167,170 @@ fn close_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result<Value, String>
}
Ok(Value::Null)
}
/// Quitting while a script runs asks whether to stop it, also over the script's own dialog, which
/// Cancel shows again as it was. Any other open dialog stays, and quitting asks nothing yet.
pub(crate) fn ask_to_close(app: &mut VectorcraftApp) {
let notice = app.ui.dialog.take_if(|d| d.kind == NOTICE_KIND);
if app.ui.dialog.is_some() {
return;
}
let mut fields = json!({});
if let Some(notice) = notice {
fields[super::PARENT] = serde_json::to_value(notice).unwrap_or_default();
}
app.ui.dialog = Some(Dialog::new(CLOSE_KIND, fields));
}
/// Closes the question quitting asks once the run it asks about has ended, and forgets the
/// script's dialog it was asked over once the run no longer waits at that dialog, so Cancel does
/// not show it again.
pub(crate) fn settle_close(app: &mut VectorcraftApp) {
if !app.ui.dialog.as_ref().is_some_and(|d| d.kind == CLOSE_KIND) {
return;
}
if !app.session.script_running() {
app.ui.dialog = None;
} else if app.session.script_dialog().is_none()
&& let Some(d) = app.ui.dialog.as_mut()
{
d.fields.remove(super::PARENT);
}
}
/// Shows the dialog the running script waits at.
pub(crate) fn open_notice(app: &mut VectorcraftApp, d: &ScriptDialog) {
let fields = json!({
"kind": d.kind.id(),
"message": d.message,
"title": d.title,
"errorIcon": d.error_icon,
"noAsDefault": d.no_as_default,
"value": d.preset,
});
app.ui.dialog = Some(Dialog::new(NOTICE_KIND, fields));
}
/// The dialog's heading: the script's title on one line, or Script Alert.
pub(crate) fn notice_heading(d: &Dialog) -> String {
match d.fields.get("title").and_then(Value::as_str) {
Some(title) => shown_text(title, false),
None => tl!("Script Alert").into(),
}
}
/// The answer of the default button: OK, or for a confirm Yes, or No with `noAsDefault`.
fn default_answer(d: &Dialog) -> &'static str {
match (d.str("kind").as_str(), d.bool("noAsDefault")) {
("confirm", true) => "no",
("confirm", false) => "yes",
_ => "ok",
}
}
/// A button that is the primary one when it is the default.
fn answer_button(ui: &mut egui::Ui, label: &str, default: bool) -> bool {
if default { widgets::primary_button_enabled(ui, label, true).clicked() } else { widgets::secondary_button(ui, label).clicked() }
}
fn notice_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction {
let t = Tokens::get(ui.ctx());
let kind = d.str("kind");
let message = shown_text(&d.str("message"), true);
ui.horizontal_top(|ui| {
if d.bool("errorIcon") {
ui.label(egui::RichText::new("!").font(theme::semibold(22.0)).color(t.error));
ui.add_space(8.0);
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| ui.label(&message));
});
if kind == "prompt" {
ui.add_space(8.0);
let name = cut_to(&shown_text(&message, false), 200).to_string();
super::form::text_edit_named(ui, d, "value", &name, ui.available_width());
let value = d.str("value");
if value.len() > MAX_PROMPT_BYTES {
d.fields.insert("value".into(), json!(cut_to(&value, MAX_PROMPT_BYTES)));
}
}
ui.add_space(16.0);
let default = default_answer(d);
let mut answer = None;
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
let buttons: &[(&str, &str)] = match kind.as_str() {
"confirm" => &[("yes", "Yes"), ("no", "No")],
"prompt" => &[("ok", "OK"), ("cancel", "Cancel")],
_ => &[("ok", "OK")],
};
for (id, label) in buttons {
if answer_button(ui, tl!(label), *id == default) {
answer = Some(*id);
}
ui.add_space(8.0);
}
// The row runs right to left: Stop goes at its left end.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
if widgets::secondary_button(ui, tl!("Stop")).clicked() {
answer = Some("stop");
}
});
});
if answer.is_none() && crate::shortcuts::pressed_anew(ui.ctx(), egui::Key::Enter) {
answer = Some(default);
}
let Some(answer) = answer else { return DialogAction::None };
d.fields.insert("answer".into(), json!(answer));
DialogAction::Confirm
}
/// Gives the running script the dialog's `answer`, or the default button's.
fn notice_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let answer = d.fields.get("answer").and_then(Value::as_str).unwrap_or(default_answer(d));
give(app, d, answer)
}
/// Escape or Cancel: Cancel for a prompt, No for a confirm and Stop for an alert.
pub(super) fn notice_cancel(app: &mut VectorcraftApp, d: &Dialog) {
let answer = match d.str("kind").as_str() {
"prompt" => "cancel",
"confirm" => "no",
_ => "stop",
};
let _ = give(app, d, answer);
}
fn give(app: &mut VectorcraftApp, d: &Dialog, answer: &str) -> Result<Value, String> {
let given = match (d.str("kind").as_str(), answer) {
(_, "stop") => None,
("alert", "ok") => Some(DialogAnswer::Ok),
("confirm", "yes") => Some(DialogAnswer::Confirm(true)),
("confirm", "no") => Some(DialogAnswer::Confirm(false)),
("prompt", "ok") => Some(DialogAnswer::Prompt(Some(d.str("value")))),
("prompt", "cancel") => Some(DialogAnswer::Prompt(None)),
(kind, _) => return Err(format!("`answer` for this {kind} dialog must be one of: {}", answers_for(kind))),
};
match given {
None => {
app.session.script_request_stop();
}
Some(a) => {
// Refused while the dialog still waits: the text is too long, and the dialog stays open.
if !app.session.script_answer(a) && app.session.script_dialog().is_some() {
return Err(format!("the prompt's text is longer than {MAX_PROMPT_BYTES} bytes"));
}
}
}
if app.ui.dialog.as_ref().is_some_and(|open| open.kind == NOTICE_KIND) {
app.ui.dialog = None;
}
Ok(Value::Null)
}
fn answers_for(kind: &str) -> &'static str {
match kind {
"confirm" => "yes, no, stop",
"prompt" => "ok, cancel, stop",
_ => "ok, stop",
}
}
+1
View File
@@ -3980,6 +3980,7 @@ axis Both И двете
Stop Script Спри скрипта
Show External Script Warning Показвай предупреждение за външни скриптове
A Script Is Running Изпълнява се скрипт
Script Alert Известие от скрипт
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скриптът може да променя отворените документи и да записва файлове чрез командите, които изпълнява. Изпълнявайте само скриптове, на които имате доверие.
Do not show this warning again Не показвай повече това предупреждение
Stop the script and undo what it did? Then quit again. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4345,6 +4345,7 @@ imageTrace Outline Obris
Stop Script Zaustavi skriptu
Show External Script Warning Prikaži upozorenje za vanjske skripte
A Script Is Running Skripta se izvršava
Script Alert Upozorenje skripte
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skripta može mijenjati otvorene dokumente i pisati datoteke putem naredbi koje pokreće. Pokrećite samo skripte kojima vjerujete.
Do not show this warning again Ne prikazuj više ovo upozorenje
Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5196,6 +5196,7 @@ digits Malayalam Malaiàlam
Stop Script Atura l'script
Show External Script Warning Mostra l'avís dels scripts externs
A Script Is Running S'està executant un script
Script Alert Avís de l'script
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script pot canviar els documents oberts i escriure fitxers mitjançant les ordres que executa. Executeu només scripts de confiança.
Do not show this warning again No tornis a mostrar aquest avís
Stop the script and undo what it did? Then quit again. Voleu aturar l'script i desfer el que ha fet? Després, torneu a sortir.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4401,6 +4401,7 @@ imageTrace Outline Obrys
Stop Script Zastavit skript
Show External Script Warning Zobrazit upozornění u externích skriptů
A Script Is Running Běží skript
Script Alert Upozornění skriptu
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skript může měnit otevřené dokumenty a prostřednictvím příkazů, které spouští, zapisovat soubory. Spouštějte jen skripty, kterým důvěřujete.
Do not show this warning again Toto upozornění již nezobrazovat
Stop the script and undo what it did? Then quit again. Zastavit skript a vrátit jeho změny? Potom aplikaci ukončete znovu.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4099,6 +4099,7 @@ imageTrace Outline Kontur
Stop Script Stop script
Show External Script Warning Vis advarsel om eksterne scripts
A Script Is Running Et script kører
Script Alert Scriptadvarsel
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Et script kan ændre de åbne dokumenter og skrive filer gennem de kommandoer, det kører. Kør kun scripts, du stoler på.
Do not show this warning again Vis ikke denne advarsel igen
Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4007,6 +4007,7 @@ theme Appearance Erscheinungsbild
Stop Script Skript stoppen
Show External Script Warning Warnung bei externen Skripten anzeigen
A Script Is Running Ein Skript wird ausgeführt
Script Alert Skriptmeldung
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ein Skript kann die geöffneten Dokumente ändern und über die ausgeführten Befehle Dateien schreiben. Führen Sie nur Skripte aus, denen Sie vertrauen.
Do not show this warning again Diese Warnung nicht mehr anzeigen
Stop the script and undo what it did? Then quit again. Skript stoppen und seine Änderungen rückgängig machen? Beenden Sie danach erneut.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2247,6 +2247,7 @@ axis Both Και τα δύο
Stop Script Διακοπή σεναρίου
Show External Script Warning Εμφάνιση προειδοποίησης για εξωτερικά σενάρια
A Script Is Running Εκτελείται ένα σενάριο
Script Alert Ειδοποίηση σεναρίου
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ένα σενάριο μπορεί να αλλάξει τα ανοιχτά έγγραφα και να γράψει αρχεία μέσω των εντολών που εκτελεί. Εκτελείτε μόνο σενάρια που εμπιστεύεστε.
Do not show this warning again Να μην εμφανιστεί ξανά αυτή η προειδοποίηση
Stop the script and undo what it did? Then quit again. Διακοπή του σεναρίου και αναίρεση όσων έκανε; Έπειτα κάντε ξανά έξοδο.
Can't render this file because it is too large.
+1
View File
@@ -4091,6 +4091,7 @@ axis Both Ambos
Stop Script Detener script
Show External Script Warning Mostrar advertencia de scripts externos
A Script Is Running Se está ejecutando un script
Script Alert Aviso del script
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script puede cambiar los documentos abiertos y escribir archivos mediante los comandos que ejecuta. Ejecute solo scripts de confianza.
Do not show this warning again No volver a mostrar esta advertencia
Stop the script and undo what it did? Then quit again. ¿Detener el script y deshacer lo que hizo? Después, vuelva a salir.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4315,6 +4315,7 @@ imageTrace Outline Ääriviiva
Stop Script Pysäytä komentosarja
Show External Script Warning Näytä ulkoisten komentosarjojen varoitus
A Script Is Running Komentosarja on käynnissä
Script Alert Komentosarjan ilmoitus
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Komentosarja voi muuttaa avoimia asiakirjoja ja kirjoittaa tiedostoja suorittamillaan komennoilla. Suorita vain komentosarjoja, joihin luotat.
Do not show this warning again Älä näytä tätä varoitusta uudelleen
Stop the script and undo what it did? Then quit again. Pysäytetäänkö komentosarja ja kumotaan sen muutokset? Lopeta sitten uudelleen.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4080,6 +4080,7 @@ axis Both Les deux
Stop Script Arrêter le script
Show External Script Warning Afficher l’avertissement des scripts externes
A Script Is Running Un script est en cours d’exécution
Script Alert Alerte de script
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Un script peut modifier les documents ouverts et écrire des fichiers au moyen des commandes qu’il exécute. N’exécutez que des scripts de confiance.
Do not show this warning again Ne plus afficher cet avertissement
Stop the script and undo what it did? Then quit again. Arrêter le script et annuler ce qu’il a fait ? Quittez ensuite à nouveau.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -6186,6 +6186,7 @@ antiAlias Sharp शार्प
Stop Script स्क्रिप्ट रोकें
Show External Script Warning बाहरी स्क्रिप्ट की चेतावनी दिखाएँ
A Script Is Running एक स्क्रिप्ट चल रही है
Script Alert स्क्रिप्ट सूचना
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. स्क्रिप्ट खुले दस्तावेज़ बदल सकती है और अपने चलाए कमांड से फ़ाइलें लिख सकती है। केवल भरोसेमंद स्क्रिप्ट चलाएँ।
Do not show this warning again यह चेतावनी फिर न दिखाएँ
Stop the script and undo what it did? Then quit again. स्क्रिप्ट रोककर उसके बदलाव पूर्ववत करें? फिर दोबारा बाहर निकलें।
Can't render this file because it is too large.
+1
View File
@@ -4888,6 +4888,7 @@ imageTrace Outline Körvonal
Stop Script Parancsfájl leállítása
Show External Script Warning Figyelmeztetés külső parancsfájlok előtt
A Script Is Running Egy parancsfájl fut
Script Alert Parancsfájl-értesítés
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. A parancsfájl módosíthatja a megnyitott dokumentumokat, és a futtatott parancsokkal fájlokat írhat. Csak megbízható parancsfájlokat futtasson.
Do not show this warning again Ne jelenjen meg többé ez a figyelmeztetés
Stop the script and undo what it did? Then quit again. Leállítja a parancsfájlt, és visszavonja a módosításait? Ezután lépjen ki újra.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4099,6 +4099,7 @@ imageTrace Outline Kontur
Stop Script Hentikan Skrip
Show External Script Warning Tampilkan Peringatan Skrip Eksternal
A Script Is Running Skrip Sedang Berjalan
Script Alert Peringatan Skrip
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrip dapat mengubah dokumen yang terbuka dan menulis file melalui perintah yang dijalankannya. Jalankan hanya skrip yang Anda percayai.
Do not show this warning again Jangan tampilkan peringatan ini lagi
Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi.
Can't render this file because it contains an unexpected character in line 2320 and column 5.
+1
View File
@@ -4134,6 +4134,7 @@ axis Both Entrambi
Stop Script Interrompi script
Show External Script Warning Mostra avviso per gli script esterni
A Script Is Running È in esecuzione uno script
Script Alert Avviso dello script
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Uno script può modificare i documenti aperti e scrivere file tramite i comandi che esegue. Esegui solo script di cui ti fidi.
Do not show this warning again Non mostrare più questo avviso
Stop the script and undo what it did? Then quit again. Interrompere lo script e annullare ciò che ha fatto? Poi esci di nuovo.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4864,6 +4864,7 @@ swatch Bright Violet ビビッドバイオレット
Stop Script スクリプトを停止
Show External Script Warning 外部スクリプトの警告を表示
A Script Is Running スクリプトの実行中
Script Alert スクリプトの通知
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. スクリプトは開いているドキュメントを変更でき、実行するコマンドを通じてファイルを書き込むこともできます。信頼できるスクリプトだけを実行してください。
Do not show this warning again 今後この警告を表示しない
Stop the script and undo what it did? Then quit again. スクリプトを停止して変更を取り消しますか?その後、もう一度終了してください。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5179,6 +5179,7 @@ imageTrace Outline კონტური
Stop Script სკრიპტის შეჩერება
Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება
A Script Is Running სკრიპტი მუშაობს
Script Alert სკრიპტის შეტყობინება
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. სკრიპტს შეუძლია ღია დოკუმენტების შეცვლა და მის მიერ გაშვებული ბრძანებებით ფაილების ჩაწერა. გაუშვით მხოლოდ სანდო სკრიპტები.
Do not show this warning again აღარ აჩვენო ეს გაფრთხილება
Stop the script and undo what it did? Then quit again. შეჩერდეს სკრიპტი და გაუქმდეს მისი ცვლილებები? შემდეგ ხელახლა გამოდით.
Can't render this file because it is too large.
+1
View File
@@ -4791,6 +4791,7 @@ theme Appearance 외관
Stop Script 스크립트 중지
Show External Script Warning 외부 스크립트 경고 표시
A Script Is Running 스크립트 실행 중
Script Alert 스크립트 알림
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 스크립트는 열려 있는 문서를 변경하고 실행하는 명령을 통해 파일을 쓸 수 있습니다. 신뢰할 수 있는 스크립트만 실행하세요.
Do not show this warning again 이 경고를 다시 표시하지 않음
Stop the script and undo what it did? Then quit again. 스크립트를 중지하고 변경 내용을 취소할까요? 그런 다음 다시 종료하세요.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4113,6 +4113,7 @@ imageTrace Outline Contour
Stop Script Script stoppen
Show External Script Warning Waarschuwing voor externe scripts tonen
A Script Is Running Er wordt een script uitgevoerd
Script Alert Scriptmelding
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Een script kan de geopende documenten wijzigen en via de uitgevoerde opdrachten bestanden schrijven. Voer alleen scripts uit die u vertrouwt.
Do not show this warning again Deze waarschuwing niet meer tonen
Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4224,6 +4224,7 @@ imageTrace Outline Kontur
Stop Script Zatrzymaj skrypt
Show External Script Warning Pokazuj ostrzeżenie o skryptach zewnętrznych
A Script Is Running Skrypt jest uruchomiony
Script Alert Komunikat skryptu
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Skrypt może zmieniać otwarte dokumenty i zapisywać pliki za pomocą wykonywanych poleceń. Uruchamiaj tylko skrypty, którym ufasz.
Do not show this warning again Nie pokazuj więcej tego ostrzeżenia
Stop the script and undo what it did? Then quit again. Zatrzymać skrypt i cofnąć jego zmiany? Następnie zakończ program ponownie.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5068,6 +5068,7 @@ axis Both Ambos
Stop Script Parar script
Show External Script Warning Mostrar aviso de scripts externos
A Script Is Running Um script está em execução
Script Alert Alerta do script
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Um script pode alterar os documentos abertos e gravar arquivos pelos comandos que executa. Execute apenas scripts em que você confia.
Do not show this warning again Não mostrar este aviso novamente
Stop the script and undo what it did? Then quit again. Parar o script e desfazer o que ele fez? Depois, saia novamente.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4128,6 +4128,7 @@ axis Both Оба
Stop Script Остановить скрипт
Show External Script Warning Показывать предупреждение о внешних скриптах
A Script Is Running Выполняется скрипт
Script Alert Сообщение скрипта
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт может изменять открытые документы и записывать файлы с помощью выполняемых команд. Запускайте только те скрипты, которым доверяете.
Do not show this warning again Больше не показывать это предупреждение
Stop the script and undo what it did? Then quit again. Остановить скрипт и отменить его изменения? Затем выйдите ещё раз.
Can't render this file because it is too large.
+1
View File
@@ -4001,6 +4001,7 @@ axis Both Båda
Stop Script Stoppa skript
Show External Script Warning Visa varning för externa skript
A Script Is Running Ett skript körs
Script Alert Skriptmeddelande
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Ett skript kan ändra de öppna dokumenten och skriva filer genom de kommandon det kör. Kör bara skript du litar på.
Do not show this warning again Visa inte den här varningen igen
Stop the script and undo what it did? Then quit again. Stoppa skriptet och ångra det det gjort? Avsluta sedan igen.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3883,6 +3883,7 @@ axis Both Her İkisi
Stop Script Betiği Durdur
Show External Script Warning Harici Betik Uyarısını Göster
A Script Is Running Bir Betik Çalışıyor
Script Alert Betik Uyarısı
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Bir betik açık belgeleri değiştirebilir ve çalıştırdığı komutlarla dosya yazabilir. Yalnızca güvendiğiniz betikleri çalıştırın.
Do not show this warning again Bu uyarıyı bir daha gösterme
Stop the script and undo what it did? Then quit again. Betik durdurulsun ve yaptıkları geri alınsın mı? Ardından yeniden çıkın.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4118,6 +4118,7 @@ axis Both Обидві
Stop Script Зупинити скрипт
Show External Script Warning Показувати попередження про зовнішні скрипти
A Script Is Running Виконується скрипт
Script Alert Повідомлення скрипту
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Скрипт може змінювати відкриті документи й записувати файли за допомогою команд, які виконує. Запускайте лише ті скрипти, яким довіряєте.
Do not show this warning again Більше не показувати це попередження
Stop the script and undo what it did? Then quit again. Зупинити скрипт і скасувати його зміни? Потім вийдіть ще раз.
Can't render this file because it contains an unexpected character in line 182 and column 5.
+1
View File
@@ -4537,6 +4537,7 @@ theme Appearance Giao diện
Stop Script Dừng tập lệnh
Show External Script Warning Hiện cảnh báo tập lệnh bên ngoài
A Script Is Running Một tập lệnh đang chạy
Script Alert Thông báo của tập lệnh
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. Tập lệnh có thể thay đổi các tài liệu đang mở và ghi tệp thông qua các lệnh mà nó chạy. Chỉ chạy những tập lệnh bạn tin cậy.
Do not show this warning again Không hiện lại cảnh báo này
Stop the script and undo what it did? Then quit again. Dừng tập lệnh và hoàn tác những gì nó đã làm? Sau đó hãy thoát lại.
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -4571,6 +4571,7 @@ imageTrace Outline 轮廓
Stop Script 停止脚本
Show External Script Warning 显示外部脚本警告
A Script Is Running 脚本正在运行
Script Alert 脚本提示
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 脚本可以更改打开的文档,并可通过其运行的命令写入文件。请只运行您信任的脚本。
Do not show this warning again 不再显示此警告
Stop the script and undo what it did? Then quit again. 要停止脚本并撤销其更改吗?然后请再次退出。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2397,6 +2397,7 @@ file-filter Templates 範本
Stop Script 停止指令碼
Show External Script Warning 顯示外部指令碼警告
A Script Is Running 指令碼正在執行
Script Alert 指令碼提示
A script can change the open documents and can write files through the commands it runs. Run only scripts you trust. 指令碼可以變更開啟的文件,並可透過其執行的命令寫入檔案。請只執行您信任的指令碼。
Do not show this warning again 不再顯示此警告
Stop the script and undo what it did? Then quit again. 要停止指令碼並還原其變更嗎?然後請再次結束。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+33 -6
View File
@@ -1,6 +1,7 @@
//! A running script, advanced one slice per frame: `script.run` starts it and [`poll`] takes it to
//! its end, while the app goes on drawing the documents as they were before the run. The status bar
//! shows it with a Stop button; a run that has added a great deal waits for the person's answer.
//! shows it with a Stop button; a run that has added a great deal, or that opened a dialog with
//! `alert`, `confirm` or `prompt`, waits for the person's answer.
use serde_json::json;
@@ -14,21 +15,24 @@ pub fn poll(app: &mut VectorcraftApp) {
if !app.session.script_running() {
return;
}
if app.session.script_waiting() {
if app.ui.dialog.is_none() {
app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({})));
}
script_run::settle_close(app);
if ask(app) {
return;
}
let name = app.session.script_name().unwrap_or_default();
let cursor_settings = (app.session.caps_lock, app.session.prefs.use_precise_cursors);
let Some(result) = app.session.script_pump() else { return };
let Some(result) = app.session.script_pump() else {
// The app draws no more frames while the run waits: its question opens in this one.
ask(app);
return;
};
if cursor_settings != (app.session.caps_lock, app.session.prefs.use_precise_cursors) {
app.canvas.hover_cursor = None;
}
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::GROWTH_KIND) {
app.ui.dialog = None;
}
script_run::settle_close(app);
match result {
Ok(_) => app.status(format!("Ran {name}")),
Err(e) => failed(app, e.to_string()),
@@ -36,6 +40,29 @@ pub fn poll(app: &mut VectorcraftApp) {
app.sync_views();
}
/// Opens what the run waits at, once no other dialog is open: the script's own dialog, or the
/// question about its growth. Closes the script's dialog when the run no longer waits at it.
/// → true while the run waits.
fn ask(app: &mut VectorcraftApp) -> bool {
if let Some(dialog) = app.session.script_dialog() {
if app.ui.dialog.is_none() {
script_run::open_notice(app, &dialog);
}
return true;
}
// A run asked to stop no longer waits at its dialog.
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == script_run::NOTICE_KIND) {
app.ui.dialog = None;
}
if app.session.script_waiting() {
if app.ui.dialog.is_none() {
app.ui.dialog = Some(Dialog::new(script_run::GROWTH_KIND, json!({})));
}
return true;
}
false
}
/// Shows why a run ended without its changes. A stopped run that wrote no file says only that it
/// was stopped; any other failure shows its error, which names the files left written.
pub(crate) fn failed(app: &mut VectorcraftApp, error: String) {
+1
View File
@@ -39,6 +39,7 @@ mod saveext;
mod screenmode;
mod screenshots;
mod script_check;
mod script_dialogs;
mod script_run;
mod scripts_menu;
mod scrub;
+282
View File
@@ -0,0 +1,282 @@
//! A script's `alert`, `confirm` and `prompt` in the app: the dialog the run waits at, its buttons,
//! Enter and Escape, Stop, another dialog in the way, quitting while it waits, and the same answers
//! given by a run with nobody to ask.
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::dialogs::script_run::{CLOSE_KIND, NOTICE_KIND};
use crate::{VectorcraftApp, dialogs, menus, script_runs};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.session.prefs.show_external_script_warning = false;
app
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
}
const DRAW: &str = "vc.run('shape.rectangle', {x:0,y:0,width:10,height:10});";
fn start(app: &mut VectorcraftApp, source: &str) {
menus::run_ui_command(app, "ui.runScript", &json!({"source": source, "name": "d.js"})).unwrap().unwrap();
}
/// Polls the run until it ends, or until its dialog is open (true).
fn until_dialog(app: &mut VectorcraftApp) -> bool {
for _ in 0..10_000 {
if !app.session.script_running() {
return false;
}
script_runs::poll(app);
if app.ui.dialog.as_ref().is_some_and(|d| d.kind == NOTICE_KIND) {
return true;
}
}
panic!("the script neither ended nor opened a dialog");
}
fn answer(app: &mut VectorcraftApp, answer: &str) {
app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!(answer));
dialogs::confirm(app).unwrap();
}
thread_local! {
// One context per test, so frames follow one another as in the app.
static CTX: egui::Context = {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
};
}
/// One headless frame of the dialog layer and the shortcuts, in a window of `size`. Each key pressed
/// in it is let go in a second frame, as a person lets go of it.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>, size: egui::Vec2) {
let ctx = CTX.with(Clone::clone);
let released: Vec<egui::Event> = events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, physical_key, modifiers, pressed: true, .. } => {
Some(egui::Event::Key { key: *key, physical_key: *physical_key, pressed: false, repeat: false, modifiers: *modifiers })
}
_ => None,
})
.collect();
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size));
let pressed = !released.is_empty();
for events in std::iter::once(events).chain(pressed.then_some(released)) {
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ui.ctx());
dialogs::show(app, ui.ctx());
});
out.textures_delta.clear();
}
}
fn key(k: egui::Key) -> egui::Event {
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
const WINDOW: egui::Vec2 = egui::vec2(1200.0, 800.0);
fn log(app: &VectorcraftApp) -> Value {
app.session.script_report().unwrap()["log"].clone()
}
#[test]
fn alert_waits_and_stop_rolls_back_first_dialog() {
for stop_with_escape in [false, true] {
let mut app = app();
start(&mut app, &format!("{DRAW} while (1) {{ alert('x'); }}"));
assert!(until_dialog(&mut app));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields["kind"].as_str(), d.fields["message"].as_str(), d.fields["title"].clone()), (Some("alert"), Some("x"), Value::Null));
for _ in 0..5 {
script_runs::poll(&mut app);
}
assert!(app.session.script_running(), "the run waits at the dialog");
assert_eq!(objects(&app), 0);
if stop_with_escape {
frame(&mut app, vec![key(egui::Key::Escape)], WINDOW);
} else {
answer(&mut app, "stop");
}
assert!(app.ui.dialog.is_none());
assert!(!until_dialog(&mut app));
assert_eq!(objects(&app), 0);
assert_eq!(app.ui.status, "The script was stopped and its changes were undone");
assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["answer"], "stop");
}
}
#[test]
fn confirm_enter_uses_no_as_default() {
for (no_as_default, want) in [(true, false), (false, true)] {
let mut app = app();
start(&mut app, &format!("vc.log(confirm('Go?', {no_as_default}));"));
assert!(until_dialog(&mut app));
frame(&mut app, vec![], WINDOW);
frame(&mut app, vec![key(egui::Key::Enter)], WINDOW);
assert!(app.ui.dialog.is_none(), "Enter answers the dialog: noAsDflt {no_as_default}");
assert!(!until_dialog(&mut app));
assert_eq!(log(&app), json!([want]), "noAsDflt {no_as_default}");
}
let mut app = app();
start(&mut app, "vc.log(confirm('Go?'));");
assert!(until_dialog(&mut app));
frame(&mut app, vec![key(egui::Key::Escape)], WINDOW);
assert!(!until_dialog(&mut app));
assert_eq!(log(&app), json!([false]), "Escape is No");
}
#[test]
fn prompt_ok_and_cancel_return_values() {
let mut app = app();
start(&mut app, "vc.log(prompt('Name', 'pre', 'Ask')); vc.log(prompt('Again', 'x'));");
assert!(until_dialog(&mut app));
assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "Ask");
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["value"], "pre");
app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("typed"));
dialogs::confirm(&mut app).unwrap();
assert!(until_dialog(&mut app));
dialogs::cancel(&mut app);
assert!(!until_dialog(&mut app));
assert_eq!(log(&app), json!(["typed", null]));
let mut app = self::app();
start(&mut app, "vc.log(prompt('Name', ''));");
assert!(until_dialog(&mut app));
let e = {
app.ui.dialog.as_mut().unwrap().fields.insert("answer".into(), json!("yes"));
dialogs::confirm(&mut app).unwrap_err()
};
assert!(e.contains("ok, cancel, stop"), "{e}");
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "a wrong answer leaves the dialog open");
answer(&mut app, "ok");
assert!(!until_dialog(&mut app));
assert_eq!(log(&app), json!([""]), "OK with an empty field gives an empty string");
}
#[test]
fn the_dialog_opens_in_the_frame_the_run_waits() {
let mut app = app();
start(&mut app, "alert('x');");
script_runs::poll(&mut app);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(NOTICE_KIND), "no later frame is drawn while the run waits");
}
#[test]
fn a_prompt_text_past_the_limit_keeps_the_dialog_open() {
let mut app = app();
start(&mut app, "vc.log(prompt('Name', ''));");
assert!(until_dialog(&mut app));
let long = "x".repeat(vectorcraft_engine::script::dialogs::MAX_PROMPT_BYTES + 1);
app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!(long));
assert!(dialogs::confirm(&mut app).is_err());
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND, "the refused answer leaves the dialog open");
assert!(app.session.script_dialog().is_some(), "the run still waits");
app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("ok"));
answer(&mut app, "ok");
assert!(!until_dialog(&mut app));
assert_eq!(log(&app), json!(["ok"]));
}
#[test]
fn dialog_wait_survives_another_modal() {
let mut app = app();
start(&mut app, "alert('x');");
script_runs::poll(&mut app);
app.ui.dialog = Some(crate::state::Dialog::new(dialogs::script_run::GROWTH_KIND, json!({})));
let other = app.ui.dialog.clone();
for _ in 0..3 {
script_runs::poll(&mut app);
}
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.clone()), other.map(|d| d.kind), "the open dialog stays");
assert!(app.session.script_running());
app.ui.dialog = None;
assert!(until_dialog(&mut app), "the script's dialog opens once the other one closes");
}
#[test]
fn close_while_notification_waits_abandons_or_resumes() {
let mut app = app();
start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));"));
assert!(until_dialog(&mut app));
app.ui.dialog.as_mut().unwrap().fields.insert("value".into(), json!("half"));
dialogs::script_run::ask_to_close(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND);
script_runs::poll(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run goes on waiting");
dialogs::cancel(&mut app);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.fields["value"].as_str()), (NOTICE_KIND, Some("half")), "Cancel shows the dialog again as it was");
dialogs::script_run::ask_to_close(&mut app);
dialogs::confirm(&mut app).unwrap();
assert!(!app.session.script_running());
assert_eq!(objects(&app), 0);
assert_eq!(app.ui.status, "The script was stopped and its changes were undone");
}
#[test]
fn close_question_ends_with_a_run_stopped_another_way() {
let mut app = app();
start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));"));
assert!(until_dialog(&mut app));
dialogs::script_run::ask_to_close(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND);
assert_eq!(app.run("script.stop", json!({})).unwrap()["stopped"], true);
script_runs::poll(&mut app);
assert!(!app.session.script_running());
assert!(app.ui.dialog.is_none(), "the question about a run that has ended closes");
dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none(), "Cancel does not show the stopped script's dialog again");
assert_eq!(objects(&app), 0);
// Asked to stop, the run no longer waits at its dialog before its next slice ends it.
let mut app = self::app();
start(&mut app, &format!("{DRAW} vc.log(prompt('Name', ''));"));
assert!(until_dialog(&mut app));
dialogs::script_run::ask_to_close(&mut app);
app.run("script.stop", json!({})).unwrap();
dialogs::script_run::settle_close(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, CLOSE_KIND, "the run has not ended yet");
dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none(), "Cancel does not show a dialog the run no longer waits at");
}
#[test]
fn notification_layout_with_long_text_and_small_window() {
let mut app = app();
let long = "word ".repeat(3_000);
start(&mut app, &format!("alert({long:?} + '\\u0007\\r\\n', 'T\\nitle', true);"));
assert!(until_dialog(&mut app));
for _ in 0..3 {
frame(&mut app, vec![], egui::vec2(320.0, 240.0));
}
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, NOTICE_KIND);
assert_eq!(dialogs::script_run::notice_heading(app.ui.dialog.as_ref().unwrap()), "T\u{240a}itle");
answer(&mut app, "ok");
assert!(!until_dialog(&mut app));
assert_eq!(app.session.script_report().unwrap()["dialogs"][0]["errorIcon"], true);
}
#[test]
fn the_app_and_a_run_with_nobody_to_ask_give_the_same_results() {
let source = "vc.log(confirm('a', true, 'T')); vc.log(prompt('b', 'p')); alert('c', undefined, false); vc.log('done');";
let mut app = app();
start(&mut app, source);
for a in ["yes", "ok", "ok"] {
assert!(until_dialog(&mut app));
answer(&mut app, a);
}
assert!(!until_dialog(&mut app));
let framed = app.session.script_report().unwrap().clone();
let mut s = Session::new();
let given = json!([{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "p"}, {"kind": "alert", "value": "ok"}]);
let headless = s.execute("script.run", &json!({"source": source, "name": "d.js", "answers": given})).unwrap();
assert_eq!(framed["log"], headless["log"]);
assert_eq!(framed["dialogs"], headless["dialogs"]);
assert_eq!(framed["log"], json!([true, "p", "done"]));
}
+55 -5
View File
@@ -1569,6 +1569,8 @@ app through the global object `vc`, and writes to the Script Console and runs ot
missing file and a refused path throw errors the script can catch.
- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a
script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`.
- `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])`
open a dialog, described below. `vc.api.has("dialogs")` is true.
- `vc.args` is the `args` object.
- `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature.
@@ -1634,16 +1636,18 @@ VectorCraft sets these limits itself:
| `args` as JSON | 1 MiB |
| Log | 10,000 entries and 4 MiB |
| Script Console | 2,048 lines and 1 MiB of text; 4,096 bytes a line; 256 bytes of a script's name |
| A dialog's message, title and prompt preset | 16 KiB, 256 bytes and 16 KiB |
| `answers` | 10,000 answers and 1 MiB as JSON |
| `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON |
| Growth before a person is asked | 100,000 objects and 512 MiB of new document data |
In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and
`ui.dialog.cancel` all stop the run. A run with nobody to ask, such as a call over MCP or the command line, ends
with an error at that limit. The headless reply is `{ok: true, name, changed, written, log, fuelUsed, hostCalls,
runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console}`. In the app the reply is `{running: true,
name}`, and the report appears later as `scriptReport` in `ui.inspect`. The report of a failed run has the same
fields after `kind`, `message`, `line`, `column`, `file` (when the error is in another file than the script) and
`fuelLeft`. `answers` takes only an empty object, since scripts
have no prompts yet.
runTimeMs, timingAvailable, hostCallCounts, slowestCommands, console, dialogs, dialogCount, dialogsTruncated}`. In
the app the reply is `{running: true, name}`, and the report appears later as `scriptReport` in `ui.inspect`. The
report of a failed run has the same fields after `kind`, `message`, `line`, `column`, `file` (when the error is in
another file than the script) and `fuelLeft`.
- `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are
left out. `timingAvailable` is false on the web, where `runTimeMs` is 0.
@@ -1651,6 +1655,11 @@ have no prompts yet.
- `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id.
- `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as
text.
- `dialogs` lists the run's dialogs in the order they opened, as `{kind, message, title, errorIcon, answer}` for
`alert`, `{kind, message, title, noAsDefault, answer}` for `confirm` and `{kind, message, title, preset, answer}`
for `prompt`. `answer` is `"ok"`, `true`, `false`, the prompt's text, `null` for Cancel, `"stop"` for a dialog the
run ended at, or `"missing"` for a dialog `answers` had no answer for. `dialogCount` counts every dialog, and
`dialogsTruncated` is true when the list stopped at its limit.
```json
{"name":"run_command","arguments":{"command":"script.run","params":{"source":"vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10});"}}}
@@ -1660,6 +1669,47 @@ have no prompts yet.
vectorcraft-cli run --cmd script.run --params '{"path": "Tidy Layers.js", "args": {"margin": 12}}'
```
The help page https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
describes the three dialog functions. A script calls them as globals:
- `alert(message[, title, errorIcon])` shows the message with OK, and an error icon when `errorIcon` is true. It
returns `undefined`.
- `confirm(message[, noAsDflt, title])` shows the message with Yes and No. Yes is the default button, and No is the
default when `noAsDflt` is true. It returns `true` for Yes and `false` for No.
- `prompt(message, preset[, title])` shows the message and a text field that starts with `preset`, with OK and
Cancel. It returns the field's text for OK, an empty string included, and `null` for Cancel.
The message, the preset and a given title are read as `String(value)` gives them, and `errorIcon` and `noAsDflt` as
`Boolean(value)` gives them. A title that is left out or `undefined` is Script Alert. A call without a message, or a
`prompt` without a preset, throws a `TypeError`. A message or a prompt's preset longer than 16 KiB, or a title longer than
256 bytes, throws a `RangeError` and opens no dialog. The dialog shows line feeds, carriage returns and the two
together as line breaks, and other control characters as their Unicode Control Pictures. The title stays on one
line, with line breaks shown as Control Pictures too.
In the app, the run waits at the call until the person answers, and spends no instructions while it waits. Enter
gives the default button's answer. Escape gives Cancel for a prompt, No for a confirm and Stop for an alert. Every
dialog also has Stop, which ends the run as `script.stop` does and puts back every document. A dialog can open only
from the script's own code: called inside a callback of a built-in function such as `Array.prototype.forEach`, a
getter, a `toString` conversion or a file `$.evalFile` runs, it throws an `Error`. The code of an `#include` file
is part of the script's own code, and its dialogs open as the script's do. Quitting while a dialog waits asks whether to stop the
script, and Cancel shows the dialog again with the text typed so far. In the app, `ui.dialog.confirm` answers the
open dialog (kind `scriptDialog`) with its `answer` field (`ok`, `yes`, `no`, `cancel` or `stop`), or with the
default button's answer when `answer` is left out. A prompt's `value` longer than 16 KiB is an error, and the
dialog stays open.
A run with nobody to ask takes its answers from `answers`, a list of `{kind: "alert", value: "ok"}`, `{kind:
"confirm", value: true}` or `{kind: "confirm", value: false}`, and `{kind: "prompt", value: text}` or `{kind:
"prompt", value: null}` for Cancel. Each dialog takes the next answer in order, callbacks of built-in functions
included. The run ends with an error that the script cannot catch, and every document goes back, when no answer is
left, when the next answer is for another kind of dialog, or when the script ends with answers it did not use.
`script.run` refuses a list that is malformed, longer than 10,000 answers or larger than 1 MiB before the run starts,
and refuses any answers for a run in the app, where the person answers. An omitted `answers`, an empty list and an
empty object are the same.
```sh
vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}'
```
Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and
`$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another
file), its end, and a report of
+1
View File
@@ -731,6 +731,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`.
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).