mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -06:00
* UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes Closing a workbook (File > Close, Cmd+W, the Backstage Close, a window's close button) while the macro editor holds unsaved changes now asks about the draft first, with the editor's own Save changes to macro question, even if the workbook itself is saved. Before, the workbook closed and the draft was discarded without a question. Save or Don't Save goes on to close the workbook, which then asks about its own unsaved changes (a saved macro is one); Cancel keeps both open. A draft that cannot be saved keeps its workbook open with the reason shown. A new macro that has not been edited does not ask, and a close that leaves the workbook open (one of several windows) or a forced close goes ahead as before. * UI: a new macro's name change alone counts as an unsaved macro-editor draft The check compared the draft with an untouched new macro that took the draft's current name, so a new macro whose only change was its name compared equal, and closing its workbook discarded the name without asking. It now compares with the name the editor opened with. * UI: Quit asks about an unsaved macro-editor draft before quitting Quit (Cmd+Q, the macOS Quit menu, a quit from the Dock) went straight to the unsaved workbooks and never consulted the macro step editor, so with every workbook saved the app quit and discarded the editor's unsaved steps. It now lets the editor ask first, as the window's close button already does: Save or Don't Save goes on with the quit, Cancel stops it. With one native window per workbook, the editor goes on with the app's quit directly once it has closed: a close sent to the window it is drawn in would close only that workbook's window. * UI: a refused macro save while quitting stops the quit When the macro editor's Save changes question was answered Save during a quit (the window's close button or Quit) and the save was refused (a new macro with a name already in use, a limit), the editor stayed open with the reason shown but kept its quit intent. Closing the editor later on its own then quit the app. A refused save now stops the quit, as Cancel does. * UI: a quit takes over a workbook close waiting on the macro editor When File > Close was waiting on the macro editor's question and Quit came in (or the other way round), closing the editor went on with the quit and then replayed the close. With one native window per workbook, the quit had already moved to the next unsaved workbook's window, so the replayed close acted on that workbook and replaced the quit's Save Changes question with an ordinary close question. The quit now takes over: it asks about every unsaved workbook itself, and the waiting close is dropped. * UI: an untouched new macro closes without asking, however the editor closes Closing a workbook already left an untouched new macro alone, but the editor's own close checks still read its dirty flag, which a new macro starts with: its close button, a quit, the control channel's cancel and the close after a stopped debug run asked Save changes to macro (or refused) for a macro nobody had edited. Every close check now asks whether the draft has unsaved changes, so all of them agree. * UI: closing the window that shows the macro editor asks about its draft The editor's unsaved draft was consulted only when its workbook would close. Closing the window that shows the editor while the workbook stayed open in another window (window.close, or File > Close in that window) went ahead: the window's dialog went with it, and the editor's state was left behind with no window to show it, so the next macro opened in the editor replaced the draft without asking. That close now asks first, like a workbook close. An editor left with no window to show it (a saved one, or a forced close) is now dropped, as when its workbook closes. A window that has closed no longer counts as showing a dialog while its parked state is still kept. * UI: a close waiting on the macro editor runs in the window it was asked in A workbook or window close that waited on the editor's Save changes question was replayed with the command and its parameters alone, so file.close acted on whichever window was the session's active one when the editor closed. The editor is drawn with its own window active, but nothing tied the replay to it. The pending close now records the editor's window and enters it before running again; if that window has closed meanwhile, nothing else is closed and a message says so. * UI: a close asked while the macro editor is asking waits for the answer A second file.close or window.close for the editor's workbook or window while its Save changes question stood replaced the pending close with the new one: the answer then closed whatever the last request asked for, not what the question had been opened for. A further request for the same close now waits on the open question (no second question, and the close 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. A quit keeps taking over as before. * UI: test the quit with a macro draft in one window and unsaved cells in another The quit asks about the editor's draft first, in its window, then about each unsaved workbook in its window; Cancel at either question stops it with every window and workbook open, and a draft discarded with Don't Save before the quit was cancelled stays discarded. Covered in the single-window harness, which presses the real buttons, and in the native-windows harness, which now takes a key press in the root window. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2590 lines
110 KiB
Rust
2590 lines
110 KiB
Rust
//! 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<String>,
|
||
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<String>,
|
||
/// 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<String>,
|
||
pub tab: Tab,
|
||
/// The input selected on the Inputs tab.
|
||
pub input: usize,
|
||
pub picker: Option<Picker>,
|
||
/// The last `macro.check` result (the grid overlays come from it).
|
||
pub check: Option<Json>,
|
||
/// The debugger's state while paused.
|
||
pub debug: Option<Json>,
|
||
/// What the last run, check or debug session ended with.
|
||
pub outcome: Option<String>,
|
||
/// Errors from the last check or run, by step path.
|
||
pub errors: BTreeMap<String, String>,
|
||
/// 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<PendingClose>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
/// 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::<usize>().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<Step>, parent: &str) -> Option<&'a mut Vec<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::<usize>().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<Step>, 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::<usize>().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<Step> {
|
||
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<String>) {
|
||
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<Row>) {
|
||
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<Step>, parent: &str, from: usize, to: usize) -> Option<String> {
|
||
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::<String>()) } 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<String> =
|
||
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<String> {
|
||
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<String> {
|
||
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<String>, 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<String> {
|
||
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<Dialog> {
|
||
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<Result<Json, String>> {
|
||
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::<Vec<_>>().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::<Vec<_>>().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<usize>) -> String {
|
||
let Some(doc) = app.session.active() else { return String::new() };
|
||
let areas: Vec<String> = 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::<Vec<_>>().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<String> = 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<Option<bool>> = 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<Option<bool>> = 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::<StepDrag>(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<Rect> =
|
||
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::<StepDrag>(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<String, Json> = 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::<f64>()
|
||
&& 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<String> = 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::<Json>(&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::<Json>(&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::<Json>(&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<String> {
|
||
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<ColType>) {
|
||
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<Option<ColType>> = (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<ColType> = 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<MacroOverlay> {
|
||
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<Step> {
|
||
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]);
|
||
}
|
||
}
|