From c56e3a817ddbdb686e6239cb10152451c6edcd4f Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Tue, 4 Aug 2026 21:13:35 +0800 Subject: [PATCH 1/2] feat(io): parse LC method metadata --- crates/io/src/mass_spec.rs | 80 +++++++++++++++++ crates/io/src/waters.rs | 16 +++- crates/io/src/waters/inlet.rs | 161 ++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 crates/io/src/waters/inlet.rs diff --git a/crates/io/src/mass_spec.rs b/crates/io/src/mass_spec.rs index ed8ca44..3ec6507 100644 --- a/crates/io/src/mass_spec.rs +++ b/crates/io/src/mass_spec.rs @@ -141,6 +141,86 @@ pub struct ChromatogramChannel { pub values: Vec, } +/// One programmed composition in a liquid-chromatography gradient method. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LcGradientPoint { + pub time_min: f64, + pub flow_ml_min: f64, + pub percent_b: f64, +} + +/// The method information needed to relate a chromatographic retention time to +/// the programmed mobile-phase composition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LiquidChromatographyMethod { + pub name: Option, + pub run_time_min: f64, + pub solvent_a: Option, + pub solvent_b: Option, + pub gradient: Vec, + pub detector_wavelengths_nm: Vec, + pub column: Option, +} + +impl LiquidChromatographyMethod { + pub fn validate(&self) -> Result<(), String> { + if !self.run_time_min.is_finite() || self.run_time_min <= 0.0 { + return Err("LC method has an invalid run time".to_owned()); + } + if self.gradient.len() < 2 { + return Err("LC method needs at least two gradient points".to_owned()); + } + let mut previous = f64::NEG_INFINITY; + for point in &self.gradient { + if !point.time_min.is_finite() + || point.time_min < 0.0 + || point.time_min <= previous + || !point.flow_ml_min.is_finite() + || point.flow_ml_min <= 0.0 + || !point.percent_b.is_finite() + || !(0.0..=100.0).contains(&point.percent_b) + { + return Err("LC method has an invalid gradient point".to_owned()); + } + previous = point.time_min; + } + if self + .gradient + .last() + .is_some_and(|point| point.time_min > self.run_time_min) + { + return Err("LC method gradient extends past its run time".to_owned()); + } + if self + .detector_wavelengths_nm + .iter() + .any(|value| !value.is_finite() || *value <= 0.0) + { + return Err("LC method has an invalid detector wavelength".to_owned()); + } + Ok(()) + } + + /// Linearly interpolate the programmed B composition between time points. + pub fn percent_b_at(&self, time_min: f64) -> Option { + if !time_min.is_finite() || time_min < 0.0 { + return None; + } + let first = self.gradient.first()?; + if time_min <= first.time_min { + return Some(first.percent_b); + } + for pair in self.gradient.windows(2) { + let [left, right] = pair else { continue }; + if time_min <= right.time_min { + let fraction = (time_min - left.time_min) / (right.time_min - left.time_min); + return Some(left.percent_b + fraction * (right.percent_b - left.percent_b)); + } + } + self.gradient.last().map(|point| point.percent_b) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MassSpecRun { pub source: String, diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 57ff0fc..987de3b 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -11,6 +11,8 @@ use std::path::{Path, PathBuf}; mod chromatograms; use chromatograms::parse_auxiliary_channels; +mod inlet; +pub use inlet::load as load_inlet_method; mod metadata; use metadata::{ FunctionRecord, classify_function, parse_function_table, parse_header, parse_polarities, @@ -702,10 +704,16 @@ fn unsupported(id: AcquisitionStreamId, layout: &Layout, instrument: Option<&str } fn provenance(bundle: &Bundle) -> Provenance { - let mut parameter_paths = ["_header.txt", "_functns.inf", "_extern.inf", "_chroms.inf"] - .into_iter() - .filter_map(|name| bundle.file(name).cloned()) - .collect::>(); + let mut parameter_paths = [ + "_header.txt", + "_functns.inf", + "_extern.inf", + "_chroms.inf", + "_inlet.inf", + ] + .into_iter() + .filter_map(|name| bundle.file(name).cloned()) + .collect::>(); parameter_paths.sort(); let mut companion_paths = bundle .functions diff --git a/crates/io/src/waters/inlet.rs b/crates/io/src/waters/inlet.rs new file mode 100644 index 0000000..3e0895b --- /dev/null +++ b/crates/io/src/waters/inlet.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::{LcGradientPoint, LiquidChromatographyMethod}; + +/// Read the human-readable inlet method embedded in a MassLynx RAW bundle. +/// A missing method is distinct from a malformed method because callers can +/// supply an explicit method for otherwise valid LC–MS data. +pub fn load(path: &Path) -> Result, IoError> { + let bundle = Bundle::discover(path)?; + let Some(path) = bundle.file("_inlet.inf") else { + return Ok(None); + }; + parse(&std::fs::read(path)?).map(Some) +} + +pub(super) fn parse(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut name = None; + let mut run_time_min = None; + let mut solvent_a = None; + let mut solvent_b = None; + let mut gradient = Vec::new(); + let mut detector_wavelengths_nm = Vec::new(); + let mut column = None; + let mut in_gradient = false; + + for raw_line in text.lines() { + let line = raw_line.trim(); + if let Some(value) = value_after(line, "Inlet Method File:") { + name = Some(clean(value)); + } else if run_time_min.is_none() + && let Some(value) = value_after(line, "Run Time:") + { + run_time_min = first_number(value); + } else if let Some(value) = value_after(line, "Solvent Name A:") { + solvent_a = Some(clean(value)); + } else if let Some(value) = value_after(line, "Solvent Name B:") { + solvent_b = Some(clean(value)); + } else if line.eq_ignore_ascii_case("[Gradient Table]") { + in_gradient = true; + } else if in_gradient && line.starts_with("Run Events:") { + in_gradient = false; + } else if in_gradient { + if let Some(point) = gradient_point(line)? { + gradient.push(point); + } + } else if let Some(value) = value_after(line, "Wavelength:") + && let Some(wavelength) = first_number(value) + { + detector_wavelengths_nm.push(wavelength); + } else if let Some(value) = value_after(line, "Column Type:") { + column = Some(clean(value)); + } + } + + detector_wavelengths_nm.sort_by(f64::total_cmp); + detector_wavelengths_nm.dedup_by(|left, right| left.total_cmp(right).is_eq()); + let method = LiquidChromatographyMethod { + name, + run_time_min: run_time_min.ok_or_else(|| invalid("_INLET.INF has no pump run time"))?, + solvent_a, + solvent_b, + gradient, + detector_wavelengths_nm, + column, + }; + method.validate().map_err(invalid)?; + Ok(method) +} + +fn gradient_point(line: &str) -> Result, IoError> { + let mut tokens = line.split_whitespace(); + let Some(index) = tokens.next() else { + return Ok(None); + }; + if !index.ends_with('.') + || !index[..index.len() - 1] + .bytes() + .all(|byte| byte.is_ascii_digit()) + { + return Ok(None); + } + let values = tokens.collect::>(); + let (time, flow, percent_b) = if values + .first() + .is_some_and(|value| value.eq_ignore_ascii_case("Initial")) + { + ( + 0.0, + parse_number(values.get(1), "initial flow rate")?, + parse_number(values.get(3), "initial %B")?, + ) + } else { + ( + parse_number(values.first(), "gradient time")?, + parse_number(values.get(1), "gradient flow rate")?, + parse_number(values.get(3), "gradient %B")?, + ) + }; + Ok(Some(LcGradientPoint { + time_min: time, + flow_ml_min: flow, + percent_b, + })) +} + +fn parse_number(value: Option<&&str>, label: &str) -> Result { + value + .and_then(|value| value.parse().ok()) + .filter(|value: &f64| value.is_finite()) + .ok_or_else(|| invalid(format!("_INLET.INF has invalid {label}"))) +} + +fn first_number(value: &str) -> Option { + value + .split_whitespace() + .find_map(|token| token.parse::().ok()) + .filter(|value| value.is_finite()) +} + +fn value_after<'a>(line: &'a str, label: &str) -> Option<&'a str> { + line.get(..label.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(label)) + .map(|_| line[label.len()..].trim()) + .filter(|value| !value.is_empty()) +} + +fn clean(value: &str) -> String { + value.replace('\u{fffd}', "").trim().to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_acquity_gradient_and_detector_details() { + let method = parse( + br#"Inlet Method File: d:\methods\5-95 +-- PUMP -- + Run Time: 10.00 min + Solvent Name A: Water + acid + Solvent Name B: Acetonitrile + acid + [Gradient Table] + Time(min) Flow Rate %A %B Curve + 1. Initial 0.300 95.0 5.0 Initial + 2. 6.00 0.300 5.0 95.0 6 + 3. 8.00 0.300 5.0 95.0 1 + 4. 10.00 0.300 95.0 5.0 1 + Run Events: Yes + Wavelength: 214 nm + Wavelength: 254 nm + Column Type: ACQUITY Protein BEH C4 +"#, + ) + .unwrap(); + assert_eq!(method.gradient.len(), 4); + assert_eq!(method.percent_b_at(3.0), Some(50.0)); + assert_eq!(method.detector_wavelengths_nm, [214.0, 254.0]); + assert_eq!(method.column.as_deref(), Some("ACQUITY Protein BEH C4")); + } +} From 428a0e09e862c1dba313bb41765eede85afc143d Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Tue, 4 Aug 2026 21:13:54 +0800 Subject: [PATCH 2/2] feat(app): add sandboxed scientific script workflow --- Cargo.lock | 73 +++++ Cargo.toml | 1 + crates/app/Cargo.toml | 1 + crates/app/src/ui/batch_workflow.rs | 236 ++++++++++++++ .../src/ui/batch_workflow/script_support.rs | 140 +++++++++ crates/app/src/ui/batch_workflow_tests.rs | 88 ++++++ crates/app/src/ui/command_exec.rs | 22 +- crates/app/src/ui/commands.rs | 13 +- crates/app/src/ui/commands/identity.rs | 2 + crates/app/src/ui/commands_mass_spec_tests.rs | 21 ++ crates/app/src/ui/mod.rs | 1 + crates/app/src/ui/scientific_script.rs | 295 ++++++++++++++++++ crates/core/src/automation/resources.rs | 19 +- .../src/automation/resources/selection.rs | 42 +++ crates/core/src/automation/tests.rs | 17 +- deny.toml | 5 +- 16 files changed, 953 insertions(+), 23 deletions(-) create mode 100644 crates/app/src/ui/batch_workflow/script_support.rs create mode 100644 crates/app/src/ui/batch_workflow_tests.rs create mode 100644 crates/app/src/ui/scientific_script.rs create mode 100644 crates/core/src/automation/resources/selection.rs diff --git a/Cargo.lock b/Cargo.lock index fddc5f9..fc4b648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,6 +3360,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3767,6 +3776,9 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] [[package]] name = "option-ext" @@ -4060,6 +4072,7 @@ dependencies = [ "plotx-render", "raw-window-handle", "rfd", + "rhai", "serde_json", "uuid", "windows-sys 0.61.2", @@ -4666,6 +4679,36 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rhai" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" +dependencies = [ + "ahash", + "bitflags 2.13.0", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ring" version = "0.17.14" @@ -5109,6 +5152,21 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] [[package]] name = "smithay-client-toolkit" @@ -5182,6 +5240,12 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5377,6 +5441,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index d6118ad..81fd35a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ egui-phosphor = "0.12" fontdb = "0.23" muda = { version = "0.19.3", default-features = false } rfd = "0.17" +rhai = { version = "1", features = ["serde", "sync"] } zip = { version = "8.6", default-features = false, features = ["deflate"] } quick-xml = { version = "0.41", default-features = false } base64 = "0.22" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2961423..fdac999 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -31,6 +31,7 @@ image.workspace = true log.workspace = true num-complex.workspace = true rfd.workspace = true +rhai.workspace = true serde_json.workspace = true uuid.workspace = true diff --git a/crates/app/src/ui/batch_workflow.rs b/crates/app/src/ui/batch_workflow.rs index fa65296..924d18e 100644 --- a/crates/app/src/ui/batch_workflow.rs +++ b/crates/app/src/ui/batch_workflow.rs @@ -11,6 +11,15 @@ use plotx_core::automation::{ use plotx_core::state::PlotxApp; use std::collections::BTreeSet; use std::path::PathBuf; +use std::sync::mpsc; + +use super::commands::{self, CommandId}; + +mod script_support; +use script_support::{ + panic_message, prepare_selected_inputs, render_script_results, save_script_results, + selected_mass_spec_ids, +}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum InputSource { @@ -19,10 +28,24 @@ enum InputSource { ExternalInputs, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum CurrentProjectMode { + #[default] + ScientificScript, + RegisteredTool, +} + +struct ScriptTask { + receiver: mpsc::Receiver<(String, String, Result)>, + total: usize, + completed: usize, +} + #[derive(Default)] pub(crate) struct AutomationUi { open: bool, source: InputSource, + current_project_mode: CurrentProjectMode, query: String, selected: BTreeSet, tool_id: String, @@ -35,6 +58,10 @@ pub(crate) struct AutomationUi { workflow_error: Option, events: Vec, cancellation: TaskCancellation, + script_path: Option, + script_results: Vec, + script_error: Option, + script_task: Option, } impl AutomationUi { @@ -42,6 +69,11 @@ impl AutomationUi { ctx.data_mut(|data| data.insert_temp(egui::Id::new("automation_open_request"), true)); } + pub(crate) fn request_run_script(ctx: &egui::Context) { + Self::request_open(ctx); + ctx.data_mut(|data| data.insert_temp(egui::Id::new("automation_run_script_request"), true)); + } + pub(crate) fn is_open(&self) -> bool { self.open } @@ -53,6 +85,13 @@ impl AutomationUi { { self.open = true; } + if ctx + .data(|data| data.get_temp::(egui::Id::new("automation_run_script_request"))) + .unwrap_or(false) + { + self.source = InputSource::CurrentProject; + self.current_project_mode = CurrentProjectMode::ScientificScript; + } if !self.open { return; } @@ -92,6 +131,183 @@ impl AutomationUi { self.results(app, ui); } + fn scientific_script(&mut self, app: &mut PlotxApp, ui: &mut egui::Ui) { + self.poll_script_task(ui.ctx()); + let running = self.script_task.is_some(); + ui.heading("Run a read-only scientific script"); + ui.small( + "Runs against selected datasets, including datasets referenced by selected canvases. Scripts return JSON and cannot modify the project.", + ); + ui.horizontal(|ui| { + if ui + .add_enabled(!running, egui::Button::new("Open script…")) + .clicked() + && let Some(path) = rfd::FileDialog::new() + .add_filter("PlotX Scientific Script", &["plotxscript"]) + .pick_file() + { + self.script_path = Some(path); + self.script_error = None; + } + if let Some(path) = &self.script_path { + ui.monospace(path.display().to_string()); + } + }); + let compatible = selected_mass_spec_ids(app, &self.selected); + ui.label(format!( + "{} selected LC–MS dataset(s) will be processed.", + compatible.len() + )); + let descriptor = commands::describe(app, CommandId::RunScientificScript); + let runnable = + self.script_path.is_some() && !compatible.is_empty() && !running && descriptor.enabled; + ui.horizontal(|ui| { + if ui + .add_enabled(runnable, egui::Button::new(descriptor.label)) + .on_disabled_hover_text( + "Choose a script and select at least one LC–MS dataset or canvas first.", + ) + .clicked() + { + commands::execute_without_clipboard(CommandId::RunScientificScript, app, ui.ctx()); + } + if ui + .add_enabled( + !self.script_results.is_empty(), + egui::Button::new("Save results…"), + ) + .on_disabled_hover_text("Run the script before saving results.") + .clicked() + && let Some(path) = rfd::FileDialog::new() + .add_filter("JSON", &["json"]) + .set_file_name("plotx-script-results.json") + .save_file() + && let Err(error) = save_script_results(&path, &self.script_results) + { + self.script_error = Some(error); + } + }); + if ui + .ctx() + .data_mut(|data| { + data.remove_temp::(egui::Id::new("automation_run_script_request")) + }) + .unwrap_or(false) + { + if runnable { + self.start_scientific_script(app); + } else if self.script_path.is_none() { + self.script_error = + Some("Choose a PlotX Scientific Script before running it.".to_owned()); + } else if compatible.is_empty() { + self.script_error = Some( + "Select at least one LC–MS dataset or canvas before running the script." + .to_owned(), + ); + } + } + if let Some(error) = &self.script_error { + ui.colored_label(ui.visuals().error_fg_color, error); + } + if let Some(task) = &self.script_task { + ui.add( + egui::ProgressBar::new(task.completed as f32 / task.total as f32) + .text(format!("{} / {} inputs", task.completed, task.total)), + ); + } + if !self.script_results.is_empty() { + ui.label(format!( + "Processed {} dataset(s)", + self.script_results.len() + )); + render_script_results(ui, &self.script_results); + } + } + + fn start_scientific_script(&mut self, app: &PlotxApp) { + self.script_error = None; + self.script_results.clear(); + let Some(path) = &self.script_path else { + return; + }; + let source = match std::fs::read_to_string(path) { + Ok(source) => source, + Err(error) => { + self.script_error = Some(format!("Could not read {}: {error}", path.display())); + return; + } + }; + let inputs = prepare_selected_inputs(app, &self.selected); + let total = inputs.len(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + for (dataset_id, input, prepared) in inputs { + let result = prepared.and_then(|prepared| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::scientific_script::run_prepared(&source, prepared) + })) + .unwrap_or_else(|panic| { + Err(format!( + "The script engine panicked: {}", + panic_message(panic) + )) + }) + }); + if sender.send((dataset_id, input, result)).is_err() { + return; + } + } + }); + self.script_task = Some(ScriptTask { + receiver, + total, + completed: 0, + }); + } + + fn poll_script_task(&mut self, ctx: &egui::Context) { + let Some(task) = &mut self.script_task else { + return; + }; + loop { + match task.receiver.try_recv() { + Ok((dataset_id, input, result)) => { + task.completed += 1; + match result { + Ok(value) => self.script_results.push(serde_json::json!({ + "dataset_id": dataset_id, + "input": input, + "result": value, + })), + Err(error) => { + self.script_results.push(serde_json::json!({ + "dataset_id": dataset_id, + "input": input, + "error": error, + })); + } + } + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + if task.completed < task.total { + self.script_error = Some(format!( + "The scientific script worker stopped after {} of {} datasets.", + task.completed, task.total + )); + } + task.completed = task.total; + break; + } + } + } + if task.completed == task.total { + self.script_task = None; + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + fn current_project(&mut self, app: &mut PlotxApp, ui: &mut egui::Ui) { ui.heading("Observe and select"); ui.horizontal(|ui| { @@ -116,6 +332,22 @@ impl AutomationUi { let found = search_resources(&ProjectResourceProvider::new(app), &query); self.resource_list(app, ui, &found); ui.separator(); + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.current_project_mode, + CurrentProjectMode::ScientificScript, + "Scientific Script", + ); + ui.selectable_value( + &mut self.current_project_mode, + CurrentProjectMode::RegisteredTool, + "Registered Tool", + ); + }); + if self.current_project_mode == CurrentProjectMode::ScientificScript { + self.scientific_script(app, ui); + return; + } ui.heading("Plan a registered tool"); let registry = ToolRegistry::built_in(); let descriptors = registry.descriptors().collect::>(); @@ -485,3 +717,7 @@ fn default_parameters(tool: &str) -> String { } .to_owned() } + +#[cfg(test)] +#[path = "batch_workflow_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/batch_workflow/script_support.rs b/crates/app/src/ui/batch_workflow/script_support.rs new file mode 100644 index 0000000..f6a57db --- /dev/null +++ b/crates/app/src/ui/batch_workflow/script_support.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use plotx_core::automation::{KIND_DATASET, ProjectResourceProvider, ResourceProvider}; +use plotx_core::state::PlotxApp; + +pub(super) fn selected_mass_spec_ids(app: &PlotxApp, selected: &BTreeSet) -> Vec { + let provider = ProjectResourceProvider::new(app); + let mut candidates = BTreeSet::new(); + for id in selected { + let Some(descriptor) = provider.inspect(id) else { + continue; + }; + if descriptor.resource.kind.0 == KIND_DATASET { + candidates.insert(descriptor.resource.id); + } + candidates.extend(descriptor.lineage); + } + candidates + .into_iter() + .filter(|id| { + app.doc + .datasets + .iter() + .find(|dataset| dataset.resource_id().to_string() == *id) + .is_some_and(|dataset| dataset.as_mass_spec().is_some()) + }) + .collect() +} + +type PreparedInput = (String, String, Result); + +pub(super) fn prepare_selected_inputs( + app: &PlotxApp, + selected: &BTreeSet, +) -> Vec { + selected_mass_spec_ids(app, selected) + .into_iter() + .filter_map(|id| { + let dataset = app + .doc + .datasets + .iter() + .find(|dataset| dataset.resource_id().to_string() == id)? + .as_mass_spec()?; + let label = dataset + .name + .clone() + .unwrap_or_else(|| dataset.run.source.clone()); + let source = Path::new(&dataset.run.source); + let method = if plotx_io::waters::is_masslynx_raw(source) { + plotx_io::waters::load_inlet_method(source).ok().flatten() + } else { + None + }; + Some(( + id, + label, + Ok(crate::ui::scientific_script::prepare_run( + &dataset.run, + method, + )), + )) + }) + .collect() +} + +pub(super) fn panic_message(panic: Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "unknown panic".to_owned() + } +} + +pub(super) fn render_script_results(ui: &mut egui::Ui, results: &[serde_json::Value]) { + egui::ScrollArea::vertical() + .id_salt("scientific_script_results") + .max_height(300.0) + .show(ui, |ui| { + for (index, item) in results.iter().enumerate() { + let input = item["input"].as_str().unwrap_or("Selected dataset"); + let result = &item["result"]; + let dataset_id = item["dataset_id"].as_str().unwrap_or("unknown"); + ui.push_id(("script_result", dataset_id, index), |ui| { + ui.group(|ui| { + let title = Path::new(input) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(input); + ui.label(crate::typography::headline(title)); + if let Some(error) = item["error"].as_str() { + ui.colored_label(ui.visuals().error_fg_color, error); + } else if let Some(summary) = result["summary"].as_object() { + egui::Grid::new(("script_summary", input)) + .num_columns(2) + .spacing([12.0, 4.0]) + .show(ui, |ui| { + for (label, value) in summary { + ui.strong(label); + ui.label(summary_value(value)); + ui.end_row(); + } + }); + } else { + ui.weak("This script did not provide a human-readable summary."); + } + ui.collapsing("Technical details", |ui| { + if let Ok(text) = serde_json::to_string_pretty(item) { + ui.monospace(text); + } + }); + }) + }); + ui.add_space(6.0); + } + }); +} + +fn summary_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Null => "—".to_owned(), + value => serde_json::to_string(value).unwrap_or_else(|_| "—".to_owned()), + } +} + +pub(super) fn save_script_results( + path: &Path, + results: &[serde_json::Value], +) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(results) + .map_err(|error| format!("Could not encode script results: {error}"))?; + std::fs::write(path, bytes) + .map_err(|error| format!("Could not save {}: {error}", path.display())) +} diff --git a/crates/app/src/ui/batch_workflow_tests.rs b/crates/app/src/ui/batch_workflow_tests.rs new file mode 100644 index 0000000..6209e8d --- /dev/null +++ b/crates/app/src/ui/batch_workflow_tests.rs @@ -0,0 +1,88 @@ +use super::*; +use plotx_core::actions::Action; +use plotx_core::state::{DEFAULT_CANVAS_SIZE_MM, Dataset, MassSpecDataset}; +use plotx_io::{ + AcquisitionStream, AcquisitionStreamId, MassSpecRun, MassSpectrum, Polarity, SpectrumId, + SpectrumRepresentation, StreamRole, +}; + +#[test] +fn selecting_a_canvas_resolves_its_mass_spec_dataset() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let run = MassSpecRun { + source: "selected.raw".to_owned(), + metadata: Default::default(), + instrument: None, + streams: vec![AcquisitionStream { + id: AcquisitionStreamId::new(1), + source_native_id: None, + source_label: None, + role: StreamRole::Primary, + acquisition_range: None, + spectra: vec![MassSpectrum { + id: SpectrumId::new(1), + source_native_id: None, + retention_time_min: 1.0, + ms_level: 1, + polarity: Polarity::Unknown, + representation: SpectrumRepresentation::Centroid, + mz: vec![100.0], + intensity: vec![1.0], + tic: 1.0, + base_peak_mz: Some(100.0), + base_peak_intensity: Some(1.0), + precursor: None, + }], + }], + chromatograms: Vec::new(), + import_warnings: Vec::new(), + }; + app.execute_action(Action::insert_dataset_with_default_canvas( + &app, + Dataset::MassSpec(Box::new(MassSpecDataset::load(run))), + "LC–MS canvas".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + let expected = app.doc.datasets[0].resource_id().to_string(); + let selected = BTreeSet::from([app.doc.canvases[0].resource_id.to_string()]); + + assert_eq!(selected_mass_spec_ids(&app, &selected), vec![expected]); + let prepared = prepare_selected_inputs(&app, &selected); + assert_eq!(prepared[0].0, app.doc.datasets[0].resource_id().to_string()); + assert!(prepared[0].2.as_ref().unwrap()["lc_method"].is_null()); +} + +#[test] +fn every_background_script_error_keeps_its_dataset_id() { + let mut ui = AutomationUi::default(); + let (sender, receiver) = mpsc::channel(); + sender + .send(( + "dataset-1".to_owned(), + "First".to_owned(), + Err("one".to_owned()), + )) + .unwrap(); + sender + .send(( + "dataset-2".to_owned(), + "Second".to_owned(), + Err("two".to_owned()), + )) + .unwrap(); + drop(sender); + ui.script_task = Some(ScriptTask { + receiver, + total: 2, + completed: 0, + }); + + ui.poll_script_task(&egui::Context::default()); + + assert_eq!(ui.script_results.len(), 2); + assert_eq!(ui.script_results[0]["dataset_id"], "dataset-1"); + assert_eq!(ui.script_results[0]["error"], "one"); + assert_eq!(ui.script_results[1]["dataset_id"], "dataset-2"); + assert_eq!(ui.script_results[1]["error"], "two"); + assert!(ui.script_error.is_none()); +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 88c1597..06b19f1 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -14,6 +14,19 @@ pub fn execute( app: &mut PlotxApp, clipboard: &mut ClipboardTablePaste, ctx: &egui::Context, +) { + execute_inner(id, app, Some(clipboard), ctx); +} + +pub fn execute_without_clipboard(id: CommandId, app: &mut PlotxApp, ctx: &egui::Context) { + execute_inner(id, app, None, ctx); +} + +fn execute_inner( + id: CommandId, + app: &mut PlotxApp, + clipboard: Option<&mut ClipboardTablePaste>, + ctx: &egui::Context, ) { if matches!(id, CommandId::Undo | CommandId::Redo) { // Commit any debounced wheel zoom before the enabled gate, so the @@ -38,6 +51,9 @@ pub fn execute( CommandId::OpenFile => super::file_dialogs::open_file(app), CommandId::OpenFolder => super::file_dialogs::open_folder(app), CommandId::RunBatchWorkflow => super::batch_workflow::AutomationUi::request_open(ctx), + CommandId::RunScientificScript => { + super::batch_workflow::AutomationUi::request_run_script(ctx) + } CommandId::OpenRecent(index) => { if let Some(path) = app.session.recent_files.get(index).cloned() { super::file_dialogs::open_recent_path(app, &path); @@ -48,7 +64,11 @@ pub fn execute( ctx.open_url(egui::OpenUrl::new_tab(commands::MANUAL_URL)); } CommandId::ImportTable => super::file_dialogs::import_delimited_table(app), - CommandId::PasteTable => clipboard.request(app, ctx), + CommandId::PasteTable => { + if let Some(clipboard) = clipboard { + clipboard.request(app, ctx); + } + } CommandId::SaveProject => app.request_save_project(), CommandId::NewTable => app.new_table_dataset(), CommandId::ExportData => { diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 3ed17c9..fffc68c 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -8,7 +8,7 @@ use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; use plotx_core::properties::PropertyStep; use plotx_core::state::{Dataset, ObjectId, PlotxApp, Tool, ToolGroup, WorkflowTab}; -pub use super::command_exec::execute; +pub use super::command_exec::{execute, execute_without_clipboard}; mod identity; use identity::command_identity; @@ -47,6 +47,7 @@ pub enum CommandId { OpenFile, OpenFolder, RunBatchWorkflow, + RunScientificScript, /// Reopen the recent-list entry at this index (newest first). Registered /// per live entry, so the index always resolves against the current list. OpenRecent(usize), @@ -155,7 +156,7 @@ impl CommandId { pub fn execution_class(self) -> CommandExecutionClass { match self { - Self::RunBatchWorkflow => CommandExecutionClass::ToolEditor, + Self::RunBatchWorkflow | Self::RunScientificScript => CommandExecutionClass::ToolEditor, Self::OperationHistory | Self::CommandPalette | Self::About => { CommandExecutionClass::UiOnly } @@ -191,6 +192,7 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::OpenFile, CommandId::OpenFolder, CommandId::RunBatchWorkflow, + CommandId::RunScientificScript, CommandId::ClearRecentFiles, CommandId::HelpManual, CommandId::ImportTable, @@ -383,6 +385,13 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { // command can never be blocked by one requirement while explaining another. // `and_then` reports the first unmet requirement and skips the rest. let gate: Result<(), &'static str> = match id { + CommandId::RunScientificScript => requires( + app.doc + .datasets + .iter() + .any(|dataset| dataset.as_mass_spec().is_some()), + "Load an LC–MS dataset before running a scientific script.", + ), CommandId::CloseProject => requires( app.session.project_present || app.doc.project_path.is_some() diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 5637f9d..068506e 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -45,6 +45,7 @@ pub(super) fn command_identity( CommandId::OpenFile => plain("Open File…", Some(icon::FILE)), CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)), CommandId::RunBatchWorkflow => plain("Automation…", Some(icon::PLAY)), + CommandId::RunScientificScript => plain("Run Scientific Script", Some(icon::PLAY)), CommandId::OpenRecent(i) => ( recent_label(app, i), Some(icon::CLOCK_COUNTER_CLOCKWISE), @@ -341,6 +342,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::OpenFile => "file.open_file", CommandId::OpenFolder => "file.open_folder", CommandId::RunBatchWorkflow => "tools.automation", + CommandId::RunScientificScript => "tools.run_scientific_script", CommandId::ImportTable => "file.import_table", CommandId::PasteTable => "file.paste_table", CommandId::SaveProject => "file.save", diff --git a/crates/app/src/ui/commands_mass_spec_tests.rs b/crates/app/src/ui/commands_mass_spec_tests.rs index 1f3a15f..458af22 100644 --- a/crates/app/src/ui/commands_mass_spec_tests.rs +++ b/crates/app/src/ui/commands_mass_spec_tests.rs @@ -90,3 +90,24 @@ fn extraction_uses_the_shared_command_and_tool_surfaces() { "reopening an extraction workflow keeps its range tool active" ); } + +#[test] +fn scientific_script_run_uses_the_shared_command_catalog() { + let mut app = app_with_mass_spec(); + let command = describe(&app, CommandId::RunScientificScript); + assert!(command.enabled); + assert_eq!(command.id.stable_id(), "tools.run_scientific_script"); + assert_eq!(command.execution_class, CommandExecutionClass::ToolEditor); + + let ctx = egui::Context::default(); + execute_without_clipboard(CommandId::RunScientificScript, &mut app, &ctx); + + assert!(ctx.data(|data| { + data.get_temp::(egui::Id::new("automation_open_request")) + .unwrap_or(false) + })); + assert!(ctx.data(|data| { + data.get_temp::(egui::Id::new("automation_run_script_request")) + .unwrap_or(false) + })); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index f768ef7..e1aec7a 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod processing_templates; pub(crate) mod properties; mod ribbon; mod ribbon_chrome; +mod scientific_script; mod secondary_sidebar; mod settings_dialog; mod shortcuts; diff --git a/crates/app/src/ui/scientific_script.rs b/crates/app/src/ui/scientific_script.rs new file mode 100644 index 0000000..72da456 --- /dev/null +++ b/crates/app/src/ui/scientific_script.rs @@ -0,0 +1,295 @@ +//! Sandboxed, read-only scientific scripts used by the Automation window. +//! +//! Scripts receive only snapshots of datasets selected in PlotX. They cannot +//! name arbitrary files or mutate the document; the host exposes neutral data +//! loading and one-dimensional trace-analysis primitives. + +#[cfg(test)] +use std::path::Path; + +use plotx_analysis::peaks::{DetectParams, detect_peaks, estimate_noise}; +#[cfg(test)] +use plotx_io::Acquisition; +use plotx_io::{ChromatogramKind, LiquidChromatographyMethod, MassSpecRun}; +use rhai::module_resolvers::DummyModuleResolver; +use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position}; + +const MAX_OPERATIONS: u64 = 20_000_000; +const MAX_ARRAY_SIZE: usize = 1_000_000; +const MAX_MAP_SIZE: usize = 10_000; +const MAX_STRING_SIZE: usize = 1_000_000; +const MAX_RESULT_BYTES: usize = 10_000_000; + +#[cfg(test)] +pub(crate) fn run(source: &str, input: &Path) -> Result { + let prepared = prepare_path(input)?; + run_prepared(source, prepared) +} + +pub(crate) fn run_prepared( + source: &str, + prepared: serde_json::Value, +) -> Result { + let mut engine = Engine::new(); + engine + .set_module_resolver(DummyModuleResolver::new()) + .set_max_operations(MAX_OPERATIONS) + .set_max_expr_depths(64, 32) + .set_max_call_levels(64) + .set_max_variables(256) + .set_max_functions(128) + .set_max_modules(0) + .set_max_array_size(MAX_ARRAY_SIZE) + .set_max_map_size(MAX_MAP_SIZE) + .set_max_string_size(MAX_STRING_SIZE) + .on_print(|_| {}); + + let prepared = rhai::serde::to_dynamic(prepared) + .map_err(|error| format!("Could not expose the selected data: {error}"))?; + engine.register_fn("load_input", move || prepared.clone()); + engine.register_fn("moving_average", moving_average_dynamic); + engine.register_fn("rolling_percentile", rolling_percentile_dynamic); + engine.register_fn("estimate_noise", estimate_noise_dynamic); + engine.register_fn("detect_peaks", detect_peaks_dynamic); + engine.register_fn("format_number", format_number); + + let value = engine + .eval::(source) + .map_err(|error| format!("Script failed: {error}"))?; + let result: serde_json::Value = rhai::serde::from_dynamic(&value) + .map_err(|error| format!("Invalid script result: {error}"))?; + let result_size = serde_json::to_vec(&result) + .map_err(|error| format!("Could not measure script result: {error}"))? + .len(); + if result_size > MAX_RESULT_BYTES { + return Err(format!( + "Script result is {result_size} bytes; the limit is {MAX_RESULT_BYTES} bytes." + )); + } + Ok(result) +} + +fn format_number(value: f64, decimals: i64) -> String { + let decimals = usize::try_from(decimals).unwrap_or(0).min(12); + format!("{value:.decimals$}") +} + +fn runtime_error(message: impl Into) -> Box { + EvalAltResult::ErrorRuntime(message.into().into(), Position::NONE).into() +} + +#[cfg(test)] +fn prepare_path(path: &Path) -> Result { + let loaded = plotx_io::load_path(path) + .map_err(|error| format!("Could not load {}: {error}", path.display()))?; + let Acquisition::MassSpec(run) = loaded.acquisition else { + return Err("The selected input is not an LC–MS dataset.".to_owned()); + }; + let method = plotx_io::waters::load_inlet_method(path) + .map_err(|error| format!("Could not read the LC method: {error}"))?; + Ok(prepare_run(&run, method)) +} + +pub(crate) fn prepare_run( + run: &MassSpecRun, + method: Option, +) -> serde_json::Value { + let channels = run + .chromatograms + .iter() + .map(|channel| { + serde_json::json!({ + "id": channel.id.0, + "kind": match channel.kind { + ChromatogramKind::Optical => "optical", + ChromatogramKind::Temperature => "temperature", + ChromatogramKind::Pressure => "pressure", + ChromatogramKind::Housekeeping => "housekeeping", + ChromatogramKind::Unknown => "unknown", + }, + "description": channel.description, + "coordinate": channel.coordinate, + "unit": channel.unit, + "time_min": channel.time_min, + "values": channel.values, + }) + }) + .collect::>(); + let scans = run + .streams + .iter() + .flat_map(|stream| { + stream.spectra.iter().map(move |scan| { + serde_json::json!({ + "stream_id": stream.id.get(), + "time_min": scan.retention_time_min, + "tic": scan.tic, + }) + }) + }) + .collect::>(); + serde_json::json!({ + "source": run.source, + "instrument": run.instrument, + "chromatograms": channels, + "scans": scans, + "lc_method": method, + }) +} + +fn floats(values: Array, name: &str) -> Result, Box> { + values + .into_iter() + .map(|value| { + value + .as_float() + .or_else(|_| value.as_int().map(|value| value as f64)) + .map_err(|_| runtime_error(format!("{name} must contain only numbers."))) + }) + .collect() +} + +fn dynamic_array(values: Vec) -> Array { + values.into_iter().map(Dynamic::from_float).collect() +} + +fn moving_average_dynamic(values: Array, width: i64) -> Result> { + let values = floats(values, "values")?; + let width = usize::try_from(width) + .ok() + .filter(|width| *width > 0) + .ok_or_else(|| runtime_error("Moving-average width must be positive."))?; + Ok(dynamic_array(moving_average(&values, width))) +} + +fn rolling_percentile_dynamic( + values: Array, + width: i64, + quantile: f64, +) -> Result> { + let values = floats(values, "values")?; + let width = usize::try_from(width) + .ok() + .filter(|width| *width > 0) + .ok_or_else(|| runtime_error("Rolling-percentile width must be positive."))?; + if !(0.0..=1.0).contains(&quantile) { + return Err(runtime_error("Percentile must be between 0 and 1.")); + } + Ok(dynamic_array(rolling_percentile(&values, width, quantile))) +} + +fn estimate_noise_dynamic(values: Array) -> Result> { + Ok(estimate_noise(&floats(values, "values")?)) +} + +fn detect_peaks_dynamic(xs: Array, ys: Array, options: Map) -> Result> { + let xs = floats(xs, "x")?; + let ys = floats(ys, "y")?; + let number = |key: &str| options.get(key).and_then(|value| value.as_float().ok()); + let integer = |key: &str| options.get(key).and_then(|value| value.as_int().ok()); + let params = DetectParams { + min_height: number("min_height"), + min_prominence: number("min_prominence").unwrap_or(0.0), + min_spacing: number("min_spacing"), + max_count: integer("max_count").and_then(|value| usize::try_from(value).ok()), + }; + detect_peaks(&xs, &ys, ¶ms) + .into_iter() + .map(|peak| { + rhai::serde::to_dynamic(serde_json::json!({ + "index": peak.index, + "x": peak.x, + "y": peak.y, + "prominence": peak.prominence, + })) + .map_err(|error| runtime_error(error.to_string())) + }) + .collect() +} + +fn moving_average(values: &[f64], width: usize) -> Vec { + let radius = width / 2; + (0..values.len()) + .map(|index| { + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(values.len()); + values[start..end].iter().sum::() / (end - start) as f64 + }) + .collect() +} + +fn rolling_percentile(values: &[f64], width: usize, quantile: f64) -> Vec { + let radius = width / 2; + (0..values.len()) + .map(|index| { + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(values.len()); + let mut window = values[start..end].to_vec(); + window.sort_by(f64::total_cmp); + let rank = ((window.len() - 1) as f64 * quantile).round() as usize; + window[rank] + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn script_cannot_read_an_unselected_path() { + let error = run("load_input()", Path::new("missing.raw")).unwrap_err(); + assert!(error.contains("Could not load")); + } + + #[test] + fn filesystem_imports_are_rejected() { + let error = run_prepared( + r#"import "C:\\outside\\escape" as escape; escape::value"#, + serde_json::json!({}), + ) + .unwrap_err(); + let error_lower = error.to_ascii_lowercase(); + assert!( + error_lower.contains("module") || error_lower.contains("import"), + "{error}" + ); + } + + #[test] + fn collection_growth_is_bounded() { + let error = run_prepared( + "let values = []; values.pad(1_000_000_000, 0); values", + serde_json::json!({}), + ) + .unwrap_err(); + let error_lower = error.to_ascii_lowercase(); + assert!( + error_lower.contains("size") || error_lower.contains("limit"), + "{error}" + ); + } + + #[test] + fn prepared_input_is_available_to_a_script() { + let result = run_prepared( + r#" + let input = load_input(); + #{ + schema: "plotx.scientific-script-result.v1", + summary: #{ "Dataset": input.label }, + point_count: input.traces[0].x.len(), + } + "#, + serde_json::json!({ + "label": "example input", + "traces": [{ "x": [0.0, 1.0], "y": [2.0, 3.0] }], + }), + ) + .expect("generic script runs"); + + assert_eq!(result["schema"], "plotx.scientific-script-result.v1"); + assert_eq!(result["summary"]["Dataset"], "example input"); + assert_eq!(result["point_count"], 2); + } +} diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index f73ee75..c61c78b 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -7,6 +7,7 @@ use crate::state::{Dataset, PlotxApp}; use std::collections::BTreeMap; mod mass_spec; +mod selection; mod statistics; mod xps; mod xrd; @@ -380,23 +381,7 @@ impl ResourceProvider for ProjectResourceProvider<'_> { } fn current_selection(&self) -> Vec { - let mut selected = Vec::new(); - if let Some(dataset) = self - .app - .active_dataset() - .and_then(|index| self.app.doc.datasets.get(index)) - { - selected.push(top_ref(dataset.resource_id(), KIND_DATASET)); - } - if let Some(canvas) = self - .app - .session - .active_canvas - .and_then(|index| self.app.doc.canvases.get(index)) - { - selected.push(top_ref(canvas.resource_id, KIND_CANVAS)); - } - selected + selection::current(self.app) } fn preview(&self, target: &ResourceRef, limit: usize) -> Result { diff --git a/crates/core/src/automation/resources/selection.rs b/crates/core/src/automation/resources/selection.rs new file mode 100644 index 0000000..2186094 --- /dev/null +++ b/crates/core/src/automation/resources/selection.rs @@ -0,0 +1,42 @@ +use super::{KIND_CANVAS, KIND_DATASET, ResourceRef, top_ref}; +use crate::state::{FrameRef, PlotxApp}; + +pub(super) fn current(app: &PlotxApp) -> Vec { + let mut selected = Vec::new(); + if !app.session.ui.frame_selection.is_empty() { + for frame in &app.session.ui.frame_selection { + let target = match *frame { + FrameRef::Page(index) => app + .doc + .canvases + .get(index) + .map(|canvas| top_ref(canvas.resource_id, KIND_CANVAS)), + FrameRef::Sheet(index) => app + .doc + .datasets + .get(index) + .map(|dataset| top_ref(dataset.resource_id(), KIND_DATASET)), + }; + if let Some(target) = target + && !selected.contains(&target) + { + selected.push(target); + } + } + return selected; + } + if let Some(dataset) = app + .active_dataset() + .and_then(|index| app.doc.datasets.get(index)) + { + selected.push(top_ref(dataset.resource_id(), KIND_DATASET)); + } + if let Some(canvas) = app + .session + .active_canvas + .and_then(|index| app.doc.canvases.get(index)) + { + selected.push(top_ref(canvas.resource_id, KIND_CANVAS)); + } + selected +} diff --git a/crates/core/src/automation/tests.rs b/crates/core/src/automation/tests.rs index 79f8328..279a33e 100644 --- a/crates/core/src/automation/tests.rs +++ b/crates/core/src/automation/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::actions::Action; -use crate::state::{CanvasDocument, Dataset, PlotxApp, TableDataset, TableSeriesBinding}; +use crate::state::{CanvasDocument, Dataset, FrameRef, PlotxApp, TableDataset, TableSeriesBinding}; use std::collections::{BTreeMap, BTreeSet}; fn app_with_table_and_canvas() -> PlotxApp { @@ -65,6 +65,21 @@ fn request( } } +#[test] +fn current_selection_preserves_multi_selected_canvases() { + let mut app = app_with_table_and_canvas(); + app.doc + .canvases + .push(CanvasDocument::new("Figure 2".to_owned(), [120.0, 90.0])); + app.session.ui.frame_selection = vec![FrameRef::Page(0), FrameRef::Page(1)]; + + let selected = ProjectResourceProvider::new(&app).current_selection(); + + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].id, app.doc.canvases[0].resource_id.to_string()); + assert_eq!(selected[1].id, app.doc.canvases[1].resource_id.to_string()); +} + #[test] fn query_reports_reasons_pagination_and_stale_frozen_sets_are_rejected() { let mut app = app_with_table_and_canvas(); diff --git a/deny.toml b/deny.toml index 0915c0a..dc13768 100644 --- a/deny.toml +++ b/deny.toml @@ -45,9 +45,10 @@ allow = [ ] confidence-threshold = 0.8 exceptions = [ - # MPL is file-level copyleft. option-ext is used unmodified through - # directories and can be distributed in both PlotX license variants. + # MPL is file-level copyleft. These crates are used unmodified through + # their public APIs and can be distributed in both PlotX license variants. { crate = "option-ext", allow = ["MPL-2.0"] }, + { crate = "smartstring", allow = ["MPL-2.0"] }, ] [licenses.private]