Backstage: add Save As page and close on successful save (#727)

* 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 <noreply@anthropic.com>

* Print-page test clicks the page's Print button (the backstage list now has a Print entry too)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 05:26:06 -04:00
1 parent 59b04d7644
commit 439cfd2fe9
3 files changed
+146 -7

No files matched your search

+137 -5
View File
@@ -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::<crate::FileDialogAnswer>().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<std::cell::RefCell<Vec<(String, Vec<u8>)>>> = 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");
}
}
+6 -1
View File
@@ -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 => {
+3 -1
View File
@@ -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");