mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
Merge remote-tracking branch 'pr/4179' into lead-check-4179
This commit is contained in:
commit
43708cf5dd
6 files changed
+578
-7
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user