diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b6381..7b3c1c7651 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 af9912f456..0db6e1fefa 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 54bfe2fa3b..dc62fa2a76 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 71ba9ccdac..83492dbf1f 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 45dda39822..9282721718 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"