diff --git a/Cargo.lock b/Cargo.lock index ace8c71e1..b6665923d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5013,12 +5013,14 @@ dependencies = [ "pollster 0.4.0", "rfd", "serde_json", + "windows", "winresource", "wordcraft-control-key", "wordcraft-doc", "wordcraft-engine", "wordcraft-fonts", "wordcraft-ui-egui", + "zbus", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 363ecc840..09f06fc54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every measurement follows the Options unit, #755; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting landed; previously trivial: every measurement follows the Options unit, #755; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| +| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents | | 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) | | 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog | | 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) | @@ -202,6 +203,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Animation effects setting | | 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) | | 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) | | 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index a3e53cc13..d9b44aa69 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] } diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 87076beaf..bc5e47987 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -183,12 +183,21 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services print: Some(Box::new(print_pdf)), inbox: Some(inbox), on_dirty: Some(Box::new(move |d| dirty.set(d))), + animations_enabled: Some(animations_enabled()), store_font: Some(Box::new(font_store::store)), forget_font: Some(Box::new(font_store::forget)), ..Default::default() } } +/// Whether the system has animation effects on, as the browser passes it on: off while the +/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The +/// query is made once and asked every frame; it follows the system's setting by itself. +fn animations_enabled() -> Box Option> { + let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); + Box::new(move || reduce.as_ref().map(|query| !query.matches())) +} + /// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own /// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save. fn guard_unload(dirty: Rc>) -> Result<(), String> { diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 36e490475..d41277f43 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -33,11 +33,26 @@ winresource = "0.1" # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# The system's Reduce motion setting (System Settings › Accessibility › Display), read through +# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard, +# at this version; this adds only the `NSAccessibility` bindings. +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] } +objc2 = "0.6" # Closing the window must quit through NSApplication rather than by dropping the NSWindow # inside the AppKit run loop (see `schedule_macos_terminate`). dispatch2 = "0.3" -objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication"] } + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over +# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API. +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through +# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our +# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds +# only the `UI_ViewManagement` bindings. +windows = { version = "0.62", features = ["UI_ViewManagement"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/wordcraft/src/animations.rs b/apps/wordcraft/src/animations.rs new file mode 100644 index 000000000..49cec7099 --- /dev/null +++ b/apps/wordcraft/src/animations.rs @@ -0,0 +1,99 @@ +//! The system's animation setting, which File › Options › Accessibility › Animation effects +//! follows when set to "Use system setting" (`Services::animations_enabled`). +//! +//! - Windows: Settings › Accessibility › Visual effects › Animation effects, read through the +//! Windows Runtime's `UISettings`. +//! - macOS: System Settings › Accessibility › Display › Reduce motion, read through `NSWorkspace`. +//! - Linux and the BSDs: the desktop's setting, read through the settings portal ([`desktop`]). +//! +//! All three are safe APIs of crates already in the build (our workspace forbids unsafe). The UI +//! asks every frame, so the answer always comes from memory: Windows and macOS are asked again at +//! most once a second, and the Linux/BSD side keeps its answer up to date on a thread of its own. + +#[cfg(any(all(unix, not(target_os = "macos")), test))] +mod desktop; + +/// The answer to "does the system have animation effects on?" for the UI: `None` when the system +/// doesn't say. `ctx` is asked to redraw when an answer that arrives later changes it. +pub fn hook(ctx: &egui::Context) -> Box Option> { + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + let _ = ctx; + Box::new(remembered(system_enabled, REFRESH)) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + Box::new(desktop::watch(ctx.clone())) + } + #[cfg(not(any(target_os = "windows", unix)))] + { + let _ = ctx; + Box::new(|| None) + } +} + +/// How long an answer from Windows or macOS is kept before the system is asked again. +#[cfg(any(target_os = "windows", target_os = "macos"))] +const REFRESH: std::time::Duration = std::time::Duration::from_secs(1); + +/// `read`, asked at most once per `every` and answered from memory in between. +#[cfg(any(target_os = "windows", target_os = "macos", test))] +fn remembered(read: impl Fn() -> Option, every: std::time::Duration) -> impl Fn() -> Option { + let last = std::cell::Cell::new(None::<(std::time::Instant, Option)>); + move || { + if let Some((at, answer)) = last.get() + && at.elapsed() < every + { + return answer; + } + let answer = read(); + last.set(Some((std::time::Instant::now(), answer))); + answer + } +} + +/// Whether Windows has animation effects on; `None` when it can't be asked. +#[cfg(target_os = "windows")] +fn system_enabled() -> Option { + windows::UI::ViewManagement::UISettings::new() + .and_then(|settings| settings.AnimationsEnabled()) + .map_err(|e| log::debug!("system animation setting: {e}")) + .ok() +} + +/// Whether macOS plays animations: Reduce motion is off. +#[cfg(target_os = "macos")] +fn system_enabled() -> Option { + Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::time::Duration; + + use super::remembered; + + /// The UI asks every frame; the system is asked once per interval and the answer kept between. + #[test] + fn the_system_is_asked_once_per_interval() { + let asked = Cell::new(0); + let system = Cell::new(Some(false)); + let read = || { + asked.set(asked.get() + 1); + system.get() + }; + let kept = remembered(read, Duration::from_secs(3600)); + assert_eq!((kept(), kept(), kept()), (Some(false), Some(false), Some(false))); + system.set(Some(true)); + assert_eq!(kept(), Some(false), "within the interval the remembered answer stands"); + assert_eq!(asked.get(), 1); + + // Once the interval has passed (here: at once) the system is asked again. + let fresh = remembered(read, Duration::ZERO); + assert_eq!(fresh(), Some(true)); + system.set(None); + assert_eq!(fresh(), None); + assert_eq!(asked.get(), 3); + } +} diff --git a/apps/wordcraft/src/animations/desktop.rs b/apps/wordcraft/src/animations/desktop.rs new file mode 100644 index 000000000..b9999b2af --- /dev/null +++ b/apps/wordcraft/src/animations/desktop.rs @@ -0,0 +1,305 @@ +//! The desktop's animation setting on Linux and the BSDs. +//! +//! Asked of the XDG settings portal over D-Bus (`org.freedesktop.portal.Settings`), which GNOME, +//! KDE Plasma and the other desktops with a portal answer, inside Flatpak too. Three settings say +//! whether animations play ([`KEYS`]); any one of them saying no turns them off. Where no portal +//! answers, or it knows none of the three (a bare window manager), the user's own KDE and GTK +//! settings files are read instead, once. +//! +//! A thread asks at start-up and then follows the portal's change signal, so the UI's question is +//! always answered from memory and a change in the desktop's settings shows without a restart. + +/// What a setting holds, whichever way it was stored. +#[derive(Clone, Debug, PartialEq)] +pub enum Reading { + Bool(bool), + Number(f64), + Text(String), +} + +/// A setting as the portal names it: (namespace, key). +type Key = (&'static str, &'static str); + +/// The cross-desktop preference: 1 asks for reduced motion; 0, and values not defined yet, are +/// no preference. +const REDUCED_MOTION: Key = ("org.freedesktop.appearance", "reduced-motion"); +/// GNOME's switch, the one GTK follows. +const GNOME_ANIMATIONS: Key = ("org.gnome.desktop.interface", "enable-animations"); +/// KDE Plasma's animation speed, a factor on every animation's length: 0 makes them instant. +const KDE_ANIMATION_SPEED: Key = ("org.kde.kdeglobals.KDE", "AnimationDurationFactor"); + +/// The settings that say whether animations play. +pub const KEYS: [Key; 3] = [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]; + +/// Whether animations play according to one of [`KEYS`]; `None` for any other setting, or a +/// value that isn't one it holds. +pub fn says(namespace: &str, key: &str, value: &Reading) -> Option { + match (namespace, key) { + k if k == REDUCED_MOTION => number(value).map(|n| n != 1.0), + k if k == GNOME_ANIMATIONS => truth(value), + k if k == KDE_ANIMATION_SPEED => number(value).map(|n| n > 0.0), + _ => None, + } +} + +fn number(value: &Reading) -> Option { + let n = match value { + Reading::Number(n) => *n, + Reading::Text(t) => t.trim().parse().ok()?, + Reading::Bool(_) => return None, + }; + n.is_finite().then_some(n) +} + +fn truth(value: &Reading) -> Option { + match value { + Reading::Bool(b) => Some(*b), + Reading::Number(n) => n.is_finite().then_some(*n != 0.0), + Reading::Text(t) => match t.trim().to_ascii_lowercase().as_str() { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + }, + } +} + +/// Several settings' answers as one: off as soon as one says off, on when at least one says on, +/// `None` when none says anything. +pub fn combine(answers: impl IntoIterator>) -> Option { + let mut any = None; + for on in answers.into_iter().flatten() { + if !on { + return Some(false); + } + any = Some(true); + } + any +} + +/// The value of `key` in `[section]` of a settings file in INI form (KDE's `kdeglobals`, GTK's +/// `settings.ini`). The last one wins, as the toolkits read it. +pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { + let mut inside = false; + let mut found = None; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + inside = name.trim() == section; + } else if inside + && !line.starts_with(['#', ';']) + && let Some((k, v)) = line.split_once('=') + && k.trim() == key + { + found = Some(v.trim()); + } + } + found +} + +/// What the user's own settings files say, given their text (`None` for a file that isn't +/// there): KDE Plasma's `kdeglobals` and GTK's `settings.ini`s. +pub fn files_say(kdeglobals: Option<&str>, gtk_settings: [Option<&str>; 2]) -> Option { + let (namespace, key) = KDE_ANIMATION_SPEED; + let kde = kdeglobals.and_then(|text| ini_value(text, "KDE", key)).and_then(|v| says(namespace, key, &Reading::Text(v.to_string()))); + let gtk = gtk_settings + .into_iter() + .map(|text| text.and_then(|t| ini_value(t, "Settings", "gtk-enable-animations")).and_then(|v| truth(&Reading::Text(v.to_string())))); + combine(std::iter::once(kde).chain(gtk)) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub use watching::watch; + +#[cfg(all(unix, not(target_os = "macos")))] +mod watching { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + + use zbus::blocking::{Connection, Proxy}; + use zbus::zvariant::{OwnedValue, Value}; + + use super::{KEYS, Reading, combine, files_say, says}; + + // The latest answer, as one byte the UI thread reads without waiting. + const UNKNOWN: u8 = 0; + const ON: u8 = 1; + const OFF: u8 = 2; + + /// Start following the desktop's animation setting. The returned function gives the latest + /// answer from memory (`None` until the first one arrives, and when the desktop says nothing); + /// `ctx` is asked to redraw when the answer changes. + pub fn watch(ctx: egui::Context) -> impl Fn() -> Option { + let latest = Arc::new(AtomicU8::new(UNKNOWN)); + let shared = latest.clone(); + let spawned = std::thread::Builder::new().name("animation-setting".into()).spawn(move || { + let publish = |answer: Option| { + let code = match answer { + Some(true) => ON, + Some(false) => OFF, + None => UNKNOWN, + }; + if shared.swap(code, Ordering::Relaxed) != code { + ctx.request_repaint(); + } + }; + if let Err(e) = follow_portal(&publish) { + log::debug!("desktop animation setting: no settings portal ({e}); reading the settings files"); + publish(read_files()); + } + }); + if let Err(e) = spawned { + log::warn!("desktop animation setting: {e}"); + } + move || match latest.load(Ordering::Relaxed) { + ON => Some(true), + OFF => Some(false), + _ => None, + } + } + + /// Publish what the settings portal says, now and after every change it announces, until the + /// connection ends; an error when there is no session bus to ask on. While the portal knows + /// none of the settings, the files' answer stands. + fn follow_portal(publish: &dyn Fn(Option)) -> zbus::Result<()> { + let connection = Connection::session()?; + let portal = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?; + // Listen before the first read, so a change between the two isn't missed. + let changes = portal.receive_signal("SettingChanged")?; + // The files are read once, the first time the portal has nothing to say. + let mut files = None; + let mut ask = || ask_portal(&portal).or_else(|| *files.get_or_insert_with(read_files)); + publish(ask()); + // A setting can turn up later (KDE's has no value while the speed is at its default), so + // the changes are followed whatever the portal knew at first. Every setting is asked + // again, not just the one announced: KDE announces a new animation speed only as the + // cross-desktop preference, and its own setting must not be left at the old value. + for change in changes { + let ours = change + .body() + .deserialize::<(String, String, OwnedValue)>() + .is_ok_and(|(namespace, key, _)| KEYS.contains(&(namespace.as_str(), key.as_str()))); + if ours { + publish(ask()); + } + } + Ok(()) + } + + /// What the portal's settings come to; `None` when it has none of them. + fn ask_portal(portal: &Proxy<'_>) -> Option { + combine(KEYS.map(|(namespace, key)| read_setting(portal, namespace, key).and_then(|value| says(namespace, key, &value)))) + } + + /// One setting from the portal; `None` when it doesn't have it. + fn read_setting(portal: &Proxy<'_>, namespace: &str, key: &str) -> Option { + // `ReadOne` is the current call; portals from before it only have `Read`. + let value: OwnedValue = portal.call("ReadOne", &(namespace, key)).or_else(|_| portal.call("Read", &(namespace, key))).ok()?; + reading(&value, 0) + } + + fn reading(value: &Value<'_>, depth: u8) -> Option { + Some(match value { + Value::Bool(b) => Reading::Bool(*b), + Value::U8(n) => Reading::Number(f64::from(*n)), + Value::I16(n) => Reading::Number(f64::from(*n)), + Value::U16(n) => Reading::Number(f64::from(*n)), + Value::I32(n) => Reading::Number(f64::from(*n)), + Value::U32(n) => Reading::Number(f64::from(*n)), + Value::I64(n) => Reading::Number(*n as f64), + Value::U64(n) => Reading::Number(*n as f64), + Value::F64(n) => Reading::Number(*n), + Value::Str(s) => Reading::Text(s.as_str().to_string()), + // `Read` wraps its answer in a second variant. + Value::Value(inner) if depth < 4 => return reading(inner, depth + 1), + _ => return None, + }) + } + + /// What the user's settings files say (see [`files_say`]). + fn read_files() -> Option { + let config = std::env::var_os("XDG_CONFIG_HOME") + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + let text = |file: &str| read_capped(&config.join(file)); + let (kdeglobals, gtk4, gtk3) = (text("kdeglobals"), text("gtk-4.0/settings.ini"), text("gtk-3.0/settings.ini")); + files_say(kdeglobals.as_deref(), [gtk4.as_deref(), gtk3.as_deref()]) + } + + /// A settings file's text: at most 256 KiB of it (they are a few kilobytes). + fn read_capped(path: &Path) -> Option { + use std::io::Read as _; + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(256 * 1024).read_to_string(&mut text).ok()?; + Some(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(s: &str) -> Reading { + Reading::Text(s.to_string()) + } + + /// Each desktop's setting, as the portal hands it over, comes to animations on or off. + #[test] + fn each_desktop_setting_says_on_or_off() { + let ((appearance, reduced), (gnome, enable), (kde, factor)) = (REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED); + assert_eq!(KEYS, [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]); + // The cross-desktop preference: only 1 asks for reduced motion. + assert_eq!(says(appearance, reduced, &Reading::Number(1.0)), Some(false)); + assert_eq!(says(appearance, reduced, &Reading::Number(0.0)), Some(true)); + assert_eq!(says(appearance, reduced, &Reading::Number(7.0)), Some(true), "a value not defined yet is no preference"); + assert_eq!(says(appearance, reduced, &Reading::Bool(true)), None); + // GNOME's switch. + assert_eq!(says(gnome, enable, &Reading::Bool(false)), Some(false)); + assert_eq!(says(gnome, enable, &Reading::Bool(true)), Some(true)); + assert_eq!(says(gnome, enable, &text("nonsense")), None); + // KDE Plasma's animation speed arrives as text: 0 is instant. + assert_eq!(says(kde, factor, &text("0")), Some(false)); + assert_eq!(says(kde, factor, &text(" 0.5 ")), Some(true)); + assert_eq!(says(kde, factor, &Reading::Number(1.0)), Some(true)); + for junk in [text(""), text("fast"), Reading::Number(f64::NAN), text("inf")] { + assert_eq!(says(kde, factor, &junk), None, "{junk:?}"); + } + // Any other setting says nothing. + assert_eq!(says(gnome, "color-scheme", &Reading::Bool(false)), None); + assert_eq!(says("org.example", enable, &Reading::Bool(false)), None); + } + + /// One setting saying off is enough; none saying anything is no answer. + #[test] + fn any_setting_saying_off_turns_animations_off() { + assert_eq!(combine([]), None); + assert_eq!(combine([None, None]), None); + assert_eq!(combine([None, Some(true)]), Some(true)); + assert_eq!(combine([Some(true), None, Some(false)]), Some(false)); + assert_eq!(combine([Some(false), Some(true)]), Some(false)); + } + + /// Without a portal the user's own settings files answer: KDE's animation speed and GTK's + /// `gtk-enable-animations`. + #[test] + fn settings_files_answer_without_a_portal() { + let kde_instant = "[General]\nColorScheme=Breeze\n\n[KDE]\nAnimationDurationFactor=0\nSingleClick=false\n"; + let kde_slow = "[KDE]\nAnimationDurationFactor = 2\n"; + let gtk_off = "[Settings]\ngtk-theme-name=Adwaita\ngtk-enable-animations=0\n"; + let gtk_on = "# written by hand\n[Settings]\ngtk-enable-animations=true\n"; + assert_eq!(files_say(None, [None, None]), None); + assert_eq!(files_say(Some("[KDE]\nSingleClick=false\n"), [Some("[Settings]\n"), None]), None, "files that don't mention it"); + assert_eq!(files_say(Some(kde_instant), [None, None]), Some(false)); + assert_eq!(files_say(Some(kde_slow), [None, None]), Some(true)); + assert_eq!(files_say(None, [Some(gtk_off), None]), Some(false)); + assert_eq!(files_say(None, [None, Some(gtk_on)]), Some(true)); + assert_eq!(files_say(Some(kde_slow), [Some(gtk_on), Some(gtk_off)]), Some(false), "one file saying off is enough"); + + // The key counts only in its own section, comments don't, and the last value wins. + assert_eq!(ini_value("[Other]\nAnimationDurationFactor=0\n[KDE]\nSingleClick=true\n", "KDE", "AnimationDurationFactor"), None); + assert_eq!(ini_value("[KDE]\n#AnimationDurationFactor=0\n;AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None); + assert_eq!(ini_value("[KDE]\r\nAnimationDurationFactor=0\r\nAnimationDurationFactor=1\r\n", "KDE", "AnimationDurationFactor"), Some("1")); + assert_eq!(ini_value("AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None, "outside any section"); + assert_eq!(ini_value("", "KDE", "AnimationDurationFactor"), None); + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index c5e73eeb2..6c392787b 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -9,6 +9,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod animations; #[cfg(target_os = "macos")] mod apple_events; mod control_server; @@ -175,12 +176,14 @@ fn save_prefs(app: &WordApp) { } /// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print -/// through the system's PDF viewer (`print`, #15), and pictures on the system clipboard (#45). -fn services(file_dialog: file_dialogs::Hook) -> Services { +/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), and +/// the system's animation setting (`animations`). +fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { Services { file_dialog: Some(file_dialog), print: Some(print::hook()), clipboard_picture: Some(Box::new(clipboard_picture)), + animations_enabled: Some(animations::hook(ctx)), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), ..Default::default() } @@ -283,7 +286,7 @@ fn main() -> eframe::Result { Box::new(move |cc| { let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() }; let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); - let mut app = WordApp::new(Session::new(doc), services(file_dialog)); + let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx)); load_prefs(&mut app); app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 793b23575..369ecc94e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -479,12 +479,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.canvas_rect = Some(area); // Ctrl/⌘ + wheel (and trackpad pinch) zooms the document, like Word. egui reports both as a // zoom factor and doesn't scroll for them. Start from the shown scale so fit modes zoom too. - let zoom_delta = ui.input(|i| i.zoom_delta()); + // With animation effects off the wheel zooms in whole steps, each at once. + let animate = app.animations_on(); + let opts = ui.ctx().options(|o| o.input_options); + let zoom_delta = ui.input(|i| app.canvas.wheel.zoom(i, &opts, animate)); if (zoom_delta - 1.0).abs() > 1e-3 && ui.rect_contains_pointer(area) { let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(10.0, 500.0); let _ = app.run("view.zoom", json!({"value": pct})); } let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0)); + // The status bar's zoom readout and slider were laid out before this, from the last frame's + // scale: after a zoom change draw again at once, so they follow in the next frame rather than + // whenever something else next repaints (the caret's blink). + if (geo.scale - app.canvas.scale).abs() > 1e-6 { + ui.ctx().request_repaint(); + } app.canvas.scale = geo.scale; sync_fit_zoom(app, geo.scale); // Zooming or resizing reflowed the pages into a different number of columns: the caret's page @@ -514,11 +523,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with - // momentum, wheel notches eased in. Applied before drawing, so it shows this frame. + // momentum, wheel notches eased in (or all at once with animation effects off). Applied + // before drawing, so it shows this frame. let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none(); let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT); - let opts = ui.ctx().options(|o| o.input_options); - let wheel_delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height())); + let wheel_delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height(), animate)); // Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin // to full speed at the edge, through the same offset the wheel writes. let (down, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.stable_dt)); @@ -535,6 +544,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let mut scroll_area = egui::ScrollArea::both() .id_salt("canvas_scroll") .auto_shrink([false, false]) + // Bringing the caret into view glides there, or jumps with animation effects off. + .animated(animate) .scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() }); if delta != egui::Vec2::ZERO { let before = app.canvas.scroll_offset; @@ -571,29 +582,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height())); let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05); let key = page_key(app, page, scale_px, dim_body); - let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); - let current_scale = geo.scale * ppp; - let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) { - app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp)) - } else { - sr - }; - rects.push(visual_sr); + let mut fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); // Hide White Space shows only a band of the page; everything below paints into it. - let band = - Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot)); - if !band.intersects(ui.clip_rect().expand(200.0)) { - continue; - } - let painter = if band_top <= 0.0 && band_bot >= sr.height() { - painter.clone() - } else { - painter.with_clip_rect(painter.clip_rect().intersect(band)) - }; - // Shadow and paper. - painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); - painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE }); - if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { + let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot)); + let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0)); + // Render before placing the page: a page re-rendered in this frame (a zoom step, an + // edit) then sits on the pixel grid in this frame too, rather than a frame later. + if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { let mut opts = screen_render_options(); opts.display.marks = app.session.view.marks; opts.display.always = app.session.prefs.marks; @@ -620,9 +615,29 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } rendered += 1; - } else if !fresh { + fresh = true; + } else if visible && !fresh { ui.ctx().request_repaint(); } + let current_scale = geo.scale * ppp; + let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) { + app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp)) + } else { + sr + }; + rects.push(visual_sr); + if !visible { + continue; + } + let band = band_of(visual_sr); + let painter = if band_top <= 0.0 && band_bot >= sr.height() { + painter.clone() + } else { + painter.with_clip_rect(painter.clip_rect().intersect(band)) + }; + // Shadow and paper. + painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); + painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE }); if let Some((_, tex)) = app.canvas.textures.get(&i) { painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } diff --git a/crates/ui-egui/src/caret_glide.rs b/crates/ui-egui/src/caret_glide.rs index f2d02b0f8..0feb2b971 100644 --- a/crates/ui-egui/src/caret_glide.rs +++ b/crates/ui-egui/src/caret_glide.rs @@ -349,9 +349,9 @@ fn view_key(app: &WordApp, geo: &Geometry) -> u64 { /// on-and-off blink says. Returns where and how strongly to draw it. `content_origin` is the scrolled /// content's top-left on screen. pub fn look(app: &WordApp, ui: &Ui, content_origin: Pos2, geo: &Geometry, target: Bar, shown: bool, since_ms: f64, on: bool) -> Look { - // Turned off in Options, or egui's own switch for "no animations": the caret jumps and blinks - // on and off. - if !app.ui.caret_animation || ui.style().animation_time <= 0.0 || glide_secs() <= 0.0 { + // Turned off in Options, Animation effects off (the setting, or the system's under "Use system + // setting"), or egui's own switch for "no animations": the caret jumps and blinks on and off. + if !app.ui.caret_animation || !app.animations_on() || ui.style().animation_time <= 0.0 || glide_secs() <= 0.0 { ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(state_id()).reset()); return Look { bar: target, opacity: if on { 1.0 } else { 0.0 }, moving: false }; } @@ -808,6 +808,8 @@ mod tests { fn the_canvas_caret_glides_after_a_key() { let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); a.run("text.insert", json!({"text": "Some text here"})).unwrap(); + // Animation effects off: the caret follows that switch. + a.run("ui.animations", json!({"value": "off"})).unwrap(); let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(DT as f32).build_ui_state( |ui, app: &mut WordApp| { let ctx = ui.ctx().clone(); @@ -816,11 +818,11 @@ mod tests { }, a, ); - // The test harness turns egui's animations off, and the caret follows that switch. for _ in 0..3 { h.step(); } assert!(state(&h.ctx).drawn().is_none(), "no animations: the glide stands down"); + h.state_mut().run("ui.animations", json!({"value": "on"})).unwrap(); h.ctx.all_styles_mut(|s| s.animation_time = 0.1); for _ in 0..30 { h.step(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bbee462c0..f1f52fa8d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2042,3 +2042,11 @@ Turkish Turco Ukrainian Ucraniano {language} is now the document's default language. {language} es ahora el idioma predeterminado del documento. + Visual effects Efectos visuales + Animation effects: Efectos de animación: + On Activado + Off Desactivado + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Desplazamiento y zoom suaves, y menús y botones que aparecen gradualmente. Con «Desactivado», cada cambio se muestra al instante. + Your system has animation effects turned on. Tu sistema tiene activados los efectos de animación. + Your system has animation effects turned off. Tu sistema tiene desactivados los efectos de animación. + Your system doesn't report an animation setting, so animation effects stay on. Tu sistema no indica ningún ajuste de animación, así que los efectos de animación siguen activados. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 4fa6a272a..82e80d79d 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2036,3 +2036,11 @@ Turkish Türgi Ukrainian Ukraina {language} is now the document's default language. {language} on nüüd dokumendi vaikekeel. + Visual effects Visuaalefektid + Animation effects: Animatsiooniefektid: + On Sees + Off Väljas + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Sujuv kerimine ja suumimine ning menüüde ja nuppude sujuv ilmumine. Valikuga „Väljas“ kuvatakse iga muudatus kohe. + Your system has animation effects turned on. Süsteemis on animatsiooniefektid sisse lülitatud. + Your system has animation effects turned off. Süsteemis on animatsiooniefektid välja lülitatud. + Your system doesn't report an animation setting, so animation effects stay on. Süsteem ei teata animatsiooniseadet, seega jäävad animatsiooniefektid sisse. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9aa5bef25..da0f4672a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2026,3 +2026,11 @@ Turkish トルコ語 Ukrainian ウクライナ語 {language} is now the document's default language. {language} がこの文書の既定の言語になりました。 + Visual effects 視覚効果 + Animation effects: アニメーション効果: + On オン + Off オフ + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. スクロールやズームのなめらかな動きと、メニューやボタンのフェード表示です。「オフ」にすると、すべての変化がすぐに表示されます。 + Your system has animation effects turned on. システムのアニメーション効果はオンです。 + Your system has animation effects turned off. システムのアニメーション効果はオフです。 + Your system doesn't report an animation setting, so animation effects stay on. システムからアニメーションの設定を取得できないため、アニメーション効果はオンのままです。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1279d7501..fa4b0ef68 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2038,3 +2038,11 @@ Turkish Turco Ukrainian Ucraniano {language} is now the document's default language. {language} agora é o idioma padrão do documento. + Visual effects Efeitos visuais + Animation effects: Efeitos de animação: + On Ativado + Off Desativado + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Rolagem e zoom suaves, e menus e botões que aparecem gradualmente. Com “Desativado”, cada alteração é mostrada de imediato. + Your system has animation effects turned on. Seu sistema está com os efeitos de animação ativados. + Your system has animation effects turned off. Seu sistema está com os efeitos de animação desativados. + Your system doesn't report an animation setting, so animation effects stay on. Seu sistema não informa uma configuração de animação, então os efeitos de animação continuam ativados. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index aecf0d26e..f5338fb6f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2418,3 +2418,11 @@ preview Title Пример # Asian and Latin text fonts (#505). Asian text font: Шрифт азиатского текста: Latin text font: Шрифт латинского текста: + Visual effects Визуальные эффекты + Animation effects: Эффекты анимации: + On Включено + Off Выключено + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Плавная прокрутка и масштабирование, а также плавное появление меню и кнопок. Если выбрано «Выключено», все изменения отображаются сразу. + Your system has animation effects turned on. В системе эффекты анимации включены. + Your system has animation effects turned off. В системе эффекты анимации выключены. + Your system doesn't report an animation setting, so animation effects stay on. Система не сообщает о настройке анимации, поэтому эффекты анимации остаются включёнными. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 993f84c21..57d87244b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2036,3 +2036,11 @@ Turkish Turski Ukrainian Ukrajinski {language} is now the document's default language. {language} je sada podrazumevani jezik dokumenta. + Visual effects Vizuelni efekti + Animation effects: Efekti animacije: + On Uključeno + Off Isključeno + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Glatko pomeranje i zumiranje, kao i postepeno pojavljivanje menija i dugmadi. Uz „Isključeno“ svaka promena se prikazuje odmah. + Your system has animation effects turned on. U sistemu su efekti animacije uključeni. + Your system has animation effects turned off. U sistemu su efekti animacije isključeni. + Your system doesn't report an animation setting, so animation effects stay on. Sistem ne prijavljuje podešavanje animacije, pa efekti animacije ostaju uključeni. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 6a64352d7..519efcce1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2040,3 +2040,11 @@ Turkish Турски Ukrainian Украјински {language} is now the document's default language. {language} је сада подразумевани језик документа. + Visual effects Визуелни ефекти + Animation effects: Ефекти анимације: + On Укључено + Off Искључено + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Глатко померање и зумирање, као и постепено појављивање менија и дугмади. Уз „Искључено“ свака промена се приказује одмах. + Your system has animation effects turned on. У систему су ефекти анимације укључени. + Your system has animation effects turned off. У систему су ефекти анимације искључени. + Your system doesn't report an animation setting, so animation effects stay on. Систем не пријављује подешавање анимације, па ефекти анимације остају укључени. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d014ea7aa..4a9017b82 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2040,3 +2040,11 @@ # Asian and Latin text fonts (#505). Asian text font: Asya metni yazı tipi: Latin text font: Latin metni yazı tipi: + Visual effects Görsel efektler + Animation effects: Animasyon efektleri: + On Açık + Off Kapalı + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Yumuşak kaydırma ve yakınlaştırma ile menülerin ve düğmelerin yavaşça belirmesi. Kapalı seçildiğinde her değişiklik anında gösterilir. + Your system has animation effects turned on. Sisteminizde animasyon efektleri açık. + Your system has animation effects turned off. Sisteminizde animasyon efektleri kapalı. + Your system doesn't report an animation setting, so animation effects stay on. Sisteminiz bir animasyon ayarı bildirmiyor, bu nedenle animasyon efektleri açık kalır. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index db440612b..02212e733 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2038,3 +2038,11 @@ Turkish Турецька Ukrainian Українська {language} is now the document's default language. {language} тепер типова мова документа. + Visual effects Візуальні ефекти + Animation effects: Ефекти анімації: + On Увімкнено + Off Вимкнено + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Плавне прокручування й масштабування, а також поступова поява меню та кнопок. Якщо вибрано «Вимкнено», усі зміни відображаються одразу. + Your system has animation effects turned on. У вашій системі ефекти анімації ввімкнено. + Your system has animation effects turned off. У вашій системі ефекти анімації вимкнено. + Your system doesn't report an animation setting, so animation effects stay on. Система не повідомляє про налаштування анімації, тому ефекти анімації залишаються ввімкненими. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e1e211fab..cbc542930 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2038,3 +2038,11 @@ Turkish 土耳其语 Ukrainian 乌克兰语 {language} is now the document's default language. {language} 现在是此文档的默认语言。 + Visual effects 视觉效果 + Animation effects: 动画效果: + On 开 + Off 关 + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. 平滑滚动和缩放,以及菜单和按钮的淡入效果。选择“关”后,所有更改会立即显示。 + Your system has animation effects turned on. 系统已打开动画效果。 + Your system has animation effects turned off. 系统已关闭动画效果。 + Your system doesn't report an animation setting, so animation effects stay on. 系统未提供动画设置,因此动画效果保持打开。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1f05f5cd3..ac5605cfd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2027,3 +2027,11 @@ Turkish 土耳其文 Ukrainian 烏克蘭文 {language} is now the document's default language. {language} 現在是此文件的預設語言。 + Visual effects 視覺效果 + Animation effects: 動畫效果: + On 開 + Off 關 + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. 平滑捲動與縮放,以及功能表與按鈕的淡入效果。選擇「關」後,所有變更會立即顯示。 + Your system has animation effects turned on. 系統已開啟動畫效果。 + Your system has animation effects turned off. 系統已關閉動畫效果。 + Your system doesn't report an animation setting, so animation effects stay on. 系統未提供動畫設定,因此動畫效果保持開啟。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ce203d514..29b9a2569 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,12 @@ pub struct Services { /// Desktop: the picture on the system clipboard, if any — egui's paste only carries text /// (#45). Read when Paste finds no text; see [`paste_picture`]. pub clipboard_picture: Option Option>>, + /// Whether the system has animation effects on (Windows: Animation effects; macOS: Reduce + /// motion; Linux and the BSDs: the desktop's setting; browsers: `prefers-reduced-motion`), or + /// `None` when it doesn't say. What "Use system setting" follows ([`theme::Animations`]). + /// Asked every frame, so a change in the system's settings shows at once: the host answers + /// from memory and does any slow asking elsewhere. Without the hook, animation effects stay on. + pub animations_enabled: Option Option>>, /// Desktop: the text on the system clipboard, if any. Ctrl+V gets it from egui's paste event; /// the ribbon and context-menu Paste read it here (#584). pub clipboard_text: Option Option>>, @@ -136,6 +142,9 @@ pub struct UiState { /// Interface size in percent (#475): scales the whole interface on top of the system's /// display scale. Junk or out-of-range saved values read as 100 % or are clamped to 80–200 %. pub interface_size: theme::InterfaceSize, + /// Animation effects (smooth scrolling, fades): on, off, or follow the system's setting, which + /// is the default. An unknown saved value reads as the default. + pub animations: theme::Animations, /// The dark-mode switch `ui.json` held before `theme` (#115): read once to migrate, never written. #[serde(rename = "dark", skip_serializing)] pub(crate) legacy_dark: Option, @@ -213,6 +222,7 @@ impl Default for UiState { recent_colors: Vec::new(), theme: theme::Appearance::default(), interface_size: theme::InterfaceSize::default(), + animations: theme::Animations::default(), legacy_dark: None, nav_tab: "headings".into(), show_discord: true, @@ -302,6 +312,10 @@ pub struct WordApp { fonts_frames: u32, /// The interface theme setting last installed in egui ([`theme::apply`]). applied_theme: Option, + /// What the system last said about its animation effects ([`Services::animations_enabled`]). + system_animations: Option, + /// Whether egui's own animations were last switched on or off ([`theme::apply_animations`]). + applied_animations: Option, pub frame_ms: f64, /// The window title last sent; a viewport command schedules a repaint, so only send changes. sent_title: String, @@ -384,6 +398,8 @@ impl WordApp { want_system_cjk: false, fonts_frames: 0, applied_theme: None, + system_animations: None, + applied_animations: None, frame_ms: 0.0, sent_title: String::new(), quit_requested: false, @@ -786,6 +802,22 @@ impl WordApp { self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme)) } + /// Whether animation effects are on: the Animation effects setting, where `System` follows + /// what the system last reported (on when it reports nothing). + pub fn animations_on(&self) -> bool { + self.ui.animations.enabled(self.system_animations) + } + + /// What the system last said about its animation effects (`None`: it doesn't say). + pub fn system_animations(&self) -> Option { + self.system_animations + } + + /// Ask the host for the system's animation setting. + fn read_system_animations(&mut self) { + self.system_animations = self.services.animations_enabled.as_ref().and_then(|ask| ask()); + } + /// Commands that live in the UI layer. fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -876,6 +908,18 @@ impl WordApp { } json!({"percent": self.ui.interface_size.percent()}) } + "ui.animations" => { + // `system` (follow the system's animation setting), `on` or `off`; no value reads + // it. `enabled` is what that comes to now, `system` what the system reports. + if let Some(v) = s("value") { + match theme::Animations::from_code(v) { + Some(a) => self.ui.animations = a, + None => return Some(Err(format!("unknown animations setting {v:?}; use system, on or off"))), + } + } + self.read_system_animations(); + json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations}) + } "ui.language" => { // `auto` (follow the system) or a language code; anything else is an error. if let Some(v) = s("value") { @@ -1100,6 +1144,14 @@ impl WordApp { if ctx.zoom_factor() != zoom { ctx.set_zoom_factor(zoom); } + // Animation effects: the host is asked every frame what the system says (it answers from + // memory), so a change made in the system's settings is followed without a restart. + self.read_system_animations(); + let animate = self.animations_on(); + if self.applied_animations != Some(animate) { + theme::apply_animations(ctx, animate); + self.applied_animations = Some(animate); + } if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } @@ -1223,6 +1275,11 @@ impl WordApp { let t0 = now_ms(); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.ui.language)); + // Animation effects off: egui's scroll areas get a wheel notch whole, not eased in. + if !self.animations_on() { + let opts = ctx.options(|o| o.input_options); + ctx.input_mut(|i| i.smooth_scroll_delta = scroll::unsmoothed_scroll(i, &opts)); + } // Fonts installed by `logic` take effect on the next frame. if self.fonts_frames < 2 { self.fonts_frames += 1; @@ -1779,6 +1836,174 @@ mod tests { assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at); } + /// An app whose host reports the system's animation setting as `system` holds it. + fn app_with_system_animations(system: &std::rc::Rc>>) -> WordApp { + let system = system.clone(); + let services = Services { animations_enabled: Some(Box::new(move || system.get())), ..Default::default() }; + WordApp::new(Session::new(wordcraft_doc::Document::new()), services) + } + + /// Animation effects: "Use system setting" (the default) follows what the host reports and is + /// on when it reports nothing; On and Off ignore the system. The choice is kept between runs. + #[test] + fn animation_effects_setting_follows_the_system_and_survives_a_restart() { + use theme::Animations; + for (setting, system, on) in [ + (Animations::System, None, true), + (Animations::System, Some(true), true), + (Animations::System, Some(false), false), + (Animations::On, Some(false), true), + (Animations::Off, Some(true), false), + (Animations::Off, None, false), + ] { + assert_eq!(setting.enabled(system), on, "{setting:?} with the system at {system:?}"); + } + // No host hook (tests, headless screenshots): nothing is reported, so they are on. + let mut a = app(); + assert_eq!(a.run("ui.animations", json!({})).unwrap(), json!({"animations": "system", "enabled": true, "system": null})); + // The system has them off, as Windows reports with Animation effects switched off. + let system = std::rc::Rc::new(std::cell::Cell::new(Some(false))); + let mut a = app_with_system_animations(&system); + assert_eq!(a.run("ui.animations", json!({})).unwrap(), json!({"animations": "system", "enabled": false, "system": false})); + assert_eq!(a.run("ui.animations", json!({"value": "On"})).unwrap()["enabled"], true); + system.set(Some(true)); + assert_eq!(a.run("ui.animations", json!({"value": "off"})).unwrap(), json!({"animations": "off", "enabled": false, "system": true})); + assert!(a.run("ui.animations", json!({"value": "sometimes"})).is_err()); + assert_eq!(a.ui.animations, Animations::Off, "a rejected value keeps the setting"); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""animations":"off""#), "{saved}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.animations, Animations::Off); + assert!(!b.animations_on()); + // Preferences from before the setting, or with a value it doesn't know, follow the system. + for raw in [r#"{"tab": "Insert"}"#, r#"{"animations": "wobbly", "tab": "Insert"}"#, r#"{"animations": 3, "tab": "Insert"}"#] { + let ui = UiState::from_json(raw.as_bytes()).unwrap(); + assert_eq!((ui.animations, ui.tab.as_str()), (Animations::System, "Insert"), "{raw}"); + } + } + + /// With the system's animation effects off (what Windows reports with Settings › Accessibility › + /// Visual effects › Animation effects off), a wheel notch scrolls the page its whole distance + /// in one frame and egui's own fades and scroll glides are off. A change of the system's + /// setting is followed from the next frame. + #[test] + fn wheel_notches_land_at_once_with_animation_effects_off() { + let ctx = egui::Context::default(); + let system = std::rc::Rc::new(std::cell::Cell::new(Some(false))); + let mut a = app_with_system_animations(&system); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + assert!(!a.animations_on()); + assert_eq!(ctx.global_style().animation_time, 0.0, "no fades"); + assert_eq!(ctx.global_style().scroll_animation, egui::style::ScrollAnimation::none(), "no glide to the caret"); + let over_page = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); + let notch_down = || egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }; + let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT); + let at = a.canvas.scroll_offset.y; + frame(&mut a, vec![notch_down()]); + let landed = a.canvas.scroll_offset.y; + assert!((landed - at - notch).abs() < 0.5, "the whole notch ({notch}) lands in its frame: {}", landed - at); + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + assert_eq!(a.canvas.scroll_offset.y, landed, "nothing is left to ease in"); + // Animation effects are switched back on in the system's settings: the next frame has it. + system.set(Some(true)); + frame(&mut a, Vec::new()); + assert!(a.animations_on()); + assert_eq!(ctx.global_style().animation_time, egui::Style::default().animation_time); + assert_eq!(ctx.global_style().scroll_animation, egui::Style::default().scroll_animation); + frame(&mut a, vec![notch_down()]); + let first = a.canvas.scroll_offset.y - landed; + assert!(first > 0.0 && first < notch, "a notch eases in again: {first}"); + } + + /// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the + /// notch's whole step at once, the page is already placed and drawn as it will stay, and the + /// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces + /// (a touchpad pinch on Windows) add up to a step first rather than each zooming a little. + #[test] + fn zoom_steps_land_finished_with_animation_effects_off() { + let ctx = egui::Context::default(); + let system = std::rc::Rc::new(std::cell::Cell::new(Some(false))); + let mut a = app_with_system_animations(&system); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)); + let mut t = 0.0; + // One frame; returns how soon it asked to be drawn again. + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + let out = ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + let again = out.viewport_output.values().map(|v| v.repaint_delay).min(); + out.drop_without_applying_deltas(); + again + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + let over_page = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); + frame(&mut a, Vec::new()); + let ctrl_wheel = |notches: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, notches), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }; + let opts = egui::InputOptions::default(); + let notch_zoom = (opts.scroll_zoom_speed * opts.line_scroll_speed).exp(); + let before = a.canvas.scale; + frame(&mut a, vec![ctrl_wheel(1.0)]); + let (scale, pages) = (a.canvas.scale, a.canvas.page_rects.clone()); + assert!((scale / before - notch_zoom).abs() < 1e-3, "the whole notch in its frame: {before} -> {scale}"); + for _ in 0..30 { + frame(&mut a, Vec::new()); + assert_eq!(a.canvas.scale, scale, "nothing is left to ease in"); + assert_eq!(a.canvas.page_rects, pages, "the page was already where and how it stays"); + } + // Quarter notches: a step of about 10% with every second one, nothing in between. + frame(&mut a, vec![ctrl_wheel(-0.25)]); + assert_eq!(a.canvas.scale, scale, "a quarter notch isn't a step yet"); + frame(&mut a, vec![ctrl_wheel(-0.25)]); + let stepped = a.canvas.scale; + assert!((stepped / scale - (-0.1_f32).exp()).abs() < 1e-3, "{scale} -> {stepped}"); + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + assert_eq!(a.canvas.scale, stepped); + // Zoom Out (the status bar's minus, View › Zoom Out): the page changes in that frame, and + // the frame asks for another at once so the readout beside the slider isn't left behind. + assert_ne!(frame(&mut a, Vec::new()), Some(std::time::Duration::ZERO), "idle: nothing to redraw at once"); + a.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(frame(&mut a, Vec::new()), Some(std::time::Duration::ZERO)); + let (scale, pages) = (a.canvas.scale, a.canvas.page_rects.clone()); + assert!(scale < stepped); + frame(&mut a, Vec::new()); + assert_eq!((a.canvas.scale, &a.canvas.page_rects), (scale, &pages)); + } + /// Issue #688: dragging a selection past the viewport's bottom edge scrolls on, and the /// selection keeps growing as the content moves under the stationary pointer. #[test] diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 8564d25f5..918536ff6 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -274,6 +274,9 @@ fn language(app: &mut WordApp, ui: &mut Ui) { fn accessibility(app: &mut WordApp, ui: &mut Ui) { use wordcraft_engine::speech::{MAX_RATE, MIN_RATE}; + section(ui, "Visual effects"); + animations_picker(app, ui); + ui.add_space(8.0); section(ui, "Read Aloud"); let mut rate = app.session.read_aloud.rate(); ui.horizontal(|ui| { @@ -386,6 +389,31 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { ); } +/// Animation effects: on, off, or follow the system's setting (the default). Under "Use system +/// setting" a line says what the system reports, so it is plain what the choice comes to here. +fn animations_picker(app: &mut WordApp, ui: &mut Ui) { + use crate::theme::Animations; + ui.horizontal(|ui| { + ui.label(tl!("Animation effects:")); + egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| { + for a in Animations::ALL { + if ui.selectable_label(app.ui.animations == a, tl!(a.label())).clicked() { + let _ = app.run("ui.animations", json!({"value": a.code()})); + } + } + }); + }); + note(ui, "Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once."); + if app.ui.animations == Animations::System { + let reported = match app.system_animations() { + Some(true) => "Your system has animation effects turned on.", + Some(false) => "Your system has animation effects turned off.", + None => "Your system doesn't report an animation setting, so animation effects stay on.", + }; + note(ui, reported); + } +} + /// Interface language: follow the system (the default) or pick one (#8). fn language_picker(app: &mut WordApp, ui: &mut Ui) { use crate::i18n::{AUTO, Lang}; @@ -531,6 +559,36 @@ mod tests { assert!(h.query_by_label("Measurement units:").is_some()); } + /// File › Options › Accessibility › Animation effects: Use system setting (the default), On + /// or Off, with a line saying what the system's own setting is while it is followed. + #[test] + fn animation_effects_is_an_accessibility_option() { + use crate::theme::Animations; + let services = Services { animations_enabled: Some(Box::new(|| Some(false))), ..Default::default() }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("file.options", json!({"pane": "accessibility"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + assert_eq!(h.state().ui.animations, Animations::System); + assert!(h.query_by_label("Animation effects:").is_some()); + assert!(h.query_by_label("Your system has animation effects turned off.").is_some()); + assert!(!h.state().animations_on()); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(4); + h.get_by_label("On").click(); + h.run_steps(4); + assert_eq!(h.state().ui.animations, Animations::On); + assert!(h.state().animations_on()); + assert!(h.query_by_label("Your system has animation effects turned off.").is_none(), "only said while the system is followed"); + } + /// File › Options › Advanced › Animation: the checkbox is the `ui.caretAnimation` setting, /// and the section is translated. #[test] diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs index 6ba32b76a..358ac48c9 100644 --- a/crates/ui-egui/src/scroll.rs +++ b/crates/ui-egui/src/scroll.rs @@ -7,6 +7,12 @@ //! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose //! system doesn't already send momentum events (not macOS, Windows or the web); //! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui. +//! +//! With animation effects off ([`crate::theme::Animations`]) nothing eases: a notch scrolls the +//! page its whole distance at once, and so do egui's own scroll areas ([`unsmoothed_scroll`]). +//! Ctrl/⌘+wheel zooms in whole steps, each at once ([`CanvasScroll::zoom`]). Touchpad momentum +//! stays: it carries on the fingers' own movement, as the momentum macOS and Windows send does +//! whatever their animation setting. use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2}; @@ -37,6 +43,17 @@ const MAX_SPEED: f32 = 8000.0; /// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s. const DECAY: f32 = 3.0; +/// With animation effects off, Ctrl/⌘+wheel zooms in steps of this size (the natural logarithm of +/// the factor: about 10%). A mouse-wheel notch is two steps at once, the same zoom as with +/// animation effects on. Turns that arrive in small pieces (a touchpad pinch that Windows reports +/// as Ctrl+wheel, a free-spinning or high-resolution wheel) add up to a step first, so the zoom +/// moves in a few plain steps instead of gliding. +const ZOOM_STEP: f32 = 0.1; +/// Part of a zoom step is forgotten after this many seconds without more Ctrl/⌘+wheel input. +const ZOOM_STEP_IDLE: f64 = 0.75; +/// Most zoom steps taken from one frame's input (the zoom itself is clamped to 10–500%). +const MAX_ZOOM_STEPS: f32 = 64.0; + /// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps /// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate /// independent. Deltas are in screen points, positive = content moves down/right. @@ -129,19 +146,25 @@ impl Momentum { } } -/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches. +/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches, and +/// the part of a zoom step the wheel has turned so far. #[derive(Clone, Debug, Default)] pub struct CanvasScroll { momentum: Momentum, /// Wheel distance still to scroll, eased out over a few frames. pending: Vec2, + /// Ctrl/⌘+wheel zoom not yet applied (natural logarithm, less than a [`ZOOM_STEP`]), and the + /// input time it last grew. Only used with animation effects off. + zoom_pending: f32, + zoom_time: f64, } impl CanvasScroll { /// This frame's scroll delta in screen points (positive = content moves down/right) from the /// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do /// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height. - pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 { + /// `animate`: animation effects are on, so wheel notches ease in rather than land at once. + pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32, animate: bool) -> Vec2 { let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX }; let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 }; // Browsers report lines (Firefox: three per notch) where native platforms report notches. @@ -196,9 +219,10 @@ impl CanvasScroll { if SYNTH_MOMENTUM && !fed { now += self.momentum.tick(dt); } - // Ease wheel notches in: 90% of the way in 0.1 s (egui's curve). + // Ease wheel notches in: 90% of the way in 0.1 s (egui's curve). Without animation effects + // the whole distance lands this frame. if self.pending != Vec2::ZERO { - let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 }; + let t = if animate && dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 }; let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t }; let step = Vec2::new(ease(self.pending.x), ease(self.pending.y)); now += step; @@ -217,6 +241,42 @@ impl CanvasScroll { pub fn is_animating(&self) -> bool { self.pending != Vec2::ZERO || self.momentum.is_active() } + + /// This frame's zoom factor from Ctrl/⌘+wheel and pinch gestures (1.0: none). With `animate` + /// it is egui's, which eases a wheel notch's zoom in over a few frames. Without, the wheel + /// zooms in whole steps ([`ZOOM_STEP`]), each at once, however finely the turns arrive; a + /// pinch the system reports as a pinch still follows the fingers. + pub fn zoom(&mut self, input: &egui::InputState, opts: &InputOptions, animate: bool) -> f32 { + // Fingers on a touch screen are measured directly, never eased. + if animate || input.multi_touch().is_some() { + self.zoom_pending = 0.0; + return input.zoom_delta(); + } + let mut zoom = 1.0_f32; + let mut turned = 0.0_f32; + for event in &input.events { + match event { + Event::Zoom(factor) => zoom *= factor, + Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } if modifiers.matches_any(opts.zoom_modifier) => { + let d = egui_points(*unit, *delta, input, opts); + turned += opts.scroll_zoom_speed * (d.x + d.y); + } + _ => {} + } + } + if turned != 0.0 { + if input.time - self.zoom_time > ZOOM_STEP_IDLE { + self.zoom_pending = 0.0; + } + self.zoom_time = input.time; + let total = self.zoom_pending + turned; + // A hair of tolerance: a notch's two steps mustn't come out as 1.9999. + let steps = if total.is_finite() { (total / ZOOM_STEP + 1e-3_f32.copysign(total)).trunc() } else { 0.0 }; + self.zoom_pending = if total.is_finite() { total - steps * ZOOM_STEP } else { 0.0 }; + zoom *= (steps.clamp(-MAX_ZOOM_STEPS, MAX_ZOOM_STEPS) * ZOOM_STEP).exp(); + } + if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 } + } } /// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas. @@ -230,6 +290,33 @@ fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 { } } +/// A wheel event's delta in points the way egui's own scroll areas count it: a line is +/// `line_scroll_speed` points and a page is the window's height. +fn egui_points(unit: MouseWheelUnit, delta: Vec2, input: &egui::InputState, opts: &InputOptions) -> Vec2 { + let d = match unit { + MouseWheelUnit::Point => delta, + MouseWheelUnit::Line => delta * opts.line_scroll_speed, + MouseWheelUnit::Page => delta * input.viewport_rect().height(), + }; + if d.is_finite() { d } else { Vec2::ZERO } +} + +/// This frame's wheel scrolling for egui's own scroll areas (panes, lists, dialogs, Backstage) +/// with nothing held back: egui eases a wheel notch in over the next frames +/// (`InputState::smooth_scroll_delta`) and has no switch for it, so with animation effects off the +/// app puts this in its place every frame. Ctrl/⌘+wheel is left out, as in egui: it zooms. +pub fn unsmoothed_scroll(input: &egui::InputState, opts: &InputOptions) -> Vec2 { + let mut total = Vec2::ZERO; + for event in &input.events { + if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = event + && !modifiers.matches_any(opts.zoom_modifier) + { + total += axis_lock(egui_points(*unit, *delta, input, opts), *modifiers, opts); + } + } + if total.is_finite() { total } else { Vec2::ZERO } +} + /// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but /// gently, so zoomed-out overviews don't crawl and close-ups don't jump. pub fn notch_px(zoom: f32) -> f32 { @@ -309,6 +396,111 @@ mod tests { assert!(!m.is_active()); } + fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } + } + + /// One 60 fps frame over a tall egui scroll area with the pointer on it; `animate` false puts + /// [`unsmoothed_scroll`] in place of egui's eased delta, as the app does. Returns the scroll + /// area's offset and the frame's [`CanvasScroll::zoom`] (its state is kept with the context). + fn egui_frame(ctx: &egui::Context, frame_no: &mut u32, animate: bool, mut events: Vec) -> (f32, f32) { + *frame_no += 1; + events.insert(0, Event::PointerMoved(egui::pos2(50.0, 50.0))); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0)); + let input = egui::RawInput { events, time: Some(f64::from(*frame_no) / 60.0), screen_rect: Some(screen), ..Default::default() }; + let mut seen = (0.0, 1.0); + ctx.run_ui(input, |ui| { + let opts = ui.ctx().options(|o| o.input_options); + if !animate { + ui.ctx().input_mut(|i| i.smooth_scroll_delta = unsmoothed_scroll(i, &opts)); + } + let id = egui::Id::new("canvas scroll under test"); + let mut scroll = ui.data(|d| d.get_temp::(id)).unwrap_or_default(); + let zoom = ui.input(|i| scroll.zoom(i, &opts, animate)); + ui.data_mut(|d| d.insert_temp(id, scroll)); + let out = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 5000.0))); + seen = (out.state.offset.y, zoom); + }) + .drop_without_applying_deltas(); + seen + } + + /// egui's own scroll areas ease a wheel notch in over several frames; with animation effects + /// off the notch lands whole in its frame, and nothing more arrives afterwards. + #[test] + fn egui_scroll_areas_take_a_wheel_notch_at_once_without_animation() { + let line = InputOptions::default().line_scroll_speed; + let notch = || vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::NONE)]; + let settle = |ctx: &egui::Context, n: &mut u32, animate| (0..40).map(|_| egui_frame(ctx, n, animate, Vec::new()).0).last().unwrap(); + + let (ctx, mut n) = (egui::Context::default(), 0); + egui_frame(&ctx, &mut n, true, Vec::new()); + let first = egui_frame(&ctx, &mut n, true, notch()).0; + assert!(first > 0.0 && first < line, "egui eases the notch in: {first}"); + assert!((settle(&ctx, &mut n, true) - line).abs() < 0.5); + + let (ctx, mut n) = (egui::Context::default(), 0); + egui_frame(&ctx, &mut n, false, Vec::new()); + assert_eq!(egui_frame(&ctx, &mut n, false, notch()).0, line, "the whole notch in one frame"); + assert_eq!(settle(&ctx, &mut n, false), line, "and none of egui's leftover easing after it"); + // Shift turns the notch sideways, and Ctrl/⌘+wheel zooms rather than scrolls, as in egui. + assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::SHIFT)]).0, line); + assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::COMMAND)]).0, line); + // Touchpad deltas pass through as they are. + assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Point, Vec2::new(0.0, -3.0), Modifiers::NONE)]).0, line + 3.0); + // A junk delta scrolls nothing. Last: egui's own wheel state doesn't recover from it. + let junk = vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, f32::NEG_INFINITY), Modifiers::NONE)]; + assert_eq!(egui_frame(&ctx, &mut n, false, junk).0, line + 3.0); + } + + /// Ctrl/⌘+wheel: egui spreads a notch's zoom over several frames. With animation effects off + /// the wheel zooms in whole steps, each at once: a notch is two steps in its own frame, and + /// turns that arrive in small pieces add up to a step first rather than each zooming a little. + #[test] + fn wheel_zoom_steps_at_once_without_animation() { + let opts = InputOptions::default(); + let notch_zoom = (opts.scroll_zoom_speed * opts.line_scroll_speed).exp(); + let step = ZOOM_STEP.exp(); + let turn = |notches: f32| vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, notches), Modifiers::COMMAND)]; + let near = |a: f32, b: f32| (a - b).abs() < 1e-5; + + let (ctx, mut n) = (egui::Context::default(), 0); + egui_frame(&ctx, &mut n, true, Vec::new()); + let first = egui_frame(&ctx, &mut n, true, turn(1.0)).1; + assert!(first > 1.0 && first < notch_zoom, "egui eases the zoom in: {first} of {notch_zoom}"); + + let (ctx, mut n) = (egui::Context::default(), 0); + egui_frame(&ctx, &mut n, false, Vec::new()); + let zoom = egui_frame(&ctx, &mut n, false, turn(1.0)).1; + assert!(near(zoom, notch_zoom) && near(zoom, step * step), "a notch is two steps at once: {zoom}"); + assert_eq!(egui_frame(&ctx, &mut n, false, Vec::new()).1, 1.0, "nothing left for later frames"); + assert!(near(egui_frame(&ctx, &mut n, false, turn(-1.0)).1, 1.0 / notch_zoom)); + // Quarter notches, as a touchpad pinch or a free-spinning wheel sends them: a step with + // every second one and nothing in between, never a little with each. + let zooms: Vec = (0..4).map(|_| egui_frame(&ctx, &mut n, false, turn(0.25)).1).collect(); + assert!(zooms[0] == 1.0 && near(zooms[1], step) && zooms[2] == 1.0 && near(zooms[3], step), "{zooms:?}"); + let zooms: Vec = (0..4).map(|_| egui_frame(&ctx, &mut n, false, turn(-0.25)).1).collect(); + assert!(zooms[0] == 1.0 && near(zooms[1], 1.0 / step) && zooms[2] == 1.0 && near(zooms[3], 1.0 / step), "{zooms:?}"); + // Part of a step doesn't wait for ever: after a pause the count starts again. + assert_eq!(egui_frame(&ctx, &mut n, false, turn(0.25)).1, 1.0); + for _ in 0..60 { + assert_eq!(egui_frame(&ctx, &mut n, false, Vec::new()).1, 1.0); + } + assert_eq!(egui_frame(&ctx, &mut n, false, turn(0.25)).1, 1.0, "the quarter turn of a second ago has lapsed"); + assert!(near(egui_frame(&ctx, &mut n, false, turn(0.25)).1, step)); + // A pinch the system reports as a pinch arrives as a zoom factor of its own and follows the + // fingers; a plain notch doesn't zoom. + assert_eq!(egui_frame(&ctx, &mut n, false, vec![Event::Zoom(1.5)]).1, 1.5); + assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, 1.0), Modifiers::NONE)]).1, 1.0); + // An absurd turn is a bounded zoom, and junk never comes out as a zoom factor (the endless + // wheel delta last, as above). + let huge = egui_frame(&ctx, &mut n, false, turn(1e6)).1; + assert!(huge > notch_zoom && huge <= (MAX_ZOOM_STEPS * ZOOM_STEP).exp() * 1.001, "{huge}"); + for junk in [Event::Zoom(f32::NAN), Event::Zoom(0.0), wheel(MouseWheelUnit::Page, Vec2::new(0.0, f32::INFINITY), Modifiers::COMMAND)] { + assert_eq!(egui_frame(&ctx, &mut n, false, vec![junk]).1, 1.0); + } + } + #[test] fn hostile_numbers_are_ignored() { let mut m = Momentum::default(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 27c18e399..bc23ff56b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -128,6 +128,72 @@ impl From for u16 { } } +/// The Animation effects setting (File › Options › Accessibility, `ui.animations`): on, off, or +/// `System`, which follows the system's own setting as the host reports it +/// ([`Services::animations_enabled`](crate::Services::animations_enabled): Windows' Animation +/// effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's +/// `prefers-reduced-motion`). +/// Off: wheel notches scroll and zoom at once ([`crate::scroll`]) and nothing fades or glides +/// ([`apply_animations`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(from = "String", into = "String")] +pub enum Animations { + #[default] + System, + On, + Off, +} + +impl Animations { + /// In menu order. + pub const ALL: [Animations; 3] = [Animations::System, Animations::On, Animations::Off]; + + /// The value used in `ui.json` and by the `ui.animations` command. + pub fn code(self) -> &'static str { + match self { + Animations::System => "system", + Animations::On => "on", + Animations::Off => "off", + } + } + + pub fn from_code(code: &str) -> Option { + Animations::ALL.into_iter().find(|a| a.code().eq_ignore_ascii_case(code.trim())) + } + + /// The English label (translated with `tl!` where shown). + pub fn label(self) -> &'static str { + match self { + Animations::System => "Use system setting", + Animations::On => "On", + Animations::Off => "Off", + } + } + + /// Whether animation effects are on, given what the system reports (`None`: it doesn't say). + /// `System` falls back to on when the system reports nothing. + pub fn enabled(self, system: Option) -> bool { + match self { + Animations::System => system.unwrap_or(true), + Animations::On => true, + Animations::Off => false, + } + } +} + +/// An unknown saved value reads as the default rather than discarding the whole `ui.json`. +impl From for Animations { + fn from(s: String) -> Self { + Animations::from_code(&s).unwrap_or_default() + } +} + +impl From for String { + fn from(a: Animations) -> Self { + a.code().to_string() + } +} + /// Every colour the UI uses; widgets never hard-code colours. #[derive(Clone, Copy, Debug)] pub struct Tokens { @@ -512,6 +578,17 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) { }); } +/// Turn egui's own animations on or off: hover and press fades, menus, tooltips and windows fading +/// in, sections sliding open, and the glide when a scroll area brings the caret or a list item +/// into view. Off, each of them happens at once. +pub fn apply_animations(ctx: &egui::Context, on: bool) { + let defaults = egui::Style::default(); + ctx.all_styles_mut(|s| { + s.animation_time = if on { defaults.animation_time } else { 0.0 }; + s.scroll_animation = if on { defaults.scroll_animation } else { egui::style::ScrollAnimation::none() }; + }); +} + /// egui's visuals for one of our palettes. fn visuals(t: &Tokens) -> Visuals { let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ac028bb3b..4df392c8c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -685,7 +685,8 @@ fn list_rows<'a>( /// How a drawn button looks this frame. Hover fades in over egui's animation time and the /// pressed state blends in while the pointer is down, as on egui's own buttons. Framed buttons /// take egui's widget visuals (fill, stroke, text colour, expansion) so they match `ui.button`; -/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`]. +/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`]. With +/// animation effects off (egui's animation time is zero) both states show at once. pub struct DrawnState { pub fill: Color32, pub stroke: Stroke, @@ -696,7 +697,7 @@ pub struct DrawnState { pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState { let pressed = resp.is_pointer_button_down_on(); let h = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() || pressed); - let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, 0.06); + let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, ui.style().animation_time.min(0.06)); if framed { let w = &ui.visuals().widgets; let mix = |a: Color32, b: Color32, c: Color32| a.lerp_to_gamma(b, h).lerp_to_gamma(c, p); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d366e4203..bd46c534f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/gaps.md b/docs/gaps.md index 2048e4d33..9042318ad 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting landed, following the system's setting on Windows, macOS, Linux/BSD and the web; previously trivial: Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -56,7 +56,10 @@ The same gaps grouped the way the parity documents are, for agents working in on re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, and Draw Table and Eraser (#303) landed on 2026-10-10. - **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column drag on the - ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78). + ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78); File › + Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD + desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real + GNOME or KDE session yet. - **File-format gaps:** #1, #2, #5, #9, #14, #19, #23, #26; embedded fonts; glossary/building blocks. - **Hardware gaps:** #6, #12, #28; scanner/Continuity Camera insert; KDE soft text (#140). - **Localization gaps:** #8, #11, #16, #17; native-speaker review of every catalog (human). @@ -66,6 +69,8 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | +| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet | | 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | | 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts | | 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72bf0d8ca..7cf41790b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every measurement follows the Options unit, #755; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting; previously trivial: every measurement follows the Options unit, #755; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -32,6 +32,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — | +| Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | | File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); not yet tried with real screen readers | full | 50% | 5–8 | @@ -41,6 +42,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart | +| 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents | | 2026-10-11 | trivial | Built-in file browser when the system file dialog can't be shown on Linux/BSD; `ui.fileBrowser` (#576) | | 2026-10-11 | trivial | Every measurement now follows File › Options › Advanced › Measurement units: the Line Numbers "From text" distance and the Hyphenation zone were still fixed in inches (#755) | | 2026-10-11 | trivial | Track Changes Options › Show revisions in balloons works: tracked deletions in the body leave the text for "Deleted: …" balloons in the markup area, joined to where they were; inline again where no balloons show (Comments pane, Read Mode, side by side, Web Layout), and always inline in headers, footers, notes and text boxes |