mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-14 06:12:18 +00:00
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:
57 files changed
+4909
-119
No files matched your search
+4
-2
@@ -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! |
|
||||
|
||||
@@ -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()}));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ mod data;
|
||||
mod find;
|
||||
mod format_cells;
|
||||
mod insert_function;
|
||||
mod macros;
|
||||
mod names;
|
||||
mod paste;
|
||||
mod show;
|
||||
@@ -187,7 +186,7 @@ impl Dialog {
|
||||
}
|
||||
}
|
||||
|
||||
fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
|
||||
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
title: title.into(),
|
||||
@@ -946,11 +945,9 @@ impl Dialog {
|
||||
)
|
||||
}
|
||||
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
|
||||
"macros" => {
|
||||
let mut d = Dialog::custom("macros", "Macros", json!({}));
|
||||
d.result = app.session.run("view.macros", json!({})).ok();
|
||||
d
|
||||
}
|
||||
"macros" => crate::macro_ui::open_dialog(app, ¶ms),
|
||||
"recordMacro" => return crate::macro_ui::open_record(app, ¶ms),
|
||||
"macroEditor" => return crate::macro_editor::open(app, ¶ms),
|
||||
"statistics" => {
|
||||
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
|
||||
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<_>>())))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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`].
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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, ¶ms, &v);
|
||||
Some(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if !ask(app, title, id, ¶ms, &e) {
|
||||
app.message = Some((title.into(), crate::clean_error(&e)));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The question in an engine error that asks something: `approve: …` or `confirm: …`.
|
||||
fn question_text(e: &str, prefix: &str) -> Option<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?"));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -679,6 +679,8 @@ fn dialogs_open_and_close() {
|
||||
"pivotCalculatedField",
|
||||
"about",
|
||||
"macros",
|
||||
"recordMacro",
|
||||
"macroEditor",
|
||||
"analysisToolPak",
|
||||
"solver",
|
||||
] {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user