diff --git a/ROADMAP.md b/ROADMAP.md index 45c55a35c..3d1a1d0d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -86,7 +86,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; typed macros in the engine, no interface yet; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | +| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | +| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs index 61ca844a2..b14fbdb23 100644 --- a/crates/engine/src/cmd/extra/scripts.rs +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -3,8 +3,13 @@ use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, boo pub(super) fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; - let cmds: Vec = - s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str())).map(|(id, p)| json!({"command": id, "params": p})).collect(); + let cmds: Vec = s + .journal + .iter() + .skip(start) + .filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str())) + .map(|(id, p)| json!({"command": id, "params": p})) + .collect(); let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 31a2f7a03..d55af2b6f 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -3,11 +3,18 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef}; use gridcraft_formula::adjust::{Axis, Edit}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill, + Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline, + Workbook, +}; use serde_json::{Value as Json, json}; use super::sheet::{rewrite, shift_cell_features}; -use super::*; +use super::{ + CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit, + f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; use crate::selection::current_region; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 71dfbef67..396e0e28f 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -5,7 +5,7 @@ use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param}; use crate::macros::recorder::RecordingStart; pub fn specs() -> Vec { @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Record Macro", ["View", "Macros"], None, - "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}", has_doc, record ), @@ -211,13 +211,13 @@ fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.is_recording() { return stop_recording(s, p); } - start_recording(s, bool_param(p, "relative").unwrap_or(true)) + start_recording(s, p, bool_param(p, "relative").unwrap_or(true)) } -/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `name` and -/// `description` when starting (they name the macro when it stops), `relative` when stopping (it -/// is chosen when recording starts), `stop: false` while recording (the call stops it), anything -/// beside `cancel: true`, and parameters of the wrong kind. +/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and +/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while +/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong +/// kind. `name` and `description` count when starting and when stopping (stopping wins). fn check_record_params(recording: bool, p: &Json) -> Result<()> { let given = |k: &str| p.get(k).filter(|v| !v.is_null()); for k in ["stop", "cancel", "relative"] { @@ -225,13 +225,13 @@ fn check_record_params(recording: bool, p: &Json) -> Result<()> { return Err(bad("macro.record", format!("`{k}` must be true or false"))); } } - for k in ["name", "description"] { + for k in ["name", "description", "shortcut"] { if given(k).is_some_and(|v| !v.is_string()) { return Err(bad("macro.record", format!("`{k}` must be text"))); } } if given("cancel").and_then(Json::as_bool) == Some(true) { - if let Some(k) = ["stop", "relative", "name", "description"].into_iter().find(|k| given(k).is_some()) { + if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) { return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored"))); } return Ok(()); @@ -243,22 +243,50 @@ fn check_record_params(recording: bool, p: &Json) -> Result<()> { "a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved", )); } - if recording || stop == Some(true) { - if given("relative").is_some() { - return Err(bad("macro.record", "`relative` is chosen when recording starts")); - } - } else if let Some(k) = ["name", "description"].into_iter().find(|k| given(k).is_some()) { - return Err(bad("macro.record", format!("`{k}` names the macro when recording stops, not when it starts"))); + if (recording || stop == Some(true)) + && let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some()) + { + return Err(bad("macro.record", format!("`{k}` is chosen when recording starts"))); } Ok(()) } -fn start_recording(s: &mut Session, relative: bool) -> Result { - let start = RecordingStart::new(s.doc()?, relative); +/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are +/// checked now, so a bad name or a taken shortcut is refused before anything is recorded. +fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result { + let d = s.doc()?; + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?), + None => None, + }; + let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) { + Some(k) => { + let letter = crate::macros::parse_shortcut(k) + .ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?; + if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) { + return Err(bad("macro.record", why)); + } + Some(crate::macros::shortcut_text(letter)) + } + None => None, + }; + let description = match str_param(p, "description") { + Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => { + return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT))); + } + Some(t) => Some(t.to_string()), + None => None, + }; + let mut start = RecordingStart::new(d, relative); + (start.name, start.shortcut, start.description) = (name.clone(), shortcut, description); let input = start.selection.current().a1(); s.start_recording(); s.macro_recording = Some(start); - Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }) + let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) }; + if let Some(n) = name { + out["name"] = json!(n); + } + Ok(out) } /// Stops recording and saves what was recorded as a macro, in the workbook where recording @@ -295,8 +323,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star // input from the workbook as it is now. let start = start.unwrap_or_else(|| RecordingStart::new(d, true)); let (doc, wb) = (d.uid, d.wb.clone()); - // A name another macro has gets a suffix, so stopping never replaces a macro. - let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + // A name given when stopping wins over the one given when starting; either one, if another + // macro has it, gets a suffix, so the save never replaces a macro. + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) { Some(n) => crate::macros::unique_name(&wb, n), None => crate::macros::next_name(&wb), }; @@ -305,10 +334,20 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star // out and counted with the ones the recorder dropped. let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS)); converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS); - if let Some(desc) = str_param(p, "description") { + if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) { converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect(); } cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac)); + // The shortcut, unless another macro took it while recording; then it is left off and the + // result says so. + let mut shortcut_dropped = None; + if let Some(k) = &start.shortcut { + if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) { + converted.mac.shortcut = Some(k.clone()); + } else { + shortcut_dropped = Some(k.clone()); + } + } let steps = converted.mac.steps.len(); let inputs: Vec = converted.mac.inputs.iter().map(|i| i.name.clone()).collect(); // Saved into that workbook even when another one is active now. @@ -325,7 +364,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star let key = (doc, crate::macros::fingerprint(m)); s.macro_approvals.insert(key); } - Ok( - json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}), - ) + let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}); + if let Some(k) = shortcut_dropped { + out["shortcutDropped"] = json!(k); + } + Ok(out) } diff --git a/crates/engine/src/cmd/solver/lp.rs b/crates/engine/src/cmd/solver/lp.rs index 3c1462d71..dc67d7746 100644 --- a/crates/engine/src/cmd/solver/lp.rs +++ b/crates/engine/src/cmd/solver/lp.rs @@ -199,7 +199,7 @@ fn phase_one(tableau: &mut Tableau, normalized: &[(Vec, Sense, f64)], budge return Err(Outcome::Infeasible); } for i in 0..tableau.rows.len() { - if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) { + if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) { continue; } let pivot_col = tableau diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 75d96f0df..ecbe6f674 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -420,6 +420,71 @@ impl Taken { } } +/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a +/// macro cannot take. +pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[ + ('F', "Format Cells (Font) or Filter"), + ('G', "Workbook Statistics"), + ('H', "Replace"), + ('J', "Ungroup"), + ('K', "Group"), + ('L', "Filter"), + ('R', "Sort"), + ('S', "Save As"), + ('T', "AutoSum"), + ('Z', "Redo"), +]; + +/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give +/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift): +/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift. +pub fn parse_shortcut(s: &str) -> Option { + let letter = |t: &str| { + let mut chars = t.trim().chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), + _ => None, + } + }; + let t = s.trim().to_ascii_lowercase(); + ["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter) +} + +/// How a shortcut is stored and shown: `Ctrl+Shift+M`. +pub fn shortcut_text(letter: char) -> String { + format!("Ctrl+Shift+{}", letter.to_ascii_uppercase()) +} + +/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it. +pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option { + let letter = letter.to_ascii_uppercase(); + if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) { + return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter))); + } + wb.macros + .iter() + .find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)) + .map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name)) +} + +/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long, +/// holds line breaks or another macro has it. +pub fn check_new_name(wb: &Workbook, name: &str) -> Result { + let name = name.trim(); + let probe = Macro { name: name.to_string(), ..Macro::default() }; + probe.check_limits()?; + if name.chars().any(char::is_control) { + return Err("A macro name cannot hold line breaks or other control characters.".into()); + } + if find(wb, name).is_some() { + return Err(format!("A macro called “{name}” already exists. Choose another name.")); + } + if let Some(kept) = kept_name(wb, name) { + return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name.")); + } + Ok(name.to_string()) +} + /// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]). pub fn next_name(wb: &Workbook) -> String { (1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into()) diff --git a/crates/engine/src/macros/recorder.rs b/crates/engine/src/macros/recorder.rs index 83e5e2952..337c41661 100644 --- a/crates/engine/src/macros/recorder.rs +++ b/crates/engine/src/macros/recorder.rs @@ -32,7 +32,8 @@ use crate::record::RecordedStep; const NEAR_ROWS: u32 = 50; const NEAR_COLS: u32 = 26; -/// Where recording started, kept beside the buffer. +/// Where recording started, kept beside the buffer, with the name, shortcut and description the +/// macro is to be saved with (from the Record Macro dialog), applied when recording stops. #[derive(Clone, Debug)] pub struct RecordingStart { pub doc: u64, @@ -44,6 +45,10 @@ pub struct RecordingStart { /// started: the recorded steps may change its values (text made into numbers), and the /// macro is for data like the data it was recorded on. pub input: Option, + /// The name, shortcut and description given when recording started (Record Macro). + pub name: Option, + pub shortcut: Option, + pub description: Option, } impl RecordingStart { @@ -57,6 +62,9 @@ impl RecordingStart { selection: d.selection.clone(), relative, input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())), + name: None, + shortcut: None, + description: None, } } } diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 0cc940093..52f3ba53f 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -617,8 +617,10 @@ fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value { #[test] fn every_field_a_file_sets_has_a_limit() { + // A named edit to the sample file's JSON. + type Edit = (&'static str, fn(&mut serde_json::Value)); // The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop. - let refused: [(&str, fn(&mut serde_json::Value)); 14] = [ + let refused: [Edit; 14] = [ ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))), ("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")), ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])), @@ -641,7 +643,7 @@ fn every_field_a_file_sets_has_a_limit() { for (what, change) in refused { assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused"); } - let accepted: [(&str, fn(&mut serde_json::Value)); 9] = [ + let accepted: [Edit; 9] = [ ("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))), ("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])), ("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))), diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 8648093e5..aa17080fa 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2077,6 +2077,9 @@ fn kept_macros_count_toward_the_limit_and_keep_their_names() { let err = s.execute(cmd, p).unwrap_err().to_string(); assert!(err.contains("newer version"), "{cmd}: {err}"); } + // Record Macro refuses a kept macro's name when recording starts. + let err = s.execute("macro.record", json!({"name": "future"})).unwrap_err().to_string(); + assert!(err.contains("newer version") && !s.is_recording(), "{err}"); // A recording takes the next free name, around the kept one, and fills the last place. s.execute("macro.record", json!({"relative": false})).unwrap(); set(&mut s, "A1", "typed"); @@ -2318,12 +2321,13 @@ fn a_recording_past_the_macro_size_is_cut_and_counted() { #[test] fn record_refuses_parameters_it_would_ignore() { let mut s = s(); - for p in [json!({"name": "Early"}), json!({"description": "d"}), json!({"relative": "no"}), json!({"stop": 1})] { + // `name`, `description` and `shortcut` count when starting (Record Macro); wrong kinds don't. + for p in [json!({"name": 5}), json!({"shortcut": true}), json!({"relative": "no"}), json!({"stop": 1})] { assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); assert!(!s.is_recording(), "{p}"); } s.execute("macro.record", json!({"relative": false, "name": null})).unwrap(); - for p in [json!({"relative": true}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { + for p in [json!({"relative": true}), json!({"shortcut": "K"}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] { assert!(s.execute("macro.record", p.clone()).is_err(), "{p}"); assert!(s.is_recording(), "{p}"); } @@ -2354,3 +2358,79 @@ fn undo_while_recording_keeps_the_undone_step_as_excel_does() { s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); assert_eq!(v(&s, "A1").display(), "typed"); } + +#[test] +fn record_checks_a_name_shortcut_and_description_when_it_starts() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})).unwrap(); + for (p, needle) in [ + (json!({"name": "taken"}), "already exists"), + (json!({"name": "a\nb"}), "line breaks"), + (json!({"name": "x".repeat(300)}), "at most"), + (json!({"name": "New", "shortcut": "p"}), "already runs macro"), + (json!({"name": "New", "shortcut": "Ctrl+Shift+Z"}), "Redo"), + (json!({"name": "New", "shortcut": "F5"}), "one letter"), + (json!({"name": "New", "description": "d".repeat(100_000)}), "at most"), + ] { + let e = s.execute("macro.record", p.clone()).unwrap_err().to_string(); + assert!(e.contains(needle), "{p}: {e}"); + assert!(!s.is_recording(), "{p}"); + } + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "j", "description": "Tidies"})); + // J is Ungroup on a Mac, so it is reserved; M is free. + assert!(r.is_err_and(|e| e.to_string().contains("Ungroup"))); + let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "m", "description": "Tidies"})).unwrap(); + assert_eq!(r["name"], json!("Tidy")); + set(&mut s, "A1", "1"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], json!("Tidy")); + let m = s.execute("macro.get", json!({"name": "Tidy"})).unwrap(); + assert_eq!((m["shortcut"].clone(), m["description"].clone()), (json!("Ctrl+Shift+M"), json!("Tidies"))); + // Empty params record under the next MacroN, as before, and never open a dialog. + s.execute("macro.record", json!({})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({})).unwrap(); + assert!(r["saved"].as_str().is_some_and(|n| n.starts_with("Macro")), "{r}"); + assert!(s.take_ui_requests().is_empty(), "no dialog"); +} + +#[test] +fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { + use crate::macros::{parse_shortcut, shortcut_conflict, shortcut_text}; + assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M')); + assert_eq!(parse_shortcut("⌘⇧k"), Some('K')); + assert_eq!(parse_shortcut(" q "), Some('Q')); + assert_eq!(parse_shortcut("cmd+shift+r"), Some('R')); + // Other modifiers are refused, not read as Ctrl+Shift. + for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é", "Ctrl+Alt+M", "Shift+M", "Alt+M", "Ctrl+M"] { + assert_eq!(parse_shortcut(bad), None, "{bad}"); + } + assert_eq!(shortcut_text('m'), "Ctrl+Shift+M"); + let mut wb = gridcraft_model::Workbook::default(); + wb.macros.push(gridcraft_model::Macro { name: "A".into(), shortcut: Some("Ctrl+Shift+M".into()), ..Default::default() }); + wb.macros.push(gridcraft_model::Macro { name: "B".into(), ..Default::default() }); + assert!(shortcut_conflict(&wb, "B", 's').is_some_and(|m| m.contains("Save As"))); + assert!(shortcut_conflict(&wb, "B", 'm').is_some_and(|m| m.contains("“A”"))); + // A macro keeps its own letter. + assert_eq!(shortcut_conflict(&wb, "a", 'M'), None); + assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None); +} + +#[test] +fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() { + let mut s = s(); + s.execute("macro.record", json!({"name": "Tidy", "shortcut": "m"})).unwrap(); + set(&mut s, "A1", "1"); + // Another macro takes Ctrl+Shift+M before recording stops. + s.execute("macro.save", json!({"name": "Other", "shortcut": "Ctrl+Shift+M", "steps": []})).unwrap(); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert_eq!(r["saved"], "Tidy"); + assert_eq!(r["shortcutDropped"], "Ctrl+Shift+M", "{r}"); + assert!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["shortcut"].is_null()); + // A free one is kept and not reported. + s.execute("macro.record", json!({"name": "Keep", "shortcut": "q"})).unwrap(); + set(&mut s, "A2", "2"); + let r = s.execute("macro.record", json!({"stop": true})).unwrap(); + assert!(r.get("shortcutDropped").is_none(), "{r}"); + assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q"); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d53218d51..6fa5def4f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -8,7 +8,7 @@ use super::updates_ui; use super::{ CellRef, HashMap, INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, Notice, Perf, RangeRef, Session, SheetApp, UiRequest, UiState, backstage, clamp_interface_scale, clean_error, control, desktop_theme, dialogs, editor, floating_pictures, formula_bar, formula_locale, grid, i18n, json, - keytips, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets, + keytips, macro_ui, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets, }; mod clipboard; @@ -72,6 +72,7 @@ impl SheetApp { clipboard_text_at_copy: None, cut_text: None, tab_run: None, + macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, open_switch_windows: false, @@ -235,6 +236,8 @@ impl SheetApp { }); } dialogs::show(self, &ctx); + macro_ui::forget_closed_editor(self); + macro_ui::question_box(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 969b6faac..c4aea21f0 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -330,6 +330,14 @@ impl SheetApp { let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); self.open_backstage(page); } + // The macro step editor stays until it closes itself (its draft, overlays and debug + // run go with it), so another dialog can't replace it. + _ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => { + self.message = Some(( + "Macro".into(), + "Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(), + )); + } other => self.dialog = dialogs::Dialog::open(self, other, params), } } diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index ea507e60f..d4479d191 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -12,6 +12,13 @@ impl SheetApp { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); return; } + // The macro step editor asks about its own unsaved steps and debug run first; once it has + // closed, the window close goes on. + if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + crate::macro_editor::request_close(self, true); + return; + } if !self.session.documents().iter().any(|d| d.is_dirty()) { return; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 190300c48..af5eda156 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -282,6 +282,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - None => err("no dialog is open"), }, "ui.dialog.cancel" => { + // The macro step editor's draft, overlays and debug run go with its own close. + if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + return match crate::macro_editor::close_now(app) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + }; + } app.dialog = None; ok(Json::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a6003f0f..82b252607 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,7 +22,6 @@ mod data; mod find; mod format_cells; mod insert_function; -mod macros; mod names; mod paste; mod show; @@ -187,7 +186,7 @@ impl Dialog { } } - fn custom(name: &str, title: &str, defaults: Json) -> Dialog { + pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog { Dialog { name: name.into(), title: title.into(), @@ -946,11 +945,9 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), - "macros" => { - let mut d = Dialog::custom("macros", "Macros", json!({})); - d.result = app.session.run("view.macros", json!({})).ok(); - d - } + "macros" => crate::macro_ui::open_dialog(app, ¶ms), + "recordMacro" => return crate::macro_ui::open_record(app, ¶ms), + "macroEditor" => return crate::macro_editor::open(app, ¶ms), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); diff --git a/crates/ui-egui/src/dialogs/macros.rs b/crates/ui-egui/src/dialogs/macros.rs deleted file mode 100644 index 8be2cc047..000000000 --- a/crates/ui-egui/src/dialogs/macros.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::{Dialog, Json, SheetApp, json}; - -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -pub(super) fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 0dcc3d9bc..07debbca6 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -7,7 +7,6 @@ use super::data::{advanced_filter_form, data_validation_form, sort}; use super::find::{find, go_to}; use super::format_cells::format_cells; use super::insert_function::insert_function; -use super::macros::macros; use super::names::{name_manager, paste_name}; use super::paste::{paste_special, paste_special_shortcuts}; use super::spelling::spelling; @@ -107,6 +106,21 @@ pub(crate) fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let home = app.ui_window(); + // The macro step editor is a large resizable window of its own. + if d.name == "macroEditor" { + let mut open = true; + crate::macro_editor::window(app, ctx, &mut d, &mut open); + if !open { + app.macros.editor = None; + app.grid.macro_overlay = None; + } else if app.ui_window() != home { + // Something run from the editor switched windows: the editor stays in its own. + app.return_dialog(home, d); + } else { + app.dialog = Some(d); + } + return; + } if d.name == "pasteSpecial" { paste_special_shortcuts(&mut d, ctx); } @@ -116,7 +130,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "macros" => 480.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, @@ -176,7 +191,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(app, ui, &mut d), + "macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open), + "recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) diff --git a/crates/ui-egui/src/dnd.rs b/crates/ui-egui/src/dnd.rs new file mode 100644 index 000000000..769e1b4a1 --- /dev/null +++ b/crates/ui-egui/src/dnd.rs @@ -0,0 +1,71 @@ +//! List drag and drop shared by the PivotTable Fields pane and the macro step editor: a drag +//! source that carries a payload, where a drop lands among a list's rows, the line that marks it, +//! and the label that follows the pointer. + +use egui::{DragAndDrop, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::theme::{self, Tokens}; + +/// Where a drop at `y` lands among rows laid out top to bottom. +pub fn insert_index(rows: &[Rect], y: f32) -> usize { + rows.iter().filter(|r| r.center().y < y).count() +} + +/// Starts dragging `payload` when `r` (a widget sensing drags) begins a drag, and shows the grab +/// cursor over it. +pub fn drag_source(ui: &Ui, r: &egui::Response, payload: T) { + if r.drag_started() { + DragAndDrop::set_payload(ui.ctx(), payload); + } + if r.hovered() && !DragAndDrop::has_any_payload(ui.ctx()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } +} + +/// A drag handle under a widget that only senses clicks (a checkbox): it covers where the widget +/// was last frame (stored under `id`) and is added first, so clicks still reach the widget on top. +pub fn drag_under(ui: &mut Ui, id: Id, payload: T) { + let Some(rect) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; + let r = ui.interact(rect, id, Sense::drag()); + drag_source(ui, &r, payload); +} + +/// While dragging: outlines `zone` and draws the line where a drop at `y` would land among `rows`. +pub fn drop_marker(ui: &Ui, zone: Rect, rows: &[Rect], y: f32) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("dnd_drop_marker"))); + painter.rect_stroke(zone, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let k = insert_index(rows, y); + let y = match (rows.get(k), k.checked_sub(1).and_then(|j| rows.get(j))) { + (Some(r), _) => r.top() - 2.0, + (None, Some(r)) => r.bottom() + 2.0, + (None, None) => zone.top() + 4.0, + }; + painter.line_segment([pos2(zone.left() + 4.0, y), pos2(zone.right() - 4.0, y)], Stroke::new(2.0, t.accent)); +} + +/// The dragged item's label following the pointer. +pub fn ghost(ui: &Ui, text: &str, at: Pos2) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("dnd_ghost"))); + let galley = painter.layout_no_wrap(text.to_string(), theme::ui_font(12.0), t.text); + let rect = Rect::from_min_size(at + vec2(12.0, 8.0), galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(rect, 3.0, t.input_bg); + painter.rect_stroke(rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(rect.min + vec2(6.0, 3.0), galley, t.text); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drop_lands_before_the_first_row_below_it() { + let rows: Vec = (0..3).map(|i| Rect::from_min_size(pos2(0.0, i as f32 * 20.0), vec2(100.0, 20.0))).collect(); + assert_eq!(insert_index(&rows, -5.0), 0); + assert_eq!(insert_index(&rows, 15.0), 1); + assert_eq!(insert_index(&rows, 45.0), 2); + assert_eq!(insert_index(&rows, 500.0), 3); + assert_eq!(insert_index(&[], 10.0), 0); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 0d1b131e0..c5e1dd473 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -248,6 +248,8 @@ pub struct GridState { pub wheel_zoom: Option, /// A one-finger touch drag in progress: it pans the sheet instead of selecting. pub touch_pan: bool, + /// What the macro step editor shows on the grid (Check, debugging). + pub macro_overlay: Option, } /// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index 8d2e698ed..c55e20df1 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -14,7 +14,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). + // arrow key would move the selection the menu fills from), and a macro's question. if editing || app.dialog.is_some() || other_focus @@ -22,6 +22,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res || app.grid.filter_menu.is_some() || app.grid.fill_menu.is_some() || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + || app.macros.question.is_some() { return; } @@ -143,6 +144,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} + // Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it. + _ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {} _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { crate::ribbon::shortcut(app, key, m) } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index 193d8dc73..2e79ee165 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -722,6 +722,9 @@ pub(crate) fn paint_overlays(input: OverlayInput<'_>) { ui.label(tip); }); } + if let Some(o) = &app.grid.macro_overlay { + crate::macro_editor::paint_overlay(p, geo, sh, o); + } // Trace precedents / dependents arrows. if let Some(trace) = &app.grid.trace && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e24dad5e1..9a6d54030 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -557,6 +557,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "AutoSave" => "Automatické ukládání", "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", "All sheets" => "Všechny listy", + "Record Macro" => "Záznam makra", + "Stop Recording" => "Zastavit záznam", + "Use Relative References" => "Použít relativní odkazy", + "Run\nMacro" => "Spustit makro", + "Macros…" => "Makra…", + "Recording" => "Záznam", + "Stop" => "Zastavit", + "New Macro" => "Nové makro", + "Show" => "Zobrazit", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 218eceb43..6427b279d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -462,6 +462,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", "Text Box" => "Textfeld", + "Macros" => "Makros", + "Record Macro" => "Makro aufzeichnen", + "Stop Recording" => "Aufzeichnung beenden", + "Use Relative References" => "Relative Verweise verwenden", + "Run\nMacro" => "Makro ausführen", + "Macros…" => "Makros…", + "Recording" => "Aufzeichnung", + "Stop" => "Beenden", + "New Macro" => "Neues Makro", + "Show" => "Anzeigen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index d826b1eb8..9231f8244 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -983,6 +983,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { "Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο." } + "Record Macro" => "Καταγραφή μακροεντολής", + "Stop Recording" => "Διακοπή καταγραφής", + "Use Relative References" => "Χρήση σχετικών αναφορών", + "Run\nMacro" => "Εκτέλεση μακροεντολής", + "Macros…" => "Μακροεντολές…", + "Recording" => "Καταγραφή", + "New Macro" => "Νέα μακροεντολή", + "Show" => "Εμφάνιση", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index ab1fa1276..a4b4e884a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -478,6 +478,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", "Text Box" => "Cuadro de texto", + "Macros" => "Macros", + "Record Macro" => "Grabar macro", + "Stop Recording" => "Detener grabación", + "Use Relative References" => "Usar referencias relativas", + "Run\nMacro" => "Ejecutar macro", + "Macros…" => "Macros…", + "Recording" => "Grabando", + "Stop" => "Detener", + "New Macro" => "Nueva macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 34511876a..fdb20f7bf 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -450,6 +450,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", "Text Box" => "Zone de texte", + "Macros" => "Macros", + "Record Macro" => "Enregistrer une macro", + "Stop Recording" => "Arrêter l’enregistrement", + "Use Relative References" => "Utiliser les références relatives", + "Run\nMacro" => "Exécuter la macro", + "Macros…" => "Macros…", + "Recording" => "Enregistrement", + "Stop" => "Arrêter", + "New Macro" => "Nouvelle macro", + "Show" => "Afficher", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 7f79b4076..31bc6721c 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -986,6 +986,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Layout rapporto", "Compact" => "Compatto", "Tabular" => "Tabulare", + "Record Macro" => "Registra macro", + "Stop Recording" => "Interrompi registrazione", + "Use Relative References" => "Usa riferimenti relativi", + "Run\nMacro" => "Esegui macro", + "Macros…" => "Macro…", + "Recording" => "Registrazione", + "New Macro" => "Nuova macro", + "Show" => "Mostra", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ea48f8074..1f06755dd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -423,6 +423,14 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"), ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), + ("Record Macro", "マクロの記録"), + ("Stop Recording", "記録終了"), + ("Use Relative References", "相対参照で記録"), + ("Run\nMacro", "マクロの実行"), + ("Macros…", "マクロ…"), + ("Recording", "記録中"), + ("New Macro", "新しいマクロ"), + ("Show", "表示"), ]; /// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`]. diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 936e7417c..c54ed7eee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", "Text Box" => "텍스트 상자", + "Macros" => "매크로", + "Record Macro" => "매크로 기록", + "Stop Recording" => "기록 중지", + "Use Relative References" => "상대 참조로 기록", + "Run\nMacro" => "매크로 실행", + "Macros…" => "매크로…", + "Recording" => "기록 중", + "Stop" => "중지", + "New Macro" => "새 매크로", + "Show" => "표시", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 9cedcce74..a28459f83 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -527,6 +527,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom In" => "Inzoomen", "Zoom Out" => "Uitzoomen", "Dismiss" => "Negeren", + "Record Macro" => "Macro opnemen", + "Stop Recording" => "Opname stoppen", + "Use Relative References" => "Relatieve verwijzingen gebruiken", + "Run\nMacro" => "Macro uitvoeren", + "Macros…" => "Macro's…", + "Recording" => "Opnemen", + "Stop" => "Stoppen", + "New Macro" => "Nieuwe macro", + "Show" => "Weergeven", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 1a229f90d..d7741a9c6 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -977,6 +977,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Układ raportu", "Compact" => "Kompaktowy", "Tabular" => "Tabelaryczny", + "Record Macro" => "Zarejestruj makro", + "Stop Recording" => "Zatrzymaj rejestrowanie", + "Use Relative References" => "Użyj odwołań względnych", + "Run\nMacro" => "Uruchom makro", + "Macros…" => "Makra…", + "Recording" => "Rejestrowanie", + "New Macro" => "Nowe makro", + "Show" => "Pokaż", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index d72ee2460..c79be64e1 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -982,6 +982,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" } "All sheets" => "Todas as planilhas", + "Record Macro" => "Gravar Macro", + "Stop Recording" => "Parar Gravação", + "Use Relative References" => "Usar Referências Relativas", + "Run\nMacro" => "Executar Macro", + "Macros…" => "Macros…", + "Recording" => "Gravando", + "New Macro" => "Nova Macro", + "Show" => "Mostrar", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index e71576848..058911d87 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1039,6 +1039,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Formatare zonă diagramă…", "Error checking options" => "Opțiuni de verificare a erorilor", "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", + "Record Macro" => "Înregistrare macrocomandă", + "Stop Recording" => "Oprire înregistrare", + "Use Relative References" => "Utilizare referințe relative", + "Run\nMacro" => "Rulare macrocomandă", + "Macros…" => "Macrocomenzi…", + "Recording" => "Înregistrare", + "New Macro" => "Macrocomandă nouă", + "Show" => "Afișare", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e3ae28581..b0e894095 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -448,6 +448,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", "Text Box" => "Надпись", + "Macros" => "Макросы", + "Record Macro" => "Запись макроса", + "Stop Recording" => "Остановить запись", + "Use Relative References" => "Относительные ссылки", + "Run\nMacro" => "Выполнить макрос", + "Macros…" => "Макросы…", + "Recording" => "Запись", + "Stop" => "Остановить", + "New Macro" => "Новый макрос", + "Show" => "Показать", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index db75b63c2..c9ce49e9e 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", "Text Box" => "Текстуално поље", + "Macros" => "Makroi", + "Record Macro" => "Snimi makro", + "Stop Recording" => "Zaustavi snimanje", + "Use Relative References" => "Koristi relativne reference", + "Run\nMacro" => "Pokreni makro", + "Macros…" => "Makroi…", + "Recording" => "Snimanje", + "Stop" => "Zaustavi", + "New Macro" => "Novi makro", + "Show" => "Prikaži", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ea1a16380..68cd198bb 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Print" => "Yazdır", "Print What" => "Yazdırılacaklar", "Text Box" => "Metin Kutusu", + "Macros" => "Makrolar", + "Record Macro" => "Makro Kaydet", + "Stop Recording" => "Kaydı Durdur", + "Use Relative References" => "Göreli Başvuruları Kullan", + "Run\nMacro" => "Makro Çalıştır", + "Macros…" => "Makrolar…", + "Recording" => "Kaydediliyor", + "Stop" => "Durdur", + "New Macro" => "Yeni Makro", + "Show" => "Göster", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8055f77fc..72c1bf474 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -981,6 +981,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Report Layout", "Compact" => "Compact", "Tabular" => "Tabular", + "Record Macro" => "Записати макрос", + "Stop Recording" => "Зупинити запис", + "Use Relative References" => "Використовувати відносні посилання", + "Run\nMacro" => "Запустити макрос", + "Macros…" => "Макроси…", + "Recording" => "Запис", + "New Macro" => "Новий макрос", + "Show" => "Показати", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 874c7caff..df76b4a93 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Enable iterative calculation" => "Bật tính toán lặp", "Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.", "Text Box" => "Hộp văn bản", + "Macros" => "Macro", + "Record Macro" => "Ghi Macro", + "Stop Recording" => "Dừng Ghi", + "Use Relative References" => "Dùng Tham chiếu Tương đối", + "Run\nMacro" => "Chạy Macro", + "Macros…" => "Macro…", + "Recording" => "Đang ghi", + "Stop" => "Dừng", + "New Macro" => "Macro Mới", + "Show" => "Hiện", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9ce01e39d..d6dd0eabd 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -438,6 +438,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", "Text Box" => "文本框", + "Macros" => "宏", + "Record Macro" => "录制宏", + "Stop Recording" => "停止录制", + "Use Relative References" => "使用相对引用", + "Run\nMacro" => "运行宏", + "Macros…" => "宏…", + "Recording" => "正在录制", + "Stop" => "停止", + "New Macro" => "新建宏", + "Show" => "显示", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 071a537d8..c1f894ea9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -135,6 +135,25 @@ pub enum Icon { OpenFolder, Info, Print, + /// A macro: a script page with a run arrow. + Macro, + /// Record Macro: a ring around the recording dot. + Record, + /// Use Relative References: a cell and an arrow to a cell offset from it. + Relative, + /// A reference box's button: shrink the window to the box (Collapse) or restore it (Expand). + Collapse, + Expand, + /// The macro debugger's Run: a play triangle. + Play, + /// Step: an arrow down onto a line. + Step, + /// Continue: a bar and a play triangle. + Continue, + /// Run to Here: an arrow to a marked line. + RunTo, + /// Stop: a square. + StopSquare, } struct G<'a> { @@ -194,11 +213,12 @@ impl G<'_> { struct Palette { paper: Color32, cat: [Color32; 8], + recording: Color32, } impl Palette { fn of(t: &Tokens) -> Palette { - Palette { paper: t.paper, cat: t.cat } + Palette { paper: t.paper, cat: t.cat, recording: t.recording } } fn cat(&self, c: Cat) -> Color32 { @@ -731,6 +751,50 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); g.circle(12.3, 7.6, 0.6, Some(c), None); } + Icon::Macro => { + g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c)); + for y in [4.5, 7.0, 9.5] { + g.stroke(&[(4.5, y), (9.5, y)], light); + } + g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green); + } + Icon::Record => { + g.circle(8.0, 8.0, 6.0, None, Some(c)); + g.circle(8.0, 8.0, 3.5, Some(pal.recording), None); + } + Icon::Relative => { + g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c)); + g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c)); + g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1); + g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c); + } + Icon::Collapse | Icon::Expand => { + // A small sheet with a selected block and an arrow pointing up (collapse) or down. + g.rect(2.0, 6.5, 12.0, 7.5, None, Some(c)); + g.rect(4.0, 9.0, 4.0, 3.0, Some(c), None); + if icon == Icon::Collapse { + g.poly(&[(8.0, 1.0), (11.5, 4.5), (4.5, 4.5)], c); + } else { + g.poly(&[(8.0, 5.5), (11.5, 2.0), (4.5, 2.0)], c); + } + } + Icon::Play => g.poly(&[(4.0, 2.5), (13.0, 8.0), (4.0, 13.5)], green), + Icon::Step => { + g.line(&[(8.0, 1.5), (8.0, 9.0)], c, 1.3); + g.poly(&[(4.8, 7.0), (11.2, 7.0), (8.0, 10.8)], c); + g.line(&[(3.0, 13.5), (13.0, 13.5)], c, 1.3); + } + Icon::Continue => { + g.rect(2.5, 2.5, 2.2, 11.0, Some(c), None); + g.poly(&[(6.5, 2.5), (14.0, 8.0), (6.5, 13.5)], green); + } + Icon::RunTo => { + g.line(&[(2.0, 5.0), (9.0, 5.0)], c, 1.3); + g.poly(&[(8.0, 2.0), (12.0, 5.0), (8.0, 8.0)], c); + g.line(&[(2.0, 11.5), (14.0, 11.5)], c, 1.6); + g.line(&[(13.0, 3.0), (13.0, 9.0)], c, 1.1); + } + Icon::StopSquare => g.rect(3.0, 3.0, 10.0, 10.0, Some(pal.recording), None), } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index ef0011861..2a2509f08 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -342,8 +342,8 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param) ("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal) ("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros) - ("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu) - ("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros) + ("WMR", Entry::Finish(Action::Dialog("recordMacro"))), // Excel: Record Macro; Record Macro dialog (stops recording while recording) + ("WMV", Entry::Finish(Action::Dialog("macros"))), // Excel: View Macros; containment(Macros): the Macros dialog ("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window ("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View ("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index f0e2c8f99..5dfe7b2f5 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2370,17 +2370,17 @@ }, { "seq": "WMR", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "recordMacro", "excel": "Record Macro", - "note": "Record Macro (macros menu)" + "note": "Record Macro dialog (stops recording while recording)" }, { "seq": "WMV", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "macros", "excel": "View Macros", - "note": "containment(Macros)" + "note": "containment(Macros): the Macros dialog" }, { "seq": "WN", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 421f71459..4695227fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod control; mod craft_fonts_policy; pub mod credits; pub mod dialogs; +pub mod dnd; #[cfg(not(target_arch = "wasm32"))] pub mod doc_windows; pub mod editor; @@ -27,6 +28,8 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_editor; +pub mod macro_ui; pub mod outline; pub mod panes; pub mod paper_view; @@ -266,6 +269,8 @@ pub struct SheetApp { /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). tab_run: Option<(u64, usize, u32, CellRef)>, + /// Automate › Macros: relative recording, recent macros, a macro's question. + pub macros: macro_ui::MacroUi, pub started: f64, /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs new file mode 100644 index 000000000..c74153c1b --- /dev/null +++ b/crates/ui-egui/src/macro_editor.rs @@ -0,0 +1,2471 @@ +//! The macro step editor (Automate › New Macro; Macros › Edit, Create, Step Into): a large, +//! resizable window. +//! +//! - **Left, the step list.** Each row has its number, a breakpoint dot in the gutter (click to +//! toggle), a category icon, a one-line summary, an error marker and an enabled checkbox. Rows +//! drag to reorder within their list (the shared [`crate::dnd`] helpers); Add Step opens a +//! Command Palette picker; Duplicate and Delete act on the selected step. +//! - **Right, the inspector.** Step: the selected step's fields, with RefEdit boxes that switch +//! between relative tokens (`Input.col(2)`) and absolute A1, and token suggestions. Inputs: the +//! shape, header and column types each input must have, from an example. Output: the anchor and +//! what to do when results would land on data. +//! - **The debug bar.** Check (a dry run that colours the grid), Run, Step, Continue, Run to Here +//! and Stop (keep the changes or undo them). While debugging, the current step is highlighted +//! and its parameters are shown as they will run. +//! +//! The editor works on a draft and saves it with `macro.save`; checking, running and debugging +//! save first, then go through `macro.check`, `macro.run` and `macro.debug`. + +use std::collections::BTreeMap; + +use egui::{Color32, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use gridcraft_engine::core::RangeRef; +use gridcraft_engine::macros::policy::{Policy, macro_policy}; +use gridcraft_engine::macros::resolve::{self, Env, Place}; +use gridcraft_engine::model::{ColType, CompareOp, InputKind, Macro, NamedInput, OnConflict, RepeatOf, Step, StepKind, Workbook}; +use serde_json::{Map, Value as Json, json}; + +use crate::dialogs::Dialog; +use crate::icons::{self, Icon}; +use crate::theme::{self, Cat, Tokens}; +use crate::widgets::small_button; +use crate::{SheetApp, dnd, macro_ui}; + +/// The inspector's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + Step, + Inputs, + Output, +} + +/// The Add Step picker (or Change, which replaces a step's command). +#[derive(Clone, Debug, Default)] +pub struct Picker { + pub search: String, + /// The step whose command is being changed; `None` adds a step. + pub replace: Option, + pub index: usize, +} + +/// The editor's state while it is open. +#[derive(Clone, Debug)] +pub struct Editor { + /// The workbook (`DocState::uid`) the macro is in: the editor saves, checks and runs there only. + pub doc: u64, + pub draft: Macro, + /// The name the macro is saved under (`None` until it is first saved). + pub saved_as: Option, + pub dirty: bool, + /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). + pub selected: Option, + pub tab: Tab, + /// The input selected on the Inputs tab. + pub input: usize, + pub picker: Option, + /// The last `macro.check` result (the grid overlays come from it). + pub check: Option, + /// The debugger's state while paused. + pub debug: Option, + /// What the last run, check or debug session ended with. + pub outcome: Option, + /// Errors from the last check or run, by step path. + pub errors: BTreeMap, + /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). + pub ask_stop: bool, + pub closing: bool, + /// Close as the title bar's close button does (asking first) on the next frame. + pub close_requested: bool, + /// The window is quitting: once the editor has closed, the window close goes on. + pub quit: bool, + /// 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, +} + +/// Which reference box grid selections write into, and the selection to put back on closing. +#[derive(Clone, Debug, Default)] +pub struct RefPick { + /// The reference box that grid selections currently write into. + pub pick: Option, + /// The selection reference last written by (or seen while) picking. + pub seen: String, + /// Sheet, ranges and active cell from before picking, put back when the editor closes. + pub home: Option<(usize, String, String)>, + /// The reference box the window is collapsed to (only that box shows). + pub collapsed: Option, + /// Whether the picking box was drawn this frame: a pick ends with a box that left the screen + /// (another step or tab was chosen), or a later selection would land in it unseen. + pub drawn: bool, +} + +// ---------------------------------------------------------------- step paths + +fn child(parent: &str, i: usize) -> String { + if parent.is_empty() { (i + 1).to_string() } else { format!("{parent}.{}", i + 1) } +} + +/// `"4.then.2"` → (`"4.then"`, 1). `None` for a branch key or anything else that isn't a step. +pub fn split_path(path: &str) -> Option<(&str, usize)> { + let (parent, last) = path.rsplit_once('.').unwrap_or(("", path)); + Some((parent, last.parse::().ok()?.checked_sub(1)?)) +} + +/// The step list a parent key names: `""` (the top), `"2"` (a Repeat's body), `"4.then"`. +pub fn list_mut<'a>(mut cur: &'a mut Vec, parent: &str) -> Option<&'a mut Vec> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get_mut(idx)?; + cur = match (&mut st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step> { + let (parent, i) = split_path(path)?; + list_mut(steps, parent)?.get_mut(i) +} + +/// The step list a parent key names, read-only (see [`list_mut`]). +pub fn list_at<'a>(mut cur: &'a [Step], parent: &str) -> Option<&'a [Step]> { + let toks: Vec<&str> = parent.split('.').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + while let Some(tok) = toks.get(i) { + let idx = tok.parse::().ok()?.checked_sub(1)?; + let branch = toks.get(i + 1).copied(); + let st = cur.get(idx)?; + cur = match (&st.kind, branch) { + (StepKind::If { then, .. }, Some("then")) => { + i += 2; + then + } + (StepKind::If { otherwise, .. }, Some("else")) => { + i += 2; + otherwise + } + (StepKind::Repeat { body, .. }, _) => { + i += 1; + body + } + _ => return None, + }; + } + Some(cur) +} + +/// A copy of the step at `path`. Reads the path in place: copying the whole list for every row +/// made a large macro's step list quadratic. +pub fn step_at(steps: &[Step], path: &str) -> Option { + let (parent, i) = split_path(path)?; + list_at(steps, parent)?.get(i).cloned() +} + +/// The paths of the steps with a breakpoint. +pub fn breakpoints(steps: &[Step], parent: &str, out: &mut Vec) { + for (i, st) in steps.iter().enumerate() { + let path = child(parent, i); + if st.breakpoint { + out.push(path.clone()); + } + match &st.kind { + StepKind::Repeat { body, .. } => breakpoints(body, &path, out), + StepKind::If { then, otherwise, .. } => { + breakpoints(then, &format!("{path}.then"), out); + breakpoints(otherwise, &format!("{path}.else"), out); + } + _ => {} + } + } +} + +/// One row of the step list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + /// The step path, or the branch key for an If's Then/Else row. + pub key: String, + /// The list it is in, and its place there. + pub parent: String, + pub index: usize, + pub depth: usize, + /// `Some("Then")` / `Some("Else")` for a branch row. + pub branch: Option<&'static str>, +} + +/// The step list flattened into rows, nested steps indented under their Repeat or If. +pub fn rows(steps: &[Step], parent: &str, depth: usize, out: &mut Vec) { + if depth > 16 { + return; + } + for (i, st) in steps.iter().enumerate() { + let key = child(parent, i); + out.push(Row { key: key.clone(), parent: parent.to_string(), index: i, depth, branch: None }); + match &st.kind { + StepKind::Repeat { body, .. } => rows(body, &key, depth + 1, out), + StepKind::If { then, otherwise, .. } => { + for (label, list, b) in [("Then", then, "then"), ("Else", otherwise, "else")] { + let bk = format!("{key}.{b}"); + out.push(Row { key: bk.clone(), parent: key.clone(), index: 0, depth: depth + 1, branch: Some(label) }); + rows(list, &bk, depth + 2, out); + } + } + _ => {} + } + } +} + +/// Moves the step at `from` in list `parent` so it lands before row `to` (an index among the +/// list's steps as they are now). Returns the step's new path. +pub fn move_step(steps: &mut Vec, parent: &str, from: usize, to: usize) -> Option { + let list = list_mut(steps, parent)?; + if from >= list.len() { + return None; + } + let st = list.remove(from); + let to = if to > from { to - 1 } else { to }.min(list.len()); + list.insert(to, st); + Some(child(parent, to)) +} + +// ---------------------------------------------------------------- summaries + +fn short(v: &Json) -> String { + let text = match v { + Json::String(s) => s.clone(), + other => other.to_string(), + }; + if text.chars().count() > 40 { format!("{}…", text.chars().take(39).collect::()) } else { text } +} + +/// The label of command `id` (`Set Cell`), or the id when it isn't a command. +pub fn command_label(id: &str) -> String { + gridcraft_engine::find_command(id).map(|c| c.label.to_string()).unwrap_or_else(|| id.to_string()) +} + +/// A step in one line: "Set D2 to =B2*C2", "Repeat for each row of Input.data". +pub fn summary(st: &Step) -> String { + match &st.kind { + StepKind::Command { id, params } => { + let p = |k: &str| params.get(k).map(short); + match (id.as_str(), p("cell"), p("input"), p("range")) { + ("cell.set", Some(c), Some(i), _) => format!("Set {c} to {i}"), + ("selection.set", _, _, Some(r)) => format!("Select {r}"), + _ => { + let label = command_label(id); + let args: Vec = + params.as_object().map(|o| o.iter().take(3).map(|(k, v)| format!("{k}: {}", short(v))).collect()).unwrap_or_default(); + if args.is_empty() { label } else { format!("{label} · {}", args.join(", ")) } + } + } + } + StepKind::Repeat { over, of, .. } => { + format!("Repeat for each {} of {}", if *of == RepeatOf::Rows { "row" } else { "column" }, if over.is_empty() { "…" } else { over }) + } + StepKind::If { cell, op, value, .. } => { + let op = match op { + CompareOp::Eq => "=", + CompareOp::Ne => "≠", + CompareOp::Gt => ">", + CompareOp::Ge => "≥", + CompareOp::Lt => "<", + CompareOp::Le => "≤", + CompareOp::Empty => return format!("If {cell} is empty"), + CompareOp::NotEmpty => return format!("If {cell} isn't empty"), + CompareOp::Contains => "contains", + }; + format!("If {cell} {op} {}", short(value)) + } + StepKind::Stop { message, .. } => { + if message.is_empty() { + "Stop".into() + } else { + format!("Stop: {message}") + } + } + } +} + +/// The icon and colour of a step's category. +pub fn category(st: &Step) -> (Icon, Cat) { + match &st.kind { + StepKind::Repeat { .. } => (Icon::Redo, Cat::Purple), + StepKind::If { .. } => (Icon::Validation, Cat::Amber), + StepKind::Stop { .. } => (Icon::StopSquare, Cat::Red), + StepKind::Command { id, .. } => { + let prefix = id.split('.').next().unwrap_or(""); + match prefix { + "cell" | "range" | "edit" => (Icon::Sheet, Cat::Blue), + "selection" => (Icon::Find, Cat::Slate), + "home" => (Icon::Format, Cat::Orange), + "data" | "table" | "pivot" => (Icon::SortFilter, Cat::Green), + "insert" | "chart" | "object" => (Icon::Insert, Cat::Purple), + "formulas" => (Icon::Function, Cat::Teal), + "sheet" | "view" | "pageLayout" => (Icon::Sheet, Cat::Amber), + _ => (Icon::Script, Cat::Slate), + } + } + } +} + +/// Problems the editor can see without running: an unknown command, one a macro may not run, +/// one that needs a permission the macro doesn't list, a Repeat or If with nothing to work on. +pub fn static_error(m: &Macro, st: &Step) -> Option { + match &st.kind { + StepKind::Command { id, params } => { + if gridcraft_engine::find_command(id).is_none() { + return Some(format!("There is no command “{id}”.")); + } + match macro_policy(id, params, gridcraft_engine::Trust::Local) { + Policy::Deny(why) => Some(format!("{id} can't run in a macro: {why}.")), + Policy::NeedsPermission(what) if !m.permissions.iter().any(|p| p == id) => { + Some(format!("{id} {what}: the macro must list it in its permissions.")) + } + _ => None, + } + } + StepKind::Repeat { over, .. } if over.trim().is_empty() => Some("Choose the range to repeat over.".into()), + StepKind::If { cell, .. } if cell.trim().is_empty() => Some("Choose the cell to test.".into()), + _ => None, + } +} + +/// The parameters a new step starts with, from the command's documentation: the required ones, +/// plus an optional `cell` or `range` (`{cell?, input, array?}` → cell, input). +pub fn required_keys(doc: &str) -> Vec { + let Some(start) = doc.find('{') else { return vec![] }; + let mut depth = 0i32; + let mut quoted = false; + let mut part = String::new(); + let mut parts = Vec::new(); + for c in doc.get(start + 1..).unwrap_or("").chars() { + if c == '"' { + quoted = !quoted; + } + if quoted { + continue; + } + match c { + '{' | '[' | '(' => depth += 1, + '}' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + break; + } + '}' | ']' | ')' => depth -= 1, + ',' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + continue; + } + _ => {} + } + if depth == 0 { + part.push(c); + } + } + parts + .iter() + .filter_map(|p| { + let p = p.trim(); + let key: String = p.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); + let rest = p.get(key.len()..).unwrap_or("").trim_start(); + let optional = rest.starts_with('?') || rest.starts_with('|'); + (!key.is_empty() && (!optional || key == "cell" || key == "range")).then_some(key) + }) + .take(8) + .collect() +} + +// ---------------------------------------------------------------- tokens + +/// The token for `target` relative to the input called `name` at `input`: `Input`, `Input.col(2)`, +/// `Input.rows(2..3)`, `Input.cell(1,4)`, `Input.offset(0,4)`, or a cell offset from the input's +/// first cell (`Input.cell(1,1).offset(-1,0)`) with a `resize` when it is larger than one cell. +pub fn token_for(name: &str, input: RangeRef, target: RangeRef) -> String { + let (i, r) = (input, target); + if r == i { + return name.to_string(); + } + let dr = i64::from(r.start.row) - i64::from(i.start.row); + let dc = i64::from(r.start.col) - i64::from(i.start.col); + let (h, w) = (r.height(), r.width()); + if h == i.height() && w == i.width() { + return format!("{name}.offset({dr},{dc})"); + } + let inside = i.contains(r.start) && i.contains(r.end); + if inside { + let (r0, r1) = (dr + 1, dr + i64::from(h)); + let (c0, c1) = (dc + 1, dc + i64::from(w)); + if r.start.row == i.start.row && r.end.row == i.end.row { + return if w == 1 { format!("{name}.col({c0})") } else { format!("{name}.cols({c0}..{c1})") }; + } + if r.start.col == i.start.col && r.end.col == i.end.col { + return if h == 1 { format!("{name}.row({r0})") } else { format!("{name}.rows({r0}..{r1})") }; + } + let cell = format!("{name}.cell({r0},{c0})"); + return if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") }; + } + let cell = format!("{name}.cell(1,1).offset({dr},{dc})"); + if r.is_single() { cell } else { format!("{cell}.resize({h},{w})") } +} + +/// `Sheet1!$B$2:$D$9,C4` → [(Some("Sheet1"), B2:D9), (None, C4)]. +pub fn parse_refs(text: &str) -> Vec<(Option, RangeRef)> { + text.split(',') + .filter_map(|part| { + let part = part.trim(); + let (sheet, body) = match part.rsplit_once('!') { + Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b), + None => (None, part), + }; + RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r)) + }) + .take(500) + .collect() +} + +/// The inputs bound to their examples, for showing tokens as A1 and A1 as tokens. +fn bound_inputs(wb: &Workbook, ed: &Editor) -> Vec<(String, Place, bool)> { + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &[], output: None, item: None }; + ed.draft + .inputs + .iter() + .enumerate() + .filter_map(|(k, i)| { + let example = if k == 0 { Some(ed.example.as_str()) } else { i.default_ref.as_deref() }; + let place = resolve::resolve_ref(example?, &env).ok()?; + Some((i.name.clone(), place, i.header)) + }) + .collect() +} + +/// The suggestions offered beside a reference box. +fn token_suggestions(wb: &Workbook, ed: &Editor, in_repeat: bool) -> Vec { + let mut out = Vec::new(); + for (k, i) in ed.draft.inputs.iter().enumerate() { + let n = &i.name; + out.push(n.clone()); + if i.header { + out.push(format!("{n}.data")); + } + let width = if k == 0 { parse_refs(&ed.example).first().map_or(3, |(_, r)| r.width()) } else { 3 }.clamp(1, 6); + out.extend((1..=width).map(|c| format!("{n}.col({c})"))); + out.push(format!("{n}.rows(2..)")); + out.push(format!("{n}.cell(1,1)")); + out.push(format!("{n}.right")); + out.push(format!("{n}.below")); + } + if ed.draft.output.anchor.is_some() { + out.push("Output".into()); + out.push("Output.offset(1,0)".into()); + } + if in_repeat { + out.push("Row".into()); + out.push("Row.right".into()); + } + out.extend(wb.names.iter().take(20).map(|n| format!("Name:{}", n.name))); + out +} + +// ---------------------------------------------------------------- opening + +/// Opens the editor: `{macro}` edits a saved macro (`debug: true` also starts debugging it, +/// `check: true` checks it), `{new}` starts one with that name, `{}` one with the next free name. +pub fn open(app: &mut SheetApp, params: &Json) -> Option { + let doc = app.session.active()?; + let wb = &doc.wb; + let selection = doc.selection.current().a1(); + let found = params["macro"].as_str().and_then(|n| gridcraft_engine::macros::find(wb, n)).and_then(|i| wb.macros.get(i)).cloned(); + let (draft, saved_as) = match found { + Some(m) => { + let name = m.name.clone(); + (m, Some(name)) + } + None => { + let name = params["new"] + .as_str() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(|n| gridcraft_engine::macros::unique_name(wb, n)) + .unwrap_or_else(|| gridcraft_engine::macros::next_name(wb)); + (Macro { name, inputs: vec![NamedInput::default()], ..Default::default() }, None) + } + }; + let example = draft.inputs.first().and_then(|i| i.default_ref.clone()).unwrap_or(selection); + let start_debug = params["debug"].as_bool() == Some(true) && saved_as.is_some(); + let title = format!("Macro: {}", draft.name); + let selected = (!draft.steps.is_empty()).then(|| "1".to_string()); + let doc = doc.uid; + app.macros.editor = Some(Editor { + doc, + 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, + 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 } +} + +/// The inputs a run from the editor uses: the first input's example. +fn run_inputs(ed: &Editor) -> Json { + match ed.draft.inputs.first() { + Some(i) if !ed.example.trim().is_empty() => json!({ i.name.clone(): ed.example }), + _ => json!({}), + } +} + +/// The step path an error message names (`Step 3 (cell.set): …` → `3`). +fn error_step(msg: &str) -> Option<(String, String)> { + let rest = msg.get(msg.find("Step ")? + 5..)?; + let end = rest.find([' ', ':']).unwrap_or(rest.len()); + let path = rest.get(..end)?.to_string(); + split_path(&path)?; + Some((path, msg.to_string())) +} + +/// Check: validates the inputs and dry-runs the macro; the grid shows what it would touch. +pub fn check(app: &mut SheetApp, ed: &mut Editor) { + // A reference box picking from the grid would take the selections the action makes. + ed.refs.pick = None; + if !at_home(app, ed, "Check Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match app.run("macro.check", json!({"name": name, "inputs": run_inputs(ed)})) { + Ok(v) => { + for e in v["errors"].as_array().into_iter().flatten().filter_map(Json::as_str) { + if let Some((path, msg)) = error_step(e) { + ed.errors.insert(path, msg); + } + } + if let Some(e) = v["footprint"]["error"].as_object() + && let Some(step) = e.get("step").and_then(Json::as_str) + { + ed.errors.insert(step.to_string(), e.get("message").and_then(Json::as_str).unwrap_or("").to_string()); + } + ed.outcome = Some(check_text(&v)); + ed.check = Some(v); + } + Err(e) => { + ed.check = None; + ed.outcome = Some(crate::clean_error(&e)); + } + } +} + +fn check_text(v: &Json) -> String { + if let Some(errors) = v["errors"].as_array().filter(|e| !e.is_empty()) { + return errors.iter().filter_map(Json::as_str).collect::>().join(" "); + } + if let Some(asks) = v["needsApproval"].as_array() { + return format!("Needs approval before it can run: it {}.", asks.iter().filter_map(Json::as_str).collect::>().join(", ")); + } + let cells: u64 = v["footprint"]["sheets"].as_array().into_iter().flatten().filter_map(|s| s["changed"].as_u64()).sum(); + let conflicts = v["conflicts"].as_u64().unwrap_or(0); + let mut text = format!("Check passed: writes {cells} cell{}", if cells == 1 { "" } else { "s" }); + if conflicts > 0 { + text.push_str(&format!(", {conflicts} of them hold data (red)")); + } else { + text.push_str(", over no data"); + } + if v["footprint"]["partial"].is_object() { + text.push_str(". Later steps can't be previewed."); + } + text +} + +/// Run: runs the saved macro as one undo step. +pub fn run(app: &mut SheetApp, ed: &mut Editor) { + ed.refs.pick = None; + if !at_home(app, ed, "Run Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + let name = ed.draft.name.clone(); + match macro_ui::call(app, "Run Macro", "macro.run", json!({"name": name, "inputs": run_inputs(ed)})) { + Some(v) => ed.outcome = Some(format!("Ran {} step{}.", v["ran"].as_u64().unwrap_or(0), if v["ran"] == 1 { "" } else { "s" })), + None => { + if let Some((_, text)) = app.message.as_ref() + && let Some((path, msg)) = error_step(text) + { + ed.errors.insert(path, msg); + } + } + } +} + +/// The breakpoints sent with each debugger action: the draft's (they replace the saved ones, so +/// a dot cleared while paused takes effect at once). +fn bp_params(ed: &Editor) -> Json { + let mut list = Vec::new(); + breakpoints(&ed.draft.steps, "", &mut list); + json!({"breakpoints": list, "breakpointsOnly": true}) +} + +fn with(mut base: Json, extra: Json) -> Json { + if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) { + for (k, v) in e { + b.insert(k.clone(), v.clone()); + } + } + base +} + +/// Step, Continue or Run to Here: starts debugging if needed (paused before step 1), then acts. +pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { + ed.refs.pick = None; + let debugging = app.session.macro_debug.is_some(); + // Starting selects the first step: take Run to Here's target before that. + let target = ed.selected.clone().filter(|s| split_path(s).is_some()); + if !debugging { + if !at_home(app, ed, "Debug Macro") || !ensure_saved(app, ed) { + return; + } + ed.errors.clear(); + // A dry run first, so the grid shows the inputs and what the run will write. + check(app, ed); + let name = ed.draft.name.clone(); + let p = with(json!({"name": name, "inputs": run_inputs(ed), "action": "start"}), bp_params(ed)); + let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", p) else { return }; + apply_debug(ed, &v); + if action == "step" || v["state"] != "paused" { + return; + } + } + let p = match action { + "runTo" => match target { + Some(step) => json!({"action": "runTo", "step": step}), + None => return, + }, + other => json!({"action": other}), + }; + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", with(p, bp_params(ed))) { + apply_debug(ed, &v); + } +} + +/// Stop: ends the debugged run, keeping its changes as one undo step or undoing them. +pub fn stop(app: &mut SheetApp, ed: &mut Editor, keep: bool) { + if let Some(v) = macro_ui::call(app, "Debug Macro", "macro.debug", json!({"action": "stop", "keep": keep})) { + apply_debug(ed, &v); + } +} + +/// Takes in a `macro.debug` result: paused (select the current step), or ended (say how). +pub fn apply_debug(ed: &mut Editor, v: &Json) { + match v["state"].as_str() { + Some("paused") => { + if let Some(step) = v["current"]["step"].as_str() { + ed.selected = Some(step.to_string()); + ed.tab = Tab::Step; + } + ed.outcome = None; + ed.debug = Some(v.clone()); + } + Some(state) => { + ed.debug = None; + let executed = v["executed"].as_u64().unwrap_or(0); + ed.outcome = Some(match state { + "finished" => format!("Finished: {executed} step{} ran, one undo step.", if executed == 1 { "" } else { "s" }), + "failed" => { + if let Some(step) = v["error"]["step"].as_str() { + let msg = v["error"]["message"].as_str().unwrap_or("").to_string(); + ed.errors.insert(step.to_string(), msg); + ed.selected = Some(step.to_string()); + } + v["message"].as_str().unwrap_or("The macro failed; nothing was changed.").to_string() + } + _ if v["kept"] == true => format!("Stopped after {executed} step{}; the changes were kept.", if executed == 1 { "" } else { "s" }), + _ => match v["stopped"].as_str() { + Some(msg) => format!("Stopped: {msg}"), + None => "Stopped; the changes were undone.".into(), + }, + }); + } + None => {} + } +} + +// ---------------------------------------------------------------- the window + +/// Draws the editor window. `open` turns false when it closes. +pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mut bool) { + let Some(mut ed) = app.macros.editor.take() else { + *open = false; + return; + }; + // A prompt's button re-ran a debugger action (making room for the output, approving). + if let Some(v) = app.macros.debug_result.take() { + apply_debug(&mut ed, &v); + } + if ed.start_check { + ed.start_check = false; + check(app, &mut ed); + } + if ed.start_debug { + ed.start_debug = false; + debug_action(app, &mut ed, "step"); + } + // The run ended elsewhere (the status bar, an agent). + if ed.debug.is_some() && app.session.macro_debug.is_none() { + ed.debug = None; + } + // Its workbook was closed: the macro went with it. + if !app.session.documents().iter().any(|d| d.uid == ed.doc) { + *open = false; + return; + } + let home = app.session.active().is_some_and(|d| d.uid == ed.doc); + let home_title = home_title(app, &ed); + let dirty_mark = if ed.dirty { " •" } else { "" }; + let screen = ctx.content_rect(); + let fit = (screen.size() - vec2(16.0, 16.0)).max(vec2(240.0, 200.0)); + let mut win_open = true; + ed.refs.drawn = false; + egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) + .id(Id::unique("gridcraft.macro_editor")) + .collapsible(false) + .resizable(true) + // Never larger than the app's window, so a narrow one keeps every control on screen. + .default_size(vec2(840.0, 560.0).min(fit)) + .min_size(vec2(660.0, 380.0).min(fit)) + // To the right, so the data the overlays mark (usually top left) stays in view. + .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) + .open(&mut win_open) + .show(ctx, |ui| { + if !home { + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(format!("This macro is in “{home_title}”. Switch back to it to edit, check or run the macro.")) + .color(t.danger), + ); + } + ui.add_enabled_ui(home, |ui| { + if let Some(key) = ed.refs.collapsed.clone() { + ref_edit(app, ui, d, &mut ed.refs, &key, 340.0); + apply_ref_value(&mut ed, d, &key); + return; + } + body(app, ui, d, &mut ed); + }); + }); + if ed.refs.pick.is_some() && !ed.refs.drawn { + ed.refs.pick = None; + } + if !win_open || std::mem::take(&mut ed.close_requested) { + if app.session.macro_debug.is_some() { + ed.ask_stop = true; + ed.closing = true; + } else if ed.dirty { + 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 { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// 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), the window close 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(|ed| ed.dirty) { + return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); + } + if let Some(ed) = app.macros.editor.take() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + restore_home(app, &ed.refs); + } + app.grid.macro_overlay = None; + app.dialog = None; + Ok(()) +} + +// ---------------------------------------------------------------- reference boxes + +/// A range as an absolute reference: `$B$2:$D$9`. +fn abs(r: &RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The grid selection as a reference: absolute, comma-joined areas, with the sheet name when it +/// isn't the sheet picking started on. +fn selection_ref(app: &SheetApp, home_sheet: Option) -> String { + let Some(doc) = app.session.active() else { return String::new() }; + let areas: Vec = doc.selection.ranges.iter().map(abs).collect(); + match (home_sheet, doc.wb.active()) { + (Some(h), Some(sh)) if h != doc.wb.active_sheet() => { + let q = gridcraft_engine::formula::quote_sheet(&sh.name); + areas.iter().map(|a| format!("{q}!{a}")).collect::>().join(",") + } + _ => areas.join(","), + } +} + +/// Starts picking into `key`: later grid selections are written into the box. The first pick +/// remembers the selection, so closing the editor can put it back. +fn begin_pick(app: &SheetApp, refs: &mut RefPick, key: &str) { + if refs.home.is_none() + && let Some(doc) = app.session.active() + { + let ranges: Vec = doc.selection.ranges.iter().map(|r| r.a1()).collect(); + refs.home = Some((doc.wb.active_sheet(), ranges.join(","), doc.selection.active.a1())); + } + refs.pick = Some(key.to_string()); + refs.seen = selection_ref(app, refs.home.as_ref().map(|h| h.0)); +} + +/// Puts back the sheet and selection from before picking started. +fn restore_home(app: &mut SheetApp, refs: &RefPick) { + if let Some((sheet, ranges, active)) = &refs.home { + let _ = app.session.run("selection.set", json!({"sheet": sheet, "range": ranges, "active": active})); + } +} + +/// A reference box (Excel's RefEdit): a text field that grid selections fill while it is the +/// pick target, with a button that collapses the window to just this box and expands it again. +/// The text is kept in `d.values[key]`. +fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key: &str, width: f32) { + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + let sel = selection_ref(app, refs.home.as_ref().map(|h| h.0)); + let picking = refs.pick.as_deref() == Some(key); + if picking { + refs.drawn = true; + } + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width - 30.0).id_salt(("refedit", key))); + if r.gained_focus() || (r.clicked() && !picking) { + begin_pick(app, refs, key); + } else if picking && !r.has_focus() && sel != refs.seen && app.session.macro_debug.is_none() { + // A selection made on the grid becomes the reference; while debugging, the selection + // follows the steps and is not a pick. + s = sel.clone(); + refs.seen = sel; + } + let collapsed = refs.collapsed.as_deref() == Some(key); + let t = Tokens::get(ui.ctx()); + let (icon, tip) = if collapsed { (Icon::Expand, "Expand the editor") } else { (Icon::Collapse, "Collapse the editor to this box") }; + if crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0)).clicked() { + refs.collapsed = if collapsed { None } else { Some(key.to_string()) }; + if !collapsed { + begin_pick(app, refs, key); + } + } + }); + d.values.insert(key.into(), json!(s)); +} + +/// Keep changes? (stopping a debugged run) and Save changes? (closing with unsaved edits). +fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut bool) { + if ed.ask_stop { + let mut answer: Option> = None; + egui::Modal::new(Id::unique("gridcraft.macro_editor.stop")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new("Stop debugging").strong()); + ui.label("Keep the changes the macro has made so far? They are one undo step either way."); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Undo Changes").clicked() { + answer = Some(Some(false)); + } + if ui.button("Keep Changes").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(keep)) => { + ed.ask_stop = false; + stop(app, ed, keep); + if ed.closing { + if ed.dirty { ed.ask_close = true } else { *open = false } + } + ed.closing = false; + } + Some(None) => { + ed.ask_stop = false; + ed.closing = false; + ed.quit = false; + } + None => {} + } + return; + } + if ed.ask_close { + let mut answer: Option> = None; + egui::Modal::new(Id::unique("gridcraft.macro_editor.close")).show(ctx, |ui| { + ui.set_width(360.0); + ui.label(egui::RichText::new(format!("Save changes to macro “{}”?", ed.draft.name)).strong()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + if ui.button("Don't Save").clicked() { + answer = Some(Some(false)); + } + if ui.button("Save").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(Some(true)); + } + }); + }); + match answer { + Some(Some(true)) => { + ed.ask_close = false; + if save(app, ed) { + *open = false; + } + } + Some(Some(false)) => { + ed.ask_close = false; + *open = false; + } + Some(None) => { + ed.ask_close = false; + ed.quit = false; + } + None => {} + } + } +} + +fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let debugging = app.session.macro_debug.is_some(); + // Name, description, Save; they wrap in a narrow window. + ui.horizontal_wrapped(|ui| { + let label = ui.label("Name:"); + let name = + ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")).labelled_by(label.id); + let label = ui.label("Description:"); + let desc = ui + .add_enabled( + !debugging, + egui::TextEdit::singleline(&mut ed.draft.description).desired_width((ui.available_width() - 90.0).max(80.0)).id_salt("me_desc"), + ) + .labelled_by(label.id); + if name.changed() || desc.changed() { + ed.dirty = true; + } + if ui.add_enabled(ed.dirty && !debugging, egui::Button::new("Save")).clicked() { + save(app, ed); + } + }); + ui.add_space(2.0); + toolbar(app, ui, ed, debugging); + status_line(app, ui, ed, &t); + ui.separator(); + let avail = ui.available_size(); + // The step list takes about half; in a narrow window the inspector keeps room for its tabs. + let left_w = (avail.x * 0.52).max(300.0).min((avail.x - 190.0).max(160.0)); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(left_w, avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(left_w, avail.y)); + if ed.picker.is_some() { + picker(ui, ed); + } else { + step_list(app, ui, ed, debugging); + } + }); + ui.separator(); + ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { + egui::ScrollArea::both().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { + inspector(app, ui, d, ed, debugging); + }); + }); + }); +} + +fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + // The buttons wrap onto a second row in a narrow window. + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let editing = !debugging; + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).clicked() { + ed.picker = Some(Picker::default()); + } + let has_step = ed.selected.as_deref().and_then(split_path).is_some(); + if ui + .add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) + .clicked() + { + duplicate(ed); + } + if ui.add_enabled(editing && has_step, |ui: &mut Ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).clicked() { + delete(ed); + } + sep(ui, &t); + if ui + .add_enabled(editing, |ui: &mut Ui| { + small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) + }) + .clicked() + { + check(app, ed); + } + if ui.add_enabled(editing, |ui: &mut Ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).clicked() { + run(app, ed); + } + sep(ui, &t); + if small_button(ui, Icon::Step, "Step", if debugging { "Run the current step" } else { "Start debugging: stop before the first step" }, false) + .clicked() + { + debug_action(app, ed, "step"); + } + if small_button(ui, Icon::Continue, "Continue", "Run to the next breakpoint or the end", false).clicked() { + debug_action(app, ed, "continue"); + } + let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); + if ui.add_enabled(can_run_to, |ui: &mut Ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).clicked() { + debug_action(app, ed, "runTo"); + } + if ui.add_enabled(debugging, |ui: &mut Ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).clicked() { + ed.ask_stop = true; + } + }); +} + +fn sep(ui: &mut Ui, t: &Tokens) { + let (r, _) = ui.allocate_exact_size(vec2(9.0, 20.0), Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 2.0), pos2(r.center().x, r.bottom() - 2.0)], Stroke::new(1.0, t.separator)); +} + +/// Where the run is (while debugging) or what the last check, run or debug session said, with +/// the overlay legend. +fn status_line(app: &SheetApp, ui: &mut Ui, ed: &Editor, t: &Tokens) { + if let Some(st) = &ed.debug { + let cur = &st["current"]; + let step = cur["step"].as_str().unwrap_or(""); + let what = match cur["kind"].as_str() { + Some("command") => command_label(cur["command"].as_str().unwrap_or("")), + Some(k) => k.to_string(), + None => String::new(), + }; + let mut head = format!("Paused before step {step} · {what}"); + if let (Some(at), Some(of)) = (cur["iteration"]["at"].as_u64(), cur["iteration"]["of"].as_u64()) { + head.push_str(&format!(" · pass {at} of {of}")); + } + if st["atBreakpoint"] == true { + head.push_str(" · at a breakpoint"); + } + ui.label(egui::RichText::new(head).strong().color(t.text)); + let resolved = cur.get("params").or(cur.get("over")).or(cur.get("cell")).map(|p| p.to_string()); + if let Some(p) = resolved { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Runs with:").color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(p).monospace().color(t.text)).truncate()); + }); + } else if let Some(e) = cur["paramsError"].as_str() { + ui.label(egui::RichText::new(e).color(t.danger)); + } + let changed: Vec<&str> = st["changed"].as_array().into_iter().flatten().filter_map(Json::as_str).take(6).collect(); + ui.label( + egui::RichText::new(format!("Changed so far: {}", if changed.is_empty() { "nothing".into() } else { changed.join(", ") })) + .color(t.text_dim), + ); + } else if let Some(text) = &ed.outcome { + let bad = !ed.errors.is_empty() || ed.check.as_ref().is_some_and(|c| c["ok"] == false); + ui.add(egui::Label::new(egui::RichText::new(text).color(if bad { t.danger } else { t.text })).wrap()); + } else { + ui.label( + egui::RichText::new("Check shows on the grid what the macro reads and writes. Click a step's gutter to set a breakpoint.") + .color(t.text_dim), + ); + } + if app.grid.macro_overlay.is_some() { + ui.horizontal(|ui| { + for (label, c) in [ + ("Inputs", t.sheet_macro_input), + ("Writes", t.sheet_macro_footprint), + ("Writes over data", t.sheet_macro_conflict), + ("Current step", t.sheet_macro_target), + ] { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, c); + ui.label(egui::RichText::new(label).small().color(t.text_dim)); + ui.add_space(6.0); + } + }); + } +} + +// ---------------------------------------------------------------- the step list + +/// A step being dragged: the list it is in and its place there. +#[derive(Clone, Debug)] +struct StepDrag { + parent: String, + index: usize, + label: String, +} + +fn step_list(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { + let t = Tokens::get(ui.ctx()); + let mut list = Vec::new(); + rows(&ed.draft.steps, "", 0, &mut list); + if list.is_empty() { + ui.add_space(8.0); + ui.label(egui::RichText::new("No steps yet. Add Step picks a command, or record one with Automate › Record Macro.").color(t.text_dim)); + return; + } + let current = ed.debug.as_ref().and_then(|s| s["current"]["step"].as_str().map(str::to_string)); + let dragging = egui::DragAndDrop::payload::(ui.ctx()); + let pointer = ui.ctx().pointer_latest_pos(); + let mut placed: Vec<(String, Rect)> = Vec::new(); + egui::ScrollArea::vertical().id_salt("me_steps").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for row in &list { + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag()); + placed.push((row.parent.clone(), rect)); + let selected = ed.selected.as_deref() == Some(row.key.as_str()); + let is_current = current.as_deref() == Some(row.key.as_str()); + let p = ui.painter(); + if is_current { + p.rect_filled(rect, 3.0, t.accent_soft); + } else if selected { + p.rect_filled(rect, 3.0, t.pressed); + } else if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + if resp.clicked() { + ed.selected = Some(row.key.clone()); + ed.tab = Tab::Step; + } + let indent = row.depth as f32 * 16.0; + if let Some(label) = row.branch { + p.text(pos2(rect.left() + 58.0 + indent, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::ui_bold(12.0), t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{label} branch {}", row.parent))); + continue; + } + let Some(st) = step_at(&ed.draft.steps, &row.key) else { continue }; + // Gutter: the breakpoint dot (click to toggle), the current-step arrow. + let gutter = Rect::from_min_size(rect.min, vec2(18.0, rect.height())); + let g = ui.interact(gutter, Id::unique(("me_bp", &row.key)), Sense::click()); + if st.breakpoint { + p.circle_filled(gutter.center(), 5.0, t.breakpoint); + } else if g.hovered() { + p.circle_stroke(gutter.center(), 4.5, Stroke::new(1.0, t.breakpoint)); + } + if is_current { + let c = pos2(gutter.right() + 1.0, gutter.center().y); + p.add(egui::Shape::convex_polygon(vec![c + vec2(-6.0, -5.0), c + vec2(1.0, 0.0), c + vec2(-6.0, 5.0)], t.accent, Stroke::NONE)); + } + g.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, st.breakpoint, format!("Breakpoint {}", row.key))); + if g.on_hover_text("Breakpoint: the debugger stops before this step").clicked() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.breakpoint = !s.breakpoint; + ed.dirty = true; + } + p.text(pos2(rect.left() + 48.0, rect.center().y), egui::Align2::RIGHT_CENTER, &row.key, theme::ui_font(11.5), t.text_dim); + let (icon, cat) = category(&st); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 62.0 + indent, rect.center().y), vec2(16.0, 16.0)), icon, t.cat(cat)); + let text = summary(&st); + let text_color = if st.enabled { t.text } else { t.text_disabled }; + let text_left = rect.left() + 76.0 + indent; + let text_clip = Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 46.0, rect.bottom())); + p.with_clip_rect(text_clip).text(pos2(text_left, rect.center().y), egui::Align2::LEFT_CENTER, &text, theme::ui_font(12.5), text_color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Step {}: {text}", row.key))); + // Error marker. + let error = ed.errors.get(&row.key).cloned().or_else(|| static_error(&ed.draft, &st)); + if let Some(err) = error { + let c = pos2(rect.right() - 36.0, rect.center().y); + p.circle_filled(c, 7.0, t.danger); + p.text(c, egui::Align2::CENTER_CENTER, "!", theme::ui_bold(11.0), t.window); + ui.interact(Rect::from_center_size(c, vec2(16.0, 16.0)), Id::unique(("me_err", &row.key)), Sense::hover()).on_hover_text(err); + } + // Enabled. + let mut on = st.enabled; + let cb = + ui.put(Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(18.0, 18.0)), egui::Checkbox::without_text(&mut on)); + if cb.on_hover_text("Run this step (a disabled step is skipped)").changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &row.key) + { + s.enabled = on; + ed.dirty = true; + } + if !debugging { + dnd::drag_source(ui, &resp, StepDrag { parent: row.parent.clone(), index: row.index, label: text.clone() }); + } + } + }); + // Dropping a dragged step among its own list's rows. + if let (Some(drag), Some(p)) = (dragging, pointer) { + let group: Vec = + list.iter().zip(&placed).filter(|(r, _)| r.branch.is_none() && r.parent == drag.parent).map(|(_, (_, rect))| *rect).collect(); + let zone = group.iter().fold(Rect::NOTHING, |a, r| a.union(*r)); + let area = placed.iter().fold(Rect::NOTHING, |a, (_, r)| a.union(*r)).expand(12.0); + dnd::ghost(ui, &drag.label, p); + if area.contains(p) && zone.is_positive() { + dnd::drop_marker(ui, zone, &group, p.y); + } + if ui.input(|i| i.pointer.any_released()) { + let _ = egui::DragAndDrop::take_payload::(ui.ctx()); + if area.contains(p) { + let to = dnd::insert_index(&group, p.y); + if to != drag.index + && to != drag.index + 1 + && let Some(path) = move_step(&mut ed.draft.steps, &drag.parent, drag.index, to) + { + ed.selected = Some(path); + ed.errors.clear(); + ed.dirty = true; + } + } + } + } + let _ = app; +} + +/// Where a new step goes: after the selected step in its list, at the end of a selected branch +/// or Repeat, or at the end of the macro. +fn insertion_point(ed: &Editor) -> (String, usize) { + let Some(sel) = ed.selected.as_deref() else { return (String::new(), ed.draft.steps.len()) }; + let mut steps = ed.draft.steps.clone(); + if split_path(sel).is_none() { + // A branch row: the end of that branch. + let len = list_mut(&mut steps, sel).map_or(0, |l| l.len()); + return (sel.to_string(), len); + } + if let Some(st) = step_mut(&mut steps, sel) + && let StepKind::Repeat { body, .. } = &st.kind + { + return (sel.to_string(), body.len()); + } + match split_path(sel) { + Some((parent, i)) => (parent.to_string(), i + 1), + None => (String::new(), ed.draft.steps.len()), + } +} + +fn insert_step(ed: &mut Editor, step: Step) { + let (parent, at) = insertion_point(ed); + if let Some(list) = list_mut(&mut ed.draft.steps, &parent) { + let at = at.min(list.len()); + list.insert(at, step); + ed.selected = Some(child(&parent, at)); + ed.tab = Tab::Step; + ed.errors.clear(); + ed.dirty = true; + } +} + +fn duplicate(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && let Some(st) = list.get(i).cloned() + { + list.insert(i + 1, st); + ed.selected = Some(child(parent, i + 1)); + ed.errors.clear(); + ed.dirty = true; + } +} + +fn delete(ed: &mut Editor) { + let Some(sel) = ed.selected.clone() else { return }; + let Some((parent, i)) = split_path(&sel) else { return }; + if let Some(list) = list_mut(&mut ed.draft.steps, parent) + && i < list.len() + { + list.remove(i); + ed.selected = if list.is_empty() { (!parent.is_empty()).then(|| parent.to_string()) } else { Some(child(parent, i.min(list.len() - 1))) }; + ed.errors.clear(); + ed.dirty = true; + } +} + +// ---------------------------------------------------------------- Add Step + +/// The steps the picker offers for `query`: control steps first, then commands a macro may run. +pub fn picker_items(query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_ascii_lowercase(); + let mut out: Vec<(String, String, String)> = [ + ("@repeat", "Repeat for each row", "Control: run steps once per row of a range"), + ("@if", "If", "Control: run steps when a cell meets a condition"), + ("@stop", "Stop with message", "Control: end the run with a message"), + ] + .iter() + .filter(|(_, l, _)| q.is_empty() || l.to_ascii_lowercase().contains(&q)) + .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())) + .collect(); + for c in gridcraft_engine::command_specs() { + if matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)) || c.id.starts_with("ui.") { + continue; + } + if q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q) { + out.push((c.id.to_string(), c.label.to_string(), c.menu.join(" › "))); + } + if out.len() >= 60 { + break; + } + } + out +} + +/// A new step for a picker item, with the command's required parameters filled in as blanks. +pub fn new_step(item: &str) -> Step { + match item { + "@repeat" => Step::new(StepKind::Repeat { over: "Input".into(), of: RepeatOf::Rows, max: 10_000, body: vec![] }), + "@if" => { + Step::new(StepKind::If { cell: "Input.cell(1,1)".into(), op: CompareOp::NotEmpty, value: Json::Null, then: vec![], otherwise: vec![] }) + } + "@stop" => Step::new(StepKind::Stop { message: String::new(), rollback: false }), + id => { + let doc = gridcraft_engine::find_command(id).map(|c| c.params).unwrap_or(""); + let params: Map = required_keys(doc).into_iter().map(|k| (k, json!(""))).collect(); + Step::command(id, Json::Object(params)) + } + } +} + +fn picker(ui: &mut Ui, ed: &mut Editor) { + let Some(pk) = ed.picker.as_mut() else { return }; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(if pk.replace.is_some() { "Change the command" } else { "Add a step" }).strong()); + if ui.small_button("Cancel").clicked() { + pk.search = "\u{1}".into(); + } + }); + if pk.search == "\u{1}" { + ed.picker = None; + return; + } + let r = ui.add( + egui::TextEdit::singleline(&mut pk.search) + .hint_text("Search commands, e.g. bold, sort, set…") + .desired_width(f32::INFINITY) + .id_salt("me_pick"), + ); + r.request_focus(); + if r.changed() { + pk.index = 0; + } + let mut items = picker_items(&pk.search); + // Change swaps a command step's command; it can't turn the step into Repeat, If or Stop. + if pk.replace.is_some() { + items.retain(|(id, ..)| !id.starts_with('@')); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + pk.index = (pk.index + 1).min(items.len().saturating_sub(1)); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + pk.index = pk.index.saturating_sub(1); + } + let mut chosen = ui.input(|i| i.key_pressed(egui::Key::Enter)).then_some(pk.index); + egui::ScrollArea::vertical().id_salt("me_pick_list").auto_shrink([false, false]).show(ui, |ui| { + for (i, (id, label, menu)) in items.iter().enumerate() { + let r = ui.add(egui::Button::selectable(i == pk.index, label.as_str()).min_size(vec2(ui.available_width(), 20.0))); + if r.clicked() { + chosen = Some(i); + } + r.on_hover_text(format!("{id} {menu}")); + } + if items.is_empty() { + ui.label(egui::RichText::new("No command matches.").color(t.text_dim)); + } + }); + let Some(item) = chosen.and_then(|i| items.get(i)).map(|x| x.0.clone()) else { return }; + let replace = pk.replace.clone(); + ed.picker = None; + match replace { + Some(path) => { + let fresh = new_step(&item); + if let Some(st) = step_mut(&mut ed.draft.steps, &path) { + st.kind = fresh.kind; + ed.dirty = true; + } + } + None => insert_step(ed, new_step(&item)), + } +} + +// ---------------------------------------------------------------- the inspector + +fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { + ui.horizontal_wrapped(|ui| { + for (tab, label) in [(Tab::Step, "Details"), (Tab::Inputs, "Inputs"), (Tab::Output, "Output")] { + if ui.selectable_label(ed.tab == tab, label).clicked() { + ed.tab = tab; + } + } + }); + ui.separator(); + ui.add_enabled_ui(!debugging, |ui| match ed.tab { + Tab::Step => step_tab(app, ui, d, ed), + Tab::Inputs => inputs_tab(app, ui, d, ed), + Tab::Output => output_tab(app, ui, d, ed), + }); +} + +/// The dialog value key of a reference box. +fn ref_key(path: &str, field: &str) -> String { + format!("ref:{path}:{field}") +} + +/// After a RefEdit box changed (typed, or picked on the grid): writes it into the draft. +fn apply_ref_value(ed: &mut Editor, d: &Dialog, key: &str) { + let Some(v) = d.values.get(key).and_then(Json::as_str).map(str::to_string) else { return }; + let mut parts = key.splitn(3, ':'); + let (_, path, field) = (parts.next(), parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if set_ref(ed, path, field, &v) { + ed.dirty = true; + } +} + +/// Writes reference text into the field a ref box edits. True when it changed. +fn set_ref(ed: &mut Editor, path: &str, field: &str, v: &str) -> bool { + match path { + "@output" => { + let new = (!v.trim().is_empty()).then(|| v.trim().to_string()); + let changed = ed.draft.output.anchor != new; + ed.draft.output.anchor = new; + changed + } + "@example" => { + let changed = ed.example != v; + ed.example = v.to_string(); + if let Some(i) = ed.draft.inputs.first_mut() + && i.default_ref.is_some() + { + i.default_ref = Some(v.to_string()); + } + changed + } + _ => { + let Some(st) = step_mut(&mut ed.draft.steps, path) else { return false }; + let slot: Option<&mut String> = match (&mut st.kind, field) { + (StepKind::Repeat { over, .. }, "over") => Some(over), + (StepKind::If { cell, .. }, "cell") => Some(cell), + _ => None, + }; + if let Some(s) = slot { + let changed = s != v; + *s = v.to_string(); + return changed; + } + if let StepKind::Command { params, .. } = &mut st.kind + && let Some(o) = params.as_object_mut() + { + let changed = o.get(field).and_then(Json::as_str) != Some(v); + o.insert(field.to_string(), json!(v)); + return changed; + } + false + } + } +} + +/// A reference box: RefEdit (type, or select on the grid), Relative/Absolute, and suggestions. +fn ref_field(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, (path, field): (&str, &str), value: &str, in_repeat: bool) { + let key = ref_key(path, field); + if ed.refs.pick.as_deref() != Some(key.as_str()) || d.values.get(&key).is_none() { + d.values.insert(key.clone(), json!(value)); + } + let width = (ui.available_width() - 96.0).clamp(120.0, 320.0); + ui.horizontal(|ui| { + ref_edit(app, ui, d, &mut ed.refs, &key, width); + apply_ref_value(ed, d, &key); + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + let Some(doc) = app.session.active() else { return }; + let wb = doc.wb.clone(); + let inputs = bound_inputs(&wb, ed); + let env = Env { wb: &wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + let relative = !current.trim().is_empty() && resolve::is_token(¤t, &env); + if path != "@example" { + if ui.selectable_label(relative, "Rel").on_hover_text("Relative: a token that follows the input the macro runs on").clicked() && !relative + { + let place = resolve::resolve_ref(¤t, &env).ok(); + if let (Some(p), Some((name, input, _))) = (place, inputs.first()) + && p.sheet == input.sheet + { + let tok = token_for(name, input.range, p.range); + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + if ui.selectable_label(!relative && !current.trim().is_empty(), "Abs").on_hover_text("Absolute: always these cells").clicked() + && relative + && let Ok(p) = resolve::resolve_ref(¤t, &env) + { + let a = resolve::a1(p, &env); + set_ref(ed, path, field, &a); + d.values.insert(key.clone(), json!(a)); + ed.dirty = true; + } + let menu = ui.button("▾").on_hover_text("Tokens"); + egui::Popup::menu(&menu).show(|ui| { + ui.set_max_height(300.0); + egui::ScrollArea::vertical().show(ui, |ui| { + for tok in token_suggestions(&wb, ed, in_repeat) { + let shown = + resolve::resolve_ref(&tok, &env).map(|p| format!("{tok} {}", resolve::a1(p, &env))).unwrap_or_else(|_| tok.clone()); + if ui.button(shown).clicked() { + set_ref(ed, path, field, &tok); + d.values.insert(key.clone(), json!(tok)); + ed.dirty = true; + } + } + }); + }); + } + }); + // What a token stands for on the example. + let current = d.values.get(&key).and_then(Json::as_str).unwrap_or("").to_string(); + if let Some(doc) = app.session.active() { + let wb = &doc.wb; + let inputs = bound_inputs(wb, ed); + let env = Env { wb, active_sheet: wb.active_sheet(), inputs: &inputs, output: None, item: None }; + if !current.trim().is_empty() && resolve::is_token(¤t, &env) && !current.starts_with("Row") && !current.starts_with("Output") { + let t = Tokens::get(ui.ctx()); + match resolve::resolve_ref(¤t, &env) { + Ok(p) => ui.label(egui::RichText::new(format!("= {} on the example", resolve::a1(p, &env))).small().color(t.text_dim)), + Err(e) => ui.label(egui::RichText::new(e.message()).small().color(t.danger)), + }; + } + } +} + +fn step_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + let Some(path) = ed.selected.clone() else { + ui.label(egui::RichText::new("Select a step to see its settings.").color(t.text_dim)); + return; + }; + let Some(st) = step_at(&ed.draft.steps, &path) else { + ui.label( + egui::RichText::new(format!( + "Steps here run when the If is {}. Add Step puts a step in this branch.", + if path.ends_with("then") { "true" } else { "false" } + )) + .color(t.text_dim), + ); + return; + }; + let in_repeat = path.contains('.'); + let (icon, cat) = category(&st); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.cat(cat)); + let title = match &st.kind { + StepKind::Command { id, .. } => command_label(id), + StepKind::Repeat { .. } => "Repeat".into(), + StepKind::If { .. } => "If".into(), + StepKind::Stop { .. } => "Stop".into(), + }; + ui.label(egui::RichText::new(format!("Step {path} · {title}")).strong()); + if let StepKind::Command { id, .. } = &st.kind { + ui.label(egui::RichText::new(id).monospace().color(t.text_dim)); + if ui.small_button("Change…").clicked() { + ed.picker = Some(Picker { replace: Some(path.clone()), ..Default::default() }); + } + } + }); + let mut enabled = st.enabled; + let mut bp = st.breakpoint; + ui.horizontal(|ui| { + let a = ui.checkbox(&mut enabled, "Enabled").changed(); + let b = ui.checkbox(&mut bp, "Breakpoint").changed(); + if (a || b) + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.enabled = enabled; + s.breakpoint = bp; + ed.dirty = true; + } + }); + if let Some(e) = ed.errors.get(&path).cloned().or_else(|| static_error(&ed.draft, &st)) { + ui.label(egui::RichText::new(e).color(t.danger)); + } + ui.add_space(4.0); + match st.kind.clone() { + StepKind::Command { id, params } => command_fields(app, ui, d, ed, &path, &id, ¶ms, in_repeat), + StepKind::Repeat { over, of, max, .. } => { + ui.label("For each"); + let mut of2 = of; + ui.horizontal(|ui| { + ui.radio_value(&mut of2, RepeatOf::Rows, "row"); + ui.radio_value(&mut of2, RepeatOf::Columns, "column"); + ui.label("of:"); + }); + ref_field(app, ui, d, ed, (&path, "over"), &over, in_repeat); + let mut max2 = max; + ui.horizontal(|ui| { + ui.label("At most"); + ui.add(egui::DragValue::new(&mut max2).range(1..=100_000)); + ui.label("times"); + }); + if (of2 != of || max2 != max) + && let Some(StepKind::Repeat { of, max, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *of = of2; + *max = max2; + ed.dirty = true; + } + ui.label(egui::RichText::new("Inside, Row (or Column) stands for the current one, e.g. Row.right.").small().color(t.text_dim)); + } + StepKind::If { cell, op, value, .. } => { + ui.label("If cell"); + ref_field(app, ui, d, ed, (&path, "cell"), &cell, in_repeat); + let mut op2 = op; + let ops = [ + (CompareOp::Eq, "equals"), + (CompareOp::Ne, "doesn't equal"), + (CompareOp::Gt, "is greater than"), + (CompareOp::Ge, "is at least"), + (CompareOp::Lt, "is less than"), + (CompareOp::Le, "is at most"), + (CompareOp::Empty, "is empty"), + (CompareOp::NotEmpty, "isn't empty"), + (CompareOp::Contains, "contains"), + ]; + let label = ops.iter().find(|(o, _)| *o == op).map_or("", |x| x.1); + craft_ui::ComboBox::from_id_salt(("me_op", &path)).selected_text(label).show_ui(ui, |ui| { + for (o, l) in ops { + ui.selectable_value(&mut op2, o, l); + } + }); + let mut text = match &value { + Json::String(s) => s.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let changed_value = if matches!(op2, CompareOp::Empty | CompareOp::NotEmpty) { + false + } else { + ui.add(egui::TextEdit::singleline(&mut text).hint_text("value").id_salt(("me_ifv", &path))).changed() + }; + if (op2 != op || changed_value) + && let Some(StepKind::If { op, value, .. }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *op = op2; + if changed_value { + *value = scalar(&text); + } + ed.dirty = true; + } + } + StepKind::Stop { message, rollback } => { + let (mut m, mut rb) = (message.clone(), rollback); + ui.label("Message:"); + let a = ui.add(egui::TextEdit::singleline(&mut m).desired_width(f32::INFINITY).id_salt(("me_stop", &path))).changed(); + let b = ui.checkbox(&mut rb, "Undo what the macro did before stopping").changed(); + if (a || b) + && let Some(StepKind::Stop { message, rollback }) = step_mut(&mut ed.draft.steps, &path).map(|s| &mut s.kind) + { + *message = m; + *rollback = rb; + ed.dirty = true; + } + } + } + ui.add_space(6.0); + ui.label("Note:"); + let mut note = st.note.clone(); + if ui.add(egui::TextEdit::multiline(&mut note).desired_rows(2).desired_width(f32::INFINITY).id_salt(("me_note", &path))).changed() + && let Some(s) = step_mut(&mut ed.draft.steps, &path) + { + s.note = note; + ed.dirty = true; + } +} + +/// Typed text as a JSON value: a number, TRUE/FALSE, or text. +fn scalar(text: &str) -> Json { + let t = text.trim(); + if let Ok(n) = t.parse::() + && n.is_finite() + { + return json!(n); + } + match t.to_ascii_lowercase().as_str() { + "true" => json!(true), + "false" => json!(false), + _ => json!(text), + } +} + +#[allow(clippy::too_many_arguments)] +fn command_fields(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, path: &str, id: &str, params: &Json, in_repeat: bool) { + let t = Tokens::get(ui.ctx()); + let fields: Vec<(String, Json)> = params.as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); + let mut remove: Option = None; + let mut set: Option<(String, Json)> = None; + egui::Grid::new(("me_params", path)).num_columns(3).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + for (k, v) in &fields { + ui.label(k.as_str()); + match v { + Json::String(s) if resolve::is_ref_key(k) => { + ui.vertical(|ui| ref_field(app, ui, d, ed, (path, k), s, in_repeat)); + } + Json::String(s) => { + let mut text = s.clone(); + if ui.add(egui::TextEdit::singleline(&mut text).desired_width(220.0).id_salt(("me_p", path, k))).changed() { + set = Some((k.clone(), json!(text))); + } + } + Json::Bool(b) => { + let mut on = *b; + if ui.checkbox(&mut on, "").changed() { + set = Some((k.clone(), json!(on))); + } + } + Json::Number(n) => { + let mut x = n.as_f64().unwrap_or(0.0); + if ui.add(egui::DragValue::new(&mut x).speed(0.1)).changed() { + set = Some((k.clone(), json!(x))); + } + } + other => { + let jk = format!("json:{path}:{k}"); + let mut text = d.values.get(&jk).and_then(Json::as_str).map(str::to_string).unwrap_or_else(|| other.to_string()); + let ok = serde_json::from_str::(&text).is_ok(); + let r = ui.add( + egui::TextEdit::singleline(&mut text) + .desired_width(220.0) + .font(egui::TextStyle::Monospace) + .text_color(if ok { t.text } else { t.danger }) + .id_salt(("me_pj", path, k)), + ); + if r.changed() { + if let Ok(v) = serde_json::from_str::(&text) { + set = Some((k.clone(), v)); + } + d.values.insert(jk, json!(text)); + } + } + } + if crate::widgets::icon_button(ui, Icon::Close, t.text_dim, "Remove this parameter", vec2(18.0, 18.0)).clicked() { + remove = Some(k.clone()); + } + ui.end_row(); + } + }); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) { + if !params.is_object() { + *params = json!({}); + } + if let Some(o) = params.as_object_mut() { + if let Some((k, v)) = set { + o.insert(k, v); + ed.dirty = true; + } + if let Some(k) = remove { + o.remove(&k); + ed.dirty = true; + } + } + } + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut ed.new_param.0).hint_text("parameter").desired_width(90.0).id_salt("me_np_k")); + ui.add(egui::TextEdit::singleline(&mut ed.new_param.1).hint_text("value").desired_width(140.0).id_salt("me_np_v")); + let key = ed.new_param.0.trim().to_string(); + if ui.add_enabled(!key.is_empty(), egui::Button::new("Add")).clicked() { + let raw = ed.new_param.1.clone(); + let v = serde_json::from_str::(&raw).unwrap_or(Json::String(raw)); + if let Some(StepKind::Command { params, .. }) = step_mut(&mut ed.draft.steps, path).map(|s| &mut s.kind) + && let Some(o) = params.as_object_mut() + { + o.insert(key, v); + ed.dirty = true; + } + ed.new_param = Default::default(); + } + }); + if let Some(spec) = gridcraft_engine::find_command(id) + && !spec.params.is_empty() + { + ui.add_space(4.0); + ui.add(egui::Label::new(egui::RichText::new(spec.params).small().color(t.text_dim)).wrap()); + } +} + +fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("The ranges the macro works on. Each is checked before the macro runs.").small().color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for (k, i) in ed.draft.inputs.iter().enumerate() { + if ui.selectable_label(ed.input == k, i.name.as_str()).clicked() { + ed.input = k; + } + } + if ui.small_button("Add").clicked() && ed.draft.inputs.len() < 64 { + let n = (2..).map(|k| format!("Input{k}")).find(|n| !ed.draft.inputs.iter().any(|i| i.name.eq_ignore_ascii_case(n))).unwrap_or_default(); + ed.draft.inputs.push(NamedInput { name: n, ..Default::default() }); + ed.input = ed.draft.inputs.len() - 1; + ed.dirty = true; + } + if ui.add_enabled(!ed.draft.inputs.is_empty(), egui::Button::new("Remove").small()).clicked() && ed.input < ed.draft.inputs.len() { + ed.draft.inputs.remove(ed.input); + ed.input = ed.input.saturating_sub(1); + ed.dirty = true; + } + }); + let k = ed.input; + let Some(mut i) = ed.draft.inputs.get(k).cloned() else { + ui.label(egui::RichText::new("No inputs: the macro works on fixed cells only.").color(t.text_dim)); + return; + }; + let before = i.clone(); + ui.separator(); + egui::Grid::new("me_input").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut i.name).desired_width(140.0).id_salt("me_in_name")); + ui.end_row(); + ui.label("Shape:"); + ui.horizontal(|ui| { + ui.radio_value(&mut i.kind, InputKind::Range, "range"); + ui.radio_value(&mut i.kind, InputKind::Cell, "single cell"); + }); + ui.end_row(); + if i.kind == InputKind::Range { + ui.label("Data rows:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.rows[0]).range(0..=1_048_576).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.rows[1]) + .range(0..=1_048_576) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label("Columns:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut i.cols[0]).range(0..=16_384).prefix("at least ")); + ui.add( + egui::DragValue::new(&mut i.cols[1]) + .range(0..=16_384) + .prefix("at most ") + .custom_formatter(|v, _| if v == 0.0 { "any".into() } else { format!("{v}") }), + ); + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut i.header, "First row holds column names"); + ui.end_row(); + } + ui.label(""); + ui.checkbox(&mut i.non_empty, "Every cell must hold something"); + ui.end_row(); + }); + // Column types, one per column of the example (or of the list so far). + let ncols = if k == 0 { parse_refs(&ed.example).first().map_or(0, |(_, r)| r.width() as usize) } else { 0 }.max(i.col_types.len()).min(16); + if i.kind == InputKind::Range && ncols > 0 { + ui.label("Each column must hold:"); + let headers = if k == 0 { example_headers(app, &ed.example, i.header) } else { vec![] }; + egui::Grid::new("me_types").num_columns(2).show(ui, |ui| { + for c in 0..ncols { + let label = headers.get(c).filter(|h| !h.is_empty()).cloned().unwrap_or_else(|| format!("Column {}", c + 1)); + ui.label(label); + let mut ty = i.col_types.get(c).copied().unwrap_or(ColType::Any); + let before_ty = ty; + craft_ui::ComboBox::from_id_salt(("me_ty", c)).selected_text(type_name(ty)).show_ui(ui, |ui| { + for x in [ColType::Any, ColType::Number, ColType::Text, ColType::Date, ColType::Bool] { + ui.selectable_value(&mut ty, x, type_name(x)); + } + }); + if ty != before_ty { + while i.col_types.len() <= c { + i.col_types.push(ColType::Any); + } + if let Some(slot) = i.col_types.get_mut(c) { + *slot = ty; + } + } + ui.end_row(); + } + }); + } + if i != before { + tidy_input(&mut i, &before); + if let Some(slot) = ed.draft.inputs.get_mut(k) { + *slot = i; + } + ed.dirty = true; + } + if k == 0 { + ui.separator(); + ui.label("Example (what Check, Run and Debug use here):"); + let example = ed.example.clone(); + ref_field(app, ui, d, ed, ("@example", "example"), &example, false); + ui.horizontal(|ui| { + if ui.button("Use current selection as example").clicked() { + use_selection_as_example(app, ed); + } + let mut fixed = ed.draft.inputs.first().is_some_and(|i| i.default_ref.is_some()); + if ui.checkbox(&mut fixed, "Always run on this range").on_hover_text("Otherwise the macro runs on the selection").changed() + && let Some(first) = ed.draft.inputs.first_mut() + { + first.default_ref = fixed.then(|| ed.example.clone()); + ed.dirty = true; + } + }); + } +} + +fn type_name(t: ColType) -> &'static str { + match t { + ColType::Any => "anything", + ColType::Number => "numbers", + ColType::Text => "text", + ColType::Date => "dates", + ColType::Bool => "TRUE or FALSE", + } +} + +/// The header texts of the example's first row (when it has a header). +fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { + let (Some(doc), true) = (app.session.active(), header) else { return vec![] }; + let wb = &doc.wb; + let Some((sheet, r)) = parse_refs(example).into_iter().next() else { return vec![] }; + let si = sheet.and_then(|s| wb.sheet_index(&s)).unwrap_or(wb.active_sheet()); + let Some(sh) = wb.sheet(si) else { return vec![] }; + (r.start.col..=r.end.col.min(r.start.col.saturating_add(15))) + .map(|c| gridcraft_engine::display::cell_text(wb, sh, gridcraft_engine::core::CellRef::new(r.start.row, c))) + .collect() +} + +/// Keeps an input the person is editing to the shapes a macro may declare, as the edit is made: +/// a single cell is one row and one column with no header and at most one column type; a least +/// above its most moves the other end along (the one just set wins); and a range lists no more +/// column types than it allows columns. +pub fn tidy_input(i: &mut NamedInput, before: &NamedInput) { + if i.kind == InputKind::Cell { + (i.rows, i.cols, i.header) = ([1, 1], [1, 1], false); + i.col_types.truncate(1); + return; + } + for (now, was) in [(&mut i.rows, before.rows), (&mut i.cols, before.cols)] { + if now[1] != 0 && now[0] > now[1] { + if now[0] != was[0] { + now[1] = now[0]; + } else { + now[0] = now[1]; + } + } + } + if i.cols[1] != 0 { + i.col_types.truncate(i.cols[1] as usize); + } +} + +/// "Use current selection as example": the selection becomes the first input's example, and its +/// shape, header and column types are read from it. +pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { + let Some(doc) = app.session.active() else { return }; + let r = doc.selection.current(); + let Some(sh) = doc.wb.active() else { return }; + ed.example = r.a1(); + let Some(first) = ed.draft.inputs.first_mut() else { return }; + let inferred = infer_input(sh, r); + let before = first.clone(); + first.kind = if r.is_single() { InputKind::Cell } else { InputKind::Range }; + first.header = inferred.0; + first.col_types = inferred.1; + first.cols = [r.width(), r.width()]; + first.rows = [1, 0]; + tidy_input(first, &before); + if first.default_ref.is_some() { + first.default_ref = Some(ed.example.clone()); + } + ed.dirty = true; +} + +/// Whether the first row of `r` looks like a header (all text over a row that isn't), and what +/// each column holds below it. +pub fn infer_input(sh: &gridcraft_engine::model::Sheet, r: RangeRef) -> (bool, Vec) { + use gridcraft_engine::core::{CellRef, Value}; + let kind = |v: &Value| match v { + Value::Number(_) => Some(ColType::Number), + Value::Text(_) => Some(ColType::Text), + Value::Bool(_) => Some(ColType::Bool), + _ => None, + }; + let rows = r.height().min(2_000); + let cols = r.width().min(16); + let first: Vec> = (0..cols).map(|c| kind(&sh.value(CellRef::new(r.start.row, r.start.col + c)))).collect(); + let header = rows > 1 + && first.iter().all(|k| *k == Some(ColType::Text)) + && (0..cols).any(|c| matches!(kind(&sh.value(CellRef::new(r.start.row + 1, r.start.col + c))), Some(ColType::Number | ColType::Bool))); + let start = r.start.row + u32::from(header); + let types = (0..cols) + .map(|c| { + let mut seen: Option = None; + for row in start..r.start.row + rows { + match (kind(&sh.value(CellRef::new(row, r.start.col + c))), seen) { + (None, _) => {} + (Some(k), None) => seen = Some(k), + (Some(k), Some(s)) if k == s => {} + _ => return ColType::Any, + } + } + seen.unwrap_or(ColType::Any) + }) + .collect(); + (header, types) +} + +fn output_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { + let t = Tokens::get(ui.ctx()); + ui.label("Output anchor (what the Output token stands for):"); + let anchor = ed.draft.output.anchor.clone().unwrap_or_default(); + ref_field(app, ui, d, ed, ("@output", "anchor"), &anchor, false); + ui.label( + egui::RichText::new("Leave it empty when no step uses Output. A token such as Input.right keeps it beside the data.") + .small() + .color(t.text_dim), + ); + ui.add_space(6.0); + ui.label("When results would land on cells that hold data:"); + let mut rule = ed.draft.output.on_conflict; + for (r, label) in [ + (OnConflict::Ask, "Ask (overwrite, shift down or shift right)"), + (OnConflict::Fail, "Don't run"), + (OnConflict::Overwrite, "Overwrite the data"), + (OnConflict::ShiftDown, "Shift the data down"), + (OnConflict::ShiftRight, "Shift the data right"), + ] { + ui.radio_value(&mut rule, r, label); + } + if rule != ed.draft.output.on_conflict { + ed.draft.output.on_conflict = rule; + ed.dirty = true; + } + ui.label(egui::RichText::new("Agents and other programs that run the macro get an error instead of the question.").small().color(t.text_dim)); + if !ed.draft.permissions.is_empty() { + ui.add_space(6.0); + ui.label(format!("May use files through: {}", ed.draft.permissions.join(", "))); + } +} + +// ---------------------------------------------------------------- grid overlays + +/// What the grid shows while the editor is open: the inputs (blue), the cells the macro writes +/// (green), the ones among them that hold data (red), and the current step's target (outlined). +/// Each range is on the named sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MacroOverlay { + pub inputs: Vec<(String, RangeRef)>, + pub footprint: Vec<(String, RangeRef)>, + pub conflicts: Vec<(String, RangeRef)>, + pub target: Vec<(String, RangeRef)>, +} + +impl MacroOverlay { + fn is_empty(&self) -> bool { + self.inputs.is_empty() && self.footprint.is_empty() && self.conflicts.is_empty() && self.target.is_empty() + } +} + +fn named_refs(text: &str, default_sheet: &str) -> Vec<(String, RangeRef)> { + parse_refs(text).into_iter().map(|(s, r)| (s.unwrap_or_else(|| default_sheet.to_string()), r)).collect() +} + +/// The overlay for the editor's last check and debugger state. +pub fn overlay(app: &SheetApp, ed: &Editor) -> Option { + let sheet = app.session.active()?.wb.active()?.name.clone(); + let mut o = MacroOverlay::default(); + if let Some(c) = &ed.check { + for v in c["inputs"].as_object().into_iter().flat_map(|m| m.values()).filter_map(Json::as_str) { + o.inputs.extend(named_refs(v, &sheet)); + } + for s in c["footprint"]["sheets"].as_array().into_iter().flatten() { + let name = s["sheet"].as_str().unwrap_or(&sheet).to_string(); + for v in s["cells"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.footprint.extend(named_refs(v, &name)); + } + for v in s["conflicts"].as_array().into_iter().flatten().filter_map(Json::as_str) { + o.conflicts.extend(named_refs(v, &name)); + } + } + } + if let Some(st) = &ed.debug { + let cur = &st["current"]; + if let Some(p) = cur["params"].as_object() { + for (k, v) in p { + if resolve::is_ref_key(k) + && let Some(s) = v.as_str() + { + o.target.extend(named_refs(s, &sheet)); + } + } + } + for k in ["over", "cell"] { + if let Some(s) = cur[k].as_str() { + o.target.extend(named_refs(s, &sheet)); + } + } + } + (!o.is_empty()).then_some(o) +} + +/// One overlay layer: its ranges, colour, wash alpha and stroke width. +type OverlayLayer<'a> = (&'a Vec<(String, RangeRef)>, Color32, u8, f32); + +/// Paints the overlay's ranges on `sh` (called from the grid's overlay pass). +pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_engine::model::Sheet, o: &MacroOverlay) { + let t = Tokens::get(p.ctx()); + let wash = |c: Color32, a: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); + let layers: [OverlayLayer; 3] = [ + (&o.footprint, t.sheet_macro_footprint, 30, 1.5), + (&o.inputs, t.sheet_macro_input, 26, 2.0), + (&o.conflicts, t.sheet_macro_conflict, 60, 2.0), + ]; + for (list, c, alpha, width) in layers { + for (name, r) in list.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r); + if !p.clip_rect().intersects(rect) { + continue; + } + p.rect_filled(rect, 0.0, wash(c, alpha)); + p.rect_stroke(rect, 0.0, Stroke::new(width, c), StrokeKind::Inside); + let _ = name; + } + } + for (_, r) in o.target.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&sh.name)) { + let rect = geo.range_rect(sh, *r).expand(1.0); + p.rect_stroke(rect, 1.0, Stroke::new(2.5, t.sheet_macro_target), StrokeKind::Outside); + for corner in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + p.rect_filled(Rect::from_center_size(corner, vec2(5.0, 5.0)), 0.0, t.sheet_macro_target); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_edited_input_keeps_a_shape_a_macro_may_declare() { + let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() }; + // Switching a range to a single cell drops what a cell can't have. + let mut cell = NamedInput { kind: InputKind::Cell, ..range.clone() }; + tidy_input(&mut cell, &range); + assert_eq!((cell.rows, cell.cols, cell.header, cell.col_types.len()), ([1, 1], [1, 1], false, 1)); + // A least set above the most moves the most up; a most set below the least moves the least. + let mut i = NamedInput { rows: [12, 10], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [12, 12]); + let mut i = NamedInput { rows: [2, 1], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!(i.rows, [1, 1]); + // At most 0 is no limit; fewer columns drop the column types past them. + let mut i = NamedInput { rows: [12, 0], cols: [1, 2], ..range.clone() }; + tidy_input(&mut i, &range); + assert_eq!((i.rows, i.cols, i.col_types.len()), ([12, 0], [1, 2], 2)); + // Each passes the format's own check. + for input in [cell, i] { + let m = Macro { name: "M".into(), inputs: vec![input], ..Macro::default() }; + assert_eq!(m.check_limits(), Ok(()), "{m:?}"); + } + } + + fn steps() -> Vec { + vec![ + Step::command("cell.set", json!({"cell": "A1", "input": "x"})), + Step::new(StepKind::Repeat { + over: "Input".into(), + of: RepeatOf::Rows, + max: 10, + body: vec![Step::command("cell.set", json!({"cell": "Row.right", "input": "a"})), Step::command("home.bold", json!({}))], + }), + Step::new(StepKind::If { + cell: "B1".into(), + op: CompareOp::Empty, + value: Json::Null, + then: vec![Step::new(StepKind::Stop { message: "empty".into(), rollback: false })], + otherwise: vec![], + }), + ] + } + + #[test] + fn paths_reach_nested_steps() { + let mut s = steps(); + assert_eq!(split_path("3.then.1"), Some(("3.then", 0))); + assert_eq!(split_path("3.then"), None); + assert_eq!(split_path("0"), None); + assert!(matches!(step_at(&s, "2.2").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert!(matches!(step_at(&s, "3.then.1").map(|s| s.kind), Some(StepKind::Stop { .. }))); + assert!(step_at(&s, "3.else.1").is_none()); + assert!(step_at(&s, "1.1").is_none(), "a command has no body"); + assert!(step_at(&s, "9").is_none()); + assert!(list_mut(&mut s, "3.else").is_some_and(|l| l.is_empty())); + } + + #[test] + fn rows_flatten_with_branches() { + let mut out = Vec::new(); + rows(&steps(), "", 0, &mut out); + let keys: Vec<&str> = out.iter().map(|r| r.key.as_str()).collect(); + assert_eq!(keys, ["1", "2", "2.1", "2.2", "3", "3.then", "3.then.1", "3.else"]); + assert_eq!(out.iter().find(|r| r.key == "2.2").map(|r| (r.parent.as_str(), r.index, r.depth)), Some(("2", 1, 1))); + assert_eq!(out.iter().find(|r| r.key == "3.then").and_then(|r| r.branch), Some("Then")); + } + + #[test] + fn moving_a_step_within_its_list() { + let mut s = steps(); + // Step 1 dropped after step 2 (before row 2 of 0..3). + assert_eq!(move_step(&mut s, "", 0, 2).as_deref(), Some("2")); + assert!(matches!(&s[0].kind, StepKind::Repeat { .. })); + // Inside the Repeat: the second step to the top. + assert_eq!(move_step(&mut s, "1", 1, 0).as_deref(), Some("1.1")); + assert!(matches!(step_at(&s, "1.1").map(|s| s.kind), Some(StepKind::Command { id, .. }) if id == "home.bold")); + assert_eq!(move_step(&mut s, "", 7, 0), None); + // Past the end lands last. + assert_eq!(move_step(&mut s, "", 0, 99).as_deref(), Some("3")); + } + + #[test] + fn breakpoints_are_listed_by_path() { + let mut s = steps(); + for p in ["1", "2.2", "3.then.1"] { + if let Some(st) = step_mut(&mut s, p) { + st.breakpoint = true; + } + } + let mut out = Vec::new(); + breakpoints(&s, "", &mut out); + assert_eq!(out, ["1", "2.2", "3.then.1"]); + } + + #[test] + fn summaries_and_categories() { + let s = steps(); + assert_eq!(summary(&s[0]), "Set A1 to x"); + assert_eq!(summary(&s[1]), "Repeat for each row of Input"); + assert_eq!(summary(&s[2]), "If B1 is empty"); + assert_eq!(category(&s[0]), (Icon::Sheet, Cat::Blue)); + assert_eq!(category(&s[1]).1, Cat::Purple); + let long = Step::command("home.bold", json!({"range": "A1:B2"})); + assert!(summary(&long).contains("range: A1:B2"), "{}", summary(&long)); + } + + #[test] + fn static_errors_flag_unknown_and_denied_commands() { + let m = Macro::default(); + assert!(static_error(&m, &Step::command("no.such", json!({}))).is_some_and(|e| e.contains("no command"))); + assert!(static_error(&m, &Step::command("file.save", json!({}))).is_some_and(|e| e.contains("can't run in a macro"))); + assert!(static_error(&m, &Step::command("data.getData", json!({"path": "/tmp/x.csv"}))).is_some_and(|e| e.contains("permissions"))); + assert_eq!(static_error(&m, &Step::command("cell.set", json!({"cell": "A1"}))), None); + } + + #[test] + fn required_keys_come_from_the_params_doc() { + assert_eq!(required_keys("{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}"), ["cell", "input"]); + assert_eq!(required_keys("{range?, options: {a, b}, on?: bool}"), ["range", "options"]); + assert!(required_keys("no braces").is_empty()); + assert!(required_keys("{").is_empty()); + } + + #[test] + fn the_picker_offers_control_steps_and_allowed_commands_only() { + let all = picker_items(""); + assert_eq!(all.first().map(|x| x.0.as_str()), Some("@repeat")); + // Denied commands aren't offered (exports without a path are allowed). + let all = picker_items("e"); + assert!(!all.iter().any(|x| x.0 == "file.save" || x.0 == "edit.undo" || x.0.starts_with("macro."))); + let bold = picker_items("bold"); + assert!(bold.iter().any(|x| x.0 == "home.bold")); + assert!(matches!(new_step("@if").kind, StepKind::If { .. })); + assert!(matches!(new_step("cell.set").kind, StepKind::Command { params, .. } if params.get("cell").is_some())); + } + + #[test] + fn tokens_for_ranges_resolve_back_to_them() { + let wb = Workbook::default(); + let input = RangeRef::parse("B2:D9").unwrap_or(RangeRef::cell(gridcraft_engine::core::CellRef::new(0, 0))); + let inputs = vec![("Input".to_string(), Place { sheet: 0, range: input }, false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (target, want) in [ + ("B2:D9", "Input"), + ("C2:C9", "Input.col(2)"), + ("B2:C9", "Input.cols(1..2)"), + ("B3:D9", "Input.rows(2..8)"), + ("B4:D4", "Input.row(3)"), + ("C3", "Input.cell(2,2)"), + ("C3:D4", "Input.cell(2,2).resize(2,2)"), + ("F2:H9", "Input.offset(0,4)"), + ("E2", "Input.cell(1,1).offset(0,3)"), + ("E2:E9", "Input.cell(1,1).offset(0,3).resize(8,1)"), + ] { + let r = RangeRef::parse(target).unwrap_or(input); + let tok = token_for("Input", input, r); + assert_eq!(tok, want, "{target}"); + assert_eq!(resolve::resolve_ref(&tok, &env).map(|p| p.range), Ok(r), "{tok}"); + } + } + + #[test] + fn references_parse_with_sheets_and_dollars() { + let refs = parse_refs("'My Sheet'!$B$2:$C$3, D4"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].0.as_deref(), Some("My Sheet")); + assert_eq!(refs[0].1.a1(), "B2:C3"); + assert_eq!(refs[1], (None, RangeRef::parse("D4").unwrap_or(refs[0].1))); + assert!(parse_refs("not a ref").is_empty()); + } + + #[test] + fn inputs_are_inferred_from_an_example() { + use gridcraft_engine::core::{CellRef, Value}; + let mut sh = gridcraft_engine::model::Sheet::new("S"); + for (c, h) in ["Item", "Qty"].iter().enumerate() { + sh.set_value(CellRef::new(0, c as u32), Value::Text((*h).into())); + } + for r in 1..4 { + sh.set_value(CellRef::new(r, 0), Value::Text(format!("x{r}").into())); + sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r))); + } + let (header, types) = infer_input(&sh, RangeRef::parse("A1:B4").unwrap_or(RangeRef::cell(CellRef::new(0, 0)))); + assert!(header); + assert_eq!(types, [ColType::Text, ColType::Number]); + } +} diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs new file mode 100644 index 000000000..4277721b8 --- /dev/null +++ b/crates/ui-egui/src/macro_ui.rs @@ -0,0 +1,677 @@ +//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status +//! bar), running a macro and answering its questions (approve it, make room for its output), the +//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record +//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes +//! through the `macro.*` and `automate.*` commands. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::dialogs::Dialog; +use crate::theme::{self, Tokens}; + +/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree. +pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text}; + +/// How many macros the Run Macro menu keeps as recent. +const RECENT: usize = 8; + +/// Macro state that belongs to the window, not the workbook. +#[derive(Clone, Debug)] +pub struct MacroUi { + /// Use Relative References: record steps relative to the selection when recording started. + pub relative: bool, + /// Macros run from this window, most recent first (Run Macro's menu). + pub recent: Vec, + /// A question from a macro command, answered with one of its buttons. + pub question: Option, + /// The step editor, while it is open. + pub editor: Option, + /// A debugger result from a question's button, for the editor to take in. + pub debug_result: Option, +} + +impl Default for MacroUi { + fn default() -> Self { + MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None } + } +} + +impl MacroUi { + fn note_recent(&mut self, name: &str) { + self.recent.retain(|n| !n.eq_ignore_ascii_case(name)); + self.recent.insert(0, name.to_string()); + self.recent.truncate(RECENT); + } +} + +/// A question from a command (approve this macro? overwrite the data in the way?). Each choice +/// runs the command again with its parameters added; Cancel drops it. +#[derive(Clone, Debug)] +pub struct Question { + /// The workbook (`DocState::uid`) the question is about: the answer runs only there. + pub doc: u64, + pub title: String, + pub text: String, + pub command: String, + pub params: Json, + pub choices: Vec<(String, Json)>, +} + +/// The macros of the active workbook: (name, description, shortcut). +pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option)> { + app.session + .active() + .map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone(), m.shortcut.clone())).collect()) + .unwrap_or_default() +} + +/// How many commands the recorder has captured, while recording. +pub fn recorded_steps(app: &SheetApp) -> Option { + app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped)) +} + +/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was +/// recorded as a macro. +pub fn toggle_recording(app: &mut SheetApp) { + if app.session.is_recording() { + stop_recording(app); + } else { + app.open_dialog("recordMacro", json!({})); + } +} + +/// Stops recording and says what was saved. +pub fn stop_recording(app: &mut SheetApp) { + match app.run("macro.record", json!({"stop": true})) { + Ok(v) => match v["saved"].as_str() { + Some(name) => { + let steps = v["steps"].as_u64().unwrap_or(0); + app.macros.note_recent(name); + let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }); + if let Some(k) = v["shortcutDropped"].as_str() { + text.push_str(&format!(" without {k}: another macro took it while recording")); + } + app.toast = Some((text, crate::now_ms())); + } + None => app.toast = Some(("Recording stopped".into(), crate::now_ms())), + }, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } +} + +/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt. +pub fn run_macro(app: &mut SheetApp, name: &str) { + call(app, "Run Macro", "macro.run", json!({"name": name})); +} + +/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in +/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran. +pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option { + match app.run(id, params.clone()) { + Ok(v) => { + note_result(app, id, ¶ms, &v); + Some(v) + } + Err(e) => { + if !ask(app, title, id, ¶ms, &e) { + app.message = Some((title.into(), crate::clean_error(&e))); + } + None + } + } +} + +/// The question in an engine error that asks something: `approve: …` or `confirm: …`. +fn question_text(e: &str, prefix: &str) -> Option { + let rest = e.split_once(prefix)?.1; + Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string()) +} + +/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro +/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land +/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question. +pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool { + let (text, choices) = if let Some(q) = question_text(e, "approve: ") { + (q, vec![("Run".to_string(), json!({"approve": true}))]) + } else if let Some(q) = question_text(e, "confirm: ") { + if id.starts_with("macro.") { + // The engine's hint for programs ("run again with onConflict …") becomes the buttons. + let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']); + ( + format!("{what}. Overwrite them, or move the data out of the way?"), + vec![ + ("Overwrite".to_string(), json!({"onConflict": "overwrite"})), + ("Shift Down".to_string(), json!({"onConflict": "shiftDown"})), + ("Shift Right".to_string(), json!({"onConflict": "shiftRight"})), + ], + ) + } else { + (q, vec![("OK".to_string(), json!({"overwrite": true}))]) + } + } else { + return false; + }; + let doc = app.session.active().map_or(0, |d| d.uid); + app.macros.question = Some(Question { doc, title: title.into(), text, command: id.into(), params: params.clone(), choices }); + true +} + +/// After a macro command ran (directly or from a prompt's button): remember the macro and say +/// what it did. +pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { + if id == "macro.debug" { + app.macros.debug_result = Some(v.clone()); + return; + } + if id != "macro.run" { + return; + } + let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); + if name.is_empty() { + return; + } + app.macros.note_recent(&name); + let ran = v["ran"].as_u64().unwrap_or(0); + let text = match v["stopped"].as_str() { + Some(msg) => format!("Macro “{name}” stopped: {msg}"), + None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }), + }; + app.toast = Some((text, crate::now_ms())); +} + +/// Drops the step editor once its workbook is closed. The editor's dialog stays with its +/// workbook's window, so it may not be drawn again to notice. +pub fn forget_closed_editor(app: &mut SheetApp) { + if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) { + app.macros.editor = None; + app.grid.macro_overlay = None; + } +} + +/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel. +pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { + if app.message.is_some() { + return; + } + let Some(q) = app.macros.question.clone() else { return }; + let mut answer: Option> = None; + egui::Modal::new(egui::Id::unique("gridcraft.macro_question")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label(egui::RichText::new(&q.title).strong()); + ui.add(egui::Label::new(&q.text).wrap()); + 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); + } + // Right to left: list the choices reversed, so the first one ends up next to Cancel + // and Enter picks it. + for (k, (label, extra)) in q.choices.iter().enumerate().rev() { + if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked() + || (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + answer = Some(Some(extra.clone())); + } + } + }); + }); + match answer { + // Another workbook became active meanwhile (an agent): the answer would run there. + Some(Some(_)) if app.session.active().is_none_or(|d| d.uid != q.doc) => { + app.macros.question = None; + let book = app.session.documents().iter().find(|d| d.uid == q.doc).map(|d| d.display_title()).unwrap_or_default(); + app.message = + Some((q.title.clone(), format!("The question was about “{book}”, which is no longer the active workbook, so nothing was done."))); + } + Some(Some(extra)) => { + app.macros.question = None; + let mut params = q.params.clone(); + if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) { + m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + // A second question (approve, then make room) asks again. + call(app, &q.title, &q.command, params); + } + Some(None) => app.macros.question = None, + None => {} + } +} + +/// The status bar while a macro is debugged ("Debugging “Name”", with Show when the editor is +/// closed) and while recording (a red dot, "Recording · 7 steps" and Stop). +pub fn status(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if let Some(name) = app.session.macro_debug.as_ref().map(|d| d.name.clone()) { + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.breakpoint); + ui.label(egui::RichText::new(format!("Debugging “{name}”")).font(theme::ui_font(12.5)).color(t.text)); + if app.macros.editor.is_none() { + let show = + ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Show")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if show.on_hover_text("Open the macro in the step editor").clicked() { + app.open_dialog("macroEditor", json!({"macro": name})); + } + } + } + let Some(n) = recorded_steps(app) else { return }; + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.recording); + let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" }); + ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text)); + let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if stop.on_hover_text("Stop recording and save the macro").clicked() { + stop_recording(app); + } +} + +// ---------------------------------------------------------------- shortcuts + +/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own +/// shortcuts win over a macro's (a file can carry any letter). +pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option { + let letter = parse_shortcut(key.name())?; + if RESERVED_SHORTCUTS.iter().any(|(c, _)| *c == letter) { + return None; + } + let wb = &app.session.active()?.wb; + wb.macros.iter().find(|m| m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)).map(|m| m.name.clone()) +} + +/// Ctrl+Shift+letter on the grid: runs the macro with that shortcut. True when one ran. +pub fn run_shortcut(app: &mut SheetApp, key: egui::Key) -> bool { + let Some(name) = shortcut_macro(app, key) else { return false }; + run_macro(app, &name); + true +} + +// ---------------------------------------------------------------- Macros dialog + +/// What a row of the Macros dialog is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Macro, + /// An Automate script (`automate.*`). + Script, +} + +impl Kind { + fn key(self) -> &'static str { + match self { + Kind::Macro => "macro", + Kind::Script => "script", + } + } +} + +/// A row of the Macros dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub name: String, + pub kind: Kind, + pub description: String, + pub shortcut: Option, + pub steps: u64, +} + +/// The Macros dialog's rows: the workbook's macros, then its Automate scripts (from +/// `view.macros`, kept in the dialog's result), and whether the file carries a VBA project. +pub fn entries(app: &SheetApp, d: &Dialog) -> (Vec, bool) { + let mut out: Vec = app + .session + .active() + .map(|doc| { + doc.wb + .macros + .iter() + .map(|m| Entry { + name: m.name.clone(), + kind: Kind::Macro, + description: m.description.clone(), + shortcut: m.shortcut.clone(), + steps: m.step_count() as u64, + }) + .collect() + }) + .unwrap_or_default(); + let mut vba = false; + for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() { + if it.get("kind").and_then(Json::as_str) == Some("vba") { + vba = true; + continue; + } + let Some(name) = it.get("name").and_then(Json::as_str) else { continue }; + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps }); + } + (out, vba) +} + +/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros` +/// (the Automate scripts and a file's VBA project), as before macros existed. +pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { + let names = macro_list(app); + let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned(); + let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default(); + let doc = app.session.active().map_or(0, |d| d.uid); + let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro", "doc": doc})); + d.result = app.session.run("view.macros", json!({})).ok(); + d +} + +/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the +/// description under them; Options… switches to the shortcut and description of one macro. +pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + // Its rows and the Options pane are the workbook's it opened on: another workbook becoming + // active (an agent) closes it rather than run, delete or change a macro of the same name there. + let active = app.session.active().map(|doc| doc.uid); + if d.values.get("doc").and_then(Json::as_u64).is_some_and(|doc| Some(doc) != active) { + *open = false; + return; + } + // Scripts can change while the dialog is open (Delete): read them again when the workbook does. + let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0); + if d.values.get("revision").and_then(Json::as_u64) != Some(rev) { + d.result = app.session.run("view.macros", json!({})).ok(); + d.values.insert("revision".into(), json!(rev)); + } + if d.values.get("options").and_then(Json::as_bool) == Some(true) { + options(app, ui, d); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + let untrusted = d + .result + .as_ref() + .and_then(Json::as_array) + .is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); + if untrusted { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + let (list, vba) = entries(app, d); + let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string(); + let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro }; + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(330.0); + let label = ui.label("Macro name:"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).labelled_by(label.id).changed() { + kind = Kind::Macro; + } + egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(326.0, 200.0)); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + if list.is_empty() { + ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim)); + } + for e in &list { + let label = match (e.kind, &e.shortcut) { + (Kind::Macro, Some(k)) => format!("{} {k}", e.name), + (Kind::Macro, None) => e.name.clone(), + (Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }), + }; + let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim()); + let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0))); + if r.clicked() { + name = e.name.clone(); + kind = e.kind; + } + if r.double_clicked() { + *open = false; + run_entry(app, e); + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(18.0); + let w = vec2(100.0, 24.0); + let target = name.trim().to_string(); + let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned(); + let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro); + if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked() + && let Some(e) = &found + { + *open = false; + run_entry(app, e); + } + if ui + .add_enabled(is_macro, egui::Button::new("Step Into").min_size(w)) + .on_hover_text("Open the macro in the step editor and stop before its first step") + .clicked() + { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target, "debug": true})); + } + if ui.add_enabled(is_macro, egui::Button::new("Edit").min_size(w)).clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"macro": target})); + } + let free = !target.is_empty() && !list.iter().any(|e| e.kind == Kind::Macro && e.name.eq_ignore_ascii_case(&target)); + if ui.add_enabled(free, egui::Button::new("Create").min_size(w)).on_hover_text("Write a new macro with this name").clicked() { + *open = false; + app.open_dialog("macroEditor", json!({"new": target})); + } + if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked() + && let Some(e) = &found + { + let (title, command, what) = match e.kind { + Kind::Macro => ("Delete Macro", "macro.delete", "macro"), + Kind::Script => ("Delete Script", "automate.deleteScript", "script"), + }; + app.macros.question = Some(Question { + doc: app.session.active().map_or(0, |d| d.uid), + title: title.into(), + text: format!("Do you want to delete {what} “{}”?", e.name), + command: command.into(), + params: json!({"name": e.name}), + choices: vec![("Delete".into(), json!({}))], + }); + } + if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked() + && let Some(e) = &found + { + let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default(); + d.values.insert("options".into(), json!(true)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(e.description)); + } + ui.add_space(12.0); + if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() { + *open = false; + } + }); + }); + ui.add_space(6.0); + ui.label("Description"); + let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim)); + if vba { + ui.add_space(6.0); + ui.label( + egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim), + ); + } + d.values.insert("name".into(), json!(name)); + d.values.insert("kind".into(), json!(kind.key())); +} + +/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script. +fn run_entry(app: &mut SheetApp, e: &Entry) { + match e.kind { + Kind::Macro => run_macro(app, &e.name), + Kind::Script => { + call(app, "Run Script", "automate.runScript", json!({"name": e.name})); + } + } +} + +/// Macro Options: the shortcut letter and the description of the selected macro. +fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { + let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string(); + ui.label(egui::RichText::new(format!("Macro name: {name}")).strong()); + ui.add_space(4.0); + ui.label("Shortcut key:"); + let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string(); + shortcut_field(ui, &mut key, "macro_shortcut"); + let letter = parse_shortcut(&key); + let conflict = match (key.trim().is_empty(), letter, app.session.active()) { + (true, ..) => None, + (false, None, _) => Some("Use a letter from A to Z.".to_string()), + (false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c), + (false, Some(_), None) => None, + }; + if let Some(c) = &conflict { + ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(4.0); + let label = ui.label("Description:"); + let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string(); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")).labelled_by(label.id); + ui.add_space(6.0); + let mut done = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + done = true; + } + if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() { + done = save_options(app, &name, letter, &desc); + } + }); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); + if done { + d.values.remove("options"); + } +} + +/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter. +fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) { + ui.horizontal(|ui| { + let label = ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" }); + ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)).labelled_by(label.id); + }); + *key = key.to_ascii_uppercase(); +} + +/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown). +pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, description: &str) -> bool { + let mut m = match app.run("macro.get", json!({"name": name})) { + Ok(m) if m.is_object() => m, + Ok(_) => return false, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + return false; + } + }; + if let Some(o) = m.as_object_mut() { + match letter { + Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))), + None => o.remove("shortcut"), + }; + o.insert("description".into(), json!(description)); + } + // Macro Options changes a macro that exists, so the save replaces it. + match app.run("macro.save", json!({"macro": m, "replace": true})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + false + } + } +} + +// ---------------------------------------------------------------- Record Macro dialog + +/// Record Macro: the macro's name, shortcut and description before recording starts. While +/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel). +pub fn open_record(app: &mut SheetApp, params: &Json) -> Option { + if app.session.is_recording() { + stop_recording(app); + return None; + } + let suggested = app.session.active().map(|d| gridcraft_engine::macros::next_name(&d.wb)).unwrap_or_else(|| "Macro1".into()); + let name = params["name"].as_str().map(str::to_string).filter(|n| !n.trim().is_empty()).unwrap_or(suggested); + Some(Dialog::custom("recordMacro", "Record Macro", json!({"name": name, "shortcut": "", "description": ""}))) +} + +/// What is wrong with the Record Macro dialog's name or shortcut, if anything. +pub fn record_problem(app: &SheetApp, name: &str, key: &str) -> Option { + let doc = app.session.active()?; + if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) { + return Some(e); + } + if key.trim().is_empty() { + return None; + } + match parse_shortcut(key) { + None => Some("Use a letter from A to Z for the shortcut.".into()), + Some(c) => shortcut_conflict(&doc.wb, name, c), + } +} + +/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the +/// shortcut is not usable, and the reason shows under the fields. +pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut name = get(d, "name"); + let mut key = get(d, "shortcut"); + let mut desc = get(d, "description"); + let label = ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")).labelled_by(label.id); + ui.add_space(4.0); + ui.label("Shortcut key:"); + shortcut_field(ui, &mut key, "record_shortcut"); + ui.add_space(4.0); + let label = ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")).labelled_by(label.id); + ui.checkbox(&mut app.macros.relative, "Use Relative References"); + let problem = record_problem(app, &name, &key); + if let Some(p) = &problem { + ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + *open = false; + } + if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() { + let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative}); + if !key.trim().is_empty() { + p["shortcut"] = json!(key.trim()); + } + match app.run("macro.record", p) { + Ok(_) => *open = false, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } + } + }); + d.values.insert("name".into(), json!(name)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn questions_are_read_from_the_error() { + let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)"; + assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened.")); + assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None); + assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?")); + } +} diff --git a/crates/ui-egui/src/ribbon/automate.rs b/crates/ui-egui/src/ribbon/automate.rs index e994b997f..f2546658d 100644 --- a/crates/ui-egui/src/ribbon/automate.rs +++ b/crates/ui-egui/src/ribbon/automate.rs @@ -1,10 +1,75 @@ -use super::controls::control_keytip; +use super::controls::{control_keytip, sep}; use super::{Icon, SheetApp, Ui, big_button, json}; +use crate::theme::Tokens; +use crate::widgets::{big_split_button, small_button, small_toggle_button}; +use egui::vec2; pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) { + // Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative + // References; Run Macro, whose main part runs the last macro and whose menu lists them all. if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); } + let recording = app.session.is_recording(); + ui.vertical(|ui| { + let (label, tip) = + if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") }; + let r = small_button(ui, Icon::Record, label, tip, false); + control_keytip(app, ui, &r, "macro.record"); + if r.clicked() { + crate::macro_ui::toggle_recording(app); + } + let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data"; + let rel = app.macros.relative; + let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner; + if r.clicked() && !recording { + app.macros.relative = !rel; + } + if small_button(ui, Icon::Macro, "New Macro", "Write a macro step by step", false).clicked() { + app.open_dialog("macroEditor", json!({})); + } + }); + let macros = crate::macro_ui::macro_list(app); + let recent: Vec = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect(); + let (main, arrow) = big_split_button(ui, Icon::Macro, "Run\nMacro", "Run a macro on the selection"); + control_keytip(app, ui, &arrow, "macro.run"); + if main.clicked() { + // The last macro run, or the only one; otherwise choose in the Macros dialog. + match recent.first().or(macros.first().map(|m| &m.0)) { + Some(name) if macros.len() == 1 || !recent.is_empty() => { + let name = name.clone(); + crate::macro_ui::run_macro(app, &name); + } + _ => app.open_dialog("macros", json!({})), + } + } + egui::Popup::menu(&arrow).show(|ui| { + let t = Tokens::get(ui.ctx()); + if !recent.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("Recent")).small().color(t.text_dim)); + for n in &recent { + if ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + } + if macros.is_empty() { + ui.label(egui::RichText::new("No macros in this workbook").italics().color(t.text_dim)); + } + for (n, desc, _) in macros.iter().take(30) { + let r = ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))); + let r = if desc.is_empty() { r } else { r.on_hover_text(desc) }; + if r.clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + if ui.add(egui::Button::new(app.ui.language.tr("Macros…")).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + app.open_dialog("macros", json!({})); + } + }); + sep(ui); let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); control_keytip(app, ui, &__k, "commandSearch"); if __k.clicked() { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index d49f09e90..53803d58c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -340,6 +340,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Point => "Point", }; text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + crate::macro_ui::status(app, ui); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0adb1563c..f37eb0c35 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -104,6 +104,16 @@ pub struct Tokens { pub sheet_control_text: Color32, /// A placeholder fill on the sheet (a picture that has not loaded). pub sheet_track: Color32, + /// The macro recording dot (Record Macro, the status bar). + pub recording: Color32, + /// A breakpoint dot in the macro step editor's gutter. + pub breakpoint: Color32, + /// Macro overlays on the sheet (Check and debugging): the inputs, the cells the macro writes, + /// the cells it would write over, and the current step's target. Sheet colours. + pub sheet_macro_input: Color32, + pub sheet_macro_footprint: Color32, + pub sheet_macro_conflict: Color32, + pub sheet_macro_target: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -131,6 +141,12 @@ const fn rgb(hex: u32) -> Color32 { /// theme). The light theme's formula bar is white too, so it uses them as well. const SHEET_REF_COLORS: [Color32; 8] = [rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)]; +/// Macro overlays on the sheet, the same in both themes (the sheet stays white). +const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8); +const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D); +const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C); +const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0); + /// Code colours for one background; plain text uses the surface's own text colour. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Syntax { @@ -222,6 +238,12 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)), + breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)), + sheet_macro_input: SHEET_MACRO_INPUT, + sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, + sheet_macro_conflict: SHEET_MACRO_CONFLICT, + sheet_macro_target: SHEET_MACRO_TARGET, } } @@ -363,6 +385,22 @@ mod contrast_tests { add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); + add("recording dot on status bar", t.recording, t.status_bar, GLYPH); + for (name, c) in [ + ("macro input", t.sheet_macro_input), + ("macro footprint", t.sheet_macro_footprint), + ("macro conflict", t.sheet_macro_conflict), + ("macro target", t.sheet_macro_target), + ] { + add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH); + } + // The macro step editor: breakpoint dots in the gutter, error markers, the current step. + add("breakpoint on window", t.breakpoint, t.window, GLYPH); + add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH); + add("text on current step", t.text, t.accent_soft, TEXT); + add("current step arrow", t.accent, t.accent_soft, GLYPH); + add("error marker on window", t.danger, t.window, GLYPH); v } @@ -397,6 +435,10 @@ mod contrast_tests { (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track), (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track) ); + assert_eq!( + (l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target), + (d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target) + ); for c in Cat::ALL { assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own"); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 63490a36e..1552db2bb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -109,6 +109,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); paint_big_label(ui, rect, &lines, dropdown); + let name = label.replace('\n', " "); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -203,6 +206,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } + // The accessible name is the label, or the tooltip for an icon-only button. + let name = if label.is_empty() { tr(ui, tip) } else { label }; + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); + resp.on_hover_text(tip_text(ui, tip)) +} + +/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its +/// accessible role is a checkbox, selected while `on`. +pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click()); + if on || resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, 4.0, t.pressed); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let color = if ui.is_enabled() { t.text } else { t.text_disabled }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index a2c36e3f3..e2fc537ac 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -152,7 +152,8 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() { let (mut h, id) = fixture(); let inside = corner(&h, id) - vec2(100.0, 80.0); right_click(&mut h, inside, vec2(0.0, 0.0)); - h.get_by_role_and_label(Role::Button, "Delete").click(); + // Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last. + h.get_all_by_role_and_label(Role::Button, "Delete").last().unwrap().click(); h.run_steps(3); assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); assert!(h.state().grid.object_menu.is_none()); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index a70efa782..e48a5cd32 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -679,6 +679,8 @@ fn dialogs_open_and_close() { "pivotCalculatedField", "about", "macros", + "recordMacro", + "macroEditor", "analysisToolPak", "solver", ] { diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index a22fabc98..810338a48 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2539,14 +2539,14 @@ fn keytip_wm_() { fn keytip_wmr_() { let mut h = harness(blank()); sequence(&mut h, "WMR", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMR must open the recordMacro dialog"); } #[test] fn keytip_wmv_() { let mut h = harness(blank()); sequence(&mut h, "WMV", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMV must open the macros dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs new file mode 100644 index 000000000..f2130fcb6 --- /dev/null +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -0,0 +1,554 @@ +//! The macro step editor: editing, Add Step, breakpoints, dragging steps, and the debugger +//! (step, continue, run to the end as one undo step, stop with keep or undo). + +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::StepKind; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +/// Three steps that write A1, A2, A3. +fn three(h: &mut Harness) { + let steps: Vec<_> = ["A1", "A2", "A3"].iter().map(|c| json!({"command": "cell.set", "params": {"cell": c, "input": c}})).collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Three", "output": {"onConflict": "overwrite"}, "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"})); + h.run_steps(3); +} + +fn draft_cells(h: &Harness) -> Vec { + let ed = h.state().macros.editor.as_ref().unwrap(); + ed.draft + .steps + .iter() + .map(|s| match &s.kind { + StepKind::Command { params, .. } => params["cell"].as_str().unwrap_or("").to_string(), + _ => String::new(), + }) + .collect() +} + +fn click(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_macro_add_step_through_the_picker_and_save() { + let mut h = harness(); + click(&mut h, "New Macro"); + assert!(h.state().macros.editor.is_some()); + click(&mut h, "Add Step"); + h.input_mut().events.push(Event::Text("bold".into())); + h.run_steps(2); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(matches!(&ed.draft.steps[..], [s] if matches!(&s.kind, StepKind::Command { id, .. } if id == "home.bold"))); + assert_eq!(ed.selected.as_deref(), Some("1")); + click(&mut h, "Save"); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!(wb.macros[0].name, ed.draft.name); + assert!(!h.state().macros.editor.as_ref().unwrap().dirty); +} + +#[test] +fn the_gutter_toggles_breakpoints() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 2"); + assert!(h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); + click(&mut h, "Breakpoint 2"); + assert!(!h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint); +} + +#[test] +fn dragging_a_step_reorders_the_list() { + let mut h = harness(); + three(&mut h); + let from = h.get_by_label("Step 1: Set A1 to A1").rect().center(); + let to = h.get_by_label("Step 3: Set A3 to A3").rect().center() + egui::vec2(0.0, 8.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for k in 1..=6 { + let p = from + (to - from) * (k as f32 / 6.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(draft_cells(&h), ["A2", "A3", "A1"]); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.dirty); + assert_eq!(ed.selected.as_deref(), Some("3"), "the moved step stays selected"); +} + +#[test] +fn step_continue_and_finish_as_one_undo_step() { + let mut h = harness(); + three(&mut h); + click(&mut h, "Breakpoint 3"); + let undo_before = h.state().session.active().unwrap().undo.len(); + // Continue starts the run (saving the breakpoint first) and stops before step 3. + click(&mut h, "Continue"); + let ed = h.state().macros.editor.clone().unwrap(); + assert_eq!(ed.debug.as_ref().and_then(|d| d["current"]["step"].as_str()), Some("3")); + assert_eq!(ed.selected.as_deref(), Some("3"), "the current step is selected"); + assert!(h.query_by_label_contains("Paused before step 3").is_some()); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Text("A1".into()), Value::Empty)); + assert!(h.state().grid.macro_overlay.as_ref().is_some_and(|o| !o.target.is_empty()), "the target is outlined"); + // Clearing the breakpoint while paused, then Step runs step 3 and the run finishes. + click(&mut h, "Breakpoint 3"); + click(&mut h, "Step"); + assert!(h.state().session.macro_debug.is_none()); + assert_eq!(value(&h, "A3"), Value::Text("A3".into())); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(ed.outcome.as_deref().is_some_and(|o| o.starts_with("Finished")), "{:?}", ed.outcome); + let doc = h.state().session.active().unwrap(); + // The breakpoint save is one entry, the run another. + assert_eq!(doc.undo.last().map(|e| e.label.as_str()), Some("Run Macro: Three")); + assert!(doc.undo.len() <= undo_before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Empty, Value::Empty)); +} + +#[test] +fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { + for keep in [false, true] { + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + click(&mut h, "Run to Here"); + assert_eq!( + h.state().macros.editor.as_ref().unwrap().debug.as_ref().and_then(|d| d["current"]["step"].as_str()), + Some("3"), + "{:?} {:?}", + h.state().message, + h.state().macros.editor.as_ref().unwrap().outcome + ); + click(&mut h, "Stop"); + assert!(h.state().macros.editor.as_ref().unwrap().ask_stop); + click(&mut h, if keep { "Keep Changes" } else { "Undo Changes" }); + assert!(h.state().session.macro_debug.is_none()); + let want = if keep { Value::Text("A2".into()) } else { Value::Empty }; + assert_eq!(value(&h, "A2"), want, "keep = {keep}"); + assert_eq!(value(&h, "A3"), Value::Empty); + } +} + +/// Step 3's Cell box is picking from the grid, with the selection it last saw (A1). +fn pick_step_3_cell(h: &mut Harness) { + let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs; + refs.pick = Some("ref:3:cell".into()); + refs.seen = "$A$1".into(); +} + +#[test] +fn a_pick_ends_when_its_box_leaves_the_screen() { + // Choosing another step hides the picking box; a selection made meanwhile must not land in + // it when the step is shown again. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + pick_step_3_cell(&mut h); + h.run_steps(2); + click(&mut h, "Step 1: Set A1 to A1"); + assert!(h.state().macros.editor.as_ref().unwrap().refs.pick.is_none(), "the pick ends with its box"); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(2); + click(&mut h, "Step 3: Set A3 to A3"); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); +} + +#[test] +fn debugging_never_writes_the_selection_into_a_reference_box() { + // A reference box that was picking from the grid stays out of it once an action starts, and + // the selections the debugger makes while it runs never land in a box or dirty the draft. + let mut h = harness(); + three(&mut h); + click(&mut h, "Step 3: Set A3 to A3"); + pick_step_3_cell(&mut h); + h.run_steps(2); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + click(&mut h, "Run to Here"); + assert!(h.state().session.macro_debug.is_some()); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(ed.refs.pick.is_none(), "the action ends the pick"); + assert!(!ed.dirty); + // A box focused while debugging does not take the selection either. + pick_step_3_cell(&mut h); + h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap(); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(!ed.dirty, "the draft stays clean"); + assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); + h.state_mut().macros.editor.as_mut().unwrap().refs.pick = None; + click(&mut h, "Stop"); + click(&mut h, "Undo Changes"); + assert!(h.state().session.macro_debug.is_none()); +} + +#[test] +fn step_into_from_the_macros_dialog_pauses_before_the_first_step() { + let mut h = harness(); + three(&mut h); + h.state_mut().dialog = None; + h.state_mut().macros.editor = None; + h.state_mut().open_dialog("macros", json!({"name": "Three"})); + h.run_steps(2); + click(&mut h, "Step Into"); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().session.macro_debug.is_some()); + assert!(h.query_by_label_contains("Debugging “Three”").is_some(), "the status bar says so"); +} + +#[test] +fn a_recorded_macro_opens_in_the_editor() { + let mut h = harness(); + click(&mut h, "Record Macro"); + // The Record Macro dialog comes first. + h.run_steps(2); + click(&mut h, "OK"); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "B2", "input": "5"})).unwrap(); + h.state_mut().run("home.bold", json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Stop"); + let name = h.state().session.active().unwrap().wb.macros[0].name.clone(); + h.state_mut().open_dialog("macroEditor", json!({"macro": name})); + h.run_steps(3); + let ed = h.state().macros.editor.clone().unwrap(); + assert!(!ed.draft.steps.is_empty()); + assert!(!ed.dirty); +} + +#[test] +fn run_from_the_editor_asks_before_writing_over_data() { + let mut h = harness(); + h.state_mut().run("cell.set", json!({"cell": "A2", "input": "keep me"})).unwrap(); + let steps = vec![json!({"command": "cell.set", "params": {"cell": "A2", "input": "new"}})]; + h.state_mut().run("macro.save", json!({"macro": {"name": "Over", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Over", "check": true})); + h.run_steps(3); + // Check marks the cell it would write over. + let o = h.state().grid.macro_overlay.clone().unwrap(); + assert_eq!(o.conflicts.iter().map(|(_, r)| r.a1()).collect::>(), ["A2"]); + click(&mut h, "Run"); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.choices.len() == 3)); + click(&mut h, "Overwrite"); + assert_eq!(value(&h, "A2"), Value::Text("new".into())); +} + +#[test] +fn a_new_macro_never_replaces_one_with_its_name() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor = None; + h.state_mut().dialog = None; + h.run_steps(2); + click(&mut h, "New Macro"); + for button in ["Save", "Check", "Run"] { + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "three".into(); + ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, button); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1, "{button}"); + assert_eq!(wb.macros[0].name, "Three", "{button}"); + assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact"); + assert!(value(&h, "B1").is_empty(), "{button}: nothing ran"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("A macro called “three” already exists. Choose another name."), "{button}: {msg}"); + assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none()); + h.run_steps(2); + } +} + +#[test] +fn a_rename_that_lands_before_a_failed_save_is_remembered() { + let mut h = harness(); + three(&mut h); + let big = "x".repeat(5 * 1024 * 1024); + { + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "Renamed".into(); + // Too large to keep in the workbook: the rename lands, the save after it fails. + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big}))); + ed.dirty = true; + } + h.run_steps(2); + click(&mut h, "Save"); + let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default(); + assert!(msg.contains("too large"), "{msg}"); + assert_eq!(h.state().macros.editor.as_ref().unwrap().saved_as.as_deref(), Some("Renamed")); + // Trimmed back, the next Save works. + h.state_mut().macros.editor.as_mut().unwrap().draft.steps.pop(); + h.run_steps(2); + click(&mut h, "Save"); + assert!(h.state_mut().message.take().is_none()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3)); +} + +#[test] +fn another_dialog_never_replaces_the_open_editor() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.run_steps(2); + for name in ["formatCells", "macros", "macroEditor"] { + h.state_mut().open_dialog(name, json!({})); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("macroEditor"), "{name}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"), "{name}"); + } + click(&mut h, "New Macro"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); +} + +#[test] +fn enter_in_change_never_picks_a_control_step() { + let mut h = harness(); + three(&mut h); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + for search in ["", "stop"] { + h.state_mut().macros.editor.as_mut().unwrap().selected = Some("1".into()); + h.run_steps(2); + click(&mut h, "Change…"); + if !search.is_empty() { + h.input_mut().events.push(Event::Text(search.into())); + h.run_steps(2); + } + h.input_mut().events.push(enter.clone()); + h.run_steps(3); + let ed = h.state().macros.editor.as_ref().unwrap(); + assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind); + } +} + +#[test] +fn the_editor_works_only_on_the_workbook_it_opened_in() { + let mut h = harness(); + // A new macro whose one step writes A1, not saved yet. + h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"})); + h.run_steps(3); + let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); + h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step]; + let book1 = h.state().session.active().unwrap().uid; + // Another workbook becomes active while the editor is open (File › New, Switch Window, an agent). + h.state_mut().session.new_workbook(); + h.run_steps(3); + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + // Save, Check, Run and Debug never act on the other workbook. + assert!(!gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + gridcraft_ui_egui::macro_editor::check(app, &mut ed); + gridcraft_ui_egui::macro_editor::run(app, &mut ed); + gridcraft_ui_egui::macro_editor::debug_action(app, &mut ed, "step"); + assert!(app.session.macro_debug.is_none()); + assert!(app.session.active().unwrap().wb.macros.is_empty(), "nothing was saved into the other workbook"); + assert_eq!(value(&h, "A1"), Value::Empty, "nothing ran there"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + h.state_mut().message = None; + h.state_mut().macros.editor = Some(ed); + h.run_steps(3); + // The editor stays with its own workbook's window: it is not drawn over the other one. + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"), "the editor is not shown here"); + // Back in its own workbook, the editor shows again and saves there. + let i = h.state().session.documents().iter().position(|d| d.uid == book1).unwrap(); + h.state_mut().session.set_active(i); + h.run_steps(3); + assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "macroEditor"), "the editor is back with its workbook"); + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + assert!(gridcraft_ui_egui::macro_editor::save(app, &mut ed)); + assert_eq!(app.session.active().unwrap().wb.macros.iter().map(|m| m.name.as_str()).collect::>(), ["Fresh"]); + app.macros.editor = Some(ed); + h.run_steps(3); + // Closing the workbook (without saving it) closes its editor. + h.state_mut().run("file.close", json!({"force": true})).unwrap(); + h.run_steps(3); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); +} + +/// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the +/// app answered with over the next frames. +fn request_window_close(h: &mut Harness) -> Vec { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +fn step_commands(h: &mut Harness) -> Vec { + let mut out = Vec::new(); + for _ in 0..4 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +#[test] +fn quitting_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + // As if the workbook had just been saved. + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + h.run_steps(2); + // Every workbook is saved, but the draft is not: the window stays open and the editor asks. + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some()); + // Cancel keeps the app and the draft. + // The formula bar's Cancel button has the same name; the question's is the button drawn last. + h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved")); + // Asked again, Don't Save closes the editor and then the window. + request_window_close(&mut h); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() { + let mut h = harness(); + three(&mut h); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + // The saved, unchanged editor closes and the window close goes on (the window then asks to + // close again, which a test harness has to do by hand); the workbook is asked about as + // without the editor. + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); +} + +#[test] +fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() { + let mut h = harness(); + three(&mut h); + // File › Close on a changed workbook asks about saving it, which can't replace the editor. + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Close the macro step editor")), "{:?}", h.state().message); +} + +#[test] +fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { + let mut h = harness(); + three(&mut h); + let ctx = h.ctx.clone(); + let reply = |o: gridcraft_ui_egui::control::Outcome| match o { + gridcraft_ui_egui::control::Outcome::Done(v) => v, + _ => json!(null), + }; + // Unsaved steps are only the editor's to ask about: cancel is refused and keeps them. + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_some(), "{r}"); + assert!(h.state().macros.editor.is_some()); + // A saved editor closes, and takes its overlays with it. + h.state_mut().macros.editor.as_mut().unwrap().dirty = false; + h.get_by_label("Check").click(); + h.run_steps(3); + assert!(h.state().grid.macro_overlay.is_some()); + let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({}))); + assert!(r.get("error").is_none(), "{r}"); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().grid.macro_overlay.is_none()); +} + +#[test] +fn the_editor_fits_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut session = Session::new(); + session.new_workbook(); + let app = SheetApp::new(session, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + three(&mut h); + h.run_steps(4); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let window = h.get_by_label_contains("Macro: Three").rect(); + assert!(screen.contains_rect(window), "the editor at {window:?} is outside the {size:?} window"); + // The editor's own controls (Delete is left out: the ribbon has one too). + for label in ["Add Step", "Duplicate", "Check", "Run", "Step", "Continue", "Run to Here", "Stop", "Details", "Inputs", "Output", "Save"] { + let r = h.get_by_label(label).rect(); + assert!(screen.contains_rect(r), "{label} at {r:?} is outside the {size:?} window"); + } + } +} + +#[test] +fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { + let mut h = harness(); + let steps: Vec<_> = (0..gridcraft_engine::model::MAX_MACRO_STEPS) + .map(|i| json!({"command": "cell.set", "params": {"cell": format!("A{}", i + 1), "input": "x"}})) + .collect(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Big", "steps": steps}})).unwrap(); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Big"})); + h.run_steps(2); + // Each row used to copy the whole list: five frames took about a minute. Now well under a + // second; the bound leaves room for a slow machine. + let t = gridcraft_ui_egui::now_ms(); + h.run_steps(5); + let ms = gridcraft_ui_egui::now_ms() - t; + assert!(ms < 10_000.0, "five frames took {ms} ms"); + assert!(h.query_by_label_contains("Step 1: ").is_some()); +} diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs new file mode 100644 index 000000000..171821cb7 --- /dev/null +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -0,0 +1,362 @@ +//! Automate › Macros in the window: recording with the status bar's Stop, the Macros dialog +//! (macros, Automate scripts and a VBA project), macro shortcuts and the question asked when a +//! macro's output would land on data. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(tab: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +fn save(h: &mut Harness, m: serde_json::Value) { + h.state_mut().run("macro.save", json!({"macro": m})).unwrap(); + h.run_steps(2); +} + +fn dialog_name(h: &Harness) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +#[test] +fn record_from_the_ribbon_and_stop_from_the_status_bar() { + let mut h = harness("Automate"); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("recordMacro"), "Record Macro asks for a name first"); + assert!(!h.state().session.is_recording()); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + assert!(h.query_by_label("Stop Recording").is_some(), "the button turns into Stop Recording"); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label_contains("Recording · 1 step").is_some(), "the status bar counts the steps"); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + let name = wb.macros[0].name.clone(); + assert_eq!(name, "Macro1"); + assert_eq!(h.state().macros.recent, [name]); + assert!(h.query_by_label_contains("Recording ·").is_none()); +} + +#[test] +fn use_relative_references_sets_how_the_next_recording_starts() { + let mut h = harness("Automate"); + assert!(h.state().macros.relative, "relative is the default"); + h.get_by_label("Use Relative References").click(); + h.run_steps(2); + assert!(!h.state().macros.relative); + h.get_by_label("Record Macro").click(); + h.run_steps(5); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.macro_recording.as_ref().is_some_and(|r| !r.relative)); +} + +#[test] +fn the_macros_dialog_runs_the_selected_macro() { + let mut h = harness("Automate"); + save( + &mut h, + json!({"name": "Fill", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "C3", "input": "done"}}]}), + ); + save(&mut h, json!({"name": "Other", "steps": []})); + h.get_by_label("Macros").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("macros")); + h.get_by_label("Fill").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "C3"), Value::Text("done".into())); + // One undo step undoes the whole run. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "C3"), Value::Empty); +} + +#[test] +fn the_macros_dialog_lists_scripts_and_the_vba_project_beside_macros() { + let mut s = Session::new(); + s.new_workbook(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(std::sync::Arc::new(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": []})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + // The dialog keeps view.macros (scripts and VBA) as its result, as before. + let items = h.state().dialog.as_ref().and_then(|d| d.result.clone()).and_then(|r| r.as_array().cloned()).unwrap_or_default(); + assert!(items.iter().any(|i| i["kind"] == "vba"), "{items:?}"); + assert!(h.query_by_label_contains("also has a VBA project").is_some()); + // A script and a macro may share a name; each has its own row. + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["kind"], json!("script")); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); + // Deleting asks first, then removes the script and leaves the macro. + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.text.contains("script “Mark”"))); + // The question's Delete, in front of the dialog's. + h.get_all_by_label("Delete").last().expect("the question's Delete").click(); + h.run_steps(3); + assert!(h.query_by_label("Mark (script, 1 step)").is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); +} + +#[test] +fn options_set_a_shortcut_that_runs_the_macro() { + let mut h = harness("Home"); + save( + &mut h, + json!({"name": "Stamp", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "B2", "input": "stamped"}}]}), + ); + assert!(gridcraft_ui_egui::macro_ui::save_options(h.state_mut(), "Stamp", Some('m'), "Stamps B2")); + let m = h.state().session.active().unwrap().wb.macros[0].clone(); + assert_eq!((m.shortcut.as_deref(), m.description.as_str()), (Some("Ctrl+Shift+M"), "Stamps B2")); + let mods = Modifiers::COMMAND | Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: false, repeat: false, modifiers: mods }); + h.run_steps(3); + assert_eq!(value(&h, "B2"), Value::Text("stamped".into())); +} + +#[test] +fn a_shortcut_gridcraft_uses_does_not_run_a_macro() { + let mut h = harness("Home"); + // A file can bring any letter; Ctrl+Shift+Z stays Redo. + let m = gridcraft_engine::model::Macro { name: "Z".into(), shortcut: Some("Ctrl+Shift+Z".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().wb).macros.push(m); + assert_eq!(gridcraft_ui_egui::macro_ui::shortcut_macro(h.state(), Key::Z), None); +} + +#[test] +fn a_macro_whose_output_lands_on_data_asks_and_can_shift_it_down() { + let mut h = harness("Automate"); + h.state_mut().run("cell.set", json!({"cell": "B1", "input": "old"})).unwrap(); + save(&mut h, json!({"name": "Put", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "new"}}]})); + gridcraft_ui_egui::macro_ui::run_macro(h.state_mut(), "Put"); + h.run_steps(2); + let q = h.state().macros.question.clone().expect("a question"); + assert_eq!(q.choices.iter().map(|c| c.0.as_str()).collect::>(), ["Overwrite", "Shift Down", "Shift Right"]); + assert!(q.text.contains("B1"), "{}", q.text); + assert!(!q.text.contains("onConflict"), "the hint for programs is not shown: {}", q.text); + h.get_by_label("Shift Down").click(); + h.run_steps(3); + assert!(h.state().macros.question.is_none()); + assert_eq!(value(&h, "B1"), Value::Text("new".into())); + assert_eq!(value(&h, "B2"), Value::Text("old".into())); + assert_eq!(h.state().macros.recent, ["Put"]); +} + +#[test] +fn the_record_macro_dialog_names_the_macro_and_checks_name_and_shortcut() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + let set = |h: &mut Harness, k: &str, v: &str| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), json!(v)); + h.run_steps(2); + }; + assert_eq!(h.state().dialog.as_ref().unwrap().values["name"], json!("Macro1"), "the next free name is suggested"); + set(&mut h, "name", "taken"); + assert!(h.query_by_label_contains("already exists").is_some()); + set(&mut h, "name", "Two\nlines"); + assert!(h.query_by_label_contains("line breaks").is_some()); + set(&mut h, "name", "Tidy Up"); + set(&mut h, "shortcut", "P"); + assert!(h.query_by_label_contains("already runs macro “Taken”").is_some()); + set(&mut h, "shortcut", "S"); + assert!(h.query_by_label_contains("Save As").is_some()); + // OK stays off while something is wrong. + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + set(&mut h, "shortcut", "Q"); + set(&mut h, "description", "Tidies the block"); + assert!(h.query_by_label_contains("Choose another").is_none()); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + let m = h.state().session.active().unwrap().wb.macros.iter().find(|m| m.name == "Tidy Up").cloned().expect("saved under its name"); + assert_eq!(m.shortcut.as_deref(), Some("Ctrl+Shift+Q")); + assert_eq!(m.description, "Tidies the block"); +} + +#[test] +fn view_macros_record_macro_toggles_recording() { + let mut h = harness("Home"); + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(4); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + // Opening it again while recording stops and saves, as Excel's Stop Recording does. + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(!h.state().session.is_recording()); +} + +#[test] +fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + // As when the workbook was opened with its scripts (#2178). + s.active_mut().unwrap().scripts_trusted = false; + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + assert!(h.query_by_label_contains("came from a file and are disabled").is_some()); + h.get_by_label("Trust Scripts").click(); + // The dialog shrinks without the row: let it settle before the next click. + h.run_steps(4); + assert!(h.state().session.doc().unwrap().scripts_trusted); + assert!(h.query_by_label("Trust Scripts").is_none(), "the row goes once the scripts are trusted"); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); +} + +#[test] +fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() { + for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("macro.save", json!({"name": "Tidy", "steps": []})).unwrap(); + let app = SheetApp::new(s, Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for (dialog, labels) in [("macros", &["Run", "Delete", "Options…", "Cancel"][..]), ("recordMacro", &["OK", "Cancel"][..])] { + h.state_mut().open_dialog(dialog, json!({})); + h.run_steps(4); + for label in labels { + // The dialog's own button: the last one drawn (the ribbon has a Delete too). + let r = h.get_all_by_label(label).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING); + assert!(screen.contains_rect(r), "{dialog}: {label} at {r:?} is outside the {size:?} window"); + } + h.state_mut().dialog = None; + h.run_steps(2); + } + } +} + +#[test] +fn a_question_is_answered_in_the_workbook_that_asked_it() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.is_some()); + // Another workbook with a macro of that name becomes active before the answer (an agent). + h.state_mut().dialog = None; + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.get_by_label("Delete").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1, "the other workbook's macro is kept"); + assert!(h.state().macros.question.is_none()); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message); + // Asked and answered in the same workbook, it deletes there. + h.state_mut().message = None; + h.run_steps(2); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + h.get_all_by_label("Delete").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().wb.macros.is_empty()); +} + +#[test] +fn the_macros_dialog_closes_when_another_workbook_becomes_active() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.state_mut().open_dialog("macros", json!({"name": "Tidy"})); + h.run_steps(2); + h.get_by_label("Options…").click(); + h.run_steps(2); + // Its rows and the Options pane hold the first workbook's macros; an agent switches workbooks. + h.state_mut().session.new_workbook(); + save(&mut h, json!({"name": "Tidy", "steps": []})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "the dialog closed instead of acting on the other workbook"); + assert!(h.state().session.active().unwrap().wb.macros[0].shortcut.is_none()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3f9a03ecb..ad5f7aa19 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -58,6 +58,8 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macro_editor; +mod macros_ui; mod menu_hover; mod merged_cells; mod more_colors; diff --git a/docs/macros.md b/docs/macros.md index 6ab45053e..3335b5ecc 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) +> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -9,7 +9,7 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the engine and its commands, not a user interface for macros. +This page describes the engine, its commands and the macro interface in the window. ## A macro @@ -195,17 +195,22 @@ on its own, never as a step of a script or macro, and is never recorded. ## Recording `macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute -references); stopping takes `name` and `description`. A name that another macro has, a kept one -included, gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro. `cancel: true` -ends a recording without saving it, the way out of one whose macro can't be saved. A parameter -the call would ignore is refused: `name` or `description` when starting, `relative` when -stopping, `stop: false` while recording, `stop`, `relative`, `name` or `description` beside -`cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active; -if that workbook was closed, stopping ends the recording without saving it. When saving the -recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged -in that workbook), recording goes on, so stopping again keeps what was recorded. The person -recorded the macro, so in a workbook opened from a file it runs without the approval a macro that -came with the file needs, until it changes. +references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's +name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+M"` or `"M"`) and its description. They are +checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a +letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name +the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins +over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy +(2)`, so stopping never replaces a macro, and a shortcut another macro took meanwhile is left off, +which the result says in `shortcutDropped`. `cancel: true` ends a recording without saving it, the +way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` +or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one +of the wrong kind. The macro is saved in the workbook where recording started, even if another one +is active; if that workbook was closed, stopping ends the recording without saving it. When saving +the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged +in that workbook), recording goes on, so stopping again keeps what was recorded. The person recorded +the macro, so in a workbook opened from a file it runs without the approval a macro that came with +the file needs, until it changes. A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to 10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps @@ -231,6 +236,10 @@ recording's 64 MB is left out and listed. Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the step it undid stays in the recording, so playing the recording back makes that edit again. +GridCraft keeps the Ctrl+Shift letters it uses itself on some platform: F, G, H, J, K, L, R, S, T +and Z (`macros::RESERVED_SHORTCUTS`). A shortcut a file brings with it on one of these letters is +kept but does not run the macro. + ## Other commands and storage `macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename @@ -256,6 +265,55 @@ a recording gets a suffix. A macro that is damaged or over the limits is left out with one warning, and the rest of the workbook and its other macros open; a macro that was left out is not in the saved file. +## In the window + +- **Automate › Macros group.** Macros opens the Macros dialog. Record Macro opens the Record + Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References); OK + starts recording and stays off while the name or the letter cannot be used, with the reason + under the fields. While recording, the button reads Stop Recording, the status bar shows a red + dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the + macro run last (or the only one); its menu lists the recent macros, then every macro. New + Macro opens the step editor on a new macro. +- **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros + (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. + Scripts that came with an opened file stay disabled until Trust Scripts (`automate.trustScripts`) + trusts them for the session. Run runs the selected macro on the selection or the selected + script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a + macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before + macros existed. +- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`; in a + narrow app window it is no larger than the window and its toolbar wraps onto a second row. The + step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a + category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches + are indented under their step, and rows drag to reorder within their list. Add Step opens a + command picker (control steps first; commands a macro may never run are left out); Duplicate and + Delete act on the selected step. The inspector's Details tab edits the selected step, with + reference boxes that take a typed reference or a selection on the grid and switch between a + token relative to the example input (Rel) and an absolute reference (Abs); the Inputs tab sets + each input's shape, header and column types, and Use current selection as example infers them; + the Output tab sets the anchor and what to do when results would land on data. The editor + belongs to the workbook it opened in: while another workbook is active it says so and Save, + Check, Run and Debug wait until that workbook is active again, and closing the workbook closes + the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is + refused with a message, and closing the app first asks about the editor's debug run and unsaved + steps, then about unsaved workbooks. +- **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes + green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and + Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and + `breakpointsOnly: true`, so a dot cleared while paused takes effect at once); the current step is + selected, its parameters are shown as they will run, and its target is outlined on the grid. + Stop asks whether to keep the changes so far or undo them. While a macro is debugged the status + bar says "Debugging" with the macro's name, and Show reopens the editor. +- **Questions.** A macro that needs approval offers Run; a macro whose output would land on data + offers Overwrite, Shift Down and Shift Right. Each button runs the command again with + `approve: true` or the chosen `onConflict`, in the workbook that asked: an answer given while + another workbook is active does nothing and says why. +- **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that + letter. The letters GridCraft uses itself keep their own meaning. + +Commands with empty parameters never open these dialogs: `macro.record {}` starts recording +under the next free name, and the dialogs are opened only from the ribbon, the menus and keys. + ## Automate scripts and VBA Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6bbb47980..7cee60f60 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (catalog count 276 / 292, as `cargo xtask parity` measures; earlier: automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), with no interface yet. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 40384c8b1..2329a2720 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -142,7 +142,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | -| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | +| Alt+F8, Ctrl+Shift+letter | Option+F8, Cmd+Shift+letter | Macros dialog, a macro's own shortcut (letters GridCraft uses keep their meaning) | **added** | +| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F11 / F12 | same | group sheets, shape cycling, full screen, window keys, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as @@ -153,7 +154,9 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a +note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step +editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options, Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). @@ -189,6 +192,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. |---|---|---| | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | +| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays | +| 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` |