diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index eff2f2631f..15cc0dfc00 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -276,18 +276,41 @@ pub(crate) fn input_id() -> egui::Id { /// as it was before a format change, into a box showing the page as it is now. `from` is the box's /// own record of that; which document `doc` last used the history is kept beside it, since a box /// in another tab on the same text would otherwise take that document's typing with its Redo. +/// When another document's box takes the text box, the box it leaves keeps its history (and +/// caret) aside, and gets it back on its return, so its typing can still be undone there. pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { let owner = id.with("typing-doc"); - if from.as_deref() == Some(text) && ctx.data(|d| d.get_temp::(owner)) == Some(doc) { + let last = ctx.data(|d| d.get_temp::(owner)); + if from.as_deref() == Some(text) && last == Some(doc) { return; } - if let Some(mut state) = egui::TextEdit::load_state(ctx, id) { - state.clear_undoer(); - egui::TextEdit::store_state(ctx, id, state); + let aside = |doc: pdfcraft_engine::DocId| id.with(("typing-aside", doc)); + let aligns = id.with(ALIGN_HISTORY); + if let Some(left) = last.filter(|l| *l != doc) + && let Some(state) = egui::TextEdit::load_state(ctx, id) + { + // A copy of its own: a state's history is shared between its clones, and the next box + // clears it in place. The rows' alignments by text go with it. + let mut kept = egui::text_edit::TextEditState::default(); + kept.cursor = state.cursor; + kept.set_undoer(state.undoer()); + ctx.data_mut(|d| { + let rows = d.get_temp::(aligns).unwrap_or_default(); + d.insert_temp(aside(left), (kept, rows)); + }); } + // Only the same box, still on the text it opened on, takes its history back. + let back = + ctx.data_mut(|d| d.remove_temp::<(egui::text_edit::TextEditState, AlignHistory)>(aside(doc))).filter(|_| from.as_deref() == Some(text)); + let (state, rows) = back.unwrap_or_else(|| { + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + state.clear_undoer(); + (state, AlignHistory::default()) + }); + egui::TextEdit::store_state(ctx, id, state); ctx.data_mut(|d| { d.insert_temp(owner, doc); - d.remove::(id.with(ALIGN_HISTORY)); + d.insert_temp(aligns, rows); }); *from = Some(text.to_owned()); } @@ -1906,6 +1929,27 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + /// The rows' alignments by text go aside with the box's history when another tab's box takes + /// the text box, and come back with it: Undo after the trip still restores a deleted row's + /// alignment, while the other tab's box starts with none. + #[test] + fn alignments_by_text_survive_a_trip_to_another_tab() { + use pdfcraft_engine::{DocId, TextAlign::Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::new("box")); + let (mut a_from, mut b_from) = (None, None); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + let mut aligns = vec![None, Some(Right)]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); + forget_typing_before(&ctx, id, DocId(2), &mut b_from, "A\nB"); + let mut other = vec![None, None]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut other]); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut other]); + assert_eq!(other, [None, None], "tab B's box gets back its own alignments, not tab A's"); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); + assert_eq!(aligns, [None, Some(Right)], "back in tab A, Undo brings the row back right-aligned"); + } + /// 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. diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 27b413af53..e7210a8ac9 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1130,6 +1130,42 @@ fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() { assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again"); } +/// Typing in tab A's added text box, the same text opened in tab B, then back to A: Edit ▸ Undo +/// takes the typing back in A's box, which stays open. +#[test] +fn undo_after_a_round_trip_through_another_tab_takes_back_added_text_typing() { + let mut h = harness(); + h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(4); + let text = pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 210.0], text: "Reviewed".into(), ..Default::default() }; + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); + assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + }; + for tab in [0, 1] { + h.state_mut().active = Some(tab); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + } + open_in(&mut h, 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + open_in(&mut h, 1); + h.state_mut().active = Some(0); + h.run_steps(3); + let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); + assert_eq!(draft(&h).as_deref(), Some("Reviewed twice"), "A's box is still open with the typing"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(draft(&h).as_deref(), Some("Reviewed"), "Undo takes the typing back in A's box"); +} + /// 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 962953d3ef..a92850a275 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6770,6 +6770,49 @@ fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() { assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back"); } +/// Typing in tab A's paragraph, a paragraph opened in tab B, then back to A: Edit ▸ Undo takes +/// the typing back in A's box. The tabs share one text box, and its history was cleared each time +/// another tab's box took it, so Undo found no step and closed A's paragraph instead +/// (skylartaylor's and FredApps's review of 08ec1db7). +#[test] +fn undo_after_a_round_trip_through_another_tab_takes_back_the_typing() { + let mut h = harness(1, |app| { + app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens"); + }); + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + if h.state().views[tab].line_editor.is_none() { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + } + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height())); + assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing"); + }; + open_in(&mut h, 0); + h.get_by_label("Bold").click(); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + open_in(&mut h, 0); + 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); + assert!(h.state().views[0].line_editor.as_ref().unwrap().text.ends_with(" more")); + open_in(&mut h, 1); + h.state_mut().active = Some(0); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some(), "A's paragraph is still open after the round trip"); + assert!(h.state_mut().execute("edit.undo"), "Undo is enabled"); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("the box stays open: Undo takes back the typing"); + assert_eq!(ed.text, original, "the typing is taken back in the box"); + 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 (the key or the menu) in the