mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
Generated
+1
@@ -4780,6 +4780,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 분할 영역 색상
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 系统标题栏
|
||||
|
||||
|
@@ -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.
|
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user