Window title: mark unsaved changes, and set it on the web (#3640)

The title now reads "<name> • — PdfCraft" while the active document
has unsaved changes, as the other Craft apps write it, and goes back to
"<name> — 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.
This commit is contained in:
Adrian
2026-10-28 19:50:20 -04:00
parent dc943eeb13
commit dda63fda0f
3 changed files with 42 additions and 6 deletions
+11 -2
View File
@@ -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
+1 -1
View File
@@ -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");
+30 -3
View File
@@ -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()));
}