From 8cacb44728f7245da72dde4844856d917ce63f11 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Mon, 12 Oct 2026 02:46:40 -0400 Subject: [PATCH] M3: follow Windows' Animation effects setting; add an Animation effects preference (#1235) * M3: follow Windows' Animation effects setting; Smooth scrolling preference With Settings > Accessibility > Visual effects > Animation effects turned off, the wheel still eased: egui spreads a coarse wheel step over about a quarter of a second, for scrolling and for Ctrl-wheel zooming, and has no switch for it. - crates/ui-egui/src/motion.rs reads each frame's wheel input the way egui does, minus the easing, and hands it to the scroll areas and the views (zoom_delta). A test sums what egui eases and compares it with what is read at once, and fails if egui stops easing. - With the system asking for no animations, egui's own animations take no time and the progress card's bar steps without its sweep. - The desktop app asks WinRT's UISettings.AnimationsEnabled at launch and every two seconds of use, so a change applies without a restart. - Preferences has Smooth scrolling: Use system setting (default), On, Off. On or Off decides the wheel's scrolling and zooming, and scrolling to a place, whatever the system says. Saved, settable as smooth_scrolling on the control channel, translated in all 20 catalogs. How far a notch scrolls or zooms is unchanged. macOS, Linux and the browser aren't asked for their setting yet; Off works everywhere. Co-Authored-By: Claude Opus 5.5 * Credit Jeremy Yao by name in About (contributors/people.toml) My own entry: real name and display name "Jeremy Yao". Co-Authored-By: Claude Opus 5.5 * M3: rename Smooth scrolling to Animation effects; it decides every animation The preference also governs Ctrl-wheel zooming, so "Smooth scrolling" described only part of it. As "Animation effects" (Use system setting, On, Off) it now decides every animation in the window, the way the Windows setting of the same name does: - the wheel's scrolling and zooming, and scrolling to a place (as before); - egui's own animations, the progress card's sweep, and the chrome's hover and press washes (new on main, with durations of their own), which used to follow the system alone or not at all. Saved and set on the control channel as animation_effects (system, on, off); the unreleased smooth_scrolling name is gone. The three labels are retranslated in all 20 catalogs. Co-Authored-By: Claude Opus 5.5 * Tests: the app's two real-GPU tests take turns (FreeBSD SIGSEGV) FreeBSD CI died with SIGSEGV in the pdfcraft binary's tests on this branch (d4b928f1). The log shows a_gpu_error_while_a_window_is_set_up... (started first, still running) and pipeline_creation_error... (started shortly before) both in flight when the process died: two test threads inside the graphics libraries at once. Nothing else still running was native code. Both now take one lock for the whole test, as the UI tests already do for the same reason (crates/ui-egui/tests/view.rs). Each takes about two seconds with a device, which is why they can overlap. The FreeBSD job has been failing at the same step with the same exit code on main (84a75eae, 4a1e205f, 3d2bdfe7: every completed run since #1042 added the second real-GPU test) and on unrelated PRs. Their logs weren't available to confirm it is the same crash. Not reproduced locally (no FreeBSD machine here); the next FreeBSD run is the check. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 40 +- Cargo.lock | 1 + ROADMAP.md | 3 + apps/pdfcraft/Cargo.toml | 4 + apps/pdfcraft/src/main.rs | 23 ++ apps/pdfcraft/src/motion.rs | 75 ++++ contributors/people.toml | 5 + crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/combine_grid.rs | 2 +- crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/js_ui.rs | 8 + crates/ui-egui/src/lib.rs | 16 + crates/ui-egui/src/motion.rs | 557 ++++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 6 +- crates/ui-egui/src/widgets.rs | 18 +- crates/ui-egui/tests/ui.rs | 36 ++ crates/ui-egui/tests/view.rs | 74 ++++ docs/roadmap.md | 4 +- 37 files changed, 903 insertions(+), 33 deletions(-) create mode 100644 apps/pdfcraft/src/motion.rs create mode 100644 crates/ui-egui/src/motion.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index bad3193b4..e5ed7ff4b 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 = "a02ef4f663e133d6242bfef555d76827f1b8852e607f29bdf833d7b9499e8e01" +sha256 = "77b7fedb0a4f097893a4c6901ca7e35a5b86b04d5582a26ee282dde772df90cd" # 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 = "6c387bdd051a3eb032644c361b54219f14b44e2e447651bbe8980120fc925bbf" +sha256 = "931bd6e497c0065624cad3e1bcb94ed9bfeeb97402a58e887e7e3fa0be4da298" [[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 = "febaf4c36584c39364e037014831af6208d2dd13688b131b47f1b93403b2c190" +sha256 = "707d1ffa78f0dee179bc7531fec231aba496eddbb6c82c814887913161ea0215" [[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 = "7fce0858eb1cb48c978742f6f2ffaa70960b9e53c82125c16d19c000a29c4d2e" +sha256 = "fb9f1350f73b270a361ce087ea5f9fc899132cb1804dae92cbac5df2da4b1d18" [[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 = "3ae198601bf7ce20496c43412beac51476454f5ddf2a8c95abc6f49dc12f0347" +sha256 = "e182df8e86127ee2e93a7aa274139964f4bc0e47aa3e5e5af1b56bd5a352cb30" [[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 = "6a47c0408b555e1fa7e7587dabe89486c1df2f47d8c7caca104cd22758634693" +sha256 = "462da2f08ec2687db4745ab6226220e4857ac785228a8593574a0ed1b4de94f0" [[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 = "bcac30db22be260efcf240263922fcf8f5b69afd218f211431f18f9f8edf211d" +sha256 = "7a41df6534ff5ec76b291c6c21cadb88cd3c384d02a00f42cd1eb0ffbb6901a4" [[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 = "f7ab9bdfa5498abe967e49729d1d4ca2202ea01273a28eed1505796e2f0c17eb" +sha256 = "536f1b899a8075171e9d61b0bd0eec11af8b705ebf29c8f578bbee75562412d3" [[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 = "e15e8117be1521fb57c9d69d74afb08ac7c07c53beb9ec08ebc3c764a6d16a14" +sha256 = "c015f67b1d8a16069b676df39c0e3bfa397e1b94dd1fa1f4717b2fd53fa0335c" [[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 = "c0bb0ff06f0a37045ffe74d25e4277ed8e129b40d5c5f9c103b8bded77aeea86" +sha256 = "6285280ada0ed9f01298d22ac30c6efff7f12038b32c7b30476b9548a846f999" [[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 = "eefa762fc8ba26812739996d99e266d9431decf5554b0776f4577cd591cdee1e" +sha256 = "a9dff963485baf31241bc042b18e81a71e3670a27e8532d2ec21268ac6b55b28" [[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 = "7b4f5a9f13a21acc9b0b277b2d4dbc3678e8d4ea7eba71928f7310742d156365" +sha256 = "c0a8eea56e175d053517818c44ba937703ac83343c327eb19c580d12dee51a22" [[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 = "7231d68ccffa65b2bfabcfd5e532cb6b6583eb7e46963838ccce49dc7c7c11d7" +sha256 = "6cb45c9d8193d4c841d93a11c82d52a16c13c87f49d5443360b4064139497b41" [[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 = "9227c30d8efd89abb6a10332dc514254cca0f0387a4eb1be63b62a1b585a5f40" +sha256 = "9eb29f5aae67683db578174e5d5529cbd8e5dd74703f33be12172edcbafdcbbd" [[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 = "20f221b7c7f0c7afb7c06fb37689c53a17ab141f4f1402192956154ed247bd93" +sha256 = "7d20cac93b5f173323fd7d4bfc2a499ea353635ccf6ed0b330798cbed0b21a6e" [[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 = "87aadc11cba25578d76137291edff1a47fcf8617db84720b92ec4928be27388c" +sha256 = "771074e8ecd3a1bf47e62008150137f025160e3820885ffbe9e7d53b803d5fd6" [[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 = "701c37cbc525ccfaf5df02a1fbf6c31acf43d84f8dd57b0051a0958e66c02b57" +sha256 = "fda28db1e8c8b805c083f5b046fef4f89feade599d0c4ce93ba8029d68357348" [[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 = "48c24aebb813b279e0ea8aed678a9b2adb1da448e3ad8628694d18b49b0fa823" +sha256 = "fc002dfa644d736875bb17f7f40f121c3b59b6156bcb233a91c3b042444af16f" [[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 = "6e9c96dc18bacbeb7b13a912257f07436a19fab7b836e79dfd3f0f12d45a0c7b" +sha256 = "88cc3b28d57a0d3d8cd5e59e9f32bfae183a383a24505ec699b911b2d02ef651" [[asset]] path = "assets/icons/accessibility.svg" @@ -3328,4 +3328,4 @@ 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 = "7ef2f55454c34ef6d0c279b9a3e0817b0a8e0b4ff4e13e13bad44ff9a487e992" +sha256 = "f4652ba2d75a207c28f8600b788740d777d64a96e65a48b1d71a379de033903e" diff --git a/Cargo.lock b/Cargo.lock index 0fa55b03a..07a3e3491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3574,6 +3574,7 @@ dependencies = [ "ureq", "wayland-client", "wayland-protocols", + "windows", "winresource", "winsafe", ] diff --git a/ROADMAP.md b/ROADMAP.md index b22e3122b..34aafe292 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -166,6 +167,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. - **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. +- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged. - **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged. - **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. - **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. @@ -391,6 +393,7 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | +| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, no other animations), and Preferences has Animation effects (Use system setting, On, Off). Estimates unchanged. | | 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index b7028552b..a0f051c11 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -42,6 +42,10 @@ pollster = "1.0" # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that # shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). winsafe = { version = "0.0.29", default-features = false, features = ["user"] } +# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's +# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe +# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it. +windows = { version = "0.62", default-features = false, features = ["std", "UI_ViewManagement"] } # Single instance (#282): later launches hand their files to the running app over a Unix domain # socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which # our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ca44b7dfb..c66d2c68f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -33,6 +33,8 @@ use pdfcraft_ui_egui::PdfCraftApp; mod apple_events; mod compositor_gpu; mod logging; +#[cfg(target_os = "windows")] +mod motion; mod reopen; mod single_instance; mod text_scale; @@ -312,6 +314,12 @@ fn app_creator<'a>( } app.integrated_titlebar = integrated; app.system_text_scale = text_scale::system(); + // Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless + // it is set to On or Off. + #[cfg(target_os = "windows")] + { + app.motion.source = Some(motion::source()); + } app.update_source = Some(std::sync::Arc::new(updates::latest_release)); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); @@ -672,6 +680,19 @@ mod tests { Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) } + /// Tests that open a real GPU device take this first, so only one of them is inside the + /// graphics libraries at a time. The test threads otherwise run them together, and on + /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: + /// the first device was still being set up when the second test began its own. The UI + /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole + /// test (declared before the instance, so it's released after the device is dropped). + static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + #[test] fn a_control_channel_that_cant_be_published_stops_the_app() { let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); @@ -878,6 +899,7 @@ mod tests { // and an error wgpu raises before anything reaches the driver: a texture one pixel wider // than the device allows. use eframe::wgpu; + let _gpu = gpu(); let native = super::native_options(false, eframe::Renderer::Wgpu); let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") @@ -944,6 +966,7 @@ mod tests { #[test] fn pipeline_creation_error_is_returned_and_device_remains_usable() { use eframe::{egui_wgpu, wgpu}; + let _gpu = gpu(); let mut native = super::native_options(false, eframe::Renderer::Wgpu); let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); diff --git a/apps/pdfcraft/src/motion.rs b/apps/pdfcraft/src/motion.rs new file mode 100644 index 000000000..1aa48d48b --- /dev/null +++ b/apps/pdfcraft/src/motion.rs @@ -0,0 +1,75 @@ +//! The system's "no animations" setting, which Preferences ▸ Animation effects follows unless it is +//! set to On or Off (`pdfcraft_ui_egui::motion`). +//! +//! Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (in the Control Panel, +//! "Show animations in Windows"), read through WinRT's `UISettings.AnimationsEnabled`. Other +//! systems aren't asked yet: there the window animates as before. + +use pdfcraft_ui_egui::motion::MotionSource; + +/// Asks the system each time, and logs the answer when it changes. +pub fn source() -> MotionSource { + let last = std::cell::Cell::new(None); + Box::new(move || { + let reduced = reduced(); + if reduced.is_some() && last.replace(reduced) != reduced { + log::info!("system animation effects: {}", if reduced == Some(true) { "off" } else { "on" }); + } + reduced + }) +} + +/// Whether the system asks for no animations; `None` when it can't be asked. +pub fn reduced() -> Option { + match animations_enabled() { + Ok(enabled) => Some(!enabled), + Err(e) => { + log::debug!("system animation effects: {e}"); + None + } + } +} + +fn animations_enabled() -> windows::core::Result { + windows::UI::ViewManagement::UISettings::new()?.AnimationsEnabled() +} + +#[cfg(test)] +mod tests { + /// The user's saved Animation effects choice, or `None` when `reg` can't read it. Windows + /// keeps its on/off system parameters as the bits of `UserPreferencesMask`: bit n is the + /// parameter `SPI_GET…` 0x1000 + 2n, and client-area animation is 0x1042, so bit 33. + fn saved_choice() -> Option { + let out = std::process::Command::new("reg").args(["query", r"HKCU\Control Panel\Desktop", "/v", "UserPreferencesMask"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let hex = text.split_whitespace().skip_while(|word| *word != "REG_BINARY").nth(1)?; + let byte = u8::from_str_radix(hex.get(8..10)?, 16).ok()?; + Some(byte & 0b10 != 0) + } + + #[test] + fn the_answer_is_the_systems_animation_effects_setting() { + let answer = super::reduced(); + // The first answer is logged and later ones only when they change; the answers don't. + let source = super::source(); + assert_eq!((source(), source()), (answer, answer)); + match super::animations_enabled() { + // With Animation effects turned off, the answer must be "reduced". Only this way + // round: the session's own value may be off for other reasons (a policy, a remote + // session) while the saved choice is on. + Ok(enabled) => { + assert_eq!(answer, Some(!enabled)); + if saved_choice() == Some(false) { + assert!(!enabled, "Animation effects are off in Settings, but the answer was that animations are on"); + } + } + Err(e) => { + eprintln!("UISettings isn't available here ({e}); not compared with the saved choice"); + assert_eq!(answer, None); + } + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index de6384b0c..b8aae3c0b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -118,3 +118,8 @@ sync_github_name = true [people.lollinoo] sync_github_name = true + +[people.JeremyYao] +real_name = "Jeremy Yao" +display_name = "Jeremy Yao" +sync_github_name = false diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e0e2c144..1feaa2b9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2082,7 +2082,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // Pinch / ctrl+scroll zoom, anchored on the current page. - let zoom_delta = ui.input(|i| i.zoom_delta()); + let zoom_delta = crate::motion::zoom_delta(ui.ctx()); // 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); @@ -4335,7 +4335,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool } view.auto_scroll.paint(ui, viewport); // Pinch, or Ctrl/⌘ with the wheel, zooms the grid about the page under the pointer. - let pinch = ui.input(|i| i.zoom_delta()); + let pinch = crate::motion::zoom_delta(ui.ctx()); if (pinch - 1.0).abs() > 0.001 && pointer.is_some() && !middle_gesture { view.grid_zoom_request = Some(view.grid_zoom * pinch); } diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index 80facbc84..7fadf78cb 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -751,7 +751,7 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: app.combine_tab.card_focus_at = focused_card.map(|(_, file, nth)| (file, nth)); // Pinch, or Ctrl/⌘ with the wheel, over the grid zooms it. - let pinch = ctx.input(|i| i.zoom_delta()); + let pinch = crate::motion::zoom_delta(&ctx); if (pinch - 1.0).abs() > 0.001 && over_grid { app.combine_tab.zoom_request = Some(app.combine_zoom * pinch); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 7332e9f1f..ca34d7e80 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2279,6 +2279,9 @@ key Space مسافة scroll speed Slow بطيئة scroll speed Normal عادية scroll speed Fast سريعة + Animation effects تأثيرات الحركة +animation effects On مفعّلة +animation effects Off معطّلة Enhance camera image تحسين صورة الكاميرا Drag the corners onto the document's edges, then choose Enhance page. اسحب الزوايا إلى حواف المستند، ثم اختر تحسين الصفحة. Enhance page تحسين الصفحة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 82995e18a..a9d8525a5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2221,6 +2221,9 @@ key Space Интервал scroll speed Slow Ниска scroll speed Normal Нормална scroll speed Fast Висока + Animation effects Анимационни ефекти +animation effects On Включени +animation effects Off Изключени Enhance camera image Подобри снимка от камера Drag the corners onto the document's edges, then choose Enhance page. Плъзнете ъглите до краищата на документа, след това изберете „Подобри страницата“. Enhance page Подобри страницата diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 87f7bcec4..24097e038 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2309,6 +2309,9 @@ certificate Subject Subjekt scroll speed Slow Pomalá scroll speed Normal Normální scroll speed Fast Rychlá + Animation effects Animační efekty +animation effects On Zapnuto +animation effects Off Vypnuto # Counts that take plural forms: optimized images, discarded items, bytes, pixels and deleted fields. @plural {n} image optimized|{n} images optimized {n} obrázek optimalizován|{n} obrázky optimalizovány|{n} obrázků optimalizováno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a21593c51..d421a3bbe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2259,6 +2259,9 @@ key Space Leertaste scroll speed Slow Langsam scroll speed Normal Normal scroll speed Fast Schnell + Animation effects Animationseffekte +animation effects On Ein +animation effects Off Aus Enhance camera image Kamerafoto verbessern Drag the corners onto the document's edges, then choose Enhance page. Ziehen Sie die Ecken auf die Kanten des Dokuments und wählen Sie dann Seite verbessern. Enhance page Seite verbessern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4c1acb68c..e84cee095 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2270,6 +2270,9 @@ comment menu Edit Επεξεργασία scroll speed Slow Αργή scroll speed Normal Κανονική scroll speed Fast Γρήγορη + Animation effects Εφέ κίνησης +animation effects On Ενεργά +animation effects Off Ανενεργά Enhance camera image Βελτίωση εικόνας κάμερας Drag the corners onto the document's edges, then choose Enhance page. Σύρετε τις γωνίες στις άκρες του εγγράφου και επιλέξτε Βελτίωση σελίδας. Enhance page Βελτίωση σελίδας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 06b5fb591..2b7f8d0b9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2387,6 +2387,9 @@ key Space Espacio scroll speed Slow Lenta scroll speed Normal Normal scroll speed Fast Rápida + Animation effects Efectos de animación +animation effects On Activados +animation effects Off Desactivados Enhance camera image Mejorar foto de cámara Drag the corners onto the document's edges, then choose Enhance page. Arrastra las esquinas hasta los bordes del documento y elige Mejorar página. Enhance page Mejorar página diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 839136671..072dffc55 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2258,6 +2258,9 @@ key Space Espace scroll speed Slow Lente scroll speed Normal Normale scroll speed Fast Rapide + Animation effects Effets d'animation +animation effects On Activés +animation effects Off Désactivés Enhance camera image Améliorer la photo d'un document Drag the corners onto the document's edges, then choose Enhance page. Faites glisser les coins sur les bords du document, puis choisissez Améliorer la page. Enhance page Améliorer la page diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 88d537459..ec6c3215a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2257,6 +2257,9 @@ key Space Szóköz scroll speed Slow Lassú scroll speed Normal Normál scroll speed Fast Gyors + Animation effects Animációs effektusok +animation effects On Be +animation effects Off Ki Enhance camera image Kamerakép javítása Drag the corners onto the document's edges, then choose Enhance page. Húzza a sarkokat a dokumentum széleire, majd válassza az Oldal javítása lehetőséget. Enhance page Oldal javítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1b293065d..91be8b209 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2267,6 +2267,9 @@ key Space Spazio scroll speed Slow Lenta scroll speed Normal Normale scroll speed Fast Veloce + Animation effects Effetti di animazione +animation effects On Attivati +animation effects Off Disattivati Enhance camera image Migliora foto della fotocamera Drag the corners onto the document's edges, then choose Enhance page. Trascina gli angoli sui bordi del documento, poi scegli Migliora la pagina. Enhance page Migliora la pagina diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a3c52156b..c2ec24c40 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2258,6 +2258,9 @@ key Space スペース scroll speed Slow 遅い scroll speed Normal 標準 scroll speed Fast 速い + Animation effects アニメーション効果 +animation effects On オン +animation effects Off オフ Enhance camera image カメラ画像を補正 Drag the corners onto the document's edges, then choose Enhance page. 四隅を文書の端までドラッグしてから、「ページを補正」を選択します。 Enhance page ページを補正 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 891527287..9683235a3 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2253,6 +2253,9 @@ key Space Бос орын scroll speed Slow Баяу scroll speed Normal Қалыпты scroll speed Fast Жылдам + Animation effects Анимация әсерлері +animation effects On Қосулы +animation effects Off Өшірулі Enhance camera image Камера суретін жақсарту Drag the corners onto the document's edges, then choose Enhance page. Бұрыштарды құжаттың жиектеріне сүйреп, содан кейін «Бетті жақсарту» таңдаңыз. Enhance page Бетті жақсарту diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fbe11c0ce..7ba175be7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2239,6 +2239,9 @@ key Space Spacja scroll speed Slow Wolna scroll speed Normal Normalna scroll speed Fast Szybka + Animation effects Efekty animacji +animation effects On Włączone +animation effects Off Wyłączone Enhance camera image Popraw zdjęcie z aparatu Drag the corners onto the document's edges, then choose Enhance page. Przeciągnij narożniki na krawędzie dokumentu, a następnie wybierz Popraw stronę. Enhance page Popraw stronę diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 152c46809..3824a6fee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2272,6 +2272,9 @@ calendar date December dezembro scroll speed Slow Lenta scroll speed Normal Normal scroll speed Fast Rápida + Animation effects Efeitos de animação +animation effects On Ativados +animation effects Off Desativados Enhance camera image Aprimorar foto da câmera Drag the corners onto the document's edges, then choose Enhance page. Arraste os cantos até as bordas do documento e escolha Aprimorar página. Enhance page Aprimorar página diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 51f97fb7c..1cda1a295 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2171,6 +2171,9 @@ key Space Пробел scroll speed Slow Низкая scroll speed Normal Обычная scroll speed Fast Высокая + Animation effects Эффекты анимации +animation effects On Вкл +animation effects Off Выкл Enhance camera image Улучшить снимок с камеры Drag the corners onto the document's edges, then choose Enhance page. Перетащите углы к краям документа, затем выберите «Улучшить страницу». Enhance page Улучшить страницу diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 31958996f..d01990c46 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2260,6 +2260,9 @@ key Space Preslednica scroll speed Slow Počasi scroll speed Normal Običajno scroll speed Fast Hitro + Animation effects Učinki animacije +animation effects On Vklopljeni +animation effects Off Izklopljeni Enhance camera image Izboljšaj fotografijo s kamere Drag the corners onto the document's edges, then choose Enhance page. Povlecite vogale na robove dokumenta, nato izberite Izboljšaj stran. Enhance page Izboljšaj stran diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ca561d0ff..e306e98c8 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2173,6 +2173,9 @@ key Space స్పేస్ scroll speed Slow నెమ్మదిగా scroll speed Normal సాధారణం scroll speed Fast వేగంగా + Animation effects యానిమేషన్ ప్రభావాలు +animation effects On ఆన్ +animation effects Off ఆఫ్ Enhance camera image కెమెరా చిత్రాన్ని మెరుగుపరచు Drag the corners onto the document's edges, then choose Enhance page. మూలలను పత్రం అంచుల వరకు లాగి, ఆపై పేజీని మెరుగుపరచు ఎంచుకోండి. Enhance page పేజీని మెరుగుపరచు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8fc23d085..2971ab7ab 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2251,6 +2251,9 @@ key Space Boşluk scroll speed Slow Yavaş scroll speed Normal Orta scroll speed Fast Hızlı + Animation effects Animasyon efektleri +animation effects On Açık +animation effects Off Kapalı Enhance camera image Kamera görüntüsünü iyileştir Drag the corners onto the document's edges, then choose Enhance page. Köşeleri belgenin kenarlarına sürükleyin, ardından Sayfayı iyileştir'i seçin. Enhance page Sayfayı iyileştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dd5d03329..0c229d634 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2280,6 +2280,9 @@ key Space Пробіл scroll speed Slow Повільна scroll speed Normal Звичайна scroll speed Fast Швидка + Animation effects Ефекти анімації +animation effects On Увімкнено +animation effects Off Вимкнено Enhance camera image Поліпшити знімок з камери Drag the corners onto the document's edges, then choose Enhance page. Перетягніть кути до країв документа, потім виберіть «Поліпшити сторінку». Enhance page Поліпшити сторінку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6f8420c72..7dcb80274 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2222,6 +2222,9 @@ key Space 空格 scroll speed Slow 慢 scroll speed Normal 正常 scroll speed Fast 快 + Animation effects 动画效果 +animation effects On 开 +animation effects Off 关 Enhance camera image 增强相机图像 Drag the corners onto the document's edges, then choose Enhance page. 将四个角拖到文档边缘,然后选择“增强页面”。 Enhance page 增强页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 18305faa1..6cfa6aa4e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2132,6 +2132,9 @@ key Space 空格 scroll speed Slow 慢 scroll speed Normal 正常 scroll speed Fast 快 + Animation effects 動畫效果 +animation effects On 開 +animation effects Off 關 Table rows belong directly in a table or its head, body or foot. Fix the table's tags. 表格列應直接位於表格或其表頭、主體、表尾內。請修正表格標記。 Enhance camera image 增強相機影像 Drag the corners onto the document's edges, then choose Enhance page. 將四個角拖曳到文件邊緣,然後選擇「增強頁面」。 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 95f36911c..1f89ae601 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -357,6 +357,14 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok widgets::radio_value(ui, &mut app.scroll_speed, speed, tl_ctx!("scroll speed", speed.label())); } }); + // Whether anything animates, the wheel's scrolling and zooming included; left alone it + // follows the system's animation setting. + ui.horizontal(|ui| { + ui.label(tl!("Animation effects")); + for choice in crate::motion::AnimationEffects::ALL { + widgets::radio_value(ui, &mut app.animation_effects, choice, tl_ctx!("animation effects", choice.label())); + } + }); ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5141109d8..277580d9d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,7 @@ mod home; mod icon_data; pub mod icons; pub mod last_session; +pub mod motion; mod page_selection_ui; mod pageboxes; mod palette; @@ -474,6 +475,10 @@ pub struct PdfCraftApp { desktop_theme_enabled: bool, /// How far a mouse-wheel notch scrolls. pub scroll_speed: scroll_speed::ScrollSpeed, + /// Preferences ▸ Animation effects: on, off, or as the system's animation setting says. + pub animation_effects: motion::AnimationEffects, + /// Whether the system asked for no animations, and how the desktop app asks it. + pub motion: motion::Motion, /// Interface language preference: `auto` (follow the system) or a code from [`i18n::LANGUAGES`]. pub language: String, /// Preferences ▸ Interface size as a factor, or `None` to follow [`Self::system_text_scale`]. @@ -800,6 +805,8 @@ impl PdfCraftApp { #[cfg(target_os = "linux")] desktop_theme_enabled: false, scroll_speed: scroll_speed::ScrollSpeed::default(), + animation_effects: motion::AnimationEffects::default(), + motion: motion::Motion::default(), language: i18n::AUTO.to_string(), ui_scale: None, system_text_scale: 1.0, @@ -1708,6 +1715,7 @@ impl PdfCraftApp { "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, + "animation_effects": self.animation_effects, "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(), @@ -1793,6 +1801,9 @@ impl PdfCraftApp { if let Ok(speed) = serde_json::from_value::(v["scroll_speed"].clone()) { self.scroll_speed = speed; } + if let Ok(effects) = serde_json::from_value::(v["animation_effects"].clone()) { + self.animation_effects = effects; + } if let Ok(mode) = serde_json::from_value::(v["default_mode"].clone()) { self.default_mode = mode; } @@ -1919,6 +1930,9 @@ impl PdfCraftApp { ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } + ("animation_effects", _) => { + self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?; + } ("panel", _) => { self.right = match value { "comments" => Some(RightPanel::Comments), @@ -2351,6 +2365,8 @@ impl eframe::App for PdfCraftApp { self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some(); // Also done in `raw_input_hook`, before input; here for hosts that don't call the hook. self.scroll_speed.set_line_speed(ctx); + // Before anything reads this frame's wheel input or draws, and with the line speed just set. + self.motion.begin_pass(ctx, self.animation_effects); // Before taking this frame's drop: the grid must be drawn once with the pointer where // the files were let go before the gap is read. self.finish_grid_drop(ctx); diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs new file mode 100644 index 000000000..c091cbd97 --- /dev/null +++ b/crates/ui-egui/src/motion.rs @@ -0,0 +1,557 @@ +//! Animation effects, and the system's request to do without them. +//! +//! Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, turned off, asks +//! apps not to animate (other systems have a Reduce motion switch for it). The desktop app +//! reads the setting ([`MotionSource`]), and Preferences ▸ Animation effects +//! ([`AnimationEffects`]) follows it unless the user chose On or Off there. With animation +//! effects off, nothing in the window eases: +//! +//! - A wheel notch scrolls its whole distance in the frame it arrives. egui spreads a notch +//! over about a quarter of a second and has no switch for that, so each frame's wheel input +//! is read again here, the way egui reads it but for the easing ([`WheelReader`]), and handed +//! to egui's scroll areas in place of its own. +//! - ⌘/Ctrl-wheel zooms by a notch's whole step at once: egui makes its zoom factor from the +//! same eased wheel motion. Views read [`zoom_delta`] instead of egui's. +//! - Scrolling to a place (a row revealed by the keys, the Hand tool's drag) takes no time. +//! - egui's other animations (menus fading in, sections opening) take no time: its style's +//! animation time is zero, which the app's own fades and sweeps ask about too ([`animated`]). +//! +//! How far anything scrolls or zooms doesn't change, only how long it takes. + +use egui::style::ScrollAnimation; +use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, Vec2, vec2}; + +/// Preferences ▸ Animation effects: whether anything in the window is animated, the wheel's +/// scrolling and zooming included. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AnimationEffects { + /// Animated unless the system asks for no animations ([`Motion::reduced`]). + #[default] + System, + On, + Off, +} + +impl AnimationEffects { + pub const ALL: [Self; 3] = [Self::System, Self::On, Self::Off]; + + pub fn label(self) -> &'static str { + match self { + Self::System => "Use system setting", + Self::On => "On", + Self::Off => "Off", + } + } + + /// The name in saved settings and on the control channel. + pub fn name(self) -> &'static str { + match self { + Self::System => "system", + Self::On => "on", + Self::Off => "off", + } + } + + pub fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|choice| choice.name().eq_ignore_ascii_case(value)) + } + + /// Whether the window animates, given whether the system asks for no animations. + pub fn animates(self, system_reduced: bool) -> bool { + match self { + Self::System => !system_reduced, + Self::On => true, + Self::Off => false, + } + } +} + +/// Asks the system whether it wants no animations; `None` when it can't say, which leaves the +/// last answer standing. Asked from the UI thread every [`ASK_EVERY`] seconds while the window +/// is in use, so it must answer at once. +pub type MotionSource = Box Option>; + +/// Seconds between two questions to [`MotionSource`]. Changing the setting takes longer than +/// this away from the window, so the first frame back already follows it. +const ASK_EVERY: f64 = 2.0; + +/// What the system says about animations, and what it takes to stop egui's. +pub struct Motion { + /// The system asks for no animations. The desktop app's [`Self::source`] sets it; false + /// elsewhere, so tests and screenshots don't depend on the machine. + pub reduced: bool, + /// How to ask the system (the desktop app sets it). + pub source: Option, + /// egui time of the last question. + asked_at: f64, + /// egui's own animation time and scroll animation, kept while they are changed here. + egui_times: Option<(f32, ScrollAnimation)>, + wheel: WheelReader, +} + +impl Default for Motion { + fn default() -> Self { + Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default() } + } +} + +impl Motion { + /// Call once at the start of every pass, before any ui reads the wheel or draws. + pub(crate) fn begin_pass(&mut self, ctx: &egui::Context, effects: AnimationEffects) { + let now = ctx.input(|i| i.time); + // `>=` is false for a NaN time, which then never asks. + if now - self.asked_at >= ASK_EVERY + && let Some(source) = &self.source + { + self.asked_at = now; + if let Some(reduced) = source() { + self.reduced = reduced; + } + } + let animate = effects.animates(self.reduced); + self.sync_style(ctx, animate); + if animate { + // A gesture begun while easing is egui's to finish. + self.wheel = WheelReader::default(); + return; + } + let options = ctx.options(|o| o.input_options); + let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); + ctx.input_mut(|i| i.smooth_scroll_delta = motion.scroll); + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, motion.zoom))); + } + + /// egui's animations, and its scrolling to a place, take no time unless the window is to + /// `animate`; then both get back the times egui had. + fn sync_style(&mut self, ctx: &egui::Context, animate: bool) { + if animate && self.egui_times.is_none() { + return; + } + // Checked every pass, so a style replaced since can't bring the animations back. + let style = ctx.global_style(); + let egui_times = *self.egui_times.get_or_insert((style.animation_time, style.scroll_animation)); + let wanted = if animate { egui_times } else { (0.0, ScrollAnimation::none()) }; + if (style.animation_time, style.scroll_animation) != wanted { + ctx.all_styles_mut(|s| (s.animation_time, s.scroll_animation) = wanted); + } + if animate { + self.egui_times = None; + } + } +} + +/// Whether the window animates this pass. The app's own fades and sweeps, which have their own +/// durations, ask this; egui's animations stop by themselves ([`Motion::begin_pass`]). +pub(crate) fn animated(ctx: &egui::Context) -> bool { + ctx.global_style().animation_time > 0.0 +} + +fn zoom_id() -> egui::Id { + egui::Id::new("pdfcraft-motion-wheel-zoom") +} + +/// This pass's zoom factor from a pinch or ⌘/Ctrl with the wheel (1.0 for none): egui's +/// `zoom_delta`, without the easing while animation effects are off. +pub(crate) fn zoom_delta(ctx: &egui::Context) -> f32 { + let read: Option<(u64, f32)> = ctx.data(|d| d.get_temp(zoom_id())); + let pass = ctx.cumulative_pass_nr(); + ctx.input(|i| match read { + // Only what [`Motion::begin_pass`] read in this very pass: a factor left from an earlier + // one would zoom again every frame. Fingers on a touch screen are measured, never eased. + Some((at, zoom)) if at == pass && i.multi_touch().is_none() => zoom, + _ => i.zoom_delta(), + }) +} + +/// One pass's wheel input. +#[derive(Clone, Copy, Debug, PartialEq)] +struct WheelMotion { + /// Points to scroll by, as `InputState::smooth_scroll_delta` gives them (positive moves the + /// content right and down). + scroll: Vec2, + /// The zoom factor from pinch events and ⌘/Ctrl with the wheel. + zoom: f32, +} + +/// Reads wheel events as egui's `WheelState` does (egui 0.36, `input_state/wheel_state.rs`): +/// the same units, modifiers and touch phases, but every step lands whole in its own pass, +/// where egui keeps part of a coarse one for later passes. Compare the two when updating egui; +/// `tests::reading_at_once_goes_as_far_as_egui_eases` fails if they come apart. +#[derive(Default)] +struct WheelReader { + /// The modifiers of the touch gesture in progress (`TouchPhase::Start` to `End`). They hold + /// for the whole gesture, as in egui: its momentum arrives after the keys are let go. + touch: Option, +} + +impl WheelReader { + /// `page_height` is what one `MouseWheelUnit::Page` scrolls: the window's height in points. + fn read(&mut self, events: &[Event], page_height: f32, options: &egui::InputOptions) -> WheelMotion { + let mut delta = Vec2::ZERO; + let mut zoom = 1.0_f32; + let mut modifiers = self.touch.unwrap_or_default(); + for event in events { + match event { + Event::MouseWheel { unit, delta: step, phase, modifiers: latest } => match phase { + TouchPhase::Start => { + self.touch = Some(*latest); + modifiers = *latest; + } + TouchPhase::Move => { + modifiers = match &mut self.touch { + Some(held) => { + *held |= *latest; + *held + } + None => *latest, + }; + let mut step = match unit { + MouseWheelUnit::Point => *step, + MouseWheelUnit::Line => options.line_scroll_speed * *step, + MouseWheelUnit::Page => page_height * *step, + }; + let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); + let vertical = modifiers.matches_any(options.vertical_scroll_modifier); + if horizontal && !vertical { + step = vec2(step.x + step.y, 0.0); + } + if !horizontal && vertical { + step = vec2(0.0, step.x + step.y); + } + // The system's numbers: one that isn't finite would end up in a scroll offset. + if step.x.is_finite() && step.y.is_finite() { + delta += step; + } + } + // egui drops what the gesture moved earlier in the pass it ends in. + TouchPhase::End | TouchPhase::Cancel => { + self.touch = None; + modifiers = Modifiers::NONE; + delta = Vec2::ZERO; + } + }, + Event::Zoom(factor) => zoom *= *factor, + _ => {} + } + } + // A pass is all zoom or all scroll, by the modifiers it ends with, as in egui. + let mut scroll = delta; + if modifiers.matches_any(options.zoom_modifier) { + zoom *= (options.scroll_zoom_speed * (delta.x + delta.y)).exp(); + scroll = Vec2::ZERO; + } + if !(zoom.is_finite() && zoom > 0.0) { + zoom = 1.0; + } + WheelMotion { scroll, zoom } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta, phase, modifiers } + } + + fn notch(dy: f32, modifiers: Modifiers) -> Event { + wheel(MouseWheelUnit::Line, vec2(0.0, dy), TouchPhase::Move, modifiers) + } + + fn read(events: &[Event]) -> WheelMotion { + WheelReader::default().read(events, 600.0, &egui::InputOptions::default()) + } + + #[test] + fn a_step_is_read_whole_in_its_units() { + let line = egui::InputOptions::default().line_scroll_speed; + assert_eq!(read(&[notch(-1.0, Modifiers::NONE)]), WheelMotion { scroll: vec2(0.0, -line), zoom: 1.0 }); + assert_eq!(read(&[notch(-1.0, Modifiers::NONE), notch(-2.0, Modifiers::NONE)]).scroll, vec2(0.0, -3.0 * line), "steps of one pass add up"); + let points = wheel(MouseWheelUnit::Point, vec2(3.0, -250.0), TouchPhase::Move, Modifiers::NONE); + assert_eq!(read(&[points]).scroll, vec2(3.0, -250.0)); + let page = wheel(MouseWheelUnit::Page, vec2(0.0, -1.0), TouchPhase::Move, Modifiers::NONE); + assert_eq!(read(&[page]).scroll, vec2(0.0, -600.0)); + assert_eq!(read(&[]), WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }, "and nothing is left for a later pass"); + } + + #[test] + fn modifiers_turn_the_wheel_sideways_or_into_zoom() { + let line = egui::InputOptions::default().line_scroll_speed; + assert_eq!(read(&[notch(-1.0, Modifiers::SHIFT)]).scroll, vec2(-line, 0.0)); + let sideways = wheel(MouseWheelUnit::Point, vec2(-30.0, 0.0), TouchPhase::Move, Modifiers::ALT); + assert_eq!(read(&[sideways]).scroll, vec2(0.0, -30.0)); + let zoomed = read(&[notch(1.0, Modifiers::COMMAND)]); + assert_eq!(zoomed.scroll, Vec2::ZERO, "a zooming wheel doesn't scroll"); + let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + assert!((zoomed.zoom - step).abs() < 1e-6, "{zoomed:?}"); + assert!((read(&[notch(-1.0, Modifiers::COMMAND)]).zoom - 1.0 / step).abs() < 1e-6); + assert!((read(&[Event::Zoom(1.5), Event::Zoom(2.0)]).zoom - 3.0).abs() < 1e-6, "pinch events multiply"); + } + + #[test] + fn a_touch_gesture_keeps_its_modifiers_and_ends_clean() { + let mut reader = WheelReader::default(); + let options = egui::InputOptions::default(); + let point = |dy: f32, phase, modifiers| wheel(MouseWheelUnit::Point, vec2(0.0, dy), phase, modifiers); + // ⌘ held as the fingers land, let go before the momentum: still zooming. + let began = + reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 600.0, &options); + assert!(began.zoom > 1.0 && began.scroll == Vec2::ZERO, "{began:?}"); + let momentum = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options); + assert!(momentum.zoom > 1.0 && momentum.scroll == Vec2::ZERO, "{momentum:?}"); + // The pass it ends in moves nothing, and the next gesture starts without the modifier. + let ended = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE), point(0.0, TouchPhase::End, Modifiers::NONE)], 600.0, &options); + assert_eq!(ended, WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }); + assert_eq!(reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options).scroll, vec2(0.0, -20.0)); + } + + #[test] + fn numbers_that_are_not_finite_move_nothing() { + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let scrolled = read(&[notch(bad, Modifiers::NONE), notch(-1.0, Modifiers::NONE)]); + assert!(scrolled.scroll.x.is_finite() && scrolled.scroll.y.is_finite(), "{bad}: {scrolled:?}"); + assert_eq!(read(&[notch(bad, Modifiers::COMMAND)]).zoom, 1.0, "{bad}"); + assert_eq!(read(&[Event::Zoom(bad)]).zoom, 1.0, "{bad}"); + } + assert_eq!(read(&[Event::Zoom(0.0)]).zoom, 1.0); + assert_eq!(read(&[Event::Zoom(-2.0)]).zoom, 1.0); + // Too far to be a factor at all. + assert_eq!(read(&[notch(1.0e9, Modifiers::COMMAND)]).zoom, 1.0); + } + + /// One pass of a bare egui context, a sixtieth of a second after the last. + fn pass(ctx: &egui::Context, time: &mut f64, events: Vec, mut seen: impl FnMut(&egui::Context)) { + *time += 1.0 / 60.0; + let raw = egui::RawInput { time: Some(*time), events, ..Default::default() }; + // A bare context's first frame uploads the font atlas; nothing paints it here. + ctx.run_ui(raw, |ui| seen(ui.ctx())).textures_delta.clear(); + } + + /// The reader must stay egui's own reading minus the easing: over the passes egui takes to + /// ease a step, it hands out as much scroll (and as much zoom) as the reader does at once. + #[test] + fn reading_at_once_goes_as_far_as_egui_eases() { + let options = egui::InputOptions::default(); + for (modifiers, unit, step) in [ + (Modifiers::NONE, MouseWheelUnit::Line, vec2(0.0, -3.0)), + (Modifiers::NONE, MouseWheelUnit::Point, vec2(40.0, -250.0)), + (Modifiers::SHIFT, MouseWheelUnit::Line, vec2(0.0, 2.0)), + (Modifiers::COMMAND, MouseWheelUnit::Line, vec2(0.0, 1.0)), + (Modifiers::COMMAND, MouseWheelUnit::Point, vec2(0.0, -90.0)), + ] { + let event = wheel(unit, step, TouchPhase::Move, modifiers); + let ctx = egui::Context::default(); + let (mut time, mut scrolled, mut zoomed, mut first) = (0.0, Vec2::ZERO, 1.0_f32, None); + let mut events = vec![event.clone()]; + for _ in 0..120 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + let (scroll, zoom) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta())); + first.get_or_insert((scroll, zoom)); + scrolled += scroll; + zoomed *= zoom; + }); + } + let page_height = ctx.input(|i| i.viewport_rect().height()); + let at_once = WheelReader::default().read(&[event], page_height, &options); + assert!((scrolled - at_once.scroll).length() < 0.01, "{modifiers:?} {unit:?}: egui scrolled {scrolled:?}, read {:?}", at_once.scroll); + assert!((zoomed / at_once.zoom - 1.0).abs() < 1e-3, "{modifiers:?} {unit:?}: egui zoomed {zoomed}, read {}", at_once.zoom); + // What makes the reader necessary: egui's first pass holds part of the step back. + let first = first.expect("passes ran"); + assert!( + first != (at_once.scroll, at_once.zoom), + "{modifiers:?} {unit:?}: egui no longer eases this step ({first:?}); the reader may not be needed" + ); + } + } + + #[test] + fn reduced_motion_hands_egui_the_whole_step_and_stops_its_animations() { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let line = egui::InputOptions::default().line_scroll_speed; + let mut time = 0.0; + let mut seen = Vec::new(); + let mut events = vec![notch(-1.0, Modifiers::NONE)]; + for _ in 0..30 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + seen.push(ctx.input(|i| i.smooth_scroll_delta)); + }); + } + assert_eq!(seen.first(), Some(&vec2(0.0, -line)), "the whole notch in the pass it arrives"); + assert!(seen.iter().skip(1).all(|d| *d == Vec2::ZERO), "and none of egui's easing after it: {seen:?}"); + let style = ctx.global_style(); + assert_eq!((style.animation_time, style.scroll_animation), (0.0, egui::style::ScrollAnimation::none())); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + assert_eq!(ctx.style_of(theme).animation_time, 0.0, "{theme:?}"); + } + + // ⌘/Ctrl with the wheel: the whole zoom step at once, then nothing. + let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + let mut zooms = Vec::new(); + let mut events = vec![notch(1.0, Modifiers::COMMAND)]; + for _ in 0..30 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + zooms.push(zoom_delta(ctx)); + }); + } + assert!((zooms[0] - step).abs() < 1e-6, "{zooms:?}"); + assert!(zooms.iter().skip(1).all(|z| *z == 1.0), "{zooms:?}"); + + // Animations allowed again: egui's own values and its own times. + motion.reduced = false; + let mut eased = Vec::new(); + let mut events = vec![notch(1.0, Modifiers::COMMAND)]; + for _ in 0..3 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + eased.push((zoom_delta(ctx), ctx.input(|i| i.zoom_delta()))); + }); + } + assert!(eased.iter().all(|(ours, eguis)| ours == eguis), "{eased:?}"); + assert!(eased[0].0 > 1.0 && eased[0].0 < step, "egui eases the step: {eased:?}"); + let (style, default) = (ctx.global_style(), egui::Style::default()); + assert_eq!((style.animation_time, style.scroll_animation), (default.animation_time, default.scroll_animation)); + } + + #[test] + fn the_preference_overrides_the_system_for_every_animation() { + use AnimationEffects::{Off, On, System}; + assert_eq!(AnimationEffects::default(), System, "the system's setting is followed unless the user chooses"); + for choice in AnimationEffects::ALL { + assert_eq!(AnimationEffects::parse(choice.name()), Some(choice)); + } + assert_eq!((AnimationEffects::parse("OFF"), AnimationEffects::parse("smooth")), (Some(Off), None)); + let (line, egui_default) = (egui::InputOptions::default().line_scroll_speed, egui::Style::default()); + let egui_times = (egui_default.animation_time, egui_default.scroll_animation); + // The system's answer, the choice, and whether the window then animates. + for (reduced, effects, animates) in + [(false, System, true), (true, System, false), (false, On, true), (true, On, true), (false, Off, false), (true, Off, false)] + { + assert_eq!(effects.animates(reduced), animates, "{effects:?}, system reduced: {reduced}"); + let ctx = egui::Context::default(); + let mut motion = Motion { reduced, ..Default::default() }; + let (mut time, mut first) = (0.0, None); + pass(&ctx, &mut time, vec![notch(-1.0, Modifiers::NONE)], |ctx| { + motion.begin_pass(ctx, effects); + first = Some((ctx.input(|i| i.smooth_scroll_delta), animated(ctx))); + }); + // The wheel, egui's animations and the app's own fades all go the same way. + let whole_notch = first.map(|(scroll, _)| scroll) == Some(vec2(0.0, -line)); + assert_eq!( + (whole_notch, first.map(|(_, animated)| animated)), + (!animates, Some(animates)), + "{effects:?}, system reduced: {reduced}: {first:?}" + ); + let style = ctx.global_style(); + let times = if animates { egui_times } else { (0.0, ScrollAnimation::none()) }; + assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}, system reduced: {reduced}"); + } + // A choice made while running applies from the next pass, and choosing back undoes it. + let ctx = egui::Context::default(); + let mut motion = Motion::default(); + let mut time = 0.0; + for (effects, times) in [(Off, (0.0, ScrollAnimation::none())), (System, egui_times), (Off, (0.0, ScrollAnimation::none())), (On, egui_times)] + { + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, effects)); + let style = ctx.global_style(); + assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}"); + } + // The row's labels are translated: the heading, and On and Off as they suit it. + let de = crate::i18n::Lang::from_code("de").expect("German ships"); + assert_eq!(crate::i18n::tr(de, "Animation effects"), "Animationseffekte"); + let labels: Vec<&str> = + AnimationEffects::ALL.into_iter().map(|choice| crate::i18n::tr_ctx(de, "animation effects", choice.label())).collect(); + assert_eq!(labels, ["Systemeinstellung verwenden", "Ein", "Aus"]); + for lang in crate::i18n::Lang::all() { + for english in ["Animation effects", "On", "Off"] { + let shown = if english == "Animation effects" { + crate::i18n::tr(lang, english) + } else { + crate::i18n::tr_ctx(lang, "animation effects", english) + }; + assert!(lang.code() == "en" || shown != english, "{}: {english} is not translated", lang.code()); + } + } + } + + /// The chrome's hover wash has a duration of its own (`theme::Press`): it eases in with + /// animation effects on and is simply there with them off. + #[test] + fn the_hover_wash_eases_only_with_animation_effects_on() { + for (effects, eased) in [(AnimationEffects::On, true), (AnimationEffects::Off, false)] { + let ctx = egui::Context::default(); + let mut motion = Motion::default(); + let (mut time, mut hover) = (0.0, Vec::new()); + for _ in 0..12 { + time += 1.0 / 60.0; + let raw = egui::RawInput { time: Some(time), events: vec![Event::PointerMoved(egui::pos2(20.0, 20.0))], ..Default::default() }; + ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), effects); + let (_, response) = ui.allocate_exact_size(vec2(100.0, 40.0), egui::Sense::click()); + hover.push(crate::theme::Press::track(ui, &response).hover); + }) + .textures_delta + .clear(); + } + // The first frame the pointer is found over the control. + let first = hover.iter().copied().find(|amount| *amount > 0.0).expect("the pointer is over the control"); + assert_eq!(first < 1.0, eased, "{effects:?}: {hover:?}"); + assert_eq!(hover.last(), Some(&1.0), "{effects:?}: the wash is whole in the end either way: {hover:?}"); + } + } + + #[test] + fn a_zoom_read_in_an_earlier_pass_is_never_used_again() { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut time = 0.0; + pass(&ctx, &mut time, vec![notch(1.0, Modifiers::COMMAND)], |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + assert!(zoom_delta(ctx) > 1.0); + }); + // A pass that didn't begin here (a host that draws without `logic`): egui's own answer. + pass(&ctx, &mut time, Vec::new(), |ctx| { + let eguis = ctx.input(|i| i.zoom_delta()); + assert_eq!(zoom_delta(ctx), eguis); + }); + } + + #[test] + fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { + let ctx = egui::Context::default(); + let answer = Rc::new(Cell::new(Some(true))); + let asked = Rc::new(Cell::new(0_u32)); + let (source_answer, source_asked) = (Rc::clone(&answer), Rc::clone(&asked)); + let mut motion = Motion { + source: Some(Box::new(move || { + source_asked.set(source_asked.get() + 1); + source_answer.get() + })), + ..Default::default() + }; + let mut time = 0.0; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(motion.reduced, "asked in the first pass"); + answer.set(Some(false)); + for _ in 0..30 { + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + } + assert!(motion.reduced && asked.get() == 1, "not asked again within half a second ({} times)", asked.get()); + // Back at the window later: the first pass asks. + time += ASK_EVERY; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(!motion.reduced); + answer.set(None); + motion.reduced = true; + time += ASK_EVERY; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(motion.reduced && asked.get() == 3, "no answer leaves the last one standing"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0a7407522..927c6b48b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -141,9 +141,11 @@ impl Press { pub fn track(ui: &egui::Ui, resp: &Response) -> Self { let hot = resp.hovered() || resp.is_pointer_button_down_on(); let ease = egui::emath::easing::quadratic_out; + // With animation effects off the wash and the press are simply there. + let (hover, down) = if crate::motion::animated(ui.ctx()) { (0.12, 0.06) } else { (0.0, 0.0) }; Self { - hover: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-hover"), hot, 0.12, ease), - down: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-down"), resp.is_pointer_button_down_on(), 0.06, ease), + hover: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-hover"), hot, hover, ease), + down: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-down"), resp.is_pointer_button_down_on(), down, ease), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1610691aa..4f5aaa79b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -430,6 +430,7 @@ const PROGRESS_HEIGHT: &str = "progress-notice-height"; /// Draw [`PdfCraftApp::progress_notice`]: the label, a bar that eases towards the fraction with /// a light sweeping across it, the percentage and Cancel. Returns true when Cancel was clicked. +/// With animation effects off ([`crate::motion`]) the bar steps to the fraction and has no sweep. pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { let Some(p) = &app.progress_notice else { return false }; let t = Tokens::get(ctx); @@ -441,7 +442,8 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { (Color32::from_rgb(0x2A, 0x2A, 0x2F), Color32::WHITE) }; let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; - let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, 0.3); + let animated = crate::motion::animated(ctx); + let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); let now = ctx.input(|i| i.time); let mut cancel = false; let area = egui::Area::new(egui::Id::new("progress-notice")) @@ -473,12 +475,14 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { painter.rect_filled(done, CornerRadius::same(3), t.accent); // A soft light sweeping along the filled part says the job is alive between // updates. - let band = 48.0; - let x = done.left() - band + ((now * 160.0) % f64::from(done.width() + band)) as f32; - let clip = painter.with_clip_rect(done); - for (i, a) in [0.10_f32, 0.22, 0.10].into_iter().enumerate() { - let r = Rect::from_min_size(egui::pos2(x + i as f32 * band / 3.0, done.top()), vec2(band / 3.0, done.height())); - clip.rect_filled(r, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(a)); + if animated { + let band = 48.0; + let x = done.left() - band + ((now * 160.0) % f64::from(done.width() + band)) as f32; + let clip = painter.with_clip_rect(done); + for (i, a) in [0.10_f32, 0.22, 0.10].into_iter().enumerate() { + let r = Rect::from_min_size(egui::pos2(x + i as f32 * band / 3.0, done.top()), vec2(band / 3.0, done.height())); + clip.rect_filled(r, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(a)); + } } } if p.cancellable { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 8b8dc999c..cef2bcae6 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -856,6 +856,42 @@ fn preferences_selects_default_workspace_for_next_open() { assert_eq!(h.state().mode, Mode::Read); } +/// Preferences ▸ Animation effects: the system's animation setting until the user chooses On or +/// Off. The choice is saved, and settings holding anything else leave it alone. +#[test] +fn preferences_choose_animation_effects_and_the_choice_is_saved() { + use egui::accesskit::Role; + use pdfcraft_ui_egui::motion::AnimationEffects; + let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap()); + assert_eq!(h.state().animation_effects, AnimationEffects::System); + h.get_by_label("Animation effects"); + for (label, choice) in [ + ("Off", AnimationEffects::Off), + ("On", AnimationEffects::On), + ("Use system setting", AnimationEffects::System), + ("Off", AnimationEffects::Off), + ] { + h.get_by_role_and_label(Role::RadioButton, label).scroll_to_me(); + h.run_steps(4); + h.get_by_role_and_label(Role::RadioButton, label).click(); + h.run_steps(2); + assert_eq!(h.state().animation_effects, choice, "{label}"); + } + let saved = h.state().persist(); + assert!(saved.contains(r#""animation_effects":"off""#), "{saved}"); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert_eq!(restored.animation_effects, AnimationEffects::Off); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved.replace(r#""animation_effects":"off""#, r#""animation_effects":"sideways""#)); + assert_eq!(fresh.animation_effects, AnimationEffects::System); + // The control channel and the command line take the same three names. + fresh.set_option("animation_effects", "ON").unwrap(); + assert_eq!(fresh.animation_effects, AnimationEffects::On); + assert!(fresh.set_option("animation_effects", "sideways").unwrap_err().contains("system, on, or off")); + assert_eq!(fresh.animation_effects, AnimationEffects::On); +} + #[test] fn explicit_mode_preserves_independent_tool_and_panel_options() { use pdfcraft_ui_egui::LeftPanel; diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 6313e4492..d3744dd2e 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -256,6 +256,80 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); } +/// The 40 frames from one wheel notch on, with the system asking for no animations (Windows' +/// Animation effects turned off) or not, and Preferences ▸ Animation effects as given: how far +/// page 1 has moved up in each frame, or with ⌘/Ctrl held, the view's zoom. +fn frames_after_a_notch(system_reduced: bool, animation_effects: &str, zooming: bool) -> Vec { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "100")]); + h.state_mut().motion.reduced = system_reduced; + h.state_mut().set_option("animation_effects", animation_effects).unwrap(); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let before = rect(&h, 0).expect("page 1 on screen").min.y; + let (delta, modifiers) = if zooming { (egui::vec2(0.0, 1.0), Modifiers::COMMAND) } else { (egui::vec2(0.0, -1.0), Modifiers::NONE) }; + h.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers }); + (0..40) + .map(|_| { + h.step(); + if zooming { h.state().views[0].zoom } else { before - rect(&h, 0).expect("page 1 still laid out").min.y } + }) + .collect() +} + +/// The system's answer and the Animation effects choice, for each way to have them off and +/// each way to have them on. +const AT_ONCE: [(bool, &str); 3] = [(true, "system"), (false, "off"), (true, "off")]; +const EASED: [(bool, &str); 3] = [(false, "system"), (false, "on"), (true, "on")]; + +#[test] +fn a_wheel_notch_scrolls_at_once_with_animation_effects_off() { + // Eased, egui spreads a notch over about a quarter of a second. At once, the page moves the + // notch's whole distance in one frame. + let notch = pdfcraft_ui_egui::scroll_speed::ScrollSpeed::default().line_points(); + for (system_reduced, effects) in AT_ONCE { + let moved = frames_after_a_notch(system_reduced, effects, false); + // The frame the notch arrives in scrolls; the next one draws the page where it stays. + assert!( + moved[2..].iter().all(|y| (y - notch).abs() < 0.5), + "{effects}, system reduced {system_reduced}: one notch is {notch} points, all at once: {moved:?}" + ); + } + for (system_reduced, effects) in EASED { + let moved = frames_after_a_notch(system_reduced, effects, false); + assert!(moved[2] < notch * 0.75, "{effects}, system reduced {system_reduced}: egui eases the notch: {moved:?}"); + assert!( + (moved[39] - notch).abs() < 1.0, + "{effects}, system reduced {system_reduced}: easing changes how long a notch takes, not how far it goes: {moved:?}" + ); + } +} + +#[test] +fn the_wheel_zooms_a_whole_step_at_once_with_animation_effects_off() { + // ⌘/Ctrl with the wheel: egui eases the zoom as it eases scrolling. + let step = frames_after_a_notch(true, "system", true)[0]; + assert!(step > 1.1, "the notch zoomed in: {step}"); + for (system_reduced, effects) in AT_ONCE { + let zooms = frames_after_a_notch(system_reduced, effects, true); + assert!( + zooms.iter().all(|zoom| *zoom == step), + "{effects}, system reduced {system_reduced}: the whole step in the frame the notch arrives, and no more after it: {zooms:?}" + ); + } + for (system_reduced, effects) in EASED { + let zooms = frames_after_a_notch(system_reduced, effects, true); + assert!( + zooms[0] > 1.0 && zooms[0] < 1.0 + (step - 1.0) * 0.75, + "{effects}, system reduced {system_reduced}: egui eases the notch: {zooms:?}" + ); + assert!( + (zooms[39] / step - 1.0).abs() < 0.01, + "{effects}, system reduced {system_reduced}: easing changes how long a zoom step takes, not how far it goes: {zooms:?}" + ); + } +} + #[test] fn page_display_commands_switch_layouts() { let mut h = harness(&[]); diff --git a/docs/roadmap.md b/docs/roadmap.md index dc9c4b22d..963cda64f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: local GoToR links and confined automation candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; earlier: M10, printing on the web through the browser's print dialog, gap 4, rank 1 65–120 → 50–90 h) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -81,7 +82,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | @@ -147,6 +148,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | | 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. | | 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | +| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, no other animations. Preferences ▸ Animation effects (Use system setting, On, Off) overrides it. macOS, Linux and the browser aren't asked yet. Estimates unchanged. | | 2026-10-11 | minor | M10: printing on the web through the browser's print dialog (gap 4 closed); rank 1 drops web printing, 65–120 → 50–90 h | | 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | | 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. |