fix(ui): a text box keeps its typing history through a trip to another tab

The tabs share one text box, and its Undo history was cleared whenever
another document's box took it: opening a paragraph in tab B wiped tab
A's typing steps, so Edit ▸ Undo back in A found none and closed A's
paragraph instead of taking the typing back. The box being left now
keeps a copy of its history and caret aside, under its document, and
the same box gets it back on its return; a box new to the text box
still starts with none, so typing still never reaches another tab. The
copy is a deep one: a state's history is shared between its clones.

Carried from #4179 (abc378c0). On this branch the rows' alignments by
text go aside and come back with the history, so Undo after the trip
still restores a deleted row's alignment.
This commit is contained in:
RiceDee
2026-10-25 19:19:20 +11:00
parent c9abf15e7c
commit bfc6f8b1b9
3 changed files with 128 additions and 5 deletions
+49 -5
View File
@@ -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<String>, text: &str) {
let owner = id.with("typing-doc");
if from.as_deref() == Some(text) && ctx.data(|d| d.get_temp::<pdfcraft_engine::DocId>(owner)) == Some(doc) {
let last = ctx.data(|d| d.get_temp::<pdfcraft_engine::DocId>(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::<AlignHistory>(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::<AlignHistory>(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.
+36
View File
@@ -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.
+43
View File
@@ -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