mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-11 16:34:47 -06:00
Clicking the File tab now opens a full-window Backstage view in place of the
ribbon, formula bar, grid and sheet tabs, like Excel's. Esc or the back arrow
return to the sheet. Pages: Home (new + recent), New (blank + samples), Open
(recent + Browse), Info (location, properties, Protect Workbook), Save, Save As,
Print (page preview, scope, fit to page, Page Setup), Export (PDF, CSV, web
page), Close and Options (theme, language, formula bar, AutoSave, calculation).
Every action runs an existing command or dialog.
Adds `file.options` (reports the workbook's calculation settings and asks the
interface for the Options page) and the UI command `ui.backstage {page}`.
Keyboard shortcuts and the macOS native menu are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.9 KiB
Rust
149 lines
5.9 KiB
Rust
//! The File tab opens the Backstage view, which exposes the file operations without a native menu.
|
||
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use egui_kittest::kittest::Queryable as _;
|
||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||
use gridcraft_ui_egui::{Services, SheetApp};
|
||
|
||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||
let mut session = Session::new();
|
||
session.new_workbook();
|
||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||
|ui, app: &mut SheetApp| {
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
SheetApp::new(session, services),
|
||
);
|
||
h.run_steps(4);
|
||
h
|
||
}
|
||
|
||
/// Opens File (the Backstage view) and clicks a navigation entry or page button.
|
||
fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
|
||
if h.state().backstage.is_none() {
|
||
h.get_by_label("File").click();
|
||
h.run_steps(3);
|
||
}
|
||
click(h, label);
|
||
}
|
||
|
||
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) {
|
||
h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();
|
||
h.run_steps(3);
|
||
}
|
||
|
||
fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> {
|
||
h.state().backstage.as_ref().map(|b| b.page.id())
|
||
}
|
||
|
||
#[test]
|
||
fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() {
|
||
let opened = Arc::new(Mutex::new(0));
|
||
let capture = opened.clone();
|
||
let mut h = harness(Services {
|
||
pick_open: Some(Box::new(move || {
|
||
*capture.lock().unwrap() += 1;
|
||
None
|
||
})),
|
||
..Default::default()
|
||
});
|
||
h.state_mut().ui.ribbon_tab = "Data".into();
|
||
h.state_mut().ui.ribbon_collapsed = true;
|
||
h.run_steps(2);
|
||
h.get_by_label("File").click();
|
||
h.run_steps(3);
|
||
assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home");
|
||
choose(&mut h, "New");
|
||
assert_eq!(page(&h), Some("new"));
|
||
click(&mut h, "Blank workbook");
|
||
assert_eq!(h.state().session.documents().len(), 2);
|
||
assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet");
|
||
choose(&mut h, "Open");
|
||
click(&mut h, "Browse…");
|
||
assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker");
|
||
assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open");
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(3);
|
||
assert!(h.state().backstage.is_none(), "Esc returns to the sheet");
|
||
assert_eq!(h.state().ui.ribbon_tab, "Data");
|
||
assert!(h.state().ui.ribbon_collapsed);
|
||
}
|
||
|
||
#[test]
|
||
fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() {
|
||
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||
let capture = downloads.clone();
|
||
let mut h = harness(Services {
|
||
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))),
|
||
..Default::default()
|
||
});
|
||
h.input_mut().events.push(egui::Event::Text("123".into()));
|
||
h.run_steps(2);
|
||
assert!(h.state().editor.is_some());
|
||
choose(&mut h, "Save");
|
||
assert!(h.state().editor.is_none(), "opening File commits the cell draft");
|
||
assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As");
|
||
click(&mut h, "Download (.xlsx)");
|
||
assert!(h.state().backstage.is_none());
|
||
choose(&mut h, "Save As");
|
||
click(&mut h, "Download (.xlsx)");
|
||
choose(&mut h, "Export");
|
||
click(&mut h, "CSV (Comma delimited)");
|
||
let saved = downloads.lock().unwrap();
|
||
assert_eq!(saved.len(), 3);
|
||
for (_, bytes) in saved.iter().take(2) {
|
||
let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap();
|
||
assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
|
||
}
|
||
assert!(saved[2].0.ends_with(".csv"));
|
||
assert!(String::from_utf8_lossy(&saved[2].1).contains("123"));
|
||
drop(saved);
|
||
choose(&mut h, "Close");
|
||
assert!(h.state().backstage.is_none());
|
||
assert_eq!(h.state().session.documents().len(), 1);
|
||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
|
||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0));
|
||
}
|
||
|
||
#[test]
|
||
fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||
let mut h = harness(Services::default());
|
||
let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||
assert_eq!(r["calculation"]["mode"], "automatic");
|
||
h.run_steps(3);
|
||
assert_eq!(page(&h), Some("options"));
|
||
click(&mut h, "Info");
|
||
assert_eq!(page(&h), Some("info"));
|
||
for id in ["print", "export", "saveAs", "new", "open", "home"] {
|
||
h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap();
|
||
h.run_steps(2);
|
||
assert_eq!(page(&h), Some(id));
|
||
}
|
||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err());
|
||
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
|
||
assert!(h.state().backstage.is_none());
|
||
// The back arrow returns to the sheet too.
|
||
h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap();
|
||
h.run_steps(2);
|
||
click(&mut h, "Back");
|
||
assert!(h.state().backstage.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn export_as_pdf_is_delivered_as_a_download() {
|
||
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||
let capture = files.clone();
|
||
let mut h = harness(Services {
|
||
download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))),
|
||
..Default::default()
|
||
});
|
||
h.state_mut().run("file.exportPdf", serde_json::json!({})).unwrap();
|
||
let got = files.lock().unwrap();
|
||
assert_eq!(got.len(), 1, "Export as PDF hands a file to the browser");
|
||
assert!(got[0].0.ends_with(".pdf"));
|
||
assert!(got[0].1.starts_with(b"%PDF-"));
|
||
}
|