Engine: stopping a recording says when it left the chosen shortcut off

Record Macro checks the Ctrl+Shift letter when recording starts, but
another macro can take it before recording stops; the save then leaves
the shortcut off, and nothing said so. The result now names it in
shortcutDropped, the toast after Stop says the macro was saved without
it, and the macro page describes it.
This commit is contained in:
prxnvt
2026-10-25 06:48:57 -04:00
parent c142c70dbe
commit c442da9221
4 changed files with 52 additions and 23 deletions
+15 -9
View File
@@ -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?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs}",
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+K\" | \"K\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
has_doc,
record
),
@@ -338,11 +338,15 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
}
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
// The shortcut, unless another macro took it while recording.
if let Some(k) = &start.shortcut
&& crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none())
{
converted.mac.shortcut = Some(k.clone());
// The shortcut, unless another macro took it while recording; then it is left off and the
// result says so.
let mut shortcut_dropped = None;
if let Some(k) = &start.shortcut {
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
converted.mac.shortcut = Some(k.clone());
} else {
shortcut_dropped = Some(k.clone());
}
}
let steps = converted.mac.steps.len();
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
@@ -360,7 +364,9 @@ fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, star
let key = (doc, crate::macros::fingerprint(m));
s.macro_approvals.insert(key);
}
Ok(
json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs}),
)
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
if let Some(k) = shortcut_dropped {
out["shortcutDropped"] = json!(k);
}
Ok(out)
}
+19
View File
@@ -2413,3 +2413,22 @@ fn shortcuts_parse_to_one_letter_and_avoid_reserved_and_taken_ones() {
assert_eq!(shortcut_conflict(&wb, "a", 'M'), None);
assert_eq!(shortcut_conflict(&wb, "B", 'Q'), None);
}
#[test]
fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
let mut s = s();
s.execute("macro.record", json!({"name": "Tidy", "shortcut": "m"})).unwrap();
set(&mut s, "A1", "1");
// Another macro takes Ctrl+Shift+M before recording stops.
s.execute("macro.save", json!({"name": "Other", "shortcut": "Ctrl+Shift+M", "steps": []})).unwrap();
let r = s.execute("macro.record", json!({"stop": true})).unwrap();
assert_eq!(r["saved"], "Tidy");
assert_eq!(r["shortcutDropped"], "Ctrl+Shift+M", "{r}");
assert!(s.execute("macro.get", json!({"name": "Tidy"})).unwrap()["shortcut"].is_null());
// A free one is kept and not reported.
s.execute("macro.record", json!({"name": "Keep", "shortcut": "q"})).unwrap();
set(&mut s, "A2", "2");
let r = s.execute("macro.record", json!({"stop": true})).unwrap();
assert!(r.get("shortcutDropped").is_none(), "{r}");
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
}
+5 -1
View File
@@ -89,7 +89,11 @@ pub fn stop_recording(app: &mut SheetApp) {
Some(name) => {
let steps = v["steps"].as_u64().unwrap_or(0);
app.macros.note_recent(name);
app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), crate::now_ms()));
let mut text = format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" });
if let Some(k) = v["shortcutDropped"].as_str() {
text.push_str(&format!(" without {k}: another macro took it while recording"));
}
app.toast = Some((text, crate::now_ms()));
}
None => app.toast = Some(("Recording stopped".into(), crate::now_ms())),
},
+13 -13
View File
@@ -198,19 +198,19 @@ on its own, never as a step of a script or macro, and is never recorded.
references) and, from the Record Macro dialog, `name`, `shortcut` and `description`: the macro's
name, its Ctrl+Shift shortcut letter (`"Ctrl+Shift+K"` or `"K"`) and its description. They are
checked at once, so a name that is taken (a kept one included), too long or holds line breaks, or a
letter that GridCraft or another macro uses, is refused before anything is recorded. Without a
name the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when
stopping wins over the one given when starting; a name that another macro has by then gets a
suffix, such as `Tidy (2)`, so stopping never replaces a macro, and a shortcut another macro took
meanwhile is left off, which the result says in `shortcutDropped`. `cancel: true` ends a recording
without saving it, the way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative` or `shortcut` when
stopping, `stop: false` while recording, anything beside `cancel: true`, and one of the wrong kind.
The macro is saved in the workbook where recording started, even if another one is active;
if that workbook was closed, stopping ends the recording without saving it. When saving the
recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged
in that workbook), recording goes on, so stopping again keeps what was recorded. The person
recorded the macro, so in a workbook opened from a file it runs without the approval a macro that
came with the file needs, until it changes.
letter that GridCraft or another macro uses, is refused before anything is recorded. Without a name
the macro is called `Macro1`, `Macro2` and so on. A `name` or `description` given when stopping wins
over the one given when starting; a name that another macro has by then gets a suffix, such as `Tidy
(2)`, so stopping never replaces a macro, and a shortcut another macro took meanwhile is left off,
which the result says in `shortcutDropped`. `cancel: true` ends a recording without saving it, the
way out of one whose macro can't be saved. A parameter the call would ignore is refused: `relative`
or `shortcut` when stopping, `stop: false` while recording, anything beside `cancel: true`, and one
of the wrong kind. The macro is saved in the workbook where recording started, even if another one
is active; if that workbook was closed, stopping ends the recording without saving it. When saving
the recording fails (a name the check refuses, a workbook at its macro limit, a macro being debugged
in that workbook), recording goes on, so stopping again keeps what was recorded. The person recorded
the macro, so in a workbook opened from a file it runs without the approval a macro that came with
the file needs, until it changes.
A recording keeps up to 100,000 commands and 64 MB of them as JSON, and a macro holds up to
10,000 steps and 4,194,304 characters of JSON. The steps past any of these limits, and steps