Merge PR #4573: Interface: Animation Effects follows the system setting, or is on or off

Conflicts with #5547 (Animated Zoom) resolved in wheel_nav.rs, canvas.rs and
panels.rs: the Navigator and canvas pass both Animated Zoom and
!animations_off; with Animation Effects off, the sideways part of an
animated wheel zoom pans by the frame's own events folded by their
modifiers (#4573's unsmoothed scroll) instead of the plain-event raw.x,
so Ctrl + wheel still pans sideways there. #4573's modifier test runs
with Animated Zoom off and on.
This commit is contained in:
David Mann
2026-10-25 10:30:00 +01:00
39 changed files with 1118 additions and 66 deletions
Generated
+1
View File
@@ -4780,6 +4780,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"ureq",
"windows",
"winit",
"winresource",
"winsafe",
+1 -1
View File
@@ -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"] }
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"] }
[dev-dependencies]
serde_json = { workspace = true }
+6
View File
@@ -447,6 +447,12 @@ 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()
}
}
+10 -5
View File
@@ -54,13 +54,14 @@ 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) 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.
# 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.
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", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow", "NSWorkspace"] }
# 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 }
@@ -96,6 +97,10 @@ 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]
+281
View File
@@ -0,0 +1,281 @@
//! 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<bool>) -> 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<bool>)>);
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<photocraft_ui_egui::SystemAnimationsFn> {
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<bool> {
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<photocraft_ui_egui::SystemAnimationsFn> {
Some(super::polled(read))
}
/// Animations are on unless Reduce motion is.
fn read() -> Option<bool> {
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<photocraft_ui_egui::SystemAnimationsFn> {
let latest = Arc::new(Mutex::new(None));
let wake: Arc<OnceLock<egui::Context>> = 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<Mutex<Option<bool>>>,
wake: Arc<OnceLock<egui::Context>>,
}
impl Publisher {
/// Stores the worker's answer, and wakes the UI when it changed.
fn publish(&self, animations: Option<bool>) {
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<Option<bool>>) -> MutexGuard<'_, Option<bool>> {
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<bool>;
/// 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<bool> {
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<String> {
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<bool> {
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<PathBuf> {
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<String> {
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<String> {
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<photocraft_ui_egui::SystemAnimationsFn> {
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);
}
}
@@ -0,0 +1,157 @@
//! 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<bool> {
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<bool> {
value.trim().parse::<f64>().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<bool> {
value.trim().parse::<u32>().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 <schema> <key>`.
pub(super) fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option<String>, gsettings: &dyn Fn(&str, &str) -> Option<String>) -> Option<bool> {
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<bool>| {
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<String> { 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);
}
}
+1
View File
@@ -5,6 +5,7 @@ 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<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
+1
View File
@@ -29,6 +29,7 @@
#![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;
+1
View File
@@ -690,6 +690,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
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())?;
+19 -4
View File
@@ -83,6 +83,12 @@ 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);
@@ -286,9 +292,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 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,
/// 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,
/// 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,
@@ -328,7 +334,7 @@ impl Default for Interface {
show_menu_colors: true,
clip_thumbnails_to_layer_bounds: false,
show_tooltips: true,
enable_animations: true,
animation_effects: AnimationEffects::System,
show_bounding_box_when_dragging_layer: false,
system_title_bar: false,
show_file_name_in_title: true,
@@ -1172,6 +1178,7 @@ 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,
@@ -1901,6 +1908,14 @@ 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();
+40
View File
@@ -85,6 +85,46 @@ 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")] {
+4 -3
View File
@@ -3559,7 +3559,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.session.prefs().interface.enable_animations,
!app.prefs_rt.animations_off,
app.session.prefs().enhanced_controls.touch_gestures,
)
.is_some());
@@ -3924,7 +3924,8 @@ 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::<bool>(bars_id)).unwrap_or(false);
let nav_hovered = response.hovered() || free_hover || over_bars;
// Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721).
// Interface › Animation Effects off: the wheel moves the canvas without a glide, in every
// window (#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.
@@ -3936,7 +3937,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.session.prefs().interface.enable_animations,
!app.prefs_rt.animations_off,
touch_gestures,
);
if nav_hovered {
+2 -1
View File
@@ -2039,7 +2039,8 @@
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ů
Enable animations Povolit animace
Animation effects Efekty animace
Use system setting Použít nastavení systému
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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -2013,7 +2013,9 @@
Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen
Show slice numbers Slice-Nummern anzeigen
Show tooltips Tooltips anzeigen
Enable animations Animationen aktivieren
Animation effects Animationseffekte
Use system setting Systemeinstellung verwenden
On Ein
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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1517,7 +1517,8 @@
Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική
Show slice numbers Εμφάνιση αριθμών τομών
Show tooltips Εμφάνιση επεξηγήσεων
Enable animations Ενεργοποίηση κινούμενων εφέ
Animation effects Εφέ κίνησης
Use system setting Χρήση ρύθμισης συστήματος
Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη
Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου
Show transformation values Εμφάνιση τιμών μετασχηματισμού
Can't render this file because it contains an unexpected character in line 1038 and column 18.
+2 -1
View File
@@ -2027,7 +2027,8 @@
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
Enable animations Activar las animaciones
Animation effects Efectos de animación
Use system setting Usar la configuración del sistema
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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2 -1
View File
@@ -1995,7 +1995,8 @@
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
Enable animations Activer les animations
Animation effects Effets d'animation
Use system setting Utiliser le réglage du système
Show highlight on rollover Afficher la mise en surbrillance au survol
Show transformation values Afficher les valeurs de transformation
Slice color Couleur des tranches
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -2989,6 +2989,8 @@ 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
@@ -3232,7 +3234,6 @@ 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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -2005,7 +2005,9 @@
Show only crosshair while painting Tampilkan crosshair saja saat melukis
Show slice numbers Tampilkan nomor slice
Show tooltips Tampilkan tooltip
Enable animations Aktifkan animasi
Animation effects Efek animasi
Use system setting Gunakan pengaturan sistem
On Hidup
Show highlight on rollover Tampilkan sorotan saat kursor melintas
Show transformation values Tampilkan nilai transformasi
Slice color Warna slice
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2 -1
View File
@@ -2021,7 +2021,8 @@
Show only crosshair while painting Mostra solo mirino durante pittura
Show slice numbers Mostra numeri sezioni
Show tooltips Mostra tooltip
Enable animations Attiva animazioni
Animation effects Effetti di animazione
Use system setting Usa impostazione di sistema
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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2021
2022
2023
2024
2025
2026
2027
2028
+3 -1
View File
@@ -2023,7 +2023,9 @@
Show only crosshair while painting 描画中は十字線のみ表示
Show slice numbers スライス番号を表示
Show tooltips ツールチップを表示
Enable animations アニメーションを有効にする
Animation effects アニメーション効果
Use system setting システム設定を使用
On オン
Show highlight on rollover ロールオーバー時にハイライトを表示
Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示
System title bar システムのタイトルバー
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -2010,7 +2010,9 @@
Show only crosshair while painting 칠하는 동안 십자선만 표시
Show slice numbers 분할 영역 번호 표시
Show tooltips 도구 설명 표시
Enable animations 애니메이션 사용
Animation effects 애니메이션 효과
Use system setting 시스템 설정 사용
On 켬
Show highlight on rollover 롤오버 시 강조 표시
Show transformation values 변형 값 표시
Slice color 분할 영역 색상
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2010
2011
2012
2013
2014
2015
2016
2017
2018
+2 -1
View File
@@ -1995,7 +1995,8 @@
Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen
Show slice numbers Segmentnummers tonen
Show tooltips Knopinfo tonen
Enable animations Animaties inschakelen
Animation effects Animatie-effecten
Use system setting Systeeminstelling gebruiken
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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3 -1
View File
@@ -2032,7 +2032,9 @@
Show only crosshair while painting Podczas malowania pokaż tylko celownik
Show slice numbers Pokaż numery plasterków
Show tooltips Pokaż podpowiedzi
Enable animations Włącz animacje
Animation effects Efekty animacji
Use system setting Użyj ustawienia systemowego
On Włączone
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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1989,7 +1989,8 @@
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
Enable animations Ativar animações
Animation effects Efeitos de animação
Use system setting Usar configuração do sistema
Show highlight on rollover Mostrar realce ao passar o mouse
Show transformation values Mostrar valores de transformação
Slice color Cor das fatias
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -2455,7 +2455,8 @@
Show only crosshair while painting Только перекрестье при рисовании
Show slice numbers Показывать номера фрагментов
Show tooltips Показывать подсказки
Enable animations Включить анимацию
Animation effects Эффекты анимации
Use system setting Использовать системную настройку
Show highlight on rollover Показывать подсветку при наведении
Show bounding box when dragging layer Показывать рамку при перетаскивании слоя
System title bar Системная строка заголовка
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1971,7 +1971,8 @@
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
Enable animations Omogući animacije
Animation effects Efekti animacije
Use system setting Koristi sistemsko podešavanje
Show highlight on rollover Istakni pri prelasku pokazivačem
Show transformation values Prikaži vrednosti transformacije
Slice color Boja isečka
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1970,6 +1970,8 @@
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
@@ -2490,7 +2492,6 @@ 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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3 -1
View File
@@ -2426,7 +2426,9 @@
Show only crosshair while painting Показувати лише перехрестя під час малювання
Show slice numbers Показувати номери фрагментів
Show tooltips Показувати підказки
Enable animations Увімкнути анімацію
Animation effects Ефекти анімації
Use system setting Використовувати системне налаштування
On Увімкнено
Show highlight on rollover Показувати підсвічування під час наведення
Show bounding box when dragging layer Показувати рамку під час перетягування шару
System title bar Системний рядок заголовка
Can't render this file because it contains an unexpected character in line 1405 and column 18.
+3 -1
View File
@@ -3600,7 +3600,9 @@ 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
Enable animations Bật hiệu ứng chuyển động
Animation effects Hiệu ứng chuyển động
Use system setting Dùng cài đặt hệ thống
On Bật
Gloss Contour Đường bao bóng
Cone - Inverted Hình nón - Đảo ngược
Cove - Deep Lõm - Sâu
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -2064,7 +2064,9 @@
Show only crosshair while painting 绘画时仅显示十字线
Show slice numbers 显示切片编号
Show tooltips 显示工具提示
Enable animations 启用动画
Animation effects 动画效果
Use system setting 使用系统设置
On 开启
Show highlight on rollover 鼠标悬停时显示高亮
Show bounding box when dragging layer 拖动图层时显示边界框
System title bar 系统标题栏
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2064
2065
2066
2067
2068
2069
2070
2071
2072
+3 -1
View File
@@ -2036,7 +2036,9 @@
Show only crosshair while painting 繪圖時僅顯示十字線
Show slice numbers 顯示切片編號
Show tooltips 顯示工具提示
Enable animations 啟用動畫
Animation effects 動畫效果
Use system setting 使用系統設定
On 開
Show highlight on rollover 滑鼠懸停時顯示醒目提示
Show bounding box when dragging layer 拖曳圖層時顯示邊界框
System title bar 系統標題列
Can't render this file because it contains an unexpected character in line 6 and column 47.
+101
View File
@@ -489,6 +489,107 @@ 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();
+13
View File
@@ -515,6 +515,10 @@ pub type LoadTextFn = Box<dyn FnMut() -> Option<String>>;
pub type SaveTextFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Platform appearance when egui cannot detect it (for example, Wayland without a theme event).
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// 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<dyn Fn(&egui::Context) -> Option<bool>>;
/// Autosave a document snapshot for crash recovery: (snapshot, revision, original path).
pub type AutosaveFn = Box<dyn FnMut(&std::sync::Arc<Document>, u64, Option<&str>) -> Result<(), String>>;
/// Poll successful or failed background writes: (document id, revision, result).
@@ -630,6 +634,10 @@ pub struct Services {
/// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`).
pub photoshop_shortcuts: Option<PhotoshopShortcutsFn>,
pub system_theme: Option<SystemThemeFn>,
/// 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<SystemAnimationsFn>,
/// 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
@@ -2313,6 +2321,11 @@ 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);
+1 -1
View File
@@ -2089,7 +2089,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, true, touch_gestures)
crate::wheel_nav::read_with(&ctx, true, pinch_zooms, animated_zoom, navigator_wheel, true, !app.prefs_rt.animations_off, touch_gestures)
{
let view = &mut app.ui.views[idx];
let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size);
+125 -19
View File
@@ -52,6 +52,9 @@ pub struct Runtime {
show_rulers: Option<bool>,
/// Tool options as last remembered in the preferences (see `tool_memory`).
pub(crate) tool_options: Option<crate::state::ToolOptions>,
/// 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<crate::state::Tool>,
/// The tool owning the session brush and the other tools' brushes, as last remembered.
pub(crate) tool_brushes: Option<crate::tool_memory::ToolBrushes>,
@@ -481,11 +484,25 @@ fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) {
}
}
/// 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;
/// 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;
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() };
@@ -1336,6 +1353,15 @@ 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]) {
@@ -1873,7 +1899,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, 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<String> = opts.iter().map(|o| choice_label(o)).collect();
let labels: Vec<String> = opts.iter().map(|o| pref_choice_label(&path, 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);
@@ -4059,29 +4085,109 @@ 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 enable_animations_off_stops_fades_and_scroll_glides_and_is_remembered() {
fn animation_effects_off_stops_egui_animations_and_is_remembered() {
let on = egui::Style::default().animation_time;
let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time);
assert!(on > 0.0, "egui animates by default");
// Saved off: the first frame after a restart applies it to both appearance styles.
let (mut app, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string()));
let (mut app, _) = app_with_saved(Some(json!({"interface": {"animationEffects": "off"}}).to_string()));
let ctx = egui::Context::default();
tick(&mut app, &ctx);
assert_eq!(both(&ctx), [0.0, 0.0]);
assert_eq!(animation_times(&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!(both(&ctx), [0.0, 0.0]);
app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).unwrap();
assert_eq!(animation_times(&ctx), [0.0, 0.0]);
app.run("prefs.set", json!({"values": {"interface.animationEffects": "on"}})).unwrap();
tick(&mut app, &ctx);
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]);
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<String> =
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<String, Value>| 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");
}
#[test]
+305 -11
View File
@@ -34,6 +34,10 @@
//! 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};
@@ -187,6 +191,82 @@ 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<Vec2> {
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<Vec2> {
let options = ctx.options(|o| o.input_options);
let id = gesture_id(ctx);
let mut in_gesture = ctx.data(|d| d.get_temp::<bool>(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")
}
@@ -339,9 +419,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 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.
/// [`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.
/// `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
@@ -381,6 +461,9 @@ pub fn read_with(
}
None => ctx.data(|d| d.get_temp::<bool>(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;
@@ -478,12 +561,13 @@ 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 Enable Animations off at once (#5721).
if alt { Vec2::new(0.0, eased) } else { Vec2::new(if smooth { scroll.x } else { raw.x }, eased) }
// 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) }
} else {
ctx.data_mut(|d| d.remove::<ZoomEase>(zoom_ease_id(owner)));
// Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail).
if smooth || alt || shift { scroll } else { raw }
// 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) }
};
classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, animated_zoom, notch })
}
@@ -540,8 +624,6 @@ 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 {
@@ -594,6 +676,14 @@ 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]
@@ -630,7 +720,7 @@ mod tests {
assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows");
}
/// Interface › Enable Animations off only stops the *pan* gliding. A plain wheel that zooms
/// Interface › Animation Effects 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]
@@ -663,6 +753,51 @@ 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<Vec2> {
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))));
@@ -916,7 +1051,7 @@ mod tests {
assert!(frames[1..].iter().all(|f| *f == 1.0), "no tail after it: {frames:?}");
}
/// Zoom with Scroll Wheel + Animated Zoom with Enable Animations off: the wheel zooms eased, but
/// Zoom with Scroll Wheel + Animated Zoom with Animation Effects 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() {
@@ -1220,6 +1355,165 @@ 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<Vec<Event>>, off: bool) -> Vec<Vec2> {
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<f32> {
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] {
+1 -1
View File
@@ -201,7 +201,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.
| UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay |
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet |
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | 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) |
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | 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) |
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | done | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); the light themes outline the slider knob with Tokens::knob_outline (dim text, at least 3:1 against dock, card and chrome; #7927) |
| UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
+1 -1
View File
@@ -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.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)"
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)"
[[item]]
id = "UI-217-11"