effectcraft/crates/script/src/ui.rs

357 lines
14 KiB
Rust

//! ScriptUI hosts: who owns a script window's JavaScript objects, and how user actions reach
//! its handlers.
//!
//! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves
//! to that thread while JavaScript runs and comes back whenever the script stops: at the end,
//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there
//! again, runs the handler, and brings it back with the windows' new state. The host ends
//! when its last window closes. Only one side holds the session at a time, so nothing runs
//! concurrently and every edit is still an ordinary undoable engine command.
//! * **The Script Console** (and every run on the web) runs inline: its windows' handlers run in
//! the console's (or a kept) context on the caller's thread, and dialogs don't block.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::mem::ManuallyDrop;
use std::sync::atomic::{AtomicU32, Ordering};
use boa_engine::{Context, JsResult, JsValue, js_string};
use effectcraft_engine::Session;
use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout};
use serde_json::{Value as J, json};
use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active};
pub(crate) const SCRIPTUI: &str = include_str!("scriptui.js");
/// The Script Console's host id.
pub(crate) const CONSOLE_HOST: u32 = u32::MAX;
static NEXT_WINDOW: AtomicU32 = AtomicU32::new(1);
static NEXT_HOST: AtomicU32 = AtomicU32::new(1);
pub(crate) fn new_host_id() -> u32 {
NEXT_HOST.fetch_add(1, Ordering::Relaxed)
}
thread_local! {
/// Contexts of inline runs whose palettes / panels are still open (dropped when they close).
static INLINE: RefCell<BTreeMap<u32, ManuallyDrop<Context>>> = const { RefCell::new(BTreeMap::new()) };
}
pub(crate) fn keep_inline(host: u32, ctx: Context) {
INLINE.with(|m| m.borrow_mut().insert(host, ManuallyDrop::new(ctx)));
}
fn drop_inline(host: u32) {
if let Some(ctx) = INLINE.with(|m| m.borrow_mut().remove(&host)) {
drop(ManuallyDrop::into_inner(ctx));
}
}
// ---------------------------------------------------------------- JavaScript side
fn call_global(ctx: &mut Context, name: &str, args: &[JsValue]) -> JsResult<JsValue> {
let f = ctx.global_object().get(js_string!(name), ctx)?;
let f = f.as_object().ok_or_else(|| throw(format!("{name} is not defined")))?;
f.call(&JsValue::undefined(), args, ctx)
}
/// The open windows of a context (closed ones are reported once, invisible, then forgotten).
pub(crate) fn snapshot(ctx: &mut Context) -> Vec<ScriptWindow> {
let Ok(v) = call_global(ctx, "__uiSnapshot", &[]) else { return vec![] };
let s = v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
serde_json::from_str(&s).unwrap_or_default()
}
/// Run a window event's handlers in `ctx`.
fn dispatch_js(ctx: &mut Context, ev: &ScriptUiEvent) -> Result<J, ScriptError> {
let args = [JsValue::from(ev.window), JsValue::from(ev.widget), js_str(&ev.kind), js_str(&ev.value.to_string())];
match call_global(ctx, "__uiDispatch", &args) {
Ok(_) => Ok(J::Null),
Err(e) => {
let mut err = script_error(&e, ctx, "ScriptUI handler", "");
err.message = format!("{} handler: {}", ev.kind, err.message);
Err(err)
}
}
}
/// `__uiNewId()`: a window id unique in the process.
pub(crate) fn native_new_id(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
Ok(JsValue::from(NEXT_WINDOW.fetch_add(1, Ordering::Relaxed)))
}
/// `__uiLayoutNative(windowJson)` → `{controlId: [x, y, width, height]}`: ScriptUI's automatic
/// layout ([`effectcraft_engine::scriptui::layout`]) for `layout.layout()`.
pub(crate) fn native_layout(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let s = arg_string(args, 0, ctx)?;
let mut w: ScriptWindow = serde_json::from_str(&s).map_err(|e| throw(format!("layout: {e}")))?;
layout(&mut w);
let mut all = vec![];
w.root.walk(&mut all);
let map: serde_json::Map<String, J> = all.iter().map(|c| (c.id.to_string(), json!(c.bounds))).collect();
Ok(js_str(&J::Object(map).to_string()))
}
/// `__uiModal(windowId)`: a dialog's `show()`. On a script-host thread it hands the session back
/// to the caller and waits for the dialog's events until it closes, returning its result. Inline
/// (Script Console, web) it returns `null` at once: the dialog stays open like a palette.
pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let id = args.first().cloned().unwrap_or_default().to_number(ctx)? as u32;
let linked = with_active(|a| Ok(a.link.is_some())).unwrap_or(false);
if !linked {
return Ok(JsValue::null());
}
loop {
let r = call_global(ctx, "__uiModalResult", &[JsValue::from(id)])?;
if !r.is_null() && !r.is_undefined() {
return Ok(r);
}
let windows = snapshot(ctx);
let out = crate::Outcome { output: settle(), waiting: true, ..Default::default() };
let Some(ev) = yield_wait(out, windows, false) else {
// The caller went away: cancel the dialog.
return Ok(JsValue::from(2));
};
if let Err(e) = dispatch_js(ctx, &ev) {
let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default();
let _ = with_active(|a| {
a.output.push(format!("Error{line}: {}", e.message));
Ok(())
});
}
}
}
// ---------------------------------------------------------------- script-host threads
pub(crate) enum ToHost {
/// Run the script (script-host threads exist only off the web: the browser runs scripts
/// inline).
#[cfg(not(target_arch = "wasm32"))]
Start(Session),
Event(Session, ScriptUiEvent),
}
impl ToHost {
/// The session a message carries (given back when the host has gone).
fn into_session(self) -> Session {
match self {
#[cfg(not(target_arch = "wasm32"))]
ToHost::Start(s) => s,
ToHost::Event(s, _) => s,
}
}
}
pub(crate) struct FromHost {
session: Session,
outcome: crate::Outcome,
windows: Vec<ScriptWindow>,
name: String,
/// The host thread has ended (no windows left).
done: bool,
}
/// The host thread's end of the hand-off.
pub(crate) struct Link {
rx: std::sync::mpsc::Receiver<ToHost>,
tx: std::sync::mpsc::Sender<FromHost>,
}
/// The caller's end.
struct HostEnd {
tx: std::sync::mpsc::Sender<ToHost>,
rx: std::sync::mpsc::Receiver<FromHost>,
}
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
static H: std::sync::OnceLock<std::sync::Mutex<BTreeMap<u32, HostEnd>>> = std::sync::OnceLock::new();
H.get_or_init(Default::default)
}
// The session crosses to script-host threads and back.
const _: fn() = || {
fn send<T: Send>() {}
send::<Session>();
};
/// On the host thread: hand the session (with the windows' state and what the script printed)
/// back to the caller, and wait for the next event. `None` when the caller has gone.
fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
let (session, link, name) = ACTIVE.with(|a| {
let mut a = a.borrow_mut();
let a = a.as_mut()?;
Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone()))
})?;
if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() {
return None;
}
if done {
return None;
}
match link.rx.recv() {
Ok(ToHost::Event(s, ev)) => {
ACTIVE.with(|a| {
if let Some(a) = a.borrow_mut().as_mut() {
a.session = s;
a.link = Some(link);
}
});
Some(ev)
}
_ => None,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn host_main(name: String, code: String, link: Link) {
let Ok(ToHost::Start(session)) = link.rx.recv() else { return };
let mut a = Active::new(session, &name);
a.link = Some(link);
ACTIVE.with(|c| *c.borrow_mut() = Some(a));
let file = if name.is_empty() { "script".to_string() } else { name.clone() };
let body = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut ctx = match crate::runtime::new_context() {
Ok(c) => c,
Err(e) => {
yield_wait(outcome(settle(), Err(ScriptError { message: e, file: file.clone(), ..Default::default() })), vec![], true);
return;
}
};
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file);
let mut out = outcome(settle(), r);
loop {
let windows = snapshot(&mut ctx);
let open = windows.iter().any(|w| w.visible);
match yield_wait(out, windows, !open) {
Some(ev) => {
let r = dispatch_js(&mut ctx, &ev);
out = outcome(settle(), r);
}
None => return,
}
}
}));
// A panic: give the session back all the same.
let left = ACTIVE.with(|c| c.borrow_mut().take());
if let Some(mut a) = left
&& let Some(link) = a.link.take()
{
let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" };
let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() };
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true });
}
}
/// Wait for the host to hand the session back; keep its link while it has windows open.
fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
match end.rx.recv() {
Ok(FromHost { session: s, outcome, windows, name, done }) => {
*session = s;
publish(session, host, &name, windows);
if done {
session.script_ui.windows.retain(|w| w.host != host);
session.script_ui.revision += 1;
} else if let Ok(mut h) = hosts().lock() {
h.insert(host, end);
}
outcome
}
Err(_) => crate::Outcome { error: Some(ScriptError { message: "the script host stopped".into(), ..Default::default() }), ..Default::default() },
}
}
/// Run a script file on a new script-host thread (see the module docs). Not on the web, where
/// scripts run inline.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome {
let host = new_host_id();
let (to_tx, to_rx) = std::sync::mpsc::channel();
let (from_tx, from_rx) = std::sync::mpsc::channel();
let (name, code) = (req.name.to_string(), req.code.to_string());
let spawned =
std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx }));
if spawned.is_err() {
return crate::runtime::run_inline(session, req);
}
if let Err(std::sync::mpsc::SendError(msg)) = to_tx.send(ToHost::Start(std::mem::take(session))) {
*session = msg.into_session();
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
}
receive(session, host, HostEnd { tx: to_tx, rx: from_rx })
}
/// Record the windows a host published (named after the script that made them).
pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
if w.script.is_empty() {
w.script = name.to_string();
}
}
let had = session.script_ui.windows.iter().any(|w| w.host == host);
if windows.is_empty() && !had {
return;
}
session.script_ui.publish(host, windows);
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) {
drop_inline(host);
}
}
/// The [`effectcraft_engine::scriptui::ScriptUiDispatch`] this crate provides: run a window
/// event's handlers in the script that made the window, then publish its windows' new state.
/// Returns the handler run's `{ok, output, error, waiting?}`.
pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result<J, String> {
if ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)) {
return Err("scripts can't drive script windows".into());
}
let w = session.script_ui.window(ev.window).ok_or_else(|| format!("no script window {}", ev.window))?;
let (host, name) = (w.host, w.script.clone());
let inline = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
if inline {
return Ok(dispatch_inline(session, host, &name, ev).to_json());
}
let end = hosts().lock().ok().and_then(|mut h| h.remove(&host));
let Some(end) = end else {
session.script_ui.windows.retain(|w| w.host != host);
session.script_ui.revision += 1;
return Err("the script that made this window has ended".into());
};
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
*session = msg.into_session();
session.script_ui.windows.retain(|w| w.host != host);
session.script_ui.revision += 1;
return Err("the script that made this window has ended".into());
}
Ok(receive(session, host, end).to_json())
}
fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> crate::Outcome {
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name)));
let guard = Restore { target: session };
let (r, windows) = if host == CONSOLE_HOST {
CONSOLE.with(|c| match c.borrow_mut().as_mut() {
Some(ctx) => {
let r = dispatch_js(ctx, ev);
(r, snapshot(ctx))
}
None => (Ok(J::Null), vec![]),
})
} else {
INLINE.with(|m| match m.borrow_mut().get_mut(&host) {
Some(ctx) => {
let r = dispatch_js(ctx, ev);
(r, snapshot(ctx))
}
None => (Ok(J::Null), vec![]),
})
};
let output = settle();
drop(guard);
publish(session, host, name, windows);
outcome(output, r)
}