From 7c66aaa2d3349cb8be8d0dc5b614fd9a8e1a80b4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:08:32 -0400 Subject: [PATCH 01/28] Engine: Record Macro takes a name, shortcut and description macro.record checks name, shortcut and description when recording starts and keeps them for the save, so a name that is taken, too long or holds line breaks, or a Ctrl+Shift letter that GridCraft or another macro uses, is refused before anything is recorded. With empty parameters it records under the next MacroN, as before. A name or description given when stopping still wins, and a shortcut that another macro took while recording is dropped. The shortcut rules (macros::parse_shortcut, shortcut_text, shortcut_conflict, RESERVED_SHORTCUTS and check_new_name) live in the engine, so the Record Macro dialog, Macro Options and agents agree. --- crates/engine/src/cmd/macros.rs | 75 ++++++++++++++++++++-------- crates/engine/src/macros/mod.rs | 61 ++++++++++++++++++++++ crates/engine/src/macros/recorder.rs | 10 +++- crates/engine/src/tests/macros.rs | 63 ++++++++++++++++++++++- docs/macros.md | 23 ++++++--- 5 files changed, 202 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 71dfbef67..de1edd367 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -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+K\" | \"K\", 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}", 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,16 @@ 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. + if let Some(k) = &start.shortcut + && crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) + { + converted.mac.shortcut = 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. diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 75d96f0df..f500b9815 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -420,6 +420,67 @@ 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"`, `"⌘⇧M"` and `"m"` all give `'M'`. `None` +/// when it is not one letter from A to Z. +pub fn parse_shortcut(s: &str) -> Option { + let last = s.trim().rsplit(['+', '⇧', '⌘']).next()?.trim(); + let mut chars = last.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), + _ => None, + } +} + +/// 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 53b8edc30..98249cb84 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/tests/macros.rs b/crates/engine/src/tests/macros.rs index 459faa406..868356c41 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,58 @@ 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')); + for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é"] { + 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); +} diff --git a/docs/macros.md b/docs/macros.md index 6ab45053e..f23358511 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 (Record Macro takes a name, shortcut and description; 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) 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 @@ -195,12 +195,17 @@ 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; +references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's +name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) 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. `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 @@ -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 From 5512c1a269c21efc7723e1990d9f803189bf92cd Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:21:35 -0400 Subject: [PATCH 02/28] =?UTF-8?q?UI:=20Automate=20=E2=80=BA=20Macros=20gro?= =?UTF-8?q?up,=20Record=20Macro=20and=20Macros=20dialogs,=20recording=20st?= =?UTF-8?q?atus,=20macro=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Automate tab gains a Macros group: Macros opens the Macros dialog, Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References) and turns into Stop Recording while recording, Use Relative References sets how the next recording starts, and Run Macro runs the macro run last; its menu lists the recent macros, then every macro. While recording, the status bar shows a red dot, "Recording · N steps" and Stop. The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the workbook's macros with their shortcuts, then its Automate scripts, and says when the file carries a VBA project, which is kept and not run. Run, Delete (which asks first) and Options (shortcut letter and description) act on the selected row. The dialog keeps the result of view.macros, so the scripts and the VBA entry are listed as before. A macro that needs approval asks with Run, and one whose output would land on data asks with Overwrite, Shift Down and Shift Right; each button runs the command again with its answer. Ctrl+Shift+letter (Cmd+Shift on a Mac) runs the macro with that shortcut; the letters GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV) now open the Record Macro and Macros dialogs. Icons for macros, recording and relative references are drawn in code, the recording dot is a theme token covered by the contrast test, and large and small ribbon buttons and icon buttons carry accessible names. --- ROADMAP.md | 6 +- crates/ui-egui/src/dialogs.rs | 54 +- crates/ui-egui/src/grid.rs | 5 +- crates/ui-egui/src/i18n/cs.rs | 7 + crates/ui-egui/src/i18n/de.rs | 8 + crates/ui-egui/src/i18n/el.rs | 6 + crates/ui-egui/src/i18n/es.rs | 8 + crates/ui-egui/src/i18n/fr.rs | 8 + crates/ui-egui/src/i18n/it.rs | 6 + crates/ui-egui/src/i18n/ja.rs | 6 + crates/ui-egui/src/i18n/ko.rs | 8 + crates/ui-egui/src/i18n/nl.rs | 7 + crates/ui-egui/src/i18n/pl.rs | 6 + crates/ui-egui/src/i18n/pt.rs | 6 + crates/ui-egui/src/i18n/ro.rs | 6 + crates/ui-egui/src/i18n/ru.rs | 8 + crates/ui-egui/src/i18n/sr.rs | 8 + crates/ui-egui/src/i18n/tr.rs | 8 + crates/ui-egui/src/i18n/uk.rs | 6 + crates/ui-egui/src/i18n/vi.rs | 8 + crates/ui-egui/src/i18n/zh.rs | 8 + crates/ui-egui/src/icons.rs | 26 +- .../src/keytips/keytip_table/generated.rs | 4 +- crates/ui-egui/src/keytips/keytips.json | 12 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/macro_ui.rs | 601 ++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 63 +- crates/ui-egui/src/tabs.rs | 1 + crates/ui-egui/src/theme.rs | 6 + crates/ui-egui/src/widgets.rs | 32 + crates/ui-egui/tests/it/keytips_generated.rs | 4 +- crates/ui-egui/tests/it/macros_ui.rs | 251 ++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/macros.md | 26 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 9 +- 36 files changed, 1171 insertions(+), 69 deletions(-) create mode 100644 crates/ui-egui/src/macro_ui.rs create mode 100644 crates/ui-egui/tests/it/macros_ui.rs diff --git a/ROADMAP.md b/ROADMAP.md index 995720e80..aac13c874 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-24 · **Change:** minor (macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (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 and a Macros dialog; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 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, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right | | 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 | @@ -236,6 +237,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7b069e597..ec496d95e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -161,7 +161,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(), @@ -906,11 +906,8 @@ 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), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -1262,7 +1259,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, @@ -1322,7 +1320,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::>()))) @@ -1885,45 +1884,6 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } -/// 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. -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" })); - } - }); - } - }); -} - fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c4c3888dc..91b3b571c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3006,7 +3006,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: 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 @@ -3014,6 +3014,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: || 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; } @@ -3127,6 +3128,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } 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/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 1e8e1a77a..09a884a55 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -548,6 +548,13 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0a5116757..c61a74df8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -452,6 +452,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Nächstes Blatt", "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index ae8320c59..d7cc1eb93 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -978,6 +978,12 @@ 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" => "Καταγραφή", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 79352bb9a..262c6bb61 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -468,6 +468,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index d422fb900..2d57f57bd 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Feuille suivante", "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c881ef1e4..c00178b13 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -981,6 +981,12 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 84b3866ad..162d82104 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -415,6 +415,12 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), + ("Record Macro", "マクロの記録"), + ("Stop Recording", "記録終了"), + ("Use Relative References", "相対参照で記録"), + ("Run\nMacro", "マクロの実行"), + ("Macros…", "マクロ…"), + ("Recording", "記録中"), ]; /// 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 d9611ed11..5f0ffc983 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "다음 시트", "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", + "Macros" => "매크로", + "Record Macro" => "매크로 기록", + "Stop Recording" => "기록 중지", + "Use Relative References" => "상대 참조로 기록", + "Run\nMacro" => "매크로 실행", + "Macros…" => "매크로…", + "Recording" => "기록 중", + "Stop" => "중지", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 13c717828..3afb4cf60 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -522,6 +522,13 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index c63206800..0e82acde5 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -972,6 +972,12 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3d26f2a29..536eeb823 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -973,6 +973,12 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f9e732cf8..469da8dca 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1034,6 +1034,12 @@ 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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 184f53412..e87536886 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следующий лист", "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "Macros" => "Макросы", + "Record Macro" => "Запись макроса", + "Stop Recording" => "Остановить запись", + "Use Relative References" => "Относительные ссылки", + "Run\nMacro" => "Выполнить макрос", + "Macros…" => "Макросы…", + "Recording" => "Запись", + "Stop" => "Остановить", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 31f62a5ef..e10ab9c2f 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следећи лист", "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 042ccdb70..daa951fdc 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -410,6 +410,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Koyu", "Sheet Options|View" => "Görünüm", "Print" => "Yazdır", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 466795365..334bbb6dd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -976,6 +976,12 @@ 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" => "Запис", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 920e75732..31d825881 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -412,6 +412,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Tính toán sổ làm việc", "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.", + "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", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 8ead92a6c..a1b4179cc 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -428,6 +428,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "下一个工作表", "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", + "Macros" => "宏", + "Record Macro" => "录制宏", + "Stop Recording" => "停止录制", + "Use Relative References" => "使用相对引用", + "Run\nMacro" => "运行宏", + "Macros…" => "宏…", + "Recording" => "正在录制", + "Stop" => "停止", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7667ddd56..43ddf3baa 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -135,6 +135,12 @@ 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, } struct G<'a> { @@ -194,11 +200,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 { @@ -747,6 +754,23 @@ 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); + } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c35a3bbad..e74c5aed3 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 5be64f867..50cc182c4 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 9bea918dc..a4e827de4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_ui; pub mod outline; pub mod panes; pub mod paper_view; @@ -301,6 +302,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. @@ -349,6 +352,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, } @@ -1326,6 +1330,7 @@ impl SheetApp { }); } dialogs::show(self, &ctx); + macro_ui::question_box(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); control::issue_screenshots(self, &ctx); @@ -1368,6 +1373,7 @@ impl SheetApp { let enabled = self.editor.is_none() && self.dialog.is_none() && self.message.is_none() + && self.macros.question.is_none() && self.name_box.is_none() && !ctx.text_edit_focused() && self.grid.context_menu.is_none() diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs new file mode 100644 index 000000000..2e650522e --- /dev/null +++ b/crates/ui-egui/src/macro_ui.rs @@ -0,0 +1,601 @@ +//! 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, +} + +impl Default for MacroUi { + fn default() -> Self { + MacroUi { relative: true, recent: vec![], question: 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 { + 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); + app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), 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; + }; + app.macros.question = Some(Question { 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.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())); +} + +/// 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::new("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 { + 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 recording indicator in the status bar: a red dot, "Recording · 7 steps" and Stop. +pub fn status(app: &mut SheetApp, ui: &mut Ui) { + let Some(n) = recorded_steps(app) else { return }; + let t = Tokens::get(ui.ctx()); + 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 mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro"})); + 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) { + // 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); + ui.label("Macro name:"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).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(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 { + 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); + 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")); + 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| { + 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)); + }); + *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)); + } + match app.run("macro.save", json!({"macro": m})) { + 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"); + ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")); + ui.add_space(4.0); + ui.label("Shortcut key:"); + shortcut_field(ui, &mut key, "record_shortcut"); + ui.add_space(4.0); + ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")); + 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.rs b/crates/ui-egui/src/ribbon.rs index ee64fed91..87d0ed176 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,9 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Cat, Tokens}; -use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; +use crate::widgets::{ + big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button, +}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -2565,9 +2567,68 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } 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; + } + }); + 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 65557ed15..1e359bd52 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -335,6 +335,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 74c13c9e2..7267694b9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,8 @@ 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, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -182,6 +184,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xC42B1C), } } @@ -239,6 +242,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xF1705F), } } @@ -1070,6 +1074,8 @@ 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); v } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 295f0eafd..b33daca05 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -60,6 +60,9 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); + let name = tr(ui, tip); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -90,6 +93,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::WidgetType::Button, enabled, &name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -184,6 +190,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::WidgetType::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::WidgetType::Checkbox, enabled, on, label)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 2dbcb8f29..10847dae4 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2547,14 +2547,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/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs new file mode 100644 index 000000000..03417dd1c --- /dev/null +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -0,0 +1,251 @@ +//! 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()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 915016971..9c2232fb6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -53,6 +53,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macros_ui; mod menu_hover; mod merged_cells; mod more_colors; diff --git a/docs/macros.md b/docs/macros.md index f23358511..12019a523 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (Record Macro takes a name, shortcut and description; 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, 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 @@ -265,6 +265,28 @@ 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. +- **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. + Run runs the selected macro on the selection or the selected script; Delete asks first; Options + sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, + as before macros existed. +- **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`. +- **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 0a602643f..59962538c 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) · **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) · **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 and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). 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, 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-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 | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49251a27c..a8783b499 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 (ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (macro interface on the Automate tab; ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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 @@ -141,7 +141,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 @@ -152,7 +153,8 @@ 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, Delete, Options) and Record Macro 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). @@ -187,6 +189,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 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 | 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` | From 1443ec417cde557881aacd9858da8f7f74ac093b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:29:40 -0400 Subject: [PATCH 03/28] UI: macro step editor with a debug bar and grid overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Macro (Automate › Macros) and Step Into, Edit and Create in the Macros dialog open a resizable step editor. It works on a draft of the macro and saves it with macro.save. 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) and fills in the command's required parameters; Duplicate and Delete act on the selected step. The inspector's Details tab edits the selected step. Reference parameters are reference boxes that take a typed reference or a selection on the grid, with a Rel/Abs switch between a token relative to the example input and an absolute reference, and token suggestions. The Inputs tab sets each input's shape, header and column types (Use current selection as example infers them); the Output tab sets the anchor and the conflict rule. The debug bar has Check (a dry run that colours the grid: inputs blue, writes green, writes over data red), Run, Step, Continue, Run to Here and Stop, which asks whether to keep the changes or undo them. The debugger receives the editor's breakpoints with breakpointsOnly, 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. While a macro is debugged, the status bar says so and Show reopens the editor. List drag and drop lives in a small dnd module. The debugger icons are drawn in code, and the breakpoint and sheet overlay colours are theme tokens covered by the contrast test. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 13 + crates/ui-egui/src/dnd.rs | 71 + crates/ui-egui/src/grid.rs | 5 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/el.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/nl.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ro.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/sr.rs | 2 + crates/ui-egui/src/i18n/tr.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/i18n/vi.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/icons.rs | 40 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/macro_editor.rs | 2269 +++++++++++++++++++++++ crates/ui-egui/src/macro_ui.rs | 45 +- crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/theme.rs | 42 + crates/ui-egui/tests/it/interaction.rs | 2 + crates/ui-egui/tests/it/macro_editor.rs | 215 +++ crates/ui-egui/tests/it/main.rs | 1 + docs/macros.md | 26 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 4 +- 34 files changed, 2770 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/dnd.rs create mode 100644 crates/ui-egui/src/macro_editor.rs create mode 100644 crates/ui-egui/tests/it/macro_editor.rs diff --git a/ROADMAP.md b/ROADMAP.md index aac13c874..2e9aa5c9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 with a recorder and a Macros dialog; 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 @@ -135,7 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 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, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right | +| 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 | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ec496d95e..3c2b9f1a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -908,6 +908,7 @@ impl Dialog { "journal" => Dialog::custom("journal", "Action Journal", json!({})), "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(); @@ -1250,6 +1251,18 @@ 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 }; + // 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.dialog = Some(d); + } else { + app.macros.editor = None; + app.grid.macro_overlay = None; + } + return; + } if d.name == "pasteSpecial" { paste_special_shortcuts(&mut d, ctx); } diff --git a/crates/ui-egui/src/dnd.rs b/crates/ui-egui/src/dnd.rs new file mode 100644 index 000000000..aa9ecfe2f --- /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::new("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::new("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 91b3b571c..63ecb4b1b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -190,6 +190,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 @@ -3796,6 +3798,9 @@ pub(crate) fn paint_overlays( 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.clone() && let Some(items) = trace.as_array() diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 09a884a55..5b1a07991 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -555,6 +555,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 c61a74df8..6a16f097c 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -460,6 +460,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 d7cc1eb93..58a1e73a1 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -984,6 +984,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 262c6bb61..0bf90ba8a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -476,6 +476,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 2d57f57bd..cbf1ce721 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -446,6 +446,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 c00178b13..e250ea61e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -987,6 +987,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 162d82104..322c70f17 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -421,6 +421,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("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 5f0ffc983..509c6bdee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -444,6 +444,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 3afb4cf60..c7bcfe6de 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -529,6 +529,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 0e82acde5..305bb549a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -978,6 +978,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 536eeb823..27bfa74a7 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -979,6 +979,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 469da8dca..b35d34e0d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1040,6 +1040,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 e87536886..65ed6d101 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -446,6 +446,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 e10ab9c2f..4722fa3e3 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -444,6 +444,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 daa951fdc..6445050a6 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -418,6 +418,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 334bbb6dd..947ac7052 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -982,6 +982,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 31d825881..1a6583b46 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -420,6 +420,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 a1b4179cc..0fc97b55d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -436,6 +436,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "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 43ddf3baa..6c9f8551b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -141,6 +141,19 @@ pub enum Icon { 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> { @@ -771,6 +784,33 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { 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/lib.rs b/crates/ui-egui/src/lib.rs index a4e827de4..db8e3d19e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod code_editor; pub mod control; pub mod credits; pub mod dialogs; +pub mod dnd; pub mod editor; mod floating_pictures; pub mod fonts; @@ -24,6 +25,7 @@ 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; diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs new file mode 100644 index 000000000..69e7463a6 --- /dev/null +++ b/crates/ui-egui/src/macro_editor.rs @@ -0,0 +1,2269 @@ +//! 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 { + 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, + /// 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, +} + +// ---------------------------------------------------------------- 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) +} + +pub fn step_at(steps: &[Step], path: &str) -> Option { + let mut copy = steps.to_vec(); + step_mut(&mut copy, path).map(|s| s.clone()) +} + +/// 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()); + app.macros.editor = Some(Editor { + 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, + 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 + +/// 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 { + 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) + && 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; + } + match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft)})) { + 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) { + if !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) { + if !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) { + 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 !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; + } + let dirty_mark = if ed.dirty { " •" } else { "" }; + let screen = ctx.content_rect(); + let mut win_open = true; + egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) + .id(Id::new("gridcraft.macro_editor")) + .collapsible(false) + .resizable(true) + .default_size(vec2(840.0, 560.0)) + .min_size(vec2(660.0, 380.0)) + // 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 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 !win_open { + 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); + app.grid.macro_overlay = if *open { overlay(app, &ed) } else { None }; + if *open { + app.macros.editor = Some(ed); + } else { + restore_home(app, &ed.refs); + } +} + +// ---------------------------------------------------------------- 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); + 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 { + // A selection made on the grid becomes the reference. + 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::new("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; + } + None => {} + } + return; + } + if ed.ask_close { + let mut answer: Option> = None; + egui::Modal::new(Id::new("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, + 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. + ui.horizontal(|ui| { + ui.label("Name:"); + let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")); + 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"), + ); + 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(); + let left_w = (avail.x * 0.52).max(300.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::vertical().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()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let editing = !debugging; + if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).inner.clicked() { + ed.picker = Some(Picker::default()); + } + let has_step = ed.selected.as_deref().and_then(split_path).is_some(); + if ui + .add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) + .inner + .clicked() + { + duplicate(ed); + } + if ui.add_enabled_ui(editing && has_step, |ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).inner.clicked() { + delete(ed); + } + sep(ui, &t); + if ui + .add_enabled_ui(editing, |ui| { + small_button(ui, Icon::Check, "Check", "Check the inputs and show on the grid what the macro would change", false) + }) + .inner + .clicked() + { + check(app, ed); + } + if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).inner.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_ui(can_run_to, |ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).inner.clicked() { + debug_action(app, ed, "runTo"); + } + if ui.add_enabled_ui(debugging, |ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).inner.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::WidgetType::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::new(("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::WidgetType::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::WidgetType::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::new(("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 items = picker_items(&pk.search); + 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() { + if pk.replace.is_some() && id.starts_with('@') { + continue; + } + 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(|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: &str, field: &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); + egui::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; + egui::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 { + 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() +} + +/// "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); + 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]; + 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) +} + +/// 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: [(&Vec<(String, RangeRef)>, Color32, u8, f32); 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::*; + + 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 index 2e650522e..833973659 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -26,11 +26,15 @@ pub struct MacroUi { 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 } + MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None } } } @@ -150,6 +154,10 @@ pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> /// 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; } @@ -208,10 +216,24 @@ pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { } } -/// The recording indicator in the status bar: a red dot, "Recording · 7 steps" and Stop. +/// 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 Some(n) = recorded_steps(app) else { return }; 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); @@ -390,6 +412,23 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut *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 { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 87d0ed176..5c0beeba8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2587,6 +2587,9 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) { 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(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7267694b9..f6d4efcad 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,14 @@ pub struct Tokens { 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`]. @@ -110,6 +118,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 { @@ -185,6 +199,11 @@ impl Tokens { sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, recording: rgb(0xC42B1C), + breakpoint: rgb(0xD13438), + sheet_macro_input: SHEET_MACRO_INPUT, + sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, + sheet_macro_conflict: SHEET_MACRO_CONFLICT, + sheet_macro_target: SHEET_MACRO_TARGET, } } @@ -243,6 +262,11 @@ impl Tokens { sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, recording: rgb(0xF1705F), + breakpoint: 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, } } @@ -1076,6 +1100,20 @@ mod contrast_tests { 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 } @@ -1110,6 +1148,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/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 431be6d72..f65621d4d 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -665,6 +665,8 @@ fn dialogs_open_and_close() { "pivotCalculatedField", "about", "macros", + "recordMacro", + "macroEditor", "analysisToolPak", "solver", ] { 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..3f9cda896 --- /dev/null +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -0,0 +1,215 @@ +//! 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); + } +} + +#[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())); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9c2232fb6..bbda217f8 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -53,6 +53,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macro_editor; mod macros_ui; mod menu_hover; mod merged_cells; diff --git a/docs/macros.md b/docs/macros.md index 12019a523..d6f9abc66 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **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, shortcuts) +> **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 @@ -272,12 +272,30 @@ other macros open; a macro that was left out is not in the saved file. 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. + 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. - Run runs the selected macro on the selection or the selected script; Delete asks first; Options - sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, + 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`. 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. +- **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`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 59962538c..015bf69f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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)), recorded from Automate › Macros and run from the Macros dialog or a Ctrl+Shift shortcut. 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 and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,7 +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, shortcuts) (no score change) | +| 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-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 | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a8783b499..880ab38e8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -154,7 +154,8 @@ 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, Macros (macros, Automate scripts and a -note on a VBA project; Run, Delete, Options) and Record Macro and others exist. Missing or partial: Options, +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 +190,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 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 | From bca9a10f8a589c14e15363077fc53dcd5b4ebbab Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:04:15 -0400 Subject: [PATCH 04/28] UI: saving a new macro never replaces one with the same name A new draft's name can be edited to that of a macro already in the workbook. Save, and Check and Run (which save first), then sent macro.save with its default replace: true and the draft took the other macro's place. A new draft now saves with replace: false, so the clash is reported and the existing macro stays as it was. --- crates/ui-egui/src/macro_editor.rs | 4 +++- crates/ui-egui/tests/it/macro_editor.rs | 29 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 69e7463a6..c5c30e85b 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -522,7 +522,9 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { app.message = Some(("Save Macro".into(), crate::clean_error(&e))); return false; } - match app.run("macro.save", json!({"macro": gridcraft_engine::macros::macro_json(&ed.draft)})) { + // A new draft never replaces a macro that has its name. + let replace = ed.saved_as.is_some(); + 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; diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 3f9cda896..8effcfd09 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -213,3 +213,32 @@ fn run_from_the_editor_asks_before_writing_over_data() { 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"), "{button}: {msg}"); + assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none()); + h.run_steps(2); + } +} From eb7b2b93f48f5e2f28a403917818f288ea0a4f32 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:48 -0400 Subject: [PATCH 05/28] UI: the macro editor remembers a rename even when the save after it fails Save renames the stored macro first and then saves the draft. When the save failed (a draft too large to keep in the workbook, say), saved_as still held the old name, so every later Save, Check, Run and debug start tried to rename a macro that no longer existed. saved_as now takes the new name as soon as the rename succeeds. --- crates/ui-egui/src/macro_editor.rs | 9 ++++++--- crates/ui-egui/tests/it/macro_editor.rs | 27 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index c5c30e85b..40d6118a4 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -517,10 +517,13 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { } if let Some(old) = ed.saved_as.clone() && !old.eq_ignore_ascii_case(&ed.draft.name) - && 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; + 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. let replace = ed.saved_as.is_some(); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8effcfd09..8e7db5206 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -242,3 +242,30 @@ fn a_new_macro_never_replaces_one_with_its_name() { 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)); +} From c523a4c48ad4daa20bc184dbb28f1b21732e2fdc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:21:39 -0400 Subject: [PATCH 06/28] UI: another dialog never replaces the open macro step editor The step editor lives in the app's one dialog slot, so Format Cells, the Macros dialog or New Macro opened while it was up took its place. The draft was lost (New Macro started a fresh one), and a closed-over editor left its grid overlays and its debug run behind. While the editor is open, requests for other dialogs are now ignored; the editor closes itself as before. --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/it/macro_editor.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db8e3d19e..8e6155bee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -826,6 +826,9 @@ 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") => {} other => self.dialog = dialogs::Dialog::open(self, other, params), } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8e7db5206..8c3b07cc1 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -269,3 +269,19 @@ fn a_rename_that_lands_before_a_failed_save_is_remembered() { 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")); +} From b9a8fa304cbb064607941e0b0da0f8ca02416f43 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:22:13 -0400 Subject: [PATCH 07/28] UI: Change in the macro step editor picks only the commands its list shows In Change mode the picker hid the Repeat, If and Stop rows but still indexed the full list, so Enter with an empty search (index 0, nothing highlighted) picked Repeat and turned the command step into a Repeat step; a search for "if" or "stop" did the same. The control rows are now dropped from the list before it is indexed, drawn or chosen. --- crates/ui-egui/src/macro_editor.rs | 9 +++++---- crates/ui-egui/tests/it/macro_editor.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 40d6118a4..8c3cf76db 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -1344,7 +1344,11 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { if r.changed() { pk.index = 0; } - let items = picker_items(&pk.search); + 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)); } @@ -1354,9 +1358,6 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { 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() { - if pk.replace.is_some() && id.starts_with('@') { - continue; - } 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); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 8c3b07cc1..c302f9bb4 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -285,3 +285,23 @@ fn another_dialog_never_replaces_the_open_editor() { 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); + } +} From 3d6a09ec758e37b0ba684bc9b6062013b29601da Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:04:01 -0400 Subject: [PATCH 08/28] UI: a reference box stops picking when a macro action starts, and debugging never fills one A reference box that had focus kept picking from the grid while Check, Run or a debugger action ran. The selections those make landed in the box and dirtied the draft, and a later Check could save that draft. The pick now ends when an action starts, and while a debug run is active a selection on the grid is never taken as a pick. Also: the parity assessment said an .xlsm's VBA project is dropped on open; it is kept unchanged on save and never run. --- crates/ui-egui/src/macro_editor.rs | 9 +++++-- crates/ui-egui/tests/it/macro_editor.rs | 34 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 8c3cf76db..955cb45f6 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -563,6 +563,8 @@ fn error_step(msg: &str) -> Option<(String, 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 !ensure_saved(app, ed) { return; } @@ -613,6 +615,7 @@ fn check_text(v: &Json) -> String { /// Run: runs the saved macro as one undo step. pub fn run(app: &mut SheetApp, ed: &mut Editor) { + ed.refs.pick = None; if !ensure_saved(app, ed) { return; } @@ -649,6 +652,7 @@ fn with(mut base: Json, extra: Json) -> Json { /// 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()); @@ -840,8 +844,9 @@ fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key 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 { - // A selection made on the grid becomes the reference. + } 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; } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index c302f9bb4..52e116f89 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -162,6 +162,40 @@ fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { } } +#[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"); + // The step's Cell box is picking, with the selection it last saw (A1). + let pick = |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(); + }; + pick(&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(&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(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 015bf69f0..18286ce52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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)), 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 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 From cec19d6af6fde42343bd5f9a7a458baa70509556 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:16:01 -0400 Subject: [PATCH 09/28] UI: a reference box's pick ends when the box leaves the screen Choosing another step or inspector tab hid the picking box but left the pick in place, so a grid selection made later for another reason landed in the box when its step was shown again, and the draft was dirtied with a reference nobody chose. A pick now ends in any frame that does not draw its box. --- crates/ui-egui/src/macro_editor.rs | 10 +++++++ crates/ui-egui/tests/it/macro_editor.rs | 36 +++++++++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 955cb45f6..12e9c1772 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -99,6 +99,9 @@ pub struct RefPick { 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 @@ -752,6 +755,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu let dirty_mark = if ed.dirty { " •" } else { "" }; let screen = ctx.content_rect(); let mut win_open = true; + ed.refs.drawn = false; egui::Window::new(format!("Macro: {}{dirty_mark}", ed.draft.name)) .id(Id::new("gridcraft.macro_editor")) .collapsible(false) @@ -769,6 +773,9 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu } body(app, ui, d, &mut ed); }); + if ed.refs.pick.is_some() && !ed.refs.drawn { + ed.refs.pick = None; + } if !win_open { if app.session.macro_debug.is_some() { ed.ask_stop = true; @@ -840,6 +847,9 @@ fn ref_edit(app: &SheetApp, ui: &mut Ui, d: &mut Dialog, refs: &mut RefPick, key 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) { diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 52e116f89..5dbf60847 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -162,6 +162,32 @@ fn run_to_here_then_stop_asks_whether_to_keep_the_changes() { } } +/// 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 @@ -169,13 +195,7 @@ fn debugging_never_writes_the_selection_into_a_reference_box() { let mut h = harness(); three(&mut h); click(&mut h, "Step 3: Set A3 to A3"); - // The step's Cell box is picking, with the selection it last saw (A1). - let pick = |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(); - }; - pick(&mut h); + pick_step_3_cell(&mut h); h.run_steps(2); assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]); click(&mut h, "Run to Here"); @@ -184,7 +204,7 @@ fn debugging_never_writes_the_selection_into_a_reference_box() { 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(&mut h); + 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(); From aa86b0c32734d101d6e2f7ab327c0f731bc584d8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:13:39 -0400 Subject: [PATCH 10/28] UI: merge fallout from the UI kit and the recorder heads Icon buttons no longer carry an accessible name: the tests that came with main since the branch was written (menu hover, popup dismissal, the File menu, More Colors) find Copy, Save and Cancel by label, and the Quick Access Toolbar and formula bar icons gave those labels twice. The macro tests need the names of large, small and toggle ribbon buttons only. The chart menu test takes the menu's Delete, which is drawn after the ribbon's Home > Cells > Delete. The Trust Scripts row ported into the Macros dialog is formatted. --- crates/ui-egui/src/macro_ui.rs | 6 +++++- crates/ui-egui/src/widgets.rs | 3 --- crates/ui-egui/tests/it/chart_resize.rs | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 833973659..acbc77a2c 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -352,7 +352,11 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut 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))); + 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."); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b33daca05..dea0bcf5e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -60,9 +60,6 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); - let name = tr(ui, tip); - let enabled = ui.is_enabled(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); 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 fb3e99165..30afcc17c 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -151,7 +151,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_label("Delete").click(); + // Home › Cells › Delete has the same accessible name; the menu is drawn over the ribbon, last. + h.get_all_by_label("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()); From 73b0edd0859eb8e91c565d23ad8ef6fabe309e5c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:13:39 -0400 Subject: [PATCH 11/28] UI: the Macros dialog offers Trust Scripts for scripts from a file The Macros dialog that replaces the scripts-only list keeps its Trust Scripts row (#2178): scripts that came with an opened file are listed as disabled until the row's button trusts them for the session, and Run works after it. A test covers the row, the trust and the run, and the macro page describes it. --- crates/ui-egui/tests/it/macros_ui.rs | 34 ++++++++++++++++++++++++++++ docs/macros.md | 2 ++ 2 files changed, 36 insertions(+) diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index 03417dd1c..d7635d572 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -249,3 +249,37 @@ fn view_macros_record_macro_toggles_recording() { 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())); +} diff --git a/docs/macros.md b/docs/macros.md index d6f9abc66..e0cbb4b97 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -276,6 +276,8 @@ other macros open; a macro that was left out is not in the saved file. 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. From a206178209acbc170fd1f6dd06309af83ca6e2bc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:19:16 -0400 Subject: [PATCH 12/28] UI: the macro step editor works only on the workbook it opened in The macro.* commands act on the active workbook, and the editor stays open while another one becomes active (File > New, Switch Window, an agent). A new draft saved then went into that other workbook, and Check, Run and Debug ran there. The editor now keeps the workbook it opened in: while another one is active it says so, its controls are disabled, Save, Check, Run and starting Debug refuse with a message that names the workbook, the grid overlays are hidden and closing does not put the selection back on the wrong sheet. Closing the workbook closes the editor. --- crates/ui-egui/src/macro_editor.rs | 60 ++++++++++++++++++++----- crates/ui-egui/tests/it/macro_editor.rs | 43 ++++++++++++++++++ docs/macros.md | 5 ++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 12e9c1772..f3e934c6a 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -52,6 +52,8 @@ pub struct Picker { /// 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, @@ -485,7 +487,9 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { 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, @@ -511,8 +515,27 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { // ---------------------------------------------------------------- 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)); @@ -568,7 +591,7 @@ fn error_step(msg: &str) -> Option<(String, String)> { 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 !ensure_saved(app, ed) { + if !at_home(app, ed, "Check Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -619,7 +642,7 @@ fn check_text(v: &Json) -> String { /// Run: runs the saved macro as one undo step. pub fn run(app: &mut SheetApp, ed: &mut Editor) { ed.refs.pick = None; - if !ensure_saved(app, ed) { + if !at_home(app, ed, "Run Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -660,7 +683,7 @@ pub fn debug_action(app: &mut SheetApp, ed: &mut Editor, action: &str) { // 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 !ensure_saved(app, ed) { + if !at_home(app, ed, "Debug Macro") || !ensure_saved(app, ed) { return; } ed.errors.clear(); @@ -752,6 +775,13 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu 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 mut win_open = true; @@ -766,12 +796,21 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu .default_pos(pos2((screen.right() - 860.0).max(screen.left() + 20.0), screen.top() + 190.0)) .open(&mut win_open) .show(ctx, |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; + 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), + ); } - body(app, ui, d, &mut ed); + 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; @@ -790,10 +829,11 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu ed.picker = None; } prompts(app, ctx, &mut ed, open); - app.grid.macro_overlay = if *open { overlay(app, &ed) } else { None }; + // 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 { + } else if home { restore_home(app, &ed.refs); } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 5dbf60847..0e986da75 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -359,3 +359,46 @@ fn enter_in_change_never_picks_a_control_step() { 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); + assert!(h.query_by_label_contains("Switch back to it").is_some(), "the editor says whose macro it is"); + // Back in its own workbook, the editor saves there. + let mut ed = h.state_mut().macros.editor.take().unwrap(); + let app = h.state_mut(); + let i = app.session.documents().iter().position(|d| d.uid == book1).unwrap(); + app.session.set_active(i); + 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")); +} diff --git a/docs/macros.md b/docs/macros.md index e0cbb4b97..d79efcc38 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -290,7 +290,10 @@ other macros open; a macro that was left out is not in the saved file. 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 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. - **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 From a18ecc81aa38275a947afb74f72dc93dffa77404 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:22 -0400 Subject: [PATCH 13/28] UI: the control channel's dialog cancel closes the macro step editor as its own close does ui.dialog.cancel emptied the dialog slot, so with the step editor open it went without its cleanup: the draft stayed behind, the Check overlays stayed on the grid, the selection the reference boxes moved was not put back and a debug run went on with no controls. The cancel now closes the editor the way its close button does, and is refused, with the reason, while the editor holds unsaved steps or a debug run, which only its window asks about. --- crates/ui-egui/src/control.rs | 7 +++++++ crates/ui-egui/src/macro_editor.rs | 20 ++++++++++++++++++ crates/ui-egui/tests/it/macro_editor.rs | 27 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8e29bccb4..b283472f3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,6 +320,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/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index f3e934c6a..51450470f 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -838,6 +838,26 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu } } +/// 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`. diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 0e986da75..169e4d480 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -402,3 +402,30 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().macros.editor.is_none()); assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } + +#[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()); +} From 12072c55297c3190de684e634b6e425dfe039c28 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:34 -0400 Subject: [PATCH 14/28] UI: a dialog asked for over the macro step editor says why it did not open The step editor keeps its window until it closes itself, so a dialog asked for while it is open is not shown. That was silent: File > Close on a changed workbook asked for its Save Changes question and nothing happened. The request now shows a message that says to close the editor first. --- crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/tests/it/macro_editor.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e6155bee..849426179 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -828,7 +828,12 @@ impl SheetApp { } // 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") => {} + _ 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/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 169e4d480..f716fcce6 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -403,6 +403,18 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +#[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(); From f73e7b7e194c6520d63998ebbe3bc578673f0cf5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:21:49 -0400 Subject: [PATCH 15/28] UI: closing the window asks about the macro step editor first A window close (the close button, Alt+F4, quitting) let the app go when every workbook was saved, losing the editor's unsaved steps, and with an unsaved workbook it made that workbook active under the editor and asked for a Save Changes question the editor's window would not let open, so nothing happened. The close now waits for the editor: it asks about its debug run and unsaved steps as its own close button does, Cancel keeps the app open, and once the editor has closed the window close goes on and asks about unsaved workbooks as before. --- crates/ui-egui/src/lib.rs | 7 +++ crates/ui-egui/src/macro_editor.rs | 33 +++++++++++-- crates/ui-egui/tests/it/macro_editor.rs | 61 +++++++++++++++++++++++++ docs/macros.md | 4 +- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 849426179..e4c7a6436 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1144,6 +1144,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); + 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/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 51450470f..9af815675 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -75,6 +75,10 @@ pub struct Editor { /// 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). @@ -503,6 +507,8 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { 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), @@ -815,7 +821,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if ed.refs.pick.is_some() && !ed.refs.drawn { ed.refs.pick = None; } - if !win_open { + if !win_open || std::mem::take(&mut ed.close_requested) { if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; @@ -833,8 +839,23 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu 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); + } 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; } } @@ -966,6 +987,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut Some(None) => { ed.ask_stop = false; ed.closing = false; + ed.quit = false; } None => {} } @@ -1000,7 +1022,10 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_close = false; *open = false; } - Some(None) => ed.ask_close = false, + Some(None) => { + ed.ask_close = false; + ed.quit = false; + } None => {} } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index f716fcce6..418d36293 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -403,6 +403,67 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { 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. + h.get_by_label("Cancel").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(); diff --git a/docs/macros.md b/docs/macros.md index d79efcc38..a31ef5cc6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -293,7 +293,9 @@ other macros open; a macro that was left out is not in the saved file. 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. + 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 From baf01c421123c3065ef0159d0288cdc6e6d500a2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:26:09 -0400 Subject: [PATCH 16/28] UI: the macro step editor fits a narrow window The editor's window had a minimum size of 660 by 380 and its toolbar was one row of about 680 pixels, so in a window 600 pixels wide Run to Here and Stop were off screen, and at 480 the inspector's tabs were cut off. The window is now never larger than the app's window, the name row, the toolbar and the inspector's tabs wrap (the toolbar's buttons are disabled in place rather than in a scope of their own, which kept them from wrapping), the inspector keeps room for its tabs, and its content scrolls sideways instead of widening the window. Tests check the editor at 600 and 480 pixels, and the Macros and Record Macro dialogs, which already fit. --- crates/ui-egui/src/macro_editor.rs | 36 +++++++++++++------------ crates/ui-egui/tests/it/macro_editor.rs | 28 +++++++++++++++++++ crates/ui-egui/tests/it/macros_ui.rs | 31 +++++++++++++++++++++ docs/macros.md | 3 ++- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 9af815675..24034c76e 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -790,14 +790,16 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu 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::new("gridcraft.macro_editor")) .collapsible(false) .resizable(true) - .default_size(vec2(840.0, 560.0)) - .min_size(vec2(660.0, 380.0)) + // 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) @@ -1034,8 +1036,8 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut 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. - ui.horizontal(|ui| { + // Name, description, Save; they wrap in a narrow window. + ui.horizontal_wrapped(|ui| { ui.label("Name:"); let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")); ui.label("Description:"); @@ -1055,7 +1057,8 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { status_line(app, ui, ed, &t); ui.separator(); let avail = ui.available_size(); - let left_w = (avail.x * 0.52).max(300.0); + // 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)); @@ -1067,7 +1070,7 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { }); ui.separator(); ui.allocate_ui_with_layout(vec2(ui.available_width(), avail.y), egui::Layout::top_down(egui::Align::Min), |ui| { - egui::ScrollArea::vertical().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { + egui::ScrollArea::both().id_salt("me_inspector").auto_shrink([false, false]).show(ui, |ui| { inspector(app, ui, d, ed, debugging); }); }); @@ -1076,34 +1079,33 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { + // 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_ui(editing, |ui| small_button(ui, Icon::Plus, "Add Step", "Add a step after the selected one", false)).inner.clicked() { + 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_ui(editing && has_step, |ui| small_button(ui, Icon::Copy, "Duplicate", "Duplicate the selected step", false)) - .inner + .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_ui(editing && has_step, |ui| small_button(ui, Icon::Delete, "Delete", "Delete the selected step", false)).inner.clicked() { + 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_ui(editing, |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) }) - .inner .clicked() { check(app, ed); } - if ui.add_enabled_ui(editing, |ui| small_button(ui, Icon::Play, "Run", "Run the macro as one undo step", false)).inner.clicked() { + 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); @@ -1116,10 +1118,10 @@ fn toolbar(app: &mut SheetApp, ui: &mut Ui, ed: &mut Editor, debugging: bool) { debug_action(app, ed, "continue"); } let can_run_to = ed.selected.as_deref().and_then(split_path).is_some(); - if ui.add_enabled_ui(can_run_to, |ui| small_button(ui, Icon::RunTo, "Run to Here", "Run until the selected step", false)).inner.clicked() { + 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_ui(debugging, |ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).inner.clicked() { + if ui.add_enabled(debugging, |ui: &mut Ui| small_button(ui, Icon::StopSquare, "Stop", "Stop debugging", false)).clicked() { ed.ask_stop = true; } }); @@ -1486,7 +1488,7 @@ fn picker(ui: &mut Ui, ed: &mut Editor) { // ---------------------------------------------------------------- the inspector fn inspector(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor, debugging: bool) { - ui.horizontal(|ui| { + 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; diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 418d36293..241c652b8 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -502,3 +502,31 @@ fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() { 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"); + } + } +} diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index d7635d572..7c18b08ff 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -283,3 +283,34 @@ fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() { 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); + } + } +} diff --git a/docs/macros.md b/docs/macros.md index a31ef5cc6..b315795ee 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -281,7 +281,8 @@ other macros open; a macro that was left out is not in the saved file. 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`. The +- **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 From 0fdb4d03e4089acfd0df055d9ca651b30c6f4864 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:27:09 -0400 Subject: [PATCH 17/28] UI: a macro question is answered only in the workbook that asked it The Macros dialog's Delete question and the questions a macro run asks (approve it, overwrite or move the data in the way) keep the command and run it again with the answer. When another workbook became active before the answer (an agent can switch workbooks under the prompt), the answer ran there: Delete removed the other workbook's macro of the same name. A question now keeps its workbook, and an answer given while another one is active does nothing and says why. --- crates/ui-egui/src/macro_ui.rs | 13 +++++++++++- crates/ui-egui/tests/it/macros_ui.rs | 30 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index acbc77a2c..78fee3317 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -50,6 +50,8 @@ impl MacroUi { /// 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, @@ -147,7 +149,8 @@ pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> } else { return false; }; - app.macros.question = Some(Question { title: title.into(), text, command: id.into(), params: params.clone(), choices }); + 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 } @@ -202,6 +205,13 @@ pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { }); }); 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(); @@ -441,6 +451,7 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut 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(), diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index 7c18b08ff..c8e6ba490 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -314,3 +314,33 @@ fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() { } } } + +#[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()); +} From a0fd599fe674f1ce7b8f3cbf12eac34a78670492 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:01 -0400 Subject: [PATCH 18/28] UI: the macro step editor reads each step in place step_at copied the whole step list to look up one step, and the step list calls it for every row on every frame, so a macro at the 10,000 step limit (which a recording can reach, or a file can carry) took about ten seconds a frame. The lookup now walks the list without copying it: five frames of that macro take well under a second. A test opens a macro at the limit with a generous time bound. --- crates/ui-egui/src/macro_editor.rs | 33 +++++++++++++++++++++++-- crates/ui-egui/tests/it/macro_editor.rs | 17 +++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 24034c76e..a225eee06 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -154,9 +154,38 @@ pub fn step_mut<'a>(steps: &'a mut Vec, path: &str) -> Option<&'a mut Step 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 mut copy = steps.to_vec(); - step_mut(&mut copy, path).map(|s| s.clone()) + let (parent, i) = split_path(path)?; + list_at(steps, parent)?.get(i).cloned() } /// The paths of the steps with a breakpoint. diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 241c652b8..d08a8d641 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -530,3 +530,20 @@ fn the_editor_fits_a_narrow_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 = std::time::Instant::now(); + h.run_steps(5); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "five frames took {:?}", t.elapsed()); + assert!(h.query_by_label_contains("Step 1: ").is_some()); +} From a414b608d7bafa3554a65b7106877b37e8564eea Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:50 -0400 Subject: [PATCH 19/28] Docs: the macro page says where a question's answer runs, and its long lines wrap The Questions entry says that an answer runs in the workbook that asked and does nothing while another one is active. The lines the restack joined in the recording paragraph and the Macros dialog entry are wrapped again. --- docs/macros.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index b315795ee..4f2051a0b 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -202,8 +202,8 @@ letter that GridCraft or another macro uses, is refused before anything is recor 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. `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 +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 @@ -277,10 +277,10 @@ other macros open; a macro that was left out is not in the saved file. - **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. + 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 @@ -306,7 +306,8 @@ other macros open; a macro that was left out is not in the saved file. 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`. + `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. From 60c20095e6c78525983ca58c3c9fdb7d88f6f72f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:35:31 -0400 Subject: [PATCH 20/28] UI: the Macros dialog closes when another workbook becomes active The dialog's rows and its Options pane hold the macros of the workbook it opened on, but Run, Delete and Options act on the active workbook. When an agent made another workbook active, the dialog stayed open over it, and OK in Options would write the first macro's shortcut and description onto the other workbook's macro of the same name. The dialog now keeps its workbook and closes when another one becomes active. --- crates/ui-egui/src/macro_ui.rs | 10 +++++++++- crates/ui-egui/tests/it/macros_ui.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 78fee3317..dcf691f65 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -343,7 +343,8 @@ 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 mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro"})); + 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 } @@ -351,6 +352,13 @@ pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { /// 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) { diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs index c8e6ba490..171821cb7 100644 --- a/crates/ui-egui/tests/it/macros_ui.rs +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -344,3 +344,19 @@ fn a_question_is_answered_in_the_workbook_that_asked_it() { 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()); +} From 1e2492b4d6474ac3cbe9e96382f9e003413b80cb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:36:54 -0400 Subject: [PATCH 21/28] Tests: the large-macro editor test reads the UI clock Clippy refuses std::time::Instant::now, which traps on wasm; the test that bounds the step list's frame time reads gridcraft_ui_egui::now_ms instead. --- crates/ui-egui/tests/it/macro_editor.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index d08a8d641..91abbd397 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -542,8 +542,9 @@ fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { 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 = std::time::Instant::now(); + let t = gridcraft_ui_egui::now_ms(); h.run_steps(5); - assert!(t.elapsed() < std::time::Duration::from_secs(10), "five frames took {:?}", t.elapsed()); + 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()); } From c142c70dbe38df105ef27dffc45acd37704ea347 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:55:55 -0400 Subject: [PATCH 22/28] UI: the macro editor keeps an input to a shape a macro may declare The format now refuses a single-cell input with a header, several column types or more than one row or column, a least above its most, and more column types than columns; the editor could make each of these, and the save then failed. - Switching an input to "single cell" sets one row and one column, takes the header off and keeps at most one column type; "Use current selection as example" does the same for one cell. - Setting "at least" above "at most" moves "at most" up (and the other way round), and lowering the columns' "at most" drops the column types past it. - Test: `an_edited_input_keeps_a_shape_a_macro_may_declare` (each result passes `check_limits`). --- crates/ui-egui/src/macro_editor.rs | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index a225eee06..621caae22 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -2013,6 +2013,7 @@ fn inputs_tab(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) }); } if i != before { + tidy_input(&mut i, &before); if let Some(slot) = ed.draft.inputs.get_mut(k) { *slot = i; } @@ -2060,6 +2061,30 @@ fn example_headers(app: &SheetApp, example: &str, header: bool) -> Vec { .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) { @@ -2069,11 +2094,13 @@ pub fn use_selection_as_example(app: &SheetApp, ed: &mut Editor) { 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()); } @@ -2241,6 +2268,31 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e 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"})), From c442da92212b76ea15efd1e427954c6c3da0a478 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:29:00 -0400 Subject: [PATCH 23/28] Engine: stopping a recording says when it left the chosen shortcut off Record Macro checks the Ctrl+Shift letter when recording starts, but another macro can take it before recording stops; the save then leaves the shortcut off, and nothing said so. The result now names it in shortcutDropped, the toast after Stop says the macro was saved without it, and the macro page describes it. --- crates/engine/src/cmd/macros.rs | 24 +++++++++++++++--------- crates/engine/src/tests/macros.rs | 19 +++++++++++++++++++ crates/ui-egui/src/macro_ui.rs | 6 +++++- docs/macros.md | 26 +++++++++++++------------- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index de1edd367..f75cee6ef 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -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?, shortcut?: \"Ctrl+Shift+K\" | \"K\", 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}", + "{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", 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 ), @@ -338,11 +338,15 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star 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. - if let Some(k) = &start.shortcut - && crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) - { - converted.mac.shortcut = Some(k.clone()); + // 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(); @@ -360,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/tests/macros.rs b/crates/engine/src/tests/macros.rs index 868356c41..19a23a1ac 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2413,3 +2413,22 @@ fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { 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/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index dcf691f65..d80e4c800 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -89,7 +89,11 @@ pub fn stop_recording(app: &mut SheetApp) { Some(name) => { let steps = v["steps"].as_u64().unwrap_or(0); app.macros.note_recent(name); - app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), crate::now_ms())); + 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())), }, diff --git a/docs/macros.md b/docs/macros.md index 4f2051a0b..5018975d6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -198,19 +198,19 @@ on its own, never as a step of a script or macro, and is never recorded. references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) 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. +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 From ea5eb2ae3c7a163d77174efc29277c1e92cce6e4 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:27:00 -0400 Subject: [PATCH 24/28] UI: Macro Options saves over the macro it changes Saving a macro over one with the same name now needs `replace: true`, so Macro Options, which changes the shortcut and description of a macro that exists, failed to save. It passes `replace: true`; `options_set_a_shortcut_that_runs_the_macro` covers it. --- crates/ui-egui/src/macro_ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index d80e4c800..ff303f586 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -574,7 +574,8 @@ pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, descri }; o.insert("description".into(), json!(description)); } - match app.run("macro.save", json!({"macro": m})) { + // 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))); From 59d85ca138f32393da9e014603c15b82539f16c0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:45 -0400 Subject: [PATCH 25/28] UI: the macro editor refuses a taken name in the person's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving over a macro now needs `replace: true`, and the engine's refusal says so in terms for programs. A new draft whose name another macro has (or a kept one from a newer version) is refused by the editor first, with "A macro called “three” already exists. Choose another name." - Test: `a_new_macro_never_replaces_one_with_its_name`. --- crates/ui-egui/src/macro_editor.rs | 9 ++++++++- crates/ui-egui/tests/it/macro_editor.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 621caae22..f140a8beb 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -586,8 +586,15 @@ pub fn save(app: &mut SheetApp, ed: &mut Editor) -> bool { // 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. + // 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()); diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 91abbd397..33d3861e4 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -291,7 +291,7 @@ fn a_new_macro_never_replaces_one_with_its_name() { 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"), "{button}: {msg}"); + 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); } From ffacf7405f949dcbed628aaef5a52c692ffa2acb Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:14:24 -0400 Subject: [PATCH 26/28] Docs: Record Macro's shortcut example uses a letter a macro may take The example was K, which GridCraft uses (Group), so a caller who copied it was refused; it is M now, in the command's description and on the macro page. --- crates/engine/src/cmd/macros.rs | 2 +- docs/macros.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index f75cee6ef..6d1cdf08a 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -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?, shortcut?: \"Ctrl+Shift+K\" | \"K\", 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)}", + "{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 ), diff --git a/docs/macros.md b/docs/macros.md index 5018975d6..3335b5ecc 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -196,7 +196,7 @@ on its own, never as a step of a script or macro, and is never recorded. `macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's -name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) and its description. They are +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 From 8fd6ec124ba8a3b9058833bd384bd2162bca0e43 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:32:53 -0400 Subject: [PATCH 27/28] Engine: a macro shortcut is one letter, alone or after Ctrl+Shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_shortcut` took the last letter of any spelling, so `"Ctrl+Alt+M"` was saved as Ctrl+Shift+M. It now takes a bare letter or one after Ctrl+Shift, Cmd+Shift or ⌘⇧, and refuses other modifiers. - Test: `shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones`. --- crates/engine/src/macros/mod.rs | 20 ++++++++++++-------- crates/engine/src/tests/macros.rs | 4 +++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index f500b9815..ecbe6f674 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -435,15 +435,19 @@ pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[ ('Z', "Redo"), ]; -/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"⌘⇧M"` and `"m"` all give `'M'`. `None` -/// when it is not one letter from A to Z. +/// 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 last = s.trim().rsplit(['+', '⇧', '⌘']).next()?.trim(); - let mut chars = last.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()), - _ => None, - } + 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`. diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 19a23a1ac..2903ac79a 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2400,7 +2400,9 @@ fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() { assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M')); assert_eq!(parse_shortcut("⌘⇧k"), Some('K')); assert_eq!(parse_shortcut(" q "), Some('Q')); - for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é"] { + 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"); From e40291fc3d0866d094b4e54871e4514aaffc9253 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:38:32 -0400 Subject: [PATCH 28/28] Macro interface: follow per-window dialogs and egui 0.37 accessibility Main now keeps a dialog with its own workbook window, so the step editor is parked with its workbook rather than drawn over another one, and closing that workbook drops the editor without drawing it again: forget_closed_editor clears it each frame. The Macros, Record Macro and editor text boxes are labelled by their captions, and the tests pick the question's Cancel and the chart menu's Delete by role, as the formula bar and ribbon share those names. --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/macro_editor.rs | 17 +++++++++------ crates/ui-egui/src/macro_ui.rs | 29 ++++++++++++++++--------- crates/ui-egui/tests/it/chart_resize.rs | 3 ++- crates/ui-egui/tests/it/macro_editor.rs | 14 +++++++----- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 26c53e337..334399d8b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1797,6 +1797,7 @@ 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); diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 087675f7c..733921407 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -1074,13 +1074,16 @@ fn body(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, ed: &mut Editor) { let debugging = app.session.macro_debug.is_some(); // Name, description, Save; they wrap in a narrow window. ui.horizontal_wrapped(|ui| { - ui.label("Name:"); - let name = ui.add_enabled(!debugging, egui::TextEdit::singleline(&mut ed.draft.name).desired_width(180.0).id_salt("me_name")); - 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"), - ); + 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; } diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 85d8de17f..4277721b8 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -181,6 +181,15 @@ pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { 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() { @@ -396,8 +405,8 @@ pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(330.0); - ui.label("Macro name:"); - if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).changed() { + 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| { @@ -528,9 +537,9 @@ fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger)); } ui.add_space(4.0); - ui.label("Description:"); + 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")); + 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| { @@ -551,8 +560,8 @@ fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { /// 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| { - 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)); + 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(); } @@ -620,14 +629,14 @@ pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut let mut name = get(d, "name"); let mut key = get(d, "shortcut"); let mut desc = get(d, "description"); - ui.label("Macro name:"); - ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")); + 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); - ui.label("Description:"); - ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")); + 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 { 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/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index 33d3861e4..f2130fcb6 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -386,12 +386,15 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { h.state_mut().message = None; h.state_mut().macros.editor = Some(ed); h.run_steps(3); - assert!(h.query_by_label_contains("Switch back to it").is_some(), "the editor says whose macro it is"); - // Back in its own workbook, the editor saves there. + // 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(); - let i = app.session.documents().iter().position(|d| d.uid == book1).unwrap(); - app.session.set_active(i); 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); @@ -435,7 +438,8 @@ fn quitting_asks_about_the_editors_unsaved_steps_first() { 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. - h.get_by_label("Cancel").click(); + // 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"));