fix(ui): formatting keeps per-row alignments, the selection, and Redo

- A row aligned on its own lost its alignment when the paragraph was then
  given a colour, size or font: the rewrite left the rows' alignments out
  once they had been applied. They now go with every rewrite.
- egui drops a box's selection while the box is unfocused, so after one
  alignment or format a second choice went to the whole paragraph or box.
  The selection is given back when formatting returns the focus.
- Redo from the menu or toolbar applied the box's typing first, a new edit
  that dropped the document's Redo. It now redoes in the box, as the key does.
This commit is contained in:
RiceDee
2026-10-24 02:34:48 +11:00
parent ea6420cde5
commit 98a5dde398
5 changed files with 150 additions and 8 deletions
+3
View File
@@ -636,6 +636,9 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
}
if t.focus {
resp.request_focus();
if let Some(range) = &t.selection {
crate::edit_text_ui::reselect(ui.ctx(), input, &t.text, range);
}
t.focus = false;
}
let buttons = egui::Frame::NONE
+28 -5
View File
@@ -281,6 +281,19 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut
*from = Some(text.to_owned());
}
/// 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.
pub(crate) fn reselect(ctx: &egui::Context, id: egui::Id, text: &str, range: &std::ops::Range<usize>) {
let chars = |at: usize| text.get(..at).map(|s| s.chars().count());
let (Some(start), Some(end)) = (chars(range.start), chars(range.end)) else { return };
if start < end {
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(start), egui::text::CCursor::new(end))));
egui::TextEdit::store_state(ctx, id, state);
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Extras {
pub underline: bool,
@@ -359,9 +372,6 @@ impl LineEditor {
// The whole paragraph's alignment when it was picked, or when the rows' own were
// dropped for it (the rows are where those put them, so leaving it out keeps them there).
align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align),
// Rows aligned on their own, sent with every rewrite of the paragraph while it is open:
// nothing in the page records them, so a rewrite without them would undo them.
aligns: if l.aligns != o.aligns || self.text != self.original { l.aligns.clone() } else { Vec::new() },
underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline),
line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0)
.then_some(self.extras.line_spacing),
@@ -369,6 +379,12 @@ impl LineEditor {
scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale),
..Default::default()
};
// Rows aligned on their own, sent with every rewrite of the paragraph while it is open
// (a colour or size change too): nothing in the page records them, so a rewrite without
// them would undo them. Alone, they are sent only when they changed.
if l.aligns != o.aligns || self.text != self.original || style != pdfcraft_engine::BlockStyle::default() {
style.aligns = l.aligns.clone();
}
if let Some(range) = &self.selection
&& (style.family.is_some()
|| style.resource.is_some()
@@ -392,9 +408,9 @@ impl LineEditor {
style
}
/// After applying formatting: the paragraph now has it.
/// After applying formatting: the paragraph now has it. The selection stays, as the box still
/// shows it, so a second alignment or format picked for it applies to the same rows.
pub fn applied(&mut self) {
self.selection = None;
self.font_resource = None;
self.family_chosen = false;
self.look0 = self.look.clone();
@@ -409,6 +425,10 @@ impl LineEditor {
self.multiline = block.rows.len() > 1;
self.source = Some((block.clone(), lines));
let before = std::mem::replace(&mut self.text, block.editor_text.clone());
if self.text != before {
// A byte range of the old text: it may name other characters, or none, in the new.
self.selection = None;
}
self.follow_rows(&before);
self.original = self.text.clone();
}
@@ -1755,6 +1775,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
}
if ed.focus {
r.request_focus();
if let Some(range) = &ed.selection {
reselect(ui.ctx(), input, &ed.text, range);
}
ed.focus = false;
}
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
+35 -1
View File
@@ -222,11 +222,33 @@ 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() };
let before = std::mem::replace(&mut ed.text, text);
ed.follow_rows(&before);
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.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
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) {
@@ -1041,6 +1063,18 @@ impl PdfCraftApp {
/// The single page a content edit changes (`None` for other edits). Like comment edits,
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
/// What the page text box `id`, showing `text`, gets back from its own Redo history, if anything;
/// its caret goes where the Redo puts it.
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)
}
fn content_page(edit: &Edit) -> Option<usize> {
match edit {
Edit::MoveObjects { page, .. }
+10 -2
View File
@@ -939,7 +939,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
}
/// Bug A for added text: with one line of the box selected, an alignment applies to that line
/// only, the box stays open, and Undo puts it back and closes the box.
/// only, as does a second one, the box stays open, and Undo puts it back and closes the box.
#[test]
fn aligning_a_selected_line_of_added_text_aligns_only_that_line() {
let mut h = harness();
@@ -964,10 +964,18 @@ fn aligning_a_selected_line_of_added_text_aligns_only_that_line() {
};
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)]));
assert!(h.state().views[0].content.draft.is_some(), "the box stays open");
// A second choice, the line still selected, is for that line again.
let button = h.get_by_label("Alignment").rect().center();
click(&mut h, button);
let item = h.get_by_label("Centre").rect().center();
click(&mut h, item);
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Center)]));
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
h.run_steps(4);
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back");
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)]), "Undo puts it back");
assert!(h.state().views[0].content.draft.is_none(), "and closes the box");
assert!(h.state_mut().execute("edit.undo"));
assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]));
}
/// The same in an added text box: Redo after Undo puts the typing back in the box, and the
+74
View File
@@ -6159,3 +6159,77 @@ fn redo_after_undoing_typing_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");
}
/// A row aligned on its own keeps its alignment when the whole paragraph is then made bold: the
/// rewrite for the bold carries the rows' alignments too, as nothing in the page records them.
#[test]
fn a_row_aligned_on_its_own_keeps_it_through_more_formatting() {
let mut h = three_row_paragraph();
let wide = row_rect(&h, "three");
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");
// Nothing selected: the bold is for the whole paragraph.
h.key_press(Key::End);
h.run_steps(2);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None);
h.get_by_label("Bold").click();
h.run_steps(4);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].base_font, "Helvetica-Bold");
let last = row_rect(&h, "nine");
assert!(
(last[2] - row_rect(&h, "three")[2].max(wide[2])).abs() < 1.0 && last[0] > wide[0] + 20.0,
"the last row is still right-aligned: {last:?}"
);
}
/// A second alignment picked while the same row is still selected applies to that row again, not
/// the whole paragraph.
#[test]
fn a_second_alignment_for_a_selected_row_stays_on_that_row() {
let mut h = three_row_paragraph();
let first = row_rect(&h, "One");
let wide = row_rect(&h, "three");
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");
align_by_pointer(&mut h, "Centre");
assert!((row_rect(&h, "One")[0] - first[0]).abs() < 0.5, "the first row stays put: {:?}", row_rect(&h, "One"));
let last = row_rect(&h, "nine");
let (mid, centre) = ((last[0] + last[2]) / 2.0, (wide[0] + wide[2]) / 2.0);
assert!((mid - centre).abs() < 1.0, "the last row is centred: {last:?} in {wide:?}");
}
/// Redo from the menu (not the key) after Undo in the box took the typing back: the typing comes
/// back in the box, and the document's own Redo (a formatting change) still waits. The menu went
/// to the document, which applied the box first: a new edit, which dropped that Redo.
#[test]
fn redo_from_the_menu_puts_the_typing_back_in_the_box() {
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();
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!(h.state_mut().execute("edit.redo"));
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");
}