Merge branch 'prxnvt/pr/macros-ui' into stack/record

Brings in the macro interface: the Macros dialog and editor, the
Automate ribbon section, drag-and-drop of macro steps, and the
macro overlay on the grid. Ported into the split module layout on main.

Clippy gate: moved wildcard imports to explicit lists, named the
test edit tables, boxed the overlay layer tuple, switched the editor
to craft_ui::ComboBox per clippy.toml, and simplified an is_some_and
in the LP solver.
This commit is contained in:
Dylan M. Taylor committed 2026-10-28 23:07:55 -04:00
commit 355840a2a2
57 files changed
+4909 -119

No files matched your search

+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -86,7 +86,7 @@ collaboration, mobile, AI).
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; typed macros in the engine, no interface yet; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
## Languages
@@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
@@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
+7 -2
View File
@@ -3,8 +3,13 @@ use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, boo
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
let cmds: Vec<Json> =
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str())).map(|(id, p)| json!({"command": id, "params": p})).collect();
let cmds: Vec<Json> = s
.journal
.iter()
.skip(start)
.filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str()))
.map(|(id, p)| json!({"command": id, "params": p}))
.collect();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
+9 -2
View File
@@ -3,11 +3,18 @@
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::*;
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
Workbook,
};
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::*;
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::current_region;
pub fn specs() -> Vec<CommandSpec> {
+65 -24
View File
@@ -5,7 +5,7 @@
use serde_json::{Value as Json, json};
use super::*;
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
use crate::macros::recorder::RecordingStart;
pub fn specs() -> Vec<CommandSpec> {
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Record Macro",
["View", "Macros"],
None,
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, description? (when stopping; a taken name gets a suffix)} toggles recording; with relative references the selection when it starts is the example input → {recording, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}",
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
has_doc,
record
),
@@ -211,13 +211,13 @@ fn record(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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<Json> {
let d = s.doc()?;
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
None => None,
};
let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
Some(k) => {
let letter = crate::macros::parse_shortcut(k)
.ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
return Err(bad("macro.record", why));
}
Some(crate::macros::shortcut_text(letter))
}
None => None,
};
let description = match str_param(p, "description") {
Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => {
return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT)));
}
Some(t) => Some(t.to_string()),
None => None,
};
let mut start = RecordingStart::new(d, relative);
(start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
let input = start.selection.current().a1();
s.start_recording();
s.macro_recording = Some(start);
Ok(if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) })
let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
if let Some(n) = name {
out["name"] = json!(n);
}
Ok(out)
}
/// Stops recording and saves what was recorded as a macro, in the workbook where recording
@@ -295,8 +323,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
// input from the workbook as it is now.
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
let (doc, wb) = (d.uid, d.wb.clone());
// A name another macro has gets a suffix, so stopping never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
// A name given when stopping wins over the one given when starting; either one, if another
// macro has it, gets a suffix, so the save never replaces a macro.
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
Some(n) => crate::macros::unique_name(&wb, n),
None => crate::macros::next_name(&wb),
};
@@ -305,10 +334,20 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
// out and counted with the ones the recorder dropped.
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
if let Some(desc) = str_param(p, "description") {
if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
// The shortcut, unless another macro took it while recording; then it is left off and the
// result says so.
let mut shortcut_dropped = None;
if let Some(k) = &start.shortcut {
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
converted.mac.shortcut = Some(k.clone());
} else {
shortcut_dropped = Some(k.clone());
}
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
// Saved into that workbook even when another one is active now.
@@ -325,7 +364,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
Ok(
json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}),
)
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
if let Some(k) = shortcut_dropped {
out["shortcutDropped"] = json!(k);
}
Ok(out)
}
+1 -1
View File
@@ -199,7 +199,7 @@ fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budge
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
+65
View File
@@ -420,6 +420,71 @@ impl Taken {
}
}
/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a
/// macro cannot take.
pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[
('F', "Format Cells (Font) or Filter"),
('G', "Workbook Statistics"),
('H', "Replace"),
('J', "Ungroup"),
('K', "Group"),
('L', "Filter"),
('R', "Sort"),
('S', "Save As"),
('T', "AutoSum"),
('Z', "Redo"),
];
/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give
/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift):
/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift.
pub fn parse_shortcut(s: &str) -> Option<char> {
let letter = |t: &str| {
let mut chars = t.trim().chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()),
_ => None,
}
};
let t = s.trim().to_ascii_lowercase();
["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter)
}
/// How a shortcut is stored and shown: `Ctrl+Shift+M`.
pub fn shortcut_text(letter: char) -> String {
format!("Ctrl+Shift+{}", letter.to_ascii_uppercase())
}
/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it.
pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option<String> {
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<String, String> {
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())
+9 -1
View File
@@ -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<NamedInput>,
/// The name, shortcut and description given when recording started (Record Macro).
pub name: Option<String>,
pub shortcut: Option<String>,
pub description: Option<String>,
}
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,
}
}
}
+4 -2
View File
@@ -617,8 +617,10 @@ fn changed(change: fn(&mut serde_json::Value)) -> serde_json::Value {
#[test]
fn every_field_a_file_sets_has_a_limit() {
// A named edit to the sample file's JSON.
type Edit = (&'static str, fn(&mut serde_json::Value));
// The sample's steps: 0 a command, 1 a Repeat whose body holds an If whose else holds a Stop.
let refused: [(&str, fn(&mut serde_json::Value)); 14] = [
let refused: [Edit; 14] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT + 1))),
("shortcut line break", |v| v["shortcut"] = json!("Ctrl+\nM")),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS + 1])),
@@ -641,7 +643,7 @@ fn every_field_a_file_sets_has_a_limit() {
for (what, change) in refused {
assert!(parse_macro(&changed(change)).is_err(), "{what} past its limit is refused");
}
let accepted: [(&str, fn(&mut serde_json::Value)); 9] = [
let accepted: [Edit; 9] = [
("shortcut", |v| v["shortcut"] = json!(long(MAX_MACRO_SHORTCUT))),
("permission count", |v| v["permissions"] = json!(vec!["data.getData"; MAX_MACRO_PERMISSIONS])),
("output anchor", |v| v["output"]["anchor"] = json!(long(MAX_MACRO_REF))),
+82 -2
View File
@@ -2077,6 +2077,9 @@ fn kept_macros_count_toward_the_limit_and_keep_their_names() {
let err = s.execute(cmd, p).unwrap_err().to_string();
assert!(err.contains("newer version"), "{cmd}: {err}");
}
// Record Macro refuses a kept macro's name when recording starts.
let err = s.execute("macro.record", json!({"name": "future"})).unwrap_err().to_string();
assert!(err.contains("newer version") && !s.is_recording(), "{err}");
// A recording takes the next free name, around the kept one, and fills the last place.
s.execute("macro.record", json!({"relative": false})).unwrap();
set(&mut s, "A1", "typed");
@@ -2318,12 +2321,13 @@ fn a_recording_past_the_macro_size_is_cut_and_counted() {
#[test]
fn record_refuses_parameters_it_would_ignore() {
let mut s = s();
for p in [json!({"name": "Early"}), json!({"description": "d"}), json!({"relative": "no"}), json!({"stop": 1})] {
// `name`, `description` and `shortcut` count when starting (Record Macro); wrong kinds don't.
for p in [json!({"name": 5}), json!({"shortcut": true}), json!({"relative": "no"}), json!({"stop": 1})] {
assert!(s.execute("macro.record", p.clone()).is_err(), "{p}");
assert!(!s.is_recording(), "{p}");
}
s.execute("macro.record", json!({"relative": false, "name": null})).unwrap();
for p in [json!({"relative": true}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] {
for p in [json!({"relative": true}), json!({"shortcut": "K"}), json!({"stop": false}), json!({"cancel": true, "name": "x"}), json!({"name": 3})] {
assert!(s.execute("macro.record", p.clone()).is_err(), "{p}");
assert!(s.is_recording(), "{p}");
}
@@ -2354,3 +2358,79 @@ fn undo_while_recording_keeps_the_undone_step_as_excel_does() {
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
assert_eq!(v(&s, "A1").display(), "typed");
}
#[test]
fn record_checks_a_name_shortcut_and_description_when_it_starts() {
let mut s = s();
s.execute("macro.save", json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})).unwrap();
for (p, needle) in [
(json!({"name": "taken"}), "already exists"),
(json!({"name": "a\nb"}), "line breaks"),
(json!({"name": "x".repeat(300)}), "at most"),
(json!({"name": "New", "shortcut": "p"}), "already runs macro"),
(json!({"name": "New", "shortcut": "Ctrl+Shift+Z"}), "Redo"),
(json!({"name": "New", "shortcut": "F5"}), "one letter"),
(json!({"name": "New", "description": "d".repeat(100_000)}), "at most"),
] {
let e = s.execute("macro.record", p.clone()).unwrap_err().to_string();
assert!(e.contains(needle), "{p}: {e}");
assert!(!s.is_recording(), "{p}");
}
let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "j", "description": "Tidies"}));
// J is Ungroup on a Mac, so it is reserved; M is free.
assert!(r.is_err_and(|e| e.to_string().contains("Ungroup")));
let r = s.execute("macro.record", json!({"name": " Tidy ", "shortcut": "m", "description": "Tidies"})).unwrap();
assert_eq!(r["name"], json!("Tidy"));
set(&mut s, "A1", "1");
let r = s.execute("macro.record", json!({"stop": true})).unwrap();
assert_eq!(r["saved"], json!("Tidy"));
let m = s.execute("macro.get", json!({"name": "Tidy"})).unwrap();
assert_eq!((m["shortcut"].clone(), m["description"].clone()), (json!("Ctrl+Shift+M"), json!("Tidies")));
// Empty params record under the next MacroN, as before, and never open a dialog.
s.execute("macro.record", json!({})).unwrap();
set(&mut s, "A2", "2");
let r = s.execute("macro.record", json!({})).unwrap();
assert!(r["saved"].as_str().is_some_and(|n| n.starts_with("Macro")), "{r}");
assert!(s.take_ui_requests().is_empty(), "no dialog");
}
#[test]
fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() {
use crate::macros::{parse_shortcut, shortcut_conflict, shortcut_text};
assert_eq!(parse_shortcut("Ctrl+Shift+M"), Some('M'));
assert_eq!(parse_shortcut("⌘⇧k"), Some('K'));
assert_eq!(parse_shortcut(" q "), Some('Q'));
assert_eq!(parse_shortcut("cmd+shift+r"), Some('R'));
// Other modifiers are refused, not read as Ctrl+Shift.
for bad in ["Ctrl+Shift+F5", "Ctrl+Shift+", "", "Ctrl+Shift+é", "Ctrl+Alt+M", "Shift+M", "Alt+M", "Ctrl+M"] {
assert_eq!(parse_shortcut(bad), None, "{bad}");
}
assert_eq!(shortcut_text('m'), "Ctrl+Shift+M");
let mut wb = gridcraft_model::Workbook::default();
wb.macros.push(gridcraft_model::Macro { name: "A".into(), shortcut: Some("Ctrl+Shift+M".into()), ..Default::default() });
wb.macros.push(gridcraft_model::Macro { name: "B".into(), ..Default::default() });
assert!(shortcut_conflict(&wb, "B", 's').is_some_and(|m| m.contains("Save As")));
assert!(shortcut_conflict(&wb, "B", 'm').is_some_and(|m| m.contains("“A”")));
// A macro keeps its own letter.
assert_eq!(shortcut_conflict(&wb, "a", 'M'), None);
assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None);
}
#[test]
fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
let mut s = s();
s.execute("macro.record", json!({"name": "Tidy", "shortcut": "m"})).unwrap();
set(&mut s, "A1", "1");
// Another macro takes Ctrl+Shift+M before recording stops.
s.execute("macro.save", json!({"name": "Other", "shortcut": "Ctrl+Shift+M", "steps": []})).unwrap();
let r = s.execute("macro.record", json!({"stop": true})).unwrap();
assert_eq!(r["saved"], "Tidy");
assert_eq!(r["shortcutDropped"], "Ctrl+Shift+M", "{r}");
assert!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["shortcut"].is_null());
// A free one is kept and not reported.
s.execute("macro.record", json!({"name": "Keep", "shortcut": "q"})).unwrap();
set(&mut s, "A2", "2");
let r = s.execute("macro.record", json!({"stop": true})).unwrap();
assert!(r.get("shortcutDropped").is_none(), "{r}");
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
}
+4 -1
View File
@@ -8,7 +8,7 @@ use super::updates_ui;
use super::{
CellRef, HashMap, INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, Notice, Perf, RangeRef, Session, SheetApp, UiRequest, UiState, backstage,
clamp_interface_scale, clean_error, control, desktop_theme, dialogs, editor, floating_pictures, formula_bar, formula_locale, grid, i18n, json,
keytips, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets,
keytips, macro_ui, panes, pivot_pane, printing, ribbon, tabs, text_box, theme, widgets,
};
mod clipboard;
@@ -72,6 +72,7 @@ impl SheetApp {
clipboard_text_at_copy: None,
cut_text: None,
tab_run: None,
macros: macro_ui::MacroUi::default(),
started: now_ms(),
allow_close: false,
open_switch_windows: false,
@@ -235,6 +236,8 @@ impl SheetApp {
});
}
dialogs::show(self, &ctx);
macro_ui::forget_closed_editor(self);
macro_ui::question_box(self, &ctx);
widgets::message_box(self, &ctx);
widgets::toast(self, &ctx);
// Requests are admitted in `logic`, for the root viewport, so only its pass issues them.
+8
View File
@@ -330,6 +330,14 @@ impl SheetApp {
let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home);
self.open_backstage(page);
}
// The macro step editor stays until it closes itself (its draft, overlays and debug
// run go with it), so another dialog can't replace it.
_ if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") => {
self.message = Some((
"Macro".into(),
"Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(),
));
}
other => self.dialog = dialogs::Dialog::open(self, other, params),
}
}
+7
View File
@@ -12,6 +12,13 @@ impl SheetApp {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
return;
}
// The macro step editor asks about its own unsaved steps and debug run first; once it has
// closed, the window close goes on.
if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
crate::macro_editor::request_close(self, true);
return;
}
if !self.session.documents().iter().any(|d| d.is_dirty()) {
return;
}
+7
View File
@@ -282,6 +282,13 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
None => err("no dialog is open"),
},
"ui.dialog.cancel" => {
// The macro step editor's draft, overlays and debug run go with its own close.
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
return match crate::macro_editor::close_now(app) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
};
}
app.dialog = None;
ok(Json::Null)
}
+4 -7
View File
@@ -22,7 +22,6 @@ mod data;
mod find;
mod format_cells;
mod insert_function;
mod macros;
mod names;
mod paste;
mod show;
@@ -187,7 +186,7 @@ impl Dialog {
}
}
fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
title: title.into(),
@@ -946,11 +945,9 @@ impl Dialog {
)
}
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
"macros" => {
let mut d = Dialog::custom("macros", "Macros", json!({}));
d.result = app.session.run("view.macros", json!({})).ok();
d
}
"macros" => crate::macro_ui::open_dialog(app, &params),
"recordMacro" => return crate::macro_ui::open_record(app, &params),
"macroEditor" => return crate::macro_editor::open(app, &params),
"statistics" => {
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
-40
View File
@@ -1,40 +0,0 @@
use super::{Dialog, Json, SheetApp, json};
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
/// preserved and not executed.
pub(super) fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
ui.add_space(6.0);
let items: Vec<Json> = 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" }));
}
});
}
});
}
+19 -3
View File
@@ -7,7 +7,6 @@ use super::data::{advanced_filter_form, data_validation_form, sort};
use super::find::{find, go_to};
use super::format_cells::format_cells;
use super::insert_function::insert_function;
use super::macros::macros;
use super::names::{name_manager, paste_name};
use super::paste::{paste_special, paste_special_shortcuts};
use super::spelling::spelling;
@@ -107,6 +106,21 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let home = app.ui_window();
// The macro step editor is a large resizable window of its own.
if d.name == "macroEditor" {
let mut open = true;
crate::macro_editor::window(app, ctx, &mut d, &mut open);
if !open {
app.macros.editor = None;
app.grid.macro_overlay = None;
} else if app.ui_window() != home {
// Something run from the editor switched windows: the editor stays in its own.
app.return_dialog(home, d);
} else {
app.dialog = Some(d);
}
return;
}
if d.name == "pasteSpecial" {
paste_special_shortcuts(&mut d, ctx);
}
@@ -116,7 +130,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let width = match d.name.as_str() {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"macros" => 480.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
@@ -176,7 +191,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
});
}
"macros" => macros(app, ui, &mut d),
"macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open),
"recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open),
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
let r = if d.name == "comments" {
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
+71
View File
@@ -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<T: std::any::Any + Send + Sync>(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<T: std::any::Any + Send + Sync>(ui: &mut Ui, id: Id, payload: T) {
let Some(rect) = ui.ctx().data(|d| d.get_temp::<Rect>(id)) else { return };
let r = ui.interact(rect, id, Sense::drag());
drag_source(ui, &r, payload);
}
/// While dragging: outlines `zone` and draws the line where a drop at `y` would land among `rows`.
pub fn drop_marker(ui: &Ui, zone: Rect, rows: &[Rect], y: f32) {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("dnd_drop_marker")));
painter.rect_stroke(zone, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
let k = insert_index(rows, y);
let y = match (rows.get(k), k.checked_sub(1).and_then(|j| rows.get(j))) {
(Some(r), _) => r.top() - 2.0,
(None, Some(r)) => r.bottom() + 2.0,
(None, None) => zone.top() + 4.0,
};
painter.line_segment([pos2(zone.left() + 4.0, y), pos2(zone.right() - 4.0, y)], Stroke::new(2.0, t.accent));
}
/// The dragged item's label following the pointer.
pub fn ghost(ui: &Ui, text: &str, at: Pos2) {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("dnd_ghost")));
let galley = painter.layout_no_wrap(text.to_string(), theme::ui_font(12.0), t.text);
let rect = Rect::from_min_size(at + vec2(12.0, 8.0), galley.size() + vec2(12.0, 6.0));
painter.rect_filled(rect, 3.0, t.input_bg);
painter.rect_stroke(rect, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.galley(rect.min + vec2(6.0, 3.0), galley, t.text);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drop_lands_before_the_first_row_below_it() {
let rows: Vec<Rect> = (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);
}
}
+2
View File
@@ -248,6 +248,8 @@ pub struct GridState {
pub wheel_zoom: Option<WheelZoom>,
/// 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<crate::macro_editor::MacroOverlay>,
}
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
+4 -1
View File
@@ -14,7 +14,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
// An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
// So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an
// arrow key would move the selection the menu fills from).
// arrow key would move the selection the menu fills from), and a macro's question.
if editing
|| app.dialog.is_some()
|| other_focus
@@ -22,6 +22,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
|| app.grid.filter_menu.is_some()
|| app.grid.fill_menu.is_some()
|| matches!(app.grid.drag, Drag::Fill { menu: true, .. })
|| app.macros.question.is_some()
{
return;
}
@@ -143,6 +144,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
Key::F4 if !cmd => {}
// Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it.
_ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {}
_ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => {
crate::ribbon::shortcut(app, key, m)
}
+3
View File
@@ -722,6 +722,9 @@ pub(crate) fn paint_overlays(input: OverlayInput<'_>) {
ui.label(tip);
});
}
if let Some(o) = &app.grid.macro_overlay {
crate::macro_editor::paint_overlay(p, geo, sh, o);
}
// Trace precedents / dependents arrows.
if let Some(trace) = &app.grid.trace
&& app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision)
+9
View File
@@ -557,6 +557,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"AutoSave" => "Automatické ukládání",
"AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)",
"All sheets" => "Všechny listy",
"Record Macro" => "Záznam makra",
"Stop Recording" => "Zastavit záznam",
"Use Relative References" => "Použít relativní odkazy",
"Run\nMacro" => "Spustit makro",
"Macros…" => "Makra…",
"Recording" => "Záznam",
"Stop" => "Zastavit",
"New Macro" => "Nové makro",
"Show" => "Zobrazit",
_ => return None,
})
}
+10
View File
@@ -462,6 +462,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Alle Blätter",
"New sheet (⇧F11)" => "Neues Blatt (⇧F11)",
"Text Box" => "Textfeld",
"Macros" => "Makros",
"Record Macro" => "Makro aufzeichnen",
"Stop Recording" => "Aufzeichnung beenden",
"Use Relative References" => "Relative Verweise verwenden",
"Run\nMacro" => "Makro ausführen",
"Macros…" => "Makros…",
"Recording" => "Aufzeichnung",
"Stop" => "Beenden",
"New Macro" => "Neues Makro",
"Show" => "Anzeigen",
_ => return None,
})
}
+8
View File
@@ -983,6 +983,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"A CSV file, web page or PDF is a copy: the workbook keeps its own file." => {
"Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο."
}
"Record Macro" => "Καταγραφή μακροεντολής",
"Stop Recording" => "Διακοπή καταγραφής",
"Use Relative References" => "Χρήση σχετικών αναφορών",
"Run\nMacro" => "Εκτέλεση μακροεντολής",
"Macros…" => "Μακροεντολές…",
"Recording" => "Καταγραφή",
"New Macro" => "Νέα μακροεντολή",
"Show" => "Εμφάνιση",
_ => return None,
})
}
+10
View File
@@ -478,6 +478,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Todas las hojas",
"New sheet (⇧F11)" => "Nueva hoja (⇧F11)",
"Text Box" => "Cuadro de texto",
"Macros" => "Macros",
"Record Macro" => "Grabar macro",
"Stop Recording" => "Detener grabación",
"Use Relative References" => "Usar referencias relativas",
"Run\nMacro" => "Ejecutar macro",
"Macros…" => "Macros…",
"Recording" => "Grabando",
"Stop" => "Detener",
"New Macro" => "Nueva macro",
"Show" => "Mostrar",
_ => return None,
})
}
+10
View File
@@ -450,6 +450,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Toutes les feuilles",
"New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)",
"Text Box" => "Zone de texte",
"Macros" => "Macros",
"Record Macro" => "Enregistrer une macro",
"Stop Recording" => "Arrêter l’enregistrement",
"Use Relative References" => "Utiliser les références relatives",
"Run\nMacro" => "Exécuter la macro",
"Macros…" => "Macros…",
"Recording" => "Enregistrement",
"Stop" => "Arrêter",
"New Macro" => "Nouvelle macro",
"Show" => "Afficher",
_ => return None,
})
}
+8
View File
@@ -986,6 +986,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Layout rapporto",
"Compact" => "Compatto",
"Tabular" => "Tabulare",
"Record Macro" => "Registra macro",
"Stop Recording" => "Interrompi registrazione",
"Use Relative References" => "Usa riferimenti relativi",
"Run\nMacro" => "Esegui macro",
"Macros…" => "Macro…",
"Recording" => "Registrazione",
"New Macro" => "Nuova macro",
"Show" => "Mostra",
_ => return None,
})
}
+8
View File
@@ -423,6 +423,14 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"),
("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"),
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
("Record Macro", "マクロの記録"),
("Stop Recording", "記録終了"),
("Use Relative References", "相対参照で記録"),
("Run\nMacro", "マクロの実行"),
("Macros…", "マクロ…"),
("Recording", "記録中"),
("New Macro", "新しいマクロ"),
("Show", "表示"),
];
/// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`].
+10
View File
@@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "모든 시트",
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
"Text Box" => "텍스트 상자",
"Macros" => "매크로",
"Record Macro" => "매크로 기록",
"Stop Recording" => "기록 중지",
"Use Relative References" => "상대 참조로 기록",
"Run\nMacro" => "매크로 실행",
"Macros…" => "매크로…",
"Recording" => "기록 중",
"Stop" => "중지",
"New Macro" => "새 매크로",
"Show" => "표시",
_ => return None,
})
}
+9
View File
@@ -527,6 +527,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Zoom In" => "Inzoomen",
"Zoom Out" => "Uitzoomen",
"Dismiss" => "Negeren",
"Record Macro" => "Macro opnemen",
"Stop Recording" => "Opname stoppen",
"Use Relative References" => "Relatieve verwijzingen gebruiken",
"Run\nMacro" => "Macro uitvoeren",
"Macros…" => "Macro's…",
"Recording" => "Opnemen",
"Stop" => "Stoppen",
"New Macro" => "Nieuwe macro",
"Show" => "Weergeven",
_ => return None,
})
}
+8
View File
@@ -977,6 +977,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Układ raportu",
"Compact" => "Kompaktowy",
"Tabular" => "Tabelaryczny",
"Record Macro" => "Zarejestruj makro",
"Stop Recording" => "Zatrzymaj rejestrowanie",
"Use Relative References" => "Użyj odwołań względnych",
"Run\nMacro" => "Uruchom makro",
"Macros…" => "Makra…",
"Recording" => "Rejestrowanie",
"New Macro" => "Nowe makro",
"Show" => "Pokaż",
_ => return None,
})
}
+8
View File
@@ -982,6 +982,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)"
}
"All sheets" => "Todas as planilhas",
"Record Macro" => "Gravar Macro",
"Stop Recording" => "Parar Gravação",
"Use Relative References" => "Usar Referências Relativas",
"Run\nMacro" => "Executar Macro",
"Macros…" => "Macros…",
"Recording" => "Gravando",
"New Macro" => "Nova Macro",
"Show" => "Mostrar",
_ => return None,
})
}
+8
View File
@@ -1039,6 +1039,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Format Chart Area…" => "Formatare zonă diagramă…",
"Error checking options" => "Opțiuni de verificare a erorilor",
"Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate",
"Record Macro" => "Înregistrare macrocomandă",
"Stop Recording" => "Oprire înregistrare",
"Use Relative References" => "Utilizare referințe relative",
"Run\nMacro" => "Rulare macrocomandă",
"Macros…" => "Macrocomenzi…",
"Recording" => "Înregistrare",
"New Macro" => "Macrocomandă nouă",
"Show" => "Afișare",
_ => return None,
})
}
+10
View File
@@ -448,6 +448,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Все листы",
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
"Text Box" => "Надпись",
"Macros" => "Макросы",
"Record Macro" => "Запись макроса",
"Stop Recording" => "Остановить запись",
"Use Relative References" => "Относительные ссылки",
"Run\nMacro" => "Выполнить макрос",
"Macros…" => "Макросы…",
"Recording" => "Запись",
"Stop" => "Остановить",
"New Macro" => "Новый макрос",
"Show" => "Показать",
_ => return None,
})
}
+10
View File
@@ -446,6 +446,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "Сви листови",
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
"Text Box" => "Текстуално поље",
"Macros" => "Makroi",
"Record Macro" => "Snimi makro",
"Stop Recording" => "Zaustavi snimanje",
"Use Relative References" => "Koristi relativne reference",
"Run\nMacro" => "Pokreni makro",
"Macros…" => "Makroi…",
"Recording" => "Snimanje",
"Stop" => "Zaustavi",
"New Macro" => "Novi makro",
"Show" => "Prikaži",
_ => return None,
})
}
+10
View File
@@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Print" => "Yazdır",
"Print What" => "Yazdırılacaklar",
"Text Box" => "Metin Kutusu",
"Macros" => "Makrolar",
"Record Macro" => "Makro Kaydet",
"Stop Recording" => "Kaydı Durdur",
"Use Relative References" => "Göreli Başvuruları Kullan",
"Run\nMacro" => "Makro Çalıştır",
"Macros…" => "Makrolar…",
"Recording" => "Kaydediliyor",
"Stop" => "Durdur",
"New Macro" => "Yeni Makro",
"Show" => "Göster",
_ => return None,
})
}
+8
View File
@@ -981,6 +981,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Report Layout" => "Report Layout",
"Compact" => "Compact",
"Tabular" => "Tabular",
"Record Macro" => "Записати макрос",
"Stop Recording" => "Зупинити запис",
"Use Relative References" => "Використовувати відносні посилання",
"Run\nMacro" => "Запустити макрос",
"Macros…" => "Макроси…",
"Recording" => "Запис",
"New Macro" => "Новий макрос",
"Show" => "Показати",
_ => return None,
})
}
+10
View File
@@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Enable iterative calculation" => "Bật tính toán lặp",
"Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.",
"Text Box" => "Hộp văn bản",
"Macros" => "Macro",
"Record Macro" => "Ghi Macro",
"Stop Recording" => "Dừng Ghi",
"Use Relative References" => "Dùng Tham chiếu Tương đối",
"Run\nMacro" => "Chạy Macro",
"Macros…" => "Macro…",
"Recording" => "Đang ghi",
"Stop" => "Dừng",
"New Macro" => "Macro Mới",
"Show" => "Hiện",
_ => return None,
})
}
+10
View File
@@ -438,6 +438,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"All sheets" => "所有工作表",
"New sheet (⇧F11)" => "新建工作表 (⇧F11)",
"Text Box" => "文本框",
"Macros" => "宏",
"Record Macro" => "录制宏",
"Stop Recording" => "停止录制",
"Use Relative References" => "使用相对引用",
"Run\nMacro" => "运行宏",
"Macros…" => "宏…",
"Recording" => "正在录制",
"Stop" => "停止",
"New Macro" => "新建宏",
"Show" => "显示",
_ => return None,
})
}
+65 -1
View File
@@ -135,6 +135,25 @@ pub enum Icon {
OpenFolder,
Info,
Print,
/// A macro: a script page with a run arrow.
Macro,
/// Record Macro: a ring around the recording dot.
Record,
/// Use Relative References: a cell and an arrow to a cell offset from it.
Relative,
/// A reference box's button: shrink the window to the box (Collapse) or restore it (Expand).
Collapse,
Expand,
/// The macro debugger's Run: a play triangle.
Play,
/// Step: an arrow down onto a line.
Step,
/// Continue: a bar and a play triangle.
Continue,
/// Run to Here: an arrow to a marked line.
RunTo,
/// Stop: a square.
StopSquare,
}
struct G<'a> {
@@ -194,11 +213,12 @@ impl G<'_> {
struct Palette {
paper: Color32,
cat: [Color32; 8],
recording: Color32,
}
impl Palette {
fn of(t: &Tokens) -> Palette {
Palette { paper: t.paper, cat: t.cat }
Palette { paper: t.paper, cat: t.cat, recording: t.recording }
}
fn cat(&self, c: Cat) -> Color32 {
@@ -731,6 +751,50 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1);
g.circle(12.3, 7.6, 0.6, Some(c), None);
}
Icon::Macro => {
g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c));
for y in [4.5, 7.0, 9.5] {
g.stroke(&[(4.5, y), (9.5, y)], light);
}
g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green);
}
Icon::Record => {
g.circle(8.0, 8.0, 6.0, None, Some(c));
g.circle(8.0, 8.0, 3.5, Some(pal.recording), None);
}
Icon::Relative => {
g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c));
g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c));
g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1);
g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c);
}
Icon::Collapse | Icon::Expand => {
// A small sheet with a selected block and an arrow pointing up (collapse) or down.
g.rect(2.0, 6.5, 12.0, 7.5, None, Some(c));
g.rect(4.0, 9.0, 4.0, 3.0, Some(c), None);
if icon == Icon::Collapse {
g.poly(&[(8.0, 1.0), (11.5, 4.5), (4.5, 4.5)], c);
} else {
g.poly(&[(8.0, 5.5), (11.5, 2.0), (4.5, 2.0)], c);
}
}
Icon::Play => g.poly(&[(4.0, 2.5), (13.0, 8.0), (4.0, 13.5)], green),
Icon::Step => {
g.line(&[(8.0, 1.5), (8.0, 9.0)], c, 1.3);
g.poly(&[(4.8, 7.0), (11.2, 7.0), (8.0, 10.8)], c);
g.line(&[(3.0, 13.5), (13.0, 13.5)], c, 1.3);
}
Icon::Continue => {
g.rect(2.5, 2.5, 2.2, 11.0, Some(c), None);
g.poly(&[(6.5, 2.5), (14.0, 8.0), (6.5, 13.5)], green);
}
Icon::RunTo => {
g.line(&[(2.0, 5.0), (9.0, 5.0)], c, 1.3);
g.poly(&[(8.0, 2.0), (12.0, 5.0), (8.0, 8.0)], c);
g.line(&[(2.0, 11.5), (14.0, 11.5)], c, 1.6);
g.line(&[(13.0, 3.0), (13.0, 9.0)], c, 1.1);
}
Icon::StopSquare => g.rect(3.0, 3.0, 10.0, 10.0, Some(pal.recording), None),
}
}
@@ -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
+6 -6
View File
@@ -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",
+5
View File
@@ -15,6 +15,7 @@ pub mod control;
mod craft_fonts_policy;
pub mod credits;
pub mod dialogs;
pub mod dnd;
#[cfg(not(target_arch = "wasm32"))]
pub mod doc_windows;
pub mod editor;
@@ -27,6 +28,8 @@ pub mod i18n;
pub mod icons;
pub mod keymap;
pub mod keytips;
pub mod macro_editor;
pub mod macro_ui;
pub mod outline;
pub mod panes;
pub mod paper_view;
@@ -266,6 +269,8 @@ pub struct SheetApp {
/// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on).
/// Enter on that cell goes to the next row in the starting column, as in Excel (#911).
tab_run: Option<(u64, usize, u32, CellRef)>,
/// Automate › Macros: relative recording, recent macros, a macro's question.
pub macros: macro_ui::MacroUi,
pub started: f64,
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
File diff suppressed because it is too large. Load diff
+677
View File
@@ -0,0 +1,677 @@
//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status
//! bar), running a macro and answering its questions (approve it, make room for its output), the
//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record
//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes
//! through the `macro.*` and `automate.*` commands.
use egui::{Sense, Ui, vec2};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::dialogs::Dialog;
use crate::theme::{self, Tokens};
/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree.
pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text};
/// How many macros the Run Macro menu keeps as recent.
const RECENT: usize = 8;
/// Macro state that belongs to the window, not the workbook.
#[derive(Clone, Debug)]
pub struct MacroUi {
/// Use Relative References: record steps relative to the selection when recording started.
pub relative: bool,
/// Macros run from this window, most recent first (Run Macro's menu).
pub recent: Vec<String>,
/// A question from a macro command, answered with one of its buttons.
pub question: Option<Question>,
/// The step editor, while it is open.
pub editor: Option<crate::macro_editor::Editor>,
/// A debugger result from a question's button, for the editor to take in.
pub debug_result: Option<Json>,
}
impl Default for MacroUi {
fn default() -> Self {
MacroUi { relative: true, recent: vec![], question: None, editor: None, debug_result: None }
}
}
impl MacroUi {
fn note_recent(&mut self, name: &str) {
self.recent.retain(|n| !n.eq_ignore_ascii_case(name));
self.recent.insert(0, name.to_string());
self.recent.truncate(RECENT);
}
}
/// A question from a command (approve this macro? overwrite the data in the way?). Each choice
/// runs the command again with its parameters added; Cancel drops it.
#[derive(Clone, Debug)]
pub struct Question {
/// The workbook (`DocState::uid`) the question is about: the answer runs only there.
pub doc: u64,
pub title: String,
pub text: String,
pub command: String,
pub params: Json,
pub choices: Vec<(String, Json)>,
}
/// The macros of the active workbook: (name, description, shortcut).
pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option<String>)> {
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<usize> {
app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped))
}
/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was
/// recorded as a macro.
pub fn toggle_recording(app: &mut SheetApp) {
if app.session.is_recording() {
stop_recording(app);
} else {
app.open_dialog("recordMacro", json!({}));
}
}
/// Stops recording and says what was saved.
pub fn stop_recording(app: &mut SheetApp) {
match app.run("macro.record", json!({"stop": true})) {
Ok(v) => match v["saved"].as_str() {
Some(name) => {
let steps = v["steps"].as_u64().unwrap_or(0);
app.macros.note_recent(name);
let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" });
if let Some(k) = v["shortcutDropped"].as_str() {
text.push_str(&format!(" without {k}: another macro took it while recording"));
}
app.toast = Some((text, crate::now_ms()));
}
None => app.toast = Some(("Recording stopped".into(), crate::now_ms())),
},
Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))),
}
}
/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt.
pub fn run_macro(app: &mut SheetApp, name: &str) {
call(app, "Run Macro", "macro.run", json!({"name": name}));
}
/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in
/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran.
pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option<Json> {
match app.run(id, params.clone()) {
Ok(v) => {
note_result(app, id, &params, &v);
Some(v)
}
Err(e) => {
if !ask(app, title, id, &params, &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<String> {
let rest = e.split_once(prefix)?.1;
Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string())
}
/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro
/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land
/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question.
pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool {
let (text, choices) = if let Some(q) = question_text(e, "approve: ") {
(q, vec![("Run".to_string(), json!({"approve": true}))])
} else if let Some(q) = question_text(e, "confirm: ") {
if id.starts_with("macro.") {
// The engine's hint for programs ("run again with onConflict …") becomes the buttons.
let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']);
(
format!("{what}. Overwrite them, or move the data out of the way?"),
vec![
("Overwrite".to_string(), json!({"onConflict": "overwrite"})),
("Shift Down".to_string(), json!({"onConflict": "shiftDown"})),
("Shift Right".to_string(), json!({"onConflict": "shiftRight"})),
],
)
} else {
(q, vec![("OK".to_string(), json!({"overwrite": true}))])
}
} else {
return false;
};
let doc = app.session.active().map_or(0, |d| d.uid);
app.macros.question = Some(Question { doc, title: title.into(), text, command: id.into(), params: params.clone(), choices });
true
}
/// After a macro command ran (directly or from a prompt's button): remember the macro and say
/// what it did.
pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) {
if id == "macro.debug" {
app.macros.debug_result = Some(v.clone());
return;
}
if id != "macro.run" {
return;
}
let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string();
if name.is_empty() {
return;
}
app.macros.note_recent(&name);
let ran = v["ran"].as_u64().unwrap_or(0);
let text = match v["stopped"].as_str() {
Some(msg) => format!("Macro “{name}” stopped: {msg}"),
None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }),
};
app.toast = Some((text, crate::now_ms()));
}
/// Drops the step editor once its workbook is closed. The editor's dialog stays with its
/// workbook's window, so it may not be drawn again to notice.
pub fn forget_closed_editor(app: &mut SheetApp) {
if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) {
app.macros.editor = None;
app.grid.macro_overlay = None;
}
}
/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel.
pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) {
if app.message.is_some() {
return;
}
let Some(q) = app.macros.question.clone() else { return };
let mut answer: Option<Option<Json>> = None;
egui::Modal::new(egui::Id::unique("gridcraft.macro_question")).show(ctx, |ui| {
ui.set_width(400.0);
ui.label(egui::RichText::new(&q.title).strong());
ui.add(egui::Label::new(&q.text).wrap());
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(None);
}
// Right to left: list the choices reversed, so the first one ends up next to Cancel
// and Enter picks it.
for (k, (label, extra)) in q.choices.iter().enumerate().rev() {
if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked()
|| (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
answer = Some(Some(extra.clone()));
}
}
});
});
match answer {
// Another workbook became active meanwhile (an agent): the answer would run there.
Some(Some(_)) if app.session.active().is_none_or(|d| d.uid != q.doc) => {
app.macros.question = None;
let book = app.session.documents().iter().find(|d| d.uid == q.doc).map(|d| d.display_title()).unwrap_or_default();
app.message =
Some((q.title.clone(), format!("The question was about “{book}”, which is no longer the active workbook, so nothing was done.")));
}
Some(Some(extra)) => {
app.macros.question = None;
let mut params = q.params.clone();
if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) {
m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone())));
}
// A second question (approve, then make room) asks again.
call(app, &q.title, &q.command, params);
}
Some(None) => app.macros.question = None,
None => {}
}
}
/// The status bar while a macro is debugged ("Debugging “Name”", with Show when the editor is
/// closed) and while recording (a red dot, "Recording · 7 steps" and Stop).
pub fn status(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if let Some(name) = app.session.macro_debug.as_ref().map(|d| d.name.clone()) {
ui.add_space(12.0);
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().circle_filled(r.center(), 4.5, t.breakpoint);
ui.label(egui::RichText::new(format!("Debugging “{name}”")).font(theme::ui_font(12.5)).color(t.text));
if app.macros.editor.is_none() {
let show =
ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Show")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0)));
if show.on_hover_text("Open the macro in the step editor").clicked() {
app.open_dialog("macroEditor", json!({"macro": name}));
}
}
}
let Some(n) = recorded_steps(app) else { return };
ui.add_space(12.0);
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().circle_filled(r.center(), 4.5, t.recording);
let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" });
ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text));
let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0)));
if stop.on_hover_text("Stop recording and save the macro").clicked() {
stop_recording(app);
}
}
// ---------------------------------------------------------------- shortcuts
/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own
/// shortcuts win over a macro's (a file can carry any letter).
pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option<String> {
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<String>,
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<Entry>, bool) {
let mut out: Vec<Entry> = app
.session
.active()
.map(|doc| {
doc.wb
.macros
.iter()
.map(|m| Entry {
name: m.name.clone(),
kind: Kind::Macro,
description: m.description.clone(),
shortcut: m.shortcut.clone(),
steps: m.step_count() as u64,
})
.collect()
})
.unwrap_or_default();
let mut vba = false;
for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() {
if it.get("kind").and_then(Json::as_str) == Some("vba") {
vba = true;
continue;
}
let Some(name) = it.get("name").and_then(Json::as_str) else { continue };
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps });
}
(out, vba)
}
/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros`
/// (the Automate scripts and a file's VBA project), as before macros existed.
pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog {
let names = macro_list(app);
let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned();
let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default();
let doc = app.session.active().map_or(0, |d| d.uid);
let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro", "doc": doc}));
d.result = app.session.run("view.macros", json!({})).ok();
d
}
/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the
/// description under them; Options… switches to the shortcut and description of one macro.
pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
// Its rows and the Options pane are the workbook's it opened on: another workbook becoming
// active (an agent) closes it rather than run, delete or change a macro of the same name there.
let active = app.session.active().map(|doc| doc.uid);
if d.values.get("doc").and_then(Json::as_u64).is_some_and(|doc| Some(doc) != active) {
*open = false;
return;
}
// Scripts can change while the dialog is open (Delete): read them again when the workbook does.
let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0);
if d.values.get("revision").and_then(Json::as_u64) != Some(rev) {
d.result = app.session.run("view.macros", json!({})).ok();
d.values.insert("revision".into(), json!(rev));
}
if d.values.get("options").and_then(Json::as_bool) == Some(true) {
options(app, ui, d);
return;
}
// Scripts that came with a file stay disabled until the user trusts the workbook (#2178).
let untrusted = d
.result
.as_ref()
.and_then(Json::as_array)
.is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)));
if untrusted {
ui.horizontal(|ui| {
ui.weak("Scripts in this workbook came from a file and are disabled.");
if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() {
let _ = app.run("automate.trustScripts", json!({"trusted": true}));
d.result = app.session.run("view.macros", json!({})).ok();
}
});
ui.add_space(6.0);
}
let (list, vba) = entries(app, d);
let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string();
let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro };
let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(330.0);
let label = ui.label("Macro name:");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).labelled_by(label.id).changed() {
kind = Kind::Macro;
}
egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(326.0, 200.0));
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
if list.is_empty() {
ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim));
}
for e in &list {
let label = match (e.kind, &e.shortcut) {
(Kind::Macro, Some(k)) => format!("{} {k}", e.name),
(Kind::Macro, None) => e.name.clone(),
(Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }),
};
let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim());
let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0)));
if r.clicked() {
name = e.name.clone();
kind = e.kind;
}
if r.double_clicked() {
*open = false;
run_entry(app, e);
}
}
});
});
});
ui.vertical(|ui| {
ui.add_space(18.0);
let w = vec2(100.0, 24.0);
let target = name.trim().to_string();
let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned();
let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro);
if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked()
&& let Some(e) = &found
{
*open = false;
run_entry(app, e);
}
if ui
.add_enabled(is_macro, egui::Button::new("Step Into").min_size(w))
.on_hover_text("Open the macro in the step editor and stop before its first step")
.clicked()
{
*open = false;
app.open_dialog("macroEditor", json!({"macro": target, "debug": true}));
}
if ui.add_enabled(is_macro, egui::Button::new("Edit").min_size(w)).clicked() {
*open = false;
app.open_dialog("macroEditor", json!({"macro": target}));
}
let free = !target.is_empty() && !list.iter().any(|e| e.kind == Kind::Macro && e.name.eq_ignore_ascii_case(&target));
if ui.add_enabled(free, egui::Button::new("Create").min_size(w)).on_hover_text("Write a new macro with this name").clicked() {
*open = false;
app.open_dialog("macroEditor", json!({"new": target}));
}
if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked()
&& let Some(e) = &found
{
let (title, command, what) = match e.kind {
Kind::Macro => ("Delete Macro", "macro.delete", "macro"),
Kind::Script => ("Delete Script", "automate.deleteScript", "script"),
};
app.macros.question = Some(Question {
doc: app.session.active().map_or(0, |d| d.uid),
title: title.into(),
text: format!("Do you want to delete {what} “{}”?", e.name),
command: command.into(),
params: json!({"name": e.name}),
choices: vec![("Delete".into(), json!({}))],
});
}
if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked()
&& let Some(e) = &found
{
let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default();
d.values.insert("options".into(), json!(true));
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(e.description));
}
ui.add_space(12.0);
if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() {
*open = false;
}
});
});
ui.add_space(6.0);
ui.label("Description");
let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default();
ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim));
if vba {
ui.add_space(6.0);
ui.label(
egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim),
);
}
d.values.insert("name".into(), json!(name));
d.values.insert("kind".into(), json!(kind.key()));
}
/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script.
fn run_entry(app: &mut SheetApp, e: &Entry) {
match e.kind {
Kind::Macro => run_macro(app, &e.name),
Kind::Script => {
call(app, "Run Script", "automate.runScript", json!({"name": e.name}));
}
}
}
/// Macro Options: the shortcut letter and the description of the selected macro.
fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) {
let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string();
ui.label(egui::RichText::new(format!("Macro name: {name}")).strong());
ui.add_space(4.0);
ui.label("Shortcut key:");
let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string();
shortcut_field(ui, &mut key, "macro_shortcut");
let letter = parse_shortcut(&key);
let conflict = match (key.trim().is_empty(), letter, app.session.active()) {
(true, ..) => None,
(false, None, _) => Some("Use a letter from A to Z.".to_string()),
(false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c),
(false, Some(_), None) => None,
};
if let Some(c) = &conflict {
ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger));
}
ui.add_space(4.0);
let label = ui.label("Description:");
let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string();
ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")).labelled_by(label.id);
ui.add_space(6.0);
let mut done = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() {
done = true;
}
if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() {
done = save_options(app, &name, letter, &desc);
}
});
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(desc));
if done {
d.values.remove("options");
}
}
/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter.
fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) {
ui.horizontal(|ui| {
let label = ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" });
ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)).labelled_by(label.id);
});
*key = key.to_ascii_uppercase();
}
/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown).
pub fn save_options(app: &mut SheetApp, name: &str, letter: Option<char>, description: &str) -> bool {
let mut m = match app.run("macro.get", json!({"name": name})) {
Ok(m) if m.is_object() => m,
Ok(_) => return false,
Err(e) => {
app.message = Some(("Macro Options".into(), crate::clean_error(&e)));
return false;
}
};
if let Some(o) = m.as_object_mut() {
match letter {
Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))),
None => o.remove("shortcut"),
};
o.insert("description".into(), json!(description));
}
// Macro Options changes a macro that exists, so the save replaces it.
match app.run("macro.save", json!({"macro": m, "replace": true})) {
Ok(_) => true,
Err(e) => {
app.message = Some(("Macro Options".into(), crate::clean_error(&e)));
false
}
}
}
// ---------------------------------------------------------------- Record Macro dialog
/// Record Macro: the macro's name, shortcut and description before recording starts. While
/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel).
pub fn open_record(app: &mut SheetApp, params: &Json) -> Option<Dialog> {
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<String> {
let doc = app.session.active()?;
if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) {
return Some(e);
}
if key.trim().is_empty() {
return None;
}
match parse_shortcut(key) {
None => Some("Use a letter from A to Z for the shortcut.".into()),
Some(c) => shortcut_conflict(&doc.wb, name, c),
}
}
/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the
/// shortcut is not usable, and the reason shows under the fields.
pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let mut name = get(d, "name");
let mut key = get(d, "shortcut");
let mut desc = get(d, "description");
let label = ui.label("Macro name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")).labelled_by(label.id);
ui.add_space(4.0);
ui.label("Shortcut key:");
shortcut_field(ui, &mut key, "record_shortcut");
ui.add_space(4.0);
let label = ui.label("Description:");
ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")).labelled_by(label.id);
ui.checkbox(&mut app.macros.relative, "Use Relative References");
let problem = record_problem(app, &name, &key);
if let Some(p) = &problem {
ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger));
}
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() {
*open = false;
}
if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() {
let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative});
if !key.trim().is_empty() {
p["shortcut"] = json!(key.trim());
}
match app.run("macro.record", p) {
Ok(_) => *open = false,
Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))),
}
}
});
d.values.insert("name".into(), json!(name));
d.values.insert("shortcut".into(), json!(key));
d.values.insert("description".into(), json!(desc));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn questions_are_read_from_the_error() {
let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)";
assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened."));
assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None);
assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?"));
}
}
+66 -1
View File
@@ -1,10 +1,75 @@
use super::controls::control_keytip;
use super::controls::{control_keytip, sep};
use super::{Icon, SheetApp, Ui, big_button, json};
use crate::theme::Tokens;
use crate::widgets::{big_split_button, small_button, small_toggle_button};
use egui::vec2;
pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) {
// Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative
// References; Run Macro, whose main part runs the last macro and whose menu lists them all.
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
}
let recording = app.session.is_recording();
ui.vertical(|ui| {
let (label, tip) =
if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") };
let r = small_button(ui, Icon::Record, label, tip, false);
control_keytip(app, ui, &r, "macro.record");
if r.clicked() {
crate::macro_ui::toggle_recording(app);
}
let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data";
let rel = app.macros.relative;
let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner;
if r.clicked() && !recording {
app.macros.relative = !rel;
}
if small_button(ui, Icon::Macro, "New Macro", "Write a macro step by step", false).clicked() {
app.open_dialog("macroEditor", json!({}));
}
});
let macros = crate::macro_ui::macro_list(app);
let recent: Vec<String> = 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() {
+1
View File
@@ -340,6 +340,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
gridcraft_engine::Mode::Point => "Point",
};
text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
crate::macro_ui::status(app, ui);
if let Some(d) = app.session.active() {
if d.wb.vba_project.is_some() {
ui.add_space(12.0);
+42
View File
@@ -104,6 +104,16 @@ pub struct Tokens {
pub sheet_control_text: Color32,
/// A placeholder fill on the sheet (a picture that has not loaded).
pub sheet_track: Color32,
/// The macro recording dot (Record Macro, the status bar).
pub recording: Color32,
/// A breakpoint dot in the macro step editor's gutter.
pub breakpoint: Color32,
/// Macro overlays on the sheet (Check and debugging): the inputs, the cells the macro writes,
/// the cells it would write over, and the current step's target. Sheet colours.
pub sheet_macro_input: Color32,
pub sheet_macro_footprint: Color32,
pub sheet_macro_conflict: Color32,
pub sheet_macro_target: Color32,
}
/// The eight category colours, in the order of [`Tokens::cat`].
@@ -131,6 +141,12 @@ const fn rgb(hex: u32) -> Color32 {
/// theme). The light theme's formula bar is white too, so it uses them as well.
const SHEET_REF_COLORS: [Color32; 8] =
[rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];
/// Macro overlays on the sheet, the same in both themes (the sheet stays white).
const SHEET_MACRO_INPUT: Color32 = rgb(0x2E6FD8);
const SHEET_MACRO_FOOTPRINT: Color32 = rgb(0x23803D);
const SHEET_MACRO_CONFLICT: Color32 = rgb(0xC42B1C);
const SHEET_MACRO_TARGET: Color32 = rgb(0x6B3FC0);
/// Code colours for one background; plain text uses the surface's own text colour.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Syntax {
@@ -222,6 +238,12 @@ impl Tokens {
sheet_control_border: SHEET_CONTROL_BORDER,
sheet_control_text: SHEET_CONTROL_TEXT,
sheet_track: SHEET_TRACK,
recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)),
breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)),
sheet_macro_input: SHEET_MACRO_INPUT,
sheet_macro_footprint: SHEET_MACRO_FOOTPRINT,
sheet_macro_conflict: SHEET_MACRO_CONFLICT,
sheet_macro_target: SHEET_MACRO_TARGET,
}
}
@@ -363,6 +385,22 @@ mod contrast_tests {
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
add("recording dot on ribbon", t.recording, t.ribbon, GLYPH);
add("recording dot on status bar", t.recording, t.status_bar, GLYPH);
for (name, c) in [
("macro input", t.sheet_macro_input),
("macro footprint", t.sheet_macro_footprint),
("macro conflict", t.sheet_macro_conflict),
("macro target", t.sheet_macro_target),
] {
add(&format!("{name} outline on sheet"), c, t.grid_bg, GLYPH);
}
// The macro step editor: breakpoint dots in the gutter, error markers, the current step.
add("breakpoint on window", t.breakpoint, t.window, GLYPH);
add("breakpoint on current step", t.breakpoint, t.accent_soft, GLYPH);
add("text on current step", t.text, t.accent_soft, TEXT);
add("current step arrow", t.accent, t.accent_soft, GLYPH);
add("error marker on window", t.danger, t.window, GLYPH);
v
}
@@ -397,6 +435,10 @@ mod contrast_tests {
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
);
assert_eq!(
(l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target),
(d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target)
);
for c in Cat::ALL {
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
}
+29
View File
@@ -109,6 +109,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
paint_big_label(ui, rect, &lines, dropdown);
let name = label.replace('\n', " ");
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name));
resp.on_hover_text(tip_text(ui, tip))
}
@@ -203,6 +206,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b
t.text_dim,
);
}
// The accessible name is the label, or the tooltip for an icon-only button.
let name = if label.is_empty() { tr(ui, tip) } else { label };
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name));
resp.on_hover_text(tip_text(ui, tip))
}
/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its
/// accessible role is a checkbox, selected while `on`.
pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::ui_font(12.5);
let label = tr(ui, label);
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0;
let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click());
if on || resp.is_pointer_button_down_on() {
ui.painter().rect_filled(rect, 4.0, t.pressed);
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(rect, 4.0, t.hover);
}
let color = if ui.is_enabled() { t.text } else { t.text_disabled };
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color);
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color);
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label));
resp.on_hover_text(tip_text(ui, tip))
}
+2 -1
View File
@@ -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());
+2
View File
@@ -679,6 +679,8 @@ fn dialogs_open_and_close() {
"pivotCalculatedField",
"about",
"macros",
"recordMacro",
"macroEditor",
"analysisToolPak",
"solver",
] {
+2 -2
View File
@@ -2539,14 +2539,14 @@ fn keytip_wm_() {
fn keytip_wmr_() {
let mut h = harness(blank());
sequence(&mut h, "WMR", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly");
assert!(h.state().dialog.is_some(), "WMR must open the recordMacro dialog");
}
#[test]
fn keytip_wmv_() {
let mut h = harness(blank());
sequence(&mut h, "WMV", false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly");
assert!(h.state().dialog.is_some(), "WMV must open the macros dialog");
}
#[test]
+554
View File
@@ -0,0 +1,554 @@
//! The macro step editor: editing, Add Step, breakpoints, dragging steps, and the debugger
//! (step, continue, run to the end as one undo step, stop with keep or undo).
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::StepKind;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.ui.ribbon_tab = "Automate".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn value(h: &Harness, a: &str) -> Value {
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default()
}
/// Three steps that write A1, A2, A3.
fn three(h: &mut Harness) {
let steps: Vec<_> = ["A1", "A2", "A3"].iter().map(|c| json!({"command": "cell.set", "params": {"cell": c, "input": c}})).collect();
h.state_mut().run("macro.save", json!({"macro": {"name": "Three", "output": {"onConflict": "overwrite"}, "steps": steps}})).unwrap();
h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"}));
h.run_steps(3);
}
fn draft_cells(h: &Harness) -> Vec<String> {
let ed = h.state().macros.editor.as_ref().unwrap();
ed.draft
.steps
.iter()
.map(|s| match &s.kind {
StepKind::Command { params, .. } => params["cell"].as_str().unwrap_or("").to_string(),
_ => String::new(),
})
.collect()
}
fn click(h: &mut Harness, label: &str) {
h.get_by_label(label).click();
h.run_steps(3);
}
#[test]
fn new_macro_add_step_through_the_picker_and_save() {
let mut h = harness();
click(&mut h, "New Macro");
assert!(h.state().macros.editor.is_some());
click(&mut h, "Add Step");
h.input_mut().events.push(Event::Text("bold".into()));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
let ed = h.state().macros.editor.clone().unwrap();
assert!(matches!(&ed.draft.steps[..], [s] if matches!(&s.kind, StepKind::Command { id, .. } if id == "home.bold")));
assert_eq!(ed.selected.as_deref(), Some("1"));
click(&mut h, "Save");
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.macros.len(), 1);
assert_eq!(wb.macros[0].name, ed.draft.name);
assert!(!h.state().macros.editor.as_ref().unwrap().dirty);
}
#[test]
fn the_gutter_toggles_breakpoints() {
let mut h = harness();
three(&mut h);
click(&mut h, "Breakpoint 2");
assert!(h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint);
click(&mut h, "Breakpoint 2");
assert!(!h.state().macros.editor.as_ref().unwrap().draft.steps[1].breakpoint);
}
#[test]
fn dragging_a_step_reorders_the_list() {
let mut h = harness();
three(&mut h);
let from = h.get_by_label("Step 1: Set A1 to A1").rect().center();
let to = h.get_by_label("Step 3: Set A3 to A3").rect().center() + egui::vec2(0.0, 8.0);
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
for k in 1..=6 {
let p = from + (to - from) * (k as f32 / 6.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(1);
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert_eq!(draft_cells(&h), ["A2", "A3", "A1"]);
let ed = h.state().macros.editor.as_ref().unwrap();
assert!(ed.dirty);
assert_eq!(ed.selected.as_deref(), Some("3"), "the moved step stays selected");
}
#[test]
fn step_continue_and_finish_as_one_undo_step() {
let mut h = harness();
three(&mut h);
click(&mut h, "Breakpoint 3");
let undo_before = h.state().session.active().unwrap().undo.len();
// Continue starts the run (saving the breakpoint first) and stops before step 3.
click(&mut h, "Continue");
let ed = h.state().macros.editor.clone().unwrap();
assert_eq!(ed.debug.as_ref().and_then(|d| d["current"]["step"].as_str()), Some("3"));
assert_eq!(ed.selected.as_deref(), Some("3"), "the current step is selected");
assert!(h.query_by_label_contains("Paused before step 3").is_some());
assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Text("A1".into()), Value::Empty));
assert!(h.state().grid.macro_overlay.as_ref().is_some_and(|o| !o.target.is_empty()), "the target is outlined");
// Clearing the breakpoint while paused, then Step runs step 3 and the run finishes.
click(&mut h, "Breakpoint 3");
click(&mut h, "Step");
assert!(h.state().session.macro_debug.is_none());
assert_eq!(value(&h, "A3"), Value::Text("A3".into()));
let ed = h.state().macros.editor.clone().unwrap();
assert!(ed.outcome.as_deref().is_some_and(|o| o.starts_with("Finished")), "{:?}", ed.outcome);
let doc = h.state().session.active().unwrap();
// The breakpoint save is one entry, the run another.
assert_eq!(doc.undo.last().map(|e| e.label.as_str()), Some("Run Macro: Three"));
assert!(doc.undo.len() <= undo_before + 2);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!((value(&h, "A1"), value(&h, "A3")), (Value::Empty, Value::Empty));
}
#[test]
fn run_to_here_then_stop_asks_whether_to_keep_the_changes() {
for keep in [false, true] {
let mut h = harness();
three(&mut h);
click(&mut h, "Step 3: Set A3 to A3");
click(&mut h, "Run to Here");
assert_eq!(
h.state().macros.editor.as_ref().unwrap().debug.as_ref().and_then(|d| d["current"]["step"].as_str()),
Some("3"),
"{:?} {:?}",
h.state().message,
h.state().macros.editor.as_ref().unwrap().outcome
);
click(&mut h, "Stop");
assert!(h.state().macros.editor.as_ref().unwrap().ask_stop);
click(&mut h, if keep { "Keep Changes" } else { "Undo Changes" });
assert!(h.state().session.macro_debug.is_none());
let want = if keep { Value::Text("A2".into()) } else { Value::Empty };
assert_eq!(value(&h, "A2"), want, "keep = {keep}");
assert_eq!(value(&h, "A3"), Value::Empty);
}
}
/// Step 3's Cell box is picking from the grid, with the selection it last saw (A1).
fn pick_step_3_cell(h: &mut Harness) {
let refs = &mut h.state_mut().macros.editor.as_mut().unwrap().refs;
refs.pick = Some("ref:3:cell".into());
refs.seen = "$A$1".into();
}
#[test]
fn a_pick_ends_when_its_box_leaves_the_screen() {
// Choosing another step hides the picking box; a selection made meanwhile must not land in
// it when the step is shown again.
let mut h = harness();
three(&mut h);
click(&mut h, "Step 3: Set A3 to A3");
pick_step_3_cell(&mut h);
h.run_steps(2);
click(&mut h, "Step 1: Set A1 to A1");
assert!(h.state().macros.editor.as_ref().unwrap().refs.pick.is_none(), "the pick ends with its box");
h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap();
h.run_steps(2);
click(&mut h, "Step 3: Set A3 to A3");
let ed = h.state().macros.editor.as_ref().unwrap();
assert!(!ed.dirty, "the draft stays clean");
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
}
#[test]
fn debugging_never_writes_the_selection_into_a_reference_box() {
// A reference box that was picking from the grid stays out of it once an action starts, and
// the selections the debugger makes while it runs never land in a box or dirty the draft.
let mut h = harness();
three(&mut h);
click(&mut h, "Step 3: Set A3 to A3");
pick_step_3_cell(&mut h);
h.run_steps(2);
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
click(&mut h, "Run to Here");
assert!(h.state().session.macro_debug.is_some());
let ed = h.state().macros.editor.as_ref().unwrap();
assert!(ed.refs.pick.is_none(), "the action ends the pick");
assert!(!ed.dirty);
// A box focused while debugging does not take the selection either.
pick_step_3_cell(&mut h);
h.state_mut().run("selection.set", json!({"range": "F9"})).unwrap();
h.run_steps(3);
let ed = h.state().macros.editor.as_ref().unwrap();
assert!(!ed.dirty, "the draft stays clean");
assert_eq!(draft_cells(&h), ["A1", "A2", "A3"]);
h.state_mut().macros.editor.as_mut().unwrap().refs.pick = None;
click(&mut h, "Stop");
click(&mut h, "Undo Changes");
assert!(h.state().session.macro_debug.is_none());
}
#[test]
fn step_into_from_the_macros_dialog_pauses_before_the_first_step() {
let mut h = harness();
three(&mut h);
h.state_mut().dialog = None;
h.state_mut().macros.editor = None;
h.state_mut().open_dialog("macros", json!({"name": "Three"}));
h.run_steps(2);
click(&mut h, "Step Into");
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
assert!(h.state().session.macro_debug.is_some());
assert!(h.query_by_label_contains("Debugging “Three”").is_some(), "the status bar says so");
}
#[test]
fn a_recorded_macro_opens_in_the_editor() {
let mut h = harness();
click(&mut h, "Record Macro");
// The Record Macro dialog comes first.
h.run_steps(2);
click(&mut h, "OK");
assert!(h.state().session.is_recording());
h.state_mut().run("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
h.state_mut().run("home.bold", json!({})).unwrap();
h.run_steps(2);
click(&mut h, "Stop");
let name = h.state().session.active().unwrap().wb.macros[0].name.clone();
h.state_mut().open_dialog("macroEditor", json!({"macro": name}));
h.run_steps(3);
let ed = h.state().macros.editor.clone().unwrap();
assert!(!ed.draft.steps.is_empty());
assert!(!ed.dirty);
}
#[test]
fn run_from_the_editor_asks_before_writing_over_data() {
let mut h = harness();
h.state_mut().run("cell.set", json!({"cell": "A2", "input": "keep me"})).unwrap();
let steps = vec![json!({"command": "cell.set", "params": {"cell": "A2", "input": "new"}})];
h.state_mut().run("macro.save", json!({"macro": {"name": "Over", "steps": steps}})).unwrap();
h.state_mut().open_dialog("macroEditor", json!({"macro": "Over", "check": true}));
h.run_steps(3);
// Check marks the cell it would write over.
let o = h.state().grid.macro_overlay.clone().unwrap();
assert_eq!(o.conflicts.iter().map(|(_, r)| r.a1()).collect::<Vec<_>>(), ["A2"]);
click(&mut h, "Run");
assert!(h.state().macros.question.as_ref().is_some_and(|q| q.choices.len() == 3));
click(&mut h, "Overwrite");
assert_eq!(value(&h, "A2"), Value::Text("new".into()));
}
#[test]
fn a_new_macro_never_replaces_one_with_its_name() {
let mut h = harness();
three(&mut h);
h.state_mut().macros.editor = None;
h.state_mut().dialog = None;
h.run_steps(2);
click(&mut h, "New Macro");
for button in ["Save", "Check", "Run"] {
{
let ed = h.state_mut().macros.editor.as_mut().unwrap();
ed.draft.name = "three".into();
ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))];
ed.dirty = true;
}
h.run_steps(2);
click(&mut h, button);
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.macros.len(), 1, "{button}");
assert_eq!(wb.macros[0].name, "Three", "{button}");
assert_eq!(wb.macros[0].steps.len(), 3, "{button}: the saved macro is intact");
assert!(value(&h, "B1").is_empty(), "{button}: nothing ran");
let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default();
assert!(msg.contains("A macro called “three” already exists. Choose another name."), "{button}: {msg}");
assert!(h.state().macros.editor.as_ref().unwrap().saved_as.is_none());
h.run_steps(2);
}
}
#[test]
fn a_rename_that_lands_before_a_failed_save_is_remembered() {
let mut h = harness();
three(&mut h);
let big = "x".repeat(5 * 1024 * 1024);
{
let ed = h.state_mut().macros.editor.as_mut().unwrap();
ed.draft.name = "Renamed".into();
// Too large to keep in the workbook: the rename lands, the save after it fails.
ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big})));
ed.dirty = true;
}
h.run_steps(2);
click(&mut h, "Save");
let msg = h.state_mut().message.take().map(|(_, m)| m).unwrap_or_default();
assert!(msg.contains("too large"), "{msg}");
assert_eq!(h.state().macros.editor.as_ref().unwrap().saved_as.as_deref(), Some("Renamed"));
// Trimmed back, the next Save works.
h.state_mut().macros.editor.as_mut().unwrap().draft.steps.pop();
h.run_steps(2);
click(&mut h, "Save");
assert!(h.state_mut().message.take().is_none());
let wb = &h.state().session.active().unwrap().wb;
assert_eq!(wb.macros.len(), 1);
assert_eq!((wb.macros[0].name.as_str(), wb.macros[0].steps.len()), ("Renamed", 3));
}
#[test]
fn another_dialog_never_replaces_the_open_editor() {
let mut h = harness();
three(&mut h);
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into();
h.run_steps(2);
for name in ["formatCells", "macros", "macroEditor"] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("macroEditor"), "{name}");
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"), "{name}");
}
click(&mut h, "New Macro");
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"));
}
#[test]
fn enter_in_change_never_picks_a_control_step() {
let mut h = harness();
three(&mut h);
let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
for search in ["", "stop"] {
h.state_mut().macros.editor.as_mut().unwrap().selected = Some("1".into());
h.run_steps(2);
click(&mut h, "Change…");
if !search.is_empty() {
h.input_mut().events.push(Event::Text(search.into()));
h.run_steps(2);
}
h.input_mut().events.push(enter.clone());
h.run_steps(3);
let ed = h.state().macros.editor.as_ref().unwrap();
assert!(matches!(&ed.draft.steps[0].kind, StepKind::Command { .. }), "{search:?}: {:?}", ed.draft.steps[0].kind);
}
}
#[test]
fn the_editor_works_only_on_the_workbook_it_opened_in() {
let mut h = harness();
// A new macro whose one step writes A1, not saved yet.
h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"}));
h.run_steps(3);
let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"}));
h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step];
let book1 = h.state().session.active().unwrap().uid;
// Another workbook becomes active while the editor is open (File › New, Switch Window, an agent).
h.state_mut().session.new_workbook();
h.run_steps(3);
let mut ed = h.state_mut().macros.editor.take().unwrap();
let app = h.state_mut();
// Save, Check, Run and Debug never act on the other workbook.
assert!(!gridcraft_ui_egui::macro_editor::save(app, &mut ed));
gridcraft_ui_egui::macro_editor::check(app, &mut ed);
gridcraft_ui_egui::macro_editor::run(app, &mut ed);
gridcraft_ui_egui::macro_editor::debug_action(app, &mut ed, "step");
assert!(app.session.macro_debug.is_none());
assert!(app.session.active().unwrap().wb.macros.is_empty(), "nothing was saved into the other workbook");
assert_eq!(value(&h, "A1"), Value::Empty, "nothing ran there");
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message);
h.state_mut().message = None;
h.state_mut().macros.editor = Some(ed);
h.run_steps(3);
// The editor stays with its own workbook's window: it is not drawn over the other one.
assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"), "the editor is not shown here");
// Back in its own workbook, the editor shows again and saves there.
let i = h.state().session.documents().iter().position(|d| d.uid == book1).unwrap();
h.state_mut().session.set_active(i);
h.run_steps(3);
assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "macroEditor"), "the editor is back with its workbook");
let mut ed = h.state_mut().macros.editor.take().unwrap();
let app = h.state_mut();
assert!(gridcraft_ui_egui::macro_editor::save(app, &mut ed));
assert_eq!(app.session.active().unwrap().wb.macros.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Fresh"]);
app.macros.editor = Some(ed);
h.run_steps(3);
// Closing the workbook (without saving it) closes its editor.
h.state_mut().run("file.close", json!({"force": true})).unwrap();
h.run_steps(3);
assert!(h.state().macros.editor.is_none());
assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor"));
}
/// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the
/// app answered with over the next frames.
fn request_window_close(h: &mut Harness) -> Vec<egui::ViewportCommand> {
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<egui::ViewportCommand> {
let mut out = Vec::new();
for _ in 0..4 {
h.step();
out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default());
}
out
}
#[test]
fn quitting_asks_about_the_editors_unsaved_steps_first() {
let mut h = harness();
three(&mut h);
h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into();
h.state_mut().macros.editor.as_mut().unwrap().dirty = true;
// As if the workbook had just been saved.
let d = h.state_mut().session.active_mut().unwrap();
d.saved = d.wb.clone();
h.run_steps(2);
// Every workbook is saved, but the draft is not: the window stays open and the editor asks.
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some());
// Cancel keeps the app and the draft.
// The formula bar's Cancel button has the same name; the question's is the button drawn last.
h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click();
let sent = step_commands(&mut h);
assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.name.as_str()), Some("Unsaved"));
// Asked again, Don't Save closes the editor and then the window.
request_window_close(&mut h);
h.get_by_label("Don't Save").click();
let sent = step_commands(&mut h);
assert!(h.state().macros.editor.is_none());
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
}
#[test]
fn quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() {
let mut h = harness();
three(&mut h);
h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap();
h.run_steps(2);
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
// The saved, unchanged editor closes and the window close goes on (the window then asks to
// close again, which a test harness has to do by hand); the workbook is asked about as
// without the editor.
assert!(h.state().macros.editor.is_none());
assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}");
request_window_close(&mut h);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
}
#[test]
fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() {
let mut h = harness();
three(&mut h);
// File › Close on a changed workbook asks about saving it, which can't replace the editor.
h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap();
h.state_mut().run("file.close", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"));
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Close the macro step editor")), "{:?}", h.state().message);
}
#[test]
fn the_control_channels_cancel_closes_the_editor_as_its_own_close_does() {
let mut h = harness();
three(&mut h);
let ctx = h.ctx.clone();
let reply = |o: gridcraft_ui_egui::control::Outcome| match o {
gridcraft_ui_egui::control::Outcome::Done(v) => v,
_ => json!(null),
};
// Unsaved steps are only the editor's to ask about: cancel is refused and keeps them.
h.state_mut().macros.editor.as_mut().unwrap().dirty = true;
let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})));
assert!(r.get("error").is_some(), "{r}");
assert!(h.state().macros.editor.is_some());
// A saved editor closes, and takes its overlays with it.
h.state_mut().macros.editor.as_mut().unwrap().dirty = false;
h.get_by_label("Check").click();
h.run_steps(3);
assert!(h.state().grid.macro_overlay.is_some());
let r = reply(gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})));
assert!(r.get("error").is_none(), "{r}");
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(h.state().macros.editor.is_none());
assert!(h.state().grid.macro_overlay.is_none());
}
#[test]
fn the_editor_fits_a_narrow_window() {
for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] {
let mut session = Session::new();
session.new_workbook();
let app = SheetApp::new(session, Default::default());
let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
three(&mut h);
h.run_steps(4);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
let window = h.get_by_label_contains("Macro: Three").rect();
assert!(screen.contains_rect(window), "the editor at {window:?} is outside the {size:?} window");
// The editor's own controls (Delete is left out: the ribbon has one too).
for label in ["Add Step", "Duplicate", "Check", "Run", "Step", "Continue", "Run to Here", "Stop", "Details", "Inputs", "Output", "Save"] {
let r = h.get_by_label(label).rect();
assert!(screen.contains_rect(r), "{label} at {r:?} is outside the {size:?} window");
}
}
}
#[test]
fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() {
let mut h = harness();
let steps: Vec<_> = (0..gridcraft_engine::model::MAX_MACRO_STEPS)
.map(|i| json!({"command": "cell.set", "params": {"cell": format!("A{}", i + 1), "input": "x"}}))
.collect();
h.state_mut().run("macro.save", json!({"macro": {"name": "Big", "steps": steps}})).unwrap();
h.state_mut().open_dialog("macroEditor", json!({"macro": "Big"}));
h.run_steps(2);
// Each row used to copy the whole list: five frames took about a minute. Now well under a
// second; the bound leaves room for a slow machine.
let t = gridcraft_ui_egui::now_ms();
h.run_steps(5);
let ms = gridcraft_ui_egui::now_ms() - t;
assert!(ms < 10_000.0, "five frames took {ms} ms");
assert!(h.query_by_label_contains("Step 1: ").is_some());
}
+362
View File
@@ -0,0 +1,362 @@
//! Automate › Macros in the window: recording with the status bar's Stop, the Macros dialog
//! (macros, Automate scripts and a VBA project), macro shortcuts and the question asked when a
//! macro's output would land on data.
use egui::{Event, Key, Modifiers};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(tab: &str) -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.ui.ribbon_tab = tab.into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn value(h: &Harness, a: &str) -> Value {
h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default()
}
fn save(h: &mut Harness, m: serde_json::Value) {
h.state_mut().run("macro.save", json!({"macro": m})).unwrap();
h.run_steps(2);
}
fn dialog_name(h: &Harness) -> Option<String> {
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::<Vec<_>>(), ["Overwrite", "Shift Down", "Shift Right"]);
assert!(q.text.contains("B1"), "{}", q.text);
assert!(!q.text.contains("onConflict"), "the hint for programs is not shown: {}", q.text);
h.get_by_label("Shift Down").click();
h.run_steps(3);
assert!(h.state().macros.question.is_none());
assert_eq!(value(&h, "B1"), Value::Text("new".into()));
assert_eq!(value(&h, "B2"), Value::Text("old".into()));
assert_eq!(h.state().macros.recent, ["Put"]);
}
#[test]
fn the_record_macro_dialog_names_the_macro_and_checks_name_and_shortcut() {
let mut h = harness("Automate");
save(&mut h, json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []}));
h.get_by_label("Record Macro").click();
h.run_steps(2);
let set = |h: &mut Harness, k: &str, v: &str| {
h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), json!(v));
h.run_steps(2);
};
assert_eq!(h.state().dialog.as_ref().unwrap().values["name"], json!("Macro1"), "the next free name is suggested");
set(&mut h, "name", "taken");
assert!(h.query_by_label_contains("already exists").is_some());
set(&mut h, "name", "Two\nlines");
assert!(h.query_by_label_contains("line breaks").is_some());
set(&mut h, "name", "Tidy Up");
set(&mut h, "shortcut", "P");
assert!(h.query_by_label_contains("already runs macro “Taken”").is_some());
set(&mut h, "shortcut", "S");
assert!(h.query_by_label_contains("Save As").is_some());
// OK stays off while something is wrong.
h.get_by_label("OK").click();
h.run_steps(2);
assert!(!h.state().session.is_recording());
set(&mut h, "shortcut", "Q");
set(&mut h, "description", "Tidies the block");
assert!(h.query_by_label_contains("Choose another").is_none());
h.get_by_label("OK").click();
h.run_steps(2);
assert!(h.state().session.is_recording());
h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();
h.get_by_label("Stop").click();
h.run_steps(2);
let m = h.state().session.active().unwrap().wb.macros.iter().find(|m| m.name == "Tidy Up").cloned().expect("saved under its name");
assert_eq!(m.shortcut.as_deref(), Some("Ctrl+Shift+Q"));
assert_eq!(m.description, "Tidies the block");
}
#[test]
fn view_macros_record_macro_toggles_recording() {
let mut h = harness("Home");
h.state_mut().open_dialog("recordMacro", json!({}));
h.run_steps(4);
h.get_by_label("OK").click();
h.run_steps(2);
assert!(h.state().session.is_recording());
// Opening it again while recording stops and saves, as Excel's Stop Recording does.
h.state_mut().open_dialog("recordMacro", json!({}));
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(!h.state().session.is_recording());
}
#[test]
fn the_macros_dialog_offers_trust_for_scripts_that_came_with_a_file() {
let mut s = Session::new();
s.new_workbook();
s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]}))
.unwrap();
// As when the workbook was opened with its scripts (#2178).
s.active_mut().unwrap().scripts_trusted = false;
let mut app = SheetApp::new(s, Default::default());
app.ui.ribbon_tab = "Automate".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h.state_mut().open_dialog("macros", json!({}));
h.run_steps(2);
assert!(h.query_by_label_contains("came from a file and are disabled").is_some());
h.get_by_label("Trust Scripts").click();
// The dialog shrinks without the row: let it settle before the next click.
h.run_steps(4);
assert!(h.state().session.doc().unwrap().scripts_trusted);
assert!(h.query_by_label("Trust Scripts").is_none(), "the row goes once the scripts are trusted");
h.get_by_label("Mark (script, 1 step)").click();
h.run_steps(2);
h.get_by_label("Run").click();
h.run_steps(3);
assert_eq!(value(&h, "D4"), Value::Text("script".into()));
}
#[test]
fn the_macros_and_record_macro_dialogs_fit_a_narrow_window() {
for size in [egui::vec2(600.0, 480.0), egui::vec2(480.0, 400.0)] {
let mut s = Session::new();
s.new_workbook();
s.execute("macro.save", json!({"name": "Tidy", "steps": []})).unwrap();
let app = SheetApp::new(s, Default::default());
let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
for (dialog, labels) in [("macros", &["Run", "Delete", "Options…", "Cancel"][..]), ("recordMacro", &["OK", "Cancel"][..])] {
h.state_mut().open_dialog(dialog, json!({}));
h.run_steps(4);
for label in labels {
// The dialog's own button: the last one drawn (the ribbon has a Delete too).
let r = h.get_all_by_label(label).last().map(|n| n.rect()).unwrap_or(egui::Rect::NOTHING);
assert!(screen.contains_rect(r), "{dialog}: {label} at {r:?} is outside the {size:?} window");
}
h.state_mut().dialog = None;
h.run_steps(2);
}
}
}
#[test]
fn a_question_is_answered_in_the_workbook_that_asked_it() {
let mut h = harness("Automate");
save(&mut h, json!({"name": "Tidy", "steps": []}));
h.state_mut().open_dialog("macros", json!({"name": "Tidy"}));
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(2);
assert!(h.state().macros.question.is_some());
// Another workbook with a macro of that name becomes active before the answer (an agent).
h.state_mut().dialog = None;
h.state_mut().session.new_workbook();
save(&mut h, json!({"name": "Tidy", "steps": []}));
h.get_by_label("Delete").click();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1, "the other workbook's macro is kept");
assert!(h.state().macros.question.is_none());
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("Book1")), "{:?}", h.state().message);
// Asked and answered in the same workbook, it deletes there.
h.state_mut().message = None;
h.run_steps(2);
h.state_mut().open_dialog("macros", json!({"name": "Tidy"}));
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(2);
h.get_all_by_label("Delete").last().unwrap().click();
h.run_steps(3);
assert!(h.state().session.active().unwrap().wb.macros.is_empty());
}
#[test]
fn the_macros_dialog_closes_when_another_workbook_becomes_active() {
let mut h = harness("Automate");
save(&mut h, json!({"name": "Tidy", "steps": []}));
h.state_mut().open_dialog("macros", json!({"name": "Tidy"}));
h.run_steps(2);
h.get_by_label("Options…").click();
h.run_steps(2);
// Its rows and the Options pane hold the first workbook's macros; an agent switches workbooks.
h.state_mut().session.new_workbook();
save(&mut h, json!({"name": "Tidy", "steps": []}));
h.run_steps(3);
assert!(h.state().dialog.is_none(), "the dialog closed instead of acting on the other workbook");
assert!(h.state().session.active().unwrap().wb.macros[0].shortcut.is_none());
}
+2
View File
@@ -58,6 +58,8 @@ mod interaction;
mod interface_scale;
mod keyboard_shortcuts;
mod keytips_generated;
mod macro_editor;
mod macros_ui;
mod menu_hover;
mod merged_cells;
mod more_colors;
+71 -13
View File
@@ -1,6 +1,6 @@
# Macros
> **Last updated:** 2026-10-25 · **Change:** minor (recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands)
> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts)
GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft
command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro
@@ -9,7 +9,7 @@ error (a file it already wrote stays), checks where its results will land before
and does not touch files without permission.
Agents run macros with `macro.run` like any other command.
This page describes the engine and its commands, not a user interface for macros.
This page describes the engine, its commands and the macro interface in the window.
## A macro
@@ -195,17 +195,22 @@ on its own, never as a step of a script or macro, and is never recorded.
## Recording
`macro.record` toggles recording. Starting takes `relative` (default true; `false` for absolute
references); stopping takes `name` and `description`. A name that another macro has, a kept one
included, gets a suffix, such as `Tidy (2)`, so stopping never replaces a macro. `cancel: true`
ends a recording without saving it, the way out of one whose macro can't be saved. A parameter
the call would ignore is refused: `name` or `description` when starting, `relative` when
stopping, `stop: false` while recording, `stop`, `relative`, `name` or `description` beside
`cancel: true`, and one of the wrong kind. The macro is saved in the workbook where recording started, even if another one is active;
if that workbook was closed, stopping ends the recording without saving it. When saving the
recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged
in that workbook), recording goes on, so stopping again keeps what was recorded. The person
recorded the macro, so in a workbook opened from a file it runs without the approval a macro that
came with the file needs, until it changes.
references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's
name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+M"` or `"M"`) and its description. They are
checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a
letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name
the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins
over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy
(2)`, so stopping never replaces a macro, and a shortcut another macro took meanwhile is left off,
which the result says in `shortcutDropped`. `cancel: true` ends a recording without saving it, the
way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative`
or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one
of the wrong kind. The macro is saved in the workbook where recording started, even if another one
is active; if that workbook was closed, stopping ends the recording without saving it. When saving
the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged
in that workbook), recording goes on, so stopping again keeps what was recorded. The person recorded
the macro, so in a workbook opened from a file it runs without the approval a macro that came with
the file needs, until it changes.
A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to
10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps
@@ -231,6 +236,10 @@ recording's 64 MB is left out and listed.
Undo while recording works as in Excel: `edit.undo` is left out (a macro never runs it), and the
step it undid stays in the recording, so playing the recording back makes that edit again.
GridCraft keeps the Ctrl+Shift letters it uses itself on some platform: F, G, H, J, K, L, R, S, T
and Z (`macros::RESERVED_SHORTCUTS`). A shortcut a file brings with it on one of these letters is
kept but does not run the macro.
## Other commands and storage
`macro.list`, `macro.get`, `macro.save {macro, replace?}`, `macro.delete` and `macro.rename
@@ -256,6 +265,55 @@ a recording gets a suffix. A macro that is
damaged or over the limits is left out with one warning, and the rest of the workbook and its
other macros open; a macro that was left out is not in the saved file.
## In the window
- **Automate › Macros group.** Macros opens the Macros dialog. Record Macro opens the Record
Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References); OK
starts recording and stays off while the name or the letter cannot be used, with the reason
under the fields. While recording, the button reads Stop Recording, the status bar shows a red
dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the
macro run last (or the only one); its menu lists the recent macros, then every macro. New
Macro opens the step editor on a new macro.
- **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros
(with their shortcuts), then its Automate scripts, and says when the file carries a VBA project.
Scripts that came with an opened file stay disabled until Trust Scripts (`automate.trustScripts`)
trusts them for the session. Run runs the selected macro on the selection or the selected
script; Step Into, Edit and Create open the step editor; Delete asks first; Options sets a
macro's shortcut letter and description. The dialog keeps the result of `view.macros`, as before
macros existed.
- **Step editor.** A resizable window that works on a draft and saves it with `macro.save`; in a
narrow app window it is no larger than the window and its toolbar wraps onto a second row. The
step list shows each step's number, a breakpoint dot in the gutter (click to set or clear), a
category icon, a summary, an error marker and an enabled checkbox; Repeat bodies and If branches
are indented under their step, and rows drag to reorder within their list. Add Step opens a
command picker (control steps first; commands a macro may never run are left out); Duplicate and
Delete act on the selected step. The inspector's Details tab edits the selected step, with
reference boxes that take a typed reference or a selection on the grid and switch between a
token relative to the example input (Rel) and an absolute reference (Abs); the Inputs tab sets
each input's shape, header and column types, and Use current selection as example infers them;
the Output tab sets the anchor and what to do when results would land on data. The editor
belongs to the workbook it opened in: while another workbook is active it says so and Save,
Check, Run and Debug wait until that workbook is active again, and closing the workbook closes
the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is
refused with a message, and closing the app first asks about the editor's debug run and unsaved
steps, then about unsaved workbooks.
- **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes
green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and
Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and
`breakpointsOnly: true`, so a dot cleared while paused takes effect at once); the current step is
selected, its parameters are shown as they will run, and its target is outlined on the grid.
Stop asks whether to keep the changes so far or undo them. While a macro is debugged the status
bar says "Debugging" with the macro's name, and Show reopens the editor.
- **Questions.** A macro that needs approval offers Run; a macro whose output would land on data
offers Overwrite, Shift Down and Shift Right. Each button runs the command again with
`approve: true` or the chosen `onConflict`, in the workbook that asked: an answer given while
another workbook is active does nothing and says why.
- **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that
letter. The letters GridCraft uses itself keep their own meaning.
Commands with empty parameters never open these dialogs: `macro.record {}` starts recording
under the next free name, and the dialogs are opened only from the ribbon, the menus and keys.
## Automate scripts and VBA
Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`,
+3 -2
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (catalog count 276 / 292, as `cargo xtask parity` measures; earlier: automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), with no interface yet. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |
## Dimensions
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) |
| 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) |
| 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change |
| 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them |
+8 -3
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -142,7 +142,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w
| Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing |
| Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** |
| Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** |
| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing |
| Alt+F8, Ctrl+Shift+letter | Option+F8, Cmd+Shift+letter | Macros dialog, a macro's own shortcut (letters GridCraft uses keep their meaning) | **added** |
| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F11 / F12 | same | group sheets, shape cycling, full screen, window keys, VBA, Power Query | missing |
| — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) |
On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as
@@ -153,7 +154,9 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options,
Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a
note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step
editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options,
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
title bar, keeping its place while it is open (#1250).
@@ -189,6 +192,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|---|---|---|
| 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty |
| 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |
| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays |
| 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs |
| 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) |
| 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |
| 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` |