Merge remote-tracking branch 'pr/4179' into lead-check-4179

This commit is contained in:
aiongg committed 2026-10-24 18:42:33 +00:00
commit 43708cf5dd
6 files changed
+578 -7

No files matched your search

+13 -2
View File
@@ -84,7 +84,9 @@ impl PdfCraftApp {
if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) {
return self.combine_can_undo(spec.needs == commands::Needs::Undo);
}
commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id))
// Redo also takes back what Undo took in a box open on the page, with no document step.
(commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id))
|| spec.needs == commands::Needs::Redo && self.open_box_can_redo())
&& (spec.id != "edit.copy"
|| self
.active
@@ -735,7 +737,16 @@ impl PdfCraftApp {
if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) {
continue;
}
if typing && !spec.in_text {
// A box typed into on the page (a paragraph being edited, added text) keeps Undo and
// Redo only while it holds typing the page doesn't have, or Redo has typing to put
// back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a
// Redo the box keeps is never taken as Undo.
let page_box = matches!(spec.id, "edit.undo" | "edit.redo")
&& self.page_box_focused(ctx)
&& !self.page_typing_unapplied()
&& !(spec.id == "edit.redo" && self.page_box_can_redo(ctx))
&& !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift));
if typing && !spec.in_text && !page_box {
continue;
}
let Some(key) = Key::from_name(s.key) else { continue };
+25 -3
View File
@@ -66,8 +66,18 @@ pub struct TextDraft {
/// Display space.
pub rect: [f64; 4],
pub text: String,
/// The box's formatting; its `text` is what the page has (empty for new text).
pub style: AddedText,
pub focus: bool,
/// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]).
pub typing_from: Option<String>,
}
impl TextDraft {
/// Typing the page doesn't have yet: Undo takes it back in the box before touching the document.
pub(crate) fn has_unapplied_typing(&self) -> bool {
self.text != self.style.text
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -394,7 +404,7 @@ pub(crate) fn page_input(
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true });
cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -432,7 +442,7 @@ pub(crate) fn page_input(
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None });
cv.selected = Some((page, i));
consumed = true;
return consumed;
@@ -508,6 +518,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.zoom();
@@ -583,6 +594,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
let job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, wrap_width, halign);
ui.fonts_mut(|f| f.layout_job(job))
};
let input = input_id();
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 mut edit = egui::TextEdit::multiline(&mut t.text)
@@ -593,7 +606,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
.id_salt("added-text-edit");
.id(input);
if letter_spacing != 0.0 {
edit = edit.layouter(&mut spaced);
}
@@ -653,12 +666,21 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option<Edit> {
Some(i) => {
let (_, a) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)?;
let AddedContent::Text(old) = &a.content else { return None };
// Unchanged is no edit, even for text saved with trailing spaces the trim would drop.
if *old == (AddedText { text: d.text.clone(), rect: d.rect, ..d.style.clone() }) {
return None;
}
let updated = AddedText { text, rect: d.rect, ..d.style };
(*old != updated).then_some(Edit::UpdateContent { page: d.page, index: i, content: AddedContent::Text(updated) })
}
}
}
/// The added-text box's text field.
pub(crate) fn input_id() -> egui::Id {
egui::Id::new("added-text-input")
}
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
+34 -1
View File
@@ -256,6 +256,33 @@ pub struct LineEditor {
/// horizontal scale (%), and what they were.
pub extras: Extras,
extras0: Extras,
/// The text the box's own Undo history starts from; see [`forget_typing_before`].
typing_from: Option<String>,
}
/// The paragraph editor's text box.
pub(crate) fn input_id() -> egui::Id {
egui::Id::new("edit-text-line-input")
}
/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
/// 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());
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1199,6 +1226,7 @@ fn open_editor(
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
typing_from: None,
});
}
@@ -1553,6 +1581,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.
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
@@ -1668,8 +1697,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
let input = input_id();
forget_typing_before(ui.ctx(), input, view_id, &mut ed.typing_from, &ed.original);
let output = egui::TextEdit::multiline(&mut ed.text)
.id(egui::Id::new("edit-text-line-input"))
.id(input)
.font(font.clone())
.margin(egui::Margin::ZERO)
.layouter(&mut layouter)
@@ -1801,6 +1832,7 @@ mod tests {
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
typing_from: None,
};
(app, ed)
}
@@ -1868,6 +1900,7 @@ mod tests {
text: "Typed text".into(),
style: crate::content_ui::default_style(),
focus: true,
typing_from: None,
};
app.views[0].content.draft = Some(draft.clone());
+167 -1
View File
@@ -227,13 +227,36 @@ 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) {
return;
}
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
match result {
Ok(label) => {
@@ -252,6 +275,90 @@ impl PdfCraftApp {
}
}
/// Before Undo or Redo: a paragraph or added text box open on the page shows the document as
/// it is before the step, so it is applied as clicking away applies it, then closed. Left
/// open, it kept showing what the step took away, and wrote from it when it was next applied.
/// What was typed is then the step Undo takes back. False (nothing is undone, and the box
/// stays open) while it holds text the standard fonts can't draw, or text that couldn't be
/// applied: it is shown, never lost.
pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
let Some(view) = self.views.get_mut(i) else { return true };
if let Some(c) = view.content.blocked() {
view.content.hold_blocked();
self.notify(crate::content_ui::undrawable_message(c));
return false;
}
if let Some(ed) = view.line_editor.take()
&& let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone())
&& !self.apply_edit(edit)
{
if let Some(view) = self.views.get_mut(i) {
view.line_editor = Some(ed);
}
return false;
}
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 };
// 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, view.content.select_added) = (draft, select_added);
}
return false;
}
true
}
/// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added
/// text.
pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool {
let focused = ctx.memory(|m| m.focused());
focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
}
/// Whether the focused box on the page has typing its own Redo would put back: after Undo in
/// the box restores what the page has, Redo still belongs to the box, not the document. The
/// same test egui makes when it handles the key.
pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool {
let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
let focused = ctx.memory(|m| m.focused());
let (id, text) = if focused == Some(crate::edit_text_ui::input_id())
&& let Some(ed) = &view.line_editor
{
(crate::edit_text_ui::input_id(), &ed.text)
} else if focused == Some(crate::content_ui::input_id())
&& let Some(d) = &view.content.draft
{
(crate::content_ui::input_id(), &d.text)
} else {
return false;
};
has_typing_to_redo(ctx, id, text)
}
/// Whether the box open on the page has a Redo of its own, the step
/// [`Self::redo_in_page_box`] takes for Redo from the menu or toolbar: focus has moved to the
/// menu by then, so unlike [`Self::page_box_can_redo`] this doesn't ask for it.
pub(crate) fn open_box_can_redo(&self) -> bool {
let Some(ctx) = &self.ctx else { return false };
let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
if let Some(ed) = &view.line_editor {
return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text);
}
view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text))
}
/// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't
/// have yet: Undo and Redo keys take it back in the box first.
pub(crate) fn page_typing_unapplied(&self) -> bool {
self.active.and_then(|i| self.views.get(i)).is_some_and(|v| {
v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing())
})
}
/// Apply any edit a view queued this frame (organize toolbar, keys).
/// Returns false when a field refused the typing queued for it (its editor is open again).
pub(crate) fn process_pending_edits(&mut self) -> bool {
@@ -1239,6 +1346,25 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) ->
if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) }
}
/// Whether the page text box `id`, showing `text`, has typing its own Redo would put back: the
/// test egui makes when it handles the key.
fn has_typing_to_redo(ctx: &egui::Context, id: egui::Id, text: &str) -> bool {
egui::TextEdit::load_state(ctx, id)
.is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.to_owned()))))
}
/// 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<String> {
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, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with};
@@ -1542,6 +1668,45 @@ mod tests {
out
}
/// `one_page_pdf`, opening without a password, whose permissions refuse changes.
fn no_changes_pdf() -> Vec<u8> {
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_commits_the_open_text_box() {
let dir = staging_dir("open-text-box");
@@ -1558,6 +1723,7 @@ mod tests {
text: "Typed text".into(),
style,
focus: true,
typing_from: None,
});
assert!(app.save_active(SaveTarget::InPlace));
+151
View File
@@ -907,3 +907,154 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
let pixels = h.render().unwrap();
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
/// The same in an added text box: Redo after Undo puts the typing back in the box, and the
/// document's own Redo stays waiting.
#[test]
fn redo_after_undoing_typing_in_added_text_puts_it_back() {
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);
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"));
h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, 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 twice"), "Redo puts the typing back");
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
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 from the menu after Undo in an added text box, with nothing for the document to redo: the
/// menu item follows the box's own Redo too, and puts the typing back.
#[test]
fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() {
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 }));
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);
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_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo");
assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step");
h.run_steps(3);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back");
}
/// 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.
#[test]
fn redo_with_an_unchanged_box_open_redoes_the_document() {
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 }));
let pdfcraft_engine::AddedContent::Text(saved) = added(&h)[0].content.clone() else { panic!() };
assert_eq!(saved.text, "Reviewed ", "the trailing space is kept");
let bold = pdfcraft_engine::AddedText { bold: true, ..saved };
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::Modifiers::SHIFT, egui::Key::Z);
h.run_steps(4);
let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() };
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");
}
+188
View File
@@ -6466,6 +6466,194 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() {
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor");
}
/// The paragraph opened for typing, as a person opens it: a click on its text.
fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
click_at(h, at);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
}
/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and
/// the editor closes. The box's own Undo used to take the key, putting back the text it had
/// before the change into a box laid out for the page after it, and the document kept the change.
#[test]
fn undo_with_the_paragraph_open_undoes_the_formatting() {
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);
{
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Edit text"));
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold");
assert!(s.views[0].line_editor.is_some(), "still editing");
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), None, "the formatting is undone");
assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica");
assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed");
}
/// Undo while typing takes back the typing in the box first; the document is left alone.
#[test]
fn undo_while_typing_takes_back_the_typing_first() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
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);
assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original);
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("still typing");
assert_eq!(ed.text, original, "the typing is taken back");
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched");
}
/// 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]
fn redo_after_undoing_typing_puts_the_typing_back() {
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);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text"));
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();
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, "the typing is taken back");
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(3);
let s = h.state();
let ed = s.views[0].line_editor.as_ref().expect("still typing");
assert_eq!(ed.text, typed, "Redo puts the typing back");
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");
}
/// 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");
}
/// Redo from the menu after Undo in the box took the typing back, with nothing for the document
/// to redo: the menu item follows the box's own Redo too, and puts the typing back. It was
/// disabled, as it followed only the document's Redo, and only the key could do it.
#[test]
fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() {
let mut h = harness(1, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
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();
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, "the typing is taken back");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo");
assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step");
h.run_steps(3);
assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back");
}
/// 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.
#[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 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: {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);
}
/// A paragraph selected by its frame goes with one Delete (or Backspace), as in Acrobat, instead
/// of being emptied a typed character at a time, and undo brings it back.
#[test]