collective

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

diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -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"))]diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs--- a/crates/ui-egui/src/app/control.rs+++ b/crates/ui-egui/src/app/control.rs@@ -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,+        }+    } }diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs--- a/crates/ui-egui/src/app/prefs.rs+++ b/crates/ui-egui/src/app/prefs.rs@@ -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);+    } }diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -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);     }diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs--- a/crates/ui-egui/src/canvas/bars.rs+++ b/crates/ui-egui/src/canvas/bars.rs@@ -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.diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs--- a/crates/ui-egui/src/control/host.rs+++ b/crates/ui-egui/src/control/host.rs@@ -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" },diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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,diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs--- a/crates/ui-egui/src/workflows/js_ui.rs+++ b/crates/ui-egui/src/workflows/js_ui.rs@@ -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 {diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs--- a/crates/ui-egui/tests/it/viewer.rs+++ b/crates/ui-egui/tests/it/viewer.rs@@ -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");+}diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -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"