From 00a7d5b8879d39d748c20532303588fe5254b947 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:49:26 -0400 Subject: [PATCH 1/3] M4: a save prompt that opens in the background asks for the user's attention Quitting from the Dock menu (or logging out, or an agent's ui.quit) while another app is in front opens "Save changes?" in a window the user can't see, so it looked as if PdfCraft hadn't quit. The prompt now sends a critical user-attention request on the frame it opens while the window isn't focused: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint. Coming back, or answering, sends Reset. This is what AppKit does by itself when an inactive app presents a modal panel; PdfCraft's prompt is drawn by egui, so AppKit never sees it. --- crates/ui-egui/src/editing.rs | 22 +++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++++++++ crates/ui-egui/tests/it/control.rs | 35 ++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 44 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b638..7b3c1c765 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -960,6 +960,28 @@ impl PdfCraftApp { self.shutdown_recovery(); } } + + /// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in + /// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the + /// request once the user comes back or the question is answered. + /// + /// The question holds up the quit the user asked for, so the request is critical, as AppKit + /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until + /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and + /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front + /// asks nothing, and neither does leaving it open: the user has seen it. + pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { + let open = self.close_request.is_some(); + let focused = ctx.input(|i| i.focused); + if open && !self.save_prompt_was_open && !focused { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); + self.save_prompt_attention = true; + } else if self.save_prompt_attention && (focused || !open) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + self.save_prompt_was_open = open; + } } /// The single page a content edit changes (`None` for other edits). Like comment edits, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af9912f45..0db6e1fef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,6 +642,11 @@ pub struct PdfCraftApp { pub close_request: Option, /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. pub(crate) save_prompt_button: dialogs::PromptButton, + /// Whether the "save changes?" question was pending last frame, to tell the frame it opens. + save_prompt_was_open: bool, + /// The "save changes?" question asked for the user's attention and the request still stands + /// (see `attend_save_prompt`). + save_prompt_attention: bool, /// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check). #[cfg(not(target_arch = "wasm32"))] pub pending_overwrite: Option, @@ -1018,6 +1023,8 @@ impl PdfCraftApp { host_dirty: None, close_request: None, save_prompt_button: Default::default(), + save_prompt_was_open: false, + save_prompt_attention: false, #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, save_override: None, @@ -3146,6 +3153,8 @@ impl eframe::App for PdfCraftApp { storage.set_string("pdfcraft", self.persist()); } + /// Each frame's work before drawing: opens, operating-system and control-channel requests, + /// the quit guard, shortcuts, and the background jobs' results. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); #[cfg(not(target_arch = "wasm32"))] @@ -3312,6 +3321,8 @@ impl eframe::App for PdfCraftApp { self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] self.process_picked(); + // Last, after everything that can open or answer the "save changes?" question. + self.attend_save_prompt(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 54bfe2fa3..dc62fa2a7 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -254,6 +254,41 @@ fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() { assert!(!close_requested(&h)); } +/// Run one frame and return the attention requests it sent to the window. +fn step_attention(h: &mut Harness<'static, PdfCraftApp>) -> Vec { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }).collect() +} + +/// Send a request, run frames until it is answered and three more, and collect every attention +/// request those frames sent. +fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Vec { + let rx = c.send(method, params); + let mut sent = Vec::new(); + for _ in 0..30 { + sent.extend(step_attention(h)); + if rx.try_recv().is_ok() { + (0..3).for_each(|_| sent.extend(step_attention(h))); + return sent; + } + } + panic!("{method}: no reply after 30 frames"); +} + +/// An agent quits while another app is in front, then answers the prompt itself: the request is +/// withdrawn, so the taskbar or the window list stops flagging a window with nothing to ask. +#[test] +fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() { + use egui::UserAttentionType::{Critical, Reset}; + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"})); + h.input_mut().focused = false; + assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["close_prompt"], false); +} + #[test] fn control_quit_clean_document_requests_normal_window_close() { for params in [json!({}), Value::Null] { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 71ba9ccda..83492dbf1 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -791,6 +791,50 @@ fn quitting_with_unsaved_changes_asks_for_each_document() { assert!(h.state().close_request.is_none()); } +/// Run `frames` frames and collect every attention request they sent to the window. +fn attention_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> Vec { + let mut sent = Vec::new(); + for _ in 0..frames { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + sent.extend(commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None })); + } + sent +} + +/// Close the window as the operating system does: Quit in the Dock menu, or logging out. +fn close_window(h: &mut Harness<'static, PdfCraftApp>) { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); +} + +/// Quit from the Dock while another app is in front: the prompt opens in a window the user can't +/// see, so the Dock icon bounces (the taskbar button flashes) until they come back. A prompt that +/// opens in front asks for nothing. +#[test] +fn a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns() { + use egui::UserAttentionType::{Critical, Reset}; + let mut h = harness(2, |_| {}); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + h.input_mut().focused = false; + close_window(&mut h); + assert_eq!(attention_over(&mut h, 4), [Critical], "asked once, when the prompt opened"); + assert_eq!(h.state().close_request, Some(CloseRequest::Quit)); + h.input_mut().focused = true; + assert_eq!(attention_over(&mut h, 3), [Reset], "coming back withdraws the request"); + h.get_by_label_contains("Save changes to “doc.pdf”"); + h.get_by_label("Cancel").click(); + assert!(attention_over(&mut h, 3).is_empty()); + assert_eq!(h.state().views.len(), 1, "cancel keeps the document open"); + // With PdfCraft in front, the prompt is in plain sight. + close_window(&mut h); + assert!(attention_over(&mut h, 4).is_empty()); + h.get_by_label_contains("Save changes to “doc.pdf”"); + // Leaving with the prompt open doesn't ask either: the user has seen it. + h.input_mut().focused = false; + assert!(attention_over(&mut h, 3).is_empty()); +} + /// PDFs written to a fresh temp folder, removed when the test ends. struct TempPdfs(std::path::PathBuf); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda3982..928272171 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -586,8 +586,8 @@ milestone = "M4" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] -notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)." +evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens", "crates/ui-egui/tests/it/editing.rs::a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns", "crates/ui-egui/tests/it/control.rs::a_save_prompt_answered_in_the_background_withdraws_its_attention_request"] +notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. A prompt that opens while PdfCraft is in the background (Quit in the Dock menu, logging out, an agent's ui.quit) asks once for the user's attention, as a critical request: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint; coming back or answering withdraws it. Not yet: the same keys on the other confirmations (Revert, reload, overwrite); the other confirmations don't ask for attention; on macOS a prompt answered without coming back (over the control channel) leaves the Dock icon bouncing until PdfCraft is activated, because winit can't withdraw the request there." [[feature]] id = "core.dirty-indicator" From 95feaad2692e5f830ff09e739190333ef88624d9 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:04:12 -0400 Subject: [PATCH 2/3] M4: each document's save question asks for attention on its own Quitting with several unsaved documents moves from one question to the next without the prompt closing, so a question that opened in the background after one the user saw asked for nothing (review feedback on #4155). close_and_continue now marks the next document's question as new. A request that still stands carries over to it instead of being sent again. --- crates/ui-egui/src/editing.rs | 18 ++++++++++++------ crates/ui-egui/src/lib.rs | 7 ++++--- crates/ui-egui/tests/it/control.rs | 30 +++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 7b3c1c765..d9736c343 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -900,6 +900,8 @@ impl PdfCraftApp { /// Close tab `index` for a close request, then ask about the next dirty document, or quit. fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { self.close_tab(index); + // Its question is answered: a question about the next document is a new one. + self.save_prompt_seen = false; // A newer prompt wins: the user may have started another close while a save was pending. if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { match self.first_dirty() { @@ -969,18 +971,22 @@ impl PdfCraftApp { /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front - /// asks nothing, and neither does leaving it open: the user has seen it. + /// asks nothing, and neither does leaving it open: the user has seen it. Each document asked + /// about when quitting is a new question, so one that opens in the background asks even after + /// a question the user saw; a request that still stands carries over to it. pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { let open = self.close_request.is_some(); let focused = ctx.input(|i| i.focused); - if open && !self.save_prompt_was_open && !focused { + if self.save_prompt_attention { + if focused || !open { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + } else if open && !self.save_prompt_seen && !focused { ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); self.save_prompt_attention = true; - } else if self.save_prompt_attention && (focused || !open) { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); - self.save_prompt_attention = false; } - self.save_prompt_was_open = open; + self.save_prompt_seen = open; } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0db6e1fef..edfcb3b71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,8 +642,9 @@ pub struct PdfCraftApp { pub close_request: Option, /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. pub(crate) save_prompt_button: dialogs::PromptButton, - /// Whether the "save changes?" question was pending last frame, to tell the frame it opens. - save_prompt_was_open: bool, + /// `attend_save_prompt` has seen the pending "save changes?" question. It is false again once + /// the question is answered, so the next document's question is told apart from it. + save_prompt_seen: bool, /// The "save changes?" question asked for the user's attention and the request still stands /// (see `attend_save_prompt`). save_prompt_attention: bool, @@ -1023,7 +1024,7 @@ impl PdfCraftApp { host_dirty: None, close_request: None, save_prompt_button: Default::default(), - save_prompt_was_open: false, + save_prompt_seen: false, save_prompt_attention: false, #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index dc62fa2a7..8bd7b5dd4 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -276,17 +276,41 @@ fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, me panic!("{method}: no reply after 30 frames"); } -/// An agent quits while another app is in front, then answers the prompt itself: the request is -/// withdrawn, so the taskbar or the window list stops flagging a window with nothing to ask. +/// Open `name` with an unsaved change, in a tab of its own. +fn open_unsaved(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let app = h.state_mut(); + app.open_bytes(name, None, fixture(1)).unwrap(); + app.active = Some(app.views.len() - 1); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); +} + +/// An agent quits while another app is in front, then answers the prompts itself. Quitting asks +/// about each unsaved document in turn; the request made for the first question stands for the +/// next, and answering the last withdraws it, so the taskbar or the window list stops flagging a +/// window with nothing to ask. Each question is a new one: when the first opened in front, the +/// next one asks when it opens in the background. #[test] fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() { use egui::UserAttentionType::{Critical, Reset}; let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"})); + open_unsaved(&mut h, "second.pdf"); h.input_mut().focused = false; assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]); + assert!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})).is_empty(), "the request stands"); assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["close_prompt"], false); + + open_unsaved(&mut h, "third.pdf"); + h.input_mut().focused = true; + assert!(attention_during(&mut h, &c, "ui.quit", json!({})).is_empty(), "the first question opens in front"); + h.input_mut().focused = false; + assert!(attention_during(&mut h, &c, "ui.state", json!({})).is_empty(), "leaving it open asks nothing"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})), [Critical], "the next opens in the background"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + let state = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(state["close_prompt"], false); + let left: Vec<_> = state["documents"].as_array().unwrap().iter().map(|d| (d["name"].clone(), d["dirty"].clone())).collect(); + assert_eq!(left, [(json!("third.pdf"), json!(true))], "Don't save closed doc.pdf and second.pdf; Cancel kept third.pdf"); } #[test] From 04bc36a61076580a84ca200ae659730343190ed4 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:16:00 -0400 Subject: [PATCH 3/3] M4: a question the user saw stays seen when an earlier save finishes behind it --- crates/ui-egui/src/editing.rs | 53 ++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index d9736c343..ae6797d91 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -900,12 +900,15 @@ impl PdfCraftApp { /// Close tab `index` for a close request, then ask about the next dirty document, or quit. fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { self.close_tab(index); - // Its question is answered: a question about the next document is a new one. - self.save_prompt_seen = false; - // A newer prompt wins: the user may have started another close while a save was pending. + // A newer prompt wins: the user may have started another close while a save was pending, + // and seen its question, which keeps counting as seen. if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { match self.first_dirty() { - Some(_) => self.close_request = Some(req), + Some(_) => { + // A question about the next document is a new one. + self.save_prompt_seen = false; + self.close_request = Some(req); + } None if req == CloseRequest::Quit => self.quit(ctx), None => {} } @@ -1653,3 +1656,45 @@ mod content_page_tests { assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None); } } + +#[cfg(test)] +mod attention_tests { + use super::CloseRequest; + use crate::PdfCraftApp; + + /// The attention requests one frame of `attend_save_prompt` sends, with the window `focused` + /// or not. + fn attend(app: &mut PdfCraftApp, ctx: &egui::Context, focused: bool) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| app.attend_save_prompt(ui.ctx())); + // The frame's font texture isn't uploaded anywhere in a unit test. + out.textures_delta.clear(); + out.viewport_output + .values() + .flat_map(|v| v.commands.iter()) + .filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }) + .collect() + } + + /// A save that finishes after a newer "save changes?" question opened in front, and after the + /// user went to another app, asks for no attention: the user saw that question. + #[test] + fn a_save_finishing_behind_a_newer_question_the_user_saw_asks_for_nothing() { + let ctx = egui::Context::default(); + let mut app = PdfCraftApp::new(); + for name in ["a.pdf", "b.pdf", "c.pdf"] { + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + app.open_bytes(name, None, bytes.as_ref().clone()).unwrap(); + app.active = Some(app.views.len() - 1); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + } + // a.pdf was answered with Save while quitting. While that save is pending, the user closes + // c.pdf's tab, and its question opens in front. + let c = app.views[2].id; + app.close_request = Some(CloseRequest::Tab(c)); + assert!(attend(&mut app, &ctx, true).is_empty()); + // a.pdf's save finishes once the user has gone to another app. + app.close_and_continue(&ctx, 0, CloseRequest::Quit); + assert_eq!(app.close_request, Some(CloseRequest::Tab(c)), "the newer question stands"); + assert!(attend(&mut app, &ctx, false).is_empty(), "the user has seen it"); + } +}