Macros UX: Excel-style info bar, Macros dialog, status badge (issue #6)

The engine now preserves a workbook's VBA project, but the UI had no surface
for it and silently dropped every open-time warning. Give preserved macros an
honest, Excel-shaped home without a false "Enable Content":

- Info bar under the ribbon (Excel's message-bar slot) showing the warning a
  `file.open` result carries. A VBA project gets an amber bar reading "This
  workbook contains VBA macros. GridCraft keeps them intact when you save, but
  does not run them." with a "What this means" button; anything else becomes a
  blue informational bar. Dismissable; a clean open clears a stale notice.
- Macros dialog (Automate ▸ Macros, Alt+F8): lists GridCraft's own scripts and
  the preserved VBA project (presence + size, not module names — the blob is
  opaque). Mirrors Excel's Developer ▸ Macros without claiming to run VBA.
- Status-bar badge "Macros preserved (not executed)" whenever the workbook
  carries a VBA project.

New panel `notice_bar` reuses theme tokens (notice_warn_*/notice_info_*), so it
follows light/dark. Two new UI commands, `ui.notice` and `ui.notice.dismiss`,
reach it from the control channel.

Tests: info bar appears for a macro workbook and clears on dismiss; a non-VBA
warning becomes an info bar; clean open clears the notice; the macros dialog
lists the VBA entry; status bar renders with a VBA project; Alt+F8 opens the
dialog. Snapshot-verified in light and dark.
This commit is contained in:
justinjohn0306 2026-10-09 05:20:04 +05:30
parent 9c1d2c3b3a
commit 596b465bb1
8 changed files with 260 additions and 4 deletions

View File

@ -512,6 +512,11 @@ impl Dialog {
d
}
"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
}
"statistics" => {
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
@ -638,7 +643,7 @@ 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" => 520.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
_ => 380.0,
};
egui::Window::new(d.title.clone())
@ -676,6 +681,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
});
}
"macros" => macros(ui, &mut d),
"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<_>>())))
@ -925,6 +931,34 @@ 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(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;
}
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) {
ui.horizontal(|ui| {
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {

View File

@ -1603,6 +1603,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})),
Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})),
Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})),
Key::F8 if m.alt => {
app.open_dialog("macros", json!({}));
}
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
Key::F4 if !cmd => {}
_ if cmd => crate::ribbon::shortcut(app, key, m),

View File

@ -63,6 +63,23 @@ pub struct SheetView {
pub scroll: egui::Vec2,
}
/// Severity of a notice shown in the info bar under the ribbon.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoticeKind {
Info,
Warning,
}
/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the
/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros).
#[derive(Clone, Debug, PartialEq)]
pub struct Notice {
pub text: String,
pub kind: NoticeKind,
/// Optional button: `(label, dialog name)` — opens that dialog when clicked.
pub action: Option<(String, String)>,
}
/// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)]
pub struct Services {
@ -96,6 +113,8 @@ pub struct SheetApp {
pub dialog: Option<dialogs::Dialog>,
pub message: Option<(String, String)>,
pub toast: Option<(String, f64)>,
/// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>,
pub services: Services,
/// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
@ -121,6 +140,7 @@ impl SheetApp {
dialog: None,
message: None,
toast: None,
notice: None,
services,
synthetic: Default::default(),
control_rx: None,
@ -196,6 +216,18 @@ impl SheetApp {
self.message = None;
Ok(Json::Null)
}
"ui.notice" => Ok(match &self.notice {
Some(n) => json!({
"text": n.text,
"kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" },
"action": n.action,
}),
None => Json::Null,
}),
"ui.notice.dismiss" => {
self.notice = None;
Ok(Json::Null)
}
_ => return None,
};
Some(r)
@ -249,16 +281,48 @@ impl SheetApp {
pub fn open_path(&mut self, path: &str) {
match self.session.run("file.open", json!({"path": path})) {
Ok(_) => {
Ok(r) => {
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(20);
self.show_open_warnings(&r);
}
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
/// Kept deliberately generic: any open-time warning can reach the user this way.
pub fn show_open_warnings(&mut self, r: &Json) {
let Some(list) = r.get("warnings").and_then(Json::as_array) else {
self.notice = None;
return;
};
let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect();
if msgs.is_empty() {
// A clean open clears any notice left over from the previous document.
self.notice = None;
return;
}
let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect();
if !vba.is_empty() {
self.notice = Some(Notice {
text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(),
kind: NoticeKind::Warning,
action: Some(("What this means".into(), "macros".into())),
});
} else {
let text = if msgs.len() == 1 {
msgs[0].to_string()
} else {
format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0])
};
self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None });
}
}
pub fn view_key(&self) -> Option<(u64, usize)> {
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
}
@ -375,8 +439,9 @@ impl SheetApp {
.unwrap_or_default();
for (name, bytes) in arrived {
let b64 = gridcraft_engine::io::base64_encode(&bytes);
if let Err(e) = self.session.run("file.open", json!({"name": name, "base64": b64})) {
self.message = Some(("GridCraft".into(), clean_error(&e)));
match self.session.run("file.open", json!({"name": name, "base64": b64})) {
Ok(r) => self.show_open_warnings(&r),
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
}
self.after_engine();
}
@ -402,6 +467,7 @@ impl SheetApp {
let t = theme::Tokens::get(&ctx);
ribbon::title_bar(self, ui);
ribbon::show(self, ui);
widgets::notice_bar(self, ui);
if self.ui.formula_bar {
formula_bar::show(self, ui);
}

View File

@ -1574,6 +1574,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
}
fn automate(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
}
if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() {
app.open_dialog("commandSearch", json!({}));
}

View File

@ -192,6 +192,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
};
ui.label(egui::RichText::new(mode).font(theme::ui_font(12.5)).color(t.text_dim));
if let Some(d) = app.session.active() {
if d.wb.vba_project.is_some() {
ui.add_space(12.0);
ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
}
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
ui.add_space(12.0);
ui.label(egui::RichText::new("Calculate").font(theme::ui_font(12.5)).color(t.text_dim));

View File

@ -43,6 +43,11 @@ pub struct Tokens {
pub menu_bg: Color32,
pub shadow: Color32,
pub danger: Color32,
/// Info-bar (notice strip) fills, by severity.
pub notice_warn_bg: Color32,
pub notice_warn_border: Color32,
pub notice_info_bg: Color32,
pub notice_info_border: Color32,
}
impl Tokens {
@ -80,6 +85,10 @@ impl Tokens {
menu_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(40),
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
}
}
@ -117,6 +126,10 @@ impl Tokens {
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: Color32::from_black_alpha(90),
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
}
}

View File

@ -198,3 +198,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) {
);
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an
/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`.
pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) {
let Some(notice) = app.notice.clone() else { return };
let t = Tokens::get(ui.ctx());
let (bg, border) = match notice.kind {
crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border),
crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border),
};
let mut dismiss = false;
let mut action: Option<(String, String)> = None;
egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| {
// Bottom border as a 1px strip spanning the bar.
let bar = ui.max_rect();
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside);
ui.horizontal_centered(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(
ui.painter(),
r,
match notice.kind {
crate::NoticeKind::Warning => Icon::Lock,
crate::NoticeKind::Info => Icon::Note,
},
border,
);
ui.add_space(6.0);
ui.label(egui::RichText::new(&notice.text).font(theme::ui_font(12.5)).color(t.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() {
dismiss = true;
}
if let Some((label, dialog)) = &notice.action
&& ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked()
{
action = Some((label.clone(), dialog.clone()));
}
});
});
});
if let Some((_, dialog)) = action {
app.open_dialog(&dialog, serde_json::json!({}));
}
if dismiss {
app.notice = None;
}
}

View File

@ -113,6 +113,7 @@ fn dialogs_open_and_close() {
"spelling",
"goTo",
"about",
"macros",
] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
@ -146,3 +147,87 @@ fn column_autocomplete_completes_on_enter() {
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(value(&h, "A5"), Value::from("Ea"));
}
/// An .xlsm carrying a VBA project, saved to bytes.
fn macro_workbook_bytes() -> Vec<u8> {
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec());
gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()
}
#[test]
fn opening_a_macro_workbook_shows_a_notice() {
// The reader's "macros kept but not executed" warning must reach a dismissible info bar,
// not be silently dropped (issue #6 follow-up: Excel-style message bar).
let mut h = harness(blank());
let b64 = gridcraft_engine::io::base64_encode(&macro_workbook_bytes());
let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
assert!(
res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))),
"engine reports the macro warning: {res}"
);
// The UI-side handler turns that open result into the notice (open_path does this in the app).
h.state_mut().show_open_warnings(&res);
let n = h.state().notice.clone().expect("a notice was shown");
assert!(n.text.contains("VBA"), "{}", n.text);
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog");
// The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it.
h.run_steps(2);
let got = h.state_mut().run("ui.notice", json!({})).unwrap();
assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning"));
h.state_mut().run("ui.notice.dismiss", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().notice.is_none());
}
#[test]
fn plain_open_warning_becomes_an_info_notice() {
let mut h = harness(blank());
h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]}));
let n = h.state().notice.clone().unwrap();
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info);
assert!(n.action.is_none());
assert!(n.text.contains("external"));
// No warnings → no notice, no panic.
h.state_mut().show_open_warnings(&json!({"warnings": []}));
assert!(h.state().notice.is_none());
}
#[test]
fn macros_dialog_lists_vba_and_scripts() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
let mut h = harness(s);
h.state_mut().open_dialog("macros", json!({}));
h.run_steps(3);
let d = h.state().dialog.as_ref().expect("macros dialog open");
assert_eq!(d.name(), "macros");
let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default();
assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}");
h.state_mut().dialog = None;
}
#[test]
fn status_bar_shows_macro_badge() {
let mut s = blank();
let mut wb = gridcraft_engine::model::Workbook::new();
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
// Rendering the status bar must not panic with a VBA project present.
let mut h = harness(s);
h.run_steps(3);
h.state_mut().ui.dark = true;
h.run_steps(2);
}
#[test]
fn alt_f8_opens_the_macros_dialog() {
let mut h = harness(blank());
key(&mut h, Key::F8, Modifiers::ALT);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros"));
h.state_mut().dialog = None;
}