From 632ba10ed5c8b41d9f53a0385514aeadeb4b4b31 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:17:48 +1100 Subject: [PATCH] fix(ui): Redo from the menu with typing in a page box stays in the box Redo from the menu or toolbar closed the box first, which applied its typing: a new edit that dropped the document's waiting Redo, and the Redo itself then failed. The step was gone. Redo now goes to the box as the key does: what Undo took back there comes back, and typing the page doesn't have yet stays as it is, with the document's Redo waiting. A new text box the document refused was put back without the request to select the item once it exists, which stayed behind. It is put back with the box. --- crates/ui-egui/src/editing.rs | 78 ++++++++++++++++++++++++++++-- crates/ui-egui/tests/it/content.rs | 33 +++++++++++++ crates/ui-egui/tests/it/editing.rs | 40 +++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 39e0fd1adf..99cd53eff4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -222,11 +222,31 @@ impl PdfCraftApp { pub fn redo(&mut self) { if self.combine_showing() { self.combine_history_step(false); - } else { + } else if !self.redo_in_page_box() { self.history_step(false); } } + /// Redo from the menu or toolbar while a box on the page has typing of its own: the step is + /// the box's, as the key's is. What Undo took back in the box comes back; typing the page + /// doesn't have yet stays as it is, since applying it first is a new edit, which drops the + /// document's Redo. True when the box took the step. + fn redo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + if let Some(ed) = view.line_editor.as_mut() { + let Some(text) = redo_typing(&ctx, crate::edit_text_ui::input_id(), &ed.text) else { return ed.has_unsaved_text() }; + ed.text = text; + return true; + } + if let Some(d) = view.content.draft.as_mut() { + let Some(text) = redo_typing(&ctx, crate::content_ui::input_id(), &d.text) else { return d.has_unapplied_typing() }; + d.text = text; + return true; + } + false + } + fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; if !self.close_text_editors(i, id) { @@ -274,12 +294,13 @@ impl PdfCraftApp { } let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); let Some(view) = self.views.get_mut(i) else { return true }; - let draft = view.content.draft.clone(); + // Finishing new text also asks for it to be selected once it exists: put that back too. + let (draft, select_added) = (view.content.draft.clone(), view.content.select_added); if let Some(edit) = view.content.finish_draft(&added) && !self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { - view.content.draft = draft; + (view.content.draft, view.content.select_added) = (draft, select_added); } return false; } @@ -1217,6 +1238,18 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } } +/// The box's own Redo, as the key does it: the typing Undo took back, put back in the box's text, +/// with the box's history and cursor moved along. None when the box has nothing to redo. +fn redo_typing(ctx: &egui::Context, id: egui::Id, text: &str) -> Option { + let mut state = egui::TextEdit::load_state(ctx, id)?; + let mut undoer = state.undoer(); + let (range, redone) = undoer.redo(&(state.cursor.char_range()?, text.to_owned()))?.clone(); + state.set_undoer(undoer); + state.cursor.set_char_range(Some(range)); + egui::TextEdit::store_state(ctx, id, state); + Some(redone) +} + #[cfg(test)] mod tests { use super::{SaveTarget, is_mail_temp_copy, write_atomically, write_atomically_syncing, write_atomically_with}; @@ -1493,6 +1526,45 @@ mod tests { out } + /// `one_page_pdf`, opening without a password, whose permissions refuse changes. + fn no_changes_pdf() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(one_page_pdf())).unwrap(); + let encryption = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }; + doc.set_encryption(&encryption).unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } + + /// Undo with a new text box open on a document that refuses changes: the box is put back as + /// it was. Finishing it had asked for the new item to be selected, and that request stayed + /// behind once the add was refused. + #[test] + fn a_refused_text_box_is_put_back_whole() { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, no_changes_pdf()).unwrap(); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed".into(), + style, + focus: false, + typing_from: None, + }); + app.undo(); + let content = &app.views[0].content; + assert_eq!(content.draft.as_ref().map(|d| d.text.as_str()), Some("Typed"), "the refused box stays open"); + assert_eq!(content.select_added, None, "nothing was added to select"); + } + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 41957e6f69..81d6e00049 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -892,6 +892,39 @@ fn redo_after_undoing_typing_in_added_text_puts_it_back() { assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits"); } +/// Redo from the menu with retyping the page doesn't have yet in an added text box: the typing +/// stays in the box and the document's Redo waits (closing the box first wrote the typing, which +/// dropped that Redo). After Undo in the box, Redo from the menu puts the typing back there. +#[test] +fn redo_from_the_menu_with_retyping_in_the_box_keeps_the_documents_redo() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + let bold = pdfcraft_engine::AddedText { bold: true, ..text }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); + assert!(h.state_mut().execute("edit.undo")); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "the typing stays in the box"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo from the menu puts it back"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e87697403f..dd63003653 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6068,6 +6068,46 @@ fn redo_after_undoing_typing_puts_the_typing_back() { assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); } +/// Redo from the menu or toolbar with typing in the box the page doesn't have yet. Closing the +/// box first applied the typing, a new edit that dropped the document's waiting Redo, and the +/// Redo itself then failed: the step was gone. The typing now stays in the box and the Redo +/// waits; once Undo in the box takes the typing back, Redo from the menu puts it back there. +#[test] +fn redo_from_the_menu_with_typing_in_the_box_keeps_the_documents_redo() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + // Undo the bold (closing the editor), so the document has it to redo. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + { + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("the box stays open"); + assert_eq!(ed.text, typed, "the typing stays in the box, unapplied"); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "Undo takes the typing back in the box"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.views[0].line_editor.as_ref().expect("still open").text, typed, "Redo from the menu puts the typing back"); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); +} + /// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text /// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it. /// Typing in one tab, taken back with Undo, then came back with Redo in the other tab.