mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 15:49:36 +00:00
fix(ui): typing Undo brings back a deleted row's alignment
egui's typing history keeps only text and caret, so Undo bringing back
a deleted row or line (added text, or a paragraph) gave it the
alignment of the row before it, and finishing the box could save that.
Each page box now keeps the texts it has shown through its own typing,
with every row's alignment then (follow_typing): Undo or Redo back to
one of them restores those alignments, and a new text follows the edit
with remap. An alignment picked since makes the older ones stale; they
are dropped, so Undo never takes back a pick. The record is cleared
with the box's typing history and holds 100 texts, as egui's does. It
covers typing, the keys and the menu's Undo and Redo.
On this branch the two-tab test also runs the menu's Redo in the copy,
where the box, with no Redo of its own, passes the step on and closes;
that step no longer requires the editor to stay open.
Also carries #4179's stronger two-tab test (dd47c4f9): the copy's
paragraph must be open before the key Redo and after it.
This commit is contained in:
1 parent
b7f7b27f8f
commit
2f50e1fde7
5 files changed
+206
-11
No files matched your search
@@ -645,7 +645,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
}
|
||||
// Each paragraph keeps its own alignment through the typing.
|
||||
if t.text != before {
|
||||
t.style.aligns = pdfcraft_engine::paragraph_align::remap(&before, &t.text, &t.style.aligns);
|
||||
crate::edit_text_ui::follow_typing(ui.ctx(), input, &before, &t.text, &mut [&mut t.style.aligns]);
|
||||
}
|
||||
if t.focus {
|
||||
resp.request_focus();
|
||||
|
||||
@@ -285,10 +285,59 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcr
|
||||
state.clear_undoer();
|
||||
egui::TextEdit::store_state(ctx, id, state);
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(owner, doc));
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(owner, doc);
|
||||
d.remove::<AlignHistory>(id.with(ALIGN_HISTORY));
|
||||
});
|
||||
*from = Some(text.to_owned());
|
||||
}
|
||||
|
||||
/// Each text a page box has shown through its own typing, with its rows' alignments then, oldest
|
||||
/// last: egui's history keeps only the text and the caret.
|
||||
type AlignHistory = Vec<(String, Vec<Vec<Option<pdfcraft_engine::TextAlign>>>)>;
|
||||
const ALIGN_HISTORY: &str = "aligns-by-text";
|
||||
/// As many texts as egui's own history keeps.
|
||||
const ALIGN_HISTORY_LEN: usize = 100;
|
||||
|
||||
/// The page text box `id` went from `before` to `after` through its own typing (typing, Undo or
|
||||
/// Redo), and each list of row alignments in `aligns` follows. A text the box showed before gets
|
||||
/// back the alignments it had then, so Undo bringing back a deleted row brings back its
|
||||
/// alignment; a new text follows the edit ([`pdfcraft_engine::paragraph_align::remap`]). An
|
||||
/// alignment picked since makes the older texts' alignments stale, and they are dropped.
|
||||
pub(crate) fn follow_typing(
|
||||
ctx: &egui::Context,
|
||||
id: egui::Id,
|
||||
before: &str,
|
||||
after: &str,
|
||||
aligns: &mut [&mut Vec<Option<pdfcraft_engine::TextAlign>>],
|
||||
) {
|
||||
let now: Vec<Vec<_>> = aligns.iter().map(|a| a.to_vec()).collect();
|
||||
let next = ctx.data_mut(|d| {
|
||||
let seen = d.get_temp_mut_or_default::<AlignHistory>(id.with(ALIGN_HISTORY));
|
||||
if seen.iter().any(|(text, was)| text == before && *was != now) {
|
||||
seen.clear();
|
||||
}
|
||||
remember(seen, before, now.clone());
|
||||
let next = match seen.iter().rev().find(|(text, _)| text == after) {
|
||||
Some((_, was)) if was.len() == now.len() => was.clone(),
|
||||
_ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap(before, after, a)).collect(),
|
||||
};
|
||||
remember(seen, after, next.clone());
|
||||
next
|
||||
});
|
||||
for (slot, a) in aligns.iter_mut().zip(next) {
|
||||
**slot = a;
|
||||
}
|
||||
}
|
||||
|
||||
fn remember(seen: &mut AlignHistory, text: &str, aligns: Vec<Vec<Option<pdfcraft_engine::TextAlign>>>) {
|
||||
seen.retain(|(t, _)| t != text);
|
||||
seen.push((text.to_owned(), aligns));
|
||||
if seen.len() > ALIGN_HISTORY_LEN {
|
||||
seen.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection
|
||||
/// only while it has the focus, so a click on Format text drops it; given back as formatting
|
||||
/// returns the focus, it stays shown, and a second choice reaches the same text.
|
||||
@@ -441,6 +490,12 @@ impl LineEditor {
|
||||
self.original = self.text.clone();
|
||||
}
|
||||
|
||||
/// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows
|
||||
/// aligned on their own keep their alignment, and a row Undo brings back gets its own back.
|
||||
pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) {
|
||||
follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]);
|
||||
}
|
||||
|
||||
/// The text changed from `before`: rows aligned on their own keep their alignment.
|
||||
pub(crate) fn follow_rows(&mut self, before: &str) {
|
||||
for look in [&mut self.look, &mut self.look0] {
|
||||
@@ -1780,7 +1835,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
}
|
||||
let r = output.response;
|
||||
if r.changed() {
|
||||
ed.follow_rows(&before);
|
||||
ed.follow_typed_rows(ctx, &before);
|
||||
// The next pass measures the updated draft with the Apply/Save rewrite.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -1851,6 +1906,71 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn typing_history_restores_alignments_unless_one_was_picked_since() {
|
||||
use pdfcraft_engine::TextAlign::{Center, Right};
|
||||
let (ctx, id) = (egui::Context::default(), egui::Id::new("box"));
|
||||
let mut aligns = vec![None, Some(Center), Some(Right)];
|
||||
follow_typing(
|
||||
&ctx,
|
||||
id,
|
||||
"A
|
||||
B
|
||||
C",
|
||||
"A
|
||||
C",
|
||||
&mut [&mut aligns],
|
||||
);
|
||||
assert_eq!(aligns, [None, Some(Right)], "the deleted row's entry goes");
|
||||
follow_typing(
|
||||
&ctx,
|
||||
id,
|
||||
"A
|
||||
C",
|
||||
"A
|
||||
B
|
||||
C",
|
||||
&mut [&mut aligns],
|
||||
);
|
||||
assert_eq!(aligns, [None, Some(Center), Some(Right)], "Undo brings it back");
|
||||
follow_typing(
|
||||
&ctx,
|
||||
id,
|
||||
"A
|
||||
B
|
||||
C",
|
||||
"A
|
||||
C",
|
||||
&mut [&mut aligns],
|
||||
);
|
||||
// An alignment picked for the first row, with no typing.
|
||||
aligns = vec![Some(Right), Some(Right)];
|
||||
let picked = aligns.clone();
|
||||
follow_typing(
|
||||
&ctx,
|
||||
id,
|
||||
"A
|
||||
C",
|
||||
"A
|
||||
B
|
||||
C",
|
||||
&mut [&mut aligns],
|
||||
);
|
||||
assert_eq!(
|
||||
aligns,
|
||||
pdfcraft_engine::paragraph_align::remap(
|
||||
"A
|
||||
C", "A
|
||||
B
|
||||
C", &picked
|
||||
),
|
||||
"the pick stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rotate_knob_moves_below_when_there_is_no_room_above() {
|
||||
let view = Rect::from_min_max(Pos2::new(0.0, 100.0), Pos2::new(500.0, 500.0));
|
||||
|
||||
@@ -233,12 +233,14 @@ impl PdfCraftApp {
|
||||
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().filter(|ed| ed.has_unsaved_text()) {
|
||||
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false };
|
||||
ed.text = text;
|
||||
let before = std::mem::replace(&mut ed.text, text);
|
||||
ed.follow_typed_rows(&ctx, &before);
|
||||
return true;
|
||||
}
|
||||
if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) {
|
||||
let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false };
|
||||
d.text = text;
|
||||
let before = std::mem::replace(&mut d.text, text);
|
||||
crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
@@ -262,13 +264,13 @@ impl PdfCraftApp {
|
||||
if let Some(ed) = view.line_editor.as_mut() {
|
||||
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() };
|
||||
let before = std::mem::replace(&mut ed.text, text);
|
||||
ed.follow_rows(&before);
|
||||
ed.follow_typed_rows(&ctx, &before);
|
||||
return true;
|
||||
}
|
||||
if let Some(d) = view.content.draft.as_mut() {
|
||||
let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() };
|
||||
d.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
|
||||
d.text = text;
|
||||
let before = std::mem::replace(&mut d.text, text);
|
||||
crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
||||
@@ -1089,6 +1089,47 @@ fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() {
|
||||
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back");
|
||||
}
|
||||
|
||||
/// Saved added text of three lines aligned Left, Centre and Right: the centred line deleted
|
||||
/// with its line break, then brought back with Ctrl+Z, is centred again, and Ctrl+Shift+Z deletes
|
||||
/// it again. The box's history keeps only text, so the restored line took the first line's
|
||||
/// alignment, and finishing the box would have saved that.
|
||||
#[test]
|
||||
fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() {
|
||||
use pdfcraft_engine::TextAlign::{Center, Right};
|
||||
let mut h = harness();
|
||||
let rect = [40.0, 170.0, 240.0, 215.0];
|
||||
let aligns = vec![None, Some(Center), Some(Right)];
|
||||
let text = pdfcraft_engine::AddedText { rect, text: "A\nB\nC".into(), aligns: aligns.clone(), ..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);
|
||||
// On the first line, at the top of the box.
|
||||
let point = at(&h, 45.0, 205.0);
|
||||
click(&mut h, point);
|
||||
let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.clone().expect("the box is open");
|
||||
assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns.clone()), "the editor opened on the text");
|
||||
// Select the second line with its line break, and delete it.
|
||||
h.run_steps(2);
|
||||
// To the start of the first line, wherever the click put the caret.
|
||||
for _ in 0..3 {
|
||||
h.key_press(egui::Key::ArrowUp);
|
||||
}
|
||||
h.key_press(egui::Key::Home);
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowDown);
|
||||
h.key_press(egui::Key::Backspace);
|
||||
h.run_steps(2);
|
||||
assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "the centred line is gone");
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns), "Undo brings it back centred");
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -6574,6 +6574,36 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() {
|
||||
assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back");
|
||||
}
|
||||
|
||||
/// A row aligned on its own, deleted by typing, then brought back with Undo from the menu: it
|
||||
/// comes back with its alignment, and Redo deletes it again. The box's history keeps only text,
|
||||
/// so the restored row took the alignment of the row before it.
|
||||
#[test]
|
||||
fn undo_brings_a_deleted_row_back_with_its_alignment() {
|
||||
let mut h = three_row_paragraph();
|
||||
// Right-align the last row on its own.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
|
||||
h.run_steps(2);
|
||||
align_by_pointer(&mut h, "Align right");
|
||||
let ed = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.clone().expect("the paragraph stays open");
|
||||
let (three_rows, aligned) = (ed(&h).text, ed(&h).look.aligns);
|
||||
assert_eq!(aligned.last(), Some(&Some(pdfcraft_engine::TextAlign::Right)), "{aligned:?}");
|
||||
// Delete the last row with the line break before it.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::End);
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::Home);
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
|
||||
h.key_press(Key::Backspace);
|
||||
h.run_steps(2);
|
||||
let (two_rows, after_delete) = (ed(&h).text, ed(&h).look.aligns);
|
||||
assert_eq!(pdfcraft_engine::paragraph_align::count(&two_rows), 2, "{two_rows:?}");
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(3);
|
||||
assert_eq!((ed(&h).text, ed(&h).look.aligns), (three_rows, aligned), "Undo brings the row back right-aligned");
|
||||
assert!(h.state_mut().execute("edit.redo"));
|
||||
h.run_steps(3);
|
||||
assert_eq!((ed(&h).text, ed(&h).look.aligns), (two_rows, after_delete), "Redo deletes it again");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -6771,12 +6801,14 @@ fn typing_in_one_tab_never_reaches_another_tabs_paragraph() {
|
||||
// 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);
|
||||
assert!(h.state().views[1].line_editor.is_some(), "the copy's paragraph is still open");
|
||||
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:?}");
|
||||
let text = h.state().views[1].line_editor.as_ref().expect("the copy's paragraph stays open through Redo").text.clone();
|
||||
assert!(!text.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).
|
||||
// is off here, as the copy's document has nothing to redo). With no Redo of its own, the box
|
||||
// passes the step to the document and closes, so the page's text is checked too.
|
||||
h.state_mut().redo();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
|
||||
Reference in new issue
Block a user