From 1e357a8fc4b03f54935b03668fd5977df28e6341 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 6 Nov 2026 10:07:20 +0100 Subject: [PATCH 1/4] feat(scripts): run a Script pane script's tools off the frame on the desktop --- crates/engine/src/cmd/gp.rs | 19 +++--- crates/engine/src/cmd/reads.rs | 16 +++-- crates/engine/src/cmd/workflows.rs | 79 +++++++++++++++++++--- crates/engine/src/tests.rs | 76 ++++++++++++++++++++++ crates/ui-egui/src/analysispane.rs | 101 ++++++++++++++++++++++++----- crates/ui-egui/src/readjob.rs | 29 +++++---- docs/user-guide/analysis.md | 14 +++- 7 files changed, 278 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index 0d3cacc99..4f54bbb5a 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -511,9 +511,9 @@ pub fn tool_complete(s: &mut Session, id: u64, done: ToolDone) -> Result<(), Str } #[cfg(not(target_arch = "wasm32"))] -/// A tool's run made ahead on a copy of the session, for a model the app runs on a thread of its -/// own ([`super::workflows::model_ahead`]): what it read and what it made, for the model's run -/// here to take instead of running the tool again. +/// A tool's run made ahead on a copy of the session, for a model or script the app runs on a +/// thread of its own ([`super::workflows::ahead_on_copy`]): what it read and what it made, for +/// the run here to take instead of running the tool again. pub(crate) struct RanAhead { tool: String, params: Value, @@ -526,17 +526,18 @@ pub(crate) struct RanAhead { } #[cfg(not(target_arch = "wasm32"))] -/// The tools of a model run ahead: recorded on the copy, then taken in order by the run here. +/// The tools of a model or script run ahead: recorded on the copy, then taken in order by the run +/// here. pub(crate) enum Ahead { Recording(Vec), Taking(std::collections::VecDeque), } #[cfg(not(target_arch = "wasm32"))] -/// What a model's run on a copy of the session stops at, as its run here does them: a tool that -/// writes files (which would be written twice), and plugin and GeoLibre tools, which run as they -/// always have. -const NOT_AHEAD: &str = "runs where the model runs"; +/// What a model's or script's run on a copy of the session stops at, as its run here does them: a +/// tool that writes files (which would be written twice), plugin and GeoLibre tools, which run as +/// they always have, and a script's line that may reach outside the session. +pub(crate) const NOT_AHEAD: &str = "runs where the model or script runs"; #[cfg(not(target_arch = "wasm32"))] /// Runs `run` taking the tools `ran` ahead: each one whose inputs are still the same as the run @@ -1167,7 +1168,7 @@ pub fn run_tool_with( #[cfg(not(target_arch = "wasm32"))] let view = inputs.view.get().copied(); drop(inputs); - // A file made, with a SaveFile parameter or not, is written where the model runs. + // A file made, with a SaveFile parameter or not, is written where the model or script runs. #[cfg(not(target_arch = "wasm32"))] if recording && matches!(&res, Ok(r) if matches!(r.output, Output::File { .. })) { return Err(NOT_AHEAD.into()); diff --git a/crates/engine/src/cmd/reads.rs b/crates/engine/src/cmd/reads.rs index 3e2099bb4..9d6925867 100644 --- a/crates/engine/src/cmd/reads.rs +++ b/crates/engine/src/cmd/reads.rs @@ -1,11 +1,11 @@ //! Reads the desktop app makes on a thread of its own before a command adds what they read //! (#1291 step 3): Add Data's whole-file reads (a raster, a raster GDAL converts, a GeoJSON, -//! delimited text or spreadsheet read whole), Load All Features, and a model's tools, run on a -//! copy of the session. [`read_ahead`] takes what -//! the read needs of the session, [`ReadAhead::run`] reads on the thread, and the app then runs -//! the command as it would have with the read [`Read::placed`] in the session: the command takes -//! it instead of reading again, so what it adds, says, logs and puts in Undo is the same as when -//! it reads here. A script or the command line reads where the command runs. +//! delimited text or spreadsheet read whole), Load All Features, and a model's or a script's +//! tools, run on a copy of the session. [`read_ahead`] takes what the read needs of the session, +//! [`ReadAhead::run`] reads on the thread, and the app then runs the command as it would have +//! with the read [`Read::placed`] in the session: the command takes it instead of reading again, +//! so what it adds, says, logs and puts in Undo is the same as when it reads here. A script's own +//! Add Data lines, and the command line, read where the command runs. use std::any::Any; @@ -64,7 +64,9 @@ pub fn read_ahead(s: &Session, command: &str, p: &Value) -> Option { "layer.addData" => super::layer::add_data_ahead(p), "layer.loadAll" => super::layer::load_all_ahead(s, p), #[cfg(not(target_arch = "wasm32"))] - "model.run" => Some(super::workflows::model_ahead(s, p)), + "model.run" => Some(super::workflows::ahead_on_copy(s, "model.run", p)), + #[cfg(not(target_arch = "wasm32"))] + "script.run" => Some(super::workflows::ahead_on_copy(s, "script.run", p)), _ => None, } } diff --git a/crates/engine/src/cmd/workflows.rs b/crates/engine/src/cmd/workflows.rs index 5ebd9adc3..1665e6347 100644 --- a/crates/engine/src/cmd/workflows.rs +++ b/crates/engine/src/cmd/workflows.rs @@ -501,7 +501,7 @@ fn model_graph(s: &Session, p: &Value) -> Result<(String, crate::project::ModelG } /// Runs a model's graph, or the graph passed: taking the tools the app ran ahead -/// ([`model_ahead`]) whose inputs are still the same. +/// ([`ahead_on_copy`]) whose inputs are still the same. fn run_model(s: &mut Session, p: &Value) -> CmdResult { #[cfg(not(target_arch = "wasm32"))] if let Some(ran) = super::reads::take::>(s, MODEL_AHEAD) { @@ -511,33 +511,77 @@ fn run_model(s: &mut Session, p: &Value) -> CmdResult { } #[cfg(not(target_arch = "wasm32"))] -/// The key of a model's tools run ahead ([`model_ahead`]). +/// The key of a model's tools run ahead ([`ahead_on_copy`]). const MODEL_AHEAD: &str = "model.run"; #[cfg(not(target_arch = "wasm32"))] -/// A model's run on a copy of the session, made on a thread of its own: it records what each of -/// its tools read and made, for `model.run` to take instead of running them again, so what the -/// run adds, says, logs and puts in Undo is the same as when it runs here. It stops at a tool -/// that writes files, a plugin's or GeoLibre's: from there the run here runs them as it always -/// has. What it says and how it ends are the run here's to say. -pub(crate) fn model_ahead(s: &Session, p: &Value) -> super::reads::ReadAhead { +/// The key of a script's tools run ahead ([`ahead_on_copy`]). +const SCRIPT_AHEAD: &str = "script.run"; + +#[cfg(not(target_arch = "wasm32"))] +/// `command` (`model.run` or `script.run`) run on a copy of the session, made on a thread of its +/// own: it records what each tool it runs read and made, for the command to take instead of +/// running them again, so what the run adds, says, logs and puts in Undo is the same as when it +/// runs here. It stops at a tool that writes files, a plugin's or GeoLibre's, and at a script's +/// line that may reach outside the session ([`runs_on_a_copy`]): from there the run here runs +/// them as it always has. What it says and how it ends are the run here's to say. +pub(crate) fn ahead_on_copy(s: &Session, command: &'static str, p: &Value) -> super::reads::ReadAhead { + let name = if command == SCRIPT_AHEAD { "Run Script" } else { "Run Model" }; let (project, trusted) = (s.project.clone(), s.trusted_models.clone()); let (view_size, active_layer, p) = (s.view_size, s.active_layer, p.clone()); // What its tools ask of servers and files goes in this session's Diagnostics, as when they // run here: the run here takes their results and asks nothing. let requests = s.diagnostics.capture(); - super::reads::ahead("Run Model", MODEL_AHEAD.into(), move || { + super::reads::ahead(name, command.into(), move || { let mut copy = Session::new(project); copy.requests_into = Some(requests); copy.trusted_models = trusted; copy.view_size = view_size; copy.active_layer = active_layer; copy.tools_ahead = Some(super::gp::Ahead::Recording(Vec::new())); - let _ = copy.run("model.run", &p); + let _ = copy.run(command, &p); super::gp::ran_ahead(&mut copy) }) } +#[cfg(not(target_arch = "wasm32"))] +/// The commands a script's line may run on a copy of the session ([`runs_on_a_copy`]): those +/// whose results are taken (a tool, a model) and those that change only what the taking compares +/// by value (a selection, a layer's name or query, the environment, the projection). A line that +/// makes rows of its own (an edit, a layer from a selection) isn't one: the rows it made on the +/// copy are never the run here's, so every tool after it would run again. +pub(crate) const ON_A_COPY: &[&str] = &[ + "model.run", + "gp.run", + "gp.spatialJoin", + "gp.zonalStats", + "gp.env", + "select.all", + "select.atPoint", + "select.byAttributes", + "select.byPolygon", + "select.byRadius", + "select.byRect", + "select.clear", + "select.rows", + "select.switch", + "layer.remove", + "layer.rename", + "layer.definitionQuery", + "layer.select", + "map.projection", +]; + +#[cfg(not(target_arch = "wasm32"))] +/// Whether a script's line runs on a copy of the session ([`ahead_on_copy`]): one of +/// [`ON_A_COPY`], naming no file: run twice, none changes anything outside the session. Any +/// other line stops the copy there, Add Data and Load All among them (they would read their +/// files and servers twice). +fn runs_on_a_copy(id: &str, params: &Value) -> bool { + // A model's own tools are checked as they run; any other line by what its parameters name. + ON_A_COPY.contains(&id) && (id == "model.run" || !super::items::touches_files(id, params)) +} + fn run_model_here(s: &mut Session, p: &Value) -> CmdResult { // A model saved as steps runs as steps: `%n` may stand anywhere in them, and inputs may be files. if p.get("graph").is_none_or(Value::is_null) { @@ -658,11 +702,26 @@ fn wire(v: &mut Value, outputs: &[String], step: usize, depth: usize) -> Result< /// any command, files included; notebooks vet cells that came from files before running them. /// The whole script is one undo step. pub(crate) fn run_script(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(not(target_arch = "wasm32"))] + if let Some(ran) = super::reads::take::>(s, SCRIPT_AHEAD) { + return super::gp::taking(s, ran, |s| run_script_here(s, p)); + } + run_script_here(s, p) +} + +/// Runs a script's lines in order ([`run_script`]). +fn run_script_here(s: &mut Session, p: &Value) -> CmdResult { let script = ps(p, "script").ok_or("script is required")?; let mut out = Vec::new(); for (n, id, params) in script_commands(script)? { let spec = s.registry.get(&id).ok_or_else(|| format!("line {n}: unknown command '{id}'"))?; let (run, enabled, mutates, label) = (spec.run, spec.enabled, spec.mutates, spec.label); + // On a copy of the session ([`ahead_on_copy`]): from a line that may reach outside it, + // the run here runs the rest. + #[cfg(not(target_arch = "wasm32"))] + if matches!(s.tools_ahead, Some(super::gp::Ahead::Recording(_))) && !runs_on_a_copy(&id, ¶ms) { + return Err(super::gp::NOT_AHEAD.into()); + } // As when typed directly, a line naming a layer is checked against that layer. if params.get("layer").is_some() && let Ok(lid) = s.layer_id(¶ms) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 181172133..145565812 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -28840,3 +28840,79 @@ fn a_models_tools_run_ahead_are_taken_by_its_run() { assert_eq!(s.map().layer_by_name("Rings").unwrap().dataset.features.len(), 1); std::fs::remove_dir_all(&dir).ok(); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_scripts_tools_run_ahead_are_taken_by_its_run() { + use rustgis_analysis::control::Control; + let session = || { + let mut s = Session::new(Project::default()); + let mut points = rustgis_data::Dataset::new("Points", rustgis_data::GeometryType::Point, Vec::new()); + for (x, y) in [(8.0, 47.0), (9.0, 46.0), (10.0, 45.0)] { + points.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(x, y))), Vec::new()); + } + s.map_mut().add_layer(rustgis_map::Layer::new(0, points)); + s.run("model.new", &json!({"name": "Steps", "steps": [{"tool": "buffer", "params": {"input": "Points", "distance": 1000}}]})) + .unwrap(); + s + }; + let tool = |d: u32| format!(r#"gp.run {{"tool": "buffer", "params": {{"input": "Points", "distance": {d}}}}}"#); + let layers = |s: &Session| { + s.map() + .layers + .iter() + .map(|l| (l.name.clone(), format!("{:?}", l.dataset.features.iter().map(|f| &f.geometry).collect::>()))) + .collect::>() + }; + + // Its tools, a selection between them and a model it runs are taken: what the run adds and + // keeps in history is the same, as one step to undo. + let p = json!({"script": format!("{}\nselect.all {{\"layer\": \"Points\"}}\n{}\nmodel.run {{\"name\": \"Steps\"}}", tool(1000), tool(2000))}); + let mut here = session(); + here.run("script.run", &p).unwrap(); + let mut s = session(); + let depth = s.undo_depth(); + crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s); + let r = s.run("script.run", &p).unwrap(); + assert_eq!(s.tools_taken, 3, "{r}"); + assert!(s.read_ahead.is_none() && s.tools_ahead.is_none()); + assert_eq!(layers(&s), layers(&here)); + assert_eq!(s.project.gp_history.len(), here.project.gp_history.len()); + assert_eq!(s.undo_depth(), depth + 1); + + // A line that may reach outside the session stops the copy: the run here runs it and what + // follows, so the project is saved once. + let dir = std::env::temp_dir().join(format!("rustgis-script-ahead-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let saved = dir.join("saved.rgis"); + let p = json!({"script": format!("{}\nproject.save {}\n{}", tool(1000), json!({"path": saved.display().to_string()}), tool(2000))}); + let mut s = session(); + crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s); + assert!(!saved.exists(), "the copy saved nothing"); + s.run("script.run", &p).unwrap(); + assert_eq!(s.tools_taken, 1); + assert!(saved.exists()); + assert_eq!(s.project.gp_history.len(), 2); + std::fs::remove_dir_all(&dir).ok(); + + // An edit stops the copy (the rows it makes there are never the run here's): the tool + // before it is taken, the one after it runs here. + let p = json!({"script": format!("{}\nedit.addField {{\"layer\": \"Points\", \"name\": \"n\", \"type\": \"long\"}}\n{}", tool(1000), tool(2000))}); + let mut s = session(); + crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s); + s.run("script.run", &p).unwrap(); + assert_eq!(s.tools_taken, 1); + assert!(s.map().layer_by_name("Points").unwrap().dataset.fields.iter().any(|f| f.name == "n")); + + // A selection changed meanwhile: the tools after it run here, on it. + let p = json!({"script": tool(1000)}); + let mut s = session(); + crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s); + s.map_mut().layers[0].selection = vec![0]; + s.run("script.run", &p).unwrap(); + assert_eq!(s.tools_taken, 0); + // Each command a line may run on the copy is one the session has. + for id in crate::cmd::workflows::ON_A_COPY { + assert!(s.registry.get(id).is_some(), "{id}"); + } +} diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 160bf5178..558ed60f9 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -35,6 +35,9 @@ pub struct Workflow { pub script_out: Vec<(String, bool)>, /// The Script pane's last run, shown above its results. pub script_last: Option, + /// When each Script pane run under way or waiting started (seconds since 1970, and the + /// instant), in the order asked, for [`script_ran`] to tell as each lands. + script_started: std::collections::VecDeque<(u64, web_time::Instant)>, pub trend: TrendForm, pub selected_only: Option, pub zonal_stats: ZonalStatsForm, @@ -1550,6 +1553,39 @@ fn script_below(app: &RustGisApp, ui: &Ui) -> f32 { ui.spacing().interact_size.y + gap * 3.0 + last + results } +/// Runs the Script pane's `script`: on the desktop its tools on a thread of their own first, the +/// status bar showing it and Cancel, the results saying so until it lands ([`script_ran`]). +fn start_script(app: &mut RustGisApp, script: String) { + // When it started and how long it took: a tool run outside the app in the browser + // (`gp.finish`) ends later. + let at = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + app.workflow.script_started.push_back((at, web_time::Instant::now())); + app.workflow.script_out = vec![(tl!("Running…").to_string(), false)]; + #[cfg(not(target_arch = "wasm32"))] + let _ = crate::readjob::run_then(app, "script.run", json!({"script": script}), Some(script_ran)); + #[cfg(target_arch = "wasm32")] + { + let r = app.run("script.run", json!({"script": script})); + script_ran(app, crate::Landed::Ran(r)); + } +} + +/// A Script pane run as it lands, in the pane: its number, how long it took, and each line's +/// result, or why it didn't run. +fn script_ran(app: &mut RustGisApp, landed: crate::Landed) { + let (at, started) = app.workflow.script_started.pop_front().unwrap_or((0, web_time::Instant::now())); + let n = app.workflow.script_last.as_ref().map_or(1, |r| r.n.saturating_add(1)); + app.workflow.script_last = Some(ScriptRun { n, secs: started.elapsed().as_secs_f64(), at }); + app.workflow.script_out = match landed { + crate::Landed::Ran(Ok(v)) => v["ran"] + .as_array() + .map(|a| a.iter().map(|r| (format!("{} → {}", r["command"].as_str().unwrap_or(""), r["result"]), false)).collect()) + .unwrap_or_default(), + crate::Landed::Ran(Err(e)) | crate::Landed::NotRun(e) => vec![(e, true)], + }; + app.map.invalidate(); +} + /// The most height a run's results take. fn script_out_height(ui: &Ui) -> f32 { (ui.text_style_height(&egui::TextStyle::Small) + ui.spacing().item_spacing.y) * SCRIPT_OUT_ROWS @@ -1623,21 +1659,7 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { let run = ui.button(tl!("Run")).on_hover_text(crate::i18n::fmt("Run the script ({keys})", &[("keys", shortcut)])).clicked(); if run || ctrl_enter { let script = app.workflow.script.clone(); - // When it started and how long it took: a tool run outside the app in the browser - // (`gp.finish`) ends later. - let at = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); - let started = web_time::Instant::now(); - let out = app.run("script.run", json!({"script": script})); - let n = app.workflow.script_last.as_ref().map_or(1, |r| r.n.saturating_add(1)); - app.workflow.script_last = Some(ScriptRun { n, secs: started.elapsed().as_secs_f64(), at }); - app.workflow.script_out = match out { - Ok(v) => v["ran"] - .as_array() - .map(|a| a.iter().map(|r| (format!("{} → {}", r["command"].as_str().unwrap_or(""), r["result"]), false)).collect()) - .unwrap_or_default(), - Err(e) => vec![(e, true)], - }; - app.map.invalidate(); + start_script(app, script); } if ui.button(tl!("Commands…")).on_hover_text(tl!("Search every command and its parameters")).clicked() { let _ = app.run("ui.commandSearch", Value::Null); @@ -1672,6 +1694,55 @@ mod tests { use super::*; use egui_kittest::kittest::Queryable; + /// A Script pane run works its tools out on a thread of its own: the results say Running… + /// until it lands, runs land in the order asked, and one cancelled or left says so. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_scripts_tools_run_on_a_thread_and_land_in_the_pane() { + let mut app = RustGisApp::with_sample(); + app.reads_off_frame = true; + let layers = app.session.map().layers.len(); + let script = r#"gp.run {"tool": "buffer", "params": {"input": "Cities", "distance": 100}}"#.to_string(); + let landed = |app: &mut RustGisApp| { + let end = std::time::Instant::now() + std::time::Duration::from_secs(60); + while crate::readjob::land(app) { + assert!(std::time::Instant::now() < end, "the script never landed"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; + // The results say Running… until it lands, then each line's result, as Run #1. + start_script(&mut app, script.clone()); + assert_eq!(app.workflow.script_out, vec![("Running…".to_string(), false)]); + assert_eq!(app.session.map().layers.len(), layers, "nothing added before it lands"); + landed(&mut app); + assert_eq!(app.session.map().layers.len(), layers + 1); + assert!( + app.workflow.script_out.first().is_some_and(|(l, err)| !err && l.starts_with("gp.run → ")), + "{:?}", + app.workflow.script_out + ); + assert_eq!(app.workflow.script_last.as_ref().map(|r| r.n), Some(1)); + // Run twice: the second waits for the first, and each lands as its own run. + start_script(&mut app, script.clone()); + start_script(&mut app, script.clone()); + landed(&mut app); + assert_eq!(app.session.map().layers.len(), layers + 3); + assert_eq!(app.workflow.script_last.as_ref().map(|r| r.n), Some(3)); + assert!(app.workflow.script_started.is_empty()); + // Cancelled: nothing added, and the results say so. + start_script(&mut app, script.clone()); + app.reads.cancel_for_tests(); + landed(&mut app); + assert_eq!(app.session.map().layers.len(), layers + 3); + assert_eq!(app.workflow.script_out, vec![("Run Script was cancelled".to_string(), true)]); + // Another project opened meanwhile: it is left, and the notice names it as a script. + start_script(&mut app, script); + app.session.run("project.new", &Value::Null).unwrap(); + landed(&mut app); + assert_eq!(app.workflow.script_out, vec![("Run Script was left: another project was opened".to_string(), true)]); + assert!(app.session.notices.iter().any(|n| n.text == "Run Script was left: another project was opened")); + } + /// Interpolate runs on a thread of its own from the pane (#1291 step 3): the pane goes on while /// it works, a second run waits for the first, and its layer lands through its command, which /// Record Task takes without the raster it handed over (a replay works it out again). diff --git a/crates/ui-egui/src/readjob.rs b/crates/ui-egui/src/readjob.rs index 95e47bed4..4bec2b3e4 100644 --- a/crates/ui-egui/src/readjob.rs +++ b/crates/ui-egui/src/readjob.rs @@ -55,7 +55,7 @@ struct ReadJob { slot: crate::Slot>, } -/// Runs `command` (`layer.addData`, `layer.loadAll` or `model.run`) with `p`: its read on a +/// Runs `command` (`layer.addData`, `layer.loadAll`, `model.run` or `script.run`) with `p`: its read on a /// thread of its own when the desktop app makes it so ([`reads::read_ahead`]), after any under /// way; here and now otherwise, as the command does. While one is under way or waiting it answers /// that, not what the command adds, which comes as it lands. @@ -124,9 +124,14 @@ fn not_run(app: &mut RustGisApp, then: Option, why: String) { } } -/// What a command asked for is called in a notice: an unnamed model by the command's name. -fn called(what: &str) -> &str { - if what.is_empty() { tl_raw!("Run Model") } else { what } +/// What a command asked for is called in a notice: a script, or an unnamed model, by the +/// command's name. +fn called<'a>(command: &str, what: &'a str) -> &'a str { + match (what.is_empty(), command) { + (false, _) => what, + (true, "script.run") => tl_raw!("Run Script"), + (true, _) => tl_raw!("Run Model"), + } } /// A path's last part, as a notice names the file. @@ -222,7 +227,7 @@ pub fn land(app: &mut RustGisApp) -> bool { let Some(done) = done else { return true }; let Some(job) = app.reads.job.take() else { return false }; match (job.gone, placed(app, &job.asked)) { - (Some(why), _) | (None, Err(why)) => left.push((job.asked.what, why, job.asked.then)), + (Some(why), _) | (None, Err(why)) => left.push((called(job.asked.command, &job.asked.what).to_string(), why, job.asked.then)), (None, Ok(p)) => { if job.control.is_cancelled() { // Cancelled (once it had read too): it adds nothing, and nor does what waits. @@ -255,9 +260,9 @@ pub fn land(app: &mut RustGisApp) -> bool { app.session.drop_read(); app.map.invalidate(); } - // A bug in a model's run on a copy of the session: it runs here, as it would - // have. - Err(e) if job.asked.command == "model.run" => { + // A bug in a model's or script's run on a copy of the session: it runs here, + // as it would have. + Err(e) if matches!(job.asked.command, "model.run" | "script.run") => { app.session.notify( crate::i18n::fmt( "{tool} couldn't work in the background ({e}): it ran here", @@ -289,23 +294,23 @@ pub fn land(app: &mut RustGisApp) -> bool { let _ = start(app, asked, p); app.map.invalidate(); } - Err(why) => left.push((asked.what, why, asked.then)), + Err(why) => left.push((called(asked.command, &asked.what).to_string(), why, asked.then)), } } // Those left, one notice for each reason, with a count, naming the first. for (what, why, then) in &left { - not_run(app, *then, crate::i18n::fmt("{what} was left: {why}", &[("what", called(what)), ("why", &why.to_string())])); + not_run(app, *then, crate::i18n::fmt("{what} was left: {why}", &[("what", what), ("why", &why.to_string())])); } for why in [Gone::Project, Gone::Map, Gone::Layer] { let of: Vec<&String> = left.iter().filter(|(_, w, _)| *w == why).map(|(what, ..)| what).collect(); let reason = why.to_string(); match of.as_slice() { [] => {} - [what] => app.session.notify(crate::i18n::fmt("{what} was left: {why}", &[("what", called(what)), ("why", &reason)]), false), + [what] => app.session.notify(crate::i18n::fmt("{what} was left: {why}", &[("what", what), ("why", &reason)]), false), [first, ..] => app.session.notify( crate::i18n::fmt( "{n} files or layers were left, the first {what}: {why}", - &[("n", &of.len().to_string()), ("what", called(first)), ("why", &reason)], + &[("n", &of.len().to_string()), ("what", first), ("why", &reason)], ), false, ), diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index e75f1db6a..aa2149fc9 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -1103,7 +1103,14 @@ The **Script** pane runs commands from text, one per line (`id {json}`); its box short of the buttons, and then scrolls, so **Run** stays in view; Ctrl+Enter (⌘+Enter on macOS) in the box runs the script too. Above a run's results, a line tells its number, how long it took and when it started (`Run #2 (0.84 s)`, and `14:04 UTC` on the right), so a run made again by -mistake shows (Undo takes a whole run back). It colours the commands as you type, in both +mistake shows (Undo takes a whole run back). On the desktop, a script's tools and models, with +selections, layer renames and definition queries, the environment and the projection between +them, are worked out in the background first, so the map and the rest of the window stay in +use: the status bar shows **Run Script** and **Cancel**, and the results say **Running…** until +the run is added. From the first line of any other kind (an edit, Add Data, a save, an export, +a style or a zoom, a plugin or GeoLibre tool, a tool that writes a file) the rest runs as a +script always has, holding the window until it finishes. A tool whose inputs change meanwhile runs again as the +results are added, as in a model's run. It colours the commands as you type, in both themes: `#` comments in green italics, the command id, and the JSON's strings, numbers and `true`/`false`/`null`; an id no command has, or one that can't run in a script (`ui.*`, `script.*`, `notebook.run`), and parameters that aren't valid JSON are red and underlined. A script is read to @@ -1181,8 +1188,9 @@ their name. writes a file, a plugin tool or a GeoLibre tool, with those after it. Those run as a model always has, holding the window until they finish, with the work limits of a run that can't be cancelled. A file a tool reads by its path isn't compared: one changed during the run - gives the result from what was read. A model run from a script, the command line or the - assistant runs where the command runs. + gives the result from what was read. A model run from a script in the Script pane is worked + out in the background with the script; one run from the command line or the assistant runs + where the command runs. - **Save** keeps the model in the project; **Export** writes it as a `*.model.json` file, which **Import** (or dropping the file on the window) opens again. A model from a file may not read or write files. **Copy Command** copies a `model.run` command that runs the model from the From f2cf3fe3a153c9c512268a662656eb8cf1f8d1d8 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 6 Nov 2026 11:36:42 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(scripts):=20queued=20runs=20timed=20fro?= =?UTF-8?q?m=20their=20start,=20Running=E2=80=A6=20kept=20while=20one=20wa?= =?UTF-8?q?its,=20no=20copy=20for=20a=20script=20that=20can't=20use=20it,?= =?UTF-8?q?=20left=20callbacks=20in=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/reads.rs | 2 +- crates/engine/src/cmd/workflows.rs | 9 +++++++++ crates/engine/src/tests.rs | 4 ++++ crates/ui-egui/src/analysispane.rs | 32 ++++++++++++++++++++++++++++-- crates/ui-egui/src/readjob.rs | 28 ++++++++++++++++---------- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/reads.rs b/crates/engine/src/cmd/reads.rs index 9d6925867..eaad7f109 100644 --- a/crates/engine/src/cmd/reads.rs +++ b/crates/engine/src/cmd/reads.rs @@ -66,7 +66,7 @@ pub fn read_ahead(s: &Session, command: &str, p: &Value) -> Option { #[cfg(not(target_arch = "wasm32"))] "model.run" => Some(super::workflows::ahead_on_copy(s, "model.run", p)), #[cfg(not(target_arch = "wasm32"))] - "script.run" => Some(super::workflows::ahead_on_copy(s, "script.run", p)), + "script.run" => super::workflows::script_ahead(s, p), _ => None, } } diff --git a/crates/engine/src/cmd/workflows.rs b/crates/engine/src/cmd/workflows.rs index 1665e6347..acb182653 100644 --- a/crates/engine/src/cmd/workflows.rs +++ b/crates/engine/src/cmd/workflows.rs @@ -544,6 +544,15 @@ pub(crate) fn ahead_on_copy(s: &Session, command: &'static str, p: &Value) -> su }) } +#[cfg(not(target_arch = "wasm32"))] +/// A script's run on a copy of the session ([`ahead_on_copy`]), unless its first line would stop +/// the copy at once (or the script doesn't read): then it runs here, with no copy made. +pub(crate) fn script_ahead(s: &Session, p: &Value) -> Option { + let lines = script_commands(ps(p, "script")?).ok()?; + let (_, id, params) = lines.first()?; + runs_on_a_copy(id, params).then(|| ahead_on_copy(s, SCRIPT_AHEAD, p)) +} + #[cfg(not(target_arch = "wasm32"))] /// The commands a script's line may run on a copy of the session ([`runs_on_a_copy`]): those /// whose results are taken (a tool, a model) and those that change only what the taking compares diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 145565812..ba016b776 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -28911,6 +28911,10 @@ fn a_scripts_tools_run_ahead_are_taken_by_its_run() { s.map_mut().layers[0].selection = vec![0]; s.run("script.run", &p).unwrap(); assert_eq!(s.tools_taken, 0); + // A script whose first line would stop the copy runs here, with no copy made. + let p = json!({"script": format!("edit.addField {{\"layer\": \"Points\", \"name\": \"m\", \"type\": \"long\"}}\n{}", tool(1000))}); + assert!(crate::cmd::reads::read_ahead(&s, "script.run", &p).is_none()); + assert!(crate::cmd::reads::read_ahead(&s, "script.run", &json!({"script": "# nothing"})).is_none()); // Each command a line may run on the copy is one the session has. for id in crate::cmd::workflows::ON_A_COPY { assert!(s.registry.get(id).is_some(), "{id}"); diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 558ed60f9..8d208f1f5 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -38,6 +38,8 @@ pub struct Workflow { /// When each Script pane run under way or waiting started (seconds since 1970, and the /// instant), in the order asked, for [`script_ran`] to tell as each lands. script_started: std::collections::VecDeque<(u64, web_time::Instant)>, + /// When the last Script pane run landed: one waiting behind it started then, not when asked. + script_landed: Option, pub trend: TrendForm, pub selected_only: Option, pub zonal_stats: ZonalStatsForm, @@ -1573,9 +1575,14 @@ fn start_script(app: &mut RustGisApp, script: String) { /// A Script pane run as it lands, in the pane: its number, how long it took, and each line's /// result, or why it didn't run. fn script_ran(app: &mut RustGisApp, landed: crate::Landed) { - let (at, started) = app.workflow.script_started.pop_front().unwrap_or((0, web_time::Instant::now())); + let now = web_time::Instant::now(); + let (asked_at, asked) = app.workflow.script_started.pop_front().unwrap_or((0, now)); + // One that waited for the run before it started as that one landed. + let started = app.workflow.script_landed.filter(|&l| l > asked).unwrap_or(asked); + let at = if asked_at == 0 { 0 } else { asked_at.saturating_add(started.duration_since(asked).as_secs()) }; + app.workflow.script_landed = Some(now); let n = app.workflow.script_last.as_ref().map_or(1, |r| r.n.saturating_add(1)); - app.workflow.script_last = Some(ScriptRun { n, secs: started.elapsed().as_secs_f64(), at }); + app.workflow.script_last = Some(ScriptRun { n, secs: now.duration_since(started).as_secs_f64(), at }); app.workflow.script_out = match landed { crate::Landed::Ran(Ok(v)) => v["ran"] .as_array() @@ -1583,6 +1590,10 @@ fn script_ran(app: &mut RustGisApp, landed: crate::Landed) { .unwrap_or_default(), crate::Landed::Ran(Err(e)) | crate::Landed::NotRun(e) => vec![(e, true)], }; + // Another still to land: the results say so below this one's. + if !app.workflow.script_started.is_empty() { + app.workflow.script_out.push((tl!("Running…").to_string(), false)); + } app.map.invalidate(); } @@ -1743,6 +1754,23 @@ mod tests { assert!(app.session.notices.iter().any(|n| n.text == "Run Script was left: another project was opened")); } + /// A Script pane run that waited for the one before it is timed from when that one landed, + /// and the results say Running… while another is still to land. + #[test] + fn a_queued_script_run_is_timed_from_its_start() { + let mut app = RustGisApp::with_sample(); + let asked = web_time::Instant::now() - std::time::Duration::from_secs(10); + app.workflow.script_started.extend([(1_000, asked), (1_000, asked)]); + script_ran(&mut app, crate::Landed::Ran(Ok(json!({"ran": []})))); + let first = app.workflow.script_last.as_ref().map(|r| (r.secs, r.at)).unwrap(); + assert!(first.0 >= 10.0 && first.1 == 1_000, "{first:?}"); + assert_eq!(app.workflow.script_out, vec![("Running…".to_string(), false)]); + script_ran(&mut app, crate::Landed::NotRun("cancelled".into())); + let second = app.workflow.script_last.as_ref().map(|r| (r.secs, r.at)).unwrap(); + assert!(second.0 < 5.0 && second.1 >= 1_010, "{second:?}"); + assert_eq!(app.workflow.script_out, vec![("cancelled".to_string(), true)]); + } + /// Interpolate runs on a thread of its own from the pane (#1291 step 3): the pane goes on while /// it works, a second run waits for the first, and its layer lands through its command, which /// Record Task takes without the raster it handed over (a replay works it out again). diff --git a/crates/ui-egui/src/readjob.rs b/crates/ui-egui/src/readjob.rs index 4bec2b3e4..793ce1685 100644 --- a/crates/ui-egui/src/readjob.rs +++ b/crates/ui-egui/src/readjob.rs @@ -55,10 +55,10 @@ struct ReadJob { slot: crate::Slot>, } -/// Runs `command` (`layer.addData`, `layer.loadAll`, `model.run` or `script.run`) with `p`: its read on a -/// thread of its own when the desktop app makes it so ([`reads::read_ahead`]), after any under -/// way; here and now otherwise, as the command does. While one is under way or waiting it answers -/// that, not what the command adds, which comes as it lands. +/// Runs `command` (`layer.addData`, `layer.loadAll`, `model.run` or `script.run`) with `p`: its +/// read on a thread of its own when the desktop app makes it so ([`reads::read_ahead`]), after +/// any under way; here and now otherwise, as the command does. While one is under way or waiting +/// it answers that, not what the command adds, which comes as it lands. pub fn run(app: &mut RustGisApp, command: &'static str, p: Value) -> Result { run_then(app, command, p, None) } @@ -124,6 +124,13 @@ fn not_run(app: &mut RustGisApp, then: Option, why: String) { } } +/// `asked` left for `why`: its `then` told now, and kept in `left` for the notice. +fn leave(app: &mut RustGisApp, left: &mut Vec<(String, Gone)>, asked: &Asked, why: Gone) { + let what = called(asked.command, &asked.what).to_string(); + not_run(app, asked.then, crate::i18n::fmt("{what} was left: {why}", &[("what", &what), ("why", &why.to_string())])); + left.push((what, why)); +} + /// What a command asked for is called in a notice: a script, or an unnamed model, by the /// command's name. fn called<'a>(command: &str, what: &'a str) -> &'a str { @@ -215,7 +222,9 @@ pub fn land(app: &mut RustGisApp) -> bool { if !busy(app) { return false; } - let mut left: Vec<(String, Gone, Option)> = Vec::new(); + // Those left, for the notices below; each one's `then` is told as it is left, so callers + // hear in the order asked, before a later one starts. + let mut left: Vec<(String, Gone)> = Vec::new(); // What it was for is gone: it is cancelled, and said with the rest below once it stops. let gone = app.reads.job.as_ref().filter(|j| j.gone.is_none()).and_then(|j| placed(app, &j.asked).err()); if let (Some(why), Some(job)) = (gone, app.reads.job.as_mut()) { @@ -227,7 +236,7 @@ pub fn land(app: &mut RustGisApp) -> bool { let Some(done) = done else { return true }; let Some(job) = app.reads.job.take() else { return false }; match (job.gone, placed(app, &job.asked)) { - (Some(why), _) | (None, Err(why)) => left.push((called(job.asked.command, &job.asked.what).to_string(), why, job.asked.then)), + (Some(why), _) | (None, Err(why)) => leave(app, &mut left, &job.asked, why), (None, Ok(p)) => { if job.control.is_cancelled() { // Cancelled (once it had read too): it adds nothing, and nor does what waits. @@ -294,15 +303,12 @@ pub fn land(app: &mut RustGisApp) -> bool { let _ = start(app, asked, p); app.map.invalidate(); } - Err(why) => left.push((called(asked.command, &asked.what).to_string(), why, asked.then)), + Err(why) => leave(app, &mut left, &asked, why), } } // Those left, one notice for each reason, with a count, naming the first. - for (what, why, then) in &left { - not_run(app, *then, crate::i18n::fmt("{what} was left: {why}", &[("what", what), ("why", &why.to_string())])); - } for why in [Gone::Project, Gone::Map, Gone::Layer] { - let of: Vec<&String> = left.iter().filter(|(_, w, _)| *w == why).map(|(what, ..)| what).collect(); + let of: Vec<&String> = left.iter().filter(|(_, w)| *w == why).map(|(what, _)| what).collect(); let reason = why.to_string(); match of.as_slice() { [] => {} From 24207af146e2f22a3fae8a7306255ca1da13525e Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 6 Nov 2026 12:08:35 +0100 Subject: [PATCH 3/4] docs(scripts): link the renamed ahead_on_copy --- crates/engine/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2179a15b6..c8fdc023e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -758,7 +758,7 @@ pub struct Session { #[cfg(not(target_arch = "wasm32"))] pub(crate) tools_ahead: Option, /// Where the requests a copy of the session makes for a model's run ahead go - /// ([`cmd::workflows::model_ahead`]): the session's own Diagnostics, as when they're made + /// ([`cmd::workflows::ahead_on_copy`]): the session's own Diagnostics, as when they're made /// there. Its notices stay its own: the run there says them. #[cfg(not(target_arch = "wasm32"))] pub(crate) requests_into: Option, From b982df72e62f3033342261feb1c7d113657b6a5b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 6 Nov 2026 12:35:20 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(scripts):=20a=20waiting=20run's=20Runni?= =?UTF-8?q?ng=E2=80=A6=20shows=20first,=20in=20view=20above=20long=20resul?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/analysispane.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 8d208f1f5..a783df0cb 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1590,9 +1590,10 @@ fn script_ran(app: &mut RustGisApp, landed: crate::Landed) { .unwrap_or_default(), crate::Landed::Ran(Err(e)) | crate::Landed::NotRun(e) => vec![(e, true)], }; - // Another still to land: the results say so below this one's. + // Another still to land: the results say so first, above this one's, where their scroll + // starts, however many lines it ran. if !app.workflow.script_started.is_empty() { - app.workflow.script_out.push((tl!("Running…").to_string(), false)); + app.workflow.script_out.insert(0, (tl!("Running…").to_string(), false)); } app.map.invalidate(); } @@ -1761,10 +1762,13 @@ mod tests { let mut app = RustGisApp::with_sample(); let asked = web_time::Instant::now() - std::time::Duration::from_secs(10); app.workflow.script_started.extend([(1_000, asked), (1_000, asked)]); - script_ran(&mut app, crate::Landed::Ran(Ok(json!({"ran": []})))); + let ran: Vec = (1..=8).map(|n| json!({"line": n, "command": "select.all", "result": null})).collect(); + script_ran(&mut app, crate::Landed::Ran(Ok(json!({"ran": ran})))); let first = app.workflow.script_last.as_ref().map(|r| (r.secs, r.at)).unwrap(); assert!(first.0 >= 10.0 && first.1 == 1_000, "{first:?}"); - assert_eq!(app.workflow.script_out, vec![("Running…".to_string(), false)]); + // First, where the results' scroll starts, above its eight lines. + assert_eq!(app.workflow.script_out.len(), 9); + assert_eq!(app.workflow.script_out[0], ("Running…".to_string(), false)); script_ran(&mut app, crate::Landed::NotRun("cancelled".into())); let second = app.workflow.script_last.as_ref().map(|r| (r.secs, r.at)).unwrap(); assert!(second.0 < 5.0 && second.1 >= 1_010, "{second:?}");