diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index da95171a3b..f8eeccb18e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -449,11 +449,12 @@ pub struct DocView { /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. pub redact_drag: crate::redact_ui::AreaDrag, pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, - /// Apply redactions ran in this view: Save asks where and proposes the name with `_Redacted` - /// ([`pdfcraft_engine::redacted_file_name`]). A desktop save clears it, and the document then - /// has the name it was saved under. The web build keeps it, so every download carries the - /// `_Redacted` name. - pub redacted: bool, + /// How many Apply redactions steps are in effect in this view. While it is above 0, Save asks + /// where and proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]). + /// Undo of an Apply redactions step takes one off and Redo adds it back. Revert, Reload and a + /// desktop save set it to 0; after a desktop save the document has the name it was saved + /// under. A web download keeps it, so later downloads carry the `_Redacted` name. + pub redactions_applied: usize, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Enhance ▸ Camera image: the quadrilateral being placed. @@ -716,7 +717,7 @@ impl DocView { links: Default::default(), redact_drag: None, pending_redaction: None, - redacted: false, + redactions_applied: 0, crop_drag: None, camera: None, camera_done: None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f2870f73ca..f927e00fc6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1684,7 +1684,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { - view.redacted = true; + view.redactions_applied = view.redactions_applied.saturating_add(1); } if marks == 1 { app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4d09b5cf4b..4e1f123969 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -220,6 +220,10 @@ impl PdfCraftApp { self.views[i].document_changed(&doc.info); } self.views[i].comments.selected = None; + if label == (Edit::ApplyRedactions { pages: None }).label() { + let applied = &mut self.views[i].redactions_applied; + *applied = if undo { applied.saturating_sub(1) } else { applied.saturating_add(1) }; + } let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 55d884cd9a..e93d457a0f 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -54,6 +54,8 @@ impl PdfCraftApp { view.forms.committed = None; view.forms.unbaked.clear(); view.pending_edit = None; + // The redactions applied since the last save are discarded too. + view.redactions_applied = 0; } self.notify_tr("Reverted to the last saved version"); } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 6a4ff2ec5d..02093188e8 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -49,12 +49,12 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return false }; let (name, path) = (doc.name.clone(), doc.path.clone()); // After Apply redactions, Save asks where and proposes the name with `_Redacted`. - let redacted = self.views.get(index).is_some_and(|v| v.redacted); + let redacted = self.views.get(index).is_some_and(|v| v.redactions_applied > 0); let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name }; + let target = if redacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { let doc_path = path.clone(); - let target = if redacted { SaveTarget::As } else { target }; let destination = match (target, path, &self.save_override) { (_, _, Some(p)) => p.clone(), // An email attachment's temporary copy is no place to keep work: ask, as for a new @@ -100,8 +100,8 @@ impl PdfCraftApp { && let Some(view) = self.views.get_mut(index) { view.document_changed(&doc.info); - // `redacted` stays set: the document keeps its name here, so later - // downloads are named after the redacted copy too. + // `redactions_applied` stays set: the document keeps its name here, so + // later downloads are named after the redacted copy too. } // The host reports where the file went (and any failure) itself. if !to_host { @@ -204,7 +204,7 @@ impl PdfCraftApp { view.document_changed(&doc.info); // Saving over a version that changed on disk keeps this one: nothing to ask. view.disk_changed = false; - view.redacted = false; + view.redactions_applied = 0; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index a71f09f3cc..514259b918 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -103,7 +103,7 @@ fn saving_after_applying_redactions_asks_where() { h.get_by_label("Apply").click(); h.run_steps(4); assert_eq!(marks(&h), 0); - assert!(h.state().views[0].redacted); + assert_eq!(h.state().views[0].redactions_applied, 1); let copy = dir.join("contract_Redacted.pdf"); h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where first"); @@ -112,12 +112,97 @@ fn saving_after_applying_redactions_asks_where() { assert!(copy.exists(), "the redacted copy is written where the user chose"); let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract_Redacted.pdf"); - assert!(!s.views[0].redacted); + assert_eq!(s.views[0].redactions_applied, 0); h.state_mut().pick_override = None; assert!(h.state_mut().save_active(SaveTarget::InPlace), "the next Save writes the copy in place"); let _ = std::fs::remove_dir_all(&dir); } +/// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again. +#[test] +fn undoing_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + for _ in 0..2 { + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + } + assert_eq!(h.state().views[0].redactions_applied, 2); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1, "the first Apply redactions is still in effect"); + // Undoing the second mark leaves the first Apply redactions in effect. + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions undone, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Revert discards the applied redactions, so Save writes the opened file in place again. +#[test] +fn reverting_after_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-revert-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(h.state().views[0].redactions_applied, 1); + assert!(h.state_mut().execute("file.revert")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Revert)); + h.get_all_by_label("Revert").last().expect("the button").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "after Revert, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn redaction_codes_become_the_overlay_text() { let mut h = harness();