From c01dc04b5f1ef787ad06ec12cc8910c6224fef88 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 16:36:09 +0100 Subject: [PATCH] Revert "Merge PR #4573: Interface: Animation Effects follows the system setting, or is on or off" This reverts commit acd7e072525d11076e0ffbbd7d1b4fc0717d885d, reversing changes made to bc0354db5d339a1abaab85bef51f9a842ccec96a. --- Cargo.lock | 1 - apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 6 - apps/photocraft/Cargo.toml | 15 +- apps/photocraft/src/animations.rs | 281 ---------------- apps/photocraft/src/animations/freedesktop.rs | 157 --------- apps/photocraft/src/appearance.rs | 1 - apps/photocraft/src/main.rs | 1 - apps/photocraft/src/services.rs | 1 - crates/engine/src/prefs.rs | 23 +- crates/engine/src/prefs/tests.rs | 40 --- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/i18n/cs.tsv | 3 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 3 +- crates/ui-egui/src/i18n/es.tsv | 3 +- crates/ui-egui/src/i18n/fr.tsv | 3 +- crates/ui-egui/src/i18n/hu.tsv | 3 +- crates/ui-egui/src/i18n/id.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 3 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/ru.tsv | 3 +- crates/ui-egui/src/i18n/sr.tsv | 3 +- crates/ui-egui/src/i18n/tr.tsv | 3 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/input_tests.rs | 101 ------ crates/ui-egui/src/lib.rs | 13 - crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 144 ++------ crates/ui-egui/src/wheel_nav.rs | 316 +----------------- scorecard/ui.toml | 2 +- 38 files changed, 65 insertions(+), 1117 deletions(-) delete mode 100644 apps/photocraft/src/animations.rs delete mode 100644 apps/photocraft/src/animations/freedesktop.rs diff --git a/Cargo.lock b/Cargo.lock index 7c90509d00..4dad6f9fbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4780,7 +4780,6 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "ureq", - "windows", "winit", "winresource", "winsafe", diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 44e6990755..279d13b453 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ 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", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator", "MediaQueryList"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 89ca7334e0..20e64fa046 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -447,12 +447,6 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), - // Animation Effects › Use System Setting: the browser passes on the system's setting. One - // live query, so `matches` follows a change. - system_animations: Some({ - 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())) - }), ..Default::default() } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 1427efdff2..d8e7568300 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -54,14 +54,13 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # 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" -# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs), find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), play the -# system alert sound for Beep When Done (services.rs, NSBeep) and read Reduce Motion -# (animations.rs: NSWorkspace, NSAccessibility). Already in Cargo.lock (winit, -# photocraft-tablet); pinned exactly to the same versions. +# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the +# system alert sound for Beep When Done (services.rs, NSBeep). Already in +# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow", "NSWorkspace"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } @@ -97,10 +96,6 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] } # Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal. xcap = "=0.9.8" winsafe = { version = "=0.0.29", default-features = false, features = ["user"] } -# The system's Animation effects setting (animations.rs): `UISettings.AnimationsEnabled`, a safe -# WinRT call (winsafe's `SystemParametersInfo` is unsafe). Already in Cargo.lock (wgpu, xcap, -# accesskit); pinned exactly to the same version. -windows = { version = "=0.62.2", default-features = false, features = ["std", "UI_ViewManagement"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events. [target.'cfg(target_os = "macos")'.dev-dependencies] diff --git a/apps/photocraft/src/animations.rs b/apps/photocraft/src/animations.rs deleted file mode 100644 index 635b626e42..0000000000 --- a/apps/photocraft/src/animations.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! The operating system's animation setting, for Preferences › Interface › Animation Effects › -//! Use System Setting (`Services::system_animations`). `None` means the platform gave no answer; -//! the app then keeps animations on. -//! -//! | Platform | Read from | -//! |---|---| -//! | Windows | Settings › Accessibility › Visual effects › Animation effects (`UISettings.AnimationsEnabled`) | -//! | macOS | System Settings › Accessibility › Display › Reduce motion | -//! | Linux | The settings portal, followed as it changes; it also works inside a Flatpak | -//! | Linux without the portal, the BSDs | The desktop's own files and `gsettings` (`freedesktop.rs`), read again when the window gets the focus back | -//! -//! No `unsafe`: every OS call goes through a safe binding. - -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -mod freedesktop; - -pub use platform::service; - -/// Windows and macOS answer at once, so the setting is asked for again on frames that run anyway -/// (coming back from the system's settings is one), at most once a second. -#[cfg(any(target_os = "windows", target_os = "macos"))] -fn polled(read: fn() -> Option) -> photocraft_ui_egui::SystemAnimationsFn { - const INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); - let last = std::cell::Cell::new(None::<(std::time::Instant, Option)>); - Box::new(move |_| { - if let Some((at, animations)) = last.get() - && at.elapsed() < INTERVAL - { - return animations; - } - let animations = read(); - last.set(Some((std::time::Instant::now(), animations))); - animations - }) -} - -#[cfg(target_os = "windows")] -mod platform { - /// Settings › Accessibility › Visual effects › Animation effects. - pub fn service() -> Option { - Some(super::polled(read)) - } - - /// A safe WinRT call; it reports what Windows applies to this session, so a Remote Desktop - /// connection with animations off counts too. - pub(super) fn read() -> Option { - windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok() - } -} - -#[cfg(target_os = "macos")] -mod platform { - /// System Settings › Accessibility › Display › Reduce motion. - pub fn service() -> Option { - Some(super::polled(read)) - } - - /// Animations are on unless Reduce motion is. - fn read() -> Option { - Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()) - } -} - -/// Linux and the BSDs. Reading the setting can mean waiting for the session bus or running -/// `gsettings`, so a worker thread does it and the UI reads its latest answer. -#[cfg(all(unix, not(target_os = "macos")))] -mod platform { - use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, mpsc}; - - use super::freedesktop; - - /// Starts the worker; the returned reader gives its latest answer and asks it to read again - /// when the window gets the focus back. - pub fn service() -> Option { - let latest = Arc::new(Mutex::new(None)); - let wake: Arc> = Arc::new(OnceLock::new()); - // One pending request is enough: focus changes while the worker is busy, or while it - // follows the portal and takes none, add nothing. - let (refresh, refreshes) = mpsc::sync_channel::<()>(1); - let publisher = Publisher { latest: Arc::clone(&latest), wake: Arc::clone(&wake) }; - std::thread::Builder::new().name("animations-setting".into()).spawn(move || worker(&publisher, &refreshes)).ok()?; - let last_focus = std::cell::Cell::new(true); - Some(Box::new(move |ctx| { - let _ = wake.set(ctx.clone()); - let focused = ctx.input(|i| i.focused); - let was_focused = last_focus.replace(focused); - // Back from the desktop's settings, perhaps: read again. - if focused && !was_focused { - let _ = refresh.try_send(()); - } - *lock(&latest) - })) - } - - /// Follows the portal when it has an answer; otherwise reads the desktop's own setting now - /// and on every refresh request. - fn worker(publisher: &Publisher, refreshes: &mpsc::Receiver<()>) { - #[cfg(target_os = "linux")] - if portal::follow(publisher) { - return; - } - publisher.publish(desktop::read()); - for () in refreshes { - publisher.publish(desktop::read()); - } - } - - /// The worker's side of the latest answer. A change wakes the UI. - struct Publisher { - latest: Arc>>, - wake: Arc>, - } - - impl Publisher { - /// Stores the worker's answer, and wakes the UI when it changed. - fn publish(&self, animations: Option) { - let previous = std::mem::replace(&mut *lock(&self.latest), animations); - if previous != animations - && let Some(ctx) = self.wake.get() - { - ctx.request_repaint(); - } - } - } - - /// The latest answer; a panic elsewhere while holding it doesn't lose it. - fn lock(latest: &Mutex>) -> MutexGuard<'_, Option> { - latest.lock().unwrap_or_else(PoisonError::into_inner) - } - - #[cfg(target_os = "linux")] - mod portal { - use zbus::zvariant::{OwnedValue, Value}; - - use super::freedesktop; - - /// Reads a setting's value as animations on or off. - type Parse = fn(&str) -> Option; - - /// The settings the portal may answer for, most general first: the cross-desktop one, - /// then GNOME's and KDE's own, which older portals pass on. - const SETTINGS: [(&str, &str, Parse); 3] = [ - ("org.freedesktop.appearance", "reduced-motion", freedesktop::from_reduced_motion), - ("org.gnome.desktop.interface", "enable-animations", freedesktop::from_enabled), - ("org.kde.kdeglobals.KDE", "AnimationDurationFactor", freedesktop::from_duration_factor), - ]; - - /// Publishes the portal's answer, then every change to it. `false` when the portal has - /// none: no session bus, no portal, or one that knows none of the settings. - pub fn follow(publisher: &super::Publisher) -> bool { - let Some(proxy) = crate::appearance::portal_proxy() else { return false }; - // Subscribe before the first read, so a change during start-up isn't lost. - let signals = proxy.receive_signal("SettingChanged").ok(); - let Some(first) = read(&proxy) else { return false }; - publisher.publish(Some(first)); - for message in signals.into_iter().flatten() { - let Ok((namespace, key, _)) = message.body().deserialize::<(String, String, OwnedValue)>() else { - continue; - }; - // Read them all again: which setting answers first may have changed too. - if SETTINGS.iter().any(|(ns, k, _)| *ns == namespace && *k == key) { - publisher.publish(read(&proxy)); - } - } - true - } - - /// The answer of the first setting in [`SETTINGS`] the portal knows. - fn read(proxy: &zbus::blocking::Proxy<'_>) -> Option { - SETTINGS.iter().find_map(|(namespace, key, parse)| { - let value: OwnedValue = proxy.call("Read", &(*namespace, *key)).ok()?; - parse(&as_text(value)?) - }) - } - - /// A setting's value as the text a settings file would hold. - fn as_text(value: OwnedValue) -> Option { - let mut value: Value<'_> = value.into(); - // The Settings.Read result is a variant; some portals nest another variant inside it. - for _ in 0..4 { - match value { - Value::Value(inner) => value = *inner, - Value::Bool(on) => return Some(on.to_string()), - Value::U32(n) => return Some(n.to_string()), - Value::I32(n) => return Some(n.to_string()), - Value::F64(n) => return Some(n.to_string()), - Value::Str(text) => return Some(text.to_string()), - _ => return None, - } - } - None - } - - #[cfg(test)] - mod tests { - use super::*; - - /// `value` as the portal hands it over. - fn owned(value: Value<'_>) -> OwnedValue { - OwnedValue::try_from(value).unwrap() - } - - /// Each value type a portal sends, nested or not, reads as the text a settings file - /// would hold; other types aren't settings. - #[test] - fn portal_values_read_as_text() { - let nested = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))); - assert_eq!(as_text(owned(nested)).as_deref(), Some("1")); - assert_eq!(as_text(owned(Value::from(false))).as_deref(), Some("false")); - assert_eq!(as_text(owned(Value::F64(0.5))).as_deref(), Some("0.5")); - assert_eq!(as_text(owned(Value::from("0"))).as_deref(), Some("0")); - assert_eq!(as_text(owned(Value::from(vec![1u32, 2]))), None); - assert_eq!(SETTINGS[0].1, "reduced-motion", "the cross-desktop setting is asked first"); - } - } - } - - /// The desktop's own setting, without the portal. - mod desktop { - use std::path::PathBuf; - - use super::freedesktop; - - /// A settings file is a few kilobytes; a larger one isn't read. - const MAX_CONFIG_BYTES: u64 = 1 << 20; - - /// The setting of the desktop this session runs (`XDG_CURRENT_DESKTOP`). - pub fn read() -> Option { - let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); - freedesktop::without_portal(&desktop, &config_file, &gsettings) - } - - /// `$XDG_CONFIG_HOME`, or `~/.config` when it isn't set. - fn config_home() -> Option { - let var = |key: &str| std::env::var_os(key).filter(|value| !value.is_empty()).map(PathBuf::from); - var("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| Some(var("HOME")?.join(".config"))) - } - - /// The text of `name` in the user's config directory, if it is there and not too large. - fn config_file(name: &str) -> Option { - use std::io::Read as _; - let mut text = String::new(); - std::fs::File::open(config_home()?.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; - Some(text) - } - - /// `gsettings get`, by absolute path (`/usr/local` and `/usr/pkg` are where the BSDs - /// install it). - fn gsettings(schema: &str, key: &str) -> Option { - let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings", "/usr/pkg/bin/gsettings"] - .into_iter() - .find(|path| std::path::Path::new(path).is_file())?; - let output = std::process::Command::new(exe).args(["get", schema, key]).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() - } - } -} - -#[cfg(not(any(unix, target_os = "windows")))] -mod platform { - /// No reader here: animations stay on. - pub fn service() -> Option { - None - } -} - -#[cfg(all(test, target_os = "windows"))] -mod tests { - /// The real setting of the machine the tests run on: whatever it is, reading it doesn't - /// panic and the service passes it on (`--nocapture` shows it). - #[test] - fn windows_reports_its_animation_setting() { - let animations = super::platform::read(); - println!("UISettings.AnimationsEnabled: {animations:?}"); - let service = super::platform::service().expect("service"); - assert_eq!(service(&egui::Context::default()), animations); - } -} diff --git a/apps/photocraft/src/animations/freedesktop.rs b/apps/photocraft/src/animations/freedesktop.rs deleted file mode 100644 index f11a85e527..0000000000 --- a/apps/photocraft/src/animations/freedesktop.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Where Linux and BSD desktops keep their animation setting, and how to read its value. -//! -//! Plain functions over text, so the tests run on every platform. Each one answers whether -//! animations are on, or `None` when the value doesn't parse (the next source is asked then). - -/// GSettings' and the portal's `true`/`false`, or GTK's `1`/`0`. -pub(super) fn from_enabled(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "true" | "1" => Some(true), - "false" | "0" => Some(false), - _ => None, - } -} - -/// KDE's animation speed factor: 0 turns animations off. -pub(super) fn from_duration_factor(value: &str) -> Option { - value.trim().parse::().ok().filter(|factor| factor.is_finite()).map(|factor| factor > 0.0) -} - -/// The settings portal's `reduced-motion`: 1 asks for less motion; 0 and any value added later -/// mean no preference. -#[cfg(any(test, target_os = "linux"))] -pub(super) fn from_reduced_motion(value: &str) -> Option { - value.trim().parse::().ok().map(|code| code != 1) -} - -/// The desktop's setting when there is no settings portal to ask. `desktop` is -/// `XDG_CURRENT_DESKTOP`, `config_file` reads a file in the user's config directory and -/// `gsettings` runs `gsettings get `. -pub(super) fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { - let running = |name: &str| desktop.split(':').any(|part| part.eq_ignore_ascii_case(name)); - let in_file = |file: &str, section: &str, key: &str, parse: fn(&str) -> Option| { - let text = config_file(file)?; - parse(ini_value(&text, section, key)?) - }; - - // KDE Plasma keeps it in its own file. A kdeglobals left over from another desktop doesn't count. - if running("KDE") - && let Some(animations) = in_file("kdeglobals", "KDE", "AnimationDurationFactor", from_duration_factor) - { - return Some(animations); - } - // GNOME and the desktops built on it, or MATE's copy of the same key. - let schema = if running("MATE") { "org.mate.interface" } else { "org.gnome.desktop.interface" }; - let gnome = gsettings(schema, "enable-animations").and_then(|out| from_enabled(&out)); - if gnome == Some(false) { - return gnome; - } - // `true` may only be the schema's default on a desktop that doesn't use it, so GTK's own - // file comes first: it is what a bare window manager's user writes, and what KDE keeps in step. - let gtk = ["gtk-4.0/settings.ini", "gtk-3.0/settings.ini"].iter().find_map(|file| in_file(file, "Settings", "gtk-enable-animations", from_enabled)); - gtk.or(gnome) -} - -/// The last value of `key` in `[section]` of an INI-style file (KDE's config, GTK's settings). -fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { - let mut in_section = false; - let mut found = None; - for line in text.lines().map(str::trim) { - if let Some(name) = line.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) { - in_section = name == section; - } else if in_section && let Some((name, value)) = line.split_once('=') { - // KDE marks an entry the user may not change as `Key[$i]=value`. - let name = name.split_once('[').map_or(name, |(name, _)| name); - if name.trim() == key { - found = Some(value.trim()); - } - } - } - found -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Each reader turns its own kind of value into on or off, and anything else into `None`. - #[test] - fn each_kind_of_value_reads_as_on_or_off() { - for (value, animations) in - [("true\n", Some(true)), ("false\n", Some(false)), ("1", Some(true)), ("0", Some(false)), ("FALSE", Some(false)), ("", None), ("maybe", None)] - { - assert_eq!(from_enabled(value), animations, "{value:?}"); - } - for (value, animations) in [ - ("0", Some(false)), - ("0.0", Some(false)), - ("0.5", Some(true)), - ("1", Some(true)), - ("-1", Some(false)), - ("", None), - ("fast", None), - ("NaN", None), - ("inf", None), - ] { - assert_eq!(from_duration_factor(value), animations, "{value:?}"); - } - for (value, animations) in [("0", Some(true)), ("1", Some(false)), ("2", Some(true)), ("true", None), ("-1", None), ("", None)] { - assert_eq!(from_reduced_motion(value), animations, "{value:?}"); - } - } - - /// A key is read from its own section only, the last entry wins, and junk reads as nothing. - #[test] - fn ini_values_come_from_their_own_section() { - let kde = "[General]\nAnimationDurationFactor=3\n\n[KDE]\nSingleClick=false\n AnimationDurationFactor = 0 \n[KDE][Sub]\nAnimationDurationFactor=7\n"; - assert_eq!(ini_value(kde, "KDE", "AnimationDurationFactor"), Some("0")); - assert_eq!(ini_value(kde, "KDE", "Missing"), None); - assert_eq!(ini_value("[KDE]\nAnimationDurationFactor[$i]=0.5\n", "KDE", "AnimationDurationFactor"), Some("0.5"), "an immutable entry"); - let gtk = "[Settings]\ngtk-enable-animations=1\ngtk-enable-animations=0\n"; - assert_eq!(ini_value(gtk, "Settings", "gtk-enable-animations"), Some("0"), "the last one wins"); - // Hostile text: no section, no `=`, stray brackets, non-ASCII. - for junk in ["", "AnimationDurationFactor=0", "[KDE", "[KDE]\n=\n[]\n]]][[[\n", "[KDE]\nклавиша=значение\n\u{0}\u{feff}"] { - assert_eq!(ini_value(junk, "KDE", "AnimationDurationFactor"), None, "{junk:?}"); - } - } - - /// Without the portal: KDE's file only on KDE, GNOME's or MATE's key, then GTK's file. - #[test] - fn without_the_portal_each_desktop_is_read_where_it_keeps_the_setting() { - let files = |kdeglobals: Option<&'static str>, gtk3: Option<&'static str>| { - move |file: &str| match file { - "kdeglobals" => kdeglobals.map(String::from), - "gtk-3.0/settings.ini" => gtk3.map(String::from), - _ => None, - } - }; - let no_gsettings = |_: &str, _: &str| -> Option { None }; - let gnome = |on: &'static str| { - move |schema: &str, key: &str| (schema == "org.gnome.desktop.interface" && key == "enable-animations").then(|| format!("{on}\n")) - }; - let kde_off = Some("[KDE]\nAnimationDurationFactor=0\n"); - let gtk_off = Some("[Settings]\ngtk-enable-animations=false\n"); - - // KDE Plasma: its own file, only while Plasma is the desktop. - assert_eq!(without_portal("KDE", &files(kde_off, None), &no_gsettings), Some(false)); - assert_eq!(without_portal("KDE", &files(Some("[KDE]\nAnimationDurationFactor=0.5\n"), gtk_off), &no_gsettings), Some(true)); - assert_eq!(without_portal("ubuntu:GNOME", &files(kde_off, None), &gnome("true")), Some(true), "a leftover kdeglobals is not GNOME's setting"); - // Plasma without the entry (animations at their default speed) falls through to GTK's file. - assert_eq!(without_portal("KDE", &files(Some("[KDE]\nSingleClick=false\n"), gtk_off), &no_gsettings), Some(false)); - // GNOME and the desktops that share its schema. - assert_eq!(without_portal("GNOME", &files(None, None), &gnome("false")), Some(false)); - assert_eq!(without_portal("GNOME", &files(None, None), &gnome("true")), Some(true)); - // MATE keeps the same key in its own schema. - let mate = |schema: &str, _: &str| (schema == "org.mate.interface").then(|| "false\n".to_string()); - assert_eq!(without_portal("MATE", &files(None, None), &mate), Some(false)); - assert_eq!(without_portal("GNOME", &files(None, None), &mate), None); - // A bare window manager: GTK's settings file, which also outranks a schema default. - assert_eq!(without_portal("sway", &files(None, gtk_off), &no_gsettings), Some(false)); - assert_eq!(without_portal("", &files(None, gtk_off), &gnome("true")), Some(false)); - assert_eq!(without_portal("", &files(None, Some("[Settings]\ngtk-enable-animations=1\n")), &no_gsettings), Some(true)); - // Nothing to read, or nothing readable: unknown, never a panic. - assert_eq!(without_portal("", &files(None, None), &no_gsettings), None); - let unreadable = files(Some("\u{0}[[[=]]]"), Some("=\n[Settings]\ngtk-enable-animations=perhaps")); - assert_eq!(without_portal("KDE:\u{0}::", &unreadable, &|_, _| Some("\u{fffd}".into())), None); - } -} diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index b5cfdedef4..aaae41a1f2 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -5,7 +5,6 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; #[cfg(target_os = "linux")] const PORTAL_KEY: &str = "color-scheme"; -/// The settings portal, also asked for the animation setting (`animations.rs`). #[cfg(target_os = "linux")] pub(crate) fn portal_proxy() -> Option> { let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index f1086b6a59..858efedb92 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -29,7 +29,6 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -mod animations; mod app_dirs; mod app_icon; mod appearance; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 69442735a5..2966eee8ed 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -691,7 +691,6 @@ pub fn native(automation: Option) -> photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), system_theme: crate::appearance::service(), - system_animations: crate::animations::service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8f49a3f903..1525741a5e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -83,12 +83,6 @@ choice!( LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto ); choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small); -choice!( - /// Interface animations: `system` follows the operating system's animation setting (Windows - /// "Animation effects", macOS "Reduce motion", the desktop's setting on Linux and the BSDs, - /// `prefers-reduced-motion` in a browser) and keeps them on where it cannot be read. - AnimationEffects { System = "system", On = "on", Off = "off" } default System -); choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); @@ -292,9 +286,9 @@ pub struct Interface { /// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281). pub clip_thumbnails_to_layer_bounds: bool, pub show_tooltips: bool, - /// Menus, popups, dialogs and collapsing sections fade and slide, and scrolling glides to - /// its new position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). - pub animation_effects: AnimationEffects, + /// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new + /// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). + pub enable_animations: bool, /// Move tool drags show only the layer's outline and an arrow, leaving its pixels in place /// until release. Off (the default), the pixels follow the pointer live inside the outline. pub show_bounding_box_when_dragging_layer: bool, @@ -334,7 +328,7 @@ impl Default for Interface { show_menu_colors: true, clip_thumbnails_to_layer_bounds: false, show_tooltips: true, - animation_effects: AnimationEffects::System, + enable_animations: true, show_bounding_box_when_dragging_layer: false, system_title_bar: false, show_file_name_in_title: true, @@ -1202,7 +1196,6 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "interface.canvasBorder" => CanvasBorder::NAMES, "interface.uiScale" => UiScale::NAMES, "interface.uiFontSize" => UiFontSize::NAMES, - "interface.animationEffects" => AnimationEffects::NAMES, "historyLog.destination" => LogDestination::NAMES, "historyLog.detail" => LogDetail::NAMES, "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES, @@ -1932,14 +1925,6 @@ impl Session { // A partially migrated file may already contain a newer theme selection (#5139). interface.entry(slot).or_insert_with(|| json!(theme.name())); } - // Animation Effects was the Enable Animations checkbox (on by default) before it could - // follow the system: a saved "off" stays off. - if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) - && !interface.contains_key("animationEffects") - && interface.get("enableAnimations").and_then(Value::as_bool) == Some(false) - { - interface.insert("animationEffects".into(), json!(AnimationEffects::Off.name())); - } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); let mut invalid = Vec::new(); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 8b0515905c..6f17a21970 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -100,46 +100,6 @@ fn appearance_defaults_and_legacy_theme_migrate() { } } -/// Interface › Animation Effects: the system's setting by default, validated like any choice, and -/// the Enable Animations checkbox it replaced still means what it was saved as. -#[test] -fn animation_effects_default_to_the_system_and_keep_a_saved_off() { - assert_eq!(Preferences::default().interface.animation_effects, AnimationEffects::System); - assert_eq!(choices("interface.animationEffects"), Some(AnimationEffects::NAMES)); - assert_eq!(AnimationEffects::NAMES, ["system", "on", "off"]); - let mut s = Session::new(); - for (value, effects) in [("off", AnimationEffects::Off), ("on", AnimationEffects::On), ("system", AnimationEffects::System)] { - s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": value})).unwrap(); - assert_eq!(s.prefs().interface.animation_effects, effects); - assert_eq!(s.execute("prefs.get", json!({"path": "interface.animationEffects"})).unwrap(), json!(value)); - let mut restarted = Session::new(); - restarted.load_prefs_json(&s.prefs_to_json()).unwrap(); - assert_eq!(restarted.prefs().interface.animation_effects, effects, "{value} survives a restart"); - } - // A value that is no choice is an error and changes nothing. - s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": "off"})).unwrap(); - for bad in [json!("sometimes"), json!("Off"), json!(""), json!(true), json!(0), json!(null), json!(["off"]), json!({})] { - assert!(s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": bad})).is_err(), "{bad}"); - assert_eq!(s.prefs().interface.animation_effects, AnimationEffects::Off); - } - s.execute("prefs.reset", json!({"path": "interface.animationEffects"})).unwrap(); - assert_eq!(s.prefs().interface.animation_effects, AnimationEffects::System); - // The checkbox was on by default: a saved "off" stays off, "on" now follows the system, and - // a choice made since wins over the old key left in the file. - for (saved, effects) in [ - (json!({"enableAnimations": false}), AnimationEffects::Off), - (json!({"enableAnimations": true}), AnimationEffects::System), - (json!({"enableAnimations": false, "animationEffects": "on"}), AnimationEffects::On), - (json!({"enableAnimations": "nope"}), AnimationEffects::System), - (json!({}), AnimationEffects::System), - ] { - let mut old = Session::new(); - old.load_prefs_json(&json!({"interface": saved}).to_string()).unwrap(); - assert_eq!(old.prefs().interface.animation_effects, effects, "{saved}"); - } - assert!(s.execute("prefs.set", json!({"path": "interface.enableAnimations", "value": false})).is_err(), "the old path is gone"); -} - #[test] fn legacy_theme_migration_preserves_saved_theme_slots() { for (legacy, slot, saved) in [("proMedium", "darkTheme", "solarizedDark"), ("studioLight", "lightTheme", "adwaita")] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c2ee262b1c..a3b19dc3ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3625,7 +3625,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.session.prefs().general.animated_zoom, wheel_owner, hover_here, - !app.prefs_rt.animations_off, + app.session.prefs().interface.enable_animations, app.session.prefs().enhanced_controls.touch_gestures, ) .is_some()); @@ -3990,8 +3990,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; - // Interface › Animation Effects off: the wheel moves the canvas without a glide, in every - // window (#5721). + // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). // General › Animated Zoom: the wheel zoom eases over the frames of a notch, or is one instant step. // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor // turn the view (#204); the mouse and trackpad are unaffected. @@ -4003,7 +4002,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.session.prefs().general.animated_zoom, wheel_owner, nav_hovered, - !app.prefs_rt.animations_off, + app.session.prefs().interface.enable_animations, touch_gestures, ); if nav_hovered { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a7938f317e..e95c2f86dd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2046,8 +2046,7 @@ Show only crosshair while painting Při malování zobrazit jen nitkový kříž Show slice numbers Zobrazit čísla řezů Show tooltips Zobrazit popisky nástrojů - Animation effects Efekty animace - Use system setting Použít nastavení systému + Enable animations Povolit animace Show highlight on rollover Zobrazit zvýraznění při přejetí Show bounding box when dragging layer Při přetahování vrstvy zobrazit ohraničovací rámeček System title bar Systémové záhlaví okna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 07707b8512..78d4e785cb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2021,9 +2021,7 @@ Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen Show slice numbers Slice-Nummern anzeigen Show tooltips Tooltips anzeigen - Animation effects Animationseffekte - Use system setting Systemeinstellung verwenden - On Ein + Enable animations Animationen aktivieren Show highlight on rollover Hervorhebung bei Rollover anzeigen Show bounding box when dragging layer Begrenzungsrahmen beim Ziehen einer Ebene anzeigen System title bar System-Titelleiste diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e429672300..d773f0b015 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1520,8 +1520,7 @@ Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική Show slice numbers Εμφάνιση αριθμών τομών Show tooltips Εμφάνιση επεξηγήσεων - Animation effects Εφέ κίνησης - Use system setting Χρήση ρύθμισης συστήματος + Enable animations Ενεργοποίηση κινούμενων εφέ Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου Show transformation values Εμφάνιση τιμών μετασχηματισμού diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 74885088c6..879572c9f4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2034,8 +2034,7 @@ Show only crosshair while painting Mostrar solo la retícula al pintar Show slice numbers Mostrar los números de sector Show tooltips Mostrar las descripciones emergentes - Animation effects Efectos de animación - Use system setting Usar la configuración del sistema + Enable animations Activar las animaciones Show highlight on rollover Mostrar resaltado al pasar el cursor Show bounding box when dragging layer Mostrar el cuadro delimitador al arrastrar la capa System title bar Barra de título del sistema diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b457b1f875..9692437211 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2002,8 +2002,7 @@ Show only crosshair while painting N'afficher que le réticule pendant la peinture Show slice numbers Afficher les numéros de tranche Show tooltips Afficher les info-bulles - Animation effects Effets d'animation - Use system setting Utiliser le réglage du système + Enable animations Activer les animations Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c89fdfa596..1931aaa2b0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2993,8 +2993,6 @@ cameraRaw Yellow Sárga Show rulers Vonalzók megjelenítése Show Rulers Vonalzók megjelenítése Show tooltips Eszköztippek megjelenítése - Animation effects Animációs effektusok - Use system setting Rendszerbeállítás használata Software Szoftver Solarized dark Solarized sötét Sort Rendezés @@ -3238,6 +3236,7 @@ cameraRaw Yellow Sárga Ellipse Tool Ellipszis Elliptical Marquee Tool Ellipszis-kijelölés Enable Bekapcsolás + Enable animations Animációk engedélyezése Enable flick panning Lendítéses pásztázás engedélyezése Enable floating document window docking Lebegő dokumentumablakok dokkolásának engedélyezése Enable narrow options bar Keskeny beállítósáv engedélyezése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8f41815088..0fd52768ee 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2012,9 +2012,7 @@ Show only crosshair while painting Tampilkan crosshair saja saat melukis Show slice numbers Tampilkan nomor slice Show tooltips Tampilkan tooltip - Animation effects Efek animasi - Use system setting Gunakan pengaturan sistem - On Hidup + Enable animations Aktifkan animasi Show highlight on rollover Tampilkan sorotan saat kursor melintas Show transformation values Tampilkan nilai transformasi Slice color Warna slice diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe89d44a33..661192d7b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2028,8 +2028,7 @@ Show only crosshair while painting Mostra solo mirino durante pittura Show slice numbers Mostra numeri sezioni Show tooltips Mostra tooltip - Animation effects Effetti di animazione - Use system setting Usa impostazione di sistema + Enable animations Attiva animazioni Show highlight on rollover Mostra evidenziazione al passaggio del mouse Show bounding box when dragging layer Mostra riquadro selezione al trascinamento livello System title bar Barra del titolo di sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 52265feae2..c5c0b02fc0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2030,9 +2030,7 @@ Show only crosshair while painting 描画中は十字線のみ表示 Show slice numbers スライス番号を表示 Show tooltips ツールチップを表示 - Animation effects アニメーション効果 - Use system setting システム設定を使用 - On オン + Enable animations アニメーションを有効にする Show highlight on rollover ロールオーバー時にハイライトを表示 Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示 System title bar システムのタイトルバー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index adf5efb7b3..344722e578 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2018,9 +2018,7 @@ Show only crosshair while painting 칠하는 동안 십자선만 표시 Show slice numbers 분할 영역 번호 표시 Show tooltips 도구 설명 표시 - Animation effects 애니메이션 효과 - Use system setting 시스템 설정 사용 - On 켬 + Enable animations 애니메이션 사용 Show highlight on rollover 롤오버 시 강조 표시 Show transformation values 변형 값 표시 Slice color 분할 영역 색상 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 67209f2f2b..a8539cd159 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2002,8 +2002,7 @@ Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen Show slice numbers Segmentnummers tonen Show tooltips Knopinfo tonen - Animation effects Animatie-effecten - Use system setting Systeeminstelling gebruiken + Enable animations Animaties inschakelen Show highlight on rollover Markering bij aanwijzen tonen Show bounding box when dragging layer Selectiekader tonen bij slepen van laag System title bar Titelbalk van systeem diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5c7411a49d..2468f69b56 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2039,9 +2039,7 @@ Show only crosshair while painting Podczas malowania pokaż tylko celownik Show slice numbers Pokaż numery plasterków Show tooltips Pokaż podpowiedzi - Animation effects Efekty animacji - Use system setting Użyj ustawienia systemowego - On Włączone + Enable animations Włącz animacje Show highlight on rollover Pokaż podświetlenie po najechaniu Show bounding box when dragging layer Pokaż ramkę ograniczającą podczas przeciągania warstwy Show transformation values Pokaż wartości przekształcenia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index aa4e33c14e..098ca1f8a6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1996,8 +1996,7 @@ Show only crosshair while painting Mostrar apenas a mira ao pintar Show slice numbers Mostrar números das fatias Show tooltips Mostrar dicas de ferramentas - Animation effects Efeitos de animação - Use system setting Usar configuração do sistema + Enable animations Ativar animações Show highlight on rollover Mostrar realce ao passar o mouse Show transformation values Mostrar valores de transformação Slice color Cor das fatias diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f7dc4457f1..d3e6033306 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2459,8 +2459,7 @@ Show only crosshair while painting Только перекрестье при рисовании Show slice numbers Показывать номера фрагментов Show tooltips Показывать подсказки - Animation effects Эффекты анимации - Use system setting Использовать системную настройку + Enable animations Включить анимацию Show highlight on rollover Показывать подсветку при наведении Show bounding box when dragging layer Показывать рамку при перетаскивании слоя System title bar Системная строка заголовка diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b609494875..6a6dfb425f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1975,8 +1975,7 @@ Show only crosshair while painting Prikaži samo krstić tokom slikanja Show slice numbers Prikaži brojeve isečaka Show tooltips Prikaži opise alatki - Animation effects Efekti animacije - Use system setting Koristi sistemsko podešavanje + Enable animations Omogući animacije Show highlight on rollover Istakni pri prelasku pokazivačem Show transformation values Prikaži vrednosti transformacije Slice color Boja isečka diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8cd7cb5db8..d725b8c9ac 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1974,8 +1974,6 @@ Show only crosshair while painting Boyama sırasında yalnızca artı işaretini göster Show slice numbers Dilim numaralarını göster Show tooltips Araç ipuçlarını göster - Animation effects Animasyon efektleri - Use system setting Sistem ayarını kullan Show highlight on rollover Rollover sırasında vurgulamayı göster Show bounding box when dragging layer Katmanı sürüklerken sınırlayıcı kutuyu göster System title bar Sistem başlık çubuğu @@ -2496,6 +2494,7 @@ cameraRaw Color Renk Remove All Tümünü Kaldır Unlock Kilidi Aç Enable Etkinleştir + Enable animations Animasyonları etkinleştir Disable Devre dışı bırak On Açık Selected Seçilen diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 15a50e19f7..dd18227059 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2430,9 +2430,7 @@ Show only crosshair while painting Показувати лише перехрестя під час малювання Show slice numbers Показувати номери фрагментів Show tooltips Показувати підказки - Animation effects Ефекти анімації - Use system setting Використовувати системне налаштування - On Увімкнено + Enable animations Увімкнути анімацію Show highlight on rollover Показувати підсвічування під час наведення Show bounding box when dragging layer Показувати рамку під час перетягування шару System title bar Системний рядок заголовка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e09bd39de2..30dd0a8082 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3610,9 +3610,7 @@ cameraData Lens Ống kính New Document With Mask Tài liệu mới có mặt nạ New Layer With Mask Lớp mới có mặt nạ Convert Point Tool Công cụ Chuyển đổi điểm - Animation effects Hiệu ứng chuyển động - Use system setting Dùng cài đặt hệ thống - On Bật + Enable animations Bật hiệu ứng chuyển động Gloss Contour Đường bao bóng Cone - Inverted Hình nón - Đảo ngược Cove - Deep Lõm - Sâu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a79b84a2ba..1c20099c07 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2072,9 +2072,7 @@ Show only crosshair while painting 绘画时仅显示十字线 Show slice numbers 显示切片编号 Show tooltips 显示工具提示 - Animation effects 动画效果 - Use system setting 使用系统设置 - On 开启 + Enable animations 启用动画 Show highlight on rollover 鼠标悬停时显示高亮 Show bounding box when dragging layer 拖动图层时显示边界框 System title bar 系统标题栏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 198ae1faa0..f1a8f53b2b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2044,9 +2044,7 @@ Show only crosshair while painting 繪圖時僅顯示十字線 Show slice numbers 顯示切片編號 Show tooltips 顯示工具提示 - Animation effects 動畫效果 - Use system setting 使用系統設定 - On 開 + Enable animations 啟用動畫 Show highlight on rollover 滑鼠懸停時顯示醒目提示 Show bounding box when dragging layer 拖曳圖層時顯示邊界框 System title bar 系統標題列 diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 0eafe95192..5451ad35bd 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -489,107 +489,6 @@ fn the_wheel_reaches_12800_percent_and_stops_dead_there() { assert!((d2[0] - d0[0]).abs() < 0.02 && (d2[1] - d0[1]).abs() < 0.02, "{d0:?} -> {d2:?}"); } -/// The zoom [`notch_pan`] pans at: 800 %. -const NOTCH_PAN_ZOOM: f32 = 8.0; - -/// How far one wheel notch moves the view at [`NOTCH_PAN_ZOOM`], in document pixels: egui's -/// points per notch (`line_scroll_speed`) over the zoom. -fn notch_pan_distance(h: &Harness<'static, PhotocraftApp>) -> f32 { - h.ctx.options(|o| o.input_options.line_scroll_speed) / NOTCH_PAN_ZOOM -} - -/// One wheel notch (with `modifiers` held) over the canvas at 800 %: the view's centre before it, -/// on the frame the notch arrives, and once any glide has settled. -fn notch_pan(h: &mut Harness<'static, PhotocraftApp>, modifiers: Modifiers) -> ([f32; 2], [f32; 2], [f32; 2]) { - { - let v = &mut h.state_mut().ui.views[0]; - (v.zoom, v.center, v.fit_pending) = (NOTCH_PAN_ZOOM, [200.0, 150.0], false); - } - h.run_steps(4); - h.hover_at(h.state().last_canvas_rect.center()); - h.run_steps(2); - let before = h.state().ui.views[0].center; - wheel(h, -1.0, modifiers); - h.run_steps(1); - let on_arrival = h.state().ui.views[0].center; - h.run_steps(40); - (before, on_arrival, h.state().ui.views[0].center) -} - -/// Preferences › Interface › Animation Effects (#3273): on, a wheel notch glides the canvas to -/// its new place over the frames that follow; off, it is there on the frame the notch arrives. -#[test] -fn animation_effects_off_pans_a_wheel_notch_at_once() { - for (choice, at_once) in [("on", false), ("off", true)] { - let mut h = app_window(); - h.state_mut().run("prefs.set", json!({"path": "interface.animationEffects", "value": choice})).unwrap(); - let (c0, c1, c2) = notch_pan(&mut h, Modifiers::NONE); - let notch = notch_pan_distance(&h); - assert!((c2[1] - c0[1] - notch).abs() < 0.01 && c2[0] == c0[0], "{choice}: one notch is {notch} px at 800%: {c0:?} -> {c2:?}"); - assert_eq!(c1 == c2, at_once, "{choice}: {c0:?} -> {c1:?} -> {c2:?}"); - } -} - -/// A canvas in another OS window (Window › Arrange › New Window) reads that window's own input, -/// which `wheel_nav::unsmooth` never sees: it runs on the main window's input only. So the -/// canvas's own wheel read must not glide with Animation Effects off either (#5721), ⇧ or -/// ⌘/Ctrl + wheel must still pan sideways, and a trackpad gesture must keep its direction. -#[test] -fn animation_effects_off_stops_the_canvas_glide_without_unsmooth() { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.sync_views(); - let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - let ctx = ui.ctx().clone(); - // Fonts set up after the first frame only apply from the next one. - if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - return; - } - // What `PhotocraftApp::logic` does for a frame, without `unsmooth`. - crate::prefs_ui::tick(app, &ctx); - egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); - h.run_steps(4); - h.state_mut().run("prefs.set", json!({"path": "interface.animationEffects", "value": "off"})).unwrap(); - h.run_steps(1); - assert!(h.state().prefs_rt.animations_off); - let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; - for (modifiers, axis) in [(Modifiers::NONE, 1), (Modifiers::SHIFT, 0), (ctrl, 0)] { - let (c0, c1, c2) = notch_pan(&mut h, modifiers); - let moved = [c2[0] - c0[0], c2[1] - c0[1]]; - let notch = notch_pan_distance(&h); - assert!((moved[axis] - notch).abs() < 0.01 && moved[1 - axis] == 0.0, "{modifiers:?}: one notch is {notch} px at 800%: {moved:?}"); - assert_eq!(c1, c2, "{modifiers:?}: there on the frame the notch arrives: {c0:?} -> {c1:?} -> {c2:?}"); - } - - // A trackpad gesture keeps the direction it began with, as egui does: begun with ⇧ held, it - // still pans sideways after ⇧ is let go. - { - let v = &mut h.state_mut().ui.views[0]; - (v.zoom, v.center, v.fit_pending) = (NOTCH_PAN_ZOOM, [200.0, 150.0], false); - } - h.run_steps(4); - let before = h.state().ui.views[0].center; - let swipe = |phase, dy: f32, modifiers| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers }; - for (phase, dy, modifiers) in [ - (egui::TouchPhase::Start, 0.0, Modifiers::SHIFT), - (egui::TouchPhase::Move, -8.0, Modifiers::SHIFT), - (egui::TouchPhase::Move, -16.0, Modifiers::NONE), - (egui::TouchPhase::End, 0.0, Modifiers::NONE), - ] { - h.event_modifiers(swipe(phase, dy, modifiers), modifiers); - h.run_steps(1); - } - h.run_steps(4); - let after = h.state().ui.views[0].center; - let moved = [after[0] - before[0], after[1] - before[1]]; - assert!((moved[0] - 24.0 / NOTCH_PAN_ZOOM).abs() < 0.01 && moved[1] == 0.0, "24 points sideways for the whole gesture: {moved:?}"); -} - #[test] fn alt_scroll_zooms_in_steps_around_the_pointer() { let mut h = harness(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ff46e8addb..a878a174c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -518,10 +518,6 @@ pub type LoadTextFn = Box Option>; pub type SaveTextFn = Box Result<(), String>>; /// Platform appearance when egui cannot detect it (for example, Wayland without a theme event). pub type SystemThemeFn = Box Option>; -/// Whether the operating system animates its interface (Windows "Animation effects", macOS -/// "Reduce motion", the desktop's setting on Linux and the BSDs, `prefers-reduced-motion` in a -/// browser): `Some(false)` when the user turned animations off there, `None` when it can't tell. -pub type SystemAnimationsFn = Box Option>; /// Autosave a document snapshot for crash recovery: (snapshot, revision, original path). pub type AutosaveFn = Box, u64, Option<&str>) -> Result<(), String>>; /// Poll successful or failed background writes: (document id, revision, result). @@ -637,10 +633,6 @@ pub struct Services { /// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`). pub photoshop_shortcuts: Option, pub system_theme: Option, - /// The system's animation setting, for Preferences › Interface › Animation Effects › Use - /// System Setting. Read every frame while that is the choice, so it must be cheap; without - /// it (or without an answer) animations stay on. - pub system_animations: Option, /// The native window is connected directly to a Wayland compositor. pub is_wayland: bool, /// On Wayland, the shell command that starts this install under XWayland, where native file @@ -2328,11 +2320,6 @@ impl eframe::App for PhotocraftApp { self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); updates_ui::tick(self, ctx); - // Animation Effects off: no wheel glide in the main window's panels and lists. Before - // anything reads this frame's scroll (see `wheel_nav::unsmooth`). - if self.prefs_rt.animations_off { - wheel_nav::unsmooth(ctx); - } monitor_status::poll_frame(self, ctx, frame); // Control requests and persisted preferences can change the language in this frame. i18n::sync_context(ctx, &self.session.prefs().interface.language); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 084dccd149..81c1f9adda 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2092,7 +2092,7 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let navigator_wheel = egui::Id::new("pc-navigator-wheel"); if resp.hovered() && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = - crate::wheel_nav::read_with(&ctx, true, pinch_zooms, animated_zoom, navigator_wheel, true, !app.prefs_rt.animations_off, touch_gestures) + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, animated_zoom, navigator_wheel, true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 2f3327c337..d001a2e08d 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -52,9 +52,6 @@ pub struct Runtime { show_rulers: Option, /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, - /// Interface › Animation Effects resolved to off this frame (see `sync_animations`). The - /// wheel readers take it as their `smooth` flag (`wheel_nav::read_with`). - pub(crate) animations_off: bool, pub(crate) selected_tool: Option, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, @@ -484,25 +481,11 @@ fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) { } } -/// Interface › Animation Effects, resolved: on or off as chosen, or for Use System Setting what -/// the operating system says (on when it can't tell). -fn animations_on(app: &PhotocraftApp, ctx: &egui::Context) -> bool { - match app.session.prefs().interface.animation_effects { - prefs::AnimationEffects::On => true, - prefs::AnimationEffects::Off => false, - prefs::AnimationEffects::System => app.services.system_animations.as_ref().and_then(|read| read(ctx)).unwrap_or(true), - } -} - -/// Interface › Animation Effects: off, egui's fades and slides take no time and `scroll_to` jumps -/// straight to its target (#3273). The wheel's glide is taken out where the wheel is read: by -/// `wheel_nav::read_with` on the canvas, in every window, and by `wheel_nav::unsmooth` in the -/// main window's panels and lists. Checked every frame: a theme change rebuilds the style and the -/// system's setting can change. Both the dark and light styles are set, so an appearance switch -/// keeps it. -fn sync_animations(app: &mut PhotocraftApp, ctx: &egui::Context) { - let on = animations_on(app, ctx); - app.prefs_rt.animations_off = !on; +/// Interface › Enable Animations: off, egui's fades and slides take no time and scroll areas jump +/// straight to their target (#3273). Re-checked every frame because a theme change rebuilds the +/// style; both the dark and light styles are set, so a system appearance switch keeps it. +fn sync_animations(app: &PhotocraftApp, ctx: &egui::Context) { + let on = app.session.prefs().interface.enable_animations; let time = if on { egui::Style::default().animation_time } else { 0.0 }; if ctx.global_style().animation_time != time { let scroll = if on { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() }; @@ -1353,15 +1336,6 @@ fn choice_label(v: &str) -> String { } } -/// The label of choice `v` of the preference at `path`. Most choices read the same wherever -/// they appear; Animation Effects' `system` says what it does. -fn pref_choice_label(path: &str, v: &str) -> String { - match (path, v) { - ("interface.animationEffects", "system") => "Use system setting".into(), - _ => choice_label(v), - } -} - /// An editable `#rrggbb` field beside a colour swatch (#668): typing a valid value sets the /// colour; while it's being typed a partial value is kept, and it shows the colour otherwise. fn hex_field(ui: &mut egui::Ui, key: &str, rgb: &mut [u8; 3]) { @@ -1910,7 +1884,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke Value::String(s) if prefs::choices(&path).is_some() => { ui.label(RichText::new(tl!(&label)).color(t.text_dim)); let opts = prefs::choices(&path).unwrap_or(&[]); - let labels: Vec = opts.iter().map(|o| pref_choice_label(&path, o)).collect(); + let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); let mut cur = s.clone(); crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); @@ -4141,109 +4115,29 @@ mod tests { } } - /// egui's `animation_time` in the dark and the light style. - fn animation_times(ctx: &egui::Context) -> [f32; 2] { - [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time) - } - - /// Off zeroes egui's animation time in both styles, survives a theme change and a restart, - /// and On brings the default back. #[test] - fn animation_effects_off_stops_egui_animations_and_is_remembered() { + fn enable_animations_off_stops_fades_and_scroll_glides_and_is_remembered() { let on = egui::Style::default().animation_time; - assert!(on > 0.0, "egui animates by default"); + let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time); // Saved off: the first frame after a restart applies it to both appearance styles. - let (mut app, _) = app_with_saved(Some(json!({"interface": {"animationEffects": "off"}}).to_string())); + let (mut app, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string())); let ctx = egui::Context::default(); tick(&mut app, &ctx); - assert_eq!(animation_times(&ctx), [0.0, 0.0]); + assert_eq!(both(&ctx), [0.0, 0.0]); assert_eq!(ctx.global_style().scroll_animation.duration.max, 0.0); - assert!(app.prefs_rt.animations_off); // A theme change rebuilds the style; the next frame turns animations off again. app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight); tick(&mut app, &ctx); - assert_eq!(animation_times(&ctx), [0.0, 0.0]); - app.run("prefs.set", json!({"values": {"interface.animationEffects": "on"}})).unwrap(); + assert_eq!(both(&ctx), [0.0, 0.0]); + app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).unwrap(); tick(&mut app, &ctx); - assert_eq!(animation_times(&ctx), [on, on]); - assert!(ctx.global_style().scroll_animation.duration.max > 0.0 && !app.prefs_rt.animations_off); - } - - /// Use System Setting, the default, follows the system's answer as it changes; On and Off - /// ignore it; no answer keeps animations on. - #[test] - fn use_system_setting_follows_the_platform() { - use std::{cell::Cell, rc::Rc}; - let on = egui::Style::default().animation_time; - let system = Rc::new(Cell::new(None)); - let (mut app, _) = app_with_store(); - let read = system.clone(); - app.services.system_animations = Some(Box::new(move |_| read.get())); - let ctx = egui::Context::default(); - assert_eq!(app.session.prefs().interface.animation_effects, prefs::AnimationEffects::System, "the default"); - // As the system's setting changes; no answer keeps animations on. - for (answer, time) in [(Some(false), 0.0), (Some(true), on), (None, on), (Some(false), 0.0)] { - system.set(answer); - tick(&mut app, &ctx); - assert_eq!(animation_times(&ctx), [time, time], "the system says {answer:?}"); - } - // On and Off hold whatever the system says. - for (answer, choice, time) in [(Some(false), "on", on), (Some(true), "off", 0.0), (Some(true), "system", on)] { - system.set(answer); - app.run("prefs.set", json!({"values": {"interface.animationEffects": choice}})).unwrap(); - tick(&mut app, &ctx); - assert_eq!(animation_times(&ctx), [time, time], "{choice} while the system says {answer:?}"); - } - // A platform without the service can't tell: animations stay on. - let (mut plain, _) = app_with_store(); - let ctx = egui::Context::default(); - tick(&mut plain, &ctx); - assert_eq!(animation_times(&ctx), [on, on]); - } - - /// The old checkbox's saved "off" still turns animations off, and a value that is no choice - /// loads as the default instead of failing. - #[test] - fn old_and_hand_edited_animation_settings_load_safely() { - let on = egui::Style::default().animation_time; - let after_restart = |interface: Value| { - let (mut app, _) = app_with_saved(Some(json!({"interface": interface}).to_string())); - let ctx = egui::Context::default(); - tick(&mut app, &ctx); - animation_times(&ctx) - }; - // The Enable Animations checkbox this replaced: a saved "off" is still off. - assert_eq!(after_restart(json!({"enableAnimations": false})), [0.0, 0.0]); - // A value that is no choice doesn't crash: the defaults stay. - for junk in [json!("nope"), json!(7), json!(null)] { - assert_eq!(after_restart(json!({"animationEffects": junk})), [on, on], "{junk}"); - } - } - - /// Interface › Animation Effects is a dropdown of three choices, the system's first. - #[test] - fn animation_effects_is_a_three_way_choice_in_the_interface_section() { - use egui::accesskit::Role; - use egui_kittest::{Harness, kittest::Queryable}; - assert_eq!(humanize("animationEffects"), "Animation effects"); - let labels: Vec = - prefs::choices("interface.animationEffects").unwrap().iter().map(|c| pref_choice_label("interface.animationEffects", c)).collect(); - assert_eq!(labels, ["Use system setting", "On", "Off"]); - assert_eq!(pref_choice_label("general.colorPicker", "system"), "System", "elsewhere `system` keeps its plain label"); - let obj = json!({"animationEffects": "system"}).as_object().unwrap().clone(); - let mut h = Harness::builder() - .with_size(egui::vec2(700.0, 700.0)) - .build_ui_state(|ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None, ""), obj); - h.run_steps(4); - assert!(h.get_by_label("Animation effects").rect().is_positive()); - h.get_all_by_role(Role::ComboBox).last().unwrap().click(); - h.run_steps(4); - for choice in ["Use system setting", "On", "Off"] { - assert!(h.query_all_by_label(choice).any(|item| item.rect().is_positive()), "{choice} is offered"); - } - h.get_by_label("Off").click(); - h.run_steps(4); - assert_eq!(h.state()["animationEffects"], "off"); + assert_eq!(both(&ctx), [on, on]); + assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0); + // A hand-edited value of the wrong type doesn't crash: the defaults (animations on) stay. + let (mut bad, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": "nope"}}).to_string())); + let ctx2 = egui::Context::default(); + tick(&mut bad, &ctx2); + assert_eq!(both(&ctx2), [on, on]); } #[test] diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index b4894ca5c9..5f347061b2 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -34,10 +34,6 @@ //! and stays instant. The Alt state is taken from the wheel events (and the Alt key's own press //! and release, `wheel_events`) and kept until the next wheel event: releasing Alt while the tail //! is still settling must not turn it into a pan. -//! -//! Preferences › Interface › Animation Effects off takes the glide out: [`read_with`]'s `smooth` -//! flag for the canvas and the Navigator, in every window, and [`unsmooth`] for the main -//! window's panels and lists. use egui::{Context, Event, Id, InputState, Key, Modifiers, MouseWheelUnit, RawInput, TouchPhase, Vec2}; @@ -191,82 +187,6 @@ pub fn fold_legacy_pinch(ctx: &Context, raw: &mut RawInput) { } } -/// Where each window keeps, from frame to frame, whether a trackpad gesture is in progress in it. -/// Per window: a second document window has input of its own. -fn gesture_id(ctx: &Context) -> Id { - Id::new("pc-wheel-gesture").with(ctx.viewport_id()) -} - -/// What this frame's wheel events scroll in all, in points, each folded sideways or upright by -/// its modifiers as egui folds it. `in_gesture` carries from frame to frame whether a trackpad -/// gesture (one with begin and end phases) is in progress. -/// -/// `None` keeps egui's own scroll: in a frame with any part of such a gesture, which egui applies -/// at once and with the modifiers it started with; for a wheel event that zooms; and for a sum -/// that isn't finite. -fn unsmoothed_scroll(events: &[Event], options: &egui::InputOptions, page: f32, in_gesture: &mut bool) -> Option { - let mut gesture_frame = *in_gesture; - let mut sum = Vec2::ZERO; - for event in events { - let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; - match phase { - TouchPhase::Start => { - *in_gesture = true; - gesture_frame = true; - } - TouchPhase::End | TouchPhase::Cancel => { - *in_gesture = false; - gesture_frame = true; - } - TouchPhase::Move => { - if modifiers.matches_any(options.zoom_modifier) { - return None; - } - let points = *delta - * match unit { - MouseWheelUnit::Point => 1.0, - MouseWheelUnit::Line => options.line_scroll_speed, - MouseWheelUnit::Page => page, - }; - let sideways = modifiers.matches_any(options.horizontal_scroll_modifier); - let upright = modifiers.matches_any(options.vertical_scroll_modifier); - sum += match (sideways, upright) { - (true, false) => Vec2::new(points.x + points.y, 0.0), - (false, true) => Vec2::new(0.0, points.x + points.y), - _ => points, - }; - } - } - } - (!gesture_frame && sum.x.is_finite() && sum.y.is_finite()).then_some(sum) -} - -/// Interface › Animation Effects off: a wheel notch scrolls its whole distance in the frame it -/// arrives, in every scroll area of the window whose input `ctx` holds. egui spreads each notch -/// over about 0.2 s and has no setting to stop it, so this replaces the frame's smoothed scroll -/// with what the frame's own wheel events add up to. The frames after a notch carry only the -/// rest of egui's glide, and so scroll nothing. -/// -/// Call it once a frame, before anything reads the scroll. `PhotocraftApp::logic` does, for the -/// main window; the canvas, also in other windows, uses [`read_with`]'s `smooth` flag instead. -pub fn unsmooth(ctx: &Context) { - if let Some(scroll) = this_frames_scroll(ctx) { - ctx.input_mut(|i| i.smooth_scroll_delta = scroll); - } -} - -/// [`unsmoothed_scroll`] for this frame's input in `ctx`'s window, recording that window's -/// trackpad-gesture state for the next frame. Call it on every frame, whether or not the scroll is -/// wanted, so a gesture's begin and end are never missed. Calls in the same frame agree. -fn this_frames_scroll(ctx: &Context) -> Option { - let options = ctx.options(|o| o.input_options); - let id = gesture_id(ctx); - let mut in_gesture = ctx.data(|d| d.get_temp::(id)).unwrap_or(false); - let scroll = ctx.input(|i| unsmoothed_scroll(&i.events, &options, i.viewport_rect().height(), &mut in_gesture)); - ctx.data_mut(|d| d.insert_temp(id, in_gesture)); - scroll -} - fn alt_id() -> Id { Id::new("pc-wheel-alt") } @@ -419,9 +339,9 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, animated_zo read_with(ctx, zoom_with_wheel, pinch_zooms, animated_zoom, owner, hovered, true, true) } -/// [`read`], choosing how a wheel scroll pans: `smooth` glides it over a few frames (egui's -/// smoothed delta); off, as with Interface › Animation Effects off, the view moves the whole notch -/// in the frame the wheel turns and stops there (#5721), sideways too for ⇧ or ⌘/Ctrl + wheel. +/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's +/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch +/// in the frame the wheel turns and stops there (#5721), as scroll areas already do. /// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's /// two-finger pinch doesn't zoom ([`pinch_factor`]). /// `animated_zoom` is Preferences › General › Animated Zoom: on, the wheel zoom is eased over the @@ -461,9 +381,6 @@ pub fn read_with( } None => ctx.data(|d| d.get_temp::(shift_id())).unwrap_or(false), }; - // The pan with `smooth` off. Read here, before any early return, so the window's trackpad - // gesture state also follows frames that zoom, snap or aren't over this view. - let unsmoothed = this_frames_scroll(ctx); if !hovered { return None; @@ -561,13 +478,12 @@ pub fn read_with( let eased = ease_zoom_delta(ctx, owner, along(raw - instant)) + along(instant); // The plain wheel zooms through `y`; its sideways part (⌘/Ctrl + wheel, a trackpad's // horizontal scroll) is not a zoom event, so it pans like any scroll: smoothed, or - // with Animation Effects off at once and folded by its modifiers (#5721). - if alt { Vec2::new(0.0, eased) } else { Vec2::new(if smooth { scroll.x } else { unsmoothed.map_or(scroll.x, |u| u.x) }, eased) } + // with Enable Animations off at once (#5721). + if alt { Vec2::new(0.0, eased) } else { Vec2::new(if smooth { scroll.x } else { raw.x }, eased) } } else { ctx.data_mut(|d| d.remove::(zoom_ease_id(owner))); - // Unsmoothed, a scroll pans by this frame's own wheel events, folded sideways or upright - // by their modifiers as egui folds them (none: no tail). - if smooth { scroll } else { unsmoothed.unwrap_or(scroll) } + // Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail). + if smooth || alt || shift { scroll } else { raw } }; classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, animated_zoom, notch }) } @@ -624,6 +540,8 @@ pub fn pinch_factor(i: &InputState, touch_gestures: bool) -> f32 { #[cfg(test)] mod tests { + use egui::TouchPhase; + use super::*; fn input(scroll: Vec2, alt: bool) -> Input { @@ -676,14 +594,6 @@ mod tests { (a - b).length() < 0.01 } - /// Points per wheel notch in a context set up like the app's (egui's `line_scroll_speed`: - /// 40 natively, 8 in a browser). - fn configured_notch() -> f32 { - let ctx = Context::default(); - configure(&ctx); - ctx.options(|o| o.input_options.line_scroll_speed) - } - /// #5721: with smoothing off a wheel notch pans the whole notch in the frame it arrives and /// nothing after; smoothed (the default) it glides, reaching the same total over later frames. #[test] @@ -720,7 +630,7 @@ mod tests { assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); } - /// Interface › Animation Effects off only stops the *pan* gliding. A plain wheel that zooms + /// Interface › Enable Animations off only stops the *pan* gliding. A plain wheel that zooms /// (Zoom with Scroll Wheel) with Animated Zoom on must still ease over the frames of a notch, /// exactly as it does with the pan smoothing on. #[test] @@ -753,51 +663,6 @@ mod tests { assert!(unsmoothed.iter().zip(&smoothed).all(|(a, b)| (a - b).abs() < 1e-6), "same pacing as with pan smoothing on"); } - /// Smoothing off, ⇧ and ⌘/Ctrl + wheel still pan sideways, and Alt + wheel with Zoom with - /// Scroll Wheel on still pans upright, each the whole notch at once and the same distance as - /// smoothed: the unsmoothed pan is folded by the modifiers as egui folds the smoothed one. - #[test] - fn unsmoothed_wheel_pans_keep_their_modifiers() { - let pans = |modifiers: Modifiers, zoom_with_wheel: bool, animated_zoom: bool, smooth: bool| -> Vec { - let owner = Id::new("test-unsmoothed-modifiers"); - let ctx = Context::default(); - configure(&ctx); - (0..30) - .map(|n| { - let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), modifiers)] } else { Vec::new() }; - let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; - let mut pan = Vec2::ZERO; - let mut out = ctx.run_ui(raw, |ui| { - if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), zoom_with_wheel, true, animated_zoom, owner, true, smooth, true) { - pan = d; - } - }); - out.textures_delta.clear(); - pan - }) - .collect() - }; - let total = |pans: &[Vec2]| pans.iter().fold(Vec2::ZERO, |sum, d| sum + *d); - let notch = configured_notch(); - for (modifiers, zoom_with_wheel, want) in [ - (Modifiers::NONE, false, Vec2::new(0.0, -notch)), - (Modifiers::SHIFT, false, Vec2::new(-notch, 0.0)), - (CTRL, false, Vec2::new(-notch, 0.0)), - (CMD, false, Vec2::new(-notch, 0.0)), - (CTRL, true, Vec2::new(-notch, 0.0)), - (Modifiers::ALT, true, Vec2::new(0.0, -notch)), - ] { - // General › Animated Zoom changes how the wheel zooms, never how these pan. - for animated_zoom in [false, true] { - let direct = pans(modifiers, zoom_with_wheel, animated_zoom, false); - assert!(near(direct[0], want), "{modifiers:?} {animated_zoom}: the whole notch at once: {:?}", direct[0]); - assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "{modifiers:?} {animated_zoom}: no glide after it"); - let smooth = total(&pans(modifiers, zoom_with_wheel, animated_zoom, true)); - assert!((smooth - want).length() < 1.0, "{modifiers:?} {animated_zoom}: smoothed, the same distance: {smooth:?}"); - } - } - } - #[test] fn plain_scroll_pans() { assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0)))); @@ -1051,7 +916,7 @@ mod tests { assert!(frames[1..].iter().all(|f| *f == 1.0), "no tail after it: {frames:?}"); } - /// Zoom with Scroll Wheel + Animated Zoom with Animation Effects off: the wheel zooms eased, but + /// Zoom with Scroll Wheel + Animated Zoom with Enable Animations off: the wheel zooms eased, but /// a sideways wheel still pans without a glide (#5721), all of it in the frame it turns. #[test] fn animated_zoom_keeps_the_sideways_wheel_unsmoothed_with_animations_off() { @@ -1355,165 +1220,6 @@ mod tests { assert!(matches!(ev[0], Event::MouseWheel { .. })); } - // --- Interface › Animation Effects off (`unsmooth`) - - /// `smooth_scroll_delta` as the UI reads it on each of 30 frames at 60 fps, in a context set - /// up like the app's; `off` is Animation Effects off ([`unsmooth`] at the start of the frame). - fn scrolled(frames: Vec>, off: bool) -> Vec { - let ctx = Context::default(); - configure(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); - let mut frames = frames.into_iter(); - (0..30) - .map(|n| { - let events = frames.next().unwrap_or_default(); - let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, screen_rect: Some(screen), events, ..Default::default() }; - let mut seen = Vec2::ZERO; - let mut out = ctx.run_ui(raw, |ui| { - if off { - unsmooth(ui.ctx()); - } - seen = ui.input(|i| i.smooth_scroll_delta); - }); - out.textures_delta.clear(); - seen - }) - .collect() - } - - /// How far the frames scroll in all. - fn total(frames: &[Vec2]) -> Vec2 { - frames.iter().fold(Vec2::ZERO, |sum, d| sum + *d) - } - - /// Animation Effects off: a wheel notch scrolls its whole distance the frame it arrives, - /// instead of gliding there over the frames that follow. - #[test] - fn animations_off_scrolls_a_wheel_notch_at_once() { - let notch = configured_notch(); - let one_notch_down = || vec![vec![], vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)]]; - // egui by itself: part of the notch on the frame it arrives, the rest over the next ones. - let glide = scrolled(one_notch_down(), false); - assert!(glide[1].y < 0.0 && glide[1].y > -0.75 * notch, "the notch starts gliding: {:?}", glide[1]); - assert!(glide[2..8].iter().all(|d| d.y < 0.0), "and keeps gliding: {:?}", &glide[2..8]); - assert!(near(total(&glide), Vec2::new(0.0, -notch)), "{:?}", total(&glide)); - // Off: all of it at once, and nothing after. - let jump = scrolled(one_notch_down(), true); - assert_eq!(jump[1], Vec2::new(0.0, -notch)); - assert!(jump.iter().enumerate().all(|(n, d)| n == 1 || *d == Vec2::ZERO), "{jump:?}"); - // Sideways (⇧ or ⌘/Ctrl + wheel), two notches in one frame, a fraction of a notch each - // way, a page and a coarse pixel step (a wheel on Wayland or the web) jump the same way. - let sent = |unit, y: f32| Event::MouseWheel { unit, delta: Vec2::new(0.0, y), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; - for (events, want) in [ - (vec![wheel(Vec2::new(0.0, 1.0), Modifiers::SHIFT)], Vec2::new(notch, 0.0)), - (vec![wheel(Vec2::new(0.0, 1.0), CTRL)], Vec2::new(notch, 0.0)), - (vec![wheel(Vec2::new(0.0, 1.0), Modifiers::NONE), wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)], Vec2::new(0.0, 2.0 * notch)), - (vec![wheel(Vec2::new(-0.5, 0.25), Modifiers::NONE)], Vec2::new(-0.5 * notch, 0.25 * notch)), - (vec![sent(MouseWheelUnit::Page, -1.0)], Vec2::new(0.0, -600.0)), - (vec![sent(MouseWheelUnit::Point, 53.0)], Vec2::new(0.0, 53.0)), - ] { - let jump = scrolled(vec![events.clone()], true); - assert!(near(jump[0], want) && jump[1..].iter().all(|d| *d == Vec2::ZERO), "{events:?}: {jump:?}"); - let glide = total(&scrolled(vec![events.clone()], false)); - assert!((glide - want).length() < 1.0, "the same distance as with animations on: {events:?}: {glide:?}"); - } - // Alt + wheel still zooms one step, and doesn't pan as well. - let ctx = Context::default(); - configure(&ctx); - let raw = RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel(Vec2::new(0.0, 1.0), Modifiers::ALT)], ..Default::default() }; - let mut seen = None; - let mut out = ctx.run_ui(raw, |ui| { - unsmooth(ui.ctx()); - seen = read(ui.ctx(), false, true, false, Id::new("test-alt"), true); - }); - out.textures_delta.clear(); - assert!(matches!(seen, Some(Wheel::Zoom(f)) if (f - 1.1).abs() < 1e-4), "{seen:?}"); - } - - /// What the setting is for: an egui scroll area (panels, lists, long menus) is at its new - /// place on the frame the notch arrives, where it otherwise glides there. - #[test] - fn animations_off_scroll_areas_jump() { - let offsets = |off: bool| -> Vec { - let ctx = Context::default(); - configure(&ctx); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0)); - (0..40) - .map(|n| { - let mut events = vec![Event::PointerMoved(egui::pos2(100.0, 100.0))]; - if n == 3 { - events.push(wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)); - } - let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, screen_rect: Some(screen), events, ..Default::default() }; - let mut offset = 0.0; - let mut out = ctx.run_ui(raw, |ui| { - if off { - unsmooth(ui.ctx()); - } - let area = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 2000.0))); - offset = area.state.offset.y; - }); - out.textures_delta.clear(); - offset - }) - .collect() - }; - let notch = configured_notch(); - let (glide, jump) = (offsets(false), offsets(true)); - assert!(glide[3] > 0.0 && glide[3] < 0.75 * notch && glide[4] > glide[3], "it glides: {:?}", &glide[..8]); - assert!((glide[39] - notch).abs() < 0.01, "to one notch down: {}", glide[39]); - assert_eq!(jump[..3], [0.0; 3]); - assert!(jump[3..].iter().all(|y| *y == notch), "off, it is there at once: {:?}", &jump[..8]); - } - - /// A trackpad gesture (begin and end phases) is already applied as it arrives, with its - /// modifiers held from the start: off leaves it exactly as egui has it. - #[test] - fn animations_off_leaves_trackpad_gestures_to_egui() { - let touch = |phase, y: f32, modifiers| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: Vec2::new(0.0, y), phase, modifiers }; - let gesture = || { - vec![ - vec![touch(TouchPhase::Start, 0.0, Modifiers::SHIFT)], - vec![touch(TouchPhase::Move, 3.0, Modifiers::SHIFT)], - // Shift let go mid-gesture: egui keeps scrolling sideways. - vec![touch(TouchPhase::Move, 30.0, Modifiers::NONE)], - vec![touch(TouchPhase::Move, 2.0, Modifiers::NONE), touch(TouchPhase::End, 0.0, Modifiers::NONE)], - vec![], - // The wheel afterwards jumps again. - vec![wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)], - ] - }; - let (on, off) = (scrolled(gesture(), false), scrolled(gesture(), true)); - assert_eq!(on[..5], off[..5], "the gesture reads the same"); - assert_eq!(on[2], Vec2::new(30.0, 0.0), "sideways for the whole gesture"); - assert_eq!(off[5], Vec2::new(0.0, configured_notch())); - assert!(off[6..].iter().all(|d| *d == Vec2::ZERO)); - } - - /// A wheel event that zooms (egui's own zoom modifier, which the app turns off) and hostile - /// deltas leave the frame's scroll as egui computed it. - #[test] - fn unsmoothing_skips_zoom_and_hostile_numbers() { - let options = egui::InputOptions::default(); - let mut in_gesture = false; - let zoom = [wheel(Vec2::new(0.0, 1.0), CTRL)]; - assert_eq!(unsmoothed_scroll(&zoom, &options, 600.0, &mut in_gesture), None, "egui's default: ⌘/Ctrl + wheel zooms"); - let plain = [wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)]; - assert_eq!(unsmoothed_scroll(&plain, &options, 600.0, &mut in_gesture), Some(Vec2::new(0.0, options.line_scroll_speed))); - assert_eq!(unsmoothed_scroll(&[], &options, 600.0, &mut in_gesture), Some(Vec2::ZERO), "no wheel event: the rest of a glide is dropped"); - for bad in [f32::NAN, f32::INFINITY, -f32::INFINITY, f32::MAX] { - for page in [600.0, f32::NAN, f32::INFINITY, 0.0, -1.0] { - for unit in [MouseWheelUnit::Point, MouseWheelUnit::Line, MouseWheelUnit::Page] { - let events = [Event::MouseWheel { unit, delta: Vec2::new(bad, bad), phase: TouchPhase::Move, modifiers: Modifiers::SHIFT }]; - if let Some(sum) = unsmoothed_scroll(&events, &options, page, &mut in_gesture) { - assert!(sum.x.is_finite() && sum.y.is_finite(), "{sum:?} from {bad} {page} {unit:?}"); - } - } - } - } - assert!(!in_gesture, "no gesture phase was seen"); - } - #[test] fn hostile_numbers_never_produce_a_bad_zoom() { for s in [f32::NAN, f32::INFINITY, -f32::INFINITY, 1e30, -1e30] { diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 4ac6d336a1..cb73dd0c57 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -73,7 +73,7 @@ id = "UI-217-10" target = "Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents" status = "partial" issue = 217 -note = "interface.animationEffects (system, on, off; prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the wheel's glide is taken out by wheel_nav::read_with's smooth flag on the canvas and the Navigator (every window, #5721) and by wheel_nav::unsmooth in the main window's panels and lists; system, the default, follows the OS animation setting through Services::system_animations (apps/photocraft/src/animations.rs for Windows, macOS, Linux and the BSDs; prefers-reduced-motion on the web); the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382)" +note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382)" [[item]] id = "UI-217-11"