From dda63fda0fe4c9534c7507539270d100a8925966 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 20 Oct 2026 23:09:54 -0400 Subject: [PATCH] Window title: mark unsaved changes, and set it on the web (#3640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The title now reads " • — PdfCraft" while the active document has unsaved changes, as the other Craft apps write it, and goes back to " — PdfCraft" once saved. eframe's web runner ignores ViewportCommand::Title, so the web build never named the open document. It now also sets the page's document.title, which an embedding host reads to confirm which document opened and whether it has unsaved changes. --- crates/ui-egui/src/app.rs | 13 ++++++++-- crates/ui-egui/tests/it/accessibility.rs | 2 +- crates/ui-egui/tests/it/viewer.rs | 33 +++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 23bee9babc..352178bb7b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -546,14 +546,23 @@ impl eframe::App for PdfCraftApp { for view in &mut self.views { view.begin_render_frame(); } - // The window shows the active document's name (or title, if it asks for that). + // The window shows the active document's name (or title, if it asks for that), with • + // while it has unsaved changes, as the other Craft apps write it (#3640). + let view = self.active.and_then(|i| self.views.get(i)); + let dirty = view.and_then(|v| self.session.get(v.id)).is_some_and(|d| d.dirty); + let mark = if dirty { " •" } else { "" }; let title = self .active .and_then(|i| crate::canvas::tab_label(self, i)) .or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) - .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft")); + .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name}{mark} — PdfCraft")); if title != self.window_title { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + // eframe's web runner ignores Title: set the page's own, which an embedding host reads. + #[cfg(target_arch = "wasm32")] + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + document.set_title(&title); + } self.window_title = title; } // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on diff --git a/crates/ui-egui/tests/it/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs index 7d13df6adc..eebfc40020 100644 --- a/crates/ui-egui/tests/it/accessibility.rs +++ b/crates/ui-egui/tests/it/accessibility.rs @@ -91,7 +91,7 @@ fn checking_fixing_skipping_and_reporting() { assert_eq!(status(&h, Rule::Title), Status::Passed); assert_eq!(h.state().a11y.previous.as_ref().unwrap().1.result(Rule::Title).unwrap().status, Status::Failed); assert!(matches!(h.state().dialog, Some(Dialog::Properties(_)))); - assert_eq!(h.state().window_title, "notes — PdfCraft"); + assert_eq!(h.state().window_title, "notes • — PdfCraft"); h.state_mut().dialog = None; h.run_steps(2); h.get_by_label("Compare"); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index f2a12b69aa..0db837f901 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -974,10 +974,36 @@ fn tab_and_window_show_the_document_title_when_asked() { s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); } h.run_steps(2); - assert_eq!(h.state().window_title, "Quarterly report — PdfCraft"); + // The edits are unsaved, so the title says so (#3640). + assert_eq!(h.state().window_title, "Quarterly report • — PdfCraft"); h.get_by_label_contains("Quarterly report"); } +#[test] +fn the_window_title_marks_unsaved_changes_until_saved() { + use pdfcraft_engine::Edit; + let mut h = harness(); + h.run_steps(2); + assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); + { + let s = h.state_mut(); + let id = s.views[s.active.unwrap()].id; + s.session.apply(id, Edit::SetInfo { key: "Subject".into(), value: "Budget".into() }).unwrap(); + } + h.run_steps(2); + assert_eq!(h.state().window_title, "b.pdf • — PdfCraft", "unsaved changes (#3640)"); + // Saved under its own name, so the title changes only by the mark. + let dir = std::env::temp_dir().join(format!("pdfcraft-title-dirty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + h.state_mut().save_override = Some(dir.join("b.pdf").to_string_lossy().into_owned()); + assert!(h.state_mut().execute("file.save")); + h.run_steps(2); + let _ = std::fs::remove_dir_all(&dir); + let s = h.state(); + assert!(!s.session.get(s.views[s.active.unwrap()].id).unwrap().dirty); + assert_eq!(s.window_title, "b.pdf — PdfCraft", "saved"); +} + #[test] fn a_placeholder_title_leaves_the_file_name_on_the_tab_and_window() { // A browser's "Save as PDF" of a blank page or pop-up titles the document "about:blank" @@ -993,7 +1019,7 @@ fn a_placeholder_title_leaves_the_file_name_on_the_tab_and_window() { s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); } h.run_steps(2); - assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); + assert_eq!(h.state().window_title, "b.pdf • — PdfCraft"); assert!(h.query_by_label_contains("about:blank").is_none()); } @@ -2265,6 +2291,7 @@ fn the_accessible_window_has_the_window_title_as_its_name() { s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); } h.run_steps(2); - assert_eq!(name(&h).as_deref(), Some("Quarterly report — PdfCraft")); + // Unsaved, so it carries the window title's mark too (#3640). + assert_eq!(name(&h).as_deref(), Some("Quarterly report • — PdfCraft")); assert_eq!(name(&h), Some(h.state().window_title.clone())); }