fix(ui): typing in one tab's text box never reaches another tab's

The paragraph box, and the added-text box, each keep one Undo history under
one egui id, shared by every tab. A box cleared it when it opened on new
text, but a box in another tab on the same text (two copies of a document)
kept it: typing taken back with Undo in one tab came back with Redo in the
other, from the key or from the menu. The document that last used the
history is now kept beside it, and the history is cleared whenever another
document's box takes it over.

Tests: the paragraph and added-text cases, each with two copies of a
document open; the paragraph case tries the menu's Redo too.
This commit is contained in:
RiceDee
2026-10-24 03:33:26 +11:00
parent 98a5dde398
commit caf7bdd8e1
4 changed files with 96 additions and 8 deletions
+2 -1
View File
@@ -530,6 +530,7 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) {
/// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape
/// throws the draft away.
pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option<Edit>, bool) {
let doc_id = view.id;
let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) };
let Some(xf) = view.page_xform(page) else { return (None, false) };
let zoom = xf.rect.width() / xf.pw.max(1.0);
@@ -611,7 +612,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| aligned_galley(ui, text.as_str(), &style, &line_font, color, wrap);
let before = t.text.clone();
let input = input_id();
crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text);
crate::edit_text_ui::forget_typing_before(ui.ctx(), input, doc_id, &mut t.typing_from, &t.style.text);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let output = egui::TextEdit::multiline(&mut t.text)
+12 -7
View File
@@ -266,18 +266,22 @@ pub(crate) fn input_id() -> egui::Id {
}
/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it),
/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches
/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was
/// before a format change, into a box showing the page as it is now.
pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option<String>, text: &str) {
if from.as_deref() == Some(text) {
/// in every tab (they share the id). Once the page has `text` (the box opened on it, or formatting
/// applied it), that history is cleared: Undo in the box takes back only what was typed since, and
/// then reaches the document. Kept, it put back text from another paragraph, or the whole paragraph
/// 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.
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) {
return;
}
if let Some(mut state) = egui::TextEdit::load_state(ctx, id) {
state.clear_undoer();
egui::TextEdit::store_state(ctx, id, state);
}
ctx.data_mut(|d| d.insert_temp(owner, doc));
*from = Some(text.to_owned());
}
@@ -1624,6 +1628,7 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
let view_id = view.id;
// Clicks outside the document (the Format text panel) keep the paragraph open.
// Where the click that took the focus was: the pointer itself may be gone by then (a touch, a
// pen lifted away).
@@ -1743,7 +1748,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
};
let before = ed.text.clone();
let input = input_id();
forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original);
forget_typing_before(ui.ctx(), input, view_id, &mut ed.typing_from, &ed.original);
let output = egui::TextEdit::multiline(&mut ed.text)
.id(input)
.font(font.clone())
+37
View File
@@ -1033,3 +1033,40 @@ fn redo_with_an_unchanged_box_open_redoes_the_document() {
assert!(now.bold, "the waiting Redo was redone");
assert!(h.state().views[0].content.draft.is_none(), "the box closed with the step");
}
/// Two copies of a document, the same added text open in each: the boxes share one Undo history,
/// so typing taken back with Undo in one tab came back with Redo in the other.
#[test]
fn typing_in_one_tab_never_reaches_another_tabs_added_text() {
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, 1);
open_in(&mut h, 0);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End);
h.event(egui::Event::Text(" secret".into()));
h.run_steps(2);
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"), "taken back");
h.state_mut().active = Some(1);
h.run_steps(3);
h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z);
h.run_steps(3);
assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out");
}
+45
View File
@@ -6233,3 +6233,48 @@ fn redo_from_the_menu_puts_the_typing_back_in_the_box() {
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");
}
/// 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
/// other tab.
#[test]
fn typing_in_one_tab_never_reaches_another_tabs_paragraph() {
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);
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, 1);
let original = h.state().views[1].line_editor.as_ref().unwrap().text.clone();
open_in(&mut h, 0);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the same paragraph");
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
h.event(egui::Event::Text(" secret".into()));
h.run_steps(2);
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, "taken back");
// 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);
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:?}");
// 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).
h.state_mut().redo();
h.run_steps(3);
let s = h.state();
let text = s.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 from the menu: {text:?}");
assert_eq!(s.session.get(s.views[1].id).unwrap().text_blocks(0)[0].text, s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].text);
}