diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 969e45a55b..a190e118bb 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -645,7 +645,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } // Each paragraph keeps its own alignment through the typing. if t.text != before { - t.style.aligns = pdfcraft_engine::paragraph_align::remap(&before, &t.text, &t.style.aligns); + crate::edit_text_ui::follow_typing(ui.ctx(), input, &before, &t.text, &mut [&mut t.style.aligns]); } if t.focus { resp.request_focus(); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index dc8958f245..eff2f2631f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -285,10 +285,59 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcr state.clear_undoer(); egui::TextEdit::store_state(ctx, id, state); } - ctx.data_mut(|d| d.insert_temp(owner, doc)); + ctx.data_mut(|d| { + d.insert_temp(owner, doc); + d.remove::(id.with(ALIGN_HISTORY)); + }); *from = Some(text.to_owned()); } +/// Each text a page box has shown through its own typing, with its rows' alignments then, oldest +/// last: egui's history keeps only the text and the caret. +type AlignHistory = Vec<(String, Vec>>)>; +const ALIGN_HISTORY: &str = "aligns-by-text"; +/// As many texts as egui's own history keeps. +const ALIGN_HISTORY_LEN: usize = 100; + +/// The page text box `id` went from `before` to `after` through its own typing (typing, Undo or +/// Redo), and each list of row alignments in `aligns` follows. A text the box showed before gets +/// back the alignments it had then, so Undo bringing back a deleted row brings back its +/// alignment; a new text follows the edit ([`pdfcraft_engine::paragraph_align::remap`]). An +/// alignment picked since makes the older texts' alignments stale, and they are dropped. +pub(crate) fn follow_typing( + ctx: &egui::Context, + id: egui::Id, + before: &str, + after: &str, + aligns: &mut [&mut Vec>], +) { + let now: Vec> = aligns.iter().map(|a| a.to_vec()).collect(); + let next = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_default::(id.with(ALIGN_HISTORY)); + if seen.iter().any(|(text, was)| text == before && *was != now) { + seen.clear(); + } + remember(seen, before, now.clone()); + let next = match seen.iter().rev().find(|(text, _)| text == after) { + Some((_, was)) if was.len() == now.len() => was.clone(), + _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap(before, after, a)).collect(), + }; + remember(seen, after, next.clone()); + next + }); + for (slot, a) in aligns.iter_mut().zip(next) { + **slot = a; + } +} + +fn remember(seen: &mut AlignHistory, text: &str, aligns: Vec>>) { + seen.retain(|(t, _)| t != text); + seen.push((text.to_owned(), aligns)); + if seen.len() > ALIGN_HISTORY_LEN { + seen.remove(0); + } +} + /// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection /// only while it has the focus, so a click on Format text drops it; given back as formatting /// returns the focus, it stays shown, and a second choice reaches the same text. @@ -441,6 +490,12 @@ impl LineEditor { self.original = self.text.clone(); } + /// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows + /// aligned on their own keep their alignment, and a row Undo brings back gets its own back. + pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) { + follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); + } + /// The text changed from `before`: rows aligned on their own keep their alignment. pub(crate) fn follow_rows(&mut self, before: &str) { for look in [&mut self.look, &mut self.look0] { @@ -1780,7 +1835,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } let r = output.response; if r.changed() { - ed.follow_rows(&before); + ed.follow_typed_rows(ctx, &before); // The next pass measures the updated draft with the Apply/Save rewrite. ctx.request_repaint(); } @@ -1851,6 +1906,71 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + /// Undo back to a text the box showed brings its rows' alignments back, unless an alignment + /// was picked since: then the older alignments are stale, and the edit is followed instead, so + /// Undo never takes back the pick. + #[test] + fn typing_history_restores_alignments_unless_one_was_picked_since() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::new("box")); + let mut aligns = vec![None, Some(Center), Some(Right)]; + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Right)], "the deleted row's entry goes"); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Center), Some(Right)], "Undo brings it back"); + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + // An alignment picked for the first row, with no typing. + aligns = vec![Some(Right), Some(Right)]; + let picked = aligns.clone(); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!( + aligns, + pdfcraft_engine::paragraph_align::remap( + "A +C", "A +B +C", &picked + ), + "the pick stays" + ); + } + #[test] fn the_rotate_knob_moves_below_when_there_is_no_room_above() { let view = Rect::from_min_max(Pos2::new(0.0, 100.0), Pos2::new(500.0, 500.0)); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6dce0631da..fdb3c15273 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -233,12 +233,14 @@ impl PdfCraftApp { 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().filter(|ed| ed.has_unsaved_text()) { let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; - ed.text = text; + let before = std::mem::replace(&mut ed.text, text); + ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; - d.text = text; + let before = std::mem::replace(&mut d.text, text); + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } false @@ -262,13 +264,13 @@ impl PdfCraftApp { if let Some(ed) = view.line_editor.as_mut() { let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); - ed.follow_rows(&before); + ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut() { let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; - d.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); - d.text = text; + let before = std::mem::replace(&mut d.text, text); + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } false diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 327e3f45eb..27b413af53 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1089,6 +1089,47 @@ fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() { assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back"); } +/// Saved added text of three lines aligned Left, Centre and Right: the centred line deleted +/// with its line break, then brought back with Ctrl+Z, is centred again, and Ctrl+Shift+Z deletes +/// it again. The box's history keeps only text, so the restored line took the first line's +/// alignment, and finishing the box would have saved that. +#[test] +fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let mut h = harness(); + let rect = [40.0, 170.0, 240.0, 215.0]; + let aligns = vec![None, Some(Center), Some(Right)]; + let text = pdfcraft_engine::AddedText { rect, text: "A\nB\nC".into(), aligns: aligns.clone(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + // On the first line, at the top of the box. + let point = at(&h, 45.0, 205.0); + click(&mut h, point); + let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.clone().expect("the box is open"); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns.clone()), "the editor opened on the text"); + // Select the second line with its line break, and delete it. + h.run_steps(2); + // To the start of the first line, wherever the click put the caret. + for _ in 0..3 { + h.key_press(egui::Key::ArrowUp); + } + h.key_press(egui::Key::Home); + h.run_steps(1); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowDown); + h.key_press(egui::Key::Backspace); + h.run_steps(2); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "the centred line is gone"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns), "Undo brings it back centred"); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + h.run_steps(3); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again"); +} + /// 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 ceb4c36007..962953d3ef 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6574,6 +6574,36 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() { assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back"); } +/// A row aligned on its own, deleted by typing, then brought back with Undo from the menu: it +/// comes back with its alignment, and Redo deletes it again. The box's history keeps only text, +/// so the restored row took the alignment of the row before it. +#[test] +fn undo_brings_a_deleted_row_back_with_its_alignment() { + let mut h = three_row_paragraph(); + // Right-align the last row on its own. + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_pointer(&mut h, "Align right"); + let ed = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.clone().expect("the paragraph stays open"); + let (three_rows, aligned) = (ed(&h).text, ed(&h).look.aligns); + assert_eq!(aligned.last(), Some(&Some(pdfcraft_engine::TextAlign::Right)), "{aligned:?}"); + // Delete the last row with the line break before it. + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); + h.key_press(Key::Backspace); + h.run_steps(2); + let (two_rows, after_delete) = (ed(&h).text, ed(&h).look.aligns); + assert_eq!(pdfcraft_engine::paragraph_align::count(&two_rows), 2, "{two_rows:?}"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!((ed(&h).text, ed(&h).look.aligns), (three_rows, aligned), "Undo brings the row back right-aligned"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!((ed(&h).text, ed(&h).look.aligns), (two_rows, after_delete), "Redo deletes it again"); +} + /// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document, /// whose own Redo (a formatting change waiting here) must stay waiting. #[test] @@ -6771,12 +6801,14 @@ fn typing_in_one_tab_never_reaches_another_tabs_paragraph() { // The copy, its paragraph still open: Redo there has nothing of its own to put back. h.state_mut().active = Some(1); h.run_steps(3); + assert!(h.state().views[1].line_editor.is_some(), "the copy's paragraph is still open"); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(3); - let text = h.state().views[1].line_editor.as_ref().map(|ed| ed.text.clone()); - assert!(!text.as_deref().unwrap_or_default().contains("secret"), "the other tab's typing came in: {text:?}"); + let text = h.state().views[1].line_editor.as_ref().expect("the copy's paragraph stays open through Redo").text.clone(); + assert!(!text.contains("secret"), "the other tab's typing came in: {text:?}"); // Nor through what the menu's Redo runs, which reads the box's history itself (the menu item - // is off here, as the copy's document has nothing to redo). + // is off here, as the copy's document has nothing to redo). With no Redo of its own, the box + // passes the step to the document and closes, so the page's text is checked too. h.state_mut().redo(); h.run_steps(3); let s = h.state();