Respect motion settings and improve accessible theme transitions

This commit is contained in:
Samuel Onoja committed 2026-10-01 11:54:57 +01:00
1 parent 53bad8c672
commit 7a9fd5c864
13 files changed
+388 -65

No files matched your search

+68 -26
View File
@@ -44,9 +44,12 @@ impl IdleSettings {
Ok(self) Ok(self)
} }
fn next_poll(self, elapsed: Duration, animate: bool) -> Duration { fn next_poll(self, elapsed: Duration, fade_duration: Duration) -> Duration {
let dim_at = Duration::from_secs(self.dim_after_seconds); let dim_at = Duration::from_secs(self.dim_after_seconds);
if animate && self.dim_after_seconds != 0 && elapsed >= dim_at && elapsed < dim_at + Duration::from_millis(500) if !fade_duration.is_zero()
&& self.dim_after_seconds != 0
&& elapsed >= dim_at
&& elapsed < dim_at + fade_duration
{ {
return Duration::from_millis(16); return Duration::from_millis(16);
} }
@@ -60,16 +63,15 @@ impl IdleSettings {
.min(Duration::from_secs(1)) .min(Duration::from_secs(1))
} }
fn appearance(self, elapsed: Duration) -> (f32, bool) { fn appearance(self, elapsed: Duration, fade_duration: Duration) -> (f32, bool) {
let sleeping = self.sleep_after_seconds != 0 && elapsed >= Duration::from_secs(self.sleep_after_seconds); let sleeping = self.sleep_after_seconds != 0 && elapsed >= Duration::from_secs(self.sleep_after_seconds);
let dim = if self.dim_after_seconds == 0 { let dim_at = Duration::from_secs(self.dim_after_seconds);
let dim = if self.dim_after_seconds == 0 || elapsed < dim_at {
0.0 0.0
} else if fade_duration.is_zero() {
0.65
} else { } else {
elapsed (elapsed.saturating_sub(dim_at).as_secs_f32() / fade_duration.as_secs_f32()).min(1.0) * 0.65
.saturating_sub(Duration::from_secs(self.dim_after_seconds))
.as_secs_f32()
.min(0.5)
* 1.3
}; };
(if sleeping { 1.0 } else { dim }, sleeping) (if sleeping { 1.0 } else { dim }, sleeping)
} }
@@ -129,11 +131,11 @@ impl Ferese {
if !self.session_lock.ready_for_idle(self.space.outputs()) { if !self.session_lock.ready_for_idle(self.space.outputs()) {
return; return;
} }
let fade_duration = self.animation_duration(Duration::from_millis(500));
let since = self.session_lock.idle_since.get_or_insert(now); let since = self.session_lock.idle_since.get_or_insert(now);
let (mut opacity, sleeping) = self.lock_idle.appearance(now.saturating_duration_since(*since)); let (opacity, sleeping) = self
if !self.animations_enabled() && opacity > 0.0 && !sleeping { .lock_idle
opacity = 0.65; .appearance(now.saturating_duration_since(*since), fade_duration);
}
let changed = opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping; let changed = opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping;
let was_sleeping = self.session_lock.sleeping; let was_sleeping = self.session_lock.sleeping;
self.session_lock.idle_opacity = opacity; self.session_lock.idle_opacity = opacity;
@@ -245,7 +247,11 @@ impl SessionLockHandler for Ferese {
.session_lock .session_lock
.idle_since .idle_since
.map_or(Duration::ZERO, |since| since.elapsed()); .map_or(Duration::ZERO, |since| since.elapsed());
TimeoutAction::ToDuration(state.lock_idle.next_poll(elapsed, state.animations_enabled())) TimeoutAction::ToDuration(
state
.lock_idle
.next_poll(elapsed, state.animation_duration(Duration::from_millis(500))),
)
}) { }) {
Ok(token) => self.session_lock.idle_timer = Some(token), Ok(token) => self.session_lock.idle_timer = Some(token),
Err(error) => tracing::warn!(%error, "could not schedule lock screen inactivity"), Err(error) => tracing::warn!(%error, "could not schedule lock screen inactivity"),
@@ -355,24 +361,60 @@ smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSu
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn dim_fade_respects_speed_without_changing_idle_deadlines() {
let policy = IdleSettings::default();
for (duration, half_at) in [(250, 30_125), (1000, 30_500)] {
let fade = Duration::from_millis(duration);
assert_eq!(policy.appearance(Duration::from_secs(29), fade), (0.0, false));
let halfway = policy.appearance(Duration::from_millis(half_at), fade);
assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1);
assert_eq!(
policy.next_poll(Duration::from_millis(half_at), fade),
Duration::from_millis(16)
);
assert_eq!(policy.appearance(Duration::from_secs(120), fade), (1.0, true));
}
assert_eq!(
policy.appearance(Duration::from_secs(30), Duration::ZERO),
(0.65, false)
);
assert_eq!(policy.appearance(Duration::from_secs(120), Duration::ZERO), (1.0, true));
}
#[test] #[test]
fn idle_policy_fades_then_sleeps_and_activity_resets_it() { fn idle_policy_fades_then_sleeps_and_activity_resets_it() {
let policy = IdleSettings::default(); let policy = IdleSettings::default();
assert_eq!(policy.appearance(Duration::from_secs(29)), (0.0, false));
let halfway = policy.appearance(Duration::from_millis(30_250));
assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1);
assert_eq!(policy.appearance(Duration::from_secs(31)), (0.65, false));
assert_eq!(policy.appearance(Duration::from_secs(120)), (1.0, true));
assert_eq!(policy.appearance(Duration::ZERO), (0.0, false));
assert_eq!( assert_eq!(
policy.next_poll(Duration::from_millis(29_998), true), policy.appearance(Duration::from_secs(29), Duration::from_millis(500)),
(0.0, false)
);
let halfway = policy.appearance(Duration::from_millis(30_250), Duration::from_millis(500));
assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1);
assert_eq!(
policy.appearance(Duration::from_secs(31), Duration::from_millis(500)),
(0.65, false)
);
assert_eq!(
policy.appearance(Duration::from_secs(120), Duration::from_millis(500)),
(1.0, true)
);
assert_eq!(
policy.appearance(Duration::ZERO, Duration::from_millis(500)),
(0.0, false)
);
assert_eq!(
policy.next_poll(Duration::from_millis(29_998), Duration::from_millis(500)),
Duration::from_millis(2) Duration::from_millis(2)
); );
assert_eq!( assert_eq!(
policy.next_poll(Duration::from_secs(30), true), policy.next_poll(Duration::from_secs(30), Duration::from_millis(500)),
Duration::from_millis(16) Duration::from_millis(16)
); );
assert_eq!(policy.next_poll(Duration::from_secs(30), false), Duration::from_secs(1)); assert_eq!(
policy.next_poll(Duration::from_secs(30), Duration::ZERO),
Duration::from_secs(1)
);
} }
#[test] #[test]
@@ -427,7 +469,7 @@ mod tests {
dim_after_seconds: 0, dim_after_seconds: 0,
sleep_after_seconds: 0 sleep_after_seconds: 0
} }
.appearance(Duration::from_secs(9999)), .appearance(Duration::from_secs(9999), Duration::from_millis(500)),
(0.0, false) (0.0, false)
); );
assert_eq!( assert_eq!(
@@ -435,7 +477,7 @@ mod tests {
dim_after_seconds: 0, dim_after_seconds: 0,
sleep_after_seconds: 10 sleep_after_seconds: 10
} }
.appearance(Duration::from_secs(10)), .appearance(Duration::from_secs(10), Duration::from_millis(500)),
(1.0, true) (1.0, true)
); );
assert_eq!( assert_eq!(
@@ -443,7 +485,7 @@ mod tests {
dim_after_seconds: 1, dim_after_seconds: 1,
sleep_after_seconds: 0 sleep_after_seconds: 0
} }
.appearance(Duration::from_secs(9999)), .appearance(Duration::from_secs(9999), Duration::from_millis(500)),
(0.65, false) (0.65, false)
); );
assert!( assert!(
+51 -6
View File
@@ -269,12 +269,16 @@ struct ClosingAnimation {
} }
impl ClosingAnimation { impl ClosingAnimation {
fn advance(&mut self, delta: Duration) -> bool { fn advance(&mut self, delta: Duration, animations_enabled: bool) -> bool {
if self.close_sent { if self.close_sent {
return false; return false;
} }
self.progress = (self.progress + delta.as_secs_f64() / CLOSE_ANIMATION_DURATION.as_secs_f64()).min(1.0); self.progress = if animations_enabled {
(self.progress + delta.as_secs_f64() / CLOSE_ANIMATION_DURATION.as_secs_f64()).min(1.0)
} else {
1.0
};
if self.progress < 1.0 { if self.progress < 1.0 {
return false; return false;
} }
@@ -284,6 +288,14 @@ impl ClosingAnimation {
} }
} }
fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> Duration {
if enabled {
duration.div_f64(speed)
} else {
Duration::ZERO
}
}
pub struct Ferese { pub struct Ferese {
pub(crate) session_lock_state: SessionLockManagerState, pub(crate) session_lock_state: SessionLockManagerState,
pub(crate) session_lock: Lock, pub(crate) session_lock: Lock,
@@ -2125,7 +2137,7 @@ impl Ferese {
let mut ready_to_close = Vec::new(); let mut ready_to_close = Vec::new();
for (id, animation) in &mut self.closing_windows { for (id, animation) in &mut self.closing_windows {
if animation.advance(delta) { if animation.advance(delta, self.animations_enabled) {
ready_to_close.push(*id); ready_to_close.push(*id);
} else if !animation.close_sent { } else if !animation.close_sent {
active_animation = true; active_animation = true;
@@ -2321,6 +2333,10 @@ impl Ferese {
self.animations_enabled self.animations_enabled
} }
pub(crate) fn animation_duration(&self, duration: Duration) -> Duration {
scaled_animation_duration(duration, self.animations_enabled, self.animation_speed)
}
pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) {
let Some((window, id)) = self let Some((window, id)) = self
.window_ids .window_ids
@@ -3775,6 +3791,35 @@ impl ClientData for ClientState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn configured_motion_policy_scales_durations_and_reduced_motion_wins() {
for (source, milliseconds) in [
("animations { speed 2; }", 100),
("animations { speed 0.5; }", 400),
("animations { speed 2; reduced-motion #true; }", 0),
("animations { speed 0.5; enabled #false; }", 0),
] {
let config = crate::config::Config::parse_source(source).unwrap();
assert_eq!(
scaled_animation_duration(
Duration::from_millis(200),
config.animations_enabled(),
config.animation_speed().unwrap()
),
Duration::from_millis(milliseconds),
);
}
}
#[test]
fn disabling_motion_finishes_an_in_progress_close_once() {
let mut animation = ClosingAnimation::default();
assert!(!animation.advance(Duration::from_millis(35), true));
assert!(animation.advance(Duration::ZERO, false));
assert_eq!(animation.progress, 1.0);
assert!(!animation.advance(Duration::ZERO, false));
}
#[test] #[test]
fn workspace_drag_cancels_when_focus_or_target_ownership_changes() { fn workspace_drag_cancels_when_focus_or_target_ownership_changes() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
@@ -4101,11 +4146,11 @@ mod tests {
fn close_animation_delays_the_protocol_close_until_it_finishes() { fn close_animation_delays_the_protocol_close_until_it_finishes() {
let mut animation = ClosingAnimation::default(); let mut animation = ClosingAnimation::default();
assert!(!animation.advance(Duration::from_millis(70))); assert!(!animation.advance(Duration::from_millis(70), true));
assert_eq!(animation.progress, 0.5); assert_eq!(animation.progress, 0.5);
assert!(animation.advance(Duration::from_millis(70))); assert!(animation.advance(Duration::from_millis(70), true));
assert!(animation.close_sent); assert!(animation.close_sent);
assert!(!animation.advance(Duration::from_secs(1))); assert!(!animation.advance(Duration::from_secs(1), true));
} }
#[test] #[test]
+38 -4
View File
@@ -24,6 +24,14 @@ use crate::{Ferese, RuntimeConfig};
const DEBOUNCE: Duration = Duration::from_millis(150); const DEBOUNCE: Duration = Duration::from_millis(150);
const WALLPAPER_TIMEOUT: Duration = Duration::from_secs(2); const WALLPAPER_TIMEOUT: Duration = Duration::from_secs(2);
fn transition_progress(elapsed: Duration, duration: Duration) -> f64 {
if duration.is_zero() {
1.0
} else {
(elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0)
}
}
pub(crate) fn read_source(path: &Path) -> Result<String, String> { pub(crate) fn read_source(path: &Path) -> Result<String, String> {
let mut source = String::new(); let mut source = String::new();
std::fs::File::open(path) std::fs::File::open(path)
@@ -474,10 +482,18 @@ impl Ferese {
.as_ref() .as_ref()
.filter(|transition| transition.previous_wallpaper.is_some()) .filter(|transition| transition.previous_wallpaper.is_some())
.map_or(1., |transition| { .map_or(1., |transition| {
(transition.started.elapsed().as_secs_f32() / (TRANSITION_MS as f32 / 1000.)).clamp(0., 1.) transition_progress(transition.started.elapsed(), self.theme_transition_duration()) as f32
}) })
} }
fn theme_transition_duration(&self) -> Duration {
if self.theme_engine.snapshot.theme.reduced_motion {
Duration::ZERO
} else {
self.animation_duration(Duration::from_millis(TRANSITION_MS))
}
}
pub(crate) fn poll_theme(&mut self) { pub(crate) fn poll_theme(&mut self) {
let now = Instant::now(); let now = Instant::now();
if self if self
@@ -535,12 +551,12 @@ impl Ferese {
.theme_engine .theme_engine
.last_frame .last_frame
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_millis(16)); .is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_millis(16));
let duration = self.theme_transition_duration();
if frame_due && let Some(transition) = &self.theme_engine.transition { if frame_due && let Some(transition) = &self.theme_engine.transition {
self.theme_engine.last_frame = Some(now); self.theme_engine.last_frame = Some(now);
let progress = let progress = transition_progress(now.saturating_duration_since(transition.started), duration);
now.saturating_duration_since(transition.started).as_secs_f64() / (TRANSITION_MS as f64 / 1000.);
let target = &self.theme_engine.snapshot.theme; let target = &self.theme_engine.snapshot.theme;
let complete = progress >= 1. || target.reduced_motion; let complete = progress >= 1.;
let presented = if complete { let presented = if complete {
target.clone() target.clone()
} else { } else {
@@ -617,3 +633,21 @@ impl Ferese {
Ok(self.theme_engine.value()) Ok(self.theme_engine.value())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_and_wallpaper_share_scaled_progress_and_instant_completion() {
let elapsed = Duration::from_millis(50);
assert_eq!(transition_progress(elapsed, Duration::from_millis(200)), 0.25);
assert_eq!(transition_progress(elapsed, Duration::from_millis(100)), 0.5);
assert_eq!(transition_progress(elapsed, Duration::from_millis(400)), 0.125);
assert_eq!(transition_progress(Duration::ZERO, Duration::ZERO), 1.0);
assert_eq!(
transition_progress(Duration::from_secs(1), Duration::from_millis(200)),
1.0
);
}
}
+144 -3
View File
@@ -1027,6 +1027,7 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
} }
if accessibility.increase_contrast { if accessibility.increase_contrast {
t.colors.border = hex(readable(rgba(&t.colors.border).unwrap(), surface, 3.)); t.colors.border = hex(readable(rgba(&t.colors.border).unwrap(), surface, 3.));
resolve_material_contrast(t, minimum);
} }
if accessibility.reduce_transparency { if accessibility.reduce_transparency {
t.material.style = "solid".into(); t.material.style = "solid".into();
@@ -1035,6 +1036,44 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
} }
} }
fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
if tokens.material.style == "solid" {
return;
}
let surface = rgba(&tokens.colors.surface_base).unwrap();
let bar = rgba(&tokens.surface.bar.background).unwrap();
let raised = rgba(&tokens.colors.surface_raised).unwrap();
let app = rgba(&tokens.colors.application_background).unwrap();
let initial = tokens.material.opacity * tokens.material.tint_strength;
let readable = |backgrounds: &[[f64; 4]]| {
[[0., 0., 0., 1.], [1.; 4]].into_iter().any(|text| {
backgrounds
.iter()
.all(|background| contrast(text, *background) >= minimum)
})
};
let opacity = (0..=100)
.map(|step| initial + (1. - initial) * f64::from(step) / 100.)
.find(|opacity| {
let bounds = material_bounds(surface, *opacity);
readable(&[bounds[0], bounds[1], raised, app]) && readable(&material_bounds(bar, *opacity))
})
.unwrap_or(1.0);
tokens.material.opacity = tokens.material.opacity.max(opacity);
tokens.material.tint_strength = opacity / tokens.material.opacity.max(f64::EPSILON);
let bounds = material_bounds(surface, opacity);
let body = [bounds[0], bounds[1], raised, app];
let bar = material_bounds(bar, opacity);
for (color, backgrounds) in [
(&mut tokens.colors.text_primary, body.as_slice()),
(&mut tokens.colors.text_muted, body.as_slice()),
(&mut tokens.surface.bar.text_primary, bar.as_slice()),
(&mut tokens.surface.bar.text_muted, bar.as_slice()),
] {
*color = hex(readable_across(rgba(color).unwrap(), backgrounds, minimum));
}
}
fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] { fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] {
surface[3] = opacity; surface[3] = opacity;
[composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])] [composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])]
@@ -1064,9 +1103,12 @@ fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64;
impl ResolvedTheme { impl ResolvedTheme {
/// Bound contrast across all backdrops instead of interpolating foregrounds. /// Bound contrast across all backdrops instead of interpolating foregrounds.
pub fn transition(&self, to: &Self, progress: f64) -> Self { pub fn transition(&self, to: &Self, progress: f64) -> Self {
if to.reduced_motion { if to.reduced_motion || self.accessibility != to.accessibility || progress >= 1.0 {
return to.clone(); return to.clone();
} }
if progress <= 0.0 || self == to {
return self.clone();
}
let p = progress.clamp(0., 1.); let p = progress.clamp(0., 1.);
let p = p * p * (3. - 2. * p); let p = p * p * (3. - 2. * p);
let mut frame = to.clone(); let mut frame = to.clone();
@@ -1089,6 +1131,42 @@ impl ResolvedTheme {
frame.tokens.shadow.soft.opacity = frame.tokens.shadow.soft.opacity =
self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p; self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
let preferred = if p < 0.5 { self } else { to }; let preferred = if p < 0.5 { self } else { to };
frame.appearance = preferred.appearance;
if self.appearance == to.appearance {
let body = [
rgba(&frame.tokens.colors.surface_base).unwrap(),
rgba(&frame.tokens.colors.surface_raised).unwrap(),
rgba(&frame.tokens.colors.application_background).unwrap(),
];
let minimum = if to.accessibility.increase_contrast { 7.0 } else { 4.5 };
frame.tokens.colors.text_primary = hex(readable_across(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
&body,
minimum,
));
frame.tokens.colors.text_muted = hex(readable_across(
rgba(&preferred.tokens.colors.text_muted).unwrap(),
&body,
minimum,
));
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
frame.tokens.surface.bar.text_primary = hex(readable(
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
bar,
minimum,
));
frame.tokens.surface.bar.text_muted = hex(readable(
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
bar,
minimum,
));
frame.tokens.colors.on_accent = hex(readable(
rgba(&preferred.tokens.colors.on_accent).unwrap(),
rgba(&frame.tokens.colors.accent).unwrap(),
4.5,
));
return frame;
}
let base = rgba(&frame.tokens.colors.surface_base).unwrap(); let base = rgba(&frame.tokens.colors.surface_base).unwrap();
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap(); let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap(); let app = rgba(&frame.tokens.colors.application_background).unwrap();
@@ -1108,7 +1186,6 @@ impl ResolvedTheme {
} else { } else {
frame.tokens.material.opacity * frame.tokens.material.tint_strength frame.tokens.material.opacity * frame.tokens.material.tint_strength
}; };
// Glass spanning the neutral midpoint needs more tint to keep text readable.
for step in 0..=100 { for step in 0..=100 {
let candidate = opacity + (1. - opacity) * f64::from(step) / 100.; let candidate = opacity + (1. - opacity) * f64::from(step) / 100.;
let backgrounds = material_bounds(surface, candidate); let backgrounds = material_bounds(surface, candidate);
@@ -1502,7 +1579,7 @@ mod tests {
.theme; .theme;
for (from, to) in [(&dark, &light), (&light, &dark)] { for (from, to) in [(&dark, &light), (&light, &dark)] {
for backdrop in [[0., 0., 0., 1.], [1., 1., 1., 1.], [0.2, 0.4, 0.7, 1.]] { for backdrop in [[0., 0., 0., 1.], [1., 1., 1., 1.], [0.2, 0.4, 0.7, 1.]] {
for step in 0..=20 { for step in 1..20 {
let frame = from.transition(to, f64::from(step) / 20.); let frame = from.transition(to, f64::from(step) / 20.);
let mut surface = rgba(&frame.tokens.colors.surface_base).unwrap(); let mut surface = rgba(&frame.tokens.colors.surface_base).unwrap();
surface[3] = frame.tokens.material.opacity * frame.tokens.material.tint_strength; surface[3] = frame.tokens.material.opacity * frame.tokens.material.tint_strength;
@@ -1546,6 +1623,70 @@ mod tests {
} }
} }
#[test]
fn high_contrast_covers_translucent_surfaces() {
for mode in ["light", "dark"] {
let resolved = candidate(&format!(r#"theme {{ family "tokyo-night"; mode "{mode}"; material {{ style "translucent"; opacity 0.52; }}; accessibility {{ increase-contrast #true; }}; }}"#)).unwrap().theme;
let opacity = resolved.tokens.material.opacity * resolved.tokens.material.tint_strength;
for (background, primary, muted) in [
(
&resolved.tokens.colors.surface_base,
&resolved.tokens.colors.text_primary,
&resolved.tokens.colors.text_muted,
),
(
&resolved.tokens.surface.bar.background,
&resolved.tokens.surface.bar.text_primary,
&resolved.tokens.surface.bar.text_muted,
),
] {
for backdrop in material_bounds(rgba(background).unwrap(), opacity) {
for text in [primary, muted] {
assert!(contrast(rgba(text).unwrap(), backdrop) >= 7.0);
}
}
}
}
}
#[test]
fn transitions_preserve_endpoints_and_never_restyle_an_unchanged_theme() {
let from = candidate(
r#"theme { family "tokyo-night"; mode "dark"; material { style "translucent"; opacity 0.52; }; }"#,
)
.unwrap()
.theme;
let to =
candidate(r#"theme { family "gruvbox"; mode "dark"; material { style "translucent"; opacity 0.52; }; }"#)
.unwrap()
.theme;
assert_eq!(from.transition(&to, 0.0), from);
assert_eq!(from.transition(&to, 1.0), to);
for step in 0..=20 {
let p = f64::from(step) / 20.0;
assert_eq!(from.transition(&from, p), from);
let frame = from.transition(&to, p);
assert_eq!(frame.tokens.material.opacity, from.tokens.material.opacity);
let low = from.tokens.material.tint_strength.min(to.tokens.material.tint_strength);
let high = from.tokens.material.tint_strength.max(to.tokens.material.tint_strength);
assert!((low..=high).contains(&frame.tokens.material.tint_strength));
}
}
#[test]
fn accessibility_changes_publish_the_resolved_theme_without_a_transient_palette() {
let from = candidate(r#"theme { family "tokyo-night"; mode "dark"; }"#)
.unwrap()
.theme;
let to =
candidate(r#"theme { family "tokyo-night"; mode "dark"; accessibility { increase-contrast #true; }; }"#)
.unwrap()
.theme;
for step in 0..=20 {
assert_eq!(from.transition(&to, f64::from(step) / 20.0), to);
}
}
#[test] #[test]
fn reduced_motion_cuts_and_policy_does_not_follow_brightness() { fn reduced_motion_cuts_and_policy_does_not_follow_brightness() {
let mut light = candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }") let mut light = candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }")
+1
View File
@@ -302,6 +302,7 @@ mod tests {
let palette = Palette { let palette = Palette {
appearance: ferese_config::theme::Appearance::Dark, appearance: ferese_config::theme::Appearance::Dark,
high_contrast: false,
background: Color::from_rgb8(17, 24, 33), background: Color::from_rgb8(17, 24, 33),
sidebar: Color::from_rgb8(17, 24, 33), sidebar: Color::from_rgb8(17, 24, 33),
card: Color::from_rgb8(17, 24, 33), card: Color::from_rgb8(17, 24, 33),
+26 -4
View File
@@ -3,6 +3,7 @@ use cosmic::iced::Color;
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette { pub struct Palette {
pub appearance: ferese_config::theme::Appearance, pub appearance: ferese_config::theme::Appearance,
pub high_contrast: bool,
pub background: Color, pub background: Color,
pub sidebar: Color, pub sidebar: Color,
pub card: Color, pub card: Color,
@@ -19,6 +20,7 @@ impl Palette {
let color = |value: &str| parse_color(value).expect("validated resolved theme color"); let color = |value: &str| parse_color(value).expect("validated resolved theme color");
Self { Self {
appearance: theme.appearance, appearance: theme.appearance,
high_contrast: theme.accessibility.increase_contrast,
background: color(&theme.tokens.colors.application_background), background: color(&theme.tokens.colors.application_background),
sidebar: color(&theme.tokens.colors.surface_base), sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised), card: color(&theme.tokens.colors.surface_raised),
@@ -43,10 +45,13 @@ impl Palette {
pub fn native_theme(self) -> cosmic::Theme { pub fn native_theme(self) -> cosmic::Theme {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.); let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
let builder = if self.appearance == ferese_config::theme::Appearance::Light { use cosmic::cosmic_theme::ThemeBuilder;
cosmic::cosmic_theme::ThemeBuilder::light() use ferese_config::theme::Appearance;
} else { let builder = match (self.appearance, self.high_contrast) {
cosmic::cosmic_theme::ThemeBuilder::dark() (Appearance::Light, false) => ThemeBuilder::light(),
(Appearance::Dark, false) => ThemeBuilder::dark(),
(Appearance::Light, true) => ThemeBuilder::light_high_contrast(),
(Appearance::Dark, true) => ThemeBuilder::dark_high_contrast(),
}; };
let corners = cosmic::cosmic_theme::CornerRadii { let corners = cosmic::cosmic_theme::CornerRadii {
radius_xs: [self.radius.min(4.); 4], radius_xs: [self.radius.min(4.); 4],
@@ -108,6 +113,23 @@ pub fn surface_shade(base: Color) -> Color {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn native_theme_preserves_the_resolved_accessibility_mode() {
for appearance in [
ferese_config::theme::Appearance::Light,
ferese_config::theme::Appearance::Dark,
] {
for high_contrast in [false, true] {
let mut resolved = ferese_config::theme::ResolvedTheme::default();
resolved.appearance = appearance;
resolved.accessibility.increase_contrast = high_contrast;
let palette = Palette::from_resolved(&resolved);
assert_eq!(palette.high_contrast, high_contrast);
assert_eq!(palette.native_theme().cosmic().is_high_contrast, high_contrast);
}
}
}
#[test] #[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() { fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS { for preset in crate::PRESETS {
+7 -4
View File
@@ -53,11 +53,12 @@ and application backgrounds are separate colors. Selected mode,
accessibility inputs, requested accent, adjusted accent, warnings, schema version, accessibility inputs, requested accent, adjusted accent, warnings, schema version,
and revision accompany it. Consumers do not infer appearance from brightness. and revision accompany it. Consumers do not infer appearance from brightness.
Material tokens differ by appearance. Light glass uses full tint strength and Material tokens differ by appearance. Light translucent surfaces use full tint strength and
0.94 default opacity to stay pale over dark wallpapers; dark glass uses a softer 0.94 default opacity to stay pale over dark wallpapers; dark surfaces use a softer
0.5 tint. Shared material overrides apply to both unless the appearance overrides 0.5 tint. Shared material overrides apply to both unless the appearance overrides
them. Reduce Transparency resolves to solid them. Reduce Transparency resolves to solid
material with zero blur. Increase Contrast transforms text and border colors. material with zero blur. Increase Contrast adjusts text, borders, and translucent
surface tint for 7:1 text contrast, and enables native high-contrast controls.
Accents are adjusted against surfaces independently for each appearance, then Accents are adjusted against surfaces independently for each appearance, then
on-accent text is selected for readability. on-accent text is selected for readability.
@@ -73,7 +74,9 @@ replacement, and realtime clock jumps trigger recalculation without polling.
Prepare the incoming wallpaper before starting a 250 ms eased transition. Decode Prepare the incoming wallpaper before starting a 250 ms eased transition. Decode
or upload failures and a bounded readiness timeout retain the old wallpaper and or upload failures and a bounded readiness timeout retain the old wallpaper and
allow the theme change. Reduced motion switches immediately. allow the theme change. Reduced motion and accessibility changes apply immediately.
Transitions preserve their exact resolved endpoints. Changes within one appearance
keep the resolved material settings instead of adding temporary tint corrections.
Surface, tint, border, shadow, and accent colors can transition. Fonts and geometry Surface, tint, border, shadow, and accent colors can transition. Fonts and geometry
switch atomically. Foregrounds remain readable across dark and light backdrops. Near the neutral switch atomically. Foregrounds remain readable across dark and light backdrops. Near the neutral
+3
View File
@@ -5,6 +5,7 @@ use ferese_config::Document;
#[derive(Clone)] #[derive(Clone)]
pub struct Appearance { pub struct Appearance {
pub appearance: ferese_config::theme::Appearance, pub appearance: ferese_config::theme::Appearance,
pub high_contrast: bool,
pub dim: f32, pub dim: f32,
pub show_clock: bool, pub show_clock: bool,
pub show_date: bool, pub show_date: bool,
@@ -86,6 +87,7 @@ impl Appearance {
}; };
Self { Self {
appearance: theme.appearance, appearance: theme.appearance,
high_contrast: theme.accessibility.increase_contrast,
dim: doc dim: doc
.as_ref() .as_ref()
.and_then(|d| d.get("lock_screen.background_dim")) .and_then(|d| d.get("lock_screen.background_dim"))
@@ -117,6 +119,7 @@ impl Appearance {
pub fn theme(&self) -> cosmic::Theme { pub fn theme(&self) -> cosmic::Theme {
ferese_theme::Palette { ferese_theme::Palette {
appearance: self.appearance, appearance: self.appearance,
high_contrast: self.high_contrast,
background: self.panel, background: self.panel,
sidebar: self.panel, sidebar: self.panel,
card: self.panel, card: self.panel,
+3
View File
@@ -14,6 +14,7 @@ use crate::{AuthenticationRequest, Generation, PromptEvent};
#[derive(Clone)] #[derive(Clone)]
struct Appearance { struct Appearance {
appearance: ferese_config::theme::Appearance, appearance: ferese_config::theme::Appearance,
high_contrast: bool,
surface: Color, surface: Color,
text: Color, text: Color,
muted: Color, muted: Color,
@@ -34,6 +35,7 @@ impl Appearance {
let family = &theme.tokens.typography.font_family; let family = &theme.tokens.typography.font_family;
Self { Self {
appearance: theme.appearance, appearance: theme.appearance,
high_contrast: theme.accessibility.increase_contrast,
surface: Color { surface: Color {
a: ferese_theme::service::opacity(theme), a: ferese_theme::service::opacity(theme),
..palette.sidebar ..palette.sidebar
@@ -50,6 +52,7 @@ impl Appearance {
fn palette(&self) -> ferese_theme::Palette { fn palette(&self) -> ferese_theme::Palette {
ferese_theme::Palette { ferese_theme::Palette {
appearance: self.appearance, appearance: self.appearance,
high_contrast: self.high_contrast,
background: self.surface, background: self.surface,
sidebar: self.surface, sidebar: self.surface,
card: self.surface, card: self.surface,
+9 -2
View File
@@ -42,6 +42,7 @@ impl Default for StatusConfig {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme { pub(crate) struct ShellTheme {
pub(crate) appearance: ferese_config::theme::Appearance, pub(crate) appearance: ferese_config::theme::Appearance,
pub(crate) high_contrast: bool,
pub(crate) bar_background: [u8; 4], pub(crate) bar_background: [u8; 4],
pub(crate) bar_text_primary: [u8; 4], pub(crate) bar_text_primary: [u8; 4],
pub(crate) bar_text_muted: [u8; 4], pub(crate) bar_text_muted: [u8; 4],
@@ -70,6 +71,7 @@ impl Default for ShellTheme {
fn default() -> Self { fn default() -> Self {
Self { Self {
appearance: Default::default(), appearance: Default::default(),
high_contrast: false,
bar_background: [28, 32, 46, 255], bar_background: [28, 32, 46, 255],
bar_text_primary: [240, 243, 250, 255], bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255], bar_text_muted: [170, 180, 199, 255],
@@ -102,6 +104,7 @@ impl ShellTheme {
let surface = color(self.surface_base); let surface = color(self.surface_base);
ferese_theme::Palette { ferese_theme::Palette {
appearance: self.appearance, appearance: self.appearance,
high_contrast: self.high_contrast,
background: surface, background: surface,
sidebar: surface, sidebar: surface,
card: surface, card: surface,
@@ -339,11 +342,13 @@ pub(crate) fn load() -> ShellConfig {
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> { pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
let document = ferese_config::Document::parse(source)?; let document = ferese_config::Document::parse(source)?;
let snapshot = ferese_theme::service::current(); let snapshot = ferese_theme::service::current();
parse_document( let mut config = parse_document(
&document.with_theme(&snapshot.presented), &document.with_theme(&snapshot.presented),
snapshot.presented.appearance, snapshot.presented.appearance,
snapshot.mode, snapshot.mode,
) )?;
config.apply_theme(&snapshot.presented);
Ok(config)
} }
fn parse_document( fn parse_document(
@@ -394,6 +399,7 @@ impl ShellConfig {
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap(); let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
self.theme = shell_theme(&config); self.theme = shell_theme(&config);
self.theme.appearance = theme.appearance; self.theme.appearance = theme.appearance;
self.theme.high_contrast = theme.accessibility.increase_contrast;
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32; self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
self.theme.bar_radius = self.theme.material_radius; self.theme.bar_radius = self.theme.material_radius;
self.font_family = Some(theme.tokens.typography.font_family.clone()); self.font_family = Some(theme.tokens.typography.font_family.clone());
@@ -422,6 +428,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
ShellTheme { ShellTheme {
appearance: defaults.appearance, appearance: defaults.appearance,
high_contrast: defaults.high_contrast,
material_radius: defaults.material_radius, material_radius: defaults.material_radius,
bar_background: { bar_background: {
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background); let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
+6 -3
View File
@@ -874,11 +874,14 @@ impl FereseShell {
} }
motion::configure(config.animations, config.theme.material_radius); motion::configure(config.animations, config.theme.material_radius);
if self.config.animations != config.animations if self.config.animations != config.animations {
&& let Some(menu) = &mut self.menu if let Some(menu) = &mut self.menu {
{
menu.motion.update_settings(config.animations); menu.motion.update_settings(config.animations);
} }
if let Some(modal) = &mut self.system_modal {
modal.motion.update_settings(config.animations);
}
}
self.status_service self.status_service
.update_settings(config.status.settings_command.clone()); .update_settings(config.status.settings_command.clone());
+24 -3
View File
@@ -76,8 +76,10 @@ impl PopupMotion {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
self.start = self.progress_at(now); self.start = self.progress_at(now);
self.settings = settings; self.settings = settings;
if self.started.is_some() {
self.started = Some(now); self.started = Some(now);
} }
}
pub(crate) fn new(settings: Settings) -> Self { pub(crate) fn new(settings: Settings) -> Self {
Self { Self {
@@ -95,13 +97,13 @@ impl PopupMotion {
} }
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let Some(started) = self.started else {
return self.start;
};
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 }); let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() { if duration.is_zero() {
return self.target; return self.target;
} }
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0); let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0);
if t == 1.0 { if t == 1.0 {
return self.target; return self.target;
@@ -422,6 +424,11 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
) { ) {
let v = self.translation(); let v = self.translation();
let transform = Transformation::translate(v.x, v.y); let transform = Transformation::translate(v.x, v.y);
// Layout/region collection still run before the surface starts opening.
// No child may paint an opaque control before the presentation begins.
if self.progress <= 0.0 {
return;
}
renderer.with_transformation(transform, |renderer| { renderer.with_transformation(transform, |renderer| {
self.content self.content
.as_widget() .as_widget()
@@ -556,12 +563,26 @@ mod tests {
}, },
] { ] {
let mut motion = PopupMotion::new(settings); let mut motion = PopupMotion::new(settings);
assert_eq!(motion.progress_at(now), 0.0);
motion.begin(now);
assert_eq!(motion.progress_at(now), 1.0); assert_eq!(motion.progress_at(now), 1.0);
motion.retarget(0.0, now); motion.retarget(0.0, now);
assert_eq!(motion.progress_at(now), 0.0); assert_eq!(motion.progress_at(now), 0.0);
} }
} }
#[test]
fn updating_motion_policy_preserves_surface_readiness() {
let mut motion = PopupMotion::new(Settings::default());
motion.update_settings(Settings {
reduced_motion: true,
..Settings::default()
});
assert_eq!(motion.progress(), 0.0);
motion.begin(std::time::Instant::now());
assert_eq!(motion.progress(), 1.0);
}
#[test] #[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() { fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
+8 -10
View File
@@ -242,13 +242,15 @@ impl FereseShell {
let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else { let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else {
return; return;
}; };
modal.motion.begin(Instant::now());
if entry.primary && entry.effects.is_none() { if entry.primary && entry.effects.is_none() {
match EffectsBinding::attach_role(surface, None, 0.0) { match EffectsBinding::attach_role(surface, None, 0.0) {
Ok(effects) => entry.effects = Some(effects), Ok(effects) => entry.effects = Some(effects),
Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"), Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"),
} }
} }
if entry.primary {
modal.motion.begin(Instant::now());
}
} }
pub(super) fn update_power_materials(&self) { pub(super) fn update_power_materials(&self) {
@@ -422,13 +424,8 @@ impl FereseShell {
let content: Element<'_, cosmic::Action<Message>> = if surface.primary { let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
let theme = self.config.theme; let theme = self.config.theme;
let material = surface.effects.is_some(); let material = surface.effects.is_some();
let alpha = if material { 1.0 } else { progress }; // Opacity belongs to the presented surface, never to semantic colors.
let mut palette = theme.palette(); let palette = theme.palette();
palette.text = palette.text.scale_alpha(alpha);
palette.muted = palette.muted.scale_alpha(alpha);
palette.sidebar = palette.sidebar.scale_alpha(alpha);
palette.card = palette.card.scale_alpha(alpha);
palette.accent = palette.accent.scale_alpha(alpha);
let rows = match &modal.content { let rows = match &modal.content {
Content::Power(action) => { Content::Power(action) => {
let mut rows = column![ let mut rows = column![
@@ -526,7 +523,7 @@ impl FereseShell {
.padding(24) .padding(24)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
background: (!material) background: (!material)
.then_some(Background::Color(color_with_opacity(theme.surface_popover, alpha))), .then_some(Background::Color(color_with_opacity(theme.surface_popover, 1.0))),
text_color: Some(palette.text), text_color: Some(palette.text),
icon_color: Some(palette.text), icon_color: Some(palette.text),
border: Border { border: Border {
@@ -550,6 +547,7 @@ impl FereseShell {
} else { } else {
content content
}; };
let scrim_alpha = if surface.effects.is_some() { 1.0 } else { progress };
let centered = container(content) let centered = container(content)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -557,7 +555,7 @@ impl FereseShell {
.align_x(cosmic::iced::Alignment::Center) .align_x(cosmic::iced::Alignment::Center)
.align_y(cosmic::iced::Alignment::Center) .align_y(cosmic::iced::Alignment::Center)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.34 * progress))), background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.34 * scrim_alpha))),
..Default::default() ..Default::default()
})); }));
centered.into() centered.into()