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 <noreply@anthropic.com>
This commit is contained in:
Jeremy Yao
2026-10-11 20:10:54 -04:00
co-authored by Claude Opus 5.5
parent 377f8da36b
commit ca2036d51c
35 changed files with 835 additions and 33 deletions
+20 -20
View File
@@ -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"
Generated
+1
View File
@@ -3566,6 +3566,7 @@ dependencies = [
"ureq",
"wayland-client",
"wayland-protocols",
"windows",
"winresource",
"winsafe",
]
+4 -1
View File
@@ -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) |
+4
View File
@@ -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.
+8
View File
@@ -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"));
+75
View File
@@ -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<bool> {
match animations_enabled() {
Ok(enabled) => Some(!enabled),
Err(e) => {
log::debug!("system animation effects: {e}");
None
}
}
}
fn animations_enabled() -> windows::core::Result<bool> {
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<bool> {
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);
}
}
}
}
+2 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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);
}
+3
View File
@@ -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 تحسين الصفحة
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 Подобри страницата
Can't render this file because it contains an unexpected character in line 8 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+3
View File
@@ -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 Βελτίωση σελίδας
Can't render this file because it contains an unexpected character in line 477 and column 12.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 ページを補正
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 Бетті жақсарту
Can't render this file because it contains an unexpected character in line 813 and column 12.
+3
View File
@@ -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ę
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 Улучшить страницу
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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 పేజీని మెరుగుపరచు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 811 and column 12.
+3
View File
@@ -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 Поліпшити сторінку
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 增强页面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -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. 將四個角拖曳到文件邊緣,然後選擇「增強頁面」。
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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)));
+16
View File
@@ -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::<scroll_speed::ScrollSpeed>(v["scroll_speed"].clone()) {
self.scroll_speed = speed;
}
if let Ok(smooth) = serde_json::from_value::<motion::SmoothScrolling>(v["smooth_scrolling"].clone()) {
self.smooth_scrolling = smooth;
}
if let Ok(mode) = serde_json::from_value::<Mode>(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);
+519
View File
@@ -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> {
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<dyn Fn() -> Option<bool>>;
/// 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<MotionSource>,
/// 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<Modifiers>,
}
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<Event>, 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");
}
}
+11 -7
View File
@@ -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 {
+33
View File
@@ -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;
+71
View File
@@ -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<f32> {
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(&[]);
+3 -2
View File
@@ -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<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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<br>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<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.<br>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.<br>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. |