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())); }