diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index c5e55f063..7c049e4ba 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "53b57cc89461ecc4d48350a4b2ef969c9ba138048a8e28d6a8ee45d549847e1f"
+sha256 = "d28d638d797ff63d9906744e748a03ad8b5bd8c272ffcf07cabf656f960129a3"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "1d4b5c40d08e0c3ee328ffee487ff4133627381cf865d21bec682c247e6628dc"
+sha256 = "25f73389561052bb84f85378de97d4afafb3def7f428710cf5aee51471e6a2c3"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "11045efab47ed4a962861f0e3e9568836a8e9ece9ea2e0a4ec58ccf78d2e677f"
+sha256 = "7a1b424ed4e7602b37f37f03da7c8c142bff9af3085256b934b61baa6ceef3c8"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cb4c53d7b938a5ec8eb3a001a6c385c60b84f9f1ac36240c490d164046757c2a"
+sha256 = "d465fc7623ea6cf9293e110b90d1dd5ae70e1d7b76b02e0ec9754906429b86f7"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "af4b798c568cb1bb44ef23700e2d69c1e91c7cbb81e6fb565079a797cf02c878"
+sha256 = "60a49a50f21d711deb636a8dd415c1963094d9b36dc1b36d48f906e3364e3c92"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "8fe572236493148b23270ef5766ad1c7d65a58b293757f34772d01a44a2df026"
+sha256 = "e6d9e79205d1fa924926963466c9e52ac2fff3590471dde2e968be1c9e702c3c"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "6af18efaa6fc8d7d9e813b4411380f1f47879c3e342d800540abec5cd6cb8f81"
+sha256 = "fad25d6b5cb08c0b059b9c0884b3950093cf189d60318e6f079fd211b956b480"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "2b7787232635373d44b16b93051763b01cf8fa9e6877c87c013ad42171fdc915"
+sha256 = "59f25c35ea03cd767c8b98a1f908183b2d51d48baad7b70b86c0a702e344ed26"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9f67da0f5b455cf99be298e8318721ad6c8d306223e74ad42faf6edd2f8a839b"
+sha256 = "35bbf17699c22fa0ebb2ed3dfe2624c3be732dd9d84b8e71ff4293882e0d507e"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "f7765338632fee507a907cbabc52f72d57d903839f8a4d48b34e2cdcbb28a3bc"
+sha256 = "5fcb2470dff0c90649c1f94126696a50162a2a45f30867177f5085e10ac98f57"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "62517afff28af384a42aa377b86582ecddfe8dc8453f06c481246e96184add59"
+sha256 = "723fe3f001b881e9e151547a3cf9992fa48e790a39a049f5755e28e837d87648"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "d46ff4a0505c6374008d55fc4112d4a41508c333411551dad8f6102243bbcccd"
+sha256 = "1c12b97b51d024c005af29af40199f18ced69caa3c75a0898a2c58124d7c9686"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "61f9efbc72c1e597cdb9a2892bb87e4d0b64c1a7736bc8f7d719e9f18c85da12"
+sha256 = "8bb211bb4f856105e70d8635b309ce3fc07a810459724a48cfa941efc772dd5f"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "90fac37b3f4c69d16841fec2bc30b66aa3cd0964a493ebdac4f71e40dc424164"
+sha256 = "a24fc9bd45c58419baa35907c1108034cdd5d9b7a595ce25d8b577db0365849b"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "18c15dd98f6efe9929d6b00360dc6d15a44bbe6bfde044688bc911011868efeb"
+sha256 = "a527037961086d9ae647a380016c23370bf1c9c7027f0d35d8336c95266413dc"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "07b222d5b3fdd155ba57cec323dc9ca290c6e01331bb32f7181f21728362d53b"
+sha256 = "c317429089c85fbc65df0d85005ff451647fd63b9c61de6f97db747e1ab71e4f"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "b15b5fdca526de6d39d4a22b0c4d54f13eb5df0691ef6c0ea5e4987122ea45be"
+sha256 = "bf29aa55f9ccc61af7b2ce4f0381826cff90d89f52947f95f7f7d0f22743f167"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "882c07204a95bef324f67f891e76dae0a1a499a420e07929490e6b7f62cf4487"
+sha256 = "998ff7c8ace4315a31c623cd416745a61efd4aad18e73253cae0e92f8147d897"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7afdd36b96ac5b9c4c1023f506b98af9c559fe85ea00eb7e9518eda9fe77850b"
+sha256 = "990451cc5201b29deac2a3e5d7aaa48e11b83db0c4c14accbddab00e875fed02"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786"
+sha256 = "c3f85494bc13d6a9a82429e074b79d28ef71f97eb4e40edc3dd4b8fa4bd223c6"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "2c352743eaeec21eb9e522c71c43f3f6df34b81f024a5c72c85332e2ecc2b338"
+sha256 = "84921433e8b1e18c9a70b70f4f069d5eb62a1e41379d481f9f57c7da6c8dc448"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "a212a56fd316e58349845c750e935b28ba5074b3fd096016c3ae92cbe502648f"
+sha256 = "521c6f1657fba8b6c90b8abe3f201d6106626b9a981203c3e9397bd5df646a76"
[[asset]]
diff --git a/Cargo.lock b/Cargo.lock
index a439c0b80..07a0e4793 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4134,6 +4134,7 @@ dependencies = [
"pdfcraft-redact",
"pdfcraft-render",
"pdfcraft-sign",
+ "pdfcraft-testkit",
"pdfcraft-xfa",
"pdfcraft-xfdf",
"png 0.18.1",
diff --git a/README.md b/README.md
index af49129ec..7d3069258 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
+In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index f19c158c4..338e5f838 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,8 +1,10 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
+
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -174,6 +176,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
+- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
+- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
+- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
+- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
@@ -272,6 +278,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
+- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -564,6 +571,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
@@ -603,3 +611,5 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
+
+- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 721b7da1b..9ec86f3db 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -7,7 +7,7 @@
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on
-//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|
+//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 775955fc1..6d3344046 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -96,6 +96,9 @@ const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
const MAX_BOOKMARK_PAGE: usize = 1000;
+/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
+/// most 14 400 across).
+const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a
/// match near the start returns without touching the rest of a long document (#971).
@@ -353,7 +356,13 @@ impl Automation {
let parent = a.opt_path("parent")?.unwrap_or_default();
let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize);
let title = a.str("title")?.to_string();
- self.apply(&a, Edit::AddBookmark { parent, index, title, page })?
+ let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?);
+ let [left, top] = match self.doc(&a)?.info.pages.get(page) {
+ Some(p) => p.xyz_for_view(left, top),
+ None => [None, None],
+ };
+ let (left, top) = (left.map(f64::from), top.map(f64::from));
+ self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })?
}
"bookmark_rename" => {
let (path, title) = (a.path("path")?, a.str("title")?.to_string());
@@ -2388,6 +2397,15 @@ impl Args<'_> {
let Some(v) = self.opt_ints(key)? else { return Ok(None) };
v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some)
}
+ /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error
+ /// when it is not a number or lies absurdly far off the page.
+ fn bookmark_coordinate(&self, key: &str) -> Result> {
+ match self.opt_num(key)? {
+ None => Ok(None),
+ Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)),
+ Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))),
+ }
+ }
fn strs(&self, key: &str) -> Result> {
let v = self.get(key).ok_or_else(|| Self::missing(key))?;
let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?;
@@ -2573,7 +2591,7 @@ fn info(d: &Document) -> Value {
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
})).collect::>(),
- "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(),
+ "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(),
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
"transitions": i.transitions.iter().map(|t| json!({
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 50efc37af..d7bb416bc 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -289,8 +289,21 @@ pub fn tools() -> Vec {
}),
&["doc"],
)),
- t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
- json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
+ t(
+ "bookmark_add",
+ "Add a bookmark",
+ "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. It goes to the top of the page, or to a place on it: `top` and `left` are points from the top-left of the displayed page (like the destination bookmark_list reports), each optional, and a coordinate left out keeps the reader's position that way. The zoom is kept. Undoable.",
+ )
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "title": { "type": "string", "minLength": 1 },
+ "page": { "type": "integer", "minimum": 1 },
+ "parent": path("Parent bookmark (omit for the top level)"),
+ "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
+ "top": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the top of the displayed page." },
+ "left": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the left of the displayed page." }
+ }),
&["doc", "title", "page"],
)),
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 12e525aa9..cf07add24 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -5952,12 +5952,13 @@ fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() {
let _ = std::fs::remove_dir_all(dir);
}
-/// doc_info describes a link's set-layer-visibility action by layer name.
+/// doc_info describes a link's set-layer-visibility action by layer name, and each layer's
+/// visibility now and as the file opens.
#[test]
fn doc_info_describes_set_layer_links() {
let dir = workdir("layer-links");
let objs = [
- "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>",
+ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [5 0 R] >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>",
"<< /Type /OCG /Name (Red) >>",
@@ -5988,6 +5989,7 @@ fn doc_info_describes_set_layer_links() {
"preserve_rb": false,
})
);
+ assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }]));
}
mod close_argument_tests {
@@ -7280,3 +7282,64 @@ fn text_edit_numbers_survive_edits_that_do_not_regroup() {
}
std::fs::remove_dir_all(dir).unwrap();
}
+
+/// Where the first bookmark on `path` goes, as `bookmark_list` reports it: points from the
+/// top-left of the displayed page.
+fn bookmark_destination(a: &mut Automation, doc: u64, title: &str) -> Value {
+ let list = ok(a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
+ let row = list.as_array().unwrap().iter().find(|r| r["title"] == title).unwrap_or_else(|| panic!("{title} in {list}"));
+ row["destination"].clone()
+}
+
+#[test]
+fn a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation() {
+ let dir = workdir("bookmark-position");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [3], "degrees": 90 }));
+ let near = |v: &Value, want: Option, what: &str| match want {
+ Some(w) => assert!((v.as_f64().unwrap_or_else(|| panic!("{what}: {v}")) - w).abs() < 0.05, "{what}: {v} vs {w}"),
+ None => assert!(v.is_null(), "{what}: {v}"),
+ };
+ // Page 1 upright, page 3 turned a quarter: a displayed top and left come back as given.
+ for (page, title) in [(1, "Upright"), (3, "Turned")] {
+ for (suffix, left, top) in [(" both", Some(72.5), Some(120.25)), (" top", None, Some(200.0)), (" left", Some(50.0), None)] {
+ let name = format!("{title}{suffix}");
+ let mut args = json!({ "doc": doc, "title": name, "page": page });
+ if let Some(l) = left {
+ args["left"] = json!(l);
+ }
+ if let Some(t) = top {
+ args["top"] = json!(t);
+ }
+ ok(&mut a, "bookmark_add", args);
+ let d = bookmark_destination(&mut a, doc, &name);
+ assert_eq!(d["fit"], "XYZ", "{name}");
+ near(&d["left"], left, &format!("{name} left"));
+ near(&d["top"], top, &format!("{name} top"));
+ assert!(d["zoom"].is_null(), "the zoom is kept: {name}");
+ }
+ }
+ // No position is the page top at the reader's zoom, as before.
+ ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Plain", "page": 2 }));
+ let plain = bookmark_destination(&mut a, doc, "Plain");
+ assert!(plain["left"].is_null() && plain["top"].is_null() && plain["zoom"].is_null(), "{plain}");
+
+ // A position that is not a usable number is refused and adds nothing.
+ let before = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
+ for bad in [json!("high"), json!(2_000_000), json!(-2_000_000.5), json!(true)] {
+ for key in ["top", "left"] {
+ let args = json!({ "doc": doc, "title": "Bad", "page": 1, key: bad });
+ assert!(matches!(a.call("bookmark_add", &args), Err(ToolError::InvalidArgs(_))), "{key} {bad}");
+ }
+ }
+ assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], before, "nothing was added");
+
+ // It survives a save and reopen.
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
+ let mut b = auto(&dir);
+ let re = ok(&mut b, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
+ let d = bookmark_destination(&mut b, re, "Turned both");
+ near(&d["left"], Some(72.5), "reopened left");
+ near(&d["top"], Some(120.25), "reopened top");
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index bd92d3dca..f5e35088b 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -51,3 +51,6 @@ unicode-segmentation = "1.13"
# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace
# entry is std-only; this target adds `clock` for `chrono::Local::now`.
chrono = { workspace = true, features = ["clock"] }
+
+[dev-dependencies]
+pdfcraft-testkit = { workspace = true }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 1bd9c9c3f..ed7dc9e98 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1347,11 +1347,16 @@ pub enum Edit {
field_conflicts: pdfcraft_organize::FieldConflicts,
},
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
+ /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and
+ /// either may be `None` to keep the reader's position that way (both `None` is the page top).
+ /// A coordinate that is not finite fails the edit.
AddBookmark {
parent: Vec,
index: usize,
title: String,
page: usize,
+ left: Option,
+ top: Option,
},
RenameBookmark {
path: Vec,
@@ -2389,8 +2394,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
cx.renamed_fields = outcome.renamed_fields;
}
- Edit::AddBookmark { parent, index, title, page } => {
- pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
+ Edit::AddBookmark { parent, index, title, page, left, top } => {
+ pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?;
}
Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?,
Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?,
@@ -4315,6 +4320,15 @@ impl Session {
let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?;
let mut doc = self.docs.remove(at);
doc.id = id;
+ // How the document is shown is the reader's, not the file's: reading colours and hidden
+ // comments stay as they were (#3443). `open` built the renderer with the defaults.
+ if let Some(old) = self.docs.get(old)
+ && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments)
+ {
+ doc.config.reading_colors = old.config.reading_colors;
+ doc.config.hide_comments = old.config.hide_comments;
+ doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
+ }
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
// one; there is nothing new to autosave.
doc.generation = generation + 1;
@@ -4866,6 +4880,22 @@ impl Session {
true
}
+ /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns
+ /// `true` if any layer changed; callers must drop cached rasters and text for the document,
+ /// as for [`Self::set_layer_visible`].
+ pub fn reset_layers(&mut self, id: DocId) -> bool {
+ let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
+ let mut changed = false;
+ for l in &mut doc.info.layers {
+ changed |= l.visible != l.initial;
+ l.visible = l.initial;
+ }
+ if changed {
+ use_layer_choices(doc);
+ }
+ changed
+ }
+
/// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form
/// fields and links still draw. Returns whether anything changed.
pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 808e8aaa8..90d536264 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -685,7 +685,7 @@ fn layer_choices_survive_edits() {
#[test]
fn set_layer_actions_toggle_in_order_and_keep_radio_groups() {
use pdfcraft_render::LayerOp::{Off, On, Toggle};
- let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible };
+ let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible, initial: visible };
let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| {
// L1 and L2 are a radio-button group; L3 is on its own.
let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)];
@@ -761,6 +761,59 @@ fn set_layer_actions_change_what_renders() {
assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true));
}
+#[test]
+fn reset_layers_returns_to_the_files_visibility() {
+ // Red (left half, on) and Green (right half, off) are a radio-button group.
+ let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC";
+ let pdf = pdfcraft_testkit::Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>")
+ .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
+ .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>")
+ .stream("", content)
+ .obj("<< /Type /OCG /Name (Red) >>")
+ .obj("<< /Type /OCG /Name (Green) >>")
+ .build();
+ // A page drawing on layer Blue, which that file has off.
+ let other = pdfcraft_testkit::Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >> >>")
+ .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
+ .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /B 5 0 R >> >> >>")
+ .stream("", "/OC /B BDC 0 0 1 rg 0 0 10 10 re f EMC")
+ .obj("<< /Type /OCG /Name (Blue) >>")
+ .build();
+ let mut s = Session::new();
+ let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
+ let shown = |s: &Session| s.get(id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect::>();
+ let left = |s: &Session| {
+ let doc = s.get(id).unwrap();
+ let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
+ let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ let i = (50 * p.width as usize + 25) * 4;
+ p.rgba[i..i + 3].to_vec()
+ };
+ assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
+ let edit = Edit::InsertPagesFrom {
+ name: "b.pdf".into(),
+ bytes: Arc::new(other),
+ pages: None,
+ at: 1,
+ field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
+ };
+ s.apply(id, edit).unwrap();
+ let blue = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Blue").unwrap();
+ assert!(s.set_layer_visible(id, blue, true));
+ assert_eq!(shown(&s), [("Red".into(), false), ("Green".into(), true), ("Blue".into(), true)]);
+ assert_eq!(left(&s), [255, 255, 255]);
+ // Every layer, the inserted one too, goes back to the file's default configuration.
+ assert!(s.reset_layers(id));
+ assert_eq!(shown(&s), [("Red".into(), true), ("Green".into(), false), ("Blue".into(), false)]);
+ assert_eq!(left(&s), [255, 0, 0]);
+ assert!(!s.reset_layers(id), "already as the file opens");
+ // What the eye sees is not an edit: Undo still undoes the insert.
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Insert pages from b.pdf"));
+ assert!(!s.reset_layers(DocId(999)));
+}
+
#[test]
fn edits_to_unknown_documents_are_rejected() {
let (mut s, _) = session_with(1);
@@ -1371,9 +1424,9 @@ fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec {
fn bookmark_edits_show_in_the_viewer_undo_and_save() {
let (mut s, id) = session_with(3);
let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline);
- s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0 }).unwrap();
- s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2 }).unwrap();
- s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1 }).unwrap();
+ s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0, left: None, top: None }).unwrap();
+ s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2, left: None, top: None }).unwrap();
+ s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1, left: None, top: None }).unwrap();
// The inspector (an independent parser) sees the same tree and destinations.
assert_eq!(titles(&s), ["Start→1", "End→3[Détail→2]"]);
s.apply(id, Edit::MoveBookmark { from: vec![1, 0], to_parent: vec![], index: 0 }).unwrap();
@@ -2750,6 +2803,47 @@ fn reload_reads_the_new_file_into_the_same_document() {
assert_eq!(s.docs().len(), 2);
}
+/// #3443: reloading the file (it changed on disk) keeps how the reader shows it. Reading colours
+/// reached the new renderer, not just the setting; hidden comments stay hidden. Each setting is
+/// checked alone, so neither one rides on the other being changed.
+#[test]
+fn reload_keeps_reading_colours_and_hidden_comments() {
+ let dark_share = |s: &Session, id: DocId| {
+ let doc = s.get(id).unwrap();
+ doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest {
+ page: 0,
+ kind: pdfcraft_render::RequestKind::Pixels,
+ scale: 1.0,
+ ..Default::default()
+ }]);
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
+ let page = loop {
+ if let Some(page) = doc.renderer.try_recv() {
+ break page;
+ }
+ assert!(std::time::Instant::now() < deadline, "page render did not finish");
+ std::thread::yield_now();
+ };
+ let (px, _) = page.rgba.as_chunks::<4>();
+ px.iter().filter(|p| p[0] < 64 && p[1] < 64 && p[2] < 64).count() as f64 / px.len() as f64
+ };
+ let (mut s, id) = session_with(1);
+ assert!(dark_share(&s, id) < 0.1, "a white page as the file draws it");
+ assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK));
+ assert!(dark_share(&s, id) > 0.9, "white on black");
+ s.reload(id, Arc::new(fixture(2))).unwrap();
+ assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK));
+ assert!(dark_share(&s, id) > 0.9, "the reloaded page is drawn white on black too");
+ assert!(!s.get(id).unwrap().comments_hidden());
+
+ let (mut s, id) = session_with(1);
+ assert!(s.set_hide_comments(id, true));
+ s.reload(id, Arc::new(fixture(2))).unwrap();
+ assert!(s.get(id).unwrap().comments_hidden(), "hidden comments stay hidden");
+ assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
+ assert!(dark_share(&s, id) < 0.1);
+}
+
#[test]
fn revert_goes_back_to_the_saved_version() {
let (mut s, id) = session_with(2);
@@ -2776,8 +2870,8 @@ fn split_by_size_and_bookmarks_and_page_filters() {
assert_eq!(parts.last().unwrap().1, 6, "every page is in a part");
assert_eq!(s.split_by_size(id, 1).unwrap().len(), 6, "pages larger than the limit stand alone");
// Top-level bookmarks name the parts.
- s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0 }).unwrap();
- s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3 }).unwrap();
+ s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0, left: None, top: None }).unwrap();
+ s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3, left: None, top: None }).unwrap();
assert_eq!(s.bookmark_splits(id), [(0, "Intro".to_string()), (3, "Results".to_string())]);
// Filters: page numbers 2, 4, 6 are even.
let info = &s.get(id).unwrap().info;
@@ -6254,3 +6348,37 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
assert!(s.get(id).unwrap().info.encrypted);
assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u"));
}
+
+#[test]
+fn a_bookmark_added_at_a_point_goes_there_through_undo_and_save() {
+ use pdfcraft_render::DestView;
+ let (mut s, id) = session_with(3);
+ let view = |s: &Session, i: usize| s.get(id).unwrap().info.outline.get(i).map(|o| (o.page, o.view));
+ let add = |s: &mut Session, title: &str, page: usize, left: Option, top: Option| {
+ s.apply(id, Edit::AddBookmark { parent: vec![], index: usize::MAX, title: title.into(), page, left, top })
+ };
+ add(&mut s, "Plain", 0, None, None).unwrap();
+ add(&mut s, "Section", 1, None, Some(500.5)).unwrap();
+ add(&mut s, "Table", 2, Some(72.0), Some(300.0)).unwrap();
+ // The page top (no position) is what it always was; the others carry their place.
+ assert_eq!(view(&s, 0), Some((Some(0), DestView::Xyz { left: None, top: None, zoom: None })));
+ assert_eq!(view(&s, 1), Some((Some(1), DestView::Xyz { left: None, top: Some(500.5), zoom: None })));
+ assert_eq!(view(&s, 2), Some((Some(2), DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None })));
+
+ // A position that is not a number fails the edit, which then changes nothing.
+ for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY))] {
+ assert!(matches!(add(&mut s, "Bad", 0, left, top), Err(EditError::Bookmark(_))), "{left:?} {top:?}");
+ }
+ assert_eq!(s.get(id).unwrap().info.outline.len(), 3);
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Add bookmark"));
+
+ s.undo(id).unwrap();
+ assert_eq!(s.get(id).unwrap().info.outline.len(), 2);
+ s.redo(id).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ let mut again = Session::new();
+ let id2 = again.open("again.pdf", None, saved, None).unwrap();
+ let outline = &again.get(id2).unwrap().info.outline;
+ assert_eq!(outline[2].view, DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None });
+ assert_eq!(outline[1].view, DestView::Xyz { left: None, top: Some(500.5), zoom: None });
+}
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
index 99c228c60..d4e032b46 100644
--- a/crates/organize/src/lib.rs
+++ b/crates/organize/src/lib.rs
@@ -33,8 +33,8 @@ pub use import::{
};
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use outline::{
- Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark,
- rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks,
+ Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark,
+ move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks,
};
pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition};
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs
index dca0b35ce..39e601d73 100644
--- a/crates/organize/src/outline.rs
+++ b/crates/organize/src/outline.rs
@@ -59,6 +59,8 @@ pub enum OutlineError {
IntoItself,
#[error("the title must not be empty")]
EmptyTitle,
+ #[error("a bookmark's position must be a finite number")]
+ BadPosition,
#[error("this document has no tagged headings to make bookmarks from")]
NoEntries,
#[error("{0}")]
@@ -472,24 +474,55 @@ fn recount(doc: &mut Document) -> Result<()> {
}
fn destination(doc: &Document, page: usize) -> Result {
+ destination_at(doc, page, None, None)
+}
+
+/// A destination on `page` (0-based) at the user-space point (`left`, `top`), either of which
+/// may be left out to keep the reader's position in that direction.
+fn destination_at(doc: &Document, page: usize, left: Option, top: Option) -> Result {
+ if ![left, top].iter().flatten().all(|v| v.is_finite()) {
+ return Err(OutlineError::BadPosition);
+ }
let pages = walk(doc)?;
let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?;
- Ok(go_to(*p))
+ Ok(go_to_at(*p, left, top))
}
/// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for a
/// new bookmark when the view has no specific position).
fn go_to(page: ObjRef) -> Object {
- Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, Object::Null])
+ go_to_at(page, None, None)
+}
+
+/// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the
+/// reader's zoom; a coordinate that is left out keeps the reader's position.
+fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object {
+ let coordinate = |v: Option| v.map_or(Object::Null, Object::Real);
+ Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null])
}
/// Add a bookmark titled `title` that goes to `page` (0-based), as child `index` of
/// `parent_path` (`[]` = top level; an index past the end appends). Returns its path.
pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, title: &str, page: usize) -> Result> {
+ add_bookmark_at(doc, parent_path, index, title, page, None, None)
+}
+
+/// [`add_bookmark`] going to the user-space point (`left`, `top`) of `page`, as `/XYZ left top null`
+/// (zoom kept). Either coordinate may be left out to keep the reader's position in that
+/// direction; one that is not finite is refused.
+pub fn add_bookmark_at(
+ doc: &mut Document,
+ parent_path: &[usize],
+ index: usize,
+ title: &str,
+ page: usize,
+ left: Option,
+ top: Option,
+) -> Result> {
if title.trim().is_empty() {
return Err(OutlineError::EmptyTitle);
}
- let dest = destination(doc, page)?;
+ let dest = destination_at(doc, page, left, top)?;
let parent = container(doc, parent_path)?;
let mut kids = children_of(doc, parent, &mut HashSet::from([parent]));
let mut d = Dict::new();
diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs
index 9bd3eb396..aed8eae85 100644
--- a/crates/organize/src/tests.rs
+++ b/crates/organize/src/tests.rs
@@ -2821,3 +2821,40 @@ fn rotate_repeated_indices_accumulate_from_the_current_page_value() {
}
}
}
+
+#[test]
+fn a_bookmark_can_go_to_a_point_on_its_page_and_survives_save() {
+ let mut d = doc_a();
+ // No position: as before, `/XYZ null null null`, the page top at the reader's zoom.
+ crate::add_bookmark(&mut d, &[], 9, "Top", 0).unwrap();
+ // Only a height (a bookmark made at the top of the window), a full point, and only a left.
+ assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Section", 1, None, Some(612.25)).unwrap(), vec![1]);
+ crate::add_bookmark_at(&mut d, &[], 9, "Table", 1, Some(72.5), Some(300.0)).unwrap();
+ crate::add_bookmark_at(&mut d, &[1], 9, "Inside", 2, Some(10.0), None).unwrap();
+ let view = |d: &Document, title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().view;
+ let xyz = |left, top| Some((b"XYZ".to_vec(), vec![left, top, None]));
+ let check = |d: &Document| {
+ assert_eq!(view(d, "Top"), xyz(None, None));
+ assert_eq!(view(d, "Section"), xyz(None, Some(612.25)));
+ assert_eq!(view(d, "Table"), xyz(Some(72.5), Some(300.0)));
+ assert_eq!(view(d, "Inside"), xyz(Some(10.0), None));
+ let page_of = |title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().page;
+ assert_eq!((page_of("Top"), page_of("Section"), page_of("Table"), page_of("Inside")), (Some(0), Some(1), Some(1), Some(2)));
+ };
+ check(&d);
+ check(&full_roundtrip(&d));
+}
+
+#[test]
+fn a_bookmark_position_that_is_not_a_number_is_refused_and_changes_nothing() {
+ let mut d = doc_a();
+ crate::add_bookmark(&mut d, &[], 9, "Keep", 0).unwrap();
+ for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY)), (Some(1.0), Some(f64::NEG_INFINITY))] {
+ assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Bad", 0, left, top), Err(crate::OutlineError::BadPosition), "{left:?} {top:?}");
+ }
+ assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]);
+ // The page is still checked, and so is the title.
+ assert!(crate::add_bookmark_at(&mut d, &[], 9, "Nowhere", 7, Some(1.0), Some(1.0)).is_err());
+ assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, " ", 0, Some(1.0), Some(1.0)), Err(crate::OutlineError::EmptyTitle));
+ assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]);
+}
diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs
index efca2ce4d..0f115d305 100644
--- a/crates/organize/src/view.rs
+++ b/crates/organize/src/view.rs
@@ -96,7 +96,7 @@ pub fn initial_view(doc: &Document) -> InitialView {
..InitialView::default()
};
if let Some(vp) = c.get(b"ViewerPreferences").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) {
- let flag = |k: &[u8]| matches!(vp.get(k), Some(Object::Bool(true)));
+ let flag = |k: &[u8]| matches!(vp.get(k).map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)));
v.fit_window = flag(b"FitWindow");
v.center_window = flag(b"CenterWindow");
v.display_title = flag(b"DisplayDocTitle");
@@ -248,3 +248,50 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ
})?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pdfcraft_cos::{SaveOptions, write_full};
+ use std::sync::Arc;
+
+ fn flags(view: InitialView) -> [bool; 6] {
+ [view.fit_window, view.center_window, view.display_title, view.hide_menubar, view.hide_toolbar, view.hide_window_ui]
+ }
+
+ #[test]
+ fn initial_view_resolves_indirect_boolean_preferences() {
+ let keys: [&[u8]; 6] = [b"FitWindow", b"CenterWindow", b"DisplayDocTitle", b"HideMenubar", b"HideToolbar", b"HideWindowUI"];
+ for enabled in [true, false] {
+ for indirect_dictionary in [false, true] {
+ let mut doc = Document::new_empty();
+ let boolean = doc.add(Object::Bool(enabled));
+ let mut preferences = Dict::new();
+ for key in keys {
+ preferences.set(key.to_vec(), Object::Ref(boolean));
+ }
+ let preferences = if indirect_dictionary { Object::Ref(doc.add(preferences)) } else { Object::Dict(preferences) };
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), preferences)).unwrap();
+ assert_eq!(flags(initial_view(&doc)), [enabled; 6]);
+ let saved = write_full(&doc, &SaveOptions::default()).unwrap();
+ let reopened = Document::open(Arc::new(saved)).unwrap();
+ assert_eq!(flags(initial_view(&reopened)), [enabled; 6]);
+ assert_eq!(displays_doc_title(&reopened), enabled);
+ }
+ }
+ }
+
+ #[test]
+ fn initial_view_malformed_boolean_preferences_keep_false_defaults() {
+ for value in [Object::Null, Object::Int(1), Object::name("true"), Object::Ref(pdfcraft_cos::ObjRef::new(999_999, 0))] {
+ let mut doc = Document::new_empty();
+ let value = doc.add(value);
+ let mut preferences = Dict::new();
+ preferences.set(b"FitWindow".to_vec(), Object::Ref(value));
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences))).unwrap();
+ assert_eq!(flags(initial_view(&doc)), [false; 6]);
+ }
+ }
+}
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 0979f3d38..a09c2aec9 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -219,6 +219,18 @@ impl PageInfo {
[a * self.width, b * self.height]
}
+ /// A point as displayed (points from the page's top-left, y down, before any view rotation)
+ /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left.
+ /// Only a coordinate that is given is pinned: a bookmark made at the top of the window
+ /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides
+ /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]).
+ /// A value that is not finite counts as not given.
+ pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] {
+ let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite()));
+ let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0));
+ if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] }
+ }
+
/// A destination's user-space point (each coordinate possibly unspecified) → the same point
/// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down),
/// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page
@@ -425,6 +437,8 @@ pub struct Layer {
pub id: (u32, u16),
pub name: String,
pub visible: bool,
+ /// Shown as the file opens: the default configuration (`/OCProperties /D`).
+ pub initial: bool,
}
#[derive(Clone, Debug)]
@@ -1428,7 +1442,7 @@ impl<'a> Inspector<'a> {
_ => !base_off,
};
let Some(id) = id else { continue };
- out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible });
+ out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible });
}
}
@@ -2358,6 +2372,24 @@ trailer << /Root 1 0 R /Info 4 0 R >>
assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]);
}
+ #[test]
+ fn initial_layer_visibility_is_the_default_configuration() {
+ // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue
+ // isn't listed, so it takes the base state.
+ let pdf = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
+5 0 obj << /Type /OCG /Name (Red) >> endobj
+6 0 obj << /Type /OCG /Name (Green) >> endobj
+7 0 obj << /Type /OCG /Name (Blue) >> endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+ let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens");
+ let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect();
+ assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]);
+ }
+
const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
@@ -2778,6 +2810,41 @@ trailer << /Root 1 0 R >>
assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f))));
}
+ #[test]
+ fn a_displayed_point_becomes_the_xyz_position_that_goes_back_to_it() {
+ // Every rotation, on a crop box away from the origin: pinning a displayed point and
+ // reading the destination back lands on the same fraction of the displayed page.
+ for rotation in [0u16, 90, 180, 270] {
+ let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) };
+ let p = PageInfo { width, height, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation };
+ let (x, y) = (0.25 * width, 0.75 * height);
+ let near = |got: [Option; 2], want: [Option; 2]| {
+ for (g, w) in got.iter().zip(want) {
+ match (g, w) {
+ (Some(g), Some(w)) => assert!((g - w).abs() < 1e-4, "{rotation}: {got:?} vs {want:?}"),
+ (None, None) => {}
+ _ => panic!("{rotation}: {got:?} vs {want:?}"),
+ }
+ }
+ };
+ let [l, t] = p.xyz_for_view(Some(x), Some(y));
+ assert!(l.is_some() && t.is_some(), "{rotation}: a point pins both");
+ near(p.dest_fraction(l, t, 0), [Some(0.25), Some(0.75)]);
+ // Only a height: the horizontal position is left alone, whichever user axis that is.
+ let [l, t] = p.xyz_for_view(None, Some(y));
+ assert_eq!((l.is_some(), t.is_some()), if rotation.is_multiple_of(180) { (false, true) } else { (true, false) }, "{rotation}");
+ near(p.dest_fraction(l, t, 0), [None, Some(0.75)]);
+ let [l, t] = p.xyz_for_view(Some(x), None);
+ near(p.dest_fraction(l, t, 0), [Some(0.25), None]);
+ assert_eq!(p.xyz_for_view(None, None), [None, None]);
+ assert_eq!(p.xyz_for_view(Some(f32::NAN), Some(f32::INFINITY)), [None, None], "not finite is not given");
+ }
+ // The top of an upright page is the crop box's top edge.
+ let p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
+ assert_eq!(p.xyz_for_view(None, Some(0.0)), [None, Some(450.0)]);
+ assert_eq!(p.xyz_for_view(None, Some(100.0)), [None, Some(350.0)]);
+ }
+
#[test]
fn structure_failure_preserves_renderer_pages() {
for crash in [true, false] {
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 7e85cb880..6a75a0b7b 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -81,6 +81,17 @@ pub enum Fit {
None,
}
+/// The page display as it was before full screen, put back on leaving it
+/// ([`DocView::enter_presentation`]).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SavedDisplay {
+ pub layout: PageLayout,
+ pub continuous: bool,
+ pub cover: bool,
+ pub fit: Fit,
+ pub zoom: f32,
+}
+
/// The base page display: one page across, or a two-page spread. Whether it scrolls continuously
/// is a separate flag (`continuous`), as in Acrobat, so every combination is reachable (#752).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -414,6 +425,9 @@ pub struct DocView {
pub flash: Option<(usize, [f32; 4], f64)>,
/// Compare files: differences shaded on this document's pages (page, user-space box, colour).
pub compare_marks: Vec<(usize, [f32; 4], Color32)>,
+ /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view
+ /// shows one page or spread at a time, fitted to the screen. `None` outside full screen.
+ presentation: Option,
pages: HashMap,
/// Pages the renderer could not draw, with the reason (never re-requested).
errors: HashMap,
@@ -456,6 +470,9 @@ pub struct DocView {
clicks: u32,
last_press_time: f64,
last_press_pos: Option,
+ /// Full screen: a click's page turn, when it falls due (input time) and which way. It waits
+ /// out the double-click time, as the click may be the first of a double-click selecting a word.
+ present_turn: Option<(f64, bool)>,
last_queue: Vec,
/// Finished renders wait in the pool instead of reaching the view (tests): what the view
/// shows before a raster arrives can be looked at however fast the renderer is.
@@ -569,6 +586,8 @@ pub struct DocView {
/// The current page the Pages panel last followed: it scrolls to the current page's
/// thumbnail only when the page changes.
pub(crate) panel_followed: Option,
+ /// Scale of the thumbnails in the Pages inspector, independent of document and grid zoom.
+ thumbnail_zoom: f32,
/// How large the page grid draws its pages (1.0 = the usual thumbnails).
grid_zoom: f32,
/// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid
@@ -607,6 +626,8 @@ pub struct DocView {
/// The page grid's zoom range and the step of its buttons and keys.
pub const GRID_ZOOM_RANGE: std::ops::RangeInclusive = 0.5..=3.0;
+/// Thumbnail size in the Pages inspector, relative to its usual size.
+pub const THUMBNAIL_ZOOM_RANGE: std::ops::RangeInclusive = 0.5..=1.5;
const GRID_ZOOM_STEP: f32 = 1.25;
/// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes
/// the size on every frame, and a slightly soft or slightly large image is fine meanwhile.
@@ -729,6 +750,7 @@ impl DocView {
single_edges: None,
flash: None,
compare_marks: Vec::new(),
+ presentation: None,
pages: HashMap::new(),
errors: HashMap::new(),
waiting_since: HashMap::new(),
@@ -752,6 +774,7 @@ impl DocView {
clicks: 0,
last_press_time: 0.0,
last_press_pos: None,
+ present_turn: None,
last_queue: Vec::new(),
hold_rasters: false,
viewport_w: 800.0,
@@ -790,6 +813,7 @@ impl DocView {
pages_panel: None,
panel_gap: None,
panel_followed: None,
+ thumbnail_zoom: 1.0,
grid_zoom: 1.0,
grid_zoom_request: None,
grid_anchor: None,
@@ -852,9 +876,9 @@ impl DocView {
}
/// The document was replaced by a newer version of its file (#431). The reader's place
- /// stays: zoom, layout, rotation, page, scroll, view history and the find bar, the snapshot
- /// marker and its pending close, and the page images on screen until their replacements
- /// are drawn, so nothing flashes. Everything that pointed into the old version (a selected
+ /// stays: zoom, layout, rotation, page, scroll, full screen, view history and the find bar,
+ /// the snapshot marker and its pending close, and the page images on screen until their
+ /// replacements are drawn, so nothing flashes. Everything that pointed into the old version (a selected
/// comment, a field being typed in, text being edited, a drag, page selection) starts
/// afresh, so no stale index reaches the new one.
pub fn document_replaced(&mut self, info: &DocInfo) {
@@ -867,6 +891,7 @@ impl DocView {
fresh.current = self.current;
fresh.organize = self.organize;
fresh.grid_zoom = self.grid_zoom;
+ fresh.thumbnail_zoom = self.thumbnail_zoom;
// Same document id, so the scroll offset egui keeps for it is still this document's.
fresh.shown = self.shown;
fresh.highlight_fields = self.highlight_fields;
@@ -874,6 +899,8 @@ impl DocView {
fresh.snapshot = self.snapshot.clone();
fresh.close_requested = self.close_requested;
fresh.cover = self.cover;
+ // Still presenting, with the display to go back to on leaving.
+ fresh.presentation = self.presentation.take();
fresh.back = std::mem::take(&mut self.back);
fresh.forward = std::mem::take(&mut self.forward);
fresh.find = self.find.take();
@@ -1044,6 +1071,53 @@ impl DocView {
(!quads.is_empty()).then_some((s.page, quads))
}
+ /// Where a bookmark made now should go: the page and the `/XYZ` position `[left, top]` in user
+ /// space. With text selected it is the corner of the selection that shows at its top-left
+ /// (its page, both coordinates); otherwise the top of the window (the page showing there, and
+ /// only the position along the window's vertical, so the reader's other scroll position and
+ /// zoom are kept). `None` before the first frame has laid pages out, when the bookmark just
+ /// goes to the current page.
+ pub(crate) fn bookmark_position(&self, info: &DocInfo) -> Option<(usize, [Option; 2])> {
+ if let Some(sel) = self.selection
+ && let Some(text) = self.texts.get(&sel.page)
+ && let Some(p) = info.pages.get(sel.page)
+ {
+ // The selection's bounds in view space, which View ▸ Rotate View leaves unturned: the
+ // corner at the screen's top-left is a different one for each quarter turn.
+ let [x0, y0, x1, y1] = sel.rects(text).iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |b, r| {
+ [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])]
+ });
+ let (x, y) = match self.rotation {
+ 90 => (x0, y1),
+ 180 => (x1, y1),
+ 270 => (x1, y0),
+ _ => (x0, y0),
+ };
+ if x.is_finite() && y.is_finite() {
+ return Some((sel.page, p.xyz_for_view(Some(x), Some(y))));
+ }
+ }
+ // The page showing at the window's top edge: one that crosses it (the first, in a spread
+ // of pages of different heights, whose shorter page can start below it), else the
+ // nearest one below.
+ let top = self.viewport_screen.top();
+ let showing = || self.screen_xforms.iter().filter(|(_, xf)| xf.rect.bottom() > top);
+ let (page, xf) = showing()
+ .filter(|(_, xf)| xf.rect.top() <= top)
+ .min_by_key(|(p, _)| *p)
+ .or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0))))?;
+ let p = info.pages.get(*page)?;
+ // Above the page (in the gap before it) is the page's own top. The window's vertical is
+ // the view's y, or its x when the view is turned a quarter.
+ let (x, y) = xf.screen_to_view(pos2(xf.rect.center().x, top.max(xf.rect.top())));
+ let at = if xf.rot.is_multiple_of(180) {
+ p.xyz_for_view(None, Some(y.clamp(0.0, p.height)))
+ } else {
+ p.xyz_for_view(Some(x.clamp(0.0, p.width)), None)
+ };
+ Some((*page, at))
+ }
+
/// A page's thumbnail texture, when rendered.
pub(crate) fn thumb_id(&self, page: usize) -> Option {
self.thumbs.get(&page).map(|t| t.tex.id())
@@ -1295,8 +1369,7 @@ impl DocView {
let last = self.page_count.saturating_sub(1);
let (target, first) = match self.layout {
PageLayout::TwoUp => {
- // Spreads are [0, 1], [2, 3], … or, with a cover page, [0], [1, 2], [3, 4], ….
- let first = if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 };
+ let first = self.spread_first(c);
let target = match (forward, self.cover && c == 0) {
(true, true) => 1,
(true, false) => first.saturating_add(2),
@@ -1392,6 +1465,37 @@ impl DocView {
self.reset_coarse_state();
}
+ /// Full screen, as Acrobat's Full Screen Mode (#379, #1459): one page at a time (or one
+ /// spread, when two-page view was on: #1353), fitted to the screen, from the current page.
+ /// The display it replaces is kept for [`Self::leave_presentation`]. A no-op while presenting.
+ pub fn enter_presentation(&mut self) {
+ if self.presentation.is_some() {
+ return;
+ }
+ self.presentation =
+ Some(SavedDisplay { layout: self.layout, continuous: self.continuous, cover: self.cover, fit: self.fit, zoom: self.zoom });
+ self.continuous = false;
+ self.fit = Fit::Page;
+ self.selection = None;
+ self.goto = Some((self.current, 0.0));
+ }
+
+ /// Leave full screen: the display from before it comes back, at the page reached meanwhile.
+ pub fn leave_presentation(&mut self) {
+ let Some(saved) = self.presentation.take() else { return };
+ self.layout = saved.layout;
+ self.continuous = saved.continuous;
+ self.cover = saved.cover;
+ self.fit = saved.fit;
+ self.zoom = saved.zoom;
+ self.goto = Some((self.current, 0.0));
+ }
+
+ /// Whether the view is in full screen's one-page-at-a-time display.
+ pub fn presenting(&self) -> bool {
+ self.presentation.is_some()
+ }
+
/// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is.
/// A no-op when the layout is already current, so re-selecting it never yanks a scrolled view
/// back to the top.
@@ -1515,6 +1619,23 @@ impl DocView {
self.grid_zoom
}
+ /// Scale of the thumbnails in the Pages inspector (1.0 is the usual size).
+ pub fn thumbnail_zoom(&self) -> f32 {
+ self.thumbnail_zoom
+ }
+
+ /// Resize the inspector thumbnails, keeping the current page in view after layout.
+ /// Out-of-range values are clamped; non-finite values are ignored.
+ pub fn set_thumbnail_zoom(&mut self, zoom: f32) {
+ if zoom.is_finite() {
+ let zoom = zoom.clamp(*THUMBNAIL_ZOOM_RANGE.start(), *THUMBNAIL_ZOOM_RANGE.end());
+ if zoom != self.thumbnail_zoom {
+ self.thumbnail_zoom = zoom;
+ self.panel_followed = None;
+ }
+ }
+ }
+
/// Zoom the page grid; out-of-range values are clamped and nonsense is ignored.
pub fn set_grid_zoom(&mut self, zoom: f32) {
if zoom.is_finite() {
@@ -1967,14 +2088,30 @@ impl DocView {
}
}
+ /// The first page of the two-page spread page `c` is in: spreads are [0, 1], [2, 3], … or,
+ /// with a cover page, [0], [1, 2], [3, 4], ….
+ fn spread_first(&self, c: usize) -> usize {
+ if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 }
+ }
+
fn fit_zoom(&mut self, info: &DocInfo) {
let largest = |side: fn((f32, f32)) -> f32| info.pages.iter().map(|p| side(self.display_size(p))).fold(1.0, f32::max);
let max_w = largest(|s| s.0);
- // A single page shown on its own fits that page. Every other display (scrolling, or a
- // two-page spread) fits its largest page, so the zoom holds still while pages of other
- // sizes scroll or page past.
+ // A single page shown on its own fits that page, and full screen in two-page view fits
+ // the spread it shows (review on #2070). Every other display (scrolling, or two-page
+ // view in the window) fits its largest page, so the zoom holds still while pages of
+ // other sizes scroll or page past.
let (w, h) = if self.layout == PageLayout::Single && !self.continuous {
info.pages.get(self.current).map_or_else(|| (max_w, largest(|s| s.1)), |p| self.display_size(p))
+ } else if self.presentation.is_some() && self.layout == PageLayout::TwoUp && !self.continuous {
+ let first = self.spread_first(self.current);
+ let last = if self.cover && first == 0 { 0 } else { first.saturating_add(1) };
+ let spread: Vec<(f32, f32)> = info.pages.iter().skip(first).take(last - first + 1).map(|p| self.display_size(p)).collect();
+ if spread.is_empty() {
+ (max_w, largest(|s| s.1))
+ } else {
+ (spread.iter().map(|s| s.0).fold(1.0, f32::max), spread.iter().map(|s| s.1).fold(1.0, f32::max))
+ }
} else {
(max_w, largest(|s| s.1))
};
@@ -2311,7 +2448,8 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
view.view_history(true);
}
let key = |k| ctx.input(|i| i.key_pressed(k));
- if key(Key::Escape) {
+ // An open menu takes Escape first and closes; the selection stays for the next one (#3535).
+ if key(Key::Escape) && !egui::Popup::is_any_open(ctx) {
if view.camera.is_some() || view.camera_done.is_some() {
view.pending_action = Some(ViewAction::CancelCamera);
} else if view.selection.is_some() {
@@ -2326,6 +2464,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
if key(Key::End) {
view.go_to_page(usize::MAX);
}
+ // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the
+ // arrows and Page Up / Page Down already turn pages where nothing scrolls, below).
+ if view.presentation.is_some() {
+ if key(Key::Space) || key(Key::Enter) {
+ view.step_page(true);
+ }
+ if key(Key::Backspace) {
+ view.step_page(false);
+ }
+ }
// As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view.
let command = ctx.input(|i| i.modifiers.command);
@@ -2501,6 +2649,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Exclude the previous frame's scrollbar tracks from gesture activation.
let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail };
let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused);
+ // Full screen: a click on the document goes forward a page and a shift-click back, as in
+ // Acrobat, unless the click did something else (a link, a word selected). Decided below,
+ // once the pages have had the click.
+ let present_click = view.presentation.is_some()
+ && navigation_enabled
+ && ui.rect_contains_pointer(navigation_rect)
+ && ui.input(|i| i.pointer.primary_clicked())
+ && app.quick_tool == QuickTool::Select;
+ // Shift as held now, or as the click's own event carried it (a synthetic click).
+ let present_back = present_click
+ && ui.input(|i| {
+ i.modifiers.shift
+ || i.events.iter().any(|e| {
+ matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift)
+ })
+ });
if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) {
let z = view.zoom * zoom_delta;
match ui.input(|i| i.pointer.hover_pos()) {
@@ -3336,7 +3500,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt);
// A link or a form field under the pointer keeps its own cursor: a check box
// drawn with a glyph, or set on printed text, shows the hand, not the I-beam.
- let cursor_free = !over_link && !over_field;
+ // So do the mode bar and side panels over a page scrolled under them (#2755),
+ // unless a selection is being dragged there.
+ let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged());
if column && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if over_text && cursor_free {
@@ -3998,6 +4164,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)),
None => {}
}
+ // Full screen: the click turns the page unless it did something else first. It waits out the
+ // double-click time: turning on the first click's release sent a double-click's second click,
+ // meant to select a word, to the next page (review on #2070). A selection cancels it, and so
+ // do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns
+ // once.
+ let now = ui.input(|i| i.time);
+ if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 {
+ let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay);
+ view.present_turn = Some((now + delay, !present_back));
+ }
+ if let Some((due, forward)) = view.present_turn {
+ if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() {
+ view.present_turn = None;
+ } else if now >= due {
+ view.present_turn = None;
+ view.step_page(forward);
+ } else {
+ ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0)));
+ }
+ }
match clicked_link {
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
@@ -4116,8 +4302,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if copy_selected {
app.execute("edit.copy");
}
- // Read mode hides the tools (#2646).
- if app.mode != crate::Mode::Read {
+ // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
+ // the quick tools come back with the window.
+ if app.mode != crate::Mode::Read && !app.full_screen {
quick_bar(app, avail, ui);
}
if let Some((source, file, destination)) = remote_open {
@@ -6410,6 +6597,55 @@ trailer << /Root 1 0 R >>
}
}
+ /// Review on #2070: full screen in two-page view fits the spread it shows, the cover page
+ /// on its own included; two-page view in the window keeps one zoom for the document.
+ #[test]
+ fn full_screen_two_page_view_fits_the_spread_it_shows() {
+ let page =
+ |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
+ // Spreads [0, 1] small, [2, 3] large; with a cover page [0], [1, 2] mixed, [3].
+ let info = DocInfo { pages: vec![page(300.0, 400.0), page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
+ let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
+ (v.layout, v.continuous, v.viewport_w, v.viewport_h) = (PageLayout::TwoUp, false, 1000.0, 800.0);
+ v.fit = Fit::Page;
+ v.fit_zoom(&info);
+ let windowed = v.zoom;
+ v.enter_presentation();
+ v.fit_zoom(&info);
+ let small = v.zoom;
+ assert!(small > windowed * 1.9, "the small spread fills the screen: {small} vs {windowed}");
+ v.current = 3;
+ v.fit_zoom(&info);
+ assert!((v.zoom - windowed).abs() < 1e-4, "the large spread fits as before");
+ v.current = 1;
+ v.fit_zoom(&info);
+ assert_eq!(v.zoom, small, "page 2 is in the first spread");
+ // With a cover page: [0] alone, then [1, 2] (the large page decides), then [3].
+ v.cover = true;
+ v.current = 0;
+ v.fit_zoom(&info);
+ assert_eq!(v.zoom, small, "the cover page alone");
+ v.current = 1;
+ v.fit_zoom(&info);
+ assert!((v.zoom - windowed).abs() < 1e-4, "page 2 shares a spread with the large page 3");
+ // Scrolling while in full screen fits the document, as scrolling does in the window.
+ (v.continuous, v.current) = (true, 0);
+ v.fit_zoom(&info);
+ assert!((v.zoom - windowed).abs() < 1e-4, "continuous full screen keeps one zoom");
+ v.continuous = false;
+ // The cover page is a spread of its own even when the next page is larger.
+ let mixed = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
+ v.current = 0;
+ v.fit_zoom(&mixed);
+ assert_eq!(v.zoom, small, "the small cover alone, not with page 2");
+ // Leaving full screen brings the document-wide fit back.
+ v.leave_presentation();
+ (v.fit, v.current) = (Fit::Page, 0);
+ (v.layout, v.continuous) = (PageLayout::TwoUp, false);
+ v.fit_zoom(&info);
+ assert!((v.zoom - windowed).abs() < 1e-4, "in the window, one zoom for the document");
+ }
+
#[test]
fn fit_width_fits_the_shown_page_in_single_page_view_only() {
let page =
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 48d1d3c44..056d43f7e 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -650,144 +650,156 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.exact_size(48.0)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
- ui.spacing_mut().item_spacing.y = 4.0;
- let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
- let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
- let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
- if has && !selected {
- let c = r.rect.right_top() + vec2(-8.0, 8.0);
- ui.painter().circle_filled(c, 3.0, t.accent);
- }
- if r.clicked() {
- app.choose_right_panel(if selected { None } else { Some(panel) });
- }
- };
- rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
- rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
- rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
- rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
- rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
- rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
- rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
- if has_check {
- rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
- }
- rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
-
- // Page navigation cluster at the bottom (as in Acrobat's rail).
+ // Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so
+ // that in a short window the buttons above scroll instead of running into it (#3282).
let view = &mut app.views[index];
let mut page_display = None;
- ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
- ui.spacing_mut().item_spacing.y = 2.0;
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
- if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
- view.zoom_step(false);
- }
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
- if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
- view.zoom_step(true);
- }
- // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
- let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
- let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
- page_display = egui::Popup::menu(&resp)
- .show(|ui| {
- ui.set_min_width(220.0);
- rail_view_menu(ui, view)
- })
- .and_then(|r| r.inner);
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
- if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
- view.rotate_view(true);
- }
- // Not `columns-2` while fitting the page: that is the two-page view's icon.
- let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
- if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
- view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
- view.goto = Some((view.current, 0.0));
- }
- let id = egui::Id::new("zoom-input");
- let draft = id.with("draft");
- let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
- let edit = egui::TextEdit::singleline(&mut typed)
- .id(id)
- .desired_width(40.0)
- .horizontal_align(Align::Center)
- .font(theme::medium(11.0))
- .text_color(t.text_muted)
- .frame(egui::Frame::NONE)
- .margin(vec2(2.0, 2.0));
- let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
- ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
- if r.gained_focus() {
- let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
- let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
- state.cursor.set_char_range(Some(all));
- state.store(ui.ctx(), id);
- }
- if r.has_focus() {
- ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
- } else {
- ui.data_mut(|d| d.remove::(draft));
- if r.lost_focus()
- && ui.input(|i| i.key_pressed(egui::Key::Enter))
- && let Some(zoom) = DocView::parse_zoom_percent(&typed)
- {
- view.set_zoom(zoom);
+ egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| {
+ ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
+ ui.spacing_mut().item_spacing.y = 2.0;
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
+ if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
+ view.zoom_step(false);
}
- }
- ui.add_space(6.0);
- if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
- view.step_page(true);
- }
- if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
- view.step_page(false);
- }
- // History is distinct from stepping to the adjacent page, and belongs to this tab.
- for (forward, icon, label, shortcut, enabled) in [
- (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
- (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
- ] {
- let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
- if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
- view.view_history(forward);
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
+ if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
+ view.zoom_step(true);
}
- }
- ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted));
- let page_input_id = view.page_input_id();
- // Consume replacement selection once, including when the editor has lost focus.
- let reselect = std::mem::take(&mut view.page_input_reselect);
- // Navigation updates the idle display, never the text being edited.
- if !ui.memory(|m| m.has_focus(page_input_id)) {
- view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
- } else if reselect {
- // The replacement changed the label while egui retained this editor's cursor.
- let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
- let end = egui::text::CCursor::new(view.page_input.chars().count());
- state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
- state.store(ui.ctx(), page_input_id);
- }
- let edit = egui::TextEdit::singleline(&mut view.page_input)
- .id(page_input_id)
- .desired_width(34.0)
- .horizontal_align(Align::Center)
- .font(theme::medium(12.0))
- .margin(vec2(2.0, 4.0));
- let r = egui::Frame::NONE
- .fill(t.field)
- .stroke(Stroke::new(1.0, t.border))
- .corner_radius(CornerRadius::same(5))
- .show(ui, |ui| ui.add(edit))
- .inner
- .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
- if r.lost_focus() {
- if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
- // A page label first (logical page numbers, as Acrobat), then a number.
- let typed = view.page_input.clone();
- view.go_to_typed(&typed, &labels);
+ // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
+ let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
+ let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
+ page_display = egui::Popup::menu(&resp)
+ .show(|ui| {
+ ui.set_min_width(220.0);
+ rail_view_menu(ui, view)
+ })
+ .and_then(|r| r.inner);
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
+ if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
+ view.rotate_view(true);
}
- view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
+ // Not `columns-2` while fitting the page: that is the two-page view's icon.
+ let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
+ if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
+ view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
+ view.goto = Some((view.current, 0.0));
+ }
+ let id = egui::Id::new("zoom-input");
+ let draft = id.with("draft");
+ let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
+ let edit = egui::TextEdit::singleline(&mut typed)
+ .id(id)
+ .desired_width(40.0)
+ .horizontal_align(Align::Center)
+ .font(theme::medium(11.0))
+ .text_color(t.text_muted)
+ .frame(egui::Frame::NONE)
+ .margin(vec2(2.0, 2.0));
+ let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
+ ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
+ if r.gained_focus() {
+ let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
+ let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
+ state.cursor.set_char_range(Some(all));
+ state.store(ui.ctx(), id);
+ }
+ if r.has_focus() {
+ ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
+ } else {
+ ui.data_mut(|d| d.remove::(draft));
+ if r.lost_focus()
+ && ui.input(|i| i.key_pressed(egui::Key::Enter))
+ && let Some(zoom) = DocView::parse_zoom_percent(&typed)
+ {
+ view.set_zoom(zoom);
+ }
+ }
+ ui.add_space(6.0);
+ if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
+ view.step_page(true);
+ }
+ if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
+ view.step_page(false);
+ }
+ // History is distinct from stepping to the adjacent page, and belongs to this tab.
+ for (forward, icon, label, shortcut, enabled) in [
+ (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
+ (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
+ ] {
+ let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
+ if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
+ view.view_history(forward);
+ }
+ }
+ let font = theme::regular(11.0);
+ let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str));
+ if widgets::text_width(ui, &position, &font) > ui.available_width() {
+ position = page_count.to_string();
+ }
+ // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end.
+ let shown = if view.current < page_count { view.current + 1 } else { page_count };
+ let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]);
+ ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of);
+ let page_input_id = view.page_input_id();
+ // Consume replacement selection once, including when the editor has lost focus.
+ let reselect = std::mem::take(&mut view.page_input_reselect);
+ // Navigation updates the idle display, never the text being edited.
+ if !ui.memory(|m| m.has_focus(page_input_id)) {
+ view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
+ } else if reselect {
+ // The replacement changed the label while egui retained this editor's cursor.
+ let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
+ let end = egui::text::CCursor::new(view.page_input.chars().count());
+ state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
+ state.store(ui.ctx(), page_input_id);
+ }
+ let edit = egui::TextEdit::singleline(&mut view.page_input)
+ .id(page_input_id)
+ .desired_width(34.0)
+ .horizontal_align(Align::Center)
+ .font(theme::medium(12.0))
+ .margin(vec2(2.0, 4.0));
+ let r = egui::Frame::NONE
+ .fill(t.field)
+ .stroke(Stroke::new(1.0, t.border))
+ .corner_radius(CornerRadius::same(5))
+ .show(ui, |ui| ui.add(edit))
+ .inner
+ .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
+ if r.lost_focus() {
+ if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+ // A page label first (logical page numbers, as Acrobat), then a number.
+ let typed = view.page_input.clone();
+ view.go_to_typed(&typed, &labels);
+ }
+ view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
+ }
+ });
+ });
+ egui::ScrollArea::vertical().id_salt("rail-panels").show(ui, |ui| {
+ ui.spacing_mut().item_spacing.y = 4.0;
+ let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
+ let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
+ let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
+ if has && !selected {
+ let c = r.rect.right_top() + vec2(-8.0, 8.0);
+ ui.painter().circle_filled(c, 3.0, t.accent);
+ }
+ if r.clicked() {
+ app.choose_right_panel(if selected { None } else { Some(panel) });
+ }
+ };
+ rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
+ rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
+ rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
+ rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
+ rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
+ rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
+ rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
+ if has_check {
+ rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
}
+ rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
});
if let Some(id) = page_display {
app.execute(id);
@@ -795,6 +807,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
+/// What the rail shows under the page box: the page count, or, when the current page's label is
+/// not its physical number (front matter numbered i, ii, …), its position as "3/180" (#2834).
+fn page_position(current: usize, count: usize, label: &str) -> String {
+ let number = current.saturating_add(1).to_string();
+ if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") }
+}
+
/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page.
/// Returns the command a click asks for, for the caller to run.
fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> {
@@ -857,6 +876,21 @@ fn rail_view_menu(ui: &mut egui::Ui, view: &mut DocView) -> Option<&'static str>
picked
}
+#[cfg(test)]
+mod page_position_tests {
+ use super::page_position;
+
+ #[test]
+ fn position_shows_only_when_the_label_is_not_the_page_number() {
+ assert_eq!(page_position(0, 180, "i"), "1/180");
+ assert_eq!(page_position(2, 180, "1"), "3/180");
+ assert_eq!(page_position(2, 180, "3"), "180");
+ assert_eq!(page_position(2, 180, ""), "180");
+ assert_eq!(page_position(0, 0, "i"), "0");
+ assert_eq!(page_position(usize::MAX, 3, "x"), "3");
+ }
+}
+
#[cfg(test)]
mod tab_widths {
use super::{MAC_CONTENT_LEFT, MAC_LIGHT, MAC_LIGHT_PITCH, MAC_LIGHTS_LEFT, MAC_TITLE_BAR, tab_cap};
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 49a0e20b7..2f888764a 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -270,7 +270,7 @@ impl PdfCraftApp {
let on = !self.full_screen;
match self.ctx.clone() {
Some(ctx) => self.set_full_screen(&ctx, on),
- None => self.full_screen = on,
+ None => self.apply_full_screen(on),
}
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
diff --git a/crates/ui-egui/src/compare_ui.rs b/crates/ui-egui/src/compare_ui.rs
index f9c1cbcee..afc961e78 100644
--- a/crates/ui-egui/src/compare_ui.rs
+++ b/crates/ui-egui/src/compare_ui.rs
@@ -333,8 +333,15 @@ pub(crate) fn compare_area(app: &mut PdfCraftApp, old: usize, new: usize, ui: &m
}
}
let names = [old, new].map(|i| app.session.get(app.views[i].id).map(|d| d.name.clone()).unwrap_or_default());
+ // Drawing a half can close its own tab (its snapshot bar's Close, #3915), which shifts or
+ // removes the indexes below. Each half is found again by id, and the frame stops when one is gone.
+ let ids = [app.views[old].id, app.views[new].id];
+ let find = |app: &PdfCraftApp, id| app.views.iter().position(|v| v.id == id);
ui.painter().vline(rect.min.x + half_w, rect.y_range(), egui::Stroke::new(gap, t.border));
- for (k, (r, index)) in [(left, old), (right, new)].into_iter().enumerate() {
+ for (k, r) in [left, right].into_iter().enumerate() {
+ // Both halves must still be open: once either has closed, the pair is over.
+ let (Some(old_index), Some(new_index)) = (find(app, ids[0]), find(app, ids[1])) else { return };
+ let index = if k == 0 { old_index } else { new_index };
let caption = egui::Rect::from_min_size(r.min, egui::vec2(r.width(), caption_h));
ui.painter().rect_filled(caption, 0.0, t.panel);
let label = crate::i18n::fmt(if k == 0 { tl!("Old file: {name}") } else { tl!("New file: {name}") }, &[("name", &names[k])]);
@@ -351,6 +358,8 @@ pub(crate) fn compare_area(app: &mut PdfCraftApp, old: usize, new: usize, ui: &m
crate::canvas::document_area(app, index, &mut child);
}
// After both drew: the leader's scroll, when it moved, is the follower's next offset.
+ let (Some(old), Some(new)) = (find(app, ids[0]), find(app, ids[1])) else { return };
+ let follow = if leader == 0 { new } else { old };
let offsets = [app.views[old].last_scroll, app.views[new].last_scroll];
if let Some(c) = app.compare.as_mut() {
let lead_k = if leader == 0 { 0 } else { 1 };
diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs
index d9bc21d02..997df8141 100644
--- a/crates/ui-egui/src/content_ui.rs
+++ b/crates/ui-egui/src/content_ui.rs
@@ -357,8 +357,13 @@ pub(crate) fn page_input(
.and_then(|(_, si)| items.iter().find(|(i, _)| *i == si))
.map(|(_, a)| screen_rect(xf, info, page, a.content.rect()));
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
+ // Cursors only where the page shows: the mode bar and side panels over a page scrolled under
+ // them keep their own (#2755).
+ let shows = resp.contains_pointer();
if adding_text && hit.is_none() {
- ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
+ if shows {
+ ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
+ }
if resp.clicked() {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
@@ -383,9 +388,9 @@ pub(crate) fn page_input(
}
return consumed;
}
- if let Some((_, left, top)) = corner {
+ if let Some((_, left, top)) = corner.filter(|_| shows) {
ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
- } else if hit.is_some() {
+ } else if hit.is_some() && shows {
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
if resp.drag_started() {
@@ -523,58 +528,65 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
let (mut commit, mut cancel, mut done) = (false, false, false);
let mut undrawable = None;
let memo = &view.content.undrawable;
- // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it.
- let area = egui::Area::new(egui::Id::new(("added-text", view.id.0))).fade_in(false);
- area.order(egui::Order::Foreground).fixed_pos(r.min).constrain(false).show(ctx, |ui| {
- let Some(t) = view.content.draft.as_mut() else { return };
- ui.set_width(r.width() + 64.0);
- if let Some(source_rect) = source_rect.filter(|_| !background_ready) {
- ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE);
- }
- // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
- ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
- let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
- ui.horizontal_top(|ui| {
- ui.spacing_mut().item_spacing.x = 4.0;
- let resp = ui.add(
- egui::TextEdit::multiline(&mut t.text)
- .font(font)
- .desired_width(r.width().max(60.0))
- .desired_rows(1)
- .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
- .text_color(Color32::from_rgb(cr, cg, cb))
- .hint_text(tl!("Type text"))
- .id_salt("added-text-edit"),
- );
- if t.focus {
- resp.request_focus();
- t.focus = false;
+ // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over
+ // the page, so only where the document shows: text scrolled under the mode bar or a side
+ // panel must leave them their pointer (#2755).
+ egui::Area::new(egui::Id::new(("added-text", view.id.0)))
+ .fade_in(false)
+ .order(egui::Order::Foreground)
+ .fixed_pos(r.min)
+ .constrain_to(view.viewport_rect())
+ .constrain(false)
+ .show(ctx, |ui| {
+ let Some(t) = view.content.draft.as_mut() else { return };
+ ui.set_width(r.width() + 64.0);
+ if let Some(source_rect) = source_rect.filter(|_| !background_ready) {
+ ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE);
+ }
+ // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
+ ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
+ let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
+ ui.horizontal_top(|ui| {
+ ui.spacing_mut().item_spacing.x = 4.0;
+ let resp = ui.add(
+ egui::TextEdit::multiline(&mut t.text)
+ .font(font)
+ .desired_width(r.width().max(60.0))
+ .desired_rows(1)
+ .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
+ .text_color(Color32::from_rgb(cr, cg, cb))
+ .hint_text(tl!("Type text"))
+ .id_salt("added-text-edit"),
+ );
+ if t.focus {
+ resp.request_focus();
+ t.focus = false;
+ }
+ let buttons = egui::Frame::NONE
+ .fill(tokens.card)
+ .stroke(Stroke::new(1.0, tokens.border))
+ .corner_radius(CornerRadius::same(6))
+ .inner_margin(2)
+ .show(ui, |ui| {
+ ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0);
+ ui.horizontal(|ui| {
+ done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked();
+ cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked();
+ });
+ })
+ .response
+ .rect;
+ // Pressing a button takes the focus from the text: that is not a click away.
+ let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p));
+ cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
+ commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor;
+ });
+ // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
+ undrawable = self::undrawable(t, memo);
+ if let Some(c) = undrawable {
+ ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE));
}
- let buttons = egui::Frame::NONE
- .fill(tokens.card)
- .stroke(Stroke::new(1.0, tokens.border))
- .corner_radius(CornerRadius::same(6))
- .inner_margin(2)
- .show(ui, |ui| {
- ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0);
- ui.horizontal(|ui| {
- done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked();
- cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked();
- });
- })
- .response
- .rect;
- // Pressing a button takes the focus from the text: that is not a click away.
- let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p));
- cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
- commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor;
});
- // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
- undrawable = self::undrawable(t, memo);
- if let Some(c) = undrawable {
- ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE));
- }
- });
if cancel {
view.content.draft = None;
return (None, false);
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index a4ab35d96..debad4535 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -36,8 +36,9 @@
//! for this session without changing the saved preference. `ui.state.default_mode` reports it.
//! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference`
//! reports the choice and `ui.state.theme` reports the resolved light/dark colours.
-//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they
-//! complete and are listed in `ui.state.pending_opens`.
+//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it
+//! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until
+//! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`.
//! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect.
use std::collections::{HashMap, VecDeque};
@@ -680,6 +681,7 @@ impl Host for crate::PdfCraftApp {
"layout": format!("{:?}", v.layout),
"organize": v.organize,
"grid_zoom": v.grid_zoom(),
+ "thumbnail_zoom": v.thumbnail_zoom(),
// Pages picked in the organize grid or the Pages panel (empty: the current page).
"selected_pages": v.selected.iter().map(|p| p + 1).collect::>(),
"auto_scrolling": v.auto_scrolling(),
@@ -766,14 +768,11 @@ impl Host for crate::PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
fn open(&mut self, path: &str) -> Reply {
- let before = self.views.len();
- self.open_path(path);
- if self.views.len() > before || self.password_prompt.is_some() {
- Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() }))
- } else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) {
- Ok(json!({ "accepted": path, "pending": true }))
- } else {
- Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}")))
+ match self.open_path(path) {
+ crate::OpenOutcome::Opened => Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })),
+ crate::OpenOutcome::Focused => Ok(json!({ "opened": path, "focused": true })),
+ crate::OpenOutcome::Pending => Ok(json!({ "accepted": path, "pending": true })),
+ crate::OpenOutcome::Failed => Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))),
}
}
diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs
index cdd783a62..ff303ccfc 100644
--- a/crates/ui-egui/src/create_ui.rs
+++ b/crates/ui-egui/src/create_ui.rs
@@ -103,7 +103,11 @@ impl PdfCraftApp {
// The reading-colours preference is app-wide: the newly created document starts with it.
self.session.set_reading_colors(id, self.reading_colors);
let info = &self.session.get(id).ok_or("the new document could not be opened")?.info;
- self.views.push(crate::DocView::new(id, info, self.view_defaults));
+ let mut view = crate::DocView::new(id, info, self.view_defaults);
+ if self.full_screen {
+ view.enter_presentation();
+ }
+ self.views.push(view);
self.active = Some(self.views.len() - 1);
Ok(())
}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 8bff5ca8d..f8eee1b39 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1457,21 +1457,74 @@ fn draft_changes(app: &PdfCraftApp) -> Option> {
Some(edits)
}
-/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them: Enter
-/// saves (the default button) and Escape cancels; Don't save takes ⌘D / Ctrl+D, the macOS
-/// convention, or Alt+D / Alt+N, the mnemonics Windows and Linux desktops use for it.
-pub(crate) fn save_prompt_key(ctx: &egui::Context) -> Option> {
+/// The buttons of the "Save changes?" prompt, in the order they sit on screen, left to right.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) enum PromptButton {
+ DontSave,
+ Cancel,
+ #[default]
+ Save,
+}
+
+impl PromptButton {
+ const ORDER: [PromptButton; 3] = [PromptButton::DontSave, PromptButton::Cancel, PromptButton::Save];
+
+ fn position(self) -> usize {
+ Self::ORDER.iter().position(|b| *b == self).unwrap_or(2)
+ }
+
+ /// The button `by` places to the right (negative: left). Arrow keys stop at the ends.
+ fn step(self, by: isize) -> PromptButton {
+ let to = self.position().saturating_add_signed(by).min(Self::ORDER.len() - 1);
+ Self::ORDER.get(to).copied().unwrap_or(self)
+ }
+
+ /// The button after (or, with `back`, before) this one, wrapping round: Tab and Shift+Tab.
+ fn cycle(self, back: bool) -> PromptButton {
+ let n = Self::ORDER.len();
+ let to = if back { self.position().checked_sub(1).unwrap_or(n - 1) } else { (self.position() + 1) % n };
+ Self::ORDER.get(to).copied().unwrap_or(self)
+ }
+
+ /// What pressing the button answers.
+ fn answer(self) -> Option {
+ match self {
+ PromptButton::Save => Some(true),
+ PromptButton::DontSave => Some(false),
+ PromptButton::Cancel => None,
+ }
+ }
+}
+
+/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them. Save is
+/// the button chosen when the prompt opens, so Enter saves; the arrow keys and Tab / Shift+Tab move
+/// the choice, and Enter or Space press the chosen button. Escape cancels. Don't save also has
+/// ⌘D / Ctrl+D, the macOS convention, and Alt+D / Alt+N, the mnemonics Windows and Linux desktops
+/// use for it. Returns the answer once one is given.
+pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) -> Option> {
use egui::{Key, Modifiers};
ctx.input_mut(|i| {
- if i.consume_key(Modifiers::NONE, Key::Enter) {
- Some(Some(true))
- } else if i.consume_key(Modifiers::NONE, Key::Escape) {
- Some(None)
- } else if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) {
- Some(Some(false))
- } else {
- None
+ if i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Space) {
+ return Some(chosen.answer());
}
+ if i.consume_key(Modifiers::NONE, Key::Escape) {
+ return Some(None);
+ }
+ if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) {
+ return Some(Some(false));
+ }
+ // Shift+Tab before Tab: a key press matches a pattern that asks for fewer modifiers.
+ if i.consume_key(Modifiers::SHIFT, Key::Tab) {
+ *chosen = chosen.cycle(true);
+ } else if i.consume_key(Modifiers::NONE, Key::Tab) {
+ *chosen = chosen.cycle(false);
+ }
+ for (key, by) in [(Key::ArrowLeft, -1), (Key::ArrowUp, -1), (Key::ArrowRight, 1), (Key::ArrowDown, 1)] {
+ if i.consume_key(Modifiers::NONE, key) {
+ *chosen = chosen.step(by);
+ }
+ }
+ None
})
}
@@ -1511,14 +1564,15 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_muted));
ui.add_space(14.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
- if widgets::pill_button(ui, tl!("Save"), true).clicked() {
+ let chosen = app.save_prompt_button;
+ if widgets::pill_button_marked(ui, tl!("Save"), true, chosen == PromptButton::Save).clicked() {
choice = Some(Some(true));
}
- if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
+ if widgets::pill_button_marked(ui, tl!("Cancel"), false, chosen == PromptButton::Cancel).clicked() {
choice = Some(None);
}
ui.add_space(24.0);
- if widgets::pill_button(ui, tl!("Don't save"), false).clicked() {
+ if widgets::pill_button_marked(ui, tl!("Don't save"), false, chosen == PromptButton::DontSave).clicked() {
choice = Some(Some(false));
}
});
@@ -1767,3 +1821,34 @@ fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) {
None => {}
}
}
+
+#[cfg(test)]
+mod prompt_button_tests {
+ use super::PromptButton::{self, Cancel, DontSave, Save};
+
+ #[test]
+ fn opens_on_save_and_arrows_stop_at_the_ends() {
+ assert_eq!(PromptButton::default(), Save);
+ assert_eq!(Save.step(1), Save);
+ assert_eq!(Save.step(-1), Cancel);
+ assert_eq!(Cancel.step(-1), DontSave);
+ assert_eq!(DontSave.step(-1), DontSave);
+ assert_eq!(DontSave.step(1000), Save, "a long step clamps");
+ assert_eq!(Save.step(isize::MIN), DontSave, "and so does a huge negative one");
+ }
+
+ #[test]
+ fn tab_wraps_both_ways() {
+ assert_eq!(Save.cycle(false), DontSave);
+ assert_eq!(DontSave.cycle(true), Save);
+ assert_eq!(Cancel.cycle(false), Save);
+ assert_eq!(Cancel.cycle(true), DontSave);
+ }
+
+ #[test]
+ fn each_button_answers_as_labelled() {
+ assert_eq!(Save.answer(), Some(true));
+ assert_eq!(DontSave.answer(), Some(false));
+ assert_eq!(Cancel.answer(), None);
+ }
+}
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index 5e0e401dc..3a1ab0436 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -1317,14 +1317,18 @@ pub(crate) fn page_input(
draw_handles(boxes[hit]);
}
// A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it
- // for typing); an edge rewraps it. The open one takes the text cursor.
- ui.ctx().set_cursor_icon(if on_edge {
- egui::CursorIcon::ResizeHorizontal
- } else if active.is_none() {
- egui::CursorIcon::Move
- } else {
- egui::CursorIcon::Text
- });
+ // for typing); an edge rewraps it. The open one takes the text cursor. Only where the page
+ // shows, so the mode bar and side panels over a paragraph scrolled under them keep their own
+ // cursor (#2755).
+ if resp.contains_pointer() {
+ ui.ctx().set_cursor_icon(if on_edge {
+ egui::CursorIcon::ResizeHorizontal
+ } else if active.is_none() {
+ egui::CursorIcon::Move
+ } else {
+ egui::CursorIcon::Text
+ });
+ }
if resp.clicked() {
view.image_selection = None;
// A click that moves the editor to another paragraph applies the open one first, so its
@@ -1570,7 +1574,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// The editor draws on its page only, as the page itself does: a paragraph running past the
// page's edge (a converter relying on the page clip) must not cover the next page or the
// tool bars (#3061).
- let page_clip = xf.rect.intersect(view.viewport_rect());
+ let viewport = view.viewport_rect();
+ let page_clip = xf.rect.intersect(viewport);
let ed = view.line_editor.as_mut()?;
if hold {
ed.focus = true;
@@ -1599,10 +1604,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let mut reveal = false;
let mut look = None;
// No fade-in: while it faded, the page's own copy of the text showed through the cover.
+ // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or
+ // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as
+ // the page is, rather than pushed back into the window.
egui::Area::new(egui::Id::new("edit-text-line"))
.order(egui::Order::Foreground)
.fade_in(false)
.fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top()))
+ .constrain_to(viewport)
+ .constrain(false)
.show(ctx, |ui| {
ui.set_clip_rect(ui.clip_rect().intersect(page_clip));
// The editor opens at the source size. Once text changes, show the real draft
@@ -1628,6 +1638,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// On the frame, a press takes hold of the box (a click selects it, a drag moves it):
// the pointer says so there, and reads as text inside.
if let Some(p) = ui.input(|i| i.pointer.hover_pos())
+ && ui.clip_rect().contains(p)
&& live.expand(2.0 + FRAME_REACH).contains(p)
&& !live.expand(2.0).contains(p)
{
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 5f58d9b06..540b63715 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -846,6 +846,8 @@ impl PdfCraftApp {
/// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel.
pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option) {
let Some(req) = self.close_request.take() else { return };
+ // The next question (another dirty document) starts on Save again.
+ self.save_prompt_button = Default::default();
let index = match req {
CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) {
Some(i) => i,
@@ -1082,7 +1084,11 @@ impl PdfCraftApp {
let edit = match action {
A::New => {
let n = self.session.get(id).map_or(0, |d| d.info.outline.len());
- self.apply_edit(Edit::AddBookmark { parent: vec![], index: n, title: "Untitled".into(), page: current });
+ // Where the reader is: the selection's corner, else the top of the window.
+ let (page, [left, top]) =
+ self.session.get(id).and_then(|d| self.views.get(i)?.bookmark_position(&d.info)).unwrap_or((current, [None, None]));
+ let (left, top) = (left.map(f64::from), top.map(f64::from));
+ self.apply_edit(Edit::AddBookmark { parent: vec![], index: n, title: "Untitled".into(), page, left, top });
// Like Acrobat: the new bookmark starts in rename mode.
self.bookmark_rename = Some((vec![n], "Untitled".into()));
self.right = Some(crate::RightPanel::Bookmarks);
diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs
index 03c773807..1ec9d383f 100644
--- a/crates/ui-egui/src/files.rs
+++ b/crates/ui-egui/src/files.rs
@@ -816,7 +816,11 @@ impl PdfCraftApp {
// The reading-colours preference is app-wide: the newly created document starts with it.
self.session.set_reading_colors(id, self.reading_colors);
let Some(doc) = self.session.get(id) else { return };
- self.views.push(crate::DocView::new(id, &doc.info, self.view_defaults));
+ let mut view = crate::DocView::new(id, &doc.info, self.view_defaults);
+ if self.full_screen {
+ view.enter_presentation();
+ }
+ self.views.push(view);
self.active = Some(self.views.len() - 1);
self.notify_tr(message);
}
diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs
index 39c687243..acf0bd59b 100644
--- a/crates/ui-egui/src/fill_sign.rs
+++ b/crates/ui-egui/src/fill_sign.rs
@@ -707,34 +707,42 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
let mut commit = false;
let mut cancel = false;
// Shown at once, like the paragraph editor: a fade-in shows the page's copy through it.
- let area = egui::Area::new(egui::Id::new(("fill-text", view.id.0))).fade_in(false);
- area.order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
- let Some(t) = view.fill_text.as_mut() else { return };
- // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
- ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
- let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0);
- // The area's max rect is last frame's size, which caps the edit's width; widen it so the
- // box grows as text is typed.
- ui.set_min_width(width);
- let r = ui.add(
- egui::TextEdit::singleline(&mut t.text)
- .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
- .desired_width(width)
- .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
- .text_color(Color32::BLACK)
- .hint_text(tl!("Type text"))
- .id_salt("fill-text-edit"),
- );
- if t.focus {
- r.request_focus();
- t.focus = false;
- }
- if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
- cancel = true;
- } else if r.lost_focus() {
- commit = true;
- }
- });
+ // Over the page, so only where the document shows: a box scrolled under the mode bar or a
+ // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as
+ // the page is, rather than pushed back into the window.
+ egui::Area::new(egui::Id::new(("fill-text", view.id.0)))
+ .fade_in(false)
+ .order(egui::Order::Foreground)
+ .fixed_pos(pos)
+ .constrain_to(view.viewport_rect())
+ .constrain(false)
+ .show(ctx, |ui| {
+ let Some(t) = view.fill_text.as_mut() else { return };
+ // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
+ ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
+ let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0);
+ // The area's max rect is last frame's size, which caps the edit's width; widen it so the
+ // box grows as text is typed.
+ ui.set_min_width(width);
+ let r = ui.add(
+ egui::TextEdit::singleline(&mut t.text)
+ .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
+ .desired_width(width)
+ .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
+ .text_color(Color32::BLACK)
+ .hint_text(tl!("Type text"))
+ .id_salt("fill-text-edit"),
+ );
+ if t.focus {
+ r.request_focus();
+ t.focus = false;
+ }
+ if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+ cancel = true;
+ } else if r.lost_focus() {
+ commit = true;
+ }
+ });
if cancel {
view.fill_text = None;
return None;
diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs
index 60df5bc19..d5edb214c 100644
--- a/crates/ui-egui/src/forms_ui.rs
+++ b/crates/ui-egui/src/forms_ui.rs
@@ -136,11 +136,15 @@ pub(crate) fn page_input(
// their action.
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some();
let usable = allowed && (fillable(f) || pressable || f.kind == FormFieldKind::Signature);
- ui.ctx().set_cursor_icon(match (usable, f.kind) {
- (false, _) => egui::CursorIcon::NotAllowed,
- (true, FormFieldKind::Text) => egui::CursorIcon::Text,
- _ => egui::CursorIcon::PointingHand,
- });
+ // Only where the page shows: the mode bar and side panels over a field scrolled under them
+ // keep their own cursor (#2755).
+ if resp.contains_pointer() {
+ ui.ctx().set_cursor_icon(match (usable, f.kind) {
+ (false, _) => egui::CursorIcon::NotAllowed,
+ (true, FormFieldKind::Text) => egui::CursorIcon::Text,
+ _ => egui::CursorIcon::PointingHand,
+ });
+ }
// A text field opens on the press rather than the click, so a drag that starts on it
// selects its text (#2213).
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed());
@@ -504,79 +508,88 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
FormFieldKind::Text => {
let multiline = f.has(field_flags::MULTILINE);
// Shown at once, like the paragraph editor: a fade-in shows the field's value through it.
- let area = egui::Area::new(egui::Id::new(("form-editor", view.id.0))).fade_in(false);
- area.order(egui::Order::Foreground).fixed_pos(rect.min).show(ctx, |ui| {
- ui.set_min_size(rect.size());
- let Some(fx) = view.forms.focus.as_mut() else { return };
- // Sized from the text being typed, so an auto-sized field shrinks to keep it in view.
- let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
- // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
- ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
- let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) };
- te = te
- // Wraps (inside the 3 px side margins) at the width the size was fitted to:
- // the box less 3 pt each side, scaled.
- .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0))
- .font(egui::FontId::proportional(font))
- .background_color(FIELD_FILL)
- .text_color(Color32::BLACK)
- .margin(egui::Margin::symmetric(3, 1))
- .password(f.has(field_flags::PASSWORD))
- .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)));
- if let Some(max) = f.max_len {
- te = te.char_limit(max);
- }
- if multiline {
- te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1));
- }
- let mut laid_out = None;
- let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| {
- let out = te.show(ui);
- laid_out = Some((out.galley, out.galley_pos));
- out.response.response
- });
- if let Some(press) = fx.press.take()
- && let Some((galley, pos)) = laid_out
- && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
- {
- // The press went to the page, not to this editor, which did not exist yet.
- let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0));
- st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at)));
- st.store(ui.ctx(), r.id);
- if ui.input(|i| i.pointer.primary_down()) {
- ui.ctx().set_dragged_id(r.id);
+ // Over the page, so only where the document shows: a field scrolled under the mode bar
+ // or a side panel must leave them their pointer (#2755). It stays on its field, cut off
+ // as the page is, rather than pushed back into the window.
+ egui::Area::new(egui::Id::new(("form-editor", view.id.0)))
+ .fade_in(false)
+ .order(egui::Order::Foreground)
+ .fixed_pos(rect.min)
+ .constrain_to(view.viewport_rect())
+ .constrain(false)
+ .show(ctx, |ui| {
+ ui.set_min_size(rect.size());
+ let Some(fx) = view.forms.focus.as_mut() else { return };
+ // Sized from the text being typed, so an auto-sized field shrinks to keep it in view.
+ let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
+ // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
+ ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
+ let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) };
+ te = te
+ // Wraps (inside the 3 px side margins) at the width the size was fitted to:
+ // the box less 3 pt each side, scaled.
+ .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0))
+ .font(egui::FontId::proportional(font))
+ .background_color(FIELD_FILL)
+ .text_color(Color32::BLACK)
+ .margin(egui::Margin::symmetric(3, 1))
+ .password(f.has(field_flags::PASSWORD))
+ .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)));
+ if let Some(max) = f.max_len {
+ te = te.char_limit(max);
}
- }
- if fx.request_focus {
- r.request_focus();
- fx.request_focus = false;
- if std::mem::take(&mut fx.select_all)
+ if multiline {
+ te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1));
+ }
+ let mut laid_out = None;
+ let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| {
+ let out = te.show(ui);
+ laid_out = Some((out.galley, out.galley_pos));
+ out.response.response
+ });
+ if let Some(press) = fx.press.take()
+ && let Some((galley, pos)) = laid_out
&& let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
{
- let n = fx.text.chars().count();
- st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
+ // The press went to the page, not to this editor, which did not exist yet.
+ let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0));
+ st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at)));
st.store(ui.ctx(), r.id);
+ if ui.input(|i| i.pointer.primary_down()) {
+ ui.ctx().set_dragged_id(r.id);
+ }
}
- }
- let (enter, esc, tab, shift) = ui
- .input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift));
- if esc {
- close = true;
- } else if tab {
- next = Some(!shift);
- } else if r.lost_focus()
- && (!multiline || !enter)
- && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
- {
- if pressed {
- // The field reopened with a refused value; keep it open and focused.
- fx.request_focus = true;
- } else {
- // Enter in a single-line field, or clicking elsewhere, commits.
- commit(view, form);
+ if fx.request_focus {
+ r.request_focus();
+ fx.request_focus = false;
+ if std::mem::take(&mut fx.select_all)
+ && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id)
+ {
+ let n = fx.text.chars().count();
+ st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
+ st.store(ui.ctx(), r.id);
+ }
}
- }
- });
+ let (enter, esc, tab, shift) = ui.input(|i| {
+ (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)
+ });
+ if esc {
+ close = true;
+ } else if tab {
+ next = Some(!shift);
+ } else if r.lost_focus()
+ && (!multiline || !enter)
+ && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
+ {
+ if pressed {
+ // The field reopened with a refused value; keep it open and focused.
+ fx.request_focus = true;
+ } else {
+ // Enter in a single-line field, or clicking elsewhere, commits.
+ commit(view, form);
+ }
+ }
+ });
// Date fields: a calendar under the field (Acrobat's date picker).
if let pdfcraft_engine::form_scripts::Format::Date(fmt) = &f.actions.format
&& let Some(picked) = calendar(ctx, view, rect, fmt, today)
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index f6b3d093d..f4346abdc 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -1362,6 +1362,8 @@
This document has no layers. هذا المستند لا يحتوي على طبقات.
This document has no attachments. هذا المستند لا يحتوي على مرفقات.
Hide layer إخفاء الطبقة
+ Layer options خيارات الطبقات
+ Reset to Initial Visibility إعادة تعيين إلى الرؤية الأولية
Show layer إظهار الطبقة
Set layer visibility ضبط ظهور الطبقة
on page {label} في الصفحة {label}
@@ -2332,6 +2334,7 @@ key Space مسافة
Printer paper and scaling may differ. Check driver settings. قد يختلف ورق الطابعة ومقياس الطباعة. تحقق من إعدادات برنامج التشغيل.
Drawing scale changes. Use Actual size for full-scale plans. يتغير مقياس الرسم. استخدم الحجم الفعلي لطباعة المخططات بمقياسها الأصلي.
Reload documents when their files change on disk إعادة تحميل المستندات عند تغيّر ملفاتها على القرص
+ Always use my default zoom and page layout استخدام التكبير وتخطيط الصفحة الافتراضيين دائمًا
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. تغيّر هذا الملف على القرص. «إعادة التحميل» تعرض النسخة الجديدة وتُسقط تغييراتك غير المحفوظة؛ و«حفظ» يستبدلها بهذه النسخة.
Reload إعادة تحميل
Couldn't reload {name}: {e} تعذّرت إعادة تحميل {name}: {e}
@@ -2757,3 +2760,6 @@ certificate Version الإصدار
Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
+ Thumbnail size حجم الصور المصغرة
+ Smaller thumbnails تصغير الصور المصغرة
+ Larger thumbnails تكبير الصور المصغرة
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 90e152c27..1b17ad275 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -1304,6 +1304,8 @@
This document has no layers. Този документ няма слоеве.
This document has no attachments. Този документ няма прикачени файлове.
Hide layer Скрий слоя
+ Layer options Опции за слоеве
+ Reset to Initial Visibility Възстанови първоначалната видимост
Show layer Покажи слоя
Set layer visibility Задай видимост на слоя
on page {label} на страница {label}
@@ -2274,6 +2276,7 @@ key Space Интервал
Printer paper and scaling may differ. Check driver settings. Хартията и мащабът на принтера може да се различават. Проверете настройките на драйвера.
Drawing scale changes. Use Actual size for full-scale plans. Мащабът на чертежа се променя. Използвайте Действителен размер за планове в оригинален мащаб.
Reload documents when their files change on disk Презареждай документите, когато файловете им се променят на диска
+ Always use my default zoom and page layout Винаги използвай моя мащаб и оформление на страниците по подразбиране
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Този файл беше променен на диска. „Презареждане“ показва новата версия и отхвърля незаписаните ви промени; „Запис“ я заменя с тази версия.
Reload Презареди
Couldn't reload {name}: {e} Неуспешно презареждане на {name}: {e}
@@ -2444,3 +2447,6 @@ animation effects Off Изключени
Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
+ Thumbnail size Размер на миниатюрите
+ Smaller thumbnails По-малки миниатюри
+ Larger thumbnails По-големи миниатюри
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 1366cb254..c7bc39694 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -138,6 +138,7 @@
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft
Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku
+ Always use my default zoom and page layout Vždy používat mé výchozí zvětšení a rozvržení stránky
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tento soubor se na disku změnil. Znovu načíst zobrazí novou verzi a zahodí neuložené změny; Uložit ji nahradí touto verzí.
Reload Znovu načíst
Couldn't reload {name}: {e} Soubor {name} nelze znovu načíst: {e}
@@ -1430,6 +1431,8 @@ key Space Mezerník
This document has no layers. Tento dokument nemá žádné vrstvy.
This document has no attachments. Tento dokument nemá žádné přílohy.
Hide layer Skrýt vrstvu
+ Layer options Možnosti vrstev
+ Reset to Initial Visibility Obnovit počáteční viditelnost
Show layer Zobrazit vrstvu
Set layer visibility Nastavit viditelnost vrstvy
on page {label} na stránce {label}
@@ -2555,3 +2558,6 @@ animation effects Off Vypnuto
Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
+ Thumbnail size Velikost miniatur
+ Smaller thumbnails Menší miniatury
+ Larger thumbnails Větší miniatury
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index ac136cd91..d3d884ca9 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -1348,6 +1348,8 @@
This document has no layers. Dieses Dokument hat keine Ebenen.
This document has no attachments. Dieses Dokument hat keine Anhänge.
Hide layer Ebene ausblenden
+ Layer options Ebenenoptionen
+ Reset to Initial Visibility Ursprüngliche Sichtbarkeit wiederherstellen
Show layer Ebene einblenden
on page {label} auf Seite {label}
Save attachment… Anhang speichern…
@@ -2316,6 +2318,7 @@ key Space Leertaste
Printer paper and scaling may differ. Check driver settings. Druckerpapier und Skalierung können abweichen. Treibereinstellungen prüfen.
Drawing scale changes. Use Actual size for full-scale plans. Der Zeichnungsmaßstab ändert sich. Für maßstabsgetreue Pläne Tatsächliche Größe verwenden.
Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern
+ Always use my default zoom and page layout Immer meinen Standardzoom und mein Standard-Seitenlayout verwenden
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Diese Datei wurde auf dem Datenträger geändert. „Neu laden“ zeigt die neue Version und verwirft Ihre ungespeicherten Änderungen; „Speichern“ ersetzt sie durch diese Version.
Reload Neu laden
Couldn't reload {name}: {e} {name} konnte nicht neu geladen werden: {e}
@@ -2482,3 +2485,6 @@ animation effects Off Aus
Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
+ Thumbnail size Miniaturgröße
+ Smaller thumbnails Kleinere Miniaturen
+ Larger thumbnails Größere Miniaturen
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 133e8ca8e..e87da7f99 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -363,6 +363,8 @@
Distribute Κατανομή
Show layer Εμφάνιση επιπέδου
Hide layer Απόκρυψη επιπέδου
+ Layer options Επιλογές επιπέδων
+ Reset to Initial Visibility Επαναφορά αρχικής ορατότητας
Collate Συρραφή αντιγράφων
Fit to page Προσαρμογή στη σελίδα
Reverse pages Αντιστροφή σειράς σελίδων
@@ -2320,6 +2322,7 @@ comment menu Edit Επεξεργασία
Printer paper and scaling may differ. Check driver settings. Το χαρτί και η κλίμακα του εκτυπωτή μπορεί να διαφέρουν. Ελέγξτε τις ρυθμίσεις του προγράμματος οδήγησης.
Drawing scale changes. Use Actual size for full-scale plans. Η κλίμακα του σχεδίου αλλάζει. Για σχέδια σε πλήρη κλίμακα χρησιμοποιήστε το Πραγματικό μέγεθος.
Reload documents when their files change on disk Επαναφόρτωση εγγράφων όταν αλλάζουν τα αρχεία τους στον δίσκο
+ Always use my default zoom and page layout Να χρησιμοποιείται πάντα η προεπιλεγμένη μεγέθυνση και διάταξη σελίδας μου
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Αυτό το αρχείο άλλαξε στον δίσκο. Η επαναφόρτωση δείχνει τη νέα έκδοση και απορρίπτει τις μη αποθηκευμένες αλλαγές σας· η αποθήκευση την αντικαθιστά με αυτή την έκδοση.
Reload Επαναφόρτωση
Couldn't reload {name}: {e} Δεν ήταν δυνατή η επαναφόρτωση του {name}: {e}
@@ -2487,3 +2490,6 @@ animation effects Off Ανενεργά
Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
+ Thumbnail size Μέγεθος μικρογραφιών
+ Smaller thumbnails Μικρότερες μικρογραφίες
+ Larger thumbnails Μεγαλύτερες μικρογραφίες
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 7c7b7c8d7..d2ab8a586 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -1357,6 +1357,8 @@
This document has no layers. Este documento no tiene capas.
This document has no attachments. Este documento no tiene archivos adjuntos.
Hide layer Ocultar capa
+ Layer options Opciones de capas
+ Reset to Initial Visibility Restablecer visibilidad inicial
Show layer Mostrar capa
Set layer visibility Definir visibilidad de capas
on page {label} en la página {label}
@@ -2185,6 +2187,7 @@ action wizard Start Iniciar
Home / End Inicio / Fin
H / Space (hold) H / Espacio (mantener)
Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco
+ Always use my default zoom and page layout Usar siempre mi zoom y presentación de página predeterminados
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este archivo cambió en el disco. Recargar muestra la versión nueva y descarta los cambios sin guardar; Guardar la reemplaza por esta versión.
Reload Recargar
Couldn't reload {name}: {e} No se pudo recargar {name}: {e}
@@ -2621,3 +2624,6 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
+ Thumbnail size Tamaño de las miniaturas
+ Smaller thumbnails Miniaturas más pequeñas
+ Larger thumbnails Miniaturas más grandes
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index aa83f8197..7f9d71d42 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -1343,6 +1343,8 @@
This document has no layers. Ce document ne contient aucun calque.
This document has no attachments. Ce document ne contient aucune pièce jointe.
Hide layer Masquer le calque
+ Layer options Options des calques
+ Reset to Initial Visibility Rétablir la visibilité initiale
Show layer Afficher le calque
on page {label} à la page {label}
Save attachment… Enregistrer la pièce jointe…
@@ -2034,6 +2036,7 @@
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre.
Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft
Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque
+ Always use my default zoom and page layout Toujours utiliser mon zoom et ma présentation de page par défaut
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ce fichier a été modifié sur le disque. Recharger affiche la nouvelle version et abandonne vos modifications non enregistrées ; Enregistrer la remplace par cette version.
Reload Recharger
Couldn't reload {name}: {e} Impossible de recharger {name} : {e}
@@ -2481,3 +2484,6 @@ animation effects Off Désactivés
Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
+ Thumbnail size Taille des vignettes
+ Smaller thumbnails Réduire les vignettes
+ Larger thumbnails Agrandir les vignettes
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 123be343f..99eb996bb 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -1333,6 +1333,8 @@
This document has no layers. Ebben a dokumentumban nincs réteg.
This document has no attachments. Ebben a dokumentumban nincs melléklet.
Hide layer Réteg elrejtése
+ Layer options Réteg beállításai
+ Reset to Initial Visibility Kezdeti láthatóság visszaállítása
Show layer Réteg megjelenítése
on page {label} {label}. oldal
Save attachment… Melléklet mentése…
@@ -2310,6 +2312,7 @@ key Space Szóköz
Printer paper and scaling may differ. Check driver settings. A nyomtató papírja és méretezése eltérhet. Ellenőrizze az illesztőprogram beállításait.
Drawing scale changes. Use Actual size for full-scale plans. A rajz méretaránya változik. Méretarányos tervekhez használja a Tényleges méret beállítást.
Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen
+ Always use my default zoom and page layout Mindig az alapértelmezett nagyításom és oldalelrendezésem használata
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ez a fájl megváltozott a lemezen. Az Újratöltés az új változatot mutatja, és eldobja a nem mentett módosításokat; a Mentés erre a változatra cseréli le.
Reload Újratöltés
Couldn't reload {name}: {e} {name} újratöltése nem sikerült: {e}
@@ -2480,3 +2483,6 @@ animation effects Off Ki
Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
+ Thumbnail size Bélyegképek mérete
+ Smaller thumbnails Kisebb bélyegképek
+ Larger thumbnails Nagyobb bélyegképek
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 71715dc5d..1bf5abb26 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -1358,6 +1358,8 @@
This document has no layers. Questo documento non ha livelli.
This document has no attachments. Questo documento non ha allegati.
Hide layer Nascondi livello
+ Layer options Opzioni livelli
+ Reset to Initial Visibility Ripristina visibilità iniziale
Show layer Mostra livello
Set layer visibility Imposta visibilità livello
on page {label} a pagina {label}
@@ -2320,6 +2322,7 @@ key Space Spazio
Printer paper and scaling may differ. Check driver settings. Carta e scala della stampante possono differire. Controlla le impostazioni del driver.
Drawing scale changes. Use Actual size for full-scale plans. La scala del disegno cambia. Usa Dimensioni effettive per le planimetrie in scala.
Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco
+ Always use my default zoom and page layout Usa sempre il mio ingrandimento e layout di pagina predefiniti
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Questo file è cambiato sul disco. Ricarica mostra la nuova versione e scarta le modifiche non salvate; Salva la sostituisce con questa versione.
Reload Ricarica
Couldn't reload {name}: {e} Impossibile ricaricare {name}: {e}
@@ -2693,3 +2696,6 @@ certificate SHA-256 digest Impronta SHA-256
Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
+ Thumbnail size Dimensione miniature
+ Smaller thumbnails Miniature più piccole
+ Larger thumbnails Miniature più grandi
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index f73e753de..67e92ebbb 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -1363,6 +1363,8 @@
This document has no layers. この文書にレイヤーはありません。
This document has no attachments. この文書に添付ファイルはありません。
Hide layer レイヤーを非表示
+ Layer options レイヤーのオプション
+ Reset to Initial Visibility 初期表示状態にリセット
Show layer レイヤーを表示
Set layer visibility レイヤーの表示を設定
on page {label} ページ {label}
@@ -2311,6 +2313,7 @@ key Space スペース
Printer paper and scaling may differ. Check driver settings. プリンターの用紙や倍率は異なる場合があります。ドライバーの設定を確認してください。
Drawing scale changes. Use Actual size for full-scale plans. 図面の縮尺が変わります。原寸で印刷するには「実際のサイズ」を使用してください。
Reload documents when their files change on disk ディスク上のファイルが変更されたら文書を再読み込みする
+ Always use my default zoom and page layout 常に既定のズームとページレイアウトを使用する
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. このファイルはディスク上で変更されました。「再読み込み」は新しい版を表示して未保存の変更を破棄し、「保存」はこの版で置き換えます。
Reload 再読み込み
Couldn't reload {name}: {e} {name} を再読み込みできませんでした: {e}
@@ -2481,3 +2484,6 @@ animation effects Off オフ
Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
+ Thumbnail size サムネイルのサイズ
+ Smaller thumbnails サムネイルを小さく
+ Larger thumbnails サムネイルを大きく
diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv
index 7f113d4bf..5153d5882 100644
--- a/crates/ui-egui/src/i18n/kk.tsv
+++ b/crates/ui-egui/src/i18n/kk.tsv
@@ -1305,6 +1305,8 @@
This document has no layers. Бұл құжатта қабаттар жоқ.
This document has no attachments. Бұл құжатта тіркемелер жоқ.
Hide layer Қабатты жасыру
+ Layer options Қабат параметрлері
+ Reset to Initial Visibility Бастапқы көрінуді қалпына келтіру
Show layer Қабатты көрсету
Set layer visibility Қабаттың көрінісін орнату
on page {label} {label} бетінде
@@ -2310,6 +2312,7 @@ key Space Бос орын
Printer paper and scaling may differ. Check driver settings. Принтер қағазы мен масштабы өзгеше болуы мүмкін. Драйвер параметрлерін тексеріңіз.
Drawing scale changes. Use Actual size for full-scale plans. Сызба масштабы өзгереді. Толық масштабты жоспарлар үшін Нақты өлшемді пайдаланыңыз.
Reload documents when their files change on disk Дискідегі файлдары өзгергенде құжаттарды қайта жүктеу
+ Always use my default zoom and page layout Әрқашан әдепкі масштаб пен бет орналасуын пайдалану
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Бұл файл дискіде өзгертілді. «Қайта жүктеу» жаңа нұсқаны көрсетіп, сақталмаған өзгерістеріңізді тастайды; «Сақтау» оны осы нұсқамен алмастырады.
Reload Қайта жүктеу
Couldn't reload {name}: {e} {name} қайта жүктеу мүмкін болмады: {e}
@@ -2479,3 +2482,6 @@ animation effects Off Өшірулі
Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
+ Thumbnail size Нобай өлшемі
+ Smaller thumbnails Кішірек нобайлар
+ Larger thumbnails Үлкенірек нобайлар
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index 6448c6949..bb0f8803d 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -629,6 +629,8 @@
Height: Aukštis:
Hide field highlights Slėpti laukų paryškinimą
Hide layer Slėpti sluoksnį
+ Layer options Sluoksnių parinktys
+ Reset to Initial Visibility Atkurti pradinį matomumą
Hide menu bar Slėpti meniu juostą
Hide password Slėpti slaptažodį
Hide toolbars Slėpti įrankių juostas
@@ -969,6 +971,7 @@
Redaction code: Šalinimo kodas:
Reload Įkelti iš naujo
Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske
+ Always use my default zoom and page layout Visada naudoti mano numatytąjį mastelį ir puslapio išdėstymą
Remove checkmark Pašalinti varnelę
Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles
Remove page Pašalinti puslapį
@@ -2731,3 +2734,6 @@ theme Appearance Išvaizda
Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
+ Thumbnail size Miniatiūrų dydis
+ Smaller thumbnails Mažesnės miniatiūros
+ Larger thumbnails Didesnės miniatiūros
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 578b512bb..e2239615d 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -1348,6 +1348,8 @@
This document has no layers. Ten dokument nie zawiera warstw.
This document has no attachments. Ten dokument nie zawiera załączników.
Hide layer Ukryj warstwę
+ Layer options Opcje warstw
+ Reset to Initial Visibility Przywróć początkową widoczność
Show layer Pokaż warstwę
Set layer visibility Ustaw widoczność warstwy
on page {label} na stronie {label}
@@ -2291,6 +2293,7 @@ key Space Spacja
These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia.
This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem.
Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku
+ Always use my default zoom and page layout Zawsze używaj mojego domyślnego powiększenia i układu stron
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ten plik zmienił się na dysku. Wczytaj ponownie pokazuje nową wersję i odrzuca niezapisane zmiany; Zapisz zastępuje ją tą wersją.
Reload Wczytaj ponownie
Couldn't reload {name}: {e} Nie można ponownie wczytać {name}: {e}
@@ -2457,3 +2460,6 @@ animation effects Off Wyłączone
Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
+ Thumbnail size Rozmiar miniatur
+ Smaller thumbnails Mniejsze miniatury
+ Larger thumbnails Większe miniatury
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index ee12fe7ab..6ead2e722 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -1248,6 +1248,8 @@
This document has no layers. Este documento não tem camadas.
This document has no attachments. Este documento não tem anexos.
Hide layer Ocultar camada
+ Layer options Opções de camadas
+ Reset to Initial Visibility Redefinir visibilidade inicial
Show layer Mostrar camada
on page {label} na página {label}
Save attachment… Salvar anexo…
@@ -2313,6 +2315,7 @@ key Space Espaço
Printer paper and scaling may differ. Check driver settings. O papel e a escala da impressora podem variar. Confira as configurações do driver.
Drawing scale changes. Use Actual size for full-scale plans. A escala do desenho muda. Use Tamanho real para plantas em escala.
Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco
+ Always use my default zoom and page layout Sempre usar meu zoom e layout da página padrão
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este arquivo foi alterado no disco. Recarregar mostra a nova versão e descarta suas alterações não salvas; Salvar a substitui por esta versão.
Reload Recarregar
Couldn't reload {name}: {e} Não foi possível recarregar {name}: {e}
@@ -2495,3 +2498,6 @@ animation effects Off Desativados
Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
+ Thumbnail size Tamanho das miniaturas
+ Smaller thumbnails Miniaturas menores
+ Larger thumbnails Miniaturas maiores
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 3baec4560..4d5a12325 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -1363,6 +1363,8 @@
This document has no layers. В этом документе нет слоёв.
This document has no attachments. В этом документе нет вложений.
Hide layer Скрыть слой
+ Layer options Параметры слоёв
+ Reset to Initial Visibility Восстановить исходную видимость
Show layer Показать слой
Set layer visibility Задать видимость слоя
on page {label} на стр. {label}
@@ -2101,6 +2103,7 @@
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами.
Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft
Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске
+ Always use my default zoom and page layout Всегда использовать мой масштаб и макет страницы по умолчанию
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Этот файл был изменён на диске. «Перезагрузить» покажет новую версию и отбросит несохранённые изменения; «Сохранить» заменит её этой версией.
Reload Перезагрузить
Couldn't reload {name}: {e} Не удалось перезагрузить {name}: {e}
@@ -2394,3 +2397,6 @@ animation effects Off Выкл
Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
+ Thumbnail size Размер миниатюр
+ Smaller thumbnails Уменьшить миниатюры
+ Larger thumbnails Увеличить миниатюры
diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv
index 5ba9dc90c..d8c8512af 100644
--- a/crates/ui-egui/src/i18n/sl.tsv
+++ b/crates/ui-egui/src/i18n/sl.tsv
@@ -902,6 +902,8 @@
Hide all comments Skrij vse komentarje
Hide field highlights Skrij osvetlitev polj
Hide layer Skrij plast
+ Layer options Možnosti plasti
+ Reset to Initial Visibility Ponastavi na začetno vidnost
Hide menu bar Skrij menijsko vrstico
Hide password Skrij geslo
Hide toolbars Skrij orodne vrstice
@@ -2317,6 +2319,7 @@ key Space Preslednica
Printer paper and scaling may differ. Check driver settings. Papir in merilo tiskalnika se lahko razlikujeta. Preverite nastavitve gonilnika.
Drawing scale changes. Use Actual size for full-scale plans. Merilo risbe se spremeni. Za načrte v polnem merilu uporabite Dejansko velikost.
Reload documents when their files change on disk Znova naloži dokumente, ko se njihove datoteke na disku spremenijo
+ Always use my default zoom and page layout Vedno uporabi moje privzeto povečanje in postavitev strani
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ta datoteka se je na disku spremenila. Ponovno naloži pokaže novo različico in zavrže neshranjene spremembe; Shrani jo zamenja s to različico.
Reload Znova naloži
Couldn't reload {name}: {e} Datoteke {name} ni bilo mogoče znova naložiti: {e}
@@ -2486,3 +2489,6 @@ animation effects Off Izklopljeni
Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
+ Thumbnail size Velikost sličic
+ Smaller thumbnails Manjše sličice
+ Larger thumbnails Večje sličice
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index e0d6a2b34..d82bc504b 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -1333,6 +1333,8 @@ signature pad Type టైప్ చేయి
This document has no layers. ఈ పత్రంలో లేయర్లు లేవు.
This document has no attachments. ఈ పత్రంలో జోడింపులు లేవు.
Hide layer లేయర్ను దాచు
+ Layer options లేయర్ ఎంపికలు
+ Reset to Initial Visibility ప్రారంభ దృశ్యతకు రీసెట్ చేయి
Show layer లేయర్ను చూపించు
on page {label} పేజీ {label}లో
Save attachment… జోడింపును సేవ్ చేయి…
@@ -2020,6 +2022,7 @@ action wizard Start ప్రారంభించు
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్ను గానీ జూమ్ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు.
Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్లను మళ్లీ తెరువు
Reload documents when their files change on disk డిస్క్లో వాటి ఫైల్లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి
+ Always use my default zoom and page layout ఎల్లప్పుడూ నా డిఫాల్ట్ జూమ్ మరియు పేజీ లేఅవుట్ను ఉపయోగించు
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. ఈ ఫైల్ డిస్క్లో మారింది. రీలోడ్ కొత్త వెర్షన్ను చూపించి సేవ్ చేయని మార్పులను వదిలేస్తుంది; సేవ్ దాని స్థానంలో ఈ వెర్షన్ను ఉంచుతుంది.
Reload మళ్లీ లోడ్ చేయి
Couldn't reload {name}: {e} {name}ను మళ్లీ లోడ్ చేయడం సాధ్యం కాలేదు: {e}
@@ -2390,3 +2393,6 @@ animation effects Off ఆఫ్
Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
+ Thumbnail size థంబ్నెయిల్ పరిమాణం
+ Smaller thumbnails చిన్న థంబ్నెయిల్లు
+ Larger thumbnails పెద్ద థంబ్నెయిల్లు
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 48d34a30d..4fc064b7f 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -1320,6 +1320,8 @@
This document has no layers. Bu belgede katman yok.
This document has no attachments. Bu belgede ek yok.
Hide layer Katmanı gizle
+ Layer options Katman seçenekleri
+ Reset to Initial Visibility Başlangıç görünürlüğüne sıfırla
Show layer Katmanı göster
on page {label} sayfa {label} üzerinde
Save attachment… Eki kaydet…
@@ -2308,6 +2310,7 @@ key Space Boşluk
Printer paper and scaling may differ. Check driver settings. Yazıcı kâğıdı ve ölçekleme farklı olabilir. Sürücü ayarlarını kontrol edin.
Drawing scale changes. Use Actual size for full-scale plans. Çizim ölçeği değişir. Tam ölçekli planlar için Gerçek boyutu kullanın.
Reload documents when their files change on disk Diskteki dosyaları değiştiğinde belgeleri yeniden yükle
+ Always use my default zoom and page layout Her zaman varsayılan yakınlaştırmamı ve sayfa düzenimi kullan
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Bu dosya diskte değişti. Yeniden yükle yeni sürümü gösterir ve kaydedilmemiş değişikliklerinizi atar; Kaydet onu bu sürümle değiştirir.
Reload Yeniden yükle
Couldn't reload {name}: {e} {name} yeniden yüklenemedi: {e}
@@ -2477,3 +2480,6 @@ animation effects Off Kapalı
Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
+ Thumbnail size Küçük resim boyutu
+ Smaller thumbnails Küçük resimleri küçült
+ Larger thumbnails Küçük resimleri büyüt
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 246a4a2c5..3231a39b9 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -1360,6 +1360,8 @@
This document has no layers. Цей документ не має шарів.
This document has no attachments. Цей документ не має вкладень.
Hide layer Приховати шар
+ Layer options Параметри шарів
+ Reset to Initial Visibility Відновити початкову видимість
Show layer Показати шар
Set layer visibility Задати видимість шару
on page {label} на сторінці {label}
@@ -2333,6 +2335,7 @@ key Space Пробіл
Printer paper and scaling may differ. Check driver settings. Папір і масштаб принтера можуть відрізнятися. Перевірте налаштування драйвера.
Drawing scale changes. Use Actual size for full-scale plans. Масштаб креслення змінюється. Для друку в початковому масштабі виберіть Фактичний розмір.
Reload documents when their files change on disk Перезавантажувати документи, коли їхні файли змінюються на диску
+ Always use my default zoom and page layout Завжди використовувати мій типовий масштаб і макет сторінок
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Цей файл змінено на диску. «Перезавантажити» покаже нову версію й відкине незбережені зміни; «Зберегти» замінить її цією версією.
Reload Перезавантажити
Couldn't reload {name}: {e} Не вдалося перезавантажити {name}: {e}
@@ -2503,3 +2506,6 @@ animation effects Off Вимкнено
Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
+ Thumbnail size Розмір мініатюр
+ Smaller thumbnails Зменшити мініатюри
+ Larger thumbnails Збільшити мініатюри
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index 56caf3885..0e43b1b7c 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -625,6 +625,8 @@ theme Appearance Giao diện
Height: Chiều cao:
Hide field highlights Ẩn tô sáng trường
Hide layer Ẩn lớp
+ Layer options Tùy chọn lớp
+ Reset to Initial Visibility Đặt lại khả năng hiển thị ban đầu
Hide menu bar Ẩn thanh trình đơn
Hide password Ẩn mật khẩu
Hide toolbars Ẩn thanh công cụ
@@ -964,6 +966,7 @@ theme Appearance Giao diện
Redaction code: Mã xóa nội dung nhạy cảm:
Reload Tải lại
Reload documents when their files change on disk Tải lại tài liệu khi tệp của chúng thay đổi trên đĩa
+ Always use my default zoom and page layout Luôn dùng thu phóng và bố cục trang mặc định của tôi
Remove checkmark Bỏ dấu kiểm
Remove invalid links and bookmarks Gỡ các liên kết và dấu trang không hợp lệ
Remove page Gỡ trang
@@ -2733,3 +2736,6 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
+ Thumbnail size Kích thước hình thu nhỏ
+ Smaller thumbnails Giảm kích thước hình thu nhỏ
+ Larger thumbnails Tăng kích thước hình thu nhỏ
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 645c0ab30..a37d2e7d9 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -1311,6 +1311,8 @@
This document has no layers. 本文档没有图层。
This document has no attachments. 本文档没有附件。
Hide layer 隐藏图层
+ Layer options 图层选项
+ Reset to Initial Visibility 重置为初始可见性
Show layer 显示图层
Set layer visibility 设置图层可见性
on page {label} 第 {label} 页
@@ -2135,6 +2137,7 @@
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。
Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件
Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档
+ Always use my default zoom and page layout 始终使用我的默认缩放和页面布局
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此文件已在磁盘上更改。“重新加载”显示新版本并放弃未保存的更改;“保存”则用此版本替换它。
Reload 重新加载
Couldn't reload {name}: {e} 无法重新加载{name}:{e}
@@ -2445,3 +2448,6 @@ animation effects Off 关
Paragraph spacing 段落间距
Less 收起
Other options 其他选项
+ Thumbnail size 缩略图大小
+ Smaller thumbnails 缩小缩略图
+ Larger thumbnails 放大缩略图
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 712f0666a..3f2629b81 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -1255,6 +1255,8 @@
This document has no layers. 本文件沒有圖層。
This document has no attachments. 本文件沒有附件。
Hide layer 隱藏圖層
+ Layer options 圖層選項
+ Reset to Initial Visibility 重設為初始可見性
Show layer 顯示圖層
Set layer visibility 設定圖層可見性
on page {label} 第 {label} 頁
@@ -2185,6 +2187,7 @@ key Space 空格
Tools 工具
Inspector 檢查器
Reload documents when their files change on disk 檔案在磁碟上變更時重新載入對應的文件
+ Always use my default zoom and page layout 一律使用我的預設縮放與頁面佈局
This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. 此檔案已在磁碟上變更。「重新載入」會顯示新版本並捨棄未儲存的變更;「儲存」則會以此版本取代它。
Reload 重新載入
Couldn't reload {name}: {e} 無法重新載入{name}:{e}
@@ -2431,3 +2434,6 @@ animation effects Off 關
Paragraph spacing 段落間距
Less 收起
Other options 其他選項
+ Thumbnail size 縮圖大小
+ Smaller thumbnails 縮小縮圖
+ Larger thumbnails 放大縮圖
diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs
index df461fc6e..f03173a3f 100644
--- a/crates/ui-egui/src/js_ui.rs
+++ b/crates/ui-egui/src/js_ui.rs
@@ -368,6 +368,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
.small()
.color(t.text_muted),
);
+ ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout")));
ui.horizontal(|ui| {
ui.label(tl!("Units"));
for u in crate::units::Unit::ALL {
diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs
index bf64ea7ab..7bc4833c0 100644
--- a/crates/ui-egui/src/last_session.rs
+++ b/crates/ui-egui/src/last_session.rs
@@ -52,7 +52,7 @@ impl LastSession {
/// `path` made absolute against the current directory, without resolving links (and without
/// Windows' `\\?\` prefix, which `canonicalize` adds).
#[cfg(not(target_arch = "wasm32"))]
-fn absolute(path: &str) -> String {
+pub(crate) fn absolute(path: &str) -> String {
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index bfc9e00d6..7b228e3ec 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -6,6 +6,8 @@
//! `pdfcraft-engine`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+// The settings object in `persist` is one `json!` literal past the default limit of 128.
+#![recursion_limit = "256"]
/// Whether `a` and `b` name the same file: equal, or equal once made absolute against the
/// current directory (links are not resolved, as in `last_session`).
@@ -525,6 +527,8 @@ pub struct PdfCraftApp {
pub quick_tool: QuickTool,
/// The tool to go back to when Space, held for a temporary Hand, is released.
space_hand: Option,
+ /// The tool before full screen, given back on leaving it (see [`Self::apply_full_screen`]).
+ full_screen_tool: Option,
/// Comment author, per-tool colours and widths, pin.
pub comment_prefs: comments::CommentPrefs,
/// Resolved colours, including the current OS theme when following the system.
@@ -580,6 +584,9 @@ pub struct PdfCraftApp {
quit_session: Option,
/// Preferences: reload a document when its file changes on disk (#431).
pub reload_changed_files: bool,
+ /// Preferences: open every document at Default zoom and Default page display, whatever
+ /// layout or zoom it asks for (#1618).
+ pub always_default_view: bool,
/// Open documents' files, watched for changes on disk.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) watch: file_watch::FileWatch,
@@ -626,6 +633,8 @@ pub struct PdfCraftApp {
pub host_dirty: Option,
/// A pending "save changes?" question (closing a dirty tab or quitting).
pub close_request: Option,
+ /// The button of the "save changes?" question the keyboard has chosen; Save when it opens.
+ pub(crate) save_prompt_button: dialogs::PromptButton,
/// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check).
#[cfg(not(target_arch = "wasm32"))]
pub pending_overwrite: Option,
@@ -898,6 +907,23 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> {
Some(out)
}
+/// What [`PdfCraftApp::open_path`] did: opened the file in a new tab, focused the tab already
+/// showing it, handed it to the background open worker, or failed. Failures tell the user
+/// through a toast; this is the programmatic contract for callers that must know (the control
+/// channel, tools that wait for a file) (#1620).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OpenOutcome {
+ /// The file opened in a new tab (or raised its password prompt).
+ Opened,
+ /// A tab already showed the file; it was focused and no tab was added.
+ Focused,
+ /// The file was accepted for opening in the background; its tab appears when the open
+ /// completes (`ui.state.pending_opens` lists the files on the way).
+ Pending,
+ /// The file could not be read or opened; the user was told.
+ Failed,
+}
+
impl PdfCraftApp {
pub fn new() -> Self {
Self {
@@ -921,6 +947,7 @@ impl PdfCraftApp {
right_panel_closed: false,
quick_tool: QuickTool::Select,
space_hand: None,
+ full_screen_tool: None,
comment_prefs: Default::default(),
theme: ThemeKind::Light,
theme_preference: ThemePreference::Light,
@@ -951,6 +978,7 @@ impl PdfCraftApp {
last_session: Default::default(),
quit_session: None,
reload_changed_files: true,
+ always_default_view: false,
#[cfg(not(target_arch = "wasm32"))]
watch: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
@@ -979,6 +1007,7 @@ impl PdfCraftApp {
host_save: None,
host_dirty: None,
close_request: None,
+ save_prompt_button: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
pending_overwrite: None,
save_override: None,
@@ -1106,11 +1135,13 @@ impl PdfCraftApp {
}
/// Open a document the way it asks to be opened: navigation panel, layout, magnification,
- /// page (Document Properties ▸ Initial View).
+ /// page (Document Properties ▸ Initial View). With Always use my default zoom and page
+ /// layout on, the layout and magnification stay at the defaults the view opened with.
fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) {
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len());
- if self.active == Some(index) {
+ // A right panel the user closed stays closed, whatever the document asks for (#2884).
+ if self.active == Some(index) && !self.right_panel_closed {
match v.navigation {
N::PageOnly => {}
N::Bookmarks => self.right = Some(RightPanel::Bookmarks),
@@ -1120,29 +1151,31 @@ impl PdfCraftApp {
}
}
let view = &mut self.views[index];
- match v.layout {
- // The view opened in Default page display already.
- L::Default => {}
- L::SinglePage => (view.layout, view.continuous) = (canvas::PageLayout::Single, false),
- L::SinglePageContinuous => (view.layout, view.continuous) = (canvas::PageLayout::Single, true),
- L::TwoUp => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, false),
- L::TwoUpContinuous => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, true),
- L::TwoUpCoverPage => {
- (view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, false, true);
+ if !self.always_default_view {
+ match v.layout {
+ // The view opened in Default page display already.
+ L::Default => {}
+ L::SinglePage => (view.layout, view.continuous) = (canvas::PageLayout::Single, false),
+ L::SinglePageContinuous => (view.layout, view.continuous) = (canvas::PageLayout::Single, true),
+ L::TwoUp => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, false),
+ L::TwoUpContinuous => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, true),
+ L::TwoUpCoverPage => {
+ (view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, false, true);
+ }
+ L::TwoUpContinuousCoverPage => {
+ (view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, true, true);
+ }
}
- L::TwoUpContinuousCoverPage => {
- (view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, true, true);
+ match v.magnification {
+ // The view opened at the default zoom already.
+ M::Default => {}
+ M::ActualSize => view.set_zoom(1.0),
+ M::Percent(p) => view.set_zoom((p / 100.0) as f32),
+ M::FitPage | M::FitVisible => view.fit = canvas::Fit::Page,
+ M::FitWidth => view.fit = canvas::Fit::Width,
+ M::FitHeight => view.fit = canvas::Fit::Height,
}
}
- match v.magnification {
- // The view opened at the default zoom already.
- M::Default => {}
- M::ActualSize => view.set_zoom(1.0),
- M::Percent(p) => view.set_zoom((p / 100.0) as f32),
- M::FitPage | M::FitVisible => view.fit = canvas::Fit::Page,
- M::FitWidth => view.fit = canvas::Fit::Width,
- M::FitHeight => view.fit = canvas::Fit::Height,
- }
if v.page > 0 && v.page < pages {
view.go_to_page(v.page);
}
@@ -1354,11 +1387,18 @@ impl PdfCraftApp {
};
}
let initial = doc.initial_view();
- self.views.push(DocView::new(id, &doc.info, self.view_defaults));
+ let view = DocView::new(id, &doc.info, self.view_defaults);
+ self.views.push(view);
if activate {
self.active = Some(self.views.len() - 1);
}
self.apply_initial_view(self.views.len() - 1, &initial);
+ // After the document's own initial view, so leaving full screen comes back to it.
+ if self.full_screen
+ && let Some(view) = self.views.last_mut()
+ {
+ view.enter_presentation();
+ }
// Full screen is asked for, never taken: a document can't put the app in full screen on
// its own (the prompt is `dialogs::full_screen_prompt`).
if activate && initial.full_screen {
@@ -1598,10 +1638,32 @@ impl PdfCraftApp {
/// Enter or leave full-screen reading (Acrobat: View ▸ Full Screen Mode, ⌘L).
pub fn set_full_screen(&mut self, ctx: &egui::Context, on: bool) {
- self.full_screen = on;
+ self.apply_full_screen(on);
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
}
+ /// The full-screen state without the window: every document shows one page (or spread) at a
+ /// time fitted to the screen while it is on (Acrobat's Full Screen Mode, #379, #1459), and
+ /// gets its display back when it is off.
+ pub fn apply_full_screen(&mut self, on: bool) {
+ // A click turns the page only with the Select tool, and full screen hides the tools:
+ // select it there, and give the tool back on leaving (review on #2070).
+ if on && !self.full_screen {
+ self.full_screen_tool = Some(self.quick_tool);
+ self.quick_tool = QuickTool::Select;
+ } else if !on && let Some(tool) = self.full_screen_tool.take() {
+ self.quick_tool = tool;
+ }
+ self.full_screen = on;
+ for view in &mut self.views {
+ if on {
+ view.enter_presentation();
+ } else {
+ view.leave_presentation();
+ }
+ }
+ }
+
/// Answer the Full Screen mode prompt a document raised on opening: enter it, or leave the
/// window as it is.
pub fn resolve_full_screen_prompt(&mut self, ctx: &egui::Context, allow: bool) {
@@ -1723,6 +1785,8 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
self.open_path(path);
}
+ #[cfg(target_arch = "wasm32")]
+ let _ = path;
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1807,24 +1871,27 @@ impl PdfCraftApp {
}
#[cfg(not(target_arch = "wasm32"))]
- pub fn open_path(&mut self, path: &str) {
- self.open_path_then(path, None);
+ pub fn open_path(&mut self, path: &str) -> OpenOutcome {
+ self.open_path_then(path, None)
}
- /// Keep a chosen tool with its file until preparation and any password entry finish.
+ /// Keep a chosen tool with its file until preparation and any password entry finish. The
+ /// return value is the programmatic open contract (#1620): callers that must know whether
+ /// the file opened, focused its tab, was handed to the background worker, or failed.
#[cfg(not(target_arch = "wasm32"))]
- fn open_path_then(&mut self, path: &str, then: Option<&'static str>) {
+ fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
// One file, one tab (#2166): a file that is open already comes to the front instead,
// and the tool it was opened for runs on it. One still being opened is left to that.
if let Some(i) = self.tab_showing(path) {
+ self.startup_superseded = true;
self.active = Some(i);
if let Some(command) = then {
self.execute(command);
}
- return;
+ return OpenOutcome::Focused;
}
if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
- return;
+ return OpenOutcome::Pending;
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
@@ -1838,7 +1905,10 @@ impl PdfCraftApp {
let before = self.views.len();
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
- } else if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
+ return OpenOutcome::Failed;
+ }
+ if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
+ // Encrypted: the tool runs once the password is in.
prompt.then = then;
} else if self.views.len() > before {
if let Some(doc) = self.views.last().map(|view| view.id) {
@@ -1847,25 +1917,37 @@ impl PdfCraftApp {
if let Some(command) = then {
self.execute(command);
}
+ } else {
+ // Open reported success but added no tab and raised no prompt; the
+ // control channel answers this as a failure to open.
+ return OpenOutcome::Failed;
}
+ OpenOutcome::Opened
+ }
+ Err(error) => {
+ self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]);
+ OpenOutcome::Failed
+ }
+ }
+ } else {
+ self.startup_superseded = true;
+ match self.schedule_open(OpenRequest {
+ name: name.clone(),
+ path: Some(path.to_string()),
+ stamp,
+ input: open_worker::Input::Path(std::path::PathBuf::from(path)),
+ password: None,
+ activate: true,
+ after_open: None,
+ recovered: None,
+ then,
+ }) {
+ Ok(_) => OpenOutcome::Pending,
+ Err(error) => {
+ self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
+ OpenOutcome::Failed
}
- Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]),
}
- return;
- }
- self.startup_superseded = true;
- if let Err(error) = self.schedule_open(OpenRequest {
- name: name.clone(),
- path: Some(path.to_string()),
- stamp,
- input: open_worker::Input::Path(std::path::PathBuf::from(path)),
- password: None,
- activate: true,
- after_open: None,
- recovered: None,
- then,
- }) {
- self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
}
}
@@ -2286,6 +2368,7 @@ impl PdfCraftApp {
"default_mode": self.default_mode,
"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,
"highlight_fields": self.view_defaults.highlight_fields,
"units": self.units,
"language": self.language,
@@ -2395,6 +2478,9 @@ impl PdfCraftApp {
if let Ok(units) = serde_json::from_value::(v["units"].clone()) {
self.units = units;
}
+ if let Some(on) = v["always_default_view"].as_bool() {
+ self.always_default_view = on;
+ }
if let Some(on) = v["highlight_fields"].as_bool() {
self.view_defaults.highlight_fields = on;
}
@@ -2651,6 +2737,13 @@ impl PdfCraftApp {
self.view_defaults =
self.view_defaults.with_zoom(value).ok_or("default-zoom must be fit-width, fit-page or a percentage from 8 to 6400")?;
}
+ ("always-default-view", _) => {
+ self.always_default_view = match value {
+ "on" => true,
+ "off" => false,
+ _ => return Err("always-default-view must be on or off".into()),
+ };
+ }
("tools-side", _) => self.docking.set_tools_on_right(match value {
"left" => false,
"right" => true,
@@ -2663,6 +2756,14 @@ impl PdfCraftApp {
v.open_organize();
}
}
+ // Inspector thumbnail size, in percent, independent of document and grid zoom.
+ ("thumbnail-zoom", Some(v)) => {
+ let percent = value.trim_end_matches('%').parse::().map_err(|e| e.to_string())?;
+ if !percent.is_finite() || !canvas::THUMBNAIL_ZOOM_RANGE.contains(&(percent / 100.0)) {
+ return Err("thumbnail-zoom must be between 50 and 150".into());
+ }
+ v.set_thumbnail_zoom(percent / 100.0);
+ }
// `--grid-zoom 150`: the size of the pages in the organize grid, in percent.
("grid-zoom", Some(v)) => {
let percent = value.trim_end_matches('%').parse::().map_err(|e| e.to_string())?;
@@ -2804,6 +2905,7 @@ impl PdfCraftApp {
"cover",
"organize",
"grid-zoom",
+ "thumbnail-zoom",
"fields",
"find",
"rotate",
@@ -2841,7 +2943,7 @@ impl PdfCraftApp {
|| self.updates.open
|| self.dialog.is_some()
|| self.unlock_prompt.is_some();
- if !covered && let Some(choice) = dialogs::save_prompt_key(ctx) {
+ if !covered && let Some(choice) = dialogs::save_prompt_key(ctx, &mut self.save_prompt_button) {
self.resolve_close(ctx, choice);
}
return;
@@ -3088,7 +3190,15 @@ impl eframe::App for PdfCraftApp {
for e in os_events {
match e {
#[cfg(not(target_arch = "wasm32"))]
- OsEvent::Open(paths) => paths.iter().for_each(|p| self.open_path(p)),
+ OsEvent::Open(paths) => {
+ paths.iter().for_each(|p| {
+ self.open_path(p);
+ });
+ // Files handed to the running app (Finder, or another launch): bring its
+ // window to the front, also when minimized or full screen on another Space.
+ ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
+ ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
+ }
#[cfg(target_arch = "wasm32")]
OsEvent::Open(_) => {}
// Like closing the window: `guard_quit` asks about unsaved changes.
@@ -3172,19 +3282,16 @@ impl eframe::App for PdfCraftApp {
self.window_title = title;
}
if self.full_screen && self.active.is_some() {
- // Full screen: the page, nothing else (Esc or ⌘L to leave).
- let t = theme::Tokens::get(&ctx);
- egui::CentralPanel::default().frame(egui::Frame::NONE.fill(if t.dark() { t.pasteboard } else { egui::Color32::from_gray(32) })).show(
- ui,
- |ui| {
- if let Some(i) = self.active {
- match compare_ui::side_by_side(self, i) {
- Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
- None => canvas::document_area(self, i, ui),
- }
+ // Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc
+ // or ⌘L to leave).
+ egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| {
+ if let Some(i) = self.active {
+ match compare_ui::side_by_side(self, i) {
+ Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
+ None => canvas::document_area(self, i, ui),
}
- },
- );
+ }
+ });
dialogs::show(self, &ctx);
// Notices too: a refused field value or a failed save must be seen in full screen.
self.show_progress(&ctx);
diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs
index ebdd45054..af847b9a2 100644
--- a/crates/ui-egui/src/open_worker_tests.rs
+++ b/crates/ui-egui/src/open_worker_tests.rs
@@ -670,3 +670,35 @@ fn startup_files_wait_for_absolute_link_consent_and_preparation() {
assert_eq!(app.active, Some(1), "the user's approved navigation keeps focus");
let _ = std::fs::remove_file(path);
}
+
+/// #1928: a new file handed over by the OS (Finder, or another launch) raises the window at once,
+/// before it is prepared, and is the selected tab once it opens.
+#[test]
+#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
+fn a_new_file_from_the_os_brings_the_window_forward_before_it_is_prepared() {
+ let (mut harness, _client) = harness();
+ let (gate, started, release, finished) = open_worker::prepare_gate();
+ harness.state_mut().open_prepare_gate = Some(gate);
+ let path = std::env::temp_dir().join(format!("pdfcraft-os-open-worker-{}.pdf", std::process::id()));
+ std::fs::write(&path, fixture(2)).expect("write the handed-over PDF");
+ let mut queue = vec![crate::OsEvent::Open(vec![path.to_string_lossy().into_owned()])];
+ harness.state_mut().os_events = Some(Box::new(move || std::mem::take(&mut queue)));
+ harness.step();
+ started.recv_timeout(Duration::from_secs(5)).expect("the handed-over PDF reached preparation");
+ let commands: Vec<_> = harness.output().viewport_output.values().flat_map(|v| v.commands.clone()).collect();
+ let visible = harness.state().views.len();
+ // Always release before asserting: a failure must not strand the worker at the barrier.
+ release.send(()).expect("release the handed-over PDF");
+ finished.recv_timeout(Duration::from_secs(5)).expect("the handed-over PDF prepared");
+ assert!(commands.contains(&egui::ViewportCommand::Minimized(false)), "{commands:?}");
+ assert!(commands.contains(&egui::ViewportCommand::Focus), "{commands:?}");
+ assert_eq!(visible, 1, "the window comes forward while the file is still preparing");
+ harness.step();
+ let app = harness.state();
+ assert_eq!(app.views.len(), 2);
+ let active = app.active.expect("a selected document");
+ let doc = app.session.get(app.views[active].id).expect("the handed-over document");
+ assert_eq!(doc.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the new file is the selected tab");
+ assert_eq!(doc.info.pages.len(), 2);
+ let _ = std::fs::remove_file(path);
+}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 38d5ce557..2b87fce42 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -653,6 +653,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut nav: Option = None;
let mut close = false;
let mut toggle_layer: Option<(usize, bool)> = None;
+ let mut reset_layers = false;
let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save)
let mut bm_action: Option = None;
let mut bm_expand: Option = None;
@@ -745,6 +746,16 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
});
}
+ if panel == RightPanel::Layers && !info.layers.is_empty() {
+ let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Layer options"));
+ egui::Popup::menu(&more).show(|ui| {
+ ui.set_min_width(200.0);
+ if ui.button(tl!("Reset to Initial Visibility")).clicked() {
+ reset_layers = true;
+ ui.close();
+ }
+ });
+ }
let new_tip = crate::commands::command_tip(ui.ctx(), tl!("New bookmark ({key})"), "bookmark.add");
if panel == RightPanel::Bookmarks && bm_editable && icons::button(ui, "bookmark-plus", 26.0, false, &new_tip).clicked() {
bm_action = Some(BmAction::New);
@@ -786,6 +797,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ancestor.pop();
}
}
+ if panel == RightPanel::Pages {
+ let enabled = !modal && view.panel_drag.is_none();
+ thumbnail_size(ui, &t, view, enabled);
+ }
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
RightPanel::Comments => {
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
@@ -964,6 +979,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
{
app.views[index].invalidate_content();
}
+ if reset_layers && app.session.reset_layers(id) {
+ app.views[index].invalidate_content();
+ }
match nav {
Some(Nav::Page(p)) => app.views[index].go_to_page(p),
Some(Nav::Dest(p, dest)) => match app.session.get(id) {
@@ -1187,6 +1205,57 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
}
}
+/// Size controls stay above the scroll area, so they remain reachable on long documents.
+fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) {
+ let before = view.thumbnail_zoom();
+ let mut zoom = before;
+ let label = ui
+ .horizontal(|ui| {
+ let label = ui.label(egui::RichText::new(tl!("Thumbnail size")).small().color(t.text_muted));
+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+ ui.label(egui::RichText::new(format!("{:.0}%", before * 100.0)).small().color(t.text_muted));
+ });
+ label
+ })
+ .inner;
+ ui.add_enabled_ui(enabled, |ui| {
+ ui.spacing_mut().slider_width = (ui.available_width() - 52.0 - 2.0 * ui.spacing().item_spacing.x).max(1.0);
+ ui.horizontal(|ui| {
+ if ui
+ .add_enabled_ui(zoom > *crate::canvas::THUMBNAIL_ZOOM_RANGE.start(), |ui| {
+ icons::button(ui, "zoom-out", 26.0, false, tl!("Smaller thumbnails"))
+ })
+ .inner
+ .clicked()
+ {
+ zoom -= 0.25;
+ }
+ let mut percent = zoom * 100.0;
+ let range = (*crate::canvas::THUMBNAIL_ZOOM_RANGE.start() * 100.0)..=(*crate::canvas::THUMBNAIL_ZOOM_RANGE.end() * 100.0);
+ ui.scope(|ui| {
+ // The regular field fill blends into this panel; keep the slider's full rail visible.
+ ui.visuals_mut().widgets.inactive.bg_fill = t.border;
+ let slider = ui.add(egui::Slider::new(&mut percent, range).step_by(1.0).show_value(false).trailing_fill(true)).labelled_by(label.id);
+ slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Thumbnail size")));
+ });
+ zoom = percent / 100.0;
+ if ui
+ .add_enabled_ui(zoom < *crate::canvas::THUMBNAIL_ZOOM_RANGE.end(), |ui| {
+ icons::button(ui, "zoom-in", 26.0, false, tl!("Larger thumbnails"))
+ })
+ .inner
+ .clicked()
+ {
+ zoom += 0.25;
+ }
+ });
+ });
+ if zoom != before {
+ view.set_thumbnail_zoom(zoom);
+ }
+ ui.add_space(6.0);
+}
+
/// The page thumbnails. A click goes to the page; ⌘/Ctrl-click and ⇧-click pick several pages
/// (the selection page commands and Print act on) without moving the document. Right-click
/// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging
@@ -1215,7 +1284,9 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
// scroll bar dragged by the user already wins over a scroll to a place in egui.
let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none();
view.panel_followed = Some(view.current);
- let w = (ui.available_width() - 40.0).min(150.0);
+ let available = (ui.available_width() - 16.0).max(1.0);
+ let usual = (ui.available_width() - 40.0).clamp(1.0, 150.0);
+ let w = (usual * view.thumbnail_zoom()).min(available);
let count = info.pages.len();
let mut rows: Vec = Vec::with_capacity(info.pages.len());
let mut dropped = false;
diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs
index 7661b7175..c499eb738 100644
--- a/crates/ui-egui/src/pickers.rs
+++ b/crates/ui-egui/src/pickers.rs
@@ -163,7 +163,9 @@ impl PdfCraftApp {
};
let ask = if multiple { Ask::Files(dialog) } else { Ask::File(dialog) };
self.ask(ask, target, move |app, paths| match pick_for {
- PickFor::Open => paths.iter().for_each(|p| app.open_path(&p.to_string_lossy())),
+ PickFor::Open => paths.iter().for_each(|p| {
+ app.open_path(&p.to_string_lossy());
+ }),
PickFor::Files(purpose) => app.use_paths(purpose, &paths),
});
}
diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs
index 3cb67bb8e..f2cf2cd09 100644
--- a/crates/ui-egui/src/sign_ui.rs
+++ b/crates/ui-egui/src/sign_ui.rs
@@ -577,6 +577,9 @@ impl PdfCraftApp {
self.session.set_reading_colors(new, self.reading_colors);
let Some(doc) = self.session.get(new) else { return };
let mut view = DocView::new(new, &doc.info, self.view_defaults);
+ if self.full_screen {
+ view.enter_presentation();
+ }
view.snapshot = Some(Snapshot { kind: SnapshotKind::Revision, revision: n, from });
self.views.push(view);
self.active = Some(self.views.len() - 1);
@@ -626,6 +629,9 @@ impl PdfCraftApp {
self.session.set_reading_colors(new, self.reading_colors);
let Some(doc) = self.session.get(new) else { return };
let mut view = DocView::new(new, &doc.info, self.view_defaults);
+ if self.full_screen {
+ view.enter_presentation();
+ }
view.snapshot = Some(Snapshot { kind: SnapshotKind::SignedVersion, revision, from });
self.views.push(view);
self.active = Some(self.views.len() - 1);
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index c39f4cf05..85af96680 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -130,6 +130,12 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
/// Rounded pill button; `primary` fills with the accent.
pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
+ pill_button_marked(ui, label, primary, false)
+}
+
+/// [`pill_button`] that, when `marked`, is the keyboard's choice: it takes egui focus (so
+/// assistive tech follows it) and shows a ring in the accent colour around the pill.
+pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::medium(12.5);
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
@@ -142,6 +148,10 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
(press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text)
};
ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside);
+ if marked {
+ resp.request_focus();
+ ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
+ }
ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text);
theme::hand(resp)
}
diff --git a/crates/ui-egui/tests/bookmark_position.rs b/crates/ui-egui/tests/bookmark_position.rs
new file mode 100644
index 000000000..03fd8b50a
--- /dev/null
+++ b/crates/ui-egui/tests/bookmark_position.rs
@@ -0,0 +1,228 @@
+//! A new bookmark goes to where the reader is (#3865): the top of the window, or the corner of
+//! the selected text, instead of the top of the page. Driven like a user would, with ⌘B.
+
+use egui::{Key, Modifiers};
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use pdfcraft_engine::Edit;
+use pdfcraft_render::DestView;
+use pdfcraft_ui_egui::PdfCraftApp;
+
+/// Three 300×400 pages, each with a line of Helvetica text at (40, 150), the first optionally
+/// turned by `rotate` (the others upright).
+fn fixture(rotate: u16) -> Vec {
+ fixture_of(&[(300, 400); 3], rotate)
+}
+
+/// Pages of the given sizes (points), each with a line of Helvetica text at (40, 150), the first
+/// turned by `rotate`.
+fn fixture_of(sizes: &[(u32, u32)], rotate: u16) -> Vec {
+ let n = sizes.len();
+ let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
+ let (content, font) = (3 + n, 4 + n);
+ let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))];
+ for (i, (w, h)) in sizes.iter().enumerate() {
+ let r = if i == 0 { format!(" /Rotate {rotate}") } else { String::new() };
+ objs.push(format!(
+ "<< /Type /Page /Parent 2 0 R{r} /MediaBox [0 0 {w} {h}] /Contents {content} 0 R /Resources << /Font << /F1 {font} 0 R >> >> >>"
+ ));
+ }
+ let body = "BT /F1 14 Tf 40 150 Td (The quick brown fox) Tj ET";
+ objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
+ objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string());
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn harness(rotate: u16) -> Harness<'static, PdfCraftApp> {
+ 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("text.pdf", None, fixture(rotate)).expect("opens");
+ app.set_option("left", "closed").unwrap();
+ app.set_option("panel", "bookmarks").unwrap();
+ app
+ });
+ h.run_steps(4);
+ settle(&mut h);
+ h
+}
+
+/// Run frames until rendering has finished and the layout holds still.
+fn settle(h: &mut Harness<'static, PdfCraftApp>) {
+ for _ in 0..200 {
+ h.run_steps(2);
+ if !h.state().render_pending() {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+}
+
+/// Make a bookmark with ⌘B, as the Bookmarks button does, and say what it goes to.
+fn new_bookmark(h: &mut Harness<'static, PdfCraftApp>) -> (Option, DestView) {
+ h.key_press_modifiers(Modifiers::COMMAND, Key::B);
+ h.run_steps(3);
+ let id = h.state().views[0].id;
+ let outline = &h.state().session.get(id).unwrap().info.outline;
+ let made = outline.iter().find(|b| b.title == "Untitled").expect("the new bookmark");
+ (made.page, made.view)
+}
+
+fn xyz(view: DestView) -> (Option, Option) {
+ match view {
+ DestView::Xyz { left, top, zoom } => {
+ assert_eq!(zoom, None, "the reader's zoom is kept");
+ (left, top)
+ }
+ other => panic!("expected /XYZ, got {other:?}"),
+ }
+}
+
+fn close(got: Option, want: f32, what: &str) {
+ let got = got.unwrap_or_else(|| panic!("{what} is set"));
+ assert!((got - want).abs() < 1.0, "{what}: {got} vs {want}");
+}
+
+#[test]
+fn a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page() {
+ let mut h = harness(0);
+ // A bookmark that scrolls page 2 to 250 pt up its height puts that line at the top of the
+ // window; a bookmark made there then goes to the same place.
+ h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Mid".into(), page: 1, left: None, top: Some(250.0) });
+ h.run_steps(3);
+ h.get_by_label("Mid").click();
+ settle(&mut h);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(1), "the page at the top of the window");
+ let (left, top) = xyz(view);
+ close(top, 250.0, "top");
+ assert_eq!(left, None, "the horizontal position is the reader's");
+}
+
+#[test]
+fn a_bookmark_made_at_the_start_goes_to_the_top_of_the_first_page() {
+ let mut h = harness(0);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(0));
+ let (left, top) = xyz(view);
+ close(top, 400.0, "top is the page's top edge");
+ assert_eq!(left, None);
+}
+
+#[test]
+fn a_bookmark_made_with_text_selected_goes_to_the_selections_corner() {
+ let mut h = harness(0);
+ // "quick brown": the selection starts after "The ", 40 pt in plus its width, on the line at 150.
+ h.state_mut().views[0].select_text(0, 4, 15);
+ h.run_steps(3);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(0), "the selection's page");
+ let (left, top) = xyz(view);
+ let left = left.expect("left is set from the selection");
+ let top = top.expect("top is set from the selection");
+ assert!((66.0..=70.0).contains(&left), "left is where 'q' starts: {left}");
+ assert!((155.0..=168.0).contains(&top), "top is above the baseline at 150: {top}");
+}
+
+#[test]
+fn a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along() {
+ // Page 1 is turned a quarter: its displayed top is a user x, so that is what is pinned.
+ let mut h = harness(90);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(0));
+ let (left, top) = xyz(view);
+ close(left, 0.0, "left is the displayed top edge");
+ assert_eq!(top, None, "the displayed horizontal position is the reader's");
+}
+
+/// Turn the view a quarter at a time (View ▸ Rotate View) and let the layout settle.
+fn rotate_view(h: &mut Harness<'static, PdfCraftApp>, degrees: u16) {
+ h.state_mut().views[0].rotation = degrees;
+ h.run_steps(3);
+ settle(h);
+}
+
+#[test]
+fn a_bookmark_made_in_a_turned_view_pins_the_axis_that_is_vertical_on_screen() {
+ // At the start of the document the window's top edge is the first page's top as displayed.
+ // Turned clockwise a quarter, that edge is the page's left; half a turn, its bottom; three
+ // quarters, its right. The other coordinate is the reader's, so it stays unset.
+ for (degrees, left, top) in [(90, Some(0.0), None), (180, None, Some(0.0)), (270, Some(300.0), None)] {
+ let mut h = harness(0);
+ rotate_view(&mut h, degrees);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(0), "{degrees}");
+ let (l, t) = xyz(view);
+ match (left, top) {
+ (Some(want), None) => {
+ close(l, want, &format!("{degrees}: left"));
+ assert_eq!(t, None, "{degrees}: top is the reader's");
+ }
+ (None, Some(want)) => {
+ close(t, want, &format!("{degrees}: top"));
+ assert_eq!(l, None, "{degrees}: left is the reader's");
+ }
+ _ => unreachable!("one axis is pinned"),
+ }
+ }
+}
+
+#[test]
+fn a_bookmark_made_with_text_selected_in_a_turned_view_goes_to_the_corner_shown_top_left() {
+ // The selection "quick brown" spans x from about 68 to 143 and y from just under the baseline
+ // at 150 to just above its ascent. Its bounds' corner at the screen's top-left is the
+ // upper-left (0), lower-left (90), lower-right (180) or upper-right (270) one in the page.
+ for (degrees, right_end, upper) in [(0, false, true), (90, false, false), (180, true, false), (270, true, true)] {
+ let mut h = harness(0);
+ rotate_view(&mut h, degrees);
+ h.state_mut().views[0].select_text(0, 4, 15);
+ h.run_steps(3);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(0), "{degrees}");
+ let (left, top) = xyz(view);
+ let (left, top) = (left.expect("left"), top.expect("top"));
+ assert_eq!(left > 105.0, right_end, "{degrees}: left {left} is the {} end", if right_end { "right" } else { "left" });
+ if upper {
+ assert!((155.0..=168.0).contains(&top), "{degrees}: top {top} is above the baseline at 150");
+ } else {
+ assert!((140.0..=152.0).contains(&top), "{degrees}: top {top} is at the bottom of the text");
+ }
+ }
+}
+
+#[test]
+fn a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_the_window() {
+ use pdfcraft_ui_egui::canvas::PageLayout;
+ // Pages 1 and 2 share a row; page 1 is shorter, so it is centred and starts below page 2's top.
+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("spread.pdf", None, fixture_of(&[(300, 200), (300, 400), (300, 400)], 0)).expect("opens");
+ app.set_option("left", "closed").unwrap();
+ app.set_option("panel", "bookmarks").unwrap();
+ app
+ });
+ h.state_mut().views[0].layout = PageLayout::TwoUp;
+ h.run_steps(4);
+ settle(&mut h);
+ // 20 pt below page 2's top is at the window's top, where page 1 has not started.
+ h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Right".into(), page: 1, left: None, top: Some(380.0) });
+ h.run_steps(3);
+ h.get_by_label("Right").click();
+ settle(&mut h);
+ let (page, view) = new_bookmark(&mut h);
+ assert_eq!(page, Some(1), "page 2 crosses the top of the window; page 1 starts below it");
+ close(xyz(view).1, 380.0, "top");
+}
diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs
index 6d94fb633..f180799a5 100644
--- a/crates/ui-egui/tests/bookmarks.rs
+++ b/crates/ui-egui/tests/bookmarks.rs
@@ -156,7 +156,7 @@ fn search_finds_collapsed_bookmarks_and_keeps_original_paths() {
for (parent, index, title, page) in
[(vec![], 0, "Unrelated", 0), (vec![], 1, "Reports", 0), (vec![1], 0, "Other chapter", 1), (vec![1], 1, "Résumé results", 2)]
{
- h.state_mut().apply_edit(Edit::AddBookmark { parent, index, title: title.into(), page });
+ h.state_mut().apply_edit(Edit::AddBookmark { parent, index, title: title.into(), page, left: None, top: None });
}
h.run_steps(3);
h.get_by_label("Bookmark options").click();
@@ -252,7 +252,7 @@ fn expanding_and_collapsing_all_bookmarks() {
use pdfcraft_engine::Edit;
let mut h = harness();
for (parent, title) in [(vec![], "Part"), (vec![0], "Chapter"), (vec![0, 0], "Section")] {
- h.state_mut().apply_edit(Edit::AddBookmark { parent, index: 0, title: title.into(), page: 0 });
+ h.state_mut().apply_edit(Edit::AddBookmark { parent, index: 0, title: title.into(), page: 0, left: None, top: None });
}
h.run_steps(3);
let shown = |h: &Harness<'static, PdfCraftApp>, t: &str| h.query_by_label(t).is_some();
diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs
index 5afb4db55..8a5f69954 100644
--- a/crates/ui-egui/tests/commands.rs
+++ b/crates/ui-egui/tests/commands.rs
@@ -218,6 +218,33 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() {
let _ = std::fs::remove_dir_all(&dir);
}
+#[test]
+fn a_tool_chosen_with_an_already_open_file_focuses_its_tab_and_still_runs() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-tool-focus-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("picked.pdf");
+ std::fs::write(&path, fixture(2)).unwrap();
+ let pick = path.to_string_lossy().into_owned();
+ let picked = pick.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();
+ // The file is already open, but the Home tab is showing (no active document).
+ app.open_path(&picked);
+ app.active = None;
+ app.pick_override = Some(vec![pick]);
+ app
+ });
+ h.run_steps(2);
+ h.state_mut().run_command("export.text");
+ h.run_steps(3);
+ let app = h.state();
+ assert_eq!(app.views.len(), 1, "the existing tab is reused, not duplicated");
+ assert_eq!(app.active, Some(0), "the pick focused the tab it already opened");
+ assert!(matches!(app.dialog, Some(Dialog::Export(_))), "the tool still runs on it: {:?}", app.dialog);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
#[test]
fn shortcuts_run_registered_commands() {
let mut h = harness();
diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs
index e735668a1..e0ba29f6d 100644
--- a/crates/ui-egui/tests/compare.rs
+++ b/crates/ui-egui/tests/compare.rs
@@ -59,6 +59,44 @@ fn compare_two_versions() {
assert_eq!(h.state().session.get(h.state().active_ids().unwrap().1).unwrap().name, "Compare Report.pdf");
}
+/// Two versions compared side by side (old `v1.pdf` left, new `v2.pdf` right).
+fn side_by_side() -> Harness<'static, PdfCraftApp> {
+ let s = Session::new();
+ let v1 = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
+ let v2 = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
+ 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("v1.pdf", None, v1.clone()).unwrap();
+ app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
+ app
+ });
+ h.run_steps(4);
+ assert!(h.state_mut().execute("doc.compare"));
+ h.run_steps(2);
+ h.get_by_label("Compare").click();
+ h.run_steps(3);
+ assert!(h.state().compare.as_ref().unwrap().side_by_side);
+ h
+}
+
+/// #3915: a half that closes its own tab while it draws (its snapshot bar's Close sets
+/// `close_requested`) must not leave the frame indexing the closed tab. Either half.
+#[test]
+fn closing_either_half_of_a_side_by_side_comparison_keeps_the_app_running() {
+ for (half, name) in [(1, "v2.pdf"), (0, "v1.pdf")] {
+ let mut h = side_by_side();
+ let c = h.state().compare.as_ref().unwrap();
+ let id = if half == 1 { c.new } else { c.old };
+ let i = h.state().views.iter().position(|v| v.id == id).unwrap();
+ h.state_mut().views[i].close_requested = true;
+ h.run_steps(3);
+ let s = h.state();
+ assert_eq!(s.views.len(), 1, "the {name} tab closed");
+ assert!(s.views.iter().all(|v| v.id != id));
+ }
+}
+
/// Writes a screenshot of the Compare panel when PDFCRAFT_SHOT is set (for review).
#[test]
fn compare_panel_screenshot() {
diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs
index 46ad08cda..79b1cdae0 100644
--- a/crates/ui-egui/tests/content.rs
+++ b/crates/ui-egui/tests/content.rs
@@ -706,3 +706,75 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
h.run_steps(2);
assert!(h.state().text_style.bold);
}
+
+/// Where a mode bar tab is, by its label.
+fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
+ h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
+}
+
+/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each
+/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign.
+fn mode_tab_cursors(
+ h: &mut Harness<'static, PdfCraftApp>,
+ what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect,
+) -> [(&'static str, egui::CursorIcon); 4] {
+ let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen");
+ let y = what(h).center().y - page(h).top();
+ let tabs = mode_tab(h, "Convert").center().y;
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ let top = page(h).top();
+ h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height()));
+ h.run_steps(4);
+ let under = what(h);
+ ["Read", "Edit", "Convert", "E-Sign"].map(|label| {
+ let at = mode_tab(h, label).center();
+ assert!(under.contains(at), "{under:?} lies under {label} at {at:?}");
+ h.hover_at(at);
+ h.run_steps(2);
+ (label, h.output().platform_output.cursor_icon)
+ })
+}
+
+const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [
+ ("Read", egui::CursorIcon::PointingHand),
+ ("Edit", egui::CursorIcon::PointingHand),
+ ("Convert", egui::CursorIcon::PointingHand),
+ ("E-Sign", egui::CursorIcon::PointingHand),
+];
+
+/// Add text, with the page scrolled up under the mode bar, leaves its tabs the hand (#2755): the
+/// tool gave them its I-beam, added text its four-way arrow, and the open editor floated over
+/// them and took their clicks.
+#[test]
+fn mode_tabs_keep_the_hand_over_added_text_scrolled_under_them() {
+ let mut h = harness();
+ assert!(h.state_mut().execute("edit.text"));
+ h.state_mut().set_option("left", "closed").unwrap();
+ // The page across the window, so text placed at its left edge reaches under the tabs.
+ h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
+ h.run_steps(4);
+ assert_eq!(h.state().quick_tool, QuickTool::AddText);
+ // The empty page, ready for text.
+ assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, [10.0, 290.0, 210.0, 305.0])), HAND_ON_EVERY_TAB, "the page under the tabs");
+ // Text kept by a click lower down, which starts a second box (#74).
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ let p = at(&h, 10.0, 40.0);
+ click(&mut h, p);
+ h.event(egui::Event::Text("Reviewed".into()));
+ h.run_steps(2);
+ let p = at(&h, 10.0, 120.0);
+ click(&mut h, p);
+ h.event(egui::Event::Text("Approved".into()));
+ h.run_steps(2);
+ assert_eq!(texts(&h), ["Reviewed"]);
+ let kept = added(&h)[0].content.rect();
+ assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, kept)), HAND_ON_EVERY_TAB, "added text under the tabs");
+ // The second box, still being typed.
+ let draft = |h: &Harness<'static, PdfCraftApp>| item_on_screen(h, h.state().views[0].content.draft.as_ref().expect("the open box").rect);
+ assert_eq!(mode_tab_cursors(&mut h, draft), HAND_ON_EVERY_TAB, "the open box under the tabs");
+ let convert = mode_tab(&h, "Convert").center();
+ click(&mut h, convert);
+ assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the box");
+}
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index 7de5c5999..e0004a6df 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -86,6 +86,31 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
}
+#[test]
+fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "pages"}));
+ ok(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "50"}));
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["active"]["thumbnail_zoom"], 0.5);
+ assert_eq!(state["active"]["grid_zoom"], 1.0);
+ assert_eq!(state["documents"][0]["dirty"], false);
+ let controls = ok(&mut h, &c, "ui.inspect", json!({"query": "thumbnails"}));
+ let widgets = controls["widgets"].as_array().unwrap();
+ assert!(widgets.iter().any(|w| w["label"] == "Larger thumbnails"));
+ assert!(widgets.iter().any(|w| w["label"] == "Smaller thumbnails" && w["enabled"] == false));
+ assert!(call(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "NaN"})).is_err());
+ ok(&mut h, &c, "ui.click", json!({"label": "Larger thumbnails"}));
+ h.run_steps(3);
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"], 0.75);
+ let sliders = ok(&mut h, &c, "ui.inspect", json!({"query": "Thumbnail size", "role": "Slider"}));
+ let slider = sliders["widgets"].as_array().unwrap().first().expect("the thumbnail size slider is labelled");
+ ok(&mut h, &c, "ui.focus", json!({"id": slider["id"]}));
+ ok(&mut h, &c, "ui.key", json!({"key": "ArrowRight"}));
+ h.run_steps(3);
+ assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75);
+}
+
#[test]
fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
let (mut h, c) = harness();
@@ -156,7 +181,7 @@ fn control_button_focus_preserves_document_undo_redo_shortcuts() {
#[test]
fn control_text_focus_retains_text_undo_ownership() {
let (mut h, c) = harness_pages(2);
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0 });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None });
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"}));
let widgets = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"}));
@@ -242,7 +267,7 @@ fn control_quit_clean_document_requests_normal_window_close() {
fn bookmark_titles_can_be_searched_over_control() {
let (mut h, c) = harness();
for (index, title, page) in [(0, "Background", 0), (1, "Target chapter", 3)] {
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page, left: None, top: None });
}
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "bookmarks" }));
h.run_steps(3);
@@ -1359,8 +1384,22 @@ fn docking_renders_real_pdf_default_floating_and_redocked() {
let (mut h, c) = harness_pages_at(5, width, scale);
ok(&mut h, &c, "ui.resize", json!({"width":width,"height":900}));
ok(&mut h, &c, "ui.set", json!({"key":"panel","value":"bookmarks"}));
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Overview".into(), page: 0 });
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 1, title: "Details".into(), page: 2 });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
+ parent: vec![],
+ index: 0,
+ title: "Overview".into(),
+ page: 0,
+ left: None,
+ top: None,
+ });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
+ parent: vec![],
+ index: 1,
+ title: "Details".into(),
+ page: 2,
+ left: None,
+ top: None,
+ });
for state in ["default", "floating", "redocked"] {
if state == "floating" {
ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[160,100,330,480]}));
@@ -1521,6 +1560,89 @@ fn read_mode_retains_existing_rail_click_to_close_policy() {
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
}
+#[test]
+fn control_open_of_an_already_open_file_replies_ok_and_focuses_its_tab() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let a = dir.join("a.pdf").to_string_lossy().into_owned();
+ let b = dir.join("b.pdf").to_string_lossy().into_owned();
+ std::fs::write(&a, fixture(2)).unwrap();
+ std::fs::write(&b, fixture(3)).unwrap();
+ let slot: Arc>> = Arc::default();
+ let s = slot.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();
+ // Inline opens: this test pins the synchronous open contract (focus vs open vs
+ // failure). Live apps schedule the worker instead and reply `pending` (#1620).
+ app.run_inline = true;
+ *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
+ app
+ });
+ h.run_steps(4);
+ let c = slot.lock().unwrap().take().unwrap();
+
+ let first = ok(&mut h, &c, "ui.open", json!({ "path": a }));
+ assert_eq!(first["opened"], json!(a), "{first}");
+ ok(&mut h, &c, "ui.open", json!({ "path": b }));
+ // The file is already open: the tab focuses, and the reply must be Ok — an agent driving the
+ // app headlessly would otherwise read a focus as a read failure (#1620).
+ let again = ok(&mut h, &c, "ui.open", json!({ "path": a }));
+ assert_eq!(again["focused"], true, "{again}");
+
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ let docs = state["documents"].as_array().cloned().unwrap_or_default();
+ assert_eq!(docs.len(), 2, "opening an open file adds no tab: {state}");
+ let a_tab = docs.iter().find(|d| d["path"] == json!(a)).unwrap();
+ assert_eq!(a_tab["active"], true, "the already-open tab is in front: {state}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// The other half of the #1620 contract on a live UI: a fresh open is scheduled on the
+/// background worker and answered `pending` (upstream's reply shape), the tab appears once
+/// the open completes, and opening the file again then focuses that tab.
+#[test]
+fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-pending-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let a = dir.join("pending.pdf").to_string_lossy().into_owned();
+ std::fs::write(&a, fixture(2)).unwrap();
+ let slot: Arc>> = Arc::default();
+ let s = slot.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();
+ *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
+ app
+ });
+ h.run_steps(4);
+ let c = slot.lock().unwrap().take().unwrap();
+
+ let first = ok(&mut h, &c, "ui.open", json!({ "path": a }));
+ assert_eq!(first["accepted"], json!(a), "{first}");
+ assert_eq!(first["pending"], true, "{first}");
+
+ // The worker completes the open on later frames; the tab shows, and only then does a
+ // repeated open focus it instead of reporting another pending open.
+ let mut documents = 0;
+ for _ in 0..30 {
+ h.run_steps(1);
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default();
+ if documents == 1 {
+ break;
+ }
+ }
+ assert_eq!(documents, 1, "the pending open completed into a tab");
+
+ let again = ok(&mut h, &c, "ui.open", json!({ "path": a }));
+ assert_eq!(again["focused"], true, "{again}");
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ let docs = state["documents"].as_array().cloned().unwrap_or_default();
+ assert_eq!(docs.len(), 1, "focusing the completed open adds no tab: {state}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
fn navigation_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec) {
h.input_mut().events.extend(events);
h.step();
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 03e358c3b..39f6f2b1c 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -8,7 +8,7 @@ use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
-use pdfcraft_ui_egui::{CloseRequest, Dialog, PdfCraftApp};
+use pdfcraft_ui_egui::{CloseRequest, Dialog, OpenOutcome, PdfCraftApp};
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1".
fn fixture(n: usize) -> Vec {
@@ -632,6 +632,90 @@ fn the_save_prompt_answers_to_the_keyboard() {
let _ = std::fs::remove_file(out);
}
+/// An organize harness with a dirty document and the "Save changes?" prompt open.
+fn dirty_prompt() -> Harness<'static, PdfCraftApp> {
+ let mut h = organize(2);
+ h.get_by_label("Rotate clockwise").click();
+ h.run_steps(3);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::W);
+ h.run_steps(3);
+ h.get_by_label_contains("Save changes to “doc.pdf”");
+ h
+}
+
+/// The prompt has Save chosen on arrival; `keys` then move the choice and Enter or Space presses it.
+fn press_after(keys: &[(Modifiers, Key)], activate: Key) -> Harness<'static, PdfCraftApp> {
+ let mut h = dirty_prompt();
+ for (m, k) in keys {
+ h.key_press_modifiers(*m, *k);
+ h.run_steps(2);
+ }
+ h.key_press(activate);
+ h.run_steps(3);
+ h
+}
+
+#[test]
+fn the_save_prompt_moves_between_buttons_with_the_keyboard() {
+ let none = Modifiers::NONE;
+ // Left from Save is Cancel: Enter or Space presses it, so nothing closes and nothing saves.
+ for activate in [Key::Enter, Key::Space] {
+ let h = press_after(&[(none, Key::ArrowLeft)], activate);
+ assert_eq!(h.state().views.len(), 1, "{activate:?} on Cancel keeps the document");
+ assert!(h.state().close_request.is_none());
+ assert!(dirty(&h), "{activate:?} on Cancel doesn't save");
+ }
+ // Two left is Don't save, which closes without saving; Shift+Tab walks the same way.
+ for keys in [
+ vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft)],
+ vec![(Modifiers::SHIFT, Key::Tab), (Modifiers::SHIFT, Key::Tab)],
+ vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft), (none, Key::ArrowLeft)],
+ vec![(none, Key::Tab)],
+ ] {
+ let h = press_after(&keys, Key::Enter);
+ assert!(h.state().views.is_empty(), "{keys:?} reaches Don't save, which closes the tab");
+ }
+ // Tab from Save wraps to Don't save; arrows stop at the right end, so Save stays chosen.
+ let out = temp_path("arrows-stop.pdf");
+ let mut h = dirty_prompt();
+ h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
+ h.key_press(Key::ArrowRight);
+ h.run_steps(2);
+ h.key_press(Key::Enter);
+ h.run_steps(3);
+ assert!(h.state().views.is_empty(), "Right from Save stays on Save, which saves");
+ assert!(out.exists());
+ let _ = std::fs::remove_file(out);
+}
+
+#[test]
+fn the_save_prompt_escape_still_cancels_whatever_is_chosen() {
+ let h = press_after(&[(Modifiers::NONE, Key::ArrowLeft), (Modifiers::NONE, Key::ArrowLeft)], Key::Escape);
+ assert_eq!(h.state().views.len(), 1, "Escape cancels even with Don't save chosen");
+ assert!(dirty(&h));
+}
+
+#[test]
+fn the_save_prompt_starts_on_save_every_time_it_opens() {
+ // Move to Don't save, cancel with Escape, and ask again: the choice is back on Save.
+ let out = temp_path("reopened.pdf");
+ let mut h = dirty_prompt();
+ h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
+ h.key_press(Key::ArrowLeft);
+ h.key_press(Key::ArrowLeft);
+ h.run_steps(2);
+ h.key_press(Key::Escape);
+ h.run_steps(3);
+ assert_eq!(h.state().views.len(), 1);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::W);
+ h.run_steps(3);
+ h.key_press(Key::Enter);
+ h.run_steps(3);
+ assert!(h.state().views.is_empty(), "Enter saved and closed");
+ assert!(out.exists(), "the second prompt opened on Save");
+ let _ = std::fs::remove_file(out);
+}
+
#[test]
fn keys_leave_the_document_under_a_dialog_alone() {
// Issue #870: ⌘W with Preferences open closed the file underneath it, and in the page grid
@@ -750,7 +834,11 @@ fn the_last_session_reopens_its_files_pages_and_active_tab() {
let dir = TempPdfs::new("reopen");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 5), dir.file("c.pdf", 2)];
let opened = files.clone();
- let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
+ let mut h = session_harness(move |app| {
+ opened.iter().for_each(|f| {
+ app.open_path(f);
+ })
+ });
h.state_mut().views[1].go_to_page(3);
h.state_mut().active = Some(1);
h.run_steps(4);
@@ -772,7 +860,11 @@ fn quitting_with_unsaved_changes_still_remembers_every_file() {
let dir = TempPdfs::new("quit");
let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 1)];
let opened = files.clone();
- let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f)));
+ let mut h = session_harness(move |app| {
+ opened.iter().for_each(|f| {
+ app.open_path(f);
+ })
+ });
for tab in 0..2 {
h.state_mut().active = Some(tab);
h.state_mut().views[tab].select_pages(&[0]);
@@ -818,6 +910,95 @@ fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() {
assert!(later.views.is_empty(), "neither opens from the session");
}
+#[test]
+fn opening_the_same_path_again_focuses_its_tab() {
+ let pdfs = TempPdfs::new("dedup-open");
+ let a = pdfs.file("a.pdf", 1);
+ let b = pdfs.file("b.pdf", 1);
+ // Inline opens: this pins the synchronous open contract (open vs focus). A live app
+ // schedules the background worker instead and answers `Pending` until it completes.
+ let mut h = session_harness(|app| app.run_inline = true);
+ assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Opened);
+ assert_eq!(h.state_mut().open_path(&b), OpenOutcome::Opened);
+ h.run_steps(2);
+ assert_eq!(h.state().views.len(), 2, "two different files open two tabs");
+ assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Focused, "opening an open file focuses, it doesn't reopen");
+ h.run_steps(2);
+ assert_eq!(h.state().views.len(), 2, "opening an open file again adds no tab");
+ assert_eq!(h.state().active, Some(0), "the existing tab comes to the front");
+ assert_eq!(open_paths(h.state()), vec![a, b], "the tab order is untouched");
+}
+
+#[test]
+fn opening_a_missing_file_fails_and_tells_the_user() {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ let missing = std::env::temp_dir().join(format!("pdfcraft-no-such-file-{}.pdf", std::process::id()));
+ assert_eq!(app.open_path(&missing.to_string_lossy()), OpenOutcome::Failed);
+ assert!(app.views.is_empty());
+ assert!(app.toast.is_some(), "the failure is still reported to the user");
+}
+
+#[test]
+fn opening_the_same_file_with_a_dot_component_focuses_its_tab() {
+ let pdfs = TempPdfs::new("dedup-dot");
+ let a = pdfs.file("a.pdf", 1);
+ let dotted = format!("{}/./a.pdf", pdfs.0.display());
+ let mut h = session_harness(|app| app.run_inline = true);
+ h.state_mut().open_path(&a);
+ h.state_mut().open_path(&dotted);
+ h.run_steps(2);
+ assert_eq!(h.state().views.len(), 1, "another spelling of the same file matches");
+ assert_eq!(h.state().active, Some(0));
+}
+
+#[test]
+fn opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab() {
+ let pdfs = TempPdfs::new("dedup-vanished");
+ let a = pdfs.file("a.pdf", 1);
+ let mut h = session_harness(|_| {});
+ h.state_mut().open_path(&a);
+ // A live window opens through the background worker; the tab has to exist before the file
+ // is deleted, or the worker itself fails to read what this test is about to remove.
+ support::wait_until(&mut h, "the vanished file's tab", |app| app.views.len() == 1);
+ std::fs::remove_file(&a).unwrap();
+ h.state_mut().open_path(&a);
+ h.run_steps(2);
+ assert_eq!(h.state().views.len(), 1, "no second tab");
+ assert_eq!(h.state().active, Some(0), "the open tab is focused");
+ assert!(h.state().toast.is_none(), "no read error: the file is never read again");
+}
+
+#[test]
+fn opening_an_encrypted_document_twice_keeps_one_prompt_and_one_tab() {
+ let pdfs = TempPdfs::new("dedup-encrypted");
+ let secret = pdfs.0.join("secret.pdf");
+ std::fs::write(&secret, protected("pw", "owner", -1)).unwrap();
+ let s = secret.to_string_lossy().into_owned();
+ let mut h = session_harness(|app| app.run_inline = true);
+ h.state_mut().open_path(&s);
+ h.state_mut().open_path(&s);
+ h.run_steps(2);
+ assert!(h.state().views.is_empty(), "still locked");
+ let field = h.get_by_role(Role::PasswordInput);
+ field.focus();
+ field.type_text("pw");
+ h.run_steps(2);
+ h.key_press(Key::Enter);
+ h.run_steps(4);
+ assert_eq!(h.state().views.len(), 1, "unlocking opens one tab, not two");
+}
+
+#[test]
+fn opening_documents_without_a_path_still_open_twice() {
+ let bytes = fixture(1);
+ let mut h = session_harness(|_| {});
+ h.state_mut().open_bytes("copy.pdf", None, bytes.clone()).unwrap();
+ h.state_mut().open_bytes("copy.pdf", None, bytes).unwrap();
+ h.run_steps(2);
+ assert_eq!(h.state().views.len(), 2, "bytes have no path to compare (attachments, conversions)");
+}
+
#[test]
fn save_prompt_stays_inside_the_screen_for_a_long_filename() {
// Issue #161: an unwrapped title carrying a long filename widened the centered modal past
@@ -2016,7 +2197,14 @@ fn split_at_bookmarks_with_equal_titles_keeps_every_part() {
let mut h = organize(3);
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned());
for (index, title) in [(0, "Same"), (1, "Same"), (2, "same")] {
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page: index });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
+ parent: vec![],
+ index,
+ title: title.into(),
+ page: index,
+ left: None,
+ top: None,
+ });
}
h.state_mut().split_draft.mode = pdfcraft_ui_egui::SplitMode::Bookmarks;
h.state_mut().run_command("page.split");
@@ -4282,6 +4470,28 @@ fn dragging_pages_panel_thumbnails_reorders_pages() {
assert!(view.panel_drag.is_none());
}
+#[test]
+fn resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo() {
+ let mut h = harness(4, |app| {
+ app.right = Some(pdfcraft_ui_egui::RightPanel::Pages);
+ app.set_option("thumbnail-zoom", "50").unwrap();
+ });
+ let before = page_texts(h.state());
+ h.state_mut().views[0].select_pages(&[1, 2]);
+ h.run_steps(2);
+ let from = panel_thumb_rect(&h, "Page 2").center();
+ let to = panel_thumb_rect(&h, "Page 1").center_top() + egui::vec2(0.0, 4.0);
+ drag(&mut h, from, to);
+ h.run_steps(4);
+ assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()]);
+ assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
+ assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
+ assert!(h.state_mut().execute("edit.undo"));
+ h.run_steps(4);
+ assert_eq!(page_texts(h.state()), before);
+ assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
+}
+
#[test]
fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
@@ -5662,3 +5872,100 @@ fn shown_on_its_page_only(h: &mut Harness<'static, PdfCraftApp>, plain: &image::
}
}
}
+
+/// Where a mode bar tab is, by its label.
+fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
+ h.get_all_by_role_and_label(Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
+}
+
+/// The cursor over each mode bar tab that page 1's text lies under, once that text is scrolled
+/// up under the bar. With the All tools panel closed it lies under Read, Edit, Convert and E-Sign.
+fn mode_tab_cursors(h: &mut Harness<'static, PdfCraftApp>, text: egui::Rect) -> [(&'static str, egui::CursorIcon); 4] {
+ h.state_mut().views[0].goto = Some((0, 0.5));
+ h.run_steps(4);
+ let offset = h.state().views[0].page_screen_rect(0).expect("on screen").top();
+ ["Read", "Edit", "Convert", "E-Sign"].map(|label| {
+ let at = mode_tab(h, label).center();
+ assert!(text.translate(egui::vec2(0.0, offset)).contains(at), "the text lies under {label}");
+ h.hover_at(at);
+ h.run_steps(2);
+ (label, h.output().platform_output.cursor_icon)
+ })
+}
+
+const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [
+ ("Read", egui::CursorIcon::PointingHand),
+ ("Edit", egui::CursorIcon::PointingHand),
+ ("Convert", egui::CursorIcon::PointingHand),
+ ("E-Sign", egui::CursorIcon::PointingHand),
+];
+
+/// Page 1's text block on screen, relative to the page's top edge, in the window as it is now.
+fn page_text(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
+ let id = h.state().views[0].id;
+ let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");
+ let k = r.width() / 200.0;
+ let at = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, (300.0 - y as f32) * k);
+ egui::Rect::from_two_pos(at(block.rect[0], block.rect[1]), at(block.rect[2], block.rect[3]))
+}
+
+/// Text scrolled up under the mode bar leaves its tabs the hand (#2755). The Select tool's
+/// text layer showed its I-beam there, over chrome that hides the page.
+#[test]
+fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() {
+ let mut h = harness(2, |app| app.set_option("left", "closed").unwrap());
+ let text = page_text(&h);
+ // Where the page shows, its text takes the I-beam, once the page's text has arrived.
+ h.state_mut().views[0].goto = Some((0, 0.3));
+ h.run_steps(4);
+ let top = h.state().views[0].page_screen_rect(0).expect("on screen").top();
+ let mut cursor = egui::CursorIcon::Default;
+ for _ in 0..50 {
+ h.hover_at(text.center() + egui::vec2(0.0, top));
+ h.run_steps(2);
+ cursor = h.output().platform_output.cursor_icon;
+ if cursor == egui::CursorIcon::Text {
+ break;
+ }
+ }
+ assert_eq!(cursor, egui::CursorIcon::Text);
+ assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB);
+}
+
+/// Edit text, then the All tools panel closed, as reported (#2755): a paragraph scrolled up
+/// under the mode bar gave its tabs the four-way arrow, or the I-beam once a paragraph was open.
+/// The open paragraph's editor also covered the tabs and took their clicks.
+#[test]
+fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() {
+ let mut h = harness(2, |_| {});
+ assert!(h.state_mut().execute("edit.edit_text"));
+ h.state_mut().set_option("left", "closed").unwrap();
+ h.run_steps(4);
+ let text = page_text(&h);
+ assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "a closed paragraph under the tabs");
+ // Open it, where it shows.
+ h.state_mut().views[0].goto = Some((0, 0.3));
+ h.run_steps(4);
+ let top = h.state().views[0].page_screen_rect(0).expect("on screen").top();
+ click_at(&mut h, text.center() + egui::vec2(0.0, top));
+ assert!(h.state().views[0].line_editor.is_some());
+ assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "the open paragraph under the tabs");
+ // Nor does the editor's frame, with the paragraph's top edge just under a tab (the frame
+ // reaches 2 to 6 points outside it).
+ let convert = mode_tab(&h, "Convert").center();
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ let page = h.state().views[0].page_screen_rect(0).expect("on screen");
+ h.state_mut().views[0].goto = Some((0, (page.top() - (convert.y + 4.0 - text.top())) / page.height()));
+ h.run_steps(4);
+ let page = h.state().views[0].page_screen_rect(0).expect("on screen");
+ assert!((page.top() + text.top() - convert.y - 4.0).abs() < 1.0, "Convert lies on the paragraph's frame");
+ h.hover_at(convert);
+ h.run_steps(2);
+ assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the open paragraph's frame under a tab");
+ h.state_mut().views[0].goto = Some((0, 0.5));
+ h.run_steps(4);
+ click_at(&mut h, convert);
+ assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor");
+}
diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs
index 27fd6dd8a..a83cf6fa8 100644
--- a/crates/ui-egui/tests/fill_sign.rs
+++ b/crates/ui-egui/tests/fill_sign.rs
@@ -1515,3 +1515,64 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() {
assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog");
std::fs::remove_file(photo).unwrap();
}
+
+/// Where a mode bar tab is, by its label.
+fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
+ h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
+}
+
+/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each
+/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign.
+fn mode_tab_cursors(
+ h: &mut Harness<'static, PdfCraftApp>,
+ what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect,
+) -> [(&'static str, egui::CursorIcon); 4] {
+ let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen");
+ let y = what(h).center().y - page(h).top();
+ let tabs = mode_tab(h, "Convert").center().y;
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ let top = page(h).top();
+ h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height()));
+ h.run_steps(4);
+ let under = what(h);
+ ["Read", "Edit", "Convert", "E-Sign"].map(|label| {
+ let at = mode_tab(h, label).center();
+ assert!(under.contains(at), "{under:?} lies under {label} at {at:?}");
+ h.hover_at(at);
+ h.run_steps(2);
+ (label, h.output().platform_output.cursor_icon)
+ })
+}
+
+const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [
+ ("Read", egui::CursorIcon::PointingHand),
+ ("Edit", egui::CursorIcon::PointingHand),
+ ("Convert", egui::CursorIcon::PointingHand),
+ ("E-Sign", egui::CursorIcon::PointingHand),
+];
+
+/// The Fill & Sign text box scrolled up under the mode bar leaves its tabs the hand (#2755): it
+/// floated over them, gave them its I-beam and took their clicks.
+#[test]
+fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() {
+ let mut h = harness();
+ assert!(h.state_mut().execute("sign.fill.text"));
+ h.state_mut().set_option("left", "closed").unwrap();
+ // The page across the window, so the box reaches under the tabs.
+ h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
+ h.run_steps(4);
+ assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text));
+ click(&mut h, 10.0, 360.0);
+ h.event(egui::Event::Text("Ada Lovelace, Countess of Lovelace".into()));
+ h.run_steps(3);
+ assert_eq!(mode_tab_cursors(&mut h, type_box_rect), HAND_ON_EVERY_TAB);
+ let convert = mode_tab(&h, "Convert").center();
+ h.hover_at(convert);
+ h.run_steps(1);
+ h.drag_at(convert);
+ h.run_steps(1);
+ h.drop_at(convert);
+ h.run_steps(3);
+ assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box");
+}
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index e153dcca9..89a4419d3 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -36,6 +36,10 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize)
let f = doc.form.iter().find(|f| f.name == name).unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center()
};
+ click_at(h, p);
+}
+
+fn click_at(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
@@ -1072,3 +1076,66 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
// An explicit size is kept, as in Acrobat: the text may then run past the box.
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
}
+
+/// Where a mode bar tab is, by its label.
+fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
+ use egui_kittest::kittest::Queryable;
+ h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
+}
+
+/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each
+/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign.
+fn mode_tab_cursors(
+ h: &mut Harness<'static, PdfCraftApp>,
+ what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect,
+) -> [(&'static str, egui::CursorIcon); 4] {
+ let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen");
+ let y = what(h).center().y - page(h).top();
+ let tabs = mode_tab(h, "Convert").center().y;
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ let top = page(h).top();
+ h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height()));
+ h.run_steps(4);
+ let under = what(h);
+ ["Read", "Edit", "Convert", "E-Sign"].map(|label| {
+ let at = mode_tab(h, label).center();
+ assert!(under.contains(at), "{under:?} lies under {label} at {at:?}");
+ h.hover_at(at);
+ h.run_steps(2);
+ (label, h.output().platform_output.cursor_icon)
+ })
+}
+
+const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [
+ ("Read", egui::CursorIcon::PointingHand),
+ ("Edit", egui::CursorIcon::PointingHand),
+ ("Convert", egui::CursorIcon::PointingHand),
+ ("E-Sign", egui::CursorIcon::PointingHand),
+];
+
+/// A text field scrolled up under the mode bar leaves its tabs the hand (#2755). The field gave
+/// them its I-beam, and its open editor floated over them and took their clicks.
+#[test]
+fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() {
+ let mut h = harness();
+ // The page across the window, so the name field reaches under the tabs.
+ h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
+ h.run_steps(4);
+ let name = |h: &Harness<'static, PdfCraftApp>| {
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ let f = doc.form.iter().find(|f| f.name == "name").unwrap();
+ pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
+ };
+ assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the field under the tabs");
+ // Open it, where it shows.
+ h.state_mut().views[0].goto = Some((0, 0.0));
+ h.run_steps(4);
+ click_field(&mut h, "name", 0);
+ assert!(h.state().views[0].forms.focus.is_some(), "the editor opened");
+ assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the open field under the tabs");
+ let convert = mode_tab(&h, "Convert").center();
+ click_at(&mut h, convert);
+ assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor");
+}
diff --git a/crates/ui-egui/tests/panel_snapshots.rs b/crates/ui-egui/tests/panel_snapshots.rs
new file mode 100644
index 000000000..652a670a3
--- /dev/null
+++ b/crates/ui-egui/tests/panel_snapshots.rs
@@ -0,0 +1,257 @@
+//! Snapshot tests of every side panel (M3): each panel opens on one synthetic document and its
+//! AccessKit tree (the roles, labels, values and states a screen reader gets) is compared with a
+//! text file in `tests/snapshots/`. Text rather than pixels: with the reported OS pinned (shortcut
+//! labels follow it) it is the same on every platform and GPU, it is not an asset (AGENTS.md §1),
+//! and a reviewer can read a change in the diff. Positions are left out, so moving a widget is not
+//! a change but adding, removing or renaming one is.
+//!
+//! `UPDATE_SNAPSHOTS=1 cargo test -p pdfcraft-ui-egui --test panel_snapshots` rewrites the files.
+
+use egui::accesskit::{Role, Toggled};
+use egui_kittest::kittest::{NodeT, Queryable};
+use egui_kittest::{Harness, Node};
+use pdfcraft_engine::Session;
+use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};
+
+mod support;
+
+/// Two pages with something for every panel: bookmarks, a layer, an attachment, a text field, an
+/// unsigned signature field, a comment and a structure tree.
+fn fixture() -> Vec {
+ let page1 = "/P << /MCID 0 >> BDC BT /F1 18 Tf 72 720 Td (Panel sample) Tj ET EMC\n\
+ /OC /L1 BDC BT /F1 10 Tf 72 690 Td (Draft notes) Tj ET EMC\n";
+ let page2 = "BT /F1 12 Tf 72 720 Td (Second page sample text) Tj ET\n";
+ let objs: Vec = vec![
+ // 1 catalog
+ "<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R /OCProperties << /OCGs [9 0 R] /D << /ON [9 0 R] /Order [9 0 R] >> >> \
+ /Names << /EmbeddedFiles << /Names [(notes.txt) 11 0 R] >> >> /AcroForm << /Fields [12 0 R 13 0 R] >> \
+ /MarkInfo << /Marked true >> /StructTreeRoot 15 0 R >>"
+ .into(),
+ // 2 page tree, 3 and 4 its pages
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 17 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> /Properties << /L1 9 0 R >> >> \
+ /Annots [12 0 R 13 0 R 14 0 R] >>"
+ .into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 18 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
+ // 5 font
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ // 6 outline, 7 and 8 its items
+ "<< /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >>".into(),
+ "<< /Title (Introduction) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >>".into(),
+ "<< /Title (Details) /Parent 6 0 R /Prev 7 0 R /Dest [4 0 R /Fit] >>".into(),
+ // 9 layer
+ "<< /Type /OCG /Name (Draft notes) >>".into(),
+ // 10 embedded file, 11 its file specification
+ "<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(),
+ "<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 10 0 R >> >>".into(),
+ // 12 text field, 13 signature field, 14 comment
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [72 600 272 624] /P 3 0 R /F 4 >>".into(),
+ "<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [72 520 272 560] /P 3 0 R /F 4 >>".into(),
+ "<< /Type /Annot /Subtype /Text /Rect [400 700 420 720] /P 3 0 R /T (Reviewer) /Contents (Check this heading) \
+ /M (D:20260101120000Z) /CreationDate (D:20260101120000Z) >>"
+ .into(),
+ // 15 structure tree root, 16 its paragraph
+ "<< /Type /StructTreeRoot /K [16 0 R] /ParentTree << /Nums [0 [16 0 R]] >> >>".into(),
+ "<< /S /P /P 15 0 R /Pg 3 0 R /K 0 >>".into(),
+ // 17 and 18 page contents
+ format!("<< /Length {} >>\nstream\n{page1}endstream", page1.len()),
+ format!("<< /Length {} >>\nstream\n{page2}endstream", page2.len()),
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offs = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offs.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let x = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offs {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+fn harness(panel: &'static str) -> Harness<'static, PdfCraftApp> {
+ let bytes = fixture();
+ // Shortcut labels follow the reported OS ("Ctrl+B" against "⌘B"), so pin it rather than rely
+ // on kittest's default; the snapshots then match on every host.
+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_os(egui::os::OperatingSystem::Nix).build_eframe(move |_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("panels.pdf", None, bytes).expect("opens");
+ app.set_option("panel", panel).unwrap();
+ app
+ });
+ h.run_steps(6);
+ h
+}
+
+/// The side panel's contents: the dock node after the Inspector tab.
+fn panel<'h>(h: &'h Harness<'static, PdfCraftApp>) -> Node<'h> {
+ let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector"));
+ let dock = tab.parent().expect("the Inspector tab sits in a dock node");
+ let mut after = dock.children().skip_while(|n| n.accesskit_node().id() != tab.accesskit_node().id()).skip(1);
+ after.next().expect("the panel follows its tab")
+}
+
+/// One line per node that says something: layout containers are skipped (their children move up a
+/// level) and text runs repeat their label.
+fn describe(node: &Node<'_>, depth: usize, out: &mut String) {
+ let n = node.accesskit_node();
+ let quiet = n.role() == Role::GenericContainer && n.label().is_none() && n.value().is_none();
+ let next = if quiet || n.role() == Role::TextRun {
+ depth
+ } else {
+ let mut line = format!("{:indent$}{:?}", "", n.role(), indent = depth * 2);
+ if let Some(l) = n.label() {
+ line.push_str(&format!(" {l:?}"));
+ }
+ if let Some(v) = n.value().filter(|v| !v.is_empty() && Some(v) != n.label().as_ref()) {
+ line.push_str(&format!(" = {v:?}"));
+ }
+ match n.toggled() {
+ Some(Toggled::True) => line.push_str(" [on]"),
+ Some(Toggled::False) => line.push_str(" [off]"),
+ Some(Toggled::Mixed) => line.push_str(" [mixed]"),
+ None => {}
+ }
+ if n.is_selected() == Some(true) {
+ line.push_str(" [selected]");
+ }
+ if n.is_disabled() {
+ line.push_str(" [disabled]");
+ }
+ out.push_str(&line);
+ out.push('\n');
+ depth + 1
+ };
+ if n.role() == Role::TextRun {
+ return;
+ }
+ for c in node.children() {
+ describe(&c, next, out);
+ }
+}
+
+/// Compares the panel with `tests/snapshots/panel_.txt`, or writes it with `UPDATE_SNAPSHOTS`.
+fn check(h: &Harness<'static, PdfCraftApp>, name: &str) -> Result<(), String> {
+ let mut got = String::new();
+ describe(&panel(h), 0, &mut got);
+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots").join(format!("panel_{name}.txt"));
+ if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, &got).unwrap();
+ return Ok(());
+ }
+ match std::fs::read_to_string(&path) {
+ Ok(want) if want == got => Ok(()),
+ Ok(want) => Err(format!(
+ "{name} panel differs from {}
+--- want
+{want}
+--- got
+{got}",
+ path.display()
+ )),
+ Err(e) => Err(format!(
+ "{}: {e}
+--- got
+{got}",
+ path.display()
+ )),
+ }
+}
+
+/// Every panel, in the order of the rail.
+const ALL: [RightPanel; 11] = [
+ RightPanel::Comments,
+ RightPanel::Bookmarks,
+ RightPanel::Pages,
+ RightPanel::Fields,
+ RightPanel::Layers,
+ RightPanel::Attachments,
+ RightPanel::Signatures,
+ RightPanel::Accessibility,
+ RightPanel::Tags,
+ RightPanel::Search,
+ RightPanel::Compare,
+];
+
+/// Opens `p` with something in it and names its snapshot. No wildcard on purpose: a new
+/// `RightPanel` stops this file compiling until it is set up here (and listed in [`ALL`]).
+fn open(p: RightPanel) -> (Harness<'static, PdfCraftApp>, &'static str) {
+ let name = match p {
+ RightPanel::Comments => "comments",
+ RightPanel::Bookmarks => "bookmarks",
+ RightPanel::Pages => "pages",
+ RightPanel::Fields => "fields",
+ RightPanel::Layers => "layers",
+ RightPanel::Attachments => "attachments",
+ RightPanel::Signatures => "signatures",
+ RightPanel::Tags => "tags",
+ RightPanel::Accessibility => {
+ let mut h = harness("none");
+ assert!(h.state_mut().execute("a11y.check"));
+ h.run_steps(2);
+ h.get_by_label("Start Checking").click();
+ h.run_steps(3);
+ return (h, "accessibility");
+ }
+ RightPanel::Search => {
+ let mut h = harness("none");
+ assert!(h.state_mut().execute("edit.advanced_search"));
+ h.run_steps(2);
+ h.state_mut().views[0].find.as_mut().expect("Advanced Search opens a search").query = "sample".into();
+ support::wait_until(&mut h, "both pages searched", |app| app.views[0].find.as_ref().is_some_and(|f| f.matches.len() == 2));
+ h.run_steps(2);
+ return (h, "search");
+ }
+ RightPanel::Compare => {
+ let s = Session::new();
+ let old = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
+ let new = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
+ 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("old.pdf", None, old.clone()).unwrap();
+ app.open_bytes("new.pdf", None, new.clone()).unwrap();
+ app
+ });
+ h.run_steps(4);
+ assert!(h.state_mut().execute("doc.compare"));
+ h.run_steps(2);
+ h.get_by_label("Compare").click();
+ h.run_steps(3);
+ return (h, "compare");
+ }
+ };
+ (harness(name), name)
+}
+
+/// Every panel matches its snapshot. All of them are checked before failing, so one run shows
+/// every panel a change touched.
+#[test]
+fn every_panel_matches_its_snapshot() {
+ let mut failures = Vec::new();
+ for p in ALL {
+ let (h, name) = open(p);
+ assert_eq!(h.state().right, Some(p), "{name} opens its own panel");
+ if let Err(e) = check(&h, name) {
+ failures.push(e);
+ }
+ }
+ assert!(
+ failures.is_empty(),
+ "{} of {} panels differ (UPDATE_SNAPSHOTS=1 rewrites them)
+
+{}",
+ failures.len(),
+ ALL.len(),
+ failures.join(
+ "
+
+"
+ )
+ );
+}
diff --git a/crates/ui-egui/tests/snapshots/panel_accessibility.txt b/crates/ui-egui/tests/snapshots/panel_accessibility.txt
new file mode 100644
index 000000000..33d04008d
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_accessibility.txt
@@ -0,0 +1,85 @@
+Label = "Accessibility Checker"
+Button "Close" [off]
+Label = "7 issues"
+Button "Check again"
+Button "Report"
+Label = "Needs manual check: 5"
+Button "Collapse" [off]
+Label = "Document (2 issues)"
+Image
+Label = "Accessibility permission flag - Passed"
+Image
+Label = "Image-only PDF - Passed"
+Image
+Label = "Tagged PDF - Passed"
+Image
+Label = "Logical Reading Order - Needs manual check"
+Image
+Label = "Primary language - Failed"
+Image
+Label = "Title - Failed"
+Image
+Label = "Bookmarks - Passed"
+Image
+Label = "Color contrast - Skipped"
+Button "Collapse" [off]
+Label = "Page Content (3 issues)"
+Image
+Label = "Tagged content - Failed"
+Image
+Label = "Tagged annotations - Failed"
+Image
+Label = "Tab order - Failed"
+Image
+Label = "Character encoding - Passed"
+Image
+Label = "Tagged multimedia - Passed"
+Image
+Label = "Screen flicker - Needs manual check"
+Image
+Label = "Scripts - Needs manual check"
+Image
+Label = "Timed responses - Needs manual check"
+Image
+Label = "Navigation links - Needs manual check"
+Button "Collapse" [off]
+Label = "Forms (2 issues)"
+Image
+Label = "Tagged form fields - Failed"
+Image
+Label = "Field descriptions - Failed"
+Button "Collapse" [off]
+Label = "Alternate Text"
+Image
+Label = "Figures alternate text - Passed"
+Image
+Label = "Nested alternate text - Passed"
+Image
+Label = "Associated with content - Passed"
+Image
+Label = "Hides annotation - Passed"
+Image
+Label = "Other elements alternate text - Passed"
+Button "Collapse" [off]
+Label = "Tables"
+Image
+Label = "Rows - Passed"
+Image
+Label = "TH and TD - Passed"
+Image
+Label = "Headers - Passed"
+Image
+Label = "Regularity - Passed"
+Image
+Label = "Summary - Passed"
+Button "Collapse" [off]
+Label = "Lists"
+Image
+Label = "List items - Passed"
+Image
+Label = "Lbl and LBody - Passed"
+Button "Collapse" [off]
+Label = "Headings"
+Image
+Label = "Appropriate nesting - Passed"
+ScrollBar
diff --git a/crates/ui-egui/tests/snapshots/panel_attachments.txt b/crates/ui-egui/tests/snapshots/panel_attachments.txt
new file mode 100644
index 000000000..c61b2f860
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_attachments.txt
@@ -0,0 +1,7 @@
+Label = "Attachments"
+Label = "1"
+Button "Close" [off]
+Image
+Label = "notes.txt"
+Label = "5 bytes"
+Button "Save attachment…" [off]
diff --git a/crates/ui-egui/tests/snapshots/panel_bookmarks.txt b/crates/ui-egui/tests/snapshots/panel_bookmarks.txt
new file mode 100644
index 000000000..7ee13c4e6
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_bookmarks.txt
@@ -0,0 +1,9 @@
+Label = "Bookmarks"
+Button "Close" [off]
+Button "Bookmark options" [off]
+Button "New bookmark (Ctrl+B)" [off]
+Label = "Search"
+TextInput "Search"
+Button "Clear" [off]
+Button "Introduction"
+Button "Details"
diff --git a/crates/ui-egui/tests/snapshots/panel_comments.txt b/crates/ui-egui/tests/snapshots/panel_comments.txt
new file mode 100644
index 000000000..c5bed5873
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_comments.txt
@@ -0,0 +1,14 @@
+Label = "Comments"
+Label = "1"
+Button "Close" [off]
+Button "More options" [off]
+Button "Filter comments" [off]
+Button "Search comments" [off]
+TextInput
+Button "Collapse" [off]
+Label = "Page 1"
+Label = "1"
+Label = "Reviewer"
+Label = "2026-01-01 12:00"
+Label = "Check this heading"
+Unknown
diff --git a/crates/ui-egui/tests/snapshots/panel_compare.txt b/crates/ui-egui/tests/snapshots/panel_compare.txt
new file mode 100644
index 000000000..a2d8cc5a6
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_compare.txt
@@ -0,0 +1,16 @@
+Label = "Compare"
+Label = "2"
+Button "Close" [off]
+Label = "2 Replaced"
+Label = "0 Inserted"
+Label = "0 Deleted"
+Label = "1 Visual"
+Button "Side by side"
+Button "New file only"
+Button "Mark as comments"
+Button "Report…"
+Button "Clear"
+Label = "Replaced · page 1"
+Button "\"five\" → \"three\"" [off]
+Label = "Replaced · page 1"
+Button "\"accepted.\" → \"accepted for a week.\"" [off]
diff --git a/crates/ui-egui/tests/snapshots/panel_fields.txt b/crates/ui-egui/tests/snapshots/panel_fields.txt
new file mode 100644
index 000000000..9fa61404e
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_fields.txt
@@ -0,0 +1,6 @@
+Label = "Fields"
+Label = "2"
+Button "Close" [off]
+Label = "Page 1"
+Button "Name" [off]
+Button "Approval" [off]
diff --git a/crates/ui-egui/tests/snapshots/panel_layers.txt b/crates/ui-egui/tests/snapshots/panel_layers.txt
new file mode 100644
index 000000000..1e75f6bf6
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_layers.txt
@@ -0,0 +1,4 @@
+Label = "Layers"
+Label = "1"
+Button "Close" [off]
+CheckBox "Draft notes" [on]
diff --git a/crates/ui-egui/tests/snapshots/panel_pages.txt b/crates/ui-egui/tests/snapshots/panel_pages.txt
new file mode 100644
index 000000000..726d58208
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_pages.txt
@@ -0,0 +1,7 @@
+Label = "Pages"
+Label = "2"
+Button "Close" [off]
+Button "Page 1" [off]
+Label = "1"
+Button "Page 2" [off]
+Label = "2"
diff --git a/crates/ui-egui/tests/snapshots/panel_search.txt b/crates/ui-egui/tests/snapshots/panel_search.txt
new file mode 100644
index 000000000..d031afac6
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_search.txt
@@ -0,0 +1,11 @@
+Label = "Search"
+Button "Close" [off]
+Label = "What word or phrase would you like to search for?"
+TextInput "What word or phrase would you like to search for?" = "sample"
+CheckBox "Whole words only" [off]
+CheckBox "Case-sensitive" [off]
+Label = "2 instances"
+Label = "Page 1"
+Button "…Panel sample Draft notes…" [on]
+Label = "Page 2"
+Button "…Second page sample text…" [off]
diff --git a/crates/ui-egui/tests/snapshots/panel_signatures.txt b/crates/ui-egui/tests/snapshots/panel_signatures.txt
new file mode 100644
index 000000000..8b7038738
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_signatures.txt
@@ -0,0 +1,7 @@
+Label = "Signatures"
+Label = "0"
+Button "Close" [off]
+Button "Validate all"
+Button "Expand" [off]
+Image
+Label = "Unsigned signature field: Approval"
diff --git a/crates/ui-egui/tests/snapshots/panel_tags.txt b/crates/ui-egui/tests/snapshots/panel_tags.txt
new file mode 100644
index 000000000..7c694e0b5
--- /dev/null
+++ b/crates/ui-egui/tests/snapshots/panel_tags.txt
@@ -0,0 +1,24 @@
+Label = "Tags"
+Button "Close" [off]
+Label = "Find a tag"
+TextInput "Find a tag"
+Label = "1 tags"
+Button "P · 16" [on]
+Label = "16 0 R"
+Button "Go to page 1"
+Label = "Tag type"
+TextInput "Tag type" = "P"
+Label = "Title"
+TextInput "Title"
+Label = "Actual text"
+MultilineTextInput "Actual text"
+Label = "Alternate text"
+MultilineTextInput "Alternate text"
+Label = "Language"
+TextInput "Language"
+Button "Save tag properties" [disabled]
+Button "Revert changes" [disabled]
+Button "Move tag earlier" [disabled]
+Button "Move tag later" [disabled]
+Button "Mark as artifact"
+Button "MCID 0 · Go to page 1 · Panel sample"
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index a74e58879..e392a6614 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -3,7 +3,7 @@
mod support;
use egui_kittest::Harness;
-use egui_kittest::kittest::{NodeT, Queryable};
+use egui_kittest::kittest::{By, NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
/// A tiny PDF with two pages, two bookmarks and one sticky note.
@@ -162,6 +162,26 @@ fn closed_side_panes_stay_closed_after_a_restart_and_opening_a_pdf() {
assert!(!h.state().left_open);
}
+/// #2884: a right panel the user closed stays closed when a PDF asks for its outline
+/// (`/PageMode /UseOutlines`), within a session and after a restart.
+#[test]
+fn closed_right_panel_stays_closed_for_a_document_asking_for_its_outline() {
+ use pdfcraft_ui_egui::RightPanel;
+ let pdf = || String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageMode /UseOutlines").into_bytes();
+ let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
+ assert_eq!(h.state().right, Some(RightPanel::Comments));
+ h.state_mut().choose_right_panel(None);
+ h.state_mut().open_bytes("outline.pdf", None, pdf()).unwrap();
+ h.run_steps(3);
+ assert_eq!(h.state().right, None, "the outline request reopened the closed right panel");
+ let saved = h.state().persist();
+ let h = harness(move |app| {
+ app.restore(&saved);
+ app.open_bytes("outline.pdf", None, pdf()).expect("outline PDF opens");
+ });
+ assert_eq!(h.state().right, None, "the closed right panel reopened after a restart");
+}
+
/// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the
/// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden.
#[test]
@@ -184,7 +204,9 @@ fn read_mode_rail_buttons_open_their_panels() {
h.run_steps(3);
assert_eq!(h.state().right, None);
assert!(h.query_by_label("Alpha section").is_none());
- // A document that asks for its outline (/PageMode /UseOutlines) shows it in Read mode too.
+ // A document that asks for its outline (/PageMode /UseOutlines) shows it in Read mode too,
+ // unless the user closed the panel (#2884), so forget that closing here.
+ h.state_mut().right_panel_closed = false;
let pdf = String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageMode /UseOutlines");
h.state_mut().open_bytes("outline.pdf", None, pdf.into_bytes()).unwrap();
h.run_steps(3);
@@ -812,6 +834,30 @@ fn dropping_a_pdf_on_the_window_opens_it() {
assert_eq!(doc.bytes.as_slice(), FIXTURE);
}
+#[test]
+fn dropping_an_open_pdf_again_focuses_its_tab() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-drop-dedup-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("dropped.pdf");
+ std::fs::write(&path, FIXTURE).unwrap();
+ let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app
+ });
+ h.run_steps(3);
+ let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: FIXTURE.to_vec() });
+ h.input_mut().dropped_files.push(file.clone());
+ // The drop opens through the background worker on a live window; wait for the tab.
+ support::wait_until(&mut h, "dropped document", |app| app.views.len() == 1);
+ assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab");
+ h.input_mut().dropped_files.push(file);
+ h.run_steps(3);
+ assert_eq!(h.state().views.len(), 1, "dropping it again adds no tab");
+ std::fs::remove_dir_all(&dir).unwrap();
+}
+
#[test]
fn a_file_dropped_on_the_page_grid_is_read_from_disk_and_inserted() {
// The grid's drop reads a file from its path when its turn to be inserted comes, and inserts it
@@ -914,6 +960,15 @@ fn files_and_quit_from_the_operating_system() {
std::fs::remove_file(&path).ok();
assert_eq!(h.state().views.len(), 1, "the file opens in a tab");
h.get_by_label_contains(&name);
+ // A file opened while the app runs brings its window forward, even minimized or full screen
+ // on another Space.
+ std::fs::write(&path, FIXTURE).unwrap();
+ queue.borrow_mut().push(OsEvent::Open(vec![path.to_string_lossy().into_owned()]));
+ h.step();
+ std::fs::remove_file(&path).ok();
+ let commands: Vec<_> = h.output().viewport_output.values().flat_map(|v| v.commands.clone()).collect();
+ assert!(commands.contains(&egui::ViewportCommand::Minimized(false)), "{commands:?}");
+ assert!(commands.contains(&egui::ViewportCommand::Focus), "{commands:?}");
// Quit closes the window like the close button, so unsaved changes are asked about.
queue.borrow_mut().push(OsEvent::Quit);
h.step();
@@ -1475,3 +1530,38 @@ fn the_edit_panel_keeps_the_add_update_remove_menu() {
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)));
}
+
+/// #3282: in a short window the rail's panel buttons scroll above the page navigation instead of
+/// being drawn under the page number field.
+#[test]
+fn short_window_rail_buttons_scroll_clear_of_page_navigation() {
+ let (width, height) = (1000.0, 560.0);
+ let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(|_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap();
+ app
+ });
+ settle(&mut h);
+ let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| {
+ h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail"))
+ };
+ let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect();
+ assert!(page.left() >= width - 48.0 && page.bottom() <= height, "page field {page:?} in the rail");
+ assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field");
+ assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll");
+ let over = rail(&h, "Comments").center();
+ h.event(egui::Event::PointerMoved(over));
+ for _ in 0..8 {
+ h.event(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ delta: egui::vec2(0.0, -200.0),
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ });
+ h.run_steps(4);
+ }
+ let tags = rail(&h, "Tags");
+ assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}");
+ assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put");
+}
diff --git a/crates/ui-egui/tests/ui_group/layer_actions.rs b/crates/ui-egui/tests/ui_group/layer_actions.rs
index 95b35ff23..487834393 100644
--- a/crates/ui-egui/tests/ui_group/layer_actions.rs
+++ b/crates/ui-egui/tests/ui_group/layer_actions.rs
@@ -104,3 +104,23 @@ fn push_buttons_set_layer_visibility() {
let s = h.state();
assert!(!s.session.get(s.views[0].id).unwrap().dirty, "showing and hiding layers doesn't change the document");
}
+
+#[test]
+fn layer_options_reset_to_initial_visibility() {
+ // Red off and Green on: the opposite of how the file opens.
+ let mut h = harness(|app| {
+ app.set_option("layer", "Red=off").unwrap();
+ app.set_option("layer", "Green=on").unwrap();
+ app.set_option("panel", "layers").unwrap();
+ });
+ assert_eq!(shown(&h), layers(false, true));
+ h.get_by_label("Layer options").click();
+ h.run_steps(2);
+ h.get_by_label("Reset to Initial Visibility").click();
+ h.run_steps(3);
+ assert_eq!(shown(&h), layers(true, false));
+ let s = h.state();
+ let doc = s.session.get(s.views[0].id).unwrap();
+ assert!(!doc.dirty, "showing and hiding layers doesn't change the document");
+ assert_eq!(doc.can_undo(), None, "nor is it an edit to undo");
+}
diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs
index 9ed4e9d5d..2b6304416 100644
--- a/crates/ui-egui/tests/ui_group/text_select.rs
+++ b/crates/ui-egui/tests/ui_group/text_select.rs
@@ -226,6 +226,27 @@ fn selected_text_copy_edit_and_context_menus() {
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
}
+#[test]
+fn escape_closes_an_open_menu_before_it_clears_the_selection() {
+ // #3535: one Escape closed the main menu and dropped the selection with it.
+ use egui_kittest::kittest::Queryable;
+ let (mut h, at) = harness(|_| {});
+ click(&mut h, at);
+ click(&mut h, at);
+ h.run_steps(2);
+ assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
+ h.get_by_label("Menu").click();
+ h.run_steps(2);
+ assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
+ h.key_press(egui::Key::Escape);
+ h.run_steps(2);
+ assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closes the menu");
+ assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "and keeps the selection");
+ h.key_press(egui::Key::Escape);
+ h.run_steps(2);
+ assert_eq!(h.state().views[0].selected_text(), None, "a second Escape clears it");
+}
+
#[test]
fn selected_text_copy_command_requires_selection() {
let (mut h, at) = harness(|_| {});
@@ -544,3 +565,84 @@ fn coalesced_primary_clicks_select_line_then_spaced_click_clears() {
assert_eq!(doc.saved_bytes(), saved);
}
}
+
+/// Review on #2070: a click on the page in full screen waits out the double-click time before
+/// it turns the page, and a link clicked meanwhile used to leave that turn pending: the link
+/// jumped to page 3, then the page moved on to page 4.
+#[test]
+fn full_screen_link_click_cancels_the_pending_page_turn() {
+ let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>";
+ let four_pages = String::from_utf8(two_lines())
+ .unwrap()
+ .replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R 7 0 R 8 0 R] /Count 4")
+ .replace("/Contents 4 0 R /Resources", "/Annots [9 0 R] /Contents 4 0 R /Resources")
+ .replace(
+ "trailer",
+ &format!(
+ "6 0 obj {page} endobj\n7 0 obj {page} endobj\n8 0 obj {page} endobj\n9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 280 60] /Border [0 0 0] /Dest [7 0 R /Fit] >> endobj\ntrailer"
+ ),
+ )
+ .into_bytes();
+ let (mut h, _) = harness_with(four_pages, |_| {});
+ h.state_mut().apply_full_screen(true);
+ crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
+ let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
+ let at = |x: f32, y: f32| egui::pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height());
+ // A click on blank page area, then, within the double-click time, a click on the link.
+ h.hover_at(at(150.0, 90.0));
+ h.run_steps(1);
+ click(&mut h, at(150.0, 90.0));
+ h.run_steps(3);
+ h.hover_at(at(240.0, 40.0));
+ h.run_steps(1);
+ click(&mut h, at(240.0, 40.0));
+ h.run_steps(30);
+ assert_eq!(h.state().views[0].current, 2, "the link's page, and no turn after it");
+}
+
+/// Review on #2070: in full screen a click goes forward a page, but a double-click selects the
+/// word and stays: the click waits out the double-click time before turning. Turning on the first
+/// click's release had sent the second click to the next page. Leaving full screen meanwhile
+/// drops the pending turn.
+#[test]
+fn full_screen_double_click_selects_a_word_and_stays_on_the_page() {
+ // The same page twice.
+ let two_pages = String::from_utf8(two_lines())
+ .unwrap()
+ .replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R] /Count 2")
+ .replace(
+ "trailer",
+ "6 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\ntrailer",
+ )
+ .into_bytes();
+ let (mut h, _) = harness_with(two_pages, |_| {});
+ h.state_mut().apply_full_screen(true);
+ crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
+ let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer");
+ h.hover_at(at);
+ h.run_steps(1);
+
+ // A double-click selects "quick" and stays on page 1, also once the double-click time is over.
+ click(&mut h, at);
+ click(&mut h, at);
+ assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
+ h.run_steps(30);
+ assert_eq!(h.state().views[0].current, 0, "the double-click didn't turn the page");
+
+ // A single click turns it once the double-click time is over.
+ // Entering full screen again clears the selection.
+ h.state_mut().apply_full_screen(false);
+ h.state_mut().apply_full_screen(true);
+ h.run_steps(30);
+ click(&mut h, at);
+ h.run_steps(30);
+ assert_eq!(h.state().views[0].current, 1, "a click goes forward");
+
+ // A click right before leaving full screen doesn't turn the page after it.
+ h.state_mut().views[0].go_to_page(0);
+ h.run_steps(30);
+ click(&mut h, at);
+ h.state_mut().apply_full_screen(false);
+ h.run_steps(30);
+ assert_eq!(h.state().views[0].current, 0, "no page turn after leaving");
+}
diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs
index 39ea83df7..10bb98723 100644
--- a/crates/ui-egui/tests/view.rs
+++ b/crates/ui-egui/tests/view.rs
@@ -2,6 +2,7 @@
use egui::{Key, Modifiers, MouseWheelUnit, TouchPhase};
use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
/// Three 300×400 pt pages. Page 1 links to page 3.
@@ -761,6 +762,57 @@ fn the_rail_zoom_field_sets_a_typed_zoom() {
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%"));
}
+#[test]
+fn always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for() {
+ use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
+ // Opens on page 2 at fit page, two pages side by side.
+ let asks = String::from_utf8_lossy(PAGES).replace("/Pages 2 0 R >>", "/Pages 2 0 R /OpenAction [4 0 R /Fit] /PageLayout /TwoPageLeft >>");
+ let open = |always: Option<&str>| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ if let Some(on) = always {
+ app.set_option("always-default-view", on).unwrap();
+ }
+ app.open_bytes("asks.pdf", None, asks.clone().into_bytes()).expect("opens");
+ let v = &app.views[0];
+ (v.fit, v.layout, v.continuous, v.current)
+ };
+ // Off by default: the document's view wins.
+ assert_eq!(open(None), (Fit::Page, PageLayout::TwoUp, false, 1));
+ assert_eq!(open(Some("off")), (Fit::Page, PageLayout::TwoUp, false, 1));
+ // On: Default zoom and Default page display; the page it opens at is still the document's.
+ assert_eq!(open(Some("on")), (Fit::Width, PageLayout::Single, true, 1));
+ assert!(PdfCraftApp::new().set_option("always-default-view", "yes").is_err());
+}
+
+#[test]
+fn always_use_my_default_zoom_and_page_layout_survives_a_restart() {
+ let mut app = PdfCraftApp::new();
+ assert!(!app.always_default_view, "off by default");
+ app.always_default_view = true;
+ let mut restarted = PdfCraftApp::new();
+ restarted.restore(&app.persist());
+ assert!(restarted.always_default_view, "the choice is kept");
+ restarted.restore(r#"{"always_default_view":"bogus"}"#);
+ assert!(restarted.always_default_view, "garbage leaves it alone");
+ let mut legacy = PdfCraftApp::new();
+ legacy.restore("{}");
+ assert!(!legacy.always_default_view, "a legacy file without the key keeps it off");
+}
+
+#[test]
+fn preferences_turn_on_always_use_my_default_zoom_and_page_layout() {
+ use egui_kittest::kittest::Queryable;
+ let mut h = harness(&[]);
+ assert!(h.state_mut().execute("app.preferences"));
+ h.run_steps(3);
+ h.get_by_label("Always use my default zoom and page layout").scroll_to_me();
+ h.run_steps(4);
+ h.get_by_label("Always use my default zoom and page layout").click();
+ h.run_steps(2);
+ assert!(h.state().always_default_view);
+}
+
#[test]
fn preferences_set_the_default_zoom() {
use egui_kittest::kittest::Queryable;
@@ -1645,3 +1697,139 @@ fn long_view_menu_fits_a_short_window() {
h.run_steps(4);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
+
+fn click_with(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, modifiers: Modifiers) {
+ h.hover_at(pos);
+ h.run_steps(1);
+ for pressed in [true, false] {
+ h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers });
+ }
+ h.run_steps(4);
+}
+
+/// Full screen is Acrobat's Full Screen Mode (#379, #1459): one page at a time, fitted to the
+/// screen, with no tools; a click, Space, Enter or → go forward a page and a ⇧-click, Backspace
+/// or ← back; Esc brings the display from before it back, at the page reached.
+#[test]
+fn full_screen_shows_one_fitted_page_and_clicks_turn_it() {
+ use pdfcraft_ui_egui::canvas::Fit;
+ let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]);
+ h.run_steps(4);
+ assert!(h.state().views[0].continuous(), "continuous before");
+ h.key_press_modifiers(Modifiers::COMMAND, Key::L);
+ h.run_steps(6);
+ assert!(h.state().full_screen);
+ let v = &h.state().views[0];
+ assert!(!v.continuous(), "one page at a time");
+ assert_eq!(v.fit, Fit::Page, "fitted to the screen");
+ assert!(rect(&h, 0).is_some() && rect(&h, 1).is_none(), "only page 1 is on screen");
+ let r = rect(&h, 0).unwrap();
+ let vp = h.state().views[0].viewport_rect();
+ assert!(r.height() <= vp.height() + 1.0 && r.width() <= vp.width() + 1.0, "the page fits: {r:?} in {vp:?}");
+ assert!(h.query_by_label("Select (V)").is_none(), "no quick tools in full screen");
+ click_with(&mut h, r.center(), Modifiers::NONE);
+ assert_eq!(h.state().views[0].current, 1, "a click goes forward a page");
+ h.key_press(Key::ArrowRight);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].current, 2, "→ goes forward");
+ h.key_press(Key::Backspace);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].current, 1, "Backspace goes back");
+ let r = rect(&h, 1).expect("page 2 on screen");
+ click_with(&mut h, r.center(), Modifiers::SHIFT);
+ assert_eq!(h.state().views[0].current, 0, "a shift-click goes back");
+ h.key_press(Key::Space);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].current, 1, "Space goes forward");
+ h.key_press(Key::Escape);
+ h.run_steps(4);
+ assert!(!h.state().full_screen, "Escape leaves full screen");
+ let v = &h.state().views[0];
+ assert!(v.continuous(), "the display from before comes back");
+ assert_eq!(v.fit, Fit::None);
+ assert!((v.zoom - 0.5).abs() < 1e-3, "zoom back to 50%: {}", v.zoom);
+ assert_eq!(v.current, 1, "at the page reached in full screen");
+ h.run_steps(2);
+ assert!(h.query_by_label("Select (V)").is_some(), "the quick tools are back");
+}
+
+/// #2070 review: full screen survives the file being reloaded (Esc still brings the display from
+/// before back), and a document opened in full screen takes its own initial view first, so Esc
+/// comes back to that and full screen stays one spread at a time.
+#[test]
+fn full_screen_survives_a_reload_and_keeps_a_new_documents_own_view() {
+ use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
+ let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]);
+ h.run_steps(4);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::L);
+ h.run_steps(6);
+ {
+ let app = h.state_mut();
+ let info = app.session.get(app.views[0].id).unwrap().info.clone();
+ app.views[0].document_replaced(&info);
+ }
+ h.run_steps(4);
+ assert!(h.state().views[0].presenting(), "still presenting after the reload");
+ h.key_press(Key::Escape);
+ h.run_steps(4);
+ let v = &h.state().views[0];
+ assert!(v.continuous() && v.fit == Fit::None && (v.zoom - 0.5).abs() < 1e-3, "the display from before comes back");
+
+ h.key_press_modifiers(Modifiers::COMMAND, Key::L);
+ h.run_steps(6);
+ let two_columns = String::from_utf8_lossy(PAGES).replace("/Type /Catalog /Pages 2 0 R", "/Type /Catalog /Pages 2 0 R /PageLayout /TwoColumnLeft");
+ h.state_mut().open_bytes("two.pdf", None, two_columns.into_bytes()).expect("opens");
+ h.run_steps(6);
+ let v = h.state().views.last().unwrap();
+ assert!(v.presenting() && v.layout == PageLayout::TwoUp && !v.continuous(), "one spread at a time");
+ h.key_press(Key::Escape);
+ h.run_steps(4);
+ let v = h.state().views.last().unwrap();
+ assert!(v.layout == PageLayout::TwoUp && v.continuous(), "the document's own two-column view comes back");
+}
+
+/// Two-page view stays a spread in full screen (#1353): both pages of the spread, fitted, and
+/// → turns the spread.
+#[test]
+fn full_screen_keeps_two_page_view_as_spreads() {
+ use pdfcraft_ui_egui::canvas::PageLayout;
+ let mut h = harness(&[("layout", "two-up")]);
+ h.run_steps(4);
+ h.key_press_modifiers(Modifiers::COMMAND, Key::L);
+ h.run_steps(6);
+ let v = &h.state().views[0];
+ assert_eq!(v.layout, PageLayout::TwoUp);
+ assert!(!v.continuous(), "one spread at a time");
+ assert!(rect(&h, 0).is_some() && rect(&h, 1).is_some() && rect(&h, 2).is_none(), "the first spread only");
+ h.key_press(Key::ArrowRight);
+ h.run_steps(4);
+ assert_eq!(h.state().views[0].current, 2, "→ turns the spread");
+ assert!(rect(&h, 2).is_some() && rect(&h, 0).is_none());
+ h.key_press(Key::Escape);
+ h.run_steps(4);
+ let v = &h.state().views[0];
+ assert!(v.continuous() && v.layout == PageLayout::TwoUp, "two-page continuous view comes back");
+}
+
+/// Review on #2070: full screen hides the tools and a click turns the page only with Select, so
+/// entering it from another tool selects Select, and leaving gives the tool back.
+#[test]
+fn full_screen_clicks_turn_pages_whatever_tool_was_on() {
+ use pdfcraft_ui_egui::QuickTool;
+ let mut h = harness(&[]);
+ h.run_steps(4);
+ h.state_mut().quick_tool = QuickTool::Hand;
+ h.key_press_modifiers(Modifiers::COMMAND, Key::L);
+ h.run_steps(6);
+ assert!(h.state().full_screen);
+ assert_eq!(h.state().quick_tool, QuickTool::Select, "Select in full screen");
+ // Entering again (a second document asking for it) keeps the tool to give back.
+ h.state_mut().apply_full_screen(true);
+ let r = rect(&h, 0).expect("page 1 on screen");
+ click_with(&mut h, r.center(), Modifiers::NONE);
+ assert_eq!(h.state().views[0].current, 1, "a click goes forward");
+ h.key_press(Key::Escape);
+ h.run_steps(4);
+ assert!(!h.state().full_screen);
+ assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back");
+}
diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs
index b15469485..7bb1bbb3e 100644
--- a/crates/ui-egui/tests/viewer.rs
+++ b/crates/ui-egui/tests/viewer.rs
@@ -330,6 +330,9 @@ fn layouts_fit_height_labels_and_system_theme() {
assert_eq!(labels[1], "ii");
assert!(h.state_mut().views[0].go_to_typed("ii", &labels));
assert_eq!(h.state().views[0].current, 1);
+ // Under the box, the physical position, since "ii" does not say which page it is (#2834).
+ h.run_steps(2);
+ h.get(By::new().label(&format!("2/{}", labels.len())));
assert!(!h.state_mut().views[0].go_to_typed("xx", &labels));
// Follow the system theme.
h.state_mut().set_option("theme", "system").unwrap();
@@ -1408,6 +1411,86 @@ fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool {
h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r)
}
+#[test]
+fn pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection() {
+ let mut h = pages_panel_harness(&[(200, 300); 30]);
+ h.state_mut().views[0].select_pages(&[0, 1]);
+ h.run_steps(2);
+ let usual = thumbnail_rect(&h, 1).width();
+ let zoom = h.state().views[0].zoom;
+ let visible = |h: &Harness<'static, PdfCraftApp>| (1..=30).filter(|n| in_panel_view(h, thumbnail_rect(h, *n))).count();
+ let before = visible(&h);
+ h.get_by_label("Smaller thumbnails").click();
+ h.run_steps(4);
+ assert!(thumbnail_rect(&h, 1).width() < usual);
+ assert!(visible(&h) > before, "smaller thumbnails show more complete pages");
+ assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
+ assert_eq!(h.state().views[0].current, 0);
+ assert_eq!(h.state().views[0].zoom, zoom, "only the thumbnails are resized");
+ assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
+ if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") {
+ h.render().unwrap().save(format!("{dir}/small.png")).unwrap();
+ }
+ h.get_by_label("Larger thumbnails").click();
+ h.run_steps(4);
+ assert!((thumbnail_rect(&h, 1).width() - usual).abs() < 0.1);
+ h.get_by_label("Larger thumbnails").click();
+ h.run_steps(4);
+ assert!(thumbnail_rect(&h, 1).width() > usual);
+ assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
+ if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") {
+ h.render().unwrap().save(format!("{dir}/large.png")).unwrap();
+ h.state_mut().set_option("theme", "dark").unwrap();
+ h.run_steps(4);
+ h.render().unwrap().save(format!("{dir}/large-dark.png")).unwrap();
+ }
+}
+
+#[test]
+fn pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload() {
+ let mut h = pages_panel_harness(&[(200, 300); 6]);
+ let usual = thumbnail_rect(&h, 1).width();
+ let zoom = h.state().views[0].zoom;
+ h.state_mut().set_option("grid-zoom", "200").unwrap();
+ h.state_mut().set_option("thumbnail-zoom", "50%").unwrap();
+ h.run_steps(4);
+ let small = thumbnail_rect(&h, 1).width();
+ assert!(small < usual);
+ for invalid in ["0", "49", "151", "NaN", "inf", "nope"] {
+ assert!(h.state_mut().set_option("thumbnail-zoom", invalid).is_err(), "reject {invalid}");
+ }
+ h.run_steps(3);
+ assert_eq!(thumbnail_rect(&h, 1).width(), small);
+ assert_eq!(h.state().views[0].grid_zoom(), 2.0);
+ assert_eq!(h.state().views[0].zoom, zoom);
+ let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone();
+ h.state_mut().views[0].document_replaced(&info);
+ h.run_steps(4);
+ assert_eq!(thumbnail_rect(&h, 1).width(), small, "file reload keeps the chosen thumbnail size");
+ h.state_mut().views[0].set_thumbnail_zoom(f32::NAN);
+ assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
+ h.state_mut().views[0].set_thumbnail_zoom(f32::MAX);
+ assert_eq!(h.state().views[0].thumbnail_zoom(), 1.5);
+ assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
+ let mut empty = PdfCraftApp::new();
+ assert!(empty.set_option("thumbnail-zoom", "100").is_err());
+}
+
+#[test]
+fn pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible() {
+ let mut h = pages_panel_harness(&[(200, 300); 30]);
+ h.state_mut().views[0].go_to_page(24);
+ h.run_steps(4);
+ for size in ["50", "150", "80", "100"] {
+ h.state_mut().set_option("thumbnail-zoom", size).unwrap();
+ h.run_steps(4);
+ assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 stays visible at {size}%");
+ assert_eq!(h.state().views[0].current, 24);
+ h.get_by_label("Smaller thumbnails");
+ h.get_by_label("Larger thumbnails");
+ }
+}
+
/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages
/// panel to show it.
#[test]
diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md
index 4e21ef2ec..4ce13f195 100644
--- a/docs/pdf-spec-parity.md
+++ b/docs/pdf-spec-parity.md
@@ -52,7 +52,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2
| §14.12 | Document parts (DPart) | yes | none | `core.pdf20-dpart` planned |
| §14.13 | Associated files (`/AF`) | yes | none | `core.pdf20-associated-files` planned |
| — | Structural validation (Arlington PDF model) | yes (Preflight syntax checks) | none | `core.arlington-validation` planned |
-| — | Optional content (layers) | full | **own** toggle and default state; set-OCG-state actions; no layer editing | `view.layers-panel` |
+| — | Optional content (layers) | full | **own** toggle and default state, reset to the default state; set-OCG-state actions; no layer editing | `view.layers-panel`, `view.layers-reset` |
| — | Redaction (`/Redact` annotations, applying) | full | **own**: removes glyphs, image pixels, vectors, XObject content, annotations, fields and tags; verifies no residue | `protect.redact-*`, `protect.redaction-verification` |
## Annotations (§12.5.6)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 07a50c848..1e66c99a1 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,8 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
+> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
+
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -117,7 +120,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
-| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
+| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -126,7 +129,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
-| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Missing: page transitions, recovery on the web |
+| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done: - command registry (menus, shortcuts and palette all use it); - undo/redo; incremental, atomic and encrypted saves; - autosave and crash recovery; - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once; - CLI `edit/combine/extract/split`. Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
@@ -203,6 +206,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
+| 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. |
| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. |
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index a78409fad..9f2d0fd5d 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (column select #740, multi-object move #844, Shift-⌘-G #810 fixed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
@@ -31,9 +31,9 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Document tabs | yes | yes, with unsaved marker | shipped |
| Multiple windows, drag a tab out | yes | no | planned |
| Split window, spreadsheet split | yes | no | planned |
-| Read mode, full screen | yes | yes | shipped; presentation options planned |
+| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned |
| Light, dark, system theme | yes | yes | shipped |
-| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704) | shipped |
+| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped |
| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned |
| Message bars (restricted, signed, PDF/A, forms) | yes | restricted and signed; no PDF/A view mode | partial |
@@ -81,5 +81,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Date | Change | Summary |
|---|---|---|
+| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
+| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) |
| 2026-10-11 | minor | Column select shipped (#740, PR #765); several objects move together on one page (#844, PR #907); Shift-⌘-G goes back (#810, PR #835) |
| 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues |
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index b999d6777..f72721f23 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -34,8 +34,8 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
-evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"]
-notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
+evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"]
+notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
[[feature]]
id = "core.repair-broken-files"
@@ -586,8 +586,8 @@ milestone = "M4"
status = "shipped"
commands = ["file.close"]
tools = ["doc_close"]
-evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename"]
-notes = ""
+evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"]
+notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)."
[[feature]]
id = "core.dirty-indicator"
@@ -670,8 +670,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it"]
-notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously"
+evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it", "crates/ui-egui/tests/ui.rs::dropping_an_open_pdf_again_focuses_its_tab"]
+notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously; a drop with a filesystem path matching an open document focuses that tab (#1620); pathless bytes have nothing to match and open another tab"
[[feature]]
id = "core.recent-files"
@@ -683,7 +683,7 @@ status = "shipped"
commands = ["file.open_recent", "file.clear_recent"]
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"]
-notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it; Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"
+notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it, from any open path (#1620); Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"
[[feature]]
id = "core.reopen-last-session"
@@ -1106,8 +1106,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.full_screen"]
tools = []
-evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves"]
-notes = ""
+evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/view.rs::full_screen_keeps_two_page_view_as_spreads"]
+notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour"
[[feature]]
id = "view.full-screen-presentation"
@@ -1115,11 +1115,11 @@ title = "Full-screen presentation options (auto-advance, loop, click to advance)
area = "B"
tier = "P2"
milestone = "M12"
-status = "planned"
-commands = []
+status = "partial"
+commands = ["view.full_screen"]
tools = []
-evidence = []
-notes = ""
+evidence = ["crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"]
+notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done"
[[feature]]
id = "view.reflow"
@@ -1155,7 +1155,7 @@ status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"]
-notes = "the page box takes labels (iv, A-1) before numbers"
+notes = "the page box shows the current page's label and takes labels (iv, A-1) before numbers; where the label is not the page number the rail shows the physical position under it (3/180, dropped to the page count when it does not fit). Not yet: a preference to show physical numbers only, and labels in the organize bar's Page n of m"
[[feature]]
id = "view.view-history"
@@ -1262,8 +1262,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
-evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
-notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
+evidence = ["crates/ui-egui/tests/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
+notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
[[feature]]
id = "view.thumbnail-context-ops"
@@ -1331,11 +1331,11 @@ title = "Reset layers to initial visibility; list for all or current pages"
area = "B"
tier = "P2"
milestone = "M12"
-status = "planned"
+status = "partial"
commands = []
-tools = []
-evidence = []
-notes = ""
+tools = ["doc_info"]
+evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect.rs::initial_layer_visibility_is_the_default_configuration"]
+notes = "Layers panel ▸ Layer options ▸ Reset to Initial Visibility shows each layer as the default configuration (/OCProperties /D) has it; doc_info reports it as `initial`. Not yet: listing the layers of all pages or the current page only."
[[feature]]
id = "view.destinations-panel"
@@ -2897,7 +2897,7 @@ status = "shipped"
commands = ["page.organize"]
tools = []
evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"]
-notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control."
+notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing in Organize: a slider as in Acrobat. The Pages inspector has its own 50–150% slider and buttons (#3808)."
[[feature]]
id = "organize.split-every-n"
@@ -3256,8 +3256,8 @@ milestone = "M4"
status = "shipped"
commands = ["bookmark.add"]
tools = ["bookmark_add"]
-evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools"]
-notes = ""
+evidence = ["crates/ui-egui/tests/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/engine/src/tests.rs::bookmark_edits_show_in_the_viewer_undo_and_save", "crates/automation/tests/automation.rs::bookmarks_through_tools", "crates/ui-egui/tests/bookmark_position.rs::a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_with_text_selected_goes_to_the_selections_corner", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_in_a_turned_view_pins_the_axis_that_is_vertical_on_screen", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_made_with_text_selected_in_a_turned_view_goes_to_the_corner_shown_top_left", "crates/ui-egui/tests/bookmark_position.rs::a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_the_window", "crates/organize/src/tests.rs::a_bookmark_can_go_to_a_point_on_its_page_and_survives_save", "crates/engine/src/tests.rs::a_bookmark_added_at_a_point_goes_there_through_undo_and_save", "crates/automation/tests/automation.rs::a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation"]
+notes = "A new bookmark goes to where the reader is (#3865): the top-left of the selected text, or with nothing selected the top of the window on the page showing there, as /XYZ left top null (the reader's zoom is kept, and the horizontal position too when only the top is pinned). The window's vertical is what is pinned: on a page turned a quarter (or a view turned with View ▸ Rotate View) that is a user x, and the selection's corner is the one shown at the top-left. In a two-up spread the page crossing the window's top edge is the one used. bookmark_add takes top and left in points from the top-left of the displayed page, the same as bookmark_list reports. Missing: Set to current page (and Set Destination) still go to the page top, the zoom is not recorded, and a bookmark made while a page is only partly laid out (before the first frame) goes to the page top."
[[feature]]
id = "organize.bookmark-rename"
@@ -3484,8 +3484,8 @@ milestone = "M4"
status = "shipped"
commands = ["file.properties"]
tools = ["doc_initial_view"]
-evidence = ["crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"]
-notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)"
+evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"]
+notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)"
[[feature]]
id = "organize.custom-properties"
@@ -9828,8 +9828,8 @@ milestone = "M14"
status = "partial"
commands = []
tools = []
-evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"]
-notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Missing: Acrobat's resolution, rendering and page-content options"
+evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"]
+notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options"
[[feature]]
id = "misc.prefs-documents"