//! The macro step editor (Automate › New Macro; Macros › Edit, Create, Step Into): a large, //! resizable window. //! //! - **Left, the step list.** Each row has its number, a breakpoint dot in the gutter (click to //! toggle), a category icon, a one-line summary, an error marker and an enabled checkbox. Rows //! drag to reorder within their list (the shared [`crate::dnd`] helpers); Add Step opens a //! Command Palette picker; Duplicate and Delete act on the selected step. //! - **Right, the inspector.** Step: the selected step's fields, with RefEdit boxes that switch //! between relative tokens (`Input.col(2)`) and absolute A1, and token suggestions. Inputs: the //! shape, header and column types each input must have, from an example. Output: the anchor and //! what to do when results would land on data. //! - **The debug bar.** Check (a dry run that colours the grid), Run, Step, Continue, Run to Here //! and Stop (keep the changes or undo them). While debugging, the current step is highlighted //! and its parameters are shown as they will run. //! //! The editor works on a draft and saves it with `macro.save`; checking, running and debugging //! save first, then go through `macro.check`, `macro.run` and `macro.debug`. use std::collections::BTreeMap; use egui::{Color32, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use gridcraft_engine::core::RangeRef; use gridcraft_engine::macros::policy::{Policy, macro_policy}; use gridcraft_engine::macros::resolve::{self, Env, Place}; use gridcraft_engine::model::{ColType, CompareOp, InputKind, Macro, NamedInput, OnConflict, RepeatOf, Step, StepKind, Workbook}; use serde_json::{Map, Value as Json, json}; use crate::dialogs::Dialog; use crate::icons::{self, Icon}; use crate::theme::{self, Cat, Tokens}; use crate::widgets::small_button; use crate::{SheetApp, dnd, macro_ui}; /// The inspector's tabs. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Tab { #[default] Step, Inputs, Output, } /// The Add Step picker (or Change, which replaces a step's command). #[derive(Clone, Debug, Default)] pub struct Picker { pub search: String, /// The step whose command is being changed; `None` adds a step. pub replace: Option, pub index: usize, } /// The editor's state while it is open. #[derive(Clone, Debug)] pub struct Editor { /// The workbook (`DocState::uid`) the macro is in: the editor saves, checks and runs there only. pub doc: u64, pub draft: Macro, /// The name the macro is saved under (`None` until it is first saved). pub saved_as: Option, /// The name the editor opened with: a new macro that still has it and nothing else counts /// as untouched. pub opened_as: String, pub dirty: bool, /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). pub selected: Option, pub tab: Tab, /// The input selected on the Inputs tab. pub input: usize, pub picker: Option, /// The last `macro.check` result (the grid overlays come from it). pub check: Option, /// The debugger's state while paused. pub debug: Option, /// What the last run, check or debug session ended with. pub outcome: Option, /// Errors from the last check or run, by step path. pub errors: BTreeMap, /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). pub ask_stop: bool, pub closing: bool, /// Close as the title bar's close button does (asking first) on the next frame. pub close_requested: bool, /// The window is quitting: once the editor has closed, the window close goes on. pub quit: bool, /// The workbook (or the editor's window) is closing: once the editor has closed, this close /// runs again, in the window it was asked in. pub then_close: Option, /// Asking whether to save before closing. pub ask_close: bool, /// Start debugging on the first frame (Macros › Step Into). pub start_debug: bool, /// Check on the first frame (`{"check": true}`). pub start_check: bool, /// The example the first input stands for (the selection when the editor opened, or the /// input's default range): Check, Run and Debug use it, and tokens are shown against it. pub example: String, /// The parameter being added to the selected step: (key, value). pub new_param: (String, String), /// The reference boxes' picking state. pub refs: RefPick, } /// A workbook or window close (`file.close` or `window.close`, with its params) waiting on the /// editor's question about its draft. Once the editor has closed, it runs again in the window it /// was asked in, whichever window became active meanwhile. #[derive(Clone, Debug, PartialEq)] pub struct PendingClose { /// The editor's window, where the close was asked. pub window: u64, /// Whether the workbook closes (its last window, or `file.close {document: true}`), rather /// than the editor's window alone while the workbook stays open in another. pub whole: bool, pub id: String, pub params: Json, } /// Which reference box grid selections write into, and the selection to put back on closing. #[derive(Clone, Debug, Default)] pub struct RefPick { /// The reference box that grid selections currently write into. pub pick: Option, /// The selection reference last written by (or seen while) picking. pub seen: String, /// Sheet, ranges and active cell from before picking, put back when the editor closes. pub home: Option<(usize, String, String)>, /// The reference box the window is collapsed to (only that box shows). pub collapsed: Option, /// Whether the picking box was drawn this frame: a pick ends with a box that left the screen /// (another step or tab was chosen), or a later selection would land in it unseen. pub drawn: bool, } // ---------------------------------------------------------------- step paths fn child(parent: &str, i: usize) -> String { if parent.is_empty() { (i + 1).to_string() } else { format!("{parent}.{}", i + 1) } } /// `"4.then.2"` → (`"4.then"`, 1). `None` for a branch key or anything else that isn't a step. pub fn split_path(path: &str) -> Option<(&str, usize)> { let (parent, last) = path.rsplit_once('.').unwrap_or(("", path)); Some((parent, last.parse::().ok()?.checked_sub(1)?)) } /// The step list a parent key names: `""` (the top), `"2"` (a Repeat's body), `"4.then"`. pub fn list_mut<'a>(mut cur: &'a mut Vec, parent: &str) -> Option<&'a mut Vec> { let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); let mut i = 0; while let Some(tok) = toks.get(i) { let idx = tok.parse::().ok()?.checked_sub(1)?; let branch = toks.get(i + 1).copied(); let st = cur.get_mut(idx)?; cur = match (&mut st.kind, branch) { (StepKind::If { then, .. }, Some("then")) => { i += 2; then } (StepKind::If { otherwise, .. }, Some("else")) => { i += 2; otherwise } (StepKind::Repeat { body, .. }, _) => { i += 1; body } _ => return None, }; } Some(cur) } pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step> { let (parent, i) = split_path(path)?; list_mut(steps, parent)?.get_mut(i) } /// The step list a parent key names, read-only (see [`list_mut`]). pub fn list_at<'a>(mut cur: &'a [Step], parent: &str) -> Option<&'a [Step]> { let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); let mut i = 0; while let Some(tok) = toks.get(i) { let idx = tok.parse::().ok()?.checked_sub(1)?; let branch = toks.get(i + 1).copied(); let st = cur.get(idx)?; cur = match (&st.kind, branch) { (StepKind::If { then, .. }, Some("then")) => { i += 2; then } (StepKind::If { otherwise, .. }, Some("else")) => { i += 2; otherwise } (StepKind::Repeat { body, .. }, _) => { i += 1; body } _ => return None, }; } Some(cur) } /// A copy of the step at `path`. Reads the path in place: copying the whole list for every row /// made a large macro's step list quadratic. pub fn step_at(steps: &[Step], path: &str) -> Option { let (parent, i) = split_path(path)?; list_at(steps, parent)?.get(i).cloned() } /// The paths of the steps with a breakpoint. pub fn breakpoints(steps: &[Step], parent: &str, out: &mut Vec) { for (i, st) in steps.iter().enumerate() { let path = child(parent, i); if st.breakpoint { out.push(path.clone()); } match &st.kind { StepKind::Repeat { body, .. } => breakpoints(body, &path, out), StepKind::If { then, otherwise, .. } => { breakpoints(then, &format!("{path}.then"), out); breakpoints(otherwise, &format!("{path}.else"), out); } _ => {} } } } /// One row of the step list. #[derive(Clone, Debug, PartialEq)] pub struct Row { /// The step path, or the branch key for an If's Then/Else row. pub key: String, /// The list it is in, and its place there. pub parent: String, pub index: usize, pub depth: usize, /// `Some("Then")` / `Some("Else")` for a branch row. pub branch: Option<&'static str>, } /// The step list flattened into rows, nested steps indented under their Repeat or If. pub fn rows(steps: &[Step], parent: &str, depth: usize, out: &mut Vec) { if depth > 16 { return; } for (i, st) in steps.iter().enumerate() { let key = child(parent, i); out.push(Row { key: key.clone(), parent: parent.to_string(), index: i, depth, branch: None }); match &st.kind { StepKind::Repeat { body, .. } => rows(body, &key, depth + 1, out), StepKind::If { then, otherwise, .. } => { for (label, list, b) in [("Then", then, "then"), ("Else", otherwise, "else")] { let bk = format!("{key}.{b}"); out.push(Row { key: bk.clone(), parent: key.clone(), index: 0, depth: depth + 1, branch: Some(label) }); rows(list, &bk, depth + 2, out); } } _ => {} } } } /// Moves the step at `from` in list `parent` so it lands before row `to` (an index among the /// list's steps as they are now). Returns the step's new path. pub fn move_step(steps: &mut Vec, parent: &str, from: usize, to: usize) -> Option { let list = list_mut(steps, parent)?; if from >= list.len() { return None; } let st = list.remove(from); let to = if to > from { to - 1 } else { to }.min(list.len()); list.insert(to, st); Some(child(parent, to)) } // ---------------------------------------------------------------- summaries fn short(v: &Json) -> String { let text = match v { Json::String(s) => s.clone(), other => other.to_string(), }; if text.chars().count() > 40 { format!("{}…", text.chars().take(39).collect::()) } else { text } } /// The label of command `id` (`Set Cell`), or the id when it isn't a command. pub fn command_label(id: &str) -> String { gridcraft_engine::find_command(id).map(|c| c.label.to_string()).unwrap_or_else(|| id.to_string()) } /// A step in one line: "Set D2 to =B2*C2", "Repeat for each row of Input.data". pub fn summary(st: &Step) -> String { match &st.kind { StepKind::Command { id, params } => { let p = |k: &str| params.get(k).map(short); match (id.as_str(), p("cell"), p("input"), p("range")) { ("cell.set", Some(c), Some(i), _) => format!("Set {c} to {i}"), ("selection.set", _, _, Some(r)) => format!("Select {r}"), _ => { let label = command_label(id); let args: Vec = params.as_object().map(|o| o.iter().take(3).map(|(k, v)| format!("{k}: {}", short(v))).collect()).unwrap_or_default(); if args.is_empty() { label } else { format!("{label} · {}", args.join(", ")) } } } } StepKind::Repeat { over, of, .. } => { format!("Repeat for each {} of {}", if *of == RepeatOf::Rows { "row" } else { "column" }, if over.is_empty() { "…" } else { over }) } StepKind::If { cell, op, value, .. } => { let op = match op { CompareOp::Eq => "=", CompareOp::Ne => "≠", CompareOp::Gt => ">", CompareOp::Ge => "≥", CompareOp::Lt => "<", CompareOp::Le => "≤", CompareOp::Empty => return format!("If {cell} is empty"), CompareOp::NotEmpty => return format!("If {cell} isn't empty"), CompareOp::Contains => "contains", }; format!("If {cell} {op} {}", short(value)) } StepKind::Stop { message, .. } => { if message.is_empty() { "Stop".into() } else { format!("Stop: {message}") } } } } /// The icon and colour of a step's category. pub fn category(st: &Step) -> (Icon, Cat) { match &st.kind { StepKind::Repeat { .. } => (Icon::Redo, Cat::Purple), StepKind::If { .. } => (Icon::Validation, Cat::Amber), StepKind::Stop { .. } => (Icon::StopSquare, Cat::Red), StepKind::Command { id, .. } => { let prefix = id.split('.').next().unwrap_or(""); match prefix { "cell" | "range" | "edit" => (Icon::Sheet, Cat::Blue), "selection" => (Icon::Find, Cat::Slate), "home" => (Icon::Format, Cat::Orange), "data" | "table" | "pivot" => (Icon::SortFilter, Cat::Green), "insert" | "chart" | "object" => (Icon::Insert, Cat::Purple), "formulas" => (Icon::Function, Cat::Teal), "sheet" | "view" | "pageLayout" => (Icon::Sheet, Cat::Amber), _ => (Icon::Script, Cat::Slate), } } } } /// Problems the editor can see without running: an unknown command, one a macro may not run, /// one that needs a permission the macro doesn't list, a Repeat or If with nothing to work on. pub fn static_error(m: &Macro, st: &Step) -> Option { match &st.kind { StepKind::Command { id, params } => { if gridcraft_engine::find_command(id).is_none() { return Some(format!("There is no command “{id}”.")); } match macro_policy(id, params, gridcraft_engine::Trust::Local) { Policy::Deny(why) => Some(format!("{id} can't run in a macro: {why}.")), Policy::NeedsPermission(what) if !m.permissions.iter().any(|p| p == id) => { Some(format!("{id} {what}: the macro must list it in its permissions.")) } _ => None, } } StepKind::Repeat { over, .. } if over.trim().is_empty() => Some("Choose the range to repeat over.".into()), StepKind::If { cell, .. } if cell.trim().is_empty() => Some("Choose the cell to test.".into()), _ => None, } } /// The parameters a new step starts with, from the command's documentation: the required ones, /// plus an optional `cell` or `range` (`{cell?, input, array?}` → cell, input). pub fn required_keys(doc: &str) -> Vec { let Some(start) = doc.find('{') else { return vec![] }; let mut depth = 0i32; let mut quoted = false; let mut part = String::new(); let mut parts = Vec::new(); for c in doc.get(start + 1..).unwrap_or("").chars() { if c == '"' { quoted = !quoted; } if quoted { continue; } match c { '{' | '[' | '(' => depth += 1, '}' if depth == 0 => { parts.push(std::mem::take(&mut part)); break; } '}' | ']' | ')' => depth -= 1, ',' if depth == 0 => { parts.push(std::mem::take(&mut part)); continue; } _ => {} } if depth == 0 { part.push(c); } } parts .iter() .filter_map(|p| { let p = p.trim(); let key: String = p.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); let rest = p.get(key.len()..).unwrap_or("").trim_start(); let optional = rest.starts_with('?') || rest.starts_with('|'); (!key.is_empty() && (!optional || key == "cell" || key == "range")).then_some(key) }) .take(8) .collect() } // ---------------------------------------------------------------- tokens /// The token for `target` relative to the input called `name` at `input`: `Input`, `Input.col(2)`, /// `Input.rows(2..3)`, `Input.cell(1,4)`, `Input.offset(0,4)`, or a cell offset from the input's /// first cell (`Input.cell(1,1).offset(-1,0)`) with a `resize` when it is larger than one cell. pub fn token_for(name: &str, input: RangeRef, target: RangeRef) -> String { let (i, r) = (input, target); if r == i { return name.to_string(); } let dr = i64::from(r.start.row) - i64::from(i.start.row); let dc = i64::from(r.start.col) - i64::from(i.start.col); let (h, w) = (r.height(), r.width()); if h == i.height() && w == i.width() { return format!("{name}.offset({dr},{dc})"); } let inside = i.contains(r.start) && i.contains(r.end); if inside { let (r0, r1) = (dr + 1, dr + i64::from(h)); let (c0, c1) = (dc + 1, dc + i64::from(w)); if r.start.row == i.start.row && r.end.row == i.end.row { return if w == 1 { format!("{name}.col({c0})") } else { format!("{name}.cols({c0}..{c1})") }; } if r.start.col == i.start.col && r.end.col == i.end.col { return if h == 1 { format!("{name}.row({r0})") } else { format!("{name}.rows({r0}..{r1})") }; } let cell = format!("{name}.cell({r0},{c0})"); return if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") }; } let cell = format!("{name}.cell(1,1).offset({dr},{dc})"); if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") } } /// `Sheet1!$B$2:$D$9,C4` → [(Some("Sheet1"), B2:D9), (None, C4)]. pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { text.split(',') .filter_map(|part| { let part = part.trim(); let (sheet, body) = match part.rsplit_once('!') { Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), None => (None, part), }; RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) }) .take(500) .collect() } /// The inputs bound to their examples, for showing tokens as A1 and A1 as tokens. fn bound_inputs(wb: &Workbook, ed: &Editor) -> Vec<(String, Place, bool)> { let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &[], output: None, item: None }; ed.draft .inputs .iter() .enumerate() .filter_map(|(k, i)| { let example = if k == 0 { Some(ed.example.as_str()) } else { i.default_ref.as_deref() }; let place = resolve::resolve_ref(example?, &env).ok()?; Some((i.name.clone(), place, i.header)) }) .collect() } /// The suggestions offered beside a reference box. fn token_suggestions(wb: &Workbook, ed: &Editor, in_repeat: bool) -> Vec { let mut out = Vec::new(); for (k, i) in ed.draft.inputs.iter().enumerate() { let n = &i.name; out.push(n.clone()); if i.header { out.push(format!("{n}.data")); } let width = if k == 0 { parse_refs(&ed.example).first().map_or(3, |(_, r)| r.width()) } else { 3 }.clamp(1, 6); out.extend((1..=width).map(|c| format!("{n}.col({c})"))); out.push(format!("{n}.rows(2..)")); out.push(format!("{n}.cell(1,1)")); out.push(format!("{n}.right")); out.push(format!("{n}.below")); } if ed.draft.output.anchor.is_some() { out.push("Output".into()); out.push("Output.offset(1,0)".into()); } if in_repeat { out.push("Row".into()); out.push("Row.right".into()); } out.extend(wb.names.iter().take(20).map(|n| format!("Name:{}", n.name))); out } // ---------------------------------------------------------------- opening /// Opens the editor: `{macro}` edits a saved macro (`debug: true` also starts debugging it, /// `check: true` checks it), `{new}` starts one with that name, `{}` one with the next free name. pub fn open(app: &mut SheetApp, params: &Json) -> Option { let doc = app.session.active()?; let wb = &doc.wb; let selection = doc.selection.current().a1(); let found = params["macro"].as_str().and_then(|n| gridcraft_engine::macros::find(wb, n)).and_then(|i| wb.macros.get(i)).cloned(); let (draft, saved_as) = match found { Some(m) => { let name = m.name.clone(); (m, Some(name)) } None => { let name = params["new"] .as_str() .map(str::trim) .filter(|n| !n.is_empty()) .map(|n| gridcraft_engine::macros::unique_name(wb, n)) .unwrap_or_else(|| gridcraft_engine::macros::next_name(wb)); (Macro { name, inputs: vec![NamedInput::default()], ..Default::default() }, None) } }; let example = draft.inputs.first().and_then(|i| i.default_ref.clone()).unwrap_or(selection); let start_debug = params["debug"].as_bool() == Some(true) && saved_as.is_some(); let title = format!("Macro: {}", draft.name); let selected = (!draft.steps.is_empty()).then(|| "1".to_string()); let doc = doc.uid; app.macros.editor = Some(Editor { doc, opened_as: draft.name.clone(), draft, dirty: saved_as.is_none(), saved_as, selected, tab: Tab::Step, input: 0, picker: None, check: None, debug: None, outcome: None, errors: BTreeMap::new(), ask_stop: false, closing: false, close_requested: false, quit: false, then_close: None, ask_close: false, start_debug, start_check: params["check"].as_bool() == Some(true), example, new_param: Default::default(), refs: RefPick::default(), }); Some(Dialog::custom("macroEditor", &title, json!({}))) } // ---------------------------------------------------------------- actions /// Whether the editor's workbook is the active one, which the `macro.*` commands act on. When /// another workbook is active (File › New, Switch Window, an agent), says so and returns false. fn at_home(app: &mut SheetApp, ed: &Editor, title: &str) -> bool { if app.session.active().is_some_and(|d| d.uid == ed.doc) { return true; } let book = home_title(app, ed); app.message = Some((title.into(), format!("The macro “{}” is in “{book}”. Switch back to it to save, check or run the macro.", ed.draft.name))); false } /// The title of the editor's workbook. fn home_title(app: &SheetApp, ed: &Editor) -> String { app.session.documents().iter().find(|d| d.uid == ed.doc).map(|d| d.display_title()).unwrap_or_default() } /// Saves the draft (renaming the saved macro if its name changed). False when it couldn't. pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { if !at_home(app, ed, "Save Macro") { return false; } ed.draft.name = ed.draft.name.trim().to_string(); if let Err(e) = ed.draft.check_limits() { app.message = Some(("Save Macro".into(), e)); return false; } if let Some(old) = ed.saved_as.clone() && !old.eq_ignore_ascii_case(&ed.draft.name) { if let Err(e) = app.run("macro.rename", json!({"name": old, "to": ed.draft.name})) { app.message = Some(("Save Macro".into(), crate::clean_error(&e))); return false; } // The workbook has it under the new name now, even if the save below fails. ed.saved_as = Some(ed.draft.name.clone()); } // A new draft never replaces a macro that has its name; the person chooses another. let replace = ed.saved_as.is_some(); if !replace && let Some(doc) = app.session.active() && let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, &ed.draft.name) { app.message = Some(("Save Macro".into(), e)); return false; } match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft), "replace": replace})) { Ok(_) => { ed.saved_as = Some(ed.draft.name.clone()); ed.dirty = false; true } Err(e) => { app.message = Some(("Save Macro".into(), crate::clean_error(&e))); false } } } fn ensure_saved(app: &mut SheetApp, ed: &mut Editor) -> bool { if ed.dirty || ed.saved_as.is_none() { save(app, ed) } else { true } } impl Editor { /// The draft holds changes not yet saved into the workbook. A new macro counts once it /// differs from the empty one the editor started with, its name included. pub fn has_unsaved_changes(&self) -> bool { let untouched = Macro { name: self.opened_as.clone(), inputs: vec![NamedInput::default()], ..Default::default() }; self.dirty && (self.saved_as.is_some() || self.draft != untouched) } } /// Closing the editor's workbook (File › Close, Cmd+W, the Backstage Close, a window's close /// button) while the draft has unsaved changes: the editor asks about them first, as its own /// close button does, and the close (`id` with `params`) goes on once the editor has closed, in /// the window it was asked in; Cancel keeps both. Closing the window that shows the editor asks /// too, even when the workbook stays open in another window. A close of another of the /// workbook's windows, or a forced one, is none of the editor's business (`None`) and goes ahead /// as before. /// /// While the editor is asking, one question stands for one close: a further request for the /// same close (Cmd+W pressed again, the close button after Cmd+W) waits for the answer without a /// second question, and the pending close then runs once. A request for a different close (the /// workbook while only the editor's window was closing, or the reverse) is refused with the /// reason until the question is answered, so that the answer never closes more or less than /// what was asked. pub fn ask_before_closing(app: &mut SheetApp, id: &str, params: &Json) -> Option> { let flag = |k: &str| params.get(k).and_then(Json::as_bool) == Some(true); if flag("force") { return None; } let doc = app.macros.editor.as_ref().filter(|ed| ed.has_unsaved_changes()).map(|ed| ed.doc)?; let window = match id { "file.close" => app.session.active_window(), "window.close" => gridcraft_engine::Target::from_json(params).ok().and_then(|t| app.session.target_window(t).ok()), _ => None, }; let owner = window.and_then(|w| app.session.window(w)).map(|w| w.doc_uid)?; // The editor asks in the window it is shown in. let home = app.window_with_dialog("macroEditor")?; let only = app.session.windows().iter().filter(|w| w.doc_uid == owner).count() <= 1; // The workbook closes, or the window that shows the editor does (taking the editor with it). let whole = only || (id == "file.close" && flag("document")); if owner != doc || !(whole || window == Some(home)) { return None; } if app.ui_window() != Some(home) { app.enter_window(home); } let ed = app.macros.editor.as_mut()?; let waiting = json!({"closed": false, "dirty": true, "macroDraft": true}); // Quitting: the quit asks about every unsaved workbook itself once the editor has closed, // and Cancel keeps them all. if ed.quit { return Some(Ok(waiting)); } match &ed.then_close { Some(pending) if pending.whole == whole => {} Some(pending) => { let what = |whole: bool| if whole { "the workbook" } else { "its window" }; return Some(Err(format!( "The macro step editor is asking whether to save its draft before {} closes. Answer that first, then close {}.", what(pending.whole), what(whole) ))); } None => { ed.then_close = Some(PendingClose { window: home, whole, id: id.to_string(), params: params.clone() }); ed.close_requested = true; } } Some(Ok(waiting)) } /// The inputs a run from the editor uses: the first input's example. fn run_inputs(ed: &Editor) -> Json { match ed.draft.inputs.first() { Some(i) if !ed.example.trim().is_empty() => json!({ i.name.clone(): ed.example }), _ => json!({}), } } /// The step path an error message names (`Step 3 (cell.set): …` → `3`). fn error_step(msg: &str) -> Option<(String, String)> { let rest = msg.get(msg.find("Step ")? + 5..)?; let end = rest.find([' ', ':']).unwrap_or(rest.len()); let path = rest.get(..end)?.to_string(); split_path(&path)?; Some((path, msg.to_string())) } /// Check: validates the inputs and dry-runs the macro; the grid shows what it would touch. pub fn check(app: &mut SheetApp, ed: &mut Editor) { // A reference box picking from the grid would take the selections the action makes. ed.refs.pick = None; if !at_home(app, ed, "Check Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); let name = ed.draft.name.clone(); match app.run("macro.check", json!({"name": name, "inputs": run_inputs(ed)})) { Ok(v) => { for e in v["errors"].as_array().into_iter().flatten().filter_map(Json::as_str) { if let Some((path, msg)) = error_step(e) { ed.errors.insert(path, msg); } } if let Some(e) = v["footprint"]["error"].as_object() && let Some(step) = e.get("step").and_then(Json::as_str) { ed.errors.insert(step.to_string(), e.get("message").and_then(Json::as_str).unwrap_or("").to_string()); } ed.outcome = Some(check_text(&v)); ed.check = Some(v); } Err(e) => { ed.check = None; ed.outcome = Some(crate::clean_error(&e)); } } } fn check_text(v: &Json) -> String { if let Some(errors) = v["errors"].as_array().filter(|e| !e.is_empty()) { return errors.iter().filter_map(Json::as_str).collect::>().join(" "); } if let Some(asks) = v["needsApproval"].as_array() { return format!("Needs approval before it can run: it {}.", asks.iter().filter_map(Json::as_str).collect::>().join(", ")); } let cells: u64 = v["footprint"]["sheets"].as_array().into_iter().flatten().filter_map(|s| s["changed"].as_u64()).sum(); let conflicts = v["conflicts"].as_u64().unwrap_or(0); let mut text = format!("Check passed: writes {cells} cell{}", if cells == 1 { "" } else { "s" }); if conflicts > 0 { text.push_str(&format!(", {conflicts} of them hold data (red)")); } else { text.push_str(", over no data"); } if v["footprint"]["partial"].is_object() { text.push_str(". Later steps can't be previewed."); } text } /// Run: runs the saved macro as one undo step. pub fn run(app: &mut SheetApp, ed: &mut Editor) { ed.refs.pick = None; if !at_home(app, ed, "Run Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); let name = ed.draft.name.clone(); match macro_ui::call(app, "Run Macro", "macro.run", json!({"name": name, "inputs": run_inputs(ed)})) { Some(v) => ed.outcome = Some(format!("Ran {} step{}.", v["ran"].as_u64().unwrap_or(0), if v["ran"] == 1 { "" } else { "s" })), None => { if let Some((_, text)) = app.message.as_ref() && let Some((path, msg)) = error_step(text) { ed.errors.insert(path, msg); } } } } /// The breakpoints sent with each debugger action: the draft's (they replace the saved ones, so /// a dot cleared while paused takes effect at once). fn bp_params(ed: &Editor) -> Json { let mut list = Vec::new(); breakpoints(&ed.draft.steps, "", &mut list); json!({"breakpoints": list, "breakpointsOnly": true}) } fn with(mut base: Json, extra: Json) -> Json { if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) { for (k, v) in e { b.insert(k.clone(), v.clone()); } } base } /// Step, Continue or Run to Here: starts debugging if needed (paused before step 1), then acts. pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { ed.refs.pick = None; let debugging = app.session.macro_debug.is_some(); // Starting selects the first step: take Run to Here's target before that. let target = ed.selected.clone().filter(|s| split_path(s).is_some()); if !debugging { if !at_home(app, ed, "Debug Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); // A dry run first, so the grid shows the inputs and what the run will write. check(app, ed); let name = ed.draft.name.clone(); let p = with(json!({"name": name, "inputs": run_inputs(ed), "action": "start"}), bp_params(ed)); let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", p) else { return }; apply_debug(ed, &v); if action == "step" || v["state"] != "paused" { return; } } let p = match action { "runTo" => match target { Some(step) => json!({"action": "runTo", "step": step}), None => return, }, other => json!({"action": other}), }; if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", with(p, bp_params(ed))) { apply_debug(ed, &v); } } /// Stop: ends the debugged run, keeping its changes as one undo step or undoing them. pub fn stop(app: &mut SheetApp, ed: &mut Editor, keep: bool) { if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", json!({"action": "stop", "keep": keep})) { apply_debug(ed, &v); } } /// Takes in a `macro.debug` result: paused (select the current step), or ended (say how). pub fn apply_debug(ed: &mut Editor, v: &Json) { match v["state"].as_str() { Some("paused") => { if let Some(step) = v["current"]["step"].as_str() { ed.selected = Some(step.to_string()); ed.tab = Tab::Step; } ed.outcome = None; ed.debug = Some(v.clone()); } Some(state) => { ed.debug = None; let executed = v["executed"].as_u64().unwrap_or(0); ed.outcome = Some(match state { "finished" => format!("Finished: {executed} step{} ran, one undo step.", if executed == 1 { "" } else { "s" }), "failed" => { if let Some(step) = v["error"]["step"].as_str() { let msg = v["error"]["message"].as_str().unwrap_or("").to_string(); ed.errors.insert(step.to_string(), msg); ed.selected = Some(step.to_string()); } v["message"].as_str().unwrap_or("The macro failed; nothing was changed.").to_string() } _ if v["kept"] == true => format!("Stopped after {executed} step{}; the changes were kept.", if executed == 1 { "" } else { "s" }), _ => match v["stopped"].as_str() { Some(msg) => format!("Stopped: {msg}"), None => "Stopped; the changes were undone.".into(), }, }); } None => {} } } // ---------------------------------------------------------------- the window /// Draws the editor window. `open` turns false when it closes. pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mut bool) { let Some(mut ed) = app.macros.editor.take() else { *open = false; return; }; // A prompt's button re-ran a debugger action (making room for the output, approving). if let Some(v) = app.macros.debug_result.take() { apply_debug(&mut ed, &v); } if ed.start_check { ed.start_check = false; check(app, &mut ed); } if ed.start_debug { ed.start_debug = false; debug_action(app, &mut ed, "step"); } // The run ended elsewhere (the status bar, an agent). if ed.debug.is_some() && app.session.macro_debug.is_none() { ed.debug = None; } // Its workbook was closed: the macro went with it. if !app.session.documents().iter().any(|d| d.uid == ed.doc) { *open = false; return; } let home = app.session.active().is_some_and(|d| d.uid == ed.doc); let home_title = home_title(app, &ed); let dirty_mark = if ed.dirty { " •" } else { "" }; let screen = ctx.content_rect(); let fit = (screen.size() - vec2(16.0, 16.0)).max(vec2(240.0, 200.0)); let mut win_open = true; ed.refs.drawn = false; egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) .id(Id::unique("gridcraft.macro_editor")) .collapsible(false) .resizable(true) // Never larger than the app's window, so a narrow one keeps every control on screen. .default_size(vec2(840.0, 560.0).min(fit)) .min_size(vec2(660.0, 380.0).min(fit)) // To the right, so the data the overlays mark (usually top left) stays in view. .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) .open(&mut win_open) .show(ctx, |ui| { if !home { let t = Tokens::get(ui.ctx()); ui.label( egui::RichText::new(format!("This macro is in “{home_title}”. Switch back to it to edit, check or run the macro.")) .color(t.danger), ); } ui.add_enabled_ui(home, |ui| { if let Some(key) = ed.refs.collapsed.clone() { ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); apply_ref_value(&mut ed, d, &key); return; } body(app, ui, d, &mut ed); }); }); if ed.refs.pick.is_some() && !ed.refs.drawn { ed.refs.pick = None; } if !win_open || std::mem::take(&mut ed.close_requested) { if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; } else if ed.has_unsaved_changes() { ed.ask_close = true; } else { *open = false; } } if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && ed.picker.is_some() { ed.picker = None; } prompts(app, ctx, &mut ed, open); // The overlays mark the editor's workbook's cells, so they show only while it is active. app.grid.macro_overlay = if *open && home { overlay(app, &ed) } else { None }; if *open { app.macros.editor = Some(ed); } else { if home { restore_home(app, &ed.refs); } if ed.quit { // A quit takes over a workbook close waiting on the editor: it asks about every // unsaved workbook itself, and the close replayed after it would act on whichever // workbook the quit is asking about. if app.native_windows { // Each workbook window is a native window whose close button closes only it: // the quit goes on directly. app.continue_quit(ctx); } else { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } else if let Some(close) = ed.then_close.take() { // The close runs in the window it was asked in, not in whichever window the person // activated meanwhile: `file.close` acts on the active window and workbook. if app.session.window(close.window).is_some() { app.enter_window(close.window); app.run_or_alert(&close.id, close.params); } else { app.message = Some(("Close".into(), "The window that was closing has closed already; nothing else was closed.".into())); } } } } /// Asks the open step editor to close, as its close button does: it asks about a debug run or /// unsaved steps first. With `quit` (the window is closing, or the app is quitting), the close or /// quit goes on once the editor has closed; a Cancel in its questions keeps both open. pub fn request_close(app: &mut SheetApp, quit: bool) { if let Some(ed) = app.macros.editor.as_mut() { ed.close_requested = true; ed.quit = quit; } } /// Closes the step editor at once for a caller outside its window (the control channel's /// `ui.dialog.cancel`), with the cleanup its own close does. Refused while it holds a debug run /// or unsaved steps, which only its window asks about. pub fn close_now(app: &mut SheetApp) -> Result<(), String> { if app.session.macro_debug.is_some() { return Err("A macro is being debugged: stop it first (macro.debug {\"action\": \"stop\"}).".into()); } if app.macros.editor.as_ref().is_some_and(Editor::has_unsaved_changes) { return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); } if let Some(ed) = app.macros.editor.take() && app.session.active().is_some_and(|d| d.uid == ed.doc) { restore_home(app, &ed.refs); } app.grid.macro_overlay = None; app.dialog = None; Ok(()) } // ---------------------------------------------------------------- reference boxes /// A range as an absolute reference: `$B$2:$D$9`. fn abs(r: &RangeRef) -> String { let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } } /// The grid selection as a reference: absolute, comma-joined areas, with the sheet name when it /// isn't the sheet picking started on. fn selection_ref(app: &SheetApp, home_sheet: Option) -> String { let Some(doc) = app.session.active() else { return String::new() }; let areas: Vec = doc.selection.ranges.iter().map(abs).collect(); match (home_sheet, doc.wb.active()) { (Some(h), Some(sh)) if h != doc.wb.active_sheet() => { let q = gridcraft_engine::formula::quote_sheet(&sh.name); areas.iter().map(|a| format!("{q}!{a}")).collect::>().join(",") } _ => areas.join(","), } } /// Starts picking into `key`: later grid selections are written into the box. The first pick /// remembers the selection, so closing the editor can put it back. fn begin_pick(app: &SheetApp, refs: &mut RefPick, key: &str) { if refs.home.is_none() && let Some(doc) = app.session.active() { let ranges: Vec = doc.selection.ranges.iter().map(|r| r.a1()).collect(); refs.home = Some((doc.wb.active_sheet(), ranges.join(","), doc.selection.active.a1())); } refs.pick = Some(key.to_string()); refs.seen = selection_ref(app, refs.home.as_ref().map(|h| h.0)); } /// Puts back the sheet and selection from before picking started. fn restore_home(app: &mut SheetApp, refs: &RefPick) { if let Some((sheet, ranges, active)) = &refs.home { let _ = app.session.run("selection.set", json!({"sheet": sheet, "range": ranges, "active": active})); } } /// A reference box (Excel's RefEdit): a text field that grid selections fill while it is the /// pick target, with a button that collapses the window to just this box and expands it again. /// The text is kept in `d.values[key]`. fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key: &str, width: f32) { let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); let sel = selection_ref(app, refs.home.as_ref().map(|h| h.0)); let picking = refs.pick.as_deref() == Some(key); if picking { refs.drawn = true; } ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); if r.gained_focus() || (r.clicked() && !picking) { begin_pick(app, refs, key); } else if picking && !r.has_focus() && sel != refs.seen && app.session.macro_debug.is_none() { // A selection made on the grid becomes the reference; while debugging, the selection // follows the steps and is not a pick. s = sel.clone(); refs.seen = sel; } let collapsed = refs.collapsed.as_deref() == Some(key); let t = Tokens::get(ui.ctx()); let (icon, tip) = if collapsed { (Icon::Expand, "Expand the editor") } else { (Icon::Collapse, "Collapse the editor to this box") }; if crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0)).clicked() { refs.collapsed = if collapsed { None } else { Some(key.to_string()) }; if !collapsed { begin_pick(app, refs, key); } } }); d.values.insert(key.into(), json!(s)); } /// Keep changes? (stopping a debugged run) and Save changes? (closing with unsaved edits). fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut bool) { if ed.ask_stop { let mut answer: Option> = None; egui::Modal::new(Id::unique("gridcraft.macro_editor.stop")).show(ctx, |ui| { ui.set_width(360.0); ui.label(egui::RichText::new("Stop debugging").strong()); ui.label("Keep the changes the macro has made so far? They are one undo step either way."); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(None); } if ui.button("Undo Changes").clicked() { answer = Some(Some(false)); } if ui.button("Keep Changes").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { answer = Some(Some(true)); } }); }); match answer { Some(Some(keep)) => { ed.ask_stop = false; stop(app, ed, keep); if ed.closing { if ed.has_unsaved_changes() { ed.ask_close = true } else { *open = false } } ed.closing = false; } Some(None) => { ed.ask_stop = false; ed.closing = false; ed.quit = false; ed.then_close = None; } None => {} } return; } if ed.ask_close { let mut answer: Option> = None; egui::Modal::new(Id::unique("gridcraft.macro_editor.close")).show(ctx, |ui| { ui.set_width(360.0); ui.label(egui::RichText::new(format!("Save changes to macro “{}”?", ed.draft.name)).strong()); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(None); } if ui.button("Don't Save").clicked() { answer = Some(Some(false)); } if ui.button("Save").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { answer = Some(Some(true)); } }); }); match answer { Some(Some(true)) => { ed.ask_close = false; if save(app, ed) { *open = false; } else { // The draft stays open with the reason shown; the workbook is not closed and // the app does not quit, then or when the editor closes later. ed.then_close = None; ed.quit = false; } } Some(Some(false)) => { ed.ask_close = false; *open = false; } Some(None) => { ed.ask_close = false; ed.quit = false; ed.then_close = None; } None => {} } } } fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let t = Tokens::get(ui.ctx()); let debugging = app.session.macro_debug.is_some(); // Name, description, Save; they wrap in a narrow window. ui.horizontal_wrapped(|ui| { let label = ui.label("Name:"); let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")).labelled_by(label.id); let label = ui.label("Description:"); let desc = ui .add_enabled( !debugging, egui::TextEdit::singleline(&mut ed.draft.description).desired_width((ui.available_width() - 90.0).max(80.0)).id_salt("me_desc"), ) .labelled_by(label.id); if name.changed() || desc.changed() { ed.dirty = true; } if ui.add_enabled(ed.dirty && !debugging, egui::Button::new("Save")).clicked() { save(app, ed); } }); ui.add_space(2.0); toolbar(app, ui, ed, debugging); status_line(app, ui, ed, &t); ui.separator(); let avail = ui.available_size(); // The step list takes about half; in a narrow window the inspector keeps room for its tabs. let left_w = (avail.x * 0.52).max(300.0).min((avail.x - 190.0).max(160.0)); ui.horizontal_top(|ui| { ui.allocate_ui_with_layout(vec2(left_w, avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_size(vec2(left_w, avail.y)); if ed.picker.is_some() { picker(ui, ed); } else { step_list(app, ui, ed, debugging); } }); ui.separator(); ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { egui::ScrollArea::both().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { inspector(app, ui, d, ed, debugging); }); }); }); } fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { let t = Tokens::get(ui.ctx()); // The buttons wrap onto a second row in a narrow window. ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let editing = !debugging; if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).clicked() { ed.picker = Some(Picker::default()); } let has_step = ed.selected.as_deref().and_then(split_path).is_some(); if ui .add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) .clicked() { duplicate(ed); } if ui.add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).clicked() { delete(ed); } sep(ui, &t); if ui .add_enabled(editing, |ui: &mut Ui| { small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) }) .clicked() { check(app, ed); } if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).clicked() { run(app, ed); } sep(ui, &t); if small_button(ui, Icon::Step, "Step", if debugging { "Run the current step" } else { "Start debugging: stop before the first step" }, false) .clicked() { debug_action(app, ed, "step"); } if small_button(ui, Icon::Continue, "Continue", "Run to the next breakpoint or the end", false).clicked() { debug_action(app, ed, "continue"); } let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); if ui.add_enabled(can_run_to, |ui: &mut Ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).clicked() { debug_action(app, ed, "runTo"); } if ui.add_enabled(debugging, |ui: &mut Ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).clicked() { ed.ask_stop = true; } }); } fn sep(ui: &mut Ui, t: &Tokens) { let (r, _) = ui.allocate_exact_size(vec2(9.0, 20.0), Sense::hover()); ui.painter().line_segment([pos2(r.center().x, r.top() + 2.0), pos2(r.center().x, r.bottom() - 2.0)], Stroke::new(1.0, t.separator)); } /// Where the run is (while debugging) or what the last check, run or debug session said, with /// the overlay legend. fn status_line(app: &SheetApp, ui: &mut Ui, ed: &Editor, t: &Tokens) { if let Some(st) = &ed.debug { let cur = &st["current"]; let step = cur["step"].as_str().unwrap_or(""); let what = match cur["kind"].as_str() { Some("command") => command_label(cur["command"].as_str().unwrap_or("")), Some(k) => k.to_string(), None => String::new(), }; let mut head = format!("Paused before step {step} · {what}"); if let (Some(at), Some(of)) = (cur["iteration"]["at"].as_u64(), cur["iteration"]["of"].as_u64()) { head.push_str(&format!(" · pass {at} of {of}")); } if st["atBreakpoint"] == true { head.push_str(" · at a breakpoint"); } ui.label(egui::RichText::new(head).strong().color(t.text)); let resolved = cur.get("params").or(cur.get("over")).or(cur.get("cell")).map(|p| p.to_string()); if let Some(p) = resolved { ui.horizontal(|ui| { ui.label(egui::RichText::new("Runs with:").color(t.text_dim)); ui.add(egui::Label::new(egui::RichText::new(p).monospace().color(t.text)).truncate()); }); } else if let Some(e) = cur["paramsError"].as_str() { ui.label(egui::RichText::new(e).color(t.danger)); } let changed: Vec<&str> = st["changed"].as_array().into_iter().flatten().filter_map(Json::as_str).take(6).collect(); ui.label( egui::RichText::new(format!("Changed so far: {}", if changed.is_empty() { "nothing".into() } else { changed.join(", ") })) .color(t.text_dim), ); } else if let Some(text) = &ed.outcome { let bad = !ed.errors.is_empty() || ed.check.as_ref().is_some_and(|c| c["ok"] == false); ui.add(egui::Label::new(egui::RichText::new(text).color(if bad { t.danger } else { t.text })).wrap()); } else { ui.label( egui::RichText::new("Check shows on the grid what the macro reads and writes. Click a step's gutter to set a breakpoint.") .color(t.text_dim), ); } if app.grid.macro_overlay.is_some() { ui.horizontal(|ui| { for (label, c) in [ ("Inputs", t.sheet_macro_input), ("Writes", t.sheet_macro_footprint), ("Writes over data", t.sheet_macro_conflict), ("Current step", t.sheet_macro_target), ] { let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); ui.painter().rect_filled(r, 2.0, c); ui.label(egui::RichText::new(label).small().color(t.text_dim)); ui.add_space(6.0); } }); } } // ---------------------------------------------------------------- the step list /// A step being dragged: the list it is in and its place there. #[derive(Clone, Debug)] struct StepDrag { parent: String, index: usize, label: String, } fn step_list(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { let t = Tokens::get(ui.ctx()); let mut list = Vec::new(); rows(&ed.draft.steps, "", 0, &mut list); if list.is_empty() { ui.add_space(8.0); ui.label(egui::RichText::new("No steps yet. Add Step picks a command, or record one with Automate › Record Macro.").color(t.text_dim)); return; } let current = ed.debug.as_ref().and_then(|s| s["current"]["step"].as_str().map(str::to_string)); let dragging = egui::DragAndDrop::payload::(ui.ctx()); let pointer = ui.ctx().pointer_latest_pos(); let mut placed: Vec<(String, Rect)> = Vec::new(); egui::ScrollArea::vertical().id_salt("me_steps").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 1.0; for row in &list { let w = ui.available_width(); let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag()); placed.push((row.parent.clone(), rect)); let selected = ed.selected.as_deref() == Some(row.key.as_str()); let is_current = current.as_deref() == Some(row.key.as_str()); let p = ui.painter(); if is_current { p.rect_filled(rect, 3.0, t.accent_soft); } else if selected { p.rect_filled(rect, 3.0, t.pressed); } else if resp.hovered() { p.rect_filled(rect, 3.0, t.hover); } if resp.clicked() { ed.selected = Some(row.key.clone()); ed.tab = Tab::Step; } let indent = row.depth as f32 * 16.0; if let Some(label) = row.branch { p.text(pos2(rect.left() + 58.0 + indent, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::ui_bold(12.0), t.text_dim); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{label} branch {}", row.parent))); continue; } let Some(st) = step_at(&ed.draft.steps, &row.key) else { continue }; // Gutter: the breakpoint dot (click to toggle), the current-step arrow. let gutter = Rect::from_min_size(rect.min, vec2(18.0, rect.height())); let g = ui.interact(gutter, Id::unique(("me_bp", &row.key)), Sense::click()); if st.breakpoint { p.circle_filled(gutter.center(), 5.0, t.breakpoint); } else if g.hovered() { p.circle_stroke(gutter.center(), 4.5, Stroke::new(1.0, t.breakpoint)); } if is_current { let c = pos2(gutter.right() + 1.0, gutter.center().y); p.add(egui::Shape::convex_polygon(vec![c + vec2(-6.0, -5.0), c + vec2(1.0, 0.0), c + vec2(-6.0, 5.0)], t.accent, Stroke::NONE)); } g.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, st.breakpoint, format!("Breakpoint {}", row.key))); if g.on_hover_text("Breakpoint: the debugger stops before this step").clicked() && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) { s.breakpoint = !s.breakpoint; ed.dirty = true; } p.text(pos2(rect.left() + 48.0, rect.center().y), egui::Align2::RIGHT_CENTER, &row.key, theme::ui_font(11.5), t.text_dim); let (icon, cat) = category(&st); icons::paint(p, Rect::from_center_size(pos2(rect.left() + 62.0 + indent, rect.center().y), vec2(16.0, 16.0)), icon, t.cat(cat)); let text = summary(&st); let text_color = if st.enabled { t.text } else { t.text_disabled }; let text_left = rect.left() + 76.0 + indent; let text_clip = Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 46.0, rect.bottom())); p.with_clip_rect(text_clip).text(pos2(text_left, rect.center().y), egui::Align2::LEFT_CENTER, &text, theme::ui_font(12.5), text_color); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Step {}: {text}", row.key))); // Error marker. let error = ed.errors.get(&row.key).cloned().or_else(|| static_error(&ed.draft, &st)); if let Some(err) = error { let c = pos2(rect.right() - 36.0, rect.center().y); p.circle_filled(c, 7.0, t.danger); p.text(c, egui::Align2::CENTER_CENTER, "!", theme::ui_bold(11.0), t.window); ui.interact(Rect::from_center_size(c, vec2(16.0, 16.0)), Id::unique(("me_err", &row.key)), Sense::hover()).on_hover_text(err); } // Enabled. let mut on = st.enabled; let cb = ui.put(Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(18.0, 18.0)), egui::Checkbox::without_text(&mut on)); if cb.on_hover_text("Run this step (a disabled step is skipped)").changed() && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) { s.enabled = on; ed.dirty = true; } if !debugging { dnd::drag_source(ui, &resp, StepDrag { parent: row.parent.clone(), index: row.index, label: text.clone() }); } } }); // Dropping a dragged step among its own list's rows. if let (Some(drag), Some(p)) = (dragging, pointer) { let group: Vec = list.iter().zip(&placed).filter(|(r, _)| r.branch.is_none() && r.parent == drag.parent).map(|(_, (_, rect))| *rect).collect(); let zone = group.iter().fold(Rect::NOTHING, |a, r| a.union(*r)); let area = placed.iter().fold(Rect::NOTHING, |a, (_, r)| a.union(*r)).expand(12.0); dnd::ghost(ui, &drag.label, p); if area.contains(p) && zone.is_positive() { dnd::drop_marker(ui, zone, &group, p.y); } if ui.input(|i| i.pointer.any_released()) { let _ = egui::DragAndDrop::take_payload::(ui.ctx()); if area.contains(p) { let to = dnd::insert_index(&group, p.y); if to != drag.index && to != drag.index + 1 && let Some(path) = move_step(&mut ed.draft.steps, &drag.parent, drag.index, to) { ed.selected = Some(path); ed.errors.clear(); ed.dirty = true; } } } } let _ = app; } /// Where a new step goes: after the selected step in its list, at the end of a selected branch /// or Repeat, or at the end of the macro. fn insertion_point(ed: &Editor) -> (String, usize) { let Some(sel) = ed.selected.as_deref() else { return (String::new(), ed.draft.steps.len()) }; let mut steps = ed.draft.steps.clone(); if split_path(sel).is_none() { // A branch row: the end of that branch. let len = list_mut(&mut steps, sel).map_or(0, |l| l.len()); return (sel.to_string(), len); } if let Some(st) = step_mut(&mut steps, sel) && let StepKind::Repeat { body, .. } = &st.kind { return (sel.to_string(), body.len()); } match split_path(sel) { Some((parent, i)) => (parent.to_string(), i + 1), None => (String::new(), ed.draft.steps.len()), } } fn insert_step(ed: &mut Editor, step: Step) { let (parent, at) = insertion_point(ed); if let Some(list) = list_mut(&mut ed.draft.steps, &parent) { let at = at.min(list.len()); list.insert(at, step); ed.selected = Some(child(&parent, at)); ed.tab = Tab::Step; ed.errors.clear(); ed.dirty = true; } } fn duplicate(ed: &mut Editor) { let Some(sel) = ed.selected.clone() else { return }; let Some((parent, i)) = split_path(&sel) else { return }; if let Some(list) = list_mut(&mut ed.draft.steps, parent) && let Some(st) = list.get(i).cloned() { list.insert(i + 1, st); ed.selected = Some(child(parent, i + 1)); ed.errors.clear(); ed.dirty = true; } } fn delete(ed: &mut Editor) { let Some(sel) = ed.selected.clone() else { return }; let Some((parent, i)) = split_path(&sel) else { return }; if let Some(list) = list_mut(&mut ed.draft.steps, parent) && i < list.len() { list.remove(i); ed.selected = if list.is_empty() { (!parent.is_empty()).then(|| parent.to_string()) } else { Some(child(parent, i.min(list.len() - 1))) }; ed.errors.clear(); ed.dirty = true; } } // ---------------------------------------------------------------- Add Step /// The steps the picker offers for `query`: control steps first, then commands a macro may run. pub fn picker_items(query: &str) -> Vec<(String, String, String)> { let q = query.trim().to_ascii_lowercase(); let mut out: Vec<(String, String, String)> = [ ("@repeat", "Repeat for each row", "Control: run steps once per row of a range"), ("@if", "If", "Control: run steps when a cell meets a condition"), ("@stop", "Stop with message", "Control: end the run with a message"), ] .iter() .filter(|(_, l, _)| q.is_empty() || l.to_ascii_lowercase().contains(&q)) .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())) .collect(); for c in gridcraft_engine::command_specs() { if matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)) || c.id.starts_with("ui.") { continue; } if q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q) { out.push((c.id.to_string(), c.label.to_string(), c.menu.join(" › "))); } if out.len() >= 60 { break; } } out } /// A new step for a picker item, with the command's required parameters filled in as blanks. pub fn new_step(item: &str) -> Step { match item { "@repeat" => Step::new(StepKind::Repeat { over: "Input".into(), of: RepeatOf::Rows, max: 10_000, body: vec![] }), "@if" => { Step::new(StepKind::If { cell: "Input.cell(1,1)".into(), op: CompareOp::NotEmpty, value: Json::Null, then: vec![], otherwise: vec![] }) } "@stop" => Step::new(StepKind::Stop { message: String::new(), rollback: false }), id => { let doc = gridcraft_engine::find_command(id).map(|c| c.params).unwrap_or(""); let params: Map = required_keys(doc).into_iter().map(|k| (k, json!(""))).collect(); Step::command(id, Json::Object(params)) } } } fn picker(ui: &mut Ui, ed: &mut Editor) { let Some(pk) = ed.picker.as_mut() else { return }; let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.label(egui::RichText::new(if pk.replace.is_some() { "Change the command" } else { "Add a step" }).strong()); if ui.small_button("Cancel").clicked() { pk.search = "\u{1}".into(); } }); if pk.search == "\u{1}" { ed.picker = None; return; } let r = ui.add( egui::TextEdit::singleline(&mut pk.search) .hint_text("Search commands, e.g. bold, sort, set…") .desired_width(f32::INFINITY) .id_salt("me_pick"), ); r.request_focus(); if r.changed() { pk.index = 0; } let mut items = picker_items(&pk.search); // Change swaps a command step's command; it can't turn the step into Repeat, If or Stop. if pk.replace.is_some() { items.retain(|(id, ..)| !id.starts_with('@')); } if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { pk.index = (pk.index + 1).min(items.len().saturating_sub(1)); } if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { pk.index = pk.index.saturating_sub(1); } let mut chosen = ui.input(|i| i.key_pressed(egui::Key::Enter)).then_some(pk.index); egui::ScrollArea::vertical().id_salt("me_pick_list").auto_shrink([false, false]).show(ui, |ui| { for (i, (id, label, menu)) in items.iter().enumerate() { let r = ui.add(egui::Button::selectable(i == pk.index, label.as_str()).min_size(vec2(ui.available_width(), 20.0))); if r.clicked() { chosen = Some(i); } r.on_hover_text(format!("{id} {menu}")); } if items.is_empty() { ui.label(egui::RichText::new("No command matches.").color(t.text_dim)); } }); let Some(item) = chosen.and_then(|i| items.get(i)).map(|x| x.0.clone()) else { return }; let replace = pk.replace.clone(); ed.picker = None; match replace { Some(path) => { let fresh = new_step(&item); if let Some(st) = step_mut(&mut ed.draft.steps, &path) { st.kind = fresh.kind; ed.dirty = true; } } None => insert_step(ed, new_step(&item)), } } // ---------------------------------------------------------------- the inspector fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { ui.horizontal_wrapped(|ui| { for (tab, label) in [(Tab::Step, "Details"), (Tab::Inputs, "Inputs"), (Tab::Output, "Output")] { if ui.selectable_label(ed.tab == tab, label).clicked() { ed.tab = tab; } } }); ui.separator(); ui.add_enabled_ui(!debugging, |ui| match ed.tab { Tab::Step => step_tab(app, ui, d, ed), Tab::Inputs => inputs_tab(app, ui, d, ed), Tab::Output => output_tab(app, ui, d, ed), }); } /// The dialog value key of a reference box. fn ref_key(path: &str, field: &str) -> String { format!("ref:{path}:{field}") } /// After a RefEdit box changed (typed, or picked on the grid): writes it into the draft. fn apply_ref_value(ed: &mut Editor, d: &Dialog, key: &str) { let Some(v) = d.values.get(key).and_then(Json::as_str).map(str::to_string) else { return }; let mut parts = key.splitn(3, ':'); let (_, path, field) = (parts.next(), parts.next().unwrap_or(""), parts.next().unwrap_or("")); if set_ref(ed, path, field, &v) { ed.dirty = true; } } /// Writes reference text into the field a ref box edits. True when it changed. fn set_ref(ed: &mut Editor, path: &str, field: &str, v: &str) -> bool { match path { "@output" => { let new = (!v.trim().is_empty()).then(|| v.trim().to_string()); let changed = ed.draft.output.anchor != new; ed.draft.output.anchor = new; changed } "@example" => { let changed = ed.example != v; ed.example = v.to_string(); if let Some(i) = ed.draft.inputs.first_mut() && i.default_ref.is_some() { i.default_ref = Some(v.to_string()); } changed } _ => { let Some(st) = step_mut(&mut ed.draft.steps, path) else { return false }; let slot: Option<&mut String> = match (&mut st.kind, field) { (StepKind::Repeat { over, .. }, "over") => Some(over), (StepKind::If { cell, .. }, "cell") => Some(cell), _ => None, }; if let Some(s) = slot { let changed = s != v; *s = v.to_string(); return changed; } if let StepKind::Command { params, .. } = &mut st.kind && let Some(o) = params.as_object_mut() { let changed = o.get(field).and_then(Json::as_str) != Some(v); o.insert(field.to_string(), json!(v)); return changed; } false } } } /// A reference box: RefEdit (type, or select on the grid), Relative/Absolute, and suggestions. fn ref_field(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, (path, field): (&str, &str), value: &str, in_repeat: bool) { let key = ref_key(path, field); if ed.refs.pick.as_deref() != Some(key.as_str()) || d.values.get(&key).is_none() { d.values.insert(key.clone(), json!(value)); } let width = (ui.available_width() - 96.0).clamp(120.0, 320.0); ui.horizontal(|ui| { ref_edit(app, ui, d, &mut ed.refs, &key, width); apply_ref_value(ed, d, &key); let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); let Some(doc) = app.session.active() else { return }; let wb = doc.wb.clone(); let inputs = bound_inputs(&wb, ed); let env = Env { wb: &wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; let relative = !current.trim().is_empty() && resolve::is_token(¤t, &env); if path != "@example" { if ui.selectable_label(relative, "Rel").on_hover_text("Relative: a token that follows the input the macro runs on").clicked() && !relative { let place = resolve::resolve_ref(¤t, &env).ok(); if let (Some(p), Some((name, input, _))) = (place, inputs.first()) && p.sheet == input.sheet { let tok = token_for(name, input.range, p.range); set_ref(ed, path, field, &tok); d.values.insert(key.clone(), json!(tok)); ed.dirty = true; } } if ui.selectable_label(!relative && !current.trim().is_empty(), "Abs").on_hover_text("Absolute: always these cells").clicked() && relative && let Ok(p) = resolve::resolve_ref(¤t, &env) { let a = resolve::a1(p, &env); set_ref(ed, path, field, &a); d.values.insert(key.clone(), json!(a)); ed.dirty = true; } let menu = ui.button("▾").on_hover_text("Tokens"); egui::Popup::menu(&menu).show(|ui| { ui.set_max_height(300.0); egui::ScrollArea::vertical().show(ui, |ui| { for tok in token_suggestions(&wb, ed, in_repeat) { let shown = resolve::resolve_ref(&tok, &env).map(|p| format!("{tok} {}", resolve::a1(p, &env))).unwrap_or_else(|_| tok.clone()); if ui.button(shown).clicked() { set_ref(ed, path, field, &tok); d.values.insert(key.clone(), json!(tok)); ed.dirty = true; } } }); }); } }); // What a token stands for on the example. let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); if let Some(doc) = app.session.active() { let wb = &doc.wb; let inputs = bound_inputs(wb, ed); let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; if !current.trim().is_empty() && resolve::is_token(¤t, &env) && !current.starts_with("Row") && !current.starts_with("Output") { let t = Tokens::get(ui.ctx()); match resolve::resolve_ref(¤t, &env) { Ok(p) => ui.label(egui::RichText::new(format!("= {} on the example", resolve::a1(p, &env))).small().color(t.text_dim)), Err(e) => ui.label(egui::RichText::new(e.message()).small().color(t.danger)), }; } } } fn step_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let t = Tokens::get(ui.ctx()); let Some(path) = ed.selected.clone() else { ui.label(egui::RichText::new("Select a step to see its settings.").color(t.text_dim)); return; }; let Some(st) = step_at(&ed.draft.steps, &path) else { ui.label( egui::RichText::new(format!( "Steps here run when the If is {}. Add Step puts a step in this branch.", if path.ends_with("then") { "true" } else { "false" } )) .color(t.text_dim), ); return; }; let in_repeat = path.contains('.'); let (icon, cat) = category(&st); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); icons::paint(ui.painter(), r, icon, t.cat(cat)); let title = match &st.kind { StepKind::Command { id, .. } => command_label(id), StepKind::Repeat { .. } => "Repeat".into(), StepKind::If { .. } => "If".into(), StepKind::Stop { .. } => "Stop".into(), }; ui.label(egui::RichText::new(format!("Step {path} · {title}")).strong()); if let StepKind::Command { id, .. } = &st.kind { ui.label(egui::RichText::new(id).monospace().color(t.text_dim)); if ui.small_button("Change…").clicked() { ed.picker = Some(Picker { replace: Some(path.clone()), ..Default::default() }); } } }); let mut enabled = st.enabled; let mut bp = st.breakpoint; ui.horizontal(|ui| { let a = ui.checkbox(&mut enabled, "Enabled").changed(); let b = ui.checkbox(&mut bp, "Breakpoint").changed(); if (a || b) && let Some(s) = step_mut(&mut ed.draft.steps, &path) { s.enabled = enabled; s.breakpoint = bp; ed.dirty = true; } }); if let Some(e) = ed.errors.get(&path).cloned().or_else(|| static_error(&ed.draft, &st)) { ui.label(egui::RichText::new(e).color(t.danger)); } ui.add_space(4.0); match st.kind.clone() { StepKind::Command { id, params } => command_fields(app, ui, d, ed, &path, &id, ¶ms, in_repeat), StepKind::Repeat { over, of, max, .. } => { ui.label("For each"); let mut of2 = of; ui.horizontal(|ui| { ui.radio_value(&mut of2, RepeatOf::Rows, "row"); ui.radio_value(&mut of2, RepeatOf::Columns, "column"); ui.label("of:"); }); ref_field(app, ui, d, ed, (&path, "over"), &over, in_repeat); let mut max2 = max; ui.horizontal(|ui| { ui.label("At most"); ui.add(egui::DragValue::new(&mut max2).range(1..=100_000)); ui.label("times"); }); if (of2 != of || max2 != max) && let Some(StepKind::Repeat { of, max, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) { *of = of2; *max = max2; ed.dirty = true; } ui.label(egui::RichText::new("Inside, Row (or Column) stands for the current one, e.g. Row.right.").small().color(t.text_dim)); } StepKind::If { cell, op, value, .. } => { ui.label("If cell"); ref_field(app, ui, d, ed, (&path, "cell"), &cell, in_repeat); let mut op2 = op; let ops = [ (CompareOp::Eq, "equals"), (CompareOp::Ne, "doesn't equal"), (CompareOp::Gt, "is greater than"), (CompareOp::Ge, "is at least"), (CompareOp::Lt, "is less than"), (CompareOp::Le, "is at most"), (CompareOp::Empty, "is empty"), (CompareOp::NotEmpty, "isn't empty"), (CompareOp::Contains, "contains"), ]; let label = ops.iter().find(|(o, _)| *o == op).map_or("", |x| x.1); craft_ui::ComboBox::from_id_salt(("me_op", &path)).selected_text(label).show_ui(ui, |ui| { for (o, l) in ops { ui.selectable_value(&mut op2, o, l); } }); let mut text = match &value { Json::String(s) => s.clone(), Json::Null => String::new(), other => other.to_string(), }; let changed_value = if matches!(op2, CompareOp::Empty | CompareOp::NotEmpty) { false } else { ui.add(egui::TextEdit::singleline(&mut text).hint_text("value").id_salt(("me_ifv", &path))).changed() }; if (op2 != op || changed_value) && let Some(StepKind::If { op, value, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) { *op = op2; if changed_value { *value = scalar(&text); } ed.dirty = true; } } StepKind::Stop { message, rollback } => { let (mut m, mut rb) = (message.clone(), rollback); ui.label("Message:"); let a = ui.add(egui::TextEdit::singleline(&mut m).desired_width(f32::INFINITY).id_salt(("me_stop", &path))).changed(); let b = ui.checkbox(&mut rb, "Undo what the macro did before stopping").changed(); if (a || b) && let Some(StepKind::Stop { message, rollback }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) { *message = m; *rollback = rb; ed.dirty = true; } } } ui.add_space(6.0); ui.label("Note:"); let mut note = st.note.clone(); if ui.add(egui::TextEdit::multiline(&mut note).desired_rows(2).desired_width(f32::INFINITY).id_salt(("me_note", &path))).changed() && let Some(s) = step_mut(&mut ed.draft.steps, &path) { s.note = note; ed.dirty = true; } } /// Typed text as a JSON value: a number, TRUE/FALSE, or text. fn scalar(text: &str) -> Json { let t = text.trim(); if let Ok(n) = t.parse::() && n.is_finite() { return json!(n); } match t.to_ascii_lowercase().as_str() { "true" => json!(true), "false" => json!(false), _ => json!(text), } } #[allow(clippy::too_many_arguments)] fn command_fields(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, path: &str, id: &str, params: &Json, in_repeat: bool) { let t = Tokens::get(ui.ctx()); let fields: Vec<(String, Json)> = params.as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); let mut remove: Option = None; let mut set: Option<(String, Json)> = None; egui::Grid::new(("me_params", path)).num_columns(3).spacing(vec2(8.0, 4.0)).show(ui, |ui| { for (k, v) in &fields { ui.label(k.as_str()); match v { Json::String(s) if resolve::is_ref_key(k) => { ui.vertical(|ui| ref_field(app, ui, d, ed, (path, k), s, in_repeat)); } Json::String(s) => { let mut text = s.clone(); if ui.add(egui::TextEdit::singleline(&mut text).desired_width(220.0).id_salt(("me_p", path, k))).changed() { set = Some((k.clone(), json!(text))); } } Json::Bool(b) => { let mut on = *b; if ui.checkbox(&mut on, "").changed() { set = Some((k.clone(), json!(on))); } } Json::Number(n) => { let mut x = n.as_f64().unwrap_or(0.0); if ui.add(egui::DragValue::new(&mut x).speed(0.1)).changed() { set = Some((k.clone(), json!(x))); } } other => { let jk = format!("json:{path}:{k}"); let mut text = d.values.get(&jk).and_then(Json::as_str).map(str::to_string).unwrap_or_else(|| other.to_string()); let ok = serde_json::from_str::(&text).is_ok(); let r = ui.add( egui::TextEdit::singleline(&mut text) .desired_width(220.0) .font(egui::TextStyle::Monospace) .text_color(if ok { t.text } else { t.danger }) .id_salt(("me_pj", path, k)), ); if r.changed() { if let Ok(v) = serde_json::from_str::(&text) { set = Some((k.clone(), v)); } d.values.insert(jk, json!(text)); } } } if crate::widgets::icon_button(ui, Icon::Close, t.text_dim, "Remove this parameter", vec2(18.0, 18.0)).clicked() { remove = Some(k.clone()); } ui.end_row(); } }); if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) { if !params.is_object() { *params = json!({}); } if let Some(o) = params.as_object_mut() { if let Some((k, v)) = set { o.insert(k, v); ed.dirty = true; } if let Some(k) = remove { o.remove(&k); ed.dirty = true; } } } ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut ed.new_param.0).hint_text("parameter").desired_width(90.0).id_salt("me_np_k")); ui.add(egui::TextEdit::singleline(&mut ed.new_param.1).hint_text("value").desired_width(140.0).id_salt("me_np_v")); let key = ed.new_param.0.trim().to_string(); if ui.add_enabled(!key.is_empty(), egui::Button::new("Add")).clicked() { let raw = ed.new_param.1.clone(); let v = serde_json::from_str::(&raw).unwrap_or(Json::String(raw)); if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) && let Some(o) = params.as_object_mut() { o.insert(key, v); ed.dirty = true; } ed.new_param = Default::default(); } }); if let Some(spec) = gridcraft_engine::find_command(id) && !spec.params.is_empty() { ui.add_space(4.0); ui.add(egui::Label::new(egui::RichText::new(spec.params).small().color(t.text_dim)).wrap()); } } fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new("The ranges the macro works on. Each is checked before the macro runs.").small().color(t.text_dim)); ui.horizontal_wrapped(|ui| { for (k, i) in ed.draft.inputs.iter().enumerate() { if ui.selectable_label(ed.input == k, i.name.as_str()).clicked() { ed.input = k; } } if ui.small_button("Add").clicked() && ed.draft.inputs.len() < 64 { let n = (2..).map(|k| format!("Input{k}")).find(|n| !ed.draft.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(n))).unwrap_or_default(); ed.draft.inputs.push(NamedInput { name: n, ..Default::default() }); ed.input = ed.draft.inputs.len() - 1; ed.dirty = true; } if ui.add_enabled(!ed.draft.inputs.is_empty(), egui::Button::new("Remove").small()).clicked() && ed.input < ed.draft.inputs.len() { ed.draft.inputs.remove(ed.input); ed.input = ed.input.saturating_sub(1); ed.dirty = true; } }); let k = ed.input; let Some(mut i) = ed.draft.inputs.get(k).cloned() else { ui.label(egui::RichText::new("No inputs: the macro works on fixed cells only.").color(t.text_dim)); return; }; let before = i.clone(); ui.separator(); egui::Grid::new("me_input").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { ui.label("Name:"); ui.add(egui::TextEdit::singleline(&mut i.name).desired_width(140.0).id_salt("me_in_name")); ui.end_row(); ui.label("Shape:"); ui.horizontal(|ui| { ui.radio_value(&mut i.kind, InputKind::Range, "range"); ui.radio_value(&mut i.kind, InputKind::Cell, "single cell"); }); ui.end_row(); if i.kind == InputKind::Range { ui.label("Data rows:"); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut i.rows[0]).range(0..=1_048_576).prefix("at least ")); ui.add( egui::DragValue::new(&mut i.rows[1]) .range(0..=1_048_576) .prefix("at most ") .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), ); }); ui.end_row(); ui.label("Columns:"); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut i.cols[0]).range(0..=16_384).prefix("at least ")); ui.add( egui::DragValue::new(&mut i.cols[1]) .range(0..=16_384) .prefix("at most ") .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), ); }); ui.end_row(); ui.label(""); ui.checkbox(&mut i.header, "First row holds column names"); ui.end_row(); } ui.label(""); ui.checkbox(&mut i.non_empty, "Every cell must hold something"); ui.end_row(); }); // Column types, one per column of the example (or of the list so far). let ncols = if k == 0 { parse_refs(&ed.example).first().map_or(0, |(_, r)| r.width() as usize) } else { 0 }.max(i.col_types.len()).min(16); if i.kind == InputKind::Range && ncols > 0 { ui.label("Each column must hold:"); let headers = if k == 0 { example_headers(app, &ed.example, i.header) } else { vec![] }; egui::Grid::new("me_types").num_columns(2).show(ui, |ui| { for c in 0..ncols { let label = headers.get(c).filter(|h| !h.is_empty()).cloned().unwrap_or_else(|| format!("Column {}", c + 1)); ui.label(label); let mut ty = i.col_types.get(c).copied().unwrap_or(ColType::Any); let before_ty = ty; craft_ui::ComboBox::from_id_salt(("me_ty", c)).selected_text(type_name(ty)).show_ui(ui, |ui| { for x in [ColType::Any, ColType::Number, ColType::Text, ColType::Date, ColType::Bool] { ui.selectable_value(&mut ty, x, type_name(x)); } }); if ty != before_ty { while i.col_types.len() <= c { i.col_types.push(ColType::Any); } if let Some(slot) = i.col_types.get_mut(c) { *slot = ty; } } ui.end_row(); } }); } if i != before { tidy_input(&mut i, &before); if let Some(slot) = ed.draft.inputs.get_mut(k) { *slot = i; } ed.dirty = true; } if k == 0 { ui.separator(); ui.label("Example (what Check, Run and Debug use here):"); let example = ed.example.clone(); ref_field(app, ui, d, ed, ("@example", "example"), &example, false); ui.horizontal(|ui| { if ui.button("Use current selection as example").clicked() { use_selection_as_example(app, ed); } let mut fixed = ed.draft.inputs.first().is_some_and(|i| i.default_ref.is_some()); if ui.checkbox(&mut fixed, "Always run on this range").on_hover_text("Otherwise the macro runs on the selection").changed() && let Some(first) = ed.draft.inputs.first_mut() { first.default_ref = fixed.then(|| ed.example.clone()); ed.dirty = true; } }); } } fn type_name(t: ColType) -> &'static str { match t { ColType::Any => "anything", ColType::Number => "numbers", ColType::Text => "text", ColType::Date => "dates", ColType::Bool => "TRUE or FALSE", } } /// The header texts of the example's first row (when it has a header). fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { let (Some(doc), true) = (app.session.active(), header) else { return vec![] }; let wb = &doc.wb; let Some((sheet, r)) = parse_refs(example).into_iter().next() else { return vec![] }; let si = sheet.and_then(|s| wb.sheet_index(&s)).unwrap_or(wb.active_sheet()); let Some(sh) = wb.sheet(si) else { return vec![] }; (r.start.col..=r.end.col.min(r.start.col.saturating_add(15))) .map(|c| gridcraft_engine::display::cell_text(wb, sh, gridcraft_engine::core::CellRef::new(r.start.row, c))) .collect() } /// Keeps an input the person is editing to the shapes a macro may declare, as the edit is made: /// a single cell is one row and one column with no header and at most one column type; a least /// above its most moves the other end along (the one just set wins); and a range lists no more /// column types than it allows columns. pub fn tidy_input(i: &mut NamedInput, before: &NamedInput) { if i.kind == InputKind::Cell { (i.rows, i.cols, i.header) = ([1, 1], [1, 1], false); i.col_types.truncate(1); return; } for (now, was) in [(&mut i.rows, before.rows), (&mut i.cols, before.cols)] { if now[1] != 0 && now[0] > now[1] { if now[0] != was[0] { now[1] = now[0]; } else { now[0] = now[1]; } } } if i.cols[1] != 0 { i.col_types.truncate(i.cols[1] as usize); } } /// "Use current selection as example": the selection becomes the first input's example, and its /// shape, header and column types are read from it. pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { let Some(doc) = app.session.active() else { return }; let r = doc.selection.current(); let Some(sh) = doc.wb.active() else { return }; ed.example = r.a1(); let Some(first) = ed.draft.inputs.first_mut() else { return }; let inferred = infer_input(sh, r); let before = first.clone(); first.kind = if r.is_single() { InputKind::Cell } else { InputKind::Range }; first.header = inferred.0; first.col_types = inferred.1; first.cols = [r.width(), r.width()]; first.rows = [1, 0]; tidy_input(first, &before); if first.default_ref.is_some() { first.default_ref = Some(ed.example.clone()); } ed.dirty = true; } /// Whether the first row of `r` looks like a header (all text over a row that isn't), and what /// each column holds below it. pub fn infer_input(sh: &gridcraft_engine::model::Sheet, r: RangeRef) -> (bool, Vec) { use gridcraft_engine::core::{CellRef, Value}; let kind = |v: &Value| match v { Value::Number(_) => Some(ColType::Number), Value::Text(_) => Some(ColType::Text), Value::Bool(_) => Some(ColType::Bool), _ => None, }; let rows = r.height().min(2_000); let cols = r.width().min(16); let first: Vec> = (0..cols).map(|c| kind(&sh.value(CellRef::new(r.start.row, r.start.col + c)))).collect(); let header = rows > 1 && first.iter().all(|k| *k == Some(ColType::Text)) && (0..cols).any(|c| matches!(kind(&sh.value(CellRef::new(r.start.row + 1, r.start.col + c))), Some(ColType::Number | ColType::Bool))); let start = r.start.row + u32::from(header); let types = (0..cols) .map(|c| { let mut seen: Option = None; for row in start..r.start.row + rows { match (kind(&sh.value(CellRef::new(row, r.start.col + c))), seen) { (None, _) => {} (Some(k), None) => seen = Some(k), (Some(k), Some(s)) if k == s => {} _ => return ColType::Any, } } seen.unwrap_or(ColType::Any) }) .collect(); (header, types) } fn output_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let t = Tokens::get(ui.ctx()); ui.label("Output anchor (what the Output token stands for):"); let anchor = ed.draft.output.anchor.clone().unwrap_or_default(); ref_field(app, ui, d, ed, ("@output", "anchor"), &anchor, false); ui.label( egui::RichText::new("Leave it empty when no step uses Output. A token such as Input.right keeps it beside the data.") .small() .color(t.text_dim), ); ui.add_space(6.0); ui.label("When results would land on cells that hold data:"); let mut rule = ed.draft.output.on_conflict; for (r, label) in [ (OnConflict::Ask, "Ask (overwrite, shift down or shift right)"), (OnConflict::Fail, "Don't run"), (OnConflict::Overwrite, "Overwrite the data"), (OnConflict::ShiftDown, "Shift the data down"), (OnConflict::ShiftRight, "Shift the data right"), ] { ui.radio_value(&mut rule, r, label); } if rule != ed.draft.output.on_conflict { ed.draft.output.on_conflict = rule; ed.dirty = true; } ui.label(egui::RichText::new("Agents and other programs that run the macro get an error instead of the question.").small().color(t.text_dim)); if !ed.draft.permissions.is_empty() { ui.add_space(6.0); ui.label(format!("May use files through: {}", ed.draft.permissions.join(", "))); } } // ---------------------------------------------------------------- grid overlays /// What the grid shows while the editor is open: the inputs (blue), the cells the macro writes /// (green), the ones among them that hold data (red), and the current step's target (outlined). /// Each range is on the named sheet. #[derive(Clone, Debug, Default, PartialEq)] pub struct MacroOverlay { pub inputs: Vec<(String, RangeRef)>, pub footprint: Vec<(String, RangeRef)>, pub conflicts: Vec<(String, RangeRef)>, pub target: Vec<(String, RangeRef)>, } impl MacroOverlay { fn is_empty(&self) -> bool { self.inputs.is_empty() && self.footprint.is_empty() && self.conflicts.is_empty() && self.target.is_empty() } } fn named_refs(text: &str, default_sheet: &str) -> Vec<(String, RangeRef)> { parse_refs(text).into_iter().map(|(s, r)| (s.unwrap_or_else(|| default_sheet.to_string()), r)).collect() } /// The overlay for the editor's last check and debugger state. pub fn overlay(app: &SheetApp, ed: &Editor) -> Option { let sheet = app.session.active()?.wb.active()?.name.clone(); let mut o = MacroOverlay::default(); if let Some(c) = &ed.check { for v in c["inputs"].as_object().into_iter().flat_map(|m| m.values()).filter_map(Json::as_str) { o.inputs.extend(named_refs(v, &sheet)); } for s in c["footprint"]["sheets"].as_array().into_iter().flatten() { let name = s["sheet"].as_str().unwrap_or(&sheet).to_string(); for v in s["cells"].as_array().into_iter().flatten().filter_map(Json::as_str) { o.footprint.extend(named_refs(v, &name)); } for v in s["conflicts"].as_array().into_iter().flatten().filter_map(Json::as_str) { o.conflicts.extend(named_refs(v, &name)); } } } if let Some(st) = &ed.debug { let cur = &st["current"]; if let Some(p) = cur["params"].as_object() { for (k, v) in p { if resolve::is_ref_key(k) && let Some(s) = v.as_str() { o.target.extend(named_refs(s, &sheet)); } } } for k in ["over", "cell"] { if let Some(s) = cur[k].as_str() { o.target.extend(named_refs(s, &sheet)); } } } (!o.is_empty()).then_some(o) } /// One overlay layer: its ranges, colour, wash alpha and stroke width. type OverlayLayer<'a> = (&'a Vec<(String, RangeRef)>, Color32, u8, f32); /// Paints the overlay's ranges on `sh` (called from the grid's overlay pass). pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_engine::model::Sheet, o: &MacroOverlay) { let t = Tokens::get(p.ctx()); let wash = |c: Color32, a: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); let layers: [OverlayLayer; 3] = [ (&o.footprint, t.sheet_macro_footprint, 30, 1.5), (&o.inputs, t.sheet_macro_input, 26, 2.0), (&o.conflicts, t.sheet_macro_conflict, 60, 2.0), ]; for (list, c, alpha, width) in layers { for (name, r) in list.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { let rect = geo.range_rect(sh, *r); if !p.clip_rect().intersects(rect) { continue; } p.rect_filled(rect, 0.0, wash(c, alpha)); p.rect_stroke(rect, 0.0, Stroke::new(width, c), StrokeKind::Inside); let _ = name; } } for (_, r) in o.target.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { let rect = geo.range_rect(sh, *r).expand(1.0); p.rect_stroke(rect, 1.0, Stroke::new(2.5, t.sheet_macro_target), StrokeKind::Outside); for corner in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { p.rect_filled(Rect::from_center_size(corner, vec2(5.0, 5.0)), 0.0, t.sheet_macro_target); } } } #[cfg(test)] mod tests { use super::*; #[test] fn an_edited_input_keeps_a_shape_a_macro_may_declare() { let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; // Switching a range to a single cell drops what a cell can't have. let mut cell = NamedInput { kind: InputKind::Cell, ..range.clone() }; tidy_input(&mut cell, &range); assert_eq!((cell.rows, cell.cols, cell.header, cell.col_types.len()), ([1, 1], [1, 1], false, 1)); // A least set above the most moves the most up; a most set below the least moves the least. let mut i = NamedInput { rows: [12, 10], ..range.clone() }; tidy_input(&mut i, &range); assert_eq!(i.rows, [12, 12]); let mut i = NamedInput { rows: [2, 1], ..range.clone() }; tidy_input(&mut i, &range); assert_eq!(i.rows, [1, 1]); // At most 0 is no limit; fewer columns drop the column types past them. let mut i = NamedInput { rows: [12, 0], cols: [1, 2], ..range.clone() }; tidy_input(&mut i, &range); assert_eq!((i.rows, i.cols, i.col_types.len()), ([12, 0], [1, 2], 2)); // Each passes the format's own check. for input in [cell, i] { let m = Macro { name: "M".into(), inputs: vec![input], ..Macro::default() }; assert_eq!(m.check_limits(), Ok(()), "{m:?}"); } } fn steps() -> Vec { vec![ Step::command("cell.set", json!({"cell": "A1", "input": "x"})), Step::new(StepKind::Repeat { over: "Input".into(), of: RepeatOf::Rows, max: 10, body: vec![Step::command("cell.set", json!({"cell": "Row.right", "input": "a"})), Step::command("home.bold", json!({}))], }), Step::new(StepKind::If { cell: "B1".into(), op: CompareOp::Empty, value: Json::Null, then: vec![Step::new(StepKind::Stop { message: "empty".into(), rollback: false })], otherwise: vec![], }), ] } #[test] fn paths_reach_nested_steps() { let mut s = steps(); assert_eq!(split_path("3.then.1"), Some(("3.then", 0))); assert_eq!(split_path("3.then"), None); assert_eq!(split_path("0"), None); assert!(matches!(step_at(&s, "2.2").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); assert!(matches!(step_at(&s, "3.then.1").map(|s| s.kind), Some(StepKind::Stop { .. }))); assert!(step_at(&s, "3.else.1").is_none()); assert!(step_at(&s, "1.1").is_none(), "a command has no body"); assert!(step_at(&s, "9").is_none()); assert!(list_mut(&mut s, "3.else").is_some_and(|l| l.is_empty())); } #[test] fn rows_flatten_with_branches() { let mut out = Vec::new(); rows(&steps(), "", 0, &mut out); let keys: Vec<&str> = out.iter().map(|r| r.key.as_str()).collect(); assert_eq!(keys, ["1", "2", "2.1", "2.2", "3", "3.then", "3.then.1", "3.else"]); assert_eq!(out.iter().find(|r| r.key == "2.2").map(|r| (r.parent.as_str(), r.index, r.depth)), Some(("2", 1, 1))); assert_eq!(out.iter().find(|r| r.key == "3.then").and_then(|r| r.branch), Some("Then")); } #[test] fn moving_a_step_within_its_list() { let mut s = steps(); // Step 1 dropped after step 2 (before row 2 of 0..3). assert_eq!(move_step(&mut s, "", 0, 2).as_deref(), Some("2")); assert!(matches!(&s[0].kind, StepKind::Repeat { .. })); // Inside the Repeat: the second step to the top. assert_eq!(move_step(&mut s, "1", 1, 0).as_deref(), Some("1.1")); assert!(matches!(step_at(&s, "1.1").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); assert_eq!(move_step(&mut s, "", 7, 0), None); // Past the end lands last. assert_eq!(move_step(&mut s, "", 0, 99).as_deref(), Some("3")); } #[test] fn breakpoints_are_listed_by_path() { let mut s = steps(); for p in ["1", "2.2", "3.then.1"] { if let Some(st) = step_mut(&mut s, p) { st.breakpoint = true; } } let mut out = Vec::new(); breakpoints(&s, "", &mut out); assert_eq!(out, ["1", "2.2", "3.then.1"]); } #[test] fn summaries_and_categories() { let s = steps(); assert_eq!(summary(&s[0]), "Set A1 to x"); assert_eq!(summary(&s[1]), "Repeat for each row of Input"); assert_eq!(summary(&s[2]), "If B1 is empty"); assert_eq!(category(&s[0]), (Icon::Sheet, Cat::Blue)); assert_eq!(category(&s[1]).1, Cat::Purple); let long = Step::command("home.bold", json!({"range": "A1:B2"})); assert!(summary(&long).contains("range: A1:B2"), "{}", summary(&long)); } #[test] fn static_errors_flag_unknown_and_denied_commands() { let m = Macro::default(); assert!(static_error(&m, &Step::command("no.such", json!({}))).is_some_and(|e| e.contains("no command"))); assert!(static_error(&m, &Step::command("file.save", json!({}))).is_some_and(|e| e.contains("can't run in a macro"))); assert!(static_error(&m, &Step::command("data.getData", json!({"path": "/tmp/x.csv"}))).is_some_and(|e| e.contains("permissions"))); assert_eq!(static_error(&m, &Step::command("cell.set", json!({"cell": "A1"}))), None); } #[test] fn required_keys_come_from_the_params_doc() { assert_eq!(required_keys("{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}"), ["cell", "input"]); assert_eq!(required_keys("{range?, options: {a, b}, on?: bool}"), ["range", "options"]); assert!(required_keys("no braces").is_empty()); assert!(required_keys("{").is_empty()); } #[test] fn the_picker_offers_control_steps_and_allowed_commands_only() { let all = picker_items(""); assert_eq!(all.first().map(|x| x.0.as_str()), Some("@repeat")); // Denied commands aren't offered (exports without a path are allowed). let all = picker_items("e"); assert!(!all.iter().any(|x| x.0 == "file.save" || x.0 == "edit.undo" || x.0.starts_with("macro."))); let bold = picker_items("bold"); assert!(bold.iter().any(|x| x.0 == "home.bold")); assert!(matches!(new_step("@if").kind, StepKind::If { .. })); assert!(matches!(new_step("cell.set").kind, StepKind::Command { params, .. } if params.get("cell").is_some())); } #[test] fn tokens_for_ranges_resolve_back_to_them() { let wb = Workbook::default(); let input = RangeRef::parse("B2:D9").unwrap_or(RangeRef::cell(gridcraft_engine::core::CellRef::new(0, 0))); let inputs = vec![("Input".to_string(), Place { sheet: 0, range: input }, false)]; let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; for (target, want) in [ ("B2:D9", "Input"), ("C2:C9", "Input.col(2)"), ("B2:C9", "Input.cols(1..2)"), ("B3:D9", "Input.rows(2..8)"), ("B4:D4", "Input.row(3)"), ("C3", "Input.cell(2,2)"), ("C3:D4", "Input.cell(2,2).resize(2,2)"), ("F2:H9", "Input.offset(0,4)"), ("E2", "Input.cell(1,1).offset(0,3)"), ("E2:E9", "Input.cell(1,1).offset(0,3).resize(8,1)"), ] { let r = RangeRef::parse(target).unwrap_or(input); let tok = token_for("Input", input, r); assert_eq!(tok, want, "{target}"); assert_eq!(resolve::resolve_ref(&tok, &env).map(|p| p.range), Ok(r), "{tok}"); } } #[test] fn references_parse_with_sheets_and_dollars() { let refs = parse_refs("'My Sheet'!$B$2:$C$3, D4"); assert_eq!(refs.len(), 2); assert_eq!(refs[0].0.as_deref(), Some("My Sheet")); assert_eq!(refs[0].1.a1(), "B2:C3"); assert_eq!(refs[1], (None, RangeRef::parse("D4").unwrap_or(refs[0].1))); assert!(parse_refs("not a ref").is_empty()); } #[test] fn inputs_are_inferred_from_an_example() { use gridcraft_engine::core::{CellRef, Value}; let mut sh = gridcraft_engine::model::Sheet::new("S"); for (c, h) in ["Item", "Qty"].iter().enumerate() { sh.set_value(CellRef::new(0, c as u32), Value::Text((*h).into())); } for r in 1..4 { sh.set_value(CellRef::new(r, 0), Value::Text(format!("x{r}").into())); sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r))); } let (header, types) = infer_input(&sh, RangeRef::parse("A1:B4").unwrap_or(RangeRef::cell(CellRef::new(0, 0)))); assert!(header); assert_eq!(types, [ColType::Text, ColType::Number]); } }