collective

Browse / github.com/storytold/pdfcraft / patch

feat(ui): an optional page number indicator over the document (#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
Harrison-Booth (imported from pull request #4894, open)
Base
f94668f1c8c78c8cf024a2b29375336607a1f76d
Size
+52 −0 in 3 files
Review
pending
Shared
2026-10-28
Id
a46657b18b4d01517f697a09

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/js_ui.rs crates/ui-egui/src/lib.rs

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@@ -59,6 +59,27 @@ const MAX_CACHED_PAGES: usize = 24; /// `MAX_CACHED_PAGES` so raster tuning never silently retunes text memory. const MAX_CACHED_TEXT_PAGES: usize = 24; +/// The page indicator's text: `4 / 42`, or `iv (4 / 42)` when the page has a label that is not+/// its number (as Acrobat's page box does). Out of range (no pages) reads `0 / 0`.+fn page_indicator_text(current: usize, count: usize, label: &str) -> String {+    let number = if current < count { current + 1 } else { count };+    if label.is_empty() || label == number.to_string() || current >= count {+        format!("{number} / {count}")+    } else {+        format!("{label} ({number} / {count})")+    }+}++/// A small pill at the bottom centre of the document area; display only, it takes no input.+fn paint_page_indicator(ui: &egui::Ui, avail: Rect, text: &str) {+    let t = Tokens::get(ui.ctx());+    let painter = ui.painter().with_clip_rect(avail);+    let galley = painter.layout_no_wrap(text.to_owned(), theme::medium(12.0), t.text);+    let pill = Rect::from_center_size(pos2(avail.center().x, avail.bottom() - 22.0), galley.size() + vec2(20.0, 10.0));+    painter.rect(pill, CornerRadius::same(255), t.chrome.gamma_multiply(0.92), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);+    painter.galley(pill.center() - galley.size() / 2.0, galley, t.text);+}+ fn rgba_bytes(width: usize, height: usize) -> usize {     width.saturating_mul(height).saturating_mul(4) }@@ -4298,6 +4319,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {      view.viewport_screen = out.inner_rect;     view.auto_scroll.paint(ui, avail);+    if app.show_page_indicator {+        let label = info.pages.get(view.current).map_or("", |p| p.label.as_str());+        paint_page_indicator(ui, avail, &page_indicator_text(view.current, info.pages.len(), label));+    }     if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) {         copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy);     }@@ -7842,6 +7867,17 @@ trailer << /Root 1 0 R >>         assert_eq!((v.current, v.goto, v.goto_point), (1, Some((1, 0.0)), None));     } +    #[test]+    fn the_page_indicator_reads_page_over_pages_with_the_label_when_it_differs() {+        assert_eq!(page_indicator_text(3, 42, ""), "4 / 42");+        assert_eq!(page_indicator_text(3, 42, "4"), "4 / 42");+        assert_eq!(page_indicator_text(3, 42, "iv"), "iv (4 / 42)");+        // No pages, or a page past the end: never beyond the last.+        assert_eq!(page_indicator_text(0, 0, ""), "0 / 0");+        assert_eq!(page_indicator_text(9, 3, "x"), "3 / 3");+        assert_eq!(page_indicator_text(usize::MAX, 3, ""), "3 / 3");+    }+     #[test]     fn destinations_set_the_zoom_mode_and_the_point_to_show() {         let info = pdfcraft_render::DocInfo {diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs--- a/crates/ui-egui/src/js_ui.rs+++ b/crates/ui-egui/src/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!("Show the current page number over the document")));     ui.horizontal(|ui| {         ui.label(tl!("Units"));         for u in crate::units::Unit::ALL {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@@ -740,6 +740,9 @@ 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: a `page / pages` indicator floating over the document, whatever the+    /// side panels show. Display only; nothing is written to the document.+    pub show_page_indicator: bool,     /// Open documents' files, watched for changes on disk.     #[cfg(not(target_arch = "wasm32"))]     pub(crate) watch: file_watch::FileWatch,@@ -1160,6 +1163,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"))]@@ -2642,6 +2646,7 @@ impl PdfCraftApp {             "default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous),             "default_zoom": self.view_defaults.zoom_name(),             "always_default_view": self.always_default_view,+            "show_page_indicator": self.show_page_indicator,             "highlight_fields": self.view_defaults.highlight_fields,             "units": self.units,             "language": self.language,@@ -2768,6 +2773,9 @@ impl PdfCraftApp {         if let Some(on) = v["always_default_view"].as_bool() {             self.always_default_view = on;         }+        if let Some(on) = v["show_page_indicator"].as_bool() {+            self.show_page_indicator = on;+        }         if let Some(paper) = v             .get("print_paper")             .and_then(serde_json::Value::as_str)@@ -3071,6 +3079,13 @@ impl PdfCraftApp {                     _ => return Err("always-default-view must be on or off".into()),                 };             }+            ("page-indicator", _) => {+                self.show_page_indicator = match value {+                    "on" => true,+                    "off" => false,+                    _ => return Err("page-indicator must be on or off".into()),+                };+            }             ("tools-side", _) => {                 self.side_panels.tools_side = match value {                     "left" => side_panels::ToolsSide::Left,