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
The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading