From 439cfd2fe977612456c6563e1cf72628374bfae0 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:56:06 +0530 Subject: [PATCH] Backstage: add Save As page and close on successful save (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Backstage: add Save As page and close on successful save * Review fixes for #727: Save As page without the open list; unnamed Save opens no native dialog The Save As page reused the Open page's recent list, so clicking a document there opened it. It now has only Browse…. Save on a document without a file goes to the Save As page instead of running file.save, which also opened the native Save As dialog. Test for both. Co-Authored-By: Claude Opus 5.5 * Print-page test clicks the page's Print button (the backstage list now has a Print entry too) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 142 ++++++++++++++++++++++++++++- crates/ui-egui/src/file_dialogs.rs | 7 +- crates/ui-egui/src/lib.rs | 4 +- 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0e2970ce89..4156006f8e 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -59,6 +59,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.add_space(if mac { 12.0 } else { 6.0 }); for (id, label) in PAGES { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); let active = app.ui.backstage_page == id; if active { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46)); @@ -74,14 +75,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ); if resp.clicked() { match id { + // A document without a file goes to the Save As page (as in Word) instead + // of `file.save`, which would also open the native Save As dialog. + // In the browser a save is a download, so it saves right away. + "save" if app.session.path.is_none() && app.services.download.is_none() => { + app.ui.backstage_page = "saveAs".into(); + } "save" => { - let _ = app.run("file.save", json!({})); + let r = app.run("file.save", json!({})); + if r.is_ok_and(|v| v.get("saved").and_then(serde_json::Value::as_bool) == Some(true) || v.get("downloaded").is_some()) { + app.ui.backstage = false; + app.canvas.want_focus = true; + } else { + app.ui.backstage_page = "saveAs".into(); + } } "saveAs" => { - app.save_as_dialog(); - } - "open" => { - app.ui.backstage_page = id.into(); + app.ui.backstage_page = "saveAs".into(); } _ => app.ui.backstage_page = id.into(), } @@ -90,6 +100,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { for (label, id) in [("About", "about"), ("Discord community", "discord")] { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); if resp.hovered() { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } @@ -109,6 +120,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "home" => home_page(app, ui), "new" => new_page(app, ui), "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), "info" => info_page(app, ui), "print" | "export" => export_page(app, ui), "options" => crate::options::page(app, ui), @@ -277,6 +289,15 @@ fn open_page(app: &mut WordApp, ui: &mut Ui) { open_list(app, ui); } +/// Save As: Browse… asks where to save. It has no recent-documents list: clicking one of those +/// opens it, which is not what Save As is for. +fn save_as_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, "Save As"); + if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() { + app.save_as_dialog(); + } +} + fn info_page(app: &mut WordApp, ui: &mut Ui) { heading(ui, "Info"); let info = app.session.run("file.info", &json!({})).unwrap_or_default(); @@ -469,4 +490,115 @@ mod tests { a.session.run("edit.undo", &json!({})).unwrap(); assert_eq!(a.session.doc.core.title, ""); } + + #[test] + fn save_as_page_shows_browse_and_no_open_list() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + assert!(h.query_by_label("Browse…").is_some(), "Save As has Browse button"); + assert!(h.query_by_label("Recent").is_none(), "Save As has no list that opens documents"); + } + + #[test] + fn backstage_save_navigates_to_save_as_when_unnamed() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs", "unnamed Save opens Save As page"); + assert!(h.state().ui.backstage, "backstage remains open"); + } + + /// Save on a document without a file shows the Save As page only: no native dialog as well. + #[test] + fn backstage_save_when_unnamed_opens_no_file_dialog() { + let shown = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + count.set(count.get() + 1); + std::sync::mpsc::channel::().1 + })), + ..Default::default() + }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs"); + assert_eq!(shown.get(), 0, "no native Save As dialog"); + assert!(!h.state().file_dialog_open()); + } + + #[test] + fn backstage_save_closes_when_saved() { + let got: std::rc::Rc)>>> = Default::default(); + let sink = got.clone(); + let services = Services { + download: Some(Box::new(move |n: &str, b: &[u8]| { + sink.borrow_mut().push((n.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert!(!h.state().ui.backstage, "successful save closes backstage"); + } + + #[test] + fn backstage_save_as_sidebar_opens_save_as_page() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save As").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs"); + } } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 788f81b679..5ee9b420aa 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -233,8 +233,13 @@ impl WordApp { } AfterPick::SaveAs(after) => { let saved = picked.is_some_and(|path| { - self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true)) + self.run("file.save", json!({"path": path})) + .is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()) }); + if saved { + self.ui.backstage = false; + self.canvas.want_focus = true; + } match after { AfterSave::Nothing => Ok(json!({"saved": saved})), AfterSave::AutoSave => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19295c700e..5bb7524f05 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2539,7 +2539,9 @@ mod tests { assert_eq!(h.query_by_label(BLURB).is_some(), hook, "hook {hook}: the Print button's blurb"); assert!(h.query_by_label("PDF document (*.pdf)").is_some(), "hook {hook}: the page rendered its export choices"); if hook { - h.get_by_role_and_label(egui::accesskit::Role::Button, "Print").click(); + // The page's own Print button (the backstage list has a Print entry too): the last. + let buttons: Vec<_> = h.query_all(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label("Print")).collect(); + buttons.last().expect("a Print button").click(); h.step(); h.step(); assert_eq!(printed.get(), 1, "the button printed");