diff --git a/ROADMAP.md b/ROADMAP.md index 995720e80..aac13c874 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (macro engine and `macro.*` commands) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -86,7 +86,7 @@ collaboration, mobile, AI). | Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | | Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | -| Automation (recorded scripts; typed macros in the engine, no interface yet; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| Automation (recorded scripts; typed macros with a recorder and a Macros dialog; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | ## Languages @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | @@ -236,6 +237,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7b069e597..ec496d95e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -161,7 +161,7 @@ impl Dialog { } } - fn custom(name: &str, title: &str, defaults: Json) -> Dialog { + pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog { Dialog { name: name.into(), title: title.into(), @@ -906,11 +906,8 @@ impl Dialog { ) } "journal" => Dialog::custom("journal", "Action Journal", json!({})), - "macros" => { - let mut d = Dialog::custom("macros", "Macros", json!({})); - d.result = app.session.run("view.macros", json!({})).ok(); - d - } + "macros" => crate::macro_ui::open_dialog(app, ¶ms), + "recordMacro" => return crate::macro_ui::open_record(app, ¶ms), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); d.result = app.session.run("review.workbookStatistics", json!({})).ok(); @@ -1262,7 +1259,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "macros" => 480.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, @@ -1322,7 +1320,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(app, ui, &mut d), + "macros" => crate::macro_ui::macros_dialog(app, ui, &mut d, &mut open), + "recordMacro" => crate::macro_ui::record_dialog(app, ui, &mut d, &mut open), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1885,45 +1884,6 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { } } -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} - fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c4c3888dc..91b3b571c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3006,7 +3006,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). + // arrow key would move the selection the menu fills from), and a macro's question. if editing || app.dialog.is_some() || other_focus @@ -3014,6 +3014,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: || app.grid.filter_menu.is_some() || app.grid.fill_menu.is_some() || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + || app.macros.question.is_some() { return; } @@ -3127,6 +3128,8 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), Key::F4 if !cmd => {} + // Ctrl+Shift+letter (⌘⇧letter) runs the macro with that shortcut, unless GridCraft uses it. + _ if cmd && shift && !m.alt && crate::macro_ui::run_shortcut(app, key) => {} _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { crate::ribbon::shortcut(app, key, m) } diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 1e8e1a77a..09a884a55 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -548,6 +548,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "AutoSave" => "Automatické ukládání", "AutoSave: save after every change (workbooks saved to a file)" => "Automatické ukládání: ukládat po každé změně (sešity uložené do souboru)", "All sheets" => "Všechny listy", + "Record Macro" => "Záznam makra", + "Stop Recording" => "Zastavit záznam", + "Use Relative References" => "Použít relativní odkazy", + "Run\nMacro" => "Spustit makro", + "Macros…" => "Makra…", + "Recording" => "Záznam", + "Stop" => "Zastavit", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0a5116757..c61a74df8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -452,6 +452,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Nächstes Blatt", "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", + "Macros" => "Makros", + "Record Macro" => "Makro aufzeichnen", + "Stop Recording" => "Aufzeichnung beenden", + "Use Relative References" => "Relative Verweise verwenden", + "Run\nMacro" => "Makro ausführen", + "Macros…" => "Makros…", + "Recording" => "Aufzeichnung", + "Stop" => "Beenden", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index ae8320c59..d7cc1eb93 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -978,6 +978,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { "Ένα αρχείο CSV, μια ιστοσελίδα ή ένα PDF είναι αντίγραφο: το βιβλίο εργασίας διατηρεί το δικό του αρχείο." } + "Record Macro" => "Καταγραφή μακροεντολής", + "Stop Recording" => "Διακοπή καταγραφής", + "Use Relative References" => "Χρήση σχετικών αναφορών", + "Run\nMacro" => "Εκτέλεση μακροεντολής", + "Macros…" => "Μακροεντολές…", + "Recording" => "Καταγραφή", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 79352bb9a..262c6bb61 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -468,6 +468,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", + "Macros" => "Macros", + "Record Macro" => "Grabar macro", + "Stop Recording" => "Detener grabación", + "Use Relative References" => "Usar referencias relativas", + "Run\nMacro" => "Ejecutar macro", + "Macros…" => "Macros…", + "Recording" => "Grabando", + "Stop" => "Detener", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index d422fb900..2d57f57bd 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Feuille suivante", "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", + "Macros" => "Macros", + "Record Macro" => "Enregistrer une macro", + "Stop Recording" => "Arrêter l’enregistrement", + "Use Relative References" => "Utiliser les références relatives", + "Run\nMacro" => "Exécuter la macro", + "Macros…" => "Macros…", + "Recording" => "Enregistrement", + "Stop" => "Arrêter", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c881ef1e4..c00178b13 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -981,6 +981,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Layout rapporto", "Compact" => "Compatto", "Tabular" => "Tabulare", + "Record Macro" => "Registra macro", + "Stop Recording" => "Interrompi registrazione", + "Use Relative References" => "Usa riferimenti relativi", + "Run\nMacro" => "Esegui macro", + "Macros…" => "Macro…", + "Recording" => "Registrazione", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 84b3866ad..162d82104 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -415,6 +415,12 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), + ("Record Macro", "マクロの記録"), + ("Stop Recording", "記録終了"), + ("Use Relative References", "相対参照で記録"), + ("Run\nMacro", "マクロの実行"), + ("Macros…", "マクロ…"), + ("Recording", "記録中"), ]; /// English label → Japanese for the dialog, message, Backstage and menu strings beyond [`CORE`]. diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d9611ed11..5f0ffc983 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "다음 시트", "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", + "Macros" => "매크로", + "Record Macro" => "매크로 기록", + "Stop Recording" => "기록 중지", + "Use Relative References" => "상대 참조로 기록", + "Run\nMacro" => "매크로 실행", + "Macros…" => "매크로…", + "Recording" => "기록 중", + "Stop" => "중지", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 13c717828..3afb4cf60 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -522,6 +522,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Zoom In" => "Inzoomen", "Zoom Out" => "Uitzoomen", "Dismiss" => "Negeren", + "Record Macro" => "Macro opnemen", + "Stop Recording" => "Opname stoppen", + "Use Relative References" => "Relatieve verwijzingen gebruiken", + "Run\nMacro" => "Macro uitvoeren", + "Macros…" => "Macro's…", + "Recording" => "Opnemen", + "Stop" => "Stoppen", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index c63206800..0e82acde5 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -972,6 +972,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Układ raportu", "Compact" => "Kompaktowy", "Tabular" => "Tabelaryczny", + "Record Macro" => "Zarejestruj makro", + "Stop Recording" => "Zatrzymaj rejestrowanie", + "Use Relative References" => "Użyj odwołań względnych", + "Run\nMacro" => "Uruchom makro", + "Macros…" => "Makra…", + "Recording" => "Rejestrowanie", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 3d26f2a29..536eeb823 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -973,6 +973,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Salvamento automático: salvar após cada alteração (pastas de trabalho salvas em um arquivo)" } "All sheets" => "Todas as planilhas", + "Record Macro" => "Gravar Macro", + "Stop Recording" => "Parar Gravação", + "Use Relative References" => "Usar Referências Relativas", + "Run\nMacro" => "Executar Macro", + "Macros…" => "Macros…", + "Recording" => "Gravando", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f9e732cf8..469da8dca 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1034,6 +1034,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Format Chart Area…" => "Formatare zonă diagramă…", "Error checking options" => "Opțiuni de verificare a erorilor", "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", + "Record Macro" => "Înregistrare macrocomandă", + "Stop Recording" => "Oprire înregistrare", + "Use Relative References" => "Utilizare referințe relative", + "Run\nMacro" => "Rulare macrocomandă", + "Macros…" => "Macrocomenzi…", + "Recording" => "Înregistrare", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 184f53412..e87536886 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -438,6 +438,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следующий лист", "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "Macros" => "Макросы", + "Record Macro" => "Запись макроса", + "Stop Recording" => "Остановить запись", + "Use Relative References" => "Относительные ссылки", + "Run\nMacro" => "Выполнить макрос", + "Macros…" => "Макросы…", + "Recording" => "Запись", + "Stop" => "Остановить", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 31f62a5ef..e10ab9c2f 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -436,6 +436,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следећи лист", "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", + "Macros" => "Makroi", + "Record Macro" => "Snimi makro", + "Stop Recording" => "Zaustavi snimanje", + "Use Relative References" => "Koristi relativne reference", + "Run\nMacro" => "Pokreni makro", + "Macros…" => "Makroi…", + "Recording" => "Snimanje", + "Stop" => "Zaustavi", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 042ccdb70..daa951fdc 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -410,6 +410,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Koyu", "Sheet Options|View" => "Görünüm", "Print" => "Yazdır", + "Macros" => "Makrolar", + "Record Macro" => "Makro Kaydet", + "Stop Recording" => "Kaydı Durdur", + "Use Relative References" => "Göreli Başvuruları Kullan", + "Run\nMacro" => "Makro Çalıştır", + "Macros…" => "Makrolar…", + "Recording" => "Kaydediliyor", + "Stop" => "Durdur", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 466795365..334bbb6dd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -976,6 +976,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Report Layout" => "Report Layout", "Compact" => "Compact", "Tabular" => "Tabular", + "Record Macro" => "Записати макрос", + "Stop Recording" => "Зупинити запис", + "Use Relative References" => "Використовувати відносні посилання", + "Run\nMacro" => "Запустити макрос", + "Macros…" => "Макроси…", + "Recording" => "Запис", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 920e75732..31d825881 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -412,6 +412,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Tính toán sổ làm việc", "Enable iterative calculation" => "Bật tính toán lặp", "Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.", + "Macros" => "Macro", + "Record Macro" => "Ghi Macro", + "Stop Recording" => "Dừng Ghi", + "Use Relative References" => "Dùng Tham chiếu Tương đối", + "Run\nMacro" => "Chạy Macro", + "Macros…" => "Macro…", + "Recording" => "Đang ghi", + "Stop" => "Dừng", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 8ead92a6c..a1b4179cc 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -428,6 +428,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "下一个工作表", "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", + "Macros" => "宏", + "Record Macro" => "录制宏", + "Stop Recording" => "停止录制", + "Use Relative References" => "使用相对引用", + "Run\nMacro" => "运行宏", + "Macros…" => "宏…", + "Recording" => "正在录制", + "Stop" => "停止", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7667ddd56..43ddf3baa 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -135,6 +135,12 @@ pub enum Icon { OpenFolder, Info, Print, + /// A macro: a script page with a run arrow. + Macro, + /// Record Macro: a ring around the recording dot. + Record, + /// Use Relative References: a cell and an arrow to a cell offset from it. + Relative, } struct G<'a> { @@ -194,11 +200,12 @@ impl G<'_> { struct Palette { paper: Color32, cat: [Color32; 8], + recording: Color32, } impl Palette { fn of(t: &Tokens) -> Palette { - Palette { paper: t.paper, cat: t.cat } + Palette { paper: t.paper, cat: t.cat, recording: t.recording } } fn cat(&self, c: Cat) -> Color32 { @@ -747,6 +754,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); g.circle(12.3, 7.6, 0.6, Some(c), None); } + Icon::Macro => { + g.rect(2.5, 1.5, 9.0, 12.0, Some(paper), Some(c)); + for y in [4.5, 7.0, 9.5] { + g.stroke(&[(4.5, y), (9.5, y)], light); + } + g.poly(&[(9.5, 8.5), (15.0, 11.75), (9.5, 15.0)], green); + } + Icon::Record => { + g.circle(8.0, 8.0, 6.0, None, Some(c)); + g.circle(8.0, 8.0, 3.5, Some(pal.recording), None); + } + Icon::Relative => { + g.rect(1.5, 1.5, 5.5, 5.0, Some(paper), Some(c)); + g.rect(9.0, 9.5, 5.5, 5.0, None, Some(c)); + g.line(&[(5.0, 7.5), (5.0, 12.0), (8.0, 12.0)], c, 1.1); + g.poly(&[(7.5, 10.2), (9.6, 12.0), (7.5, 13.8)], c); + } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c35a3bbad..e74c5aed3 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -342,8 +342,8 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param) ("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal) ("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros) - ("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu) - ("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros) + ("WMR", Entry::Finish(Action::Dialog("recordMacro"))), // Excel: Record Macro; Record Macro dialog (stops recording while recording) + ("WMV", Entry::Finish(Action::Dialog("macros"))), // Excel: View Macros; containment(Macros): the Macros dialog ("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window ("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View ("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5be64f867..50cc182c4 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2370,17 +2370,17 @@ }, { "seq": "WMR", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "recordMacro", "excel": "Record Macro", - "note": "Record Macro (macros menu)" + "note": "Record Macro dialog (stops recording while recording)" }, { "seq": "WMV", - "kind": "command", - "target": "view.macros", + "kind": "dialog", + "target": "macros", "excel": "View Macros", - "note": "containment(Macros)" + "note": "containment(Macros): the Macros dialog" }, { "seq": "WN", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9bea918dc..a4e827de4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod i18n; pub mod icons; pub mod keymap; pub mod keytips; +pub mod macro_ui; pub mod outline; pub mod panes; pub mod paper_view; @@ -301,6 +302,8 @@ pub struct SheetApp { /// A run of Tabs: (doc uid, sheet, column it started in, cell the last Tab landed on). /// Enter on that cell goes to the next row in the starting column, as in Excel (#911). tab_run: Option<(u64, usize, u32, CellRef)>, + /// Automate › Macros: relative recording, recent macros, a macro's question. + pub macros: macro_ui::MacroUi, pub started: f64, /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. @@ -349,6 +352,7 @@ impl SheetApp { clipboard_text_at_copy: None, cut_text: None, tab_run: None, + macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, } @@ -1326,6 +1330,7 @@ impl SheetApp { }); } dialogs::show(self, &ctx); + macro_ui::question_box(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); control::issue_screenshots(self, &ctx); @@ -1368,6 +1373,7 @@ impl SheetApp { let enabled = self.editor.is_none() && self.dialog.is_none() && self.message.is_none() + && self.macros.question.is_none() && self.name_box.is_none() && !ctx.text_edit_focused() && self.grid.context_menu.is_none() diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs new file mode 100644 index 000000000..2e650522e --- /dev/null +++ b/crates/ui-egui/src/macro_ui.rs @@ -0,0 +1,601 @@ +//! Automate › Macros in the window: recording (with "Recording · 7 steps" and Stop in the status +//! bar), running a macro and answering its questions (approve it, make room for its output), the +//! Macros dialog (macros, Automate scripts and a file's VBA project), Macro Options, the Record +//! Macro dialog and macro shortcuts (Ctrl+Shift+letter, ⌘⇧letter on a Mac). Everything goes +//! through the `macro.*` and `automate.*` commands. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::dialogs::Dialog; +use crate::theme::{self, Tokens}; + +/// Shortcut rules live in the engine, so the dialogs and `macro.record` agree. +pub use gridcraft_engine::macros::{RESERVED_SHORTCUTS, parse_shortcut, shortcut_conflict, shortcut_text}; + +/// How many macros the Run Macro menu keeps as recent. +const RECENT: usize = 8; + +/// Macro state that belongs to the window, not the workbook. +#[derive(Clone, Debug)] +pub struct MacroUi { + /// Use Relative References: record steps relative to the selection when recording started. + pub relative: bool, + /// Macros run from this window, most recent first (Run Macro's menu). + pub recent: Vec, + /// A question from a macro command, answered with one of its buttons. + pub question: Option, +} + +impl Default for MacroUi { + fn default() -> Self { + MacroUi { relative: true, recent: vec![], question: None } + } +} + +impl MacroUi { + fn note_recent(&mut self, name: &str) { + self.recent.retain(|n| !n.eq_ignore_ascii_case(name)); + self.recent.insert(0, name.to_string()); + self.recent.truncate(RECENT); + } +} + +/// A question from a command (approve this macro? overwrite the data in the way?). Each choice +/// runs the command again with its parameters added; Cancel drops it. +#[derive(Clone, Debug)] +pub struct Question { + pub title: String, + pub text: String, + pub command: String, + pub params: Json, + pub choices: Vec<(String, Json)>, +} + +/// The macros of the active workbook: (name, description, shortcut). +pub fn macro_list(app: &SheetApp) -> Vec<(String, String, Option)> { + app.session + .active() + .map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone(), m.shortcut.clone())).collect()) + .unwrap_or_default() +} + +/// How many commands the recorder has captured, while recording. +pub fn recorded_steps(app: &SheetApp) -> Option { + app.session.recorder.as_ref().map(|r| r.steps.len().saturating_add(r.dropped)) +} + +/// Record Macro / Stop Recording: opens the Record Macro dialog, or stops and saves what was +/// recorded as a macro. +pub fn toggle_recording(app: &mut SheetApp) { + if app.session.is_recording() { + stop_recording(app); + } else { + app.open_dialog("recordMacro", json!({})); + } +} + +/// Stops recording and says what was saved. +pub fn stop_recording(app: &mut SheetApp) { + match app.run("macro.record", json!({"stop": true})) { + Ok(v) => match v["saved"].as_str() { + Some(name) => { + let steps = v["steps"].as_u64().unwrap_or(0); + app.macros.note_recent(name); + app.toast = Some((format!("Saved macro “{name}” ({steps} step{})", if steps == 1 { "" } else { "s" }), crate::now_ms())); + } + None => app.toast = Some(("Recording stopped".into(), crate::now_ms())), + }, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } +} + +/// Runs macro `name` on the selection; its questions (approve, make room) become a prompt. +pub fn run_macro(app: &mut SheetApp, name: &str) { + call(app, "Run Macro", "macro.run", json!({"name": name})); +} + +/// Runs a macro command. Errors are shown; a question (approve it, overwrite or move the data in +/// the way) becomes a prompt whose buttons run it again. `Some` with the result when it ran. +pub fn call(app: &mut SheetApp, title: &str, id: &str, params: Json) -> Option { + match app.run(id, params.clone()) { + Ok(v) => { + note_result(app, id, ¶ms, &v); + Some(v) + } + Err(e) => { + if !ask(app, title, id, ¶ms, &e) { + app.message = Some((title.into(), crate::clean_error(&e))); + } + None + } + } +} + +/// The question in an engine error that asks something: `approve: …` or `confirm: …`. +fn question_text(e: &str, prefix: &str) -> Option { + let rest = e.split_once(prefix)?.1; + Some(rest.split_once(" (run again with").map(|(q, _)| q).unwrap_or(rest).to_string()) +} + +/// Turns a question from command `id` into a prompt whose buttons run it again: Run for a macro +/// that needs approval; Overwrite, Shift Down or Shift Right for a macro whose output would land +/// on data; OK (`overwrite: true`) for anything else. False when `e` is not a question. +pub fn ask(app: &mut SheetApp, title: &str, id: &str, params: &Json, e: &str) -> bool { + let (text, choices) = if let Some(q) = question_text(e, "approve: ") { + (q, vec![("Run".to_string(), json!({"approve": true}))]) + } else if let Some(q) = question_text(e, "confirm: ") { + if id.starts_with("macro.") { + // The engine's hint for programs ("run again with onConflict …") becomes the buttons. + let what = q.split_once(" Overwrite them").map(|(a, _)| a).unwrap_or(&q).trim_end_matches(['.', ' ']); + ( + format!("{what}. Overwrite them, or move the data out of the way?"), + vec![ + ("Overwrite".to_string(), json!({"onConflict": "overwrite"})), + ("Shift Down".to_string(), json!({"onConflict": "shiftDown"})), + ("Shift Right".to_string(), json!({"onConflict": "shiftRight"})), + ], + ) + } else { + (q, vec![("OK".to_string(), json!({"overwrite": true}))]) + } + } else { + return false; + }; + app.macros.question = Some(Question { title: title.into(), text, command: id.into(), params: params.clone(), choices }); + true +} + +/// After a macro command ran (directly or from a prompt's button): remember the macro and say +/// what it did. +pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { + if id != "macro.run" { + return; + } + let name = v["macro"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); + if name.is_empty() { + return; + } + app.macros.note_recent(&name); + let ran = v["ran"].as_u64().unwrap_or(0); + let text = match v["stopped"].as_str() { + Some(msg) => format!("Macro “{name}” stopped: {msg}"), + None => format!("Ran macro “{name}” ({ran} step{})", if ran == 1 { "" } else { "s" }), + }; + app.toast = Some((text, crate::now_ms())); +} + +/// The prompt for [`MacroUi::question`]: its text, a button for each choice and Cancel. +pub fn question_box(app: &mut SheetApp, ctx: &egui::Context) { + if app.message.is_some() { + return; + } + let Some(q) = app.macros.question.clone() else { return }; + let mut answer: Option> = None; + egui::Modal::new(egui::Id::new("gridcraft.macro_question")).show(ctx, |ui| { + ui.set_width(400.0); + ui.label(egui::RichText::new(&q.title).strong()); + ui.add(egui::Label::new(&q.text).wrap()); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(None); + } + // Right to left: list the choices reversed, so the first one ends up next to Cancel + // and Enter picks it. + for (k, (label, extra)) in q.choices.iter().enumerate().rev() { + if ui.add(egui::Button::new(label.as_str()).min_size(vec2(72.0, 0.0))).clicked() + || (k == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + answer = Some(Some(extra.clone())); + } + } + }); + }); + match answer { + Some(Some(extra)) => { + app.macros.question = None; + let mut params = q.params.clone(); + if let (Some(m), Some(extra)) = (params.as_object_mut(), extra.as_object()) { + m.extend(extra.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + // A second question (approve, then make room) asks again. + call(app, &q.title, &q.command, params); + } + Some(None) => app.macros.question = None, + None => {} + } +} + +/// The recording indicator in the status bar: a red dot, "Recording · 7 steps" and Stop. +pub fn status(app: &mut SheetApp, ui: &mut Ui) { + let Some(n) = recorded_steps(app) else { return }; + let t = Tokens::get(ui.ctx()); + ui.add_space(12.0); + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 4.5, t.recording); + let text = format!("{} · {n} step{}", app.ui.language.tr("Recording"), if n == 1 { "" } else { "s" }); + ui.label(egui::RichText::new(text).font(theme::ui_font(12.5)).color(t.text)); + let stop = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Stop")).font(theme::ui_font(12.0))).min_size(vec2(40.0, 18.0))); + if stop.on_hover_text("Stop recording and save the macro").clicked() { + stop_recording(app); + } +} + +// ---------------------------------------------------------------- shortcuts + +/// The macro whose shortcut is Ctrl+Shift+`key` in the active workbook. GridCraft's own +/// shortcuts win over a macro's (a file can carry any letter). +pub fn shortcut_macro(app: &SheetApp, key: egui::Key) -> Option { + let letter = parse_shortcut(key.name())?; + if RESERVED_SHORTCUTS.iter().any(|(c, _)| *c == letter) { + return None; + } + let wb = &app.session.active()?.wb; + wb.macros.iter().find(|m| m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter)).map(|m| m.name.clone()) +} + +/// Ctrl+Shift+letter on the grid: runs the macro with that shortcut. True when one ran. +pub fn run_shortcut(app: &mut SheetApp, key: egui::Key) -> bool { + let Some(name) = shortcut_macro(app, key) else { return false }; + run_macro(app, &name); + true +} + +// ---------------------------------------------------------------- Macros dialog + +/// What a row of the Macros dialog is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Macro, + /// An Automate script (`automate.*`). + Script, +} + +impl Kind { + fn key(self) -> &'static str { + match self { + Kind::Macro => "macro", + Kind::Script => "script", + } + } +} + +/// A row of the Macros dialog. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub name: String, + pub kind: Kind, + pub description: String, + pub shortcut: Option, + pub steps: u64, +} + +/// The Macros dialog's rows: the workbook's macros, then its Automate scripts (from +/// `view.macros`, kept in the dialog's result), and whether the file carries a VBA project. +pub fn entries(app: &SheetApp, d: &Dialog) -> (Vec, bool) { + let mut out: Vec = app + .session + .active() + .map(|doc| { + doc.wb + .macros + .iter() + .map(|m| Entry { + name: m.name.clone(), + kind: Kind::Macro, + description: m.description.clone(), + shortcut: m.shortcut.clone(), + steps: m.step_count() as u64, + }) + .collect() + }) + .unwrap_or_default(); + let mut vba = false; + for it in d.result.as_ref().and_then(Json::as_array).into_iter().flatten() { + if it.get("kind").and_then(Json::as_str) == Some("vba") { + vba = true; + continue; + } + let Some(name) = it.get("name").and_then(Json::as_str) else { continue }; + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + out.push(Entry { name: name.to_string(), kind: Kind::Script, description: String::new(), shortcut: None, steps }); + } + (out, vba) +} + +/// The Macros dialog (View › Macros, Automate › Macros, Alt+F8). Its result holds `view.macros` +/// (the Automate scripts and a file's VBA project), as before macros existed. +pub fn open_dialog(app: &mut SheetApp, params: &Json) -> Dialog { + let names = macro_list(app); + let first = app.macros.recent.iter().find(|r| names.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned(); + let name = params["name"].as_str().map(str::to_string).or(first).or_else(|| names.first().map(|m| m.0.clone())).unwrap_or_default(); + let mut d = Dialog::custom("macros", "Macros", json!({"name": name, "kind": "macro"})); + d.result = app.session.run("view.macros", json!({})).ok(); + d +} + +/// The Macros dialog's body: the name box and the list on the left, the buttons on the right, the +/// description under them; Options… switches to the shortcut and description of one macro. +pub fn macros_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + // Scripts can change while the dialog is open (Delete): read them again when the workbook does. + let rev = app.session.active().map(|doc| doc.revision).unwrap_or(0); + if d.values.get("revision").and_then(Json::as_u64) != Some(rev) { + d.result = app.session.run("view.macros", json!({})).ok(); + d.values.insert("revision".into(), json!(rev)); + } + if d.values.get("options").and_then(Json::as_bool) == Some(true) { + options(app, ui, d); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + let untrusted = d.result.as_ref().and_then(Json::as_array).is_some_and(|items| items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false))); + if untrusted { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + let (list, vba) = entries(app, d); + let mut name = d.values.get("name").and_then(Json::as_str).unwrap_or("").to_string(); + let mut kind = if d.values.get("kind").and_then(Json::as_str) == Some("script") { Kind::Script } else { Kind::Macro }; + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(330.0); + ui.label("Macro name:"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(330.0).id_salt("macro_name")).changed() { + kind = Kind::Macro; + } + egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(326.0, 200.0)); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + if list.is_empty() { + ui.label(egui::RichText::new("This workbook has no scripts or macros.").italics().color(t.text_dim)); + } + for e in &list { + let label = match (e.kind, &e.shortcut) { + (Kind::Macro, Some(k)) => format!("{} {k}", e.name), + (Kind::Macro, None) => e.name.clone(), + (Kind::Script, _) => format!("{} (script, {} step{})", e.name, e.steps, if e.steps == 1 { "" } else { "s" }), + }; + let selected = e.kind == kind && e.name.eq_ignore_ascii_case(name.trim()); + let r = ui.add(egui::Button::selectable(selected, label).min_size(vec2(322.0, 20.0))); + if r.clicked() { + name = e.name.clone(); + kind = e.kind; + } + if r.double_clicked() { + *open = false; + run_entry(app, e); + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(18.0); + let w = vec2(100.0, 24.0); + let target = name.trim().to_string(); + let found = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(&target)).cloned(); + let is_macro = found.as_ref().is_some_and(|e| e.kind == Kind::Macro); + if ui.add_enabled(found.is_some(), egui::Button::new("Run").min_size(w)).clicked() + && let Some(e) = &found + { + *open = false; + run_entry(app, e); + } + if ui.add_enabled(found.is_some(), egui::Button::new("Delete").min_size(w)).clicked() + && let Some(e) = &found + { + let (title, command, what) = match e.kind { + Kind::Macro => ("Delete Macro", "macro.delete", "macro"), + Kind::Script => ("Delete Script", "automate.deleteScript", "script"), + }; + app.macros.question = Some(Question { + title: title.into(), + text: format!("Do you want to delete {what} “{}”?", e.name), + command: command.into(), + params: json!({"name": e.name}), + choices: vec![("Delete".into(), json!({}))], + }); + } + if ui.add_enabled(is_macro, egui::Button::new("Options…").min_size(w)).clicked() + && let Some(e) = &found + { + let key = e.shortcut.as_deref().and_then(parse_shortcut).map(String::from).unwrap_or_default(); + d.values.insert("options".into(), json!(true)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(e.description)); + } + ui.add_space(12.0); + if ui.add(egui::Button::new("Cancel").min_size(w)).clicked() { + *open = false; + } + }); + }); + ui.add_space(6.0); + ui.label("Description"); + let desc = list.iter().find(|e| e.kind == kind && e.name.eq_ignore_ascii_case(name.trim())).map(|e| e.description.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(if desc.is_empty() { " ".to_string() } else { desc }).color(t.text_dim)); + if vba { + ui.add_space(6.0); + ui.label( + egui::RichText::new("This workbook also has a VBA project. GridCraft keeps it when you save but does not run it.").color(t.text_dim), + ); + } + d.values.insert("name".into(), json!(name)); + d.values.insert("kind".into(), json!(kind.key())); +} + +/// Runs a row of the Macros dialog: a macro on the selection, or an Automate script. +fn run_entry(app: &mut SheetApp, e: &Entry) { + match e.kind { + Kind::Macro => run_macro(app, &e.name), + Kind::Script => { + call(app, "Run Script", "automate.runScript", json!({"name": e.name})); + } + } +} + +/// Macro Options: the shortcut letter and the description of the selected macro. +fn options(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog) { + let name = d.values.get("name").and_then(Json::as_str).unwrap_or("").trim().to_string(); + ui.label(egui::RichText::new(format!("Macro name: {name}")).strong()); + ui.add_space(4.0); + ui.label("Shortcut key:"); + let mut key = d.values.get("shortcut").and_then(Json::as_str).unwrap_or("").to_string(); + shortcut_field(ui, &mut key, "macro_shortcut"); + let letter = parse_shortcut(&key); + let conflict = match (key.trim().is_empty(), letter, app.session.active()) { + (true, ..) => None, + (false, None, _) => Some("Use a letter from A to Z.".to_string()), + (false, Some(c), Some(doc)) => shortcut_conflict(&doc.wb, &name, c), + (false, Some(_), None) => None, + }; + if let Some(c) = &conflict { + ui.label(egui::RichText::new(c).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(4.0); + ui.label("Description:"); + let mut desc = d.values.get("description").and_then(Json::as_str).unwrap_or("").to_string(); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("macro_description")); + ui.add_space(6.0); + let mut done = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + done = true; + } + if ui.add_enabled(conflict.is_none(), egui::Button::new("OK")).clicked() { + done = save_options(app, &name, letter, &desc); + } + }); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); + if done { + d.values.remove("options"); + } +} + +/// The shortcut letter box, after "Ctrl+Shift+" (or "⌘⇧" on a Mac). Keeps one capital letter. +fn shortcut_field(ui: &mut Ui, key: &mut String, id: &str) { + ui.horizontal(|ui| { + ui.label(if ui.ctx().os() == egui::os::OperatingSystem::Mac { "⌘ ⇧ +" } else { "Ctrl+Shift+" }); + ui.add(egui::TextEdit::singleline(key).char_limit(1).desired_width(24.0).id_salt(id)); + }); + *key = key.to_ascii_uppercase(); +} + +/// Saves a macro's shortcut and description. True when saved (otherwise the error is shown). +pub fn save_options(app: &mut SheetApp, name: &str, letter: Option, description: &str) -> bool { + let mut m = match app.run("macro.get", json!({"name": name})) { + Ok(m) if m.is_object() => m, + Ok(_) => return false, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + return false; + } + }; + if let Some(o) = m.as_object_mut() { + match letter { + Some(c) => o.insert("shortcut".into(), json!(shortcut_text(c))), + None => o.remove("shortcut"), + }; + o.insert("description".into(), json!(description)); + } + match app.run("macro.save", json!({"macro": m})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("Macro Options".into(), crate::clean_error(&e))); + false + } + } +} + +// ---------------------------------------------------------------- Record Macro dialog + +/// Record Macro: the macro's name, shortcut and description before recording starts. While +/// recording, it stops instead (View › Macros › Record Macro toggles, as in Excel). +pub fn open_record(app: &mut SheetApp, params: &Json) -> Option { + if app.session.is_recording() { + stop_recording(app); + return None; + } + let suggested = app.session.active().map(|d| gridcraft_engine::macros::next_name(&d.wb)).unwrap_or_else(|| "Macro1".into()); + let name = params["name"].as_str().map(str::to_string).filter(|n| !n.trim().is_empty()).unwrap_or(suggested); + Some(Dialog::custom("recordMacro", "Record Macro", json!({"name": name, "shortcut": "", "description": ""}))) +} + +/// What is wrong with the Record Macro dialog's name or shortcut, if anything. +pub fn record_problem(app: &SheetApp, name: &str, key: &str) -> Option { + let doc = app.session.active()?; + if let Err(e) = gridcraft_engine::macros::check_new_name(&doc.wb, name) { + return Some(e); + } + if key.trim().is_empty() { + return None; + } + match parse_shortcut(key) { + None => Some("Use a letter from A to Z for the shortcut.".into()), + Some(c) => shortcut_conflict(&doc.wb, name, c), + } +} + +/// The Record Macro dialog's body. OK starts recording; it stays off while the name or the +/// shortcut is not usable, and the reason shows under the fields. +pub fn record_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let get = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut name = get(d, "name"); + let mut key = get(d, "shortcut"); + let mut desc = get(d, "description"); + ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("record_name")); + ui.add_space(4.0); + ui.label("Shortcut key:"); + shortcut_field(ui, &mut key, "record_shortcut"); + ui.add_space(4.0); + ui.label("Description:"); + ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(3).desired_width(f32::INFINITY).id_salt("record_description")); + ui.checkbox(&mut app.macros.relative, "Use Relative References"); + let problem = record_problem(app, &name, &key); + if let Some(p) = &problem { + ui.label(egui::RichText::new(p).color(Tokens::get(ui.ctx()).danger)); + } + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() { + *open = false; + } + if ui.add_enabled(problem.is_none(), egui::Button::new("OK")).clicked() { + let mut p = json!({"name": name.trim(), "description": desc, "relative": app.macros.relative}); + if !key.trim().is_empty() { + p["shortcut"] = json!(key.trim()); + } + match app.run("macro.record", p) { + Ok(_) => *open = false, + Err(e) => app.message = Some(("Record Macro".into(), crate::clean_error(&e))), + } + } + }); + d.values.insert("name".into(), json!(name)); + d.values.insert("shortcut".into(), json!(key)); + d.values.insert("description".into(), json!(desc)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn questions_are_read_from_the_error() { + let e = "approve: Run macro 'M'? It came with a workbook you opened. (run again with `approve: true`)"; + assert_eq!(question_text(e, "approve: ").as_deref(), Some("Run macro 'M'? It came with a workbook you opened.")); + assert_eq!(question_text("confirm: Replace it? (run again with `overwrite: true`)", "approve: "), None); + assert_eq!(question_text("confirm: Replace it?", "confirm: ").as_deref(), Some("Replace it?")); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ee64fed91..87d0ed176 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,9 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Cat, Tokens}; -use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; +use crate::widgets::{ + big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button, +}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -2565,9 +2567,68 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } fn automate(app: &mut SheetApp, ui: &mut Ui) { + // Macros: the Macros dialog; Record Macro (Stop Recording while recording), Use Relative + // References; Run Macro, whose main part runs the last macro and whose menu lists them all. if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); } + let recording = app.session.is_recording(); + ui.vertical(|ui| { + let (label, tip) = + if recording { ("Stop Recording", "Stop recording and save the macro") } else { ("Record Macro", "Record your actions as a macro") }; + let r = small_button(ui, Icon::Record, label, tip, false); + control_keytip(app, ui, &r, "macro.record"); + if r.clicked() { + crate::macro_ui::toggle_recording(app); + } + let tip = "Record steps relative to the cells selected when recording starts, so the macro works on other data"; + let rel = app.macros.relative; + let r = ui.add_enabled_ui(!recording, |ui| small_toggle_button(ui, Icon::Relative, "Use Relative References", tip, rel)).inner; + if r.clicked() && !recording { + app.macros.relative = !rel; + } + }); + let macros = crate::macro_ui::macro_list(app); + let recent: Vec = app.macros.recent.iter().filter(|r| macros.iter().any(|(n, ..)| n.eq_ignore_ascii_case(r))).cloned().collect(); + let (main, arrow) = big_split_button(ui, Icon::Macro, "Run\nMacro", "Run a macro on the selection"); + control_keytip(app, ui, &arrow, "macro.run"); + if main.clicked() { + // The last macro run, or the only one; otherwise choose in the Macros dialog. + match recent.first().or(macros.first().map(|m| &m.0)) { + Some(name) if macros.len() == 1 || !recent.is_empty() => { + let name = name.clone(); + crate::macro_ui::run_macro(app, &name); + } + _ => app.open_dialog("macros", json!({})), + } + } + egui::Popup::menu(&arrow).show(|ui| { + let t = Tokens::get(ui.ctx()); + if !recent.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("Recent")).small().color(t.text_dim)); + for n in &recent { + if ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + } + if macros.is_empty() { + ui.label(egui::RichText::new("No macros in this workbook").italics().color(t.text_dim)); + } + for (n, desc, _) in macros.iter().take(30) { + let r = ui.add(egui::Button::new(n.as_str()).frame(false).min_size(vec2(200.0, 22.0))); + let r = if desc.is_empty() { r } else { r.on_hover_text(desc) }; + if r.clicked() { + crate::macro_ui::run_macro(app, n); + } + } + ui.separator(); + if ui.add(egui::Button::new(app.ui.language.tr("Macros…")).frame(false).min_size(vec2(200.0, 22.0))).clicked() { + app.open_dialog("macros", json!({})); + } + }); + sep(ui); let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); control_keytip(app, ui, &__k, "commandSearch"); if __k.clicked() { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 65557ed15..1e359bd52 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -335,6 +335,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Point => "Point", }; text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); + crate::macro_ui::status(app, ui); if let Some(d) = app.session.active() { if d.wb.vba_project.is_some() { ui.add_space(12.0); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 74c13c9e2..7267694b9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,8 @@ pub struct Tokens { pub sheet_control_text: Color32, /// A placeholder fill on the sheet (a picture that has not loaded). pub sheet_track: Color32, + /// The macro recording dot (Record Macro, the status bar). + pub recording: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -182,6 +184,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xC42B1C), } } @@ -239,6 +242,7 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + recording: rgb(0xF1705F), } } @@ -1070,6 +1074,8 @@ mod contrast_tests { add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); + add("recording dot on status bar", t.recording, t.status_bar, GLYPH); v } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 295f0eafd..b33daca05 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -60,6 +60,9 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); + let name = tr(ui, tip); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -90,6 +93,9 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); paint_big_label(ui, rect, &lines, dropdown); + let name = label.replace('\n', " "); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name)); resp.on_hover_text(tip_text(ui, tip)) } @@ -184,6 +190,32 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } + // The accessible name is the label, or the tooltip for an icon-only button. + let name = if label.is_empty() { tr(ui, tip) } else { label }; + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name)); + resp.on_hover_text(tip_text(ui, tip)) +} + +/// Small labelled toggle button (Use Relative References): drawn pressed while `on`. Its +/// accessible role is a checkbox, selected while `on`. +pub fn small_toggle_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 23.0), Sense::click()); + if on || resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, 4.0, t.pressed); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let color = if ui.is_enabled() { t.text } else { t.text_disabled }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, color); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, color); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label)); resp.on_hover_text(tip_text(ui, tip)) } diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 2dbcb8f29..10847dae4 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2547,14 +2547,14 @@ fn keytip_wm_() { fn keytip_wmr_() { let mut h = harness(blank()); sequence(&mut h, "WMR", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMR must open the recordMacro dialog"); } #[test] fn keytip_wmv_() { let mut h = harness(blank()); sequence(&mut h, "WMV", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly"); + assert!(h.state().dialog.is_some(), "WMV must open the macros dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/macros_ui.rs b/crates/ui-egui/tests/it/macros_ui.rs new file mode 100644 index 000000000..03417dd1c --- /dev/null +++ b/crates/ui-egui/tests/it/macros_ui.rs @@ -0,0 +1,251 @@ +//! Automate › Macros in the window: recording with the status bar's Stop, the Macros dialog +//! (macros, Automate scripts and a VBA project), macro shortcuts and the question asked when a +//! macro's output would land on data. + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(tab: &str) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, a: &str) -> Value { + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(CellRef::parse(a).unwrap_or_default()))).unwrap_or_default() +} + +fn save(h: &mut Harness, m: serde_json::Value) { + h.state_mut().run("macro.save", json!({"macro": m})).unwrap(); + h.run_steps(2); +} + +fn dialog_name(h: &Harness) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +#[test] +fn record_from_the_ribbon_and_stop_from_the_status_bar() { + let mut h = harness("Automate"); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("recordMacro"), "Record Macro asks for a name first"); + assert!(!h.state().session.is_recording()); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + assert!(h.query_by_label("Stop Recording").is_some(), "the button turns into Stop Recording"); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label_contains("Recording · 1 step").is_some(), "the status bar counts the steps"); + h.get_by_label("Stop").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + let wb = &h.state().session.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + let name = wb.macros[0].name.clone(); + assert_eq!(name, "Macro1"); + assert_eq!(h.state().macros.recent, [name]); + assert!(h.query_by_label_contains("Recording ·").is_none()); +} + +#[test] +fn use_relative_references_sets_how_the_next_recording_starts() { + let mut h = harness("Automate"); + assert!(h.state().macros.relative, "relative is the default"); + h.get_by_label("Use Relative References").click(); + h.run_steps(2); + assert!(!h.state().macros.relative); + h.get_by_label("Record Macro").click(); + h.run_steps(5); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.macro_recording.as_ref().is_some_and(|r| !r.relative)); +} + +#[test] +fn the_macros_dialog_runs_the_selected_macro() { + let mut h = harness("Automate"); + save( + &mut h, + json!({"name": "Fill", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "C3", "input": "done"}}]}), + ); + save(&mut h, json!({"name": "Other", "steps": []})); + h.get_by_label("Macros").click(); + h.run_steps(2); + assert_eq!(dialog_name(&h).as_deref(), Some("macros")); + h.get_by_label("Fill").click(); + h.run_steps(2); + h.get_by_label("Run").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(value(&h, "C3"), Value::Text("done".into())); + // One undo step undoes the whole run. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(value(&h, "C3"), Value::Empty); +} + +#[test] +fn the_macros_dialog_lists_scripts_and_the_vba_project_beside_macros() { + let mut s = Session::new(); + s.new_workbook(); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(std::sync::Arc::new(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])); + s.active_mut().unwrap().wb = std::sync::Arc::new(wb); + s.execute("automate.newScript", json!({"name": "Mark", "commands": [{"command": "cell.set", "params": {"cell": "D4", "input": "script"}}]})) + .unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": []})).unwrap(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Automate".into(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + // The dialog keeps view.macros (scripts and VBA) as its result, as before. + let items = h.state().dialog.as_ref().and_then(|d| d.result.clone()).and_then(|r| r.as_array().cloned()).unwrap_or_default(); + assert!(items.iter().any(|i| i["kind"] == "vba"), "{items:?}"); + assert!(h.query_by_label_contains("also has a VBA project").is_some()); + // A script and a macro may share a name; each has its own row. + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().unwrap().values["kind"], json!("script")); + h.get_by_label("Run").click(); + h.run_steps(3); + assert_eq!(value(&h, "D4"), Value::Text("script".into())); + // Deleting asks first, then removes the script and leaves the macro. + h.state_mut().open_dialog("macros", json!({})); + h.run_steps(2); + h.get_by_label("Mark (script, 1 step)").click(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(h.state().macros.question.as_ref().is_some_and(|q| q.text.contains("script “Mark”"))); + // The question's Delete, in front of the dialog's. + h.get_all_by_label("Delete").last().expect("the question's Delete").click(); + h.run_steps(3); + assert!(h.query_by_label("Mark (script, 1 step)").is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); +} + +#[test] +fn options_set_a_shortcut_that_runs_the_macro() { + let mut h = harness("Home"); + save( + &mut h, + json!({"name": "Stamp", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "B2", "input": "stamped"}}]}), + ); + assert!(gridcraft_ui_egui::macro_ui::save_options(h.state_mut(), "Stamp", Some('m'), "Stamps B2")); + let m = h.state().session.active().unwrap().wb.macros[0].clone(); + assert_eq!((m.shortcut.as_deref(), m.description.as_str()), (Some("Ctrl+Shift+M"), "Stamps B2")); + let mods = Modifiers::COMMAND | Modifiers::SHIFT; + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: true, repeat: false, modifiers: mods }); + h.input_mut().events.push(Event::Key { key: Key::M, physical_key: None, pressed: false, repeat: false, modifiers: mods }); + h.run_steps(3); + assert_eq!(value(&h, "B2"), Value::Text("stamped".into())); +} + +#[test] +fn a_shortcut_gridcraft_uses_does_not_run_a_macro() { + let mut h = harness("Home"); + // A file can bring any letter; Ctrl+Shift+Z stays Redo. + let m = gridcraft_engine::model::Macro { name: "Z".into(), shortcut: Some("Ctrl+Shift+Z".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().wb).macros.push(m); + assert_eq!(gridcraft_ui_egui::macro_ui::shortcut_macro(h.state(), Key::Z), None); +} + +#[test] +fn a_macro_whose_output_lands_on_data_asks_and_can_shift_it_down() { + let mut h = harness("Automate"); + h.state_mut().run("cell.set", json!({"cell": "B1", "input": "old"})).unwrap(); + save(&mut h, json!({"name": "Put", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "new"}}]})); + gridcraft_ui_egui::macro_ui::run_macro(h.state_mut(), "Put"); + h.run_steps(2); + let q = h.state().macros.question.clone().expect("a question"); + assert_eq!(q.choices.iter().map(|c| c.0.as_str()).collect::>(), ["Overwrite", "Shift Down", "Shift Right"]); + assert!(q.text.contains("B1"), "{}", q.text); + assert!(!q.text.contains("onConflict"), "the hint for programs is not shown: {}", q.text); + h.get_by_label("Shift Down").click(); + h.run_steps(3); + assert!(h.state().macros.question.is_none()); + assert_eq!(value(&h, "B1"), Value::Text("new".into())); + assert_eq!(value(&h, "B2"), Value::Text("old".into())); + assert_eq!(h.state().macros.recent, ["Put"]); +} + +#[test] +fn the_record_macro_dialog_names_the_macro_and_checks_name_and_shortcut() { + let mut h = harness("Automate"); + save(&mut h, json!({"name": "Taken", "shortcut": "Ctrl+Shift+P", "steps": []})); + h.get_by_label("Record Macro").click(); + h.run_steps(2); + let set = |h: &mut Harness, k: &str, v: &str| { + h.state_mut().dialog.as_mut().unwrap().values.insert(k.into(), json!(v)); + h.run_steps(2); + }; + assert_eq!(h.state().dialog.as_ref().unwrap().values["name"], json!("Macro1"), "the next free name is suggested"); + set(&mut h, "name", "taken"); + assert!(h.query_by_label_contains("already exists").is_some()); + set(&mut h, "name", "Two\nlines"); + assert!(h.query_by_label_contains("line breaks").is_some()); + set(&mut h, "name", "Tidy Up"); + set(&mut h, "shortcut", "P"); + assert!(h.query_by_label_contains("already runs macro “Taken”").is_some()); + set(&mut h, "shortcut", "S"); + assert!(h.query_by_label_contains("Save As").is_some()); + // OK stays off while something is wrong. + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(!h.state().session.is_recording()); + set(&mut h, "shortcut", "Q"); + set(&mut h, "description", "Tidies the block"); + assert!(h.query_by_label_contains("Choose another").is_none()); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap(); + h.get_by_label("Stop").click(); + h.run_steps(2); + let m = h.state().session.active().unwrap().wb.macros.iter().find(|m| m.name == "Tidy Up").cloned().expect("saved under its name"); + assert_eq!(m.shortcut.as_deref(), Some("Ctrl+Shift+Q")); + assert_eq!(m.description, "Tidies the block"); +} + +#[test] +fn view_macros_record_macro_toggles_recording() { + let mut h = harness("Home"); + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(4); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().session.is_recording()); + // Opening it again while recording stops and saves, as Excel's Stop Recording does. + h.state_mut().open_dialog("recordMacro", json!({})); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(!h.state().session.is_recording()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 915016971..9c2232fb6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -53,6 +53,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod macros_ui; mod menu_hover; mod merged_cells; mod more_colors; diff --git a/docs/macros.md b/docs/macros.md index f23358511..12019a523 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (Record Macro takes a name, shortcut and description; recording: kept steps, the input as it was, text that stays text, size limit, parameters refused; saving needs `replace: true` to replace; kept macros count and keep their names; earlier: recording macros and the management commands) +> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -9,7 +9,7 @@ error (a file it already wrote stays), checks where its results will land before and does not touch files without permission. Agents run macros with `macro.run` like any other command. -This page describes the engine and its commands, not a user interface for macros. +This page describes the engine, its commands and the macro interface in the window. ## A macro @@ -265,6 +265,28 @@ a recording gets a suffix. A macro that is damaged or over the limits is left out with one warning, and the rest of the workbook and its other macros open; a macro that was left out is not in the saved file. +## In the window + +- **Automate › Macros group.** Macros opens the Macros dialog. Record Macro opens the Record + Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References); OK + starts recording and stays off while the name or the letter cannot be used, with the reason + under the fields. While recording, the button reads Stop Recording, the status bar shows a red + dot, "Recording · N steps" and Stop, and Use Relative References is locked. Run Macro runs the + macro run last (or the only one); its menu lists the recent macros, then every macro. +- **Macros dialog** (Automate › Macros, View › Macros, Alt+F8). It lists the workbook's macros + (with their shortcuts), then its Automate scripts, and says when the file carries a VBA project. + Run runs the selected macro on the selection or the selected script; Delete asks first; Options + sets a macro's shortcut letter and description. The dialog keeps the result of `view.macros`, + as before macros existed. +- **Questions.** A macro that needs approval offers Run; a macro whose output would land on data + offers Overwrite, Shift Down and Shift Right. Each button runs the command again with + `approve: true` or the chosen `onConflict`. +- **Shortcuts.** Ctrl+Shift+letter (⌘⇧letter on a Mac) on the grid runs the macro with that + letter. The letters GridCraft uses itself keep their own meaning. + +Commands with empty parameters never open these dialogs: `macro.record {}` starts recording +under the next free name, and the dialogs are opened only from the ribbon, the menus and keys. + ## Automate scripts and VBA Automate scripts (`automate.recordActions`, `newScript`, `allScripts`, `runScript`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a602643f..59962538c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (catalog count 276 / 292, as `cargo xtask parity` measures; earlier: automation: typed macros in the engine) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -93,7 +93,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), with no interface yet. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49251a27c..a8783b499 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (macro interface on the Automate tab; ribbon catalog count 276 / 292; earlier: Paste Name lays out only the names in view; Go To matches names in any case and script; one Page Layout view row, with the header and footer side margin marked unverified; earlier: Go To lists the named ranges of every sheet and the references entered before, and takes several names or references separated by commas; F3 Paste Name dialog with Paste List; right-clicking the status bar opens a menu of its statistics, with Numerical Count, Minimum and Maximum added; Ctrl+Shift+U expands and collapses the formula bar; Fill Days, Fill Weekdays, Fill Months and Fill Years in the fill-handle menus for a date; Show Details in the cell menu of a PivotTable value cell; dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; Outline Settings: summary rows above and summary columns left of their detail; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -141,7 +141,8 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | -| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | +| Alt+F8, Ctrl+Shift+letter | Option+F8, Cmd+Shift+letter | Macros dialog, a macro's own shortcut (letters GridCraft uses keep their meaning) | **added** | +| Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F11 / F12 | same | group sheets, shape cycling, full screen, window keys, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | On Windows, AltGr arrives as Ctrl+Alt: a Ctrl+Alt key that also types a character is treated as @@ -152,7 +153,8 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort, Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal -Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing or partial: Options, +Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a +note on a VBA project; Run, Delete, Options) and Record Macro and others exist. Missing or partial: Options, Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). @@ -187,6 +189,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | +| 2026-10-24 | minor | Automate › Macros group (Record Macro, Use Relative References, Run Macro), recording status in the status bar, Macros dialog with macros beside scripts, Record Macro dialog, Ctrl+Shift+letter macro shortcuts; View › Macros keytips open the dialogs | | 2026-10-24 | minor | Ribbon catalog count 275 / 291 → 276 / 292 (View › Macros › Record Macro) | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list | | 2026-10-24 | minor | Right-clicking the status bar opens a menu of its statistics, as in Excel ("If you want to customize the status bar, right-click it, and then click the options that you want"): Average, Count and Sum are on by default, and Numerical Count, Minimum and Maximum are new and off by default. The choice is a preference (`view.statusBar`), kept in `prefs.json` |