mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 05:37:00 -06:00
UI: Automate › Macros group, Record Macro and Macros dialogs, recording status, macro shortcuts
The Automate tab gains a Macros group: Macros opens the Macros dialog, Record Macro opens the Record Macro dialog (name, Ctrl+Shift shortcut letter, description, Use Relative References) and turns into Stop Recording while recording, Use Relative References sets how the next recording starts, and Run Macro runs the macro run last; its menu lists the recent macros, then every macro. While recording, the status bar shows a red dot, "Recording · N steps" and Stop. The Macros dialog (Automate › Macros, View › Macros, Alt+F8) lists the workbook's macros with their shortcuts, then its Automate scripts, and says when the file carries a VBA project, which is kept and not run. Run, Delete (which asks first) and Options (shortcut letter and description) act on the selected row. The dialog keeps the result of view.macros, so the scripts and the VBA entry are listed as before. A macro that needs approval asks with Run, and one whose output would land on data asks with Overwrite, Shift Down and Shift Right; each button runs the command again with its answer. Ctrl+Shift+letter (Cmd+Shift on a Mac) runs the macro with that shortcut; the letters GridCraft uses keep their meaning. The View › Macros keytips (WMR, WMV) now open the Record Macro and Macros dialogs. Icons for macros, recording and relative references are drawn in code, the recording dot is a theme token covered by the contrast test, and large and small ribbon buttons and icon buttons carry accessible names.
This commit is contained in:
+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-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! |
|
||||
|
||||
@@ -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::<Vec<_>>())))
|
||||
@@ -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<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" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
/// A question from a macro command, answered with one of its buttons.
|
||||
pub question: Option<Question>,
|
||||
}
|
||||
|
||||
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<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);
|
||||
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<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;
|
||||
};
|
||||
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<Option<Json>> = 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<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 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<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));
|
||||
}
|
||||
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<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");
|
||||
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?"));
|
||||
}
|
||||
}
|
||||
@@ -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<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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+24
-2
@@ -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`,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+6
-3
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user