Browse / github.com/storytold/pdfcraft / patch
feat(ui-egui): offer a persistent current-page / total-page indicator (#4810)
Viewer: offer a persistent current-page / total-page indicator ## Request Provide an optional persistent viewer indicator such as `1 / 42`, preferably pinnable to the UI, for every PDF opened. ## Scope This is a display-only preference. It must not write headers or footers into the document. The existing Header & Footer page-number tokens are per-document content, and the existing page-number-box overlap report (#4082) is a separate layout issue.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- YusufMntm (imported from pull request #4928, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +131 −3 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
39a96db867419708cb738dc3
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/app.rs crates/ui-egui/src/app/control.rs crates/ui-egui/src/app/prefs.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/bars.rs crates/ui-egui/src/control/host.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/workflows/js_ui.rs crates/ui-egui/tests/it/viewer.rs parity/acrobat-features.toml
@@ -93,6 +93,7 @@ impl PdfCraftApp { reload_changed_files: true, show_secured_notice: true, always_default_view: false,+ show_page_indicator: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), #[cfg(not(target_arch = "wasm32"))]@@ -328,6 +328,14 @@ impl PdfCraftApp { _ => return Err("secured-notice must be true or false".into()), }; }+ // `--page-indicator true` / `--page_indicator true`: show a persistent `1 / N` indicator pill over the document.+ ("page_indicator" | "page-indicator" | "show_page_indicator", _) => {+ self.show_page_indicator = match value {+ "true" | "on" => true,+ "false" | "off" => false,+ _ => return Err(format!("{key} must be true or false")),+ };+ } ("system-title-bar", _) => { self.system_title_bar = match value { "true" => true,@@ -389,4 +397,14 @@ impl PdfCraftApp { } Ok(()) }++ /// Read the current value of a named application option (`"page_indicator"`, `"language"`, …).+ pub fn get_option(&self, key: &str) -> Option<String> {+ match key {+ "page_indicator" | "page-indicator" | "show_page_indicator" => Some(self.show_page_indicator.to_string()),+ "secured-notice" | "show_secured_notice" => Some(self.show_secured_notice.to_string()),+ "language" => Some(self.language.clone()),+ _ => None,+ }+ } }@@ -144,6 +144,7 @@ impl PdfCraftApp { v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);+ v["show_page_indicator"] = serde_json::Value::Bool(self.show_page_indicator); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name));@@ -195,6 +196,9 @@ impl PdfCraftApp { if let Some(on) = v["show_secured_notice"].as_bool() { self.show_secured_notice = on; }+ if let Some(on) = v["show_page_indicator"].as_bool() {+ self.show_page_indicator = on;+ } self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) { self.set_theme_preference(preference);@@ -334,4 +338,14 @@ impl PdfCraftApp { self.apply_trusted_certificates(certs); } }++ /// Serialize the user's persistent settings as JSON (alias for [`Self::persist`]).+ pub fn save_settings(&self) -> String {+ self.persist()+ }++ /// Restore the user's persistent settings from JSON (alias for [`Self::restore`]).+ pub fn load_settings(&mut self, json: &str) {+ self.restore(json);+ } }@@ -43,7 +43,7 @@ use area::{ }; #[cfg(test)] use bars::in_size_hint_zone;-use bars::{Notice, notices, page_size_hint, quick_bar};+use bars::{Notice, notices, page_indicator, page_size_hint, quick_bar}; pub use cache::RasterMemory; pub(crate) use cache::{BASE_SIDE, TILE_THRESHOLD, texture_image, thumbnail_rows}; #[cfg(test)]@@ -862,6 +862,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); let show_secured_notice = app.show_secured_notice;+ let show_page_indicator = app.show_page_indicator; let view = &mut app.views[index]; // Opened without the owner password and something is restricted. The notice says so unless // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸@@ -2432,6 +2433,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } find_bar(view, info.pages.len(), avail, ui, &t); page_size_hint(view, info, units, avail, unobstructed, ui, &t);+ if show_page_indicator {+ page_indicator(view, info.pages.len(), avail, ui, &t);+ } if tool == QuickTool::EnhanceCamera { crate::camera_ui::bar(ui.ctx(), view, avail, &t, can_modify); }@@ -39,6 +39,32 @@ pub(super) fn page_size_hint( }); } +/// Persistent `current / total` page indicator pill at the bottom of the document area (#4810).+/// Display-only: never modifies the PDF or its content streams.+pub(super) fn page_indicator(view: &DocView, total: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) {+ if total == 0 {+ return;+ }+ let current = view.current.min(total.saturating_sub(1)).saturating_add(1);+ let text = format!("{current} / {total}");+ let fill = Color32::from_rgba_unmultiplied(t.card.r(), t.card.g(), t.card.b(), 220);+ egui::Area::new(egui::Id::unique("page-indicator"))+ .order(egui::Order::Foreground)+ .interactable(false)+ .pivot(egui::Align2::CENTER_BOTTOM)+ .fixed_pos(pos2(area.center().x, area.bottom() - 14.0))+ .show(ui.ctx(), |ui| {+ egui::Frame::NONE+ .fill(fill)+ .stroke(Stroke::new(1.0, t.border))+ .corner_radius(CornerRadius::same(99))+ .inner_margin(egui::Margin::symmetric(10, 4))+ .show(ui, |ui| {+ ui.add(egui::Label::new(egui::RichText::new(text).font(theme::medium(12.0)).color(t.text)).selectable(false));+ });+ });+}+ /// What the notice bar's buttons ask for. pub(super) enum Notice { /// Reload the file that changed on disk, dropping unsaved changes.@@ -55,6 +55,7 @@ impl Host for crate::PdfCraftApp { self.request_quit(ctx) } + /// Return the UI-state snapshot reported by `ui.state`. fn state(&self) -> Value { let active = self.active.and_then(|i| self.views.get(i)); let docs: Vec<Value> = self@@ -133,6 +134,7 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode,+ "show_page_indicator": self.show_page_indicator, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" },@@ -344,6 +344,8 @@ pub struct PdfCraftApp { /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool,+ /// Preferences: show a persistent `1 / N` page number indicator pill over the document (#4810).+ pub show_page_indicator: bool, /// Open documents' files, watched for changes on disk. #[cfg(not(target_arch = "wasm32"))] pub(crate) watch: file_watch::FileWatch,@@ -440,6 +440,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .color(t.text_muted), ); ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout")));+ ui.add(widgets::checkbox(&mut app.show_page_indicator, tl!("Always show page number indicator"))); ui.horizontal(|ui| { ui.label(tl!("Units")); for u in crate::units::Unit::ALL {@@ -2268,3 +2268,63 @@ fn the_accessible_window_has_the_window_title_as_its_name() { assert_eq!(name(&h).as_deref(), Some("Quarterly report — PdfCraft")); assert_eq!(name(&h), Some(h.state().window_title.clone())); }++/// #4810: an optional persistent `1 / N` page indicator pill can be enabled in Preferences or via+/// `set_option`, updates as the current page changes, survives settings persistence, and never+/// modifies the PDF bytes.+#[test]+fn persistent_page_indicator_shows_updates_and_leaves_document_bytes_untouched() {+ use egui::accesskit::Role;+ let initial_bytes = fixture(3);+ let expected_bytes = initial_bytes.clone();+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {+ let mut app = PdfCraftApp::new();+ app.set_option("language", "en").unwrap();+ app.open_bytes("three.pdf", None, initial_bytes.clone()).unwrap();+ app+ });+ crate::support::settle(&mut h);+ let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("page-indicator"));+ assert!(!h.state().show_page_indicator, "off by default");+ assert_eq!(h.state().get_option("page_indicator").as_deref(), Some("false"));+ assert!(!h.ctx.memory(|m| m.areas().visible_last_frame(&layer)));+ assert!(h.query_by_label("1 / 3").is_none());++ // Toggle on via Preferences -> Documents and view -> "Always show page number indicator".+ h.state_mut().set_option("dialog", "preferences").unwrap();+ h.run_steps(2);+ h.get_by_role_and_label(Role::CheckBox, "Always show page number indicator").scroll_to_me();+ h.run_steps(4);+ h.get_by_role_and_label(Role::CheckBox, "Always show page number indicator").click();+ h.run_steps(2);+ assert!(h.state().show_page_indicator);+ assert_eq!(h.state().get_option("page_indicator").as_deref(), Some("true"));+ h.state_mut().dialog = None;+ h.state_mut().set_option("panel", "none").unwrap();+ h.run_steps(3);++ // Draws "1 / 3" on page 1.+ assert!(h.ctx.memory(|m| m.areas().visible_last_frame(&layer)));+ h.get_by_label("1 / 3");++ // Changing to page 2 updates the indicator to "2 / 3".+ h.state_mut().set_option("page", "2").unwrap();+ h.run_steps(3);+ assert!(h.query_by_label("1 / 3").is_none());+ h.get_by_label("2 / 3");++ // Persists across settings save/load and supports set_option validation.+ let saved = h.state().save_settings();+ let mut restored = PdfCraftApp::new();+ restored.load_settings(&saved);+ assert!(restored.show_page_indicator);+ restored.set_option("page_indicator", "false").unwrap();+ assert!(!restored.show_page_indicator);+ assert_eq!(restored.set_option("page_indicator", "invalid"), Err("page_indicator must be true or false".into()));++ // Display-only: document is clean and bytes are untouched.+ let s = h.state();+ let doc = s.session.get(s.views[0].id).unwrap();+ assert!(!doc.dirty, "showing the page indicator must not dirty the document");+ assert_eq!(doc.bytes.as_slice(), expected_bytes.as_slice(), "document bytes must remain untouched");+}@@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = []-evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"]-notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"+evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::persistent_page_indicator_shows_updates_and_leaves_document_bytes_untouched"]+notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel; Preferences ▸ Documents and view (--page-indicator true) offers an optional persistent 1 / N page indicator pill over the document (#4810)" [[feature]] id = "view.goto-page-label"