diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 4b400ef0c7..c0e12d5d09 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -390,6 +390,7 @@ impl PdfCraftApp { K::Background }; self.marks_draft.replace = id.ends_with(".update"); + self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(kind)); } "edit.bates" => { @@ -400,6 +401,7 @@ impl PdfCraftApp { self.marks_draft.hf.text[5] = "<>".into(); } self.marks_draft.focused_box = 5; + self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)); } "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => { diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 8bf2e4dfe3..fbca7bb08e 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -7,7 +7,7 @@ use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, icons, widgets}; /// How far a watermark may be moved from the page centre, in points. const MAX_OFFSET: f64 = 20_000.0; @@ -90,6 +90,9 @@ pub struct MarksDraft { pub use_file: bool, pub file: Option<(String, std::sync::Arc>)>, pub file_page: usize, + /// The page (0-based) the preview was stepped to; `None` previews the current page. Reset + /// each time the dialog opens. + pub preview: Option, } impl Default for MarksDraft { @@ -107,6 +110,7 @@ impl Default for MarksDraft { use_file: false, file: None, file_page: 1, + preview: None, } } } @@ -172,6 +176,24 @@ fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { if pages.contains(¤t) { current } else { pages.first().copied().unwrap_or(current) } } +/// [`preview_page`] for the page stepped to (`stepped`), or the current page if none. A page +/// stepped to that the range leaves out is replaced by the page shown, so widening the range again +/// keeps that page rather than jumping back. +fn shown_preview(range: &PageRange, stepped: &mut Option, current: usize, count: usize) -> usize { + let shown = preview_page(range, stepped.unwrap_or(current), count); + if stepped.is_some() { + *stepped = Some(shown); + } + shown +} + +/// The page the preview steps to from `shown`: the range's next page after it (`forward`) or the +/// last one before it, or `None` at either end. +fn step_preview(range: &PageRange, shown: usize, count: usize, forward: bool) -> Option { + let pages = range.pages(count); + if forward { pages.into_iter().find(|&p| p > shown) } else { pages.into_iter().rev().find(|&p| p < shown) } +} + /// The dialog for `kind`; returns (apply, cancel). pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: MarkKind) -> (bool, bool) { let Some((i, id)) = app.active_ids() else { return (false, true) }; @@ -343,10 +365,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.label(egui::RichText::new(tl!("Page Range Options")).font(theme::semibold(12.5))); d.range.ui(ui, count); ui.add_space(8.0); - // The preview: the current page, or the first one the range selects if it leaves that out, - // drawn from its thumbnail at its displayed size (after /Rotate), the frame the engine lays - // the marks out in. - let target = preview_page(&d.range, current, count); + // The preview: the current page (or the page stepped to), or the first one the range selects + // if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the + // frame the engine lays the marks out in. + let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let picture = app.views.get_mut(i).and_then(|view| { view.need_thumbnail(target, true); @@ -425,14 +447,24 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M MarkKind::Background => {} }); ui.vertical(|ui| { - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("Preview: page {p} of {n}"), - &[("p", &(target + 1).to_string()), ("n", &count.to_string())], - )) - .small() - .color(t.text_faint), - ); + ui.horizontal(|ui| { + // Steps through the pages the range selects. + let (back, next) = (step_preview(&d.range, target, count, false), step_preview(&d.range, target, count, true)); + if ui.add_enabled_ui(back.is_some(), |ui| icons::button(ui, "chevron-left", 22.0, false, tl!("Previous page"))).inner.clicked() { + d.preview = back.or(d.preview); + } + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("Preview: page {p} of {n}"), + &[("p", &(target + 1).to_string()), ("n", &count.to_string())], + )) + .small() + .color(t.text_faint), + ); + if ui.add_enabled_ui(next.is_some(), |ui| icons::button(ui, "chevron-right", 22.0, false, tl!("Next page"))).inner.clicked() { + d.preview = next.or(d.preview); + } + }); let n = d.range.pages(count).len(); let applies = if n == 1 { tl!("Applies to 1 page").to_string() } else { crate::i18n::fmt(tl!("Applies to {n} pages"), &[("n", &n.to_string())]) }; @@ -597,6 +629,40 @@ mod tests { assert_eq!(preview_page(&all, 0, 0), 0, "no pages at all"); } + #[test] + fn the_preview_steps_through_the_pages_the_range_selects() { + let odd = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; + // Pages 1, 3 and 5 (0-based 0, 2, 4) of 5. + assert_eq!(step_preview(&odd, 0, 5, true), Some(2)); + assert_eq!(step_preview(&odd, 2, 5, true), Some(4)); + assert_eq!(step_preview(&odd, 4, 5, true), None, "the last page has no next"); + assert_eq!(step_preview(&odd, 4, 5, false), Some(2)); + assert_eq!(step_preview(&odd, 0, 5, false), None, "the first page has no previous"); + // From a page the range leaves out, to the nearest one it selects on that side. + assert_eq!(step_preview(&odd, 1, 5, true), Some(2)); + assert_eq!(step_preview(&odd, 1, 5, false), Some(0)); + assert_eq!(step_preview(&odd, 0, 0, true), None, "no pages at all"); + // A page stepped to is previewed while the range selects it, and the range's first page + // once a new range leaves it out. + assert_eq!(preview_page(&odd, 2, 5), 2); + let later = PageRange { all: false, from: 4, to: 5, subset: Subset::All }; + assert_eq!(preview_page(&later, 2, 5), 3); + // Narrowing the range past the page stepped to keeps the page shown instead, also once + // the range is widened again. + let mut stepped = Some(4); + let first_two = PageRange { all: false, from: 1, to: 2, subset: Subset::All }; + assert_eq!(shown_preview(&first_two, &mut stepped, 1, 5), 0); + assert_eq!(stepped, Some(0)); + assert_eq!(shown_preview(&PageRange::default(), &mut stepped, 1, 5), 0); + // A page stepped to inside the range stays; with none stepped to, the current page shows. + let mut stepped = Some(2); + assert_eq!(shown_preview(&odd, &mut stepped, 0, 5), 2); + assert_eq!(stepped, Some(2)); + let mut stepped = None; + assert_eq!(shown_preview(&odd, &mut stepped, 4, 5), 4); + assert_eq!(stepped, None); + } + #[test] fn a_background_tints_the_page_it_is_behind() { assert_eq!(behind([1.0, 0.0, 0.0], 1.0), Color32::from_rgb(255, 0, 0)); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 0ec7a99e1c..8861310115 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -265,6 +265,35 @@ fn the_preview_shows_the_page_the_range_selects_under_the_marks() { assert_eq!(tint, egui::Color32::from_rgb(255, 255, 217), "the default pale yellow, opaque"); } +/// The preview's page stepper, beside its "page N of M" label: the dialog's own button, not the +/// page navigation's of the same name. +fn preview_step(h: &mut Harness<'static, PdfCraftApp>, label: &str, now: &str) { + let row = h.get_by_label(now).rect().center().y; + h.query_all_by_label(label).find(|n| (n.rect().center().y - row).abs() < 15.0).unwrap_or_else(|| panic!("{label} beside {now:?}")).click(); + h.run_steps(2); +} + +#[test] +fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() { + let mut h = harness(); + h.state_mut().views[0].go_to_page(1); + h.run_steps(2); + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + h.get_by_label("Preview: page 2 of 3"); + preview_step(&mut h, "Next page", "Preview: page 2 of 3"); + h.get_by_label("Preview: page 3 of 3"); + preview_step(&mut h, "Previous page", "Preview: page 3 of 3"); + preview_step(&mut h, "Previous page", "Preview: page 2 of 3"); + h.get_by_label("Preview: page 1 of 3"); + // Opened again, the preview is back at the current page. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + h.get_by_label("Preview: page 2 of 3"); +} + /// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on /// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on /// the last page.