diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432b4..828187112 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -132,6 +132,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add_space(8.0); + file_menu(app, ui); let mut tabs: Vec<&str> = TABS.to_vec(); let ctx_tabs = contextual_tabs(app); tabs.extend(ctx_tabs.iter()); @@ -202,6 +203,32 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +fn file_menu(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); + egui::Popup::menu(&file).show(|ui| { + for (label, id, needs_document) in [ + ("New Workbook", "file.new", false), + ("Open…", "file.open", false), + ("Save", "file.save", true), + ("Save As…", "file.saveAs", true), + ("Close", "file.close", true), + ] { + if id == "file.save" || id == "file.close" { + ui.separator(); + } + if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { + // File operations must include the cell the user is still editing. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert(id, json!({})); + } + ui.close(); + } + } + }); +} + fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { let mut v = Vec::new(); if let Some(d) = app.session.active() diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs new file mode 100644 index 000000000..5f6ca7c9c --- /dev/null +++ b/crates/ui-egui/tests/file_menu.rs @@ -0,0 +1,76 @@ +//! The shared ribbon exposes basic file operations without requiring 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 +} + +fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_label("File").click(); + h.run_steps(3); + h.get_by_label(label).click(); + h.run_steps(3); +} + +#[test] +fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { + 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); + choose(&mut h, "New Workbook"); + assert_eq!(h.state().session.documents().len(), 2); + choose(&mut h, "Open…"); + assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert_eq!(h.state().ui.ribbon_tab, "Data"); + assert!(h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); +} + +#[test] +fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { + let downloads = Arc::new(Mutex::new(Vec::>::new())); + let capture = downloads.clone(); + let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(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"); + choose(&mut h, "Save As…"); + let saved = downloads.lock().unwrap(); + assert_eq!(saved.len(), 2); + for bytes in saved.iter() { + 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)); + } + drop(saved); + assert!(h.state().editor.is_none()); + choose(&mut h, "Close"); + 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)); +}