From ca2036d51cb0e7f64f2cec6b1457a05ed68f00f2 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sun, 11 Oct 2026 20:10:54 -0400 Subject: [PATCH] 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 --- ATTRIBUTION.toml | 40 +-- Cargo.lock | 1 + ROADMAP.md | 5 +- apps/pdfcraft/Cargo.toml | 4 + apps/pdfcraft/src/main.rs | 8 + apps/pdfcraft/src/motion.rs | 75 ++++ 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 | 7 + crates/ui-egui/src/lib.rs | 16 + crates/ui-egui/src/motion.rs | 519 ++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 18 +- crates/ui-egui/tests/ui.rs | 33 ++ crates/ui-egui/tests/view.rs | 71 ++++ docs/roadmap.md | 5 +- 35 files changed, 835 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 ab2934838..47db5c42b 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 = "fbc7790b5e9afd591b3bfbe0582f004f345b42d988989d81fdf38f312331fa01" +sha256 = "be7e716d9129584028570281aa7ee93a9d0408e267100eedfe5fed1b991856d9" # 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 = "fc734b2f2530561f8630f5d7112a237201012789b1d9167d3068b4ddb7b22f43" +sha256 = "e1aa703d9512937ece3626f5389f0c8441239ba71081a98cfa97cb55bc88b6ed" [[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 = "379d0dc6203104e0f07ea0ed1e74a365c8eec7f9cf2bf525078dac0cf58b6372" +sha256 = "beeb5d9842b93ff054022109ec7bbb1b918056c918239ac43e09b29e53f9bdb4" [[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 = "b416309efba61009d4ed92c932cf1b23bb4f3a53d0d7d35cf61bf9c3d7a011ef" +sha256 = "74fd38000f15c411800f9eb4bab49e0acaf39a1b6f67b237230d55fc914aed4a" [[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 = "066efa560b0dc5554c85194dcfb3190e03fce75145d52151bdcf7734f1829f7e" +sha256 = "84a4f748568c3bcb9cb23ccbbe344c7ad28bea47a78c9dfa1d7f3d5874e101cd" [[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 = "9061bbb9b21008b4c312b5158ed3c338cfbcb3b563e9e57b81cf72d075c9eadf" +sha256 = "433dde6b9e840272c1e4215712243a288f56617fc7c9e71f40a759699849ef86" [[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 = "068211fb4b246e1cb0ecb66598101f1c974e0313e1323e6acd522817f7f3bb57" +sha256 = "55c74cc9e2af3174433d7207fa512787bb4b8e01188edd9ab290d2839001d2b2" [[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 = "a2228f72e63c83959ea0ee7b1641bcf31e13b3c58fab62caebbd50f8d8d5c231" +sha256 = "ee31ca945449a5981bf13d838cb9e111f453af80782fac0d25548fa0350d1b06" [[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 = "8c2b1625fb84fa7c9279d2ca0d6a22c808514d6361cf3c547a7ee21c64d170ae" +sha256 = "527b12ba4605671e390e2e2375cae32378de6bf26d05fb171f97c01593c0ab7d" [[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 = "394d322e782343f95c3b9cce603a77b7e3a6625000540a17f45b55b7233e7662" +sha256 = "feca2c5cdfc814556acf66da18ef293c8201374cfc3835d7a81c1aef8614d30c" [[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 = "5c5c0420cf58767312b132b9f9cb3c23131b58b831feae3e47b421f1081fe60b" +sha256 = "89540ed71d1f2b3420333f1e9b6c3561c3a82407f0cd6d0bbe959f2e112a0302" [[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 = "1d130f3f7112ac99693c5048a96405f3371dbe893d5c618f2910b8e80b6be28c" +sha256 = "531f802250a87f12c0352ff1f0dbeb8752151b0078ec1937e062cd6a939db902" [[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 = "a6ff8309a391acc428cb67e834196e14919ab5cc0f2d9befcbfb532d2ec4ca82" +sha256 = "2605fa94a7cd33be345c8bc3828e16eed25029530211a9ab63e72913d993a254" [[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 = "40391d7be25e2a4e59cb1419df733935e69dd728fb0a3404575edc6807f27924" +sha256 = "783b16274172d4de2a4d7899de8717601ee13aa92b271b99cd29f4c5bf9fd91d" [[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 = "27fa6fae5ad0005f90a13612aa1eb8189061110eab8d6b6e7a83899af54f13e9" +sha256 = "4c01c32afa1f96fc9a6b059f920cab3155c1ccb0de2a144654adb6be3c80b5a9" [[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 = "f4e6217dfc370ffa947c07b8c23bfa7d8f3824d881d7d51062b471b95f378c82" +sha256 = "aad7fee85b9eec1577733907bb8a7c65ae2443190532fed6d813cd65ff2edda0" [[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 = "c3f539ce99a39a6f7a89740da9b7aab9643014613bfef1891efe777dc5cf057a" +sha256 = "cff0485f0ac85783b145e11f454e5a0611bd8a680ebe54e8243fda9c6568b304" [[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 = "1cf9e140b348f1e8ceb399a8f079cae392085d9f432e3e1b9d94cd1039a1c4b8" +sha256 = "47d2ec69b80352769c39a88c775a17ae7e4505325e2ad35ea2a1b7a46292fad9" [[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 = "9d52789a71fe9b0a7cc74cfd6e9e6d0a8b3d9b9a866922676e376f1431d1cae3" +sha256 = "5b3f045d4ddc84300a9809f2cdf4317168b7db0dda5f483c1dbfb2e8b32b673f" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,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 = "c5e3a75359a8faadb9efa9ac4ff31963cf9124c20269a1e21715bcabde5e1292" +sha256 = "931432d897c060dde5e9758de137fe4d4c2f7a19d244c519df2413b51f17961f" diff --git a/Cargo.lock b/Cargo.lock index 4fd9e89a9..324d669bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3566,6 +3566,7 @@ dependencies = [ "ureq", "wayland-client", "wayland-protocols", + "windows", "winresource", "winsafe", ] diff --git a/ROADMAP.md b/ROADMAP.md index 561932a9b..f61ae5513 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Smooth scrolling: system, on or off); earlier: 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) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: 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) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: 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) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: 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) @@ -140,6 +140,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M3, smooth scrolling and Windows' Animation effects setting):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has Smooth scrolling: 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, 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. Smooth scrolling set to On or Off decides the wheel's scrolling and zooming, and scrolling to a place, whatever the system says (menus, sections and the progress card always follow the system); it is saved, set as `smooth_scrolling` (`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 eases there as before; Off works everywhere. Estimates unchanged. + - **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. - **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. - **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. @@ -358,6 +360,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, egui's animations off), and Preferences has Smooth scrolling (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-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | 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..09e5a74e9 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: with it off nothing animates, and the wheel doesn't + // ease unless Preferences ▸ Smooth scrolling says so. + #[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")); diff --git a/apps/pdfcraft/src/motion.rs b/apps/pdfcraft/src/motion.rs new file mode 100644 index 000000000..a33dfc5a0 --- /dev/null +++ b/apps/pdfcraft/src/motion.rs @@ -0,0 +1,75 @@ +//! The system's "no animations" setting, which `pdfcraft_ui_egui::motion` follows (and Preferences ▸ +//! Smooth scrolling, unless it is set to On or Off). +//! +//! 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/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c8bf4a870..f8cad03ee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2073,7 +2073,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); @@ -4197,7 +4197,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 af045c283..1f9142b0e 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2263,6 +2263,9 @@ key Space مسافة scroll speed Slow بطيئة scroll speed Normal عادية scroll speed Fast سريعة + Smooth scrolling التمرير السلس +smooth scrolling On مفعّل +smooth scrolling 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 a87722788..1af879fbe 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2205,6 +2205,9 @@ key Space Интервал scroll speed Slow Ниска scroll speed Normal Нормална scroll speed Fast Висока + Smooth scrolling Плавно превъртане +smooth scrolling On Включено +smooth scrolling 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 cc52011e4..c7524c49d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2296,6 +2296,9 @@ certificate Subject Subjekt scroll speed Slow Pomalá scroll speed Normal Normální scroll speed Fast Rychlá + Smooth scrolling Hladké posouvání +smooth scrolling On Zapnuto +smooth scrolling 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 9513ca9ac..070d0d0e2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2243,6 +2243,9 @@ key Space Leertaste scroll speed Slow Langsam scroll speed Normal Normal scroll speed Fast Schnell + Smooth scrolling Sanftes Scrollen +smooth scrolling On Ein +smooth scrolling 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 d2c9ba2a3..75987041b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2259,6 +2259,9 @@ comment menu Edit Επεξεργασία scroll speed Slow Αργή scroll speed Normal Κανονική scroll speed Fast Γρήγορη + Smooth scrolling Ομαλή κύλιση +smooth scrolling On Ενεργή +smooth scrolling 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 360f12ffb..4afcaed81 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2295,6 +2295,9 @@ key Space Espacio scroll speed Slow Lenta scroll speed Normal Normal scroll speed Fast Rápida + Smooth scrolling Desplazamiento suave +smooth scrolling On Activado +smooth scrolling Off Desactivado Enhance camera image Mejorar foto de cámara Drag the corners onto the document's edges, then choose Enhance page. Arrastre las esquinas hasta los bordes del documento y elija 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 f703cc2ca..b3ac3e519 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2242,6 +2242,9 @@ key Space Espace scroll speed Slow Lente scroll speed Normal Normale scroll speed Fast Rapide + Smooth scrolling Défilement fluide +smooth scrolling On Activé +smooth scrolling Off Désactivé 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 7b0080f03..dd8a89989 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2241,6 +2241,9 @@ key Space Szóköz scroll speed Slow Lassú scroll speed Normal Normál scroll speed Fast Gyors + Smooth scrolling Finom görgetés +smooth scrolling On Be +smooth scrolling 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 643b55b79..2704e2ec4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2251,6 +2251,9 @@ key Space Spazio scroll speed Slow Lenta scroll speed Normal Normale scroll speed Fast Veloce + Smooth scrolling Scorrimento fluido +smooth scrolling On Attivato +smooth scrolling Off Disattivato 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 55f8a961b..f53d10717 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2242,6 +2242,9 @@ key Space スペース scroll speed Slow 遅い scroll speed Normal 標準 scroll speed Fast 速い + Smooth scrolling スムーズスクロール +smooth scrolling On オン +smooth scrolling 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 ed4ecb045..2cabceda4 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2241,6 +2241,9 @@ key Space Бос орын scroll speed Slow Баяу scroll speed Normal Қалыпты scroll speed Fast Жылдам + Smooth scrolling Тегіс айналдыру +smooth scrolling On Қосулы +smooth scrolling 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 83ce917a9..9da962eb5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2227,6 +2227,9 @@ key Space Spacja scroll speed Slow Wolna scroll speed Normal Normalna scroll speed Fast Szybka + Smooth scrolling Płynne przewijanie +smooth scrolling On Włączone +smooth scrolling 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 f0248695e..1b66f2fc7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2256,6 +2256,9 @@ calendar date December dezembro scroll speed Slow Lenta scroll speed Normal Normal scroll speed Fast Rápida + Smooth scrolling Rolagem suave +smooth scrolling On Ativada +smooth scrolling Off Desativada 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 c1ecd6212..9c11f7768 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2155,6 +2155,9 @@ key Space Пробел scroll speed Slow Низкая scroll speed Normal Обычная scroll speed Fast Высокая + Smooth scrolling Плавная прокрутка +smooth scrolling On Вкл +smooth scrolling 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 138316657..359ccd1d5 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2248,6 +2248,9 @@ key Space Preslednica scroll speed Slow Počasi scroll speed Normal Običajno scroll speed Fast Hitro + Smooth scrolling Gladko drsenje +smooth scrolling On Vklopljeno +smooth scrolling Off Izklopljeno 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 0c4279208..c45a6f47b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2161,6 +2161,9 @@ key Space స్పేస్ scroll speed Slow నెమ్మదిగా scroll speed Normal సాధారణం scroll speed Fast వేగంగా + Smooth scrolling సున్నితమైన స్క్రోలింగ్ +smooth scrolling On ఆన్ +smooth scrolling 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 85f7e6616..364084b74 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2239,6 +2239,9 @@ key Space Boşluk scroll speed Slow Yavaş scroll speed Normal Orta scroll speed Fast Hızlı + Smooth scrolling Yumuşak kaydırma +smooth scrolling On Açık +smooth scrolling 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 719b9304b..034130de1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2264,6 +2264,9 @@ key Space Пробіл scroll speed Slow Повільна scroll speed Normal Звичайна scroll speed Fast Швидка + Smooth scrolling Плавне прокручування +smooth scrolling On Увімкнено +smooth scrolling 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 2c706c98a..578ba5013 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2206,6 +2206,9 @@ key Space 空格 scroll speed Slow 慢 scroll speed Normal 正常 scroll speed Fast 快 + Smooth scrolling 平滑滚动 +smooth scrolling On 开 +smooth scrolling 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 62d8f9459..0c217382c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2120,6 +2120,9 @@ key Space 空格 scroll speed Slow 慢 scroll speed Normal 正常 scroll speed Fast 快 + Smooth scrolling 平滑捲動 +smooth scrolling On 開 +smooth scrolling 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..6edfb8e17 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -357,6 +357,13 @@ 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 the wheel eases; left alone it follows the system's animation setting. + ui.horizontal(|ui| { + ui.label(tl!("Smooth scrolling")); + for choice in crate::motion::SmoothScrolling::ALL { + widgets::radio_value(ui, &mut app.smooth_scrolling, choice, tl_ctx!("smooth scrolling", 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 15ee7f408..697c5c6b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -82,6 +82,7 @@ mod home; mod icon_data; pub mod icons; pub mod last_session; +pub mod motion; mod pageboxes; mod palette; mod panels; @@ -465,6 +466,10 @@ pub struct PdfCraftApp { desktop_theme_enabled: bool, /// How far a mouse-wheel notch scrolls. pub scroll_speed: scroll_speed::ScrollSpeed, + /// Preferences ▸ Smooth scrolling: eased, at once, or as the system's animation setting says. + pub smooth_scrolling: motion::SmoothScrolling, + /// 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`]. @@ -786,6 +791,8 @@ impl PdfCraftApp { #[cfg(target_os = "linux")] desktop_theme_enabled: false, scroll_speed: scroll_speed::ScrollSpeed::default(), + smooth_scrolling: motion::SmoothScrolling::default(), + motion: motion::Motion::default(), language: i18n::AUTO.to_string(), ui_scale: None, system_text_scale: 1.0, @@ -1621,6 +1628,7 @@ impl PdfCraftApp { "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, + "smooth_scrolling": self.smooth_scrolling, "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(), @@ -1706,6 +1714,9 @@ impl PdfCraftApp { if let Ok(speed) = serde_json::from_value::(v["scroll_speed"].clone()) { self.scroll_speed = speed; } + if let Ok(smooth) = serde_json::from_value::(v["smooth_scrolling"].clone()) { + self.smooth_scrolling = smooth; + } if let Ok(mode) = serde_json::from_value::(v["default_mode"].clone()) { self.default_mode = mode; } @@ -1832,6 +1843,9 @@ impl PdfCraftApp { ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } + ("smooth_scrolling", _) => { + self.smooth_scrolling = motion::SmoothScrolling::parse(value).ok_or("smooth_scrolling must be system, on, or off")?; + } ("panel", _) => { self.right = match value { "comments" => Some(RightPanel::Comments), @@ -2263,6 +2277,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, and with the line speed just set. + self.motion.begin_pass(ctx, self.smooth_scrolling); // 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..b688adec0 --- /dev/null +++ b/crates/ui-egui/src/motion.rs @@ -0,0 +1,519 @@ +//! Motion the system asked to do without. +//! +//! 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`]); while motion is reduced, 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. +//! +//! Preferences ▸ Smooth scrolling ([`SmoothScrolling`]) decides the first three by itself when +//! it is On or Off, whatever the system says; the last always follows the system. +//! +//! 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 ▸ Smooth scrolling: whether the wheel's scrolling and zooming, and scrolling to +/// a place, are eased. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SmoothScrolling { + /// Eased unless the system asks for no animations ([`Motion::reduced`]). + #[default] + System, + On, + Off, +} + +impl SmoothScrolling { + 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 scrolling is eased, given whether the system asks for no animations. + pub fn eases(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; + +/// Whether the window animates, and what it takes to stop egui from doing so. +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. + pub(crate) fn begin_pass(&mut self, ctx: &egui::Context, smooth: SmoothScrolling) { + 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 eases = smooth.eases(self.reduced); + self.sync_style(ctx, eases); + if eases { + // 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 take no time while the system asks for none, and scrolling to a place + /// takes none unless scrolling `eases`; otherwise both keep the times egui had. + fn sync_style(&mut self, ctx: &egui::Context, eases: bool) { + let unchanged = !self.reduced && eases; + if unchanged && 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 (time, scroll) = *self.egui_times.get_or_insert((style.animation_time, style.scroll_animation)); + let wanted = (if self.reduced { 0.0 } else { time }, if eases { scroll } else { ScrollAnimation::none() }); + if (style.animation_time, style.scroll_animation) != wanted { + ctx.all_styles_mut(|s| (s.animation_time, s.scroll_animation) = wanted); + } + if unchanged { + self.egui_times = None; + } + } +} + +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 unless scrolling is eased. +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, SmoothScrolling::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, SmoothScrolling::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, SmoothScrolling::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_decides_scrolling_and_the_system_the_other_animations() { + use SmoothScrolling::{Off, On, System}; + assert_eq!(SmoothScrolling::default(), System, "the system's setting is followed unless the user chooses"); + for choice in SmoothScrolling::ALL { + assert_eq!(SmoothScrolling::parse(choice.name()), Some(choice)); + } + assert_eq!((SmoothScrolling::parse("OFF"), SmoothScrolling::parse("smooth")), (Some(Off), None)); + let (line, egui_default) = (egui::InputOptions::default().line_scroll_speed, egui::Style::default()); + // The system's answer, the choice, and whether a notch then lands whole in its pass. + for (reduced, smooth, at_once) in + [(false, System, false), (true, System, true), (false, On, false), (true, On, false), (false, Off, true), (true, Off, true)] + { + assert_eq!(smooth.eases(reduced), !at_once, "{smooth:?}, 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, smooth); + first = Some(ctx.input(|i| i.smooth_scroll_delta)); + }); + assert_eq!(first == Some(vec2(0.0, -line)), at_once, "{smooth:?}, system reduced: {reduced}: {first:?}"); + let style = ctx.global_style(); + // Scrolling to a place goes with the wheel; menus and sections follow the system alone. + let scroll_to = if at_once { ScrollAnimation::none() } else { egui_default.scroll_animation }; + let other = if reduced { 0.0 } else { egui_default.animation_time }; + assert_eq!((style.scroll_animation, style.animation_time), (scroll_to, other), "{smooth:?}, 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 (smooth, scroll_to) in [(Off, ScrollAnimation::none()), (System, egui_default.scroll_animation), (Off, ScrollAnimation::none())] { + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, smooth)); + let style = ctx.global_style(); + assert_eq!((style.scroll_animation, style.animation_time), (scroll_to, egui_default.animation_time), "{smooth:?}"); + } + // 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, "Smooth scrolling"), "Sanftes Scrollen"); + let labels: Vec<&str> = SmoothScrolling::ALL.into_iter().map(|choice| crate::i18n::tr_ctx(de, "smooth scrolling", choice.label())).collect(); + assert_eq!(labels, ["Systemeinstellung verwenden", "Ein", "Aus"]); + for lang in crate::i18n::Lang::all() { + for english in ["Smooth scrolling", "On", "Off"] { + let shown = if english == "Smooth scrolling" { + crate::i18n::tr(lang, english) + } else { + crate::i18n::tr_ctx(lang, "smooth scrolling", english) + }; + assert!(lang.code() == "en" || shown != english, "{}: {english} is not translated", lang.code()); + } + } + } + + #[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, SmoothScrolling::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, SmoothScrolling::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, SmoothScrolling::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, SmoothScrolling::System)); + assert!(!motion.reduced); + answer.set(None); + motion.reduced = true; + time += ASK_EVERY; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, SmoothScrolling::System)); + assert!(motion.reduced && asked.get() == 3, "no answer leaves the last one standing"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bf440d3a4..e83016d31 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -331,6 +331,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 motion reduced ([`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); @@ -342,7 +343,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 = !app.motion.reduced; + 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")) @@ -374,12 +376,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..c8f2993da 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -856,6 +856,39 @@ fn preferences_selects_default_workspace_for_next_open() { assert_eq!(h.state().mode, Mode::Read); } +/// Preferences ▸ Smooth scrolling: 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_smooth_scrolling_and_the_choice_is_saved() { + use egui::accesskit::Role; + use pdfcraft_ui_egui::motion::SmoothScrolling; + let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap()); + assert_eq!(h.state().smooth_scrolling, SmoothScrolling::System); + h.get_by_label("Smooth scrolling"); + for (label, choice) in + [("Off", SmoothScrolling::Off), ("On", SmoothScrolling::On), ("Use system setting", SmoothScrolling::System), ("Off", SmoothScrolling::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().smooth_scrolling, choice, "{label}"); + } + let saved = h.state().persist(); + assert!(saved.contains(r#""smooth_scrolling":"off""#), "{saved}"); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert_eq!(restored.smooth_scrolling, SmoothScrolling::Off); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved.replace(r#""smooth_scrolling":"off""#, r#""smooth_scrolling":"sideways""#)); + assert_eq!(fresh.smooth_scrolling, SmoothScrolling::System); + // The control channel and the command line take the same three names. + fresh.set_option("smooth_scrolling", "ON").unwrap(); + assert_eq!(fresh.smooth_scrolling, SmoothScrolling::On); + assert!(fresh.set_option("smooth_scrolling", "sideways").unwrap_err().contains("system, on, or off")); + assert_eq!(fresh.smooth_scrolling, SmoothScrolling::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 d3d16e067..a0428fe66 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -256,6 +256,77 @@ 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 ▸ Smooth scrolling 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, smooth_scrolling: &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("smooth_scrolling", smooth_scrolling).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 Smooth scrolling choice, for each way to scroll at once and each +/// way to ease. +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_unless_scrolling_is_smooth() { + // 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, smooth) in AT_ONCE { + let moved = frames_after_a_notch(system_reduced, smooth, 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), + "{smooth}, system reduced {system_reduced}: one notch is {notch} points, all at once: {moved:?}" + ); + } + for (system_reduced, smooth) in EASED { + let moved = frames_after_a_notch(system_reduced, smooth, false); + assert!(moved[2] < notch * 0.75, "{smooth}, system reduced {system_reduced}: egui eases the notch: {moved:?}"); + assert!( + (moved[39] - notch).abs() < 1.0, + "{smooth}, 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_unless_scrolling_is_smooth() { + // ⌘/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, smooth) in AT_ONCE { + let zooms = frames_after_a_notch(system_reduced, smooth, true); + assert!( + zooms.iter().all(|zoom| *zoom == step), + "{smooth}, system reduced {system_reduced}: the whole step in the frame the notch arrives, and no more after it: {zooms:?}" + ); + } + for (system_reduced, smooth) in EASED { + let zooms = frames_after_a_notch(system_reduced, smooth, true); + assert!(zooms[0] > 1.0 && zooms[0] < 1.0 + (step - 1.0) * 0.75, "{smooth}, system reduced {system_reduced}: egui eases the notch: {zooms:?}"); + assert!( + (zooms[39] / step - 1.0).abs() < 0.01, + "{smooth}, 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 2477ea75d..3d2631d22 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Smooth scrolling; earlier: M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -78,7 +78,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. 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 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 ▸ Smooth scrolling (Use system setting, On, Off) decides the wheel's easing 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 | @@ -139,6 +139,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, egui's animations off. Preferences ▸ Smooth scrolling (Use system setting, On, Off) overrides it for the wheel. macOS, Linux and the browser aren't asked yet. Estimates unchanged. | | 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. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. |