diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index ff987716d1..d30a814d43 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -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 diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f9074af30f..53426d8757 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -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) { + 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)); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2b31091084..b7109b9c50 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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 { + 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 { match edit { Edit::MoveObjects { page, .. } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index f572d7feb2..1fd887bf5e 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -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 diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 2df634ef2f..55a77452fc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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"); +}