Respect motion settings and improve accessible theme transitions
This commit is contained in:
1 parent
53bad8c672
commit
7a9fd5c864
13 files changed
+388
-65
No files matched your search
@@ -44,9 +44,12 @@ impl IdleSettings {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -60,16 +63,15 @@ impl IdleSettings {
|
||||
.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 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
|
||||
} else if fade_duration.is_zero() {
|
||||
0.65
|
||||
} else {
|
||||
elapsed
|
||||
.saturating_sub(Duration::from_secs(self.dim_after_seconds))
|
||||
.as_secs_f32()
|
||||
.min(0.5)
|
||||
* 1.3
|
||||
(elapsed.saturating_sub(dim_at).as_secs_f32() / fade_duration.as_secs_f32()).min(1.0) * 0.65
|
||||
};
|
||||
(if sleeping { 1.0 } else { dim }, sleeping)
|
||||
}
|
||||
@@ -129,11 +131,11 @@ impl Ferese {
|
||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
||||
return;
|
||||
}
|
||||
let fade_duration = self.animation_duration(Duration::from_millis(500));
|
||||
let since = self.session_lock.idle_since.get_or_insert(now);
|
||||
let (mut opacity, sleeping) = self.lock_idle.appearance(now.saturating_duration_since(*since));
|
||||
if !self.animations_enabled() && opacity > 0.0 && !sleeping {
|
||||
opacity = 0.65;
|
||||
}
|
||||
let (opacity, sleeping) = self
|
||||
.lock_idle
|
||||
.appearance(now.saturating_duration_since(*since), fade_duration);
|
||||
let changed = opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping;
|
||||
let was_sleeping = self.session_lock.sleeping;
|
||||
self.session_lock.idle_opacity = opacity;
|
||||
@@ -245,7 +247,11 @@ impl SessionLockHandler for Ferese {
|
||||
.session_lock
|
||||
.idle_since
|
||||
.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),
|
||||
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 {
|
||||
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]
|
||||
fn idle_policy_fades_then_sleeps_and_activity_resets_it() {
|
||||
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!(
|
||||
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)
|
||||
);
|
||||
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)
|
||||
);
|
||||
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]
|
||||
@@ -427,7 +469,7 @@ mod tests {
|
||||
dim_after_seconds: 0,
|
||||
sleep_after_seconds: 0
|
||||
}
|
||||
.appearance(Duration::from_secs(9999)),
|
||||
.appearance(Duration::from_secs(9999), Duration::from_millis(500)),
|
||||
(0.0, false)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -435,7 +477,7 @@ mod tests {
|
||||
dim_after_seconds: 0,
|
||||
sleep_after_seconds: 10
|
||||
}
|
||||
.appearance(Duration::from_secs(10)),
|
||||
.appearance(Duration::from_secs(10), Duration::from_millis(500)),
|
||||
(1.0, true)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -443,7 +485,7 @@ mod tests {
|
||||
dim_after_seconds: 1,
|
||||
sleep_after_seconds: 0
|
||||
}
|
||||
.appearance(Duration::from_secs(9999)),
|
||||
.appearance(Duration::from_secs(9999), Duration::from_millis(500)),
|
||||
(0.65, false)
|
||||
);
|
||||
assert!(
|
||||
|
||||
+51
-6
@@ -269,12 +269,16 @@ struct ClosingAnimation {
|
||||
}
|
||||
|
||||
impl ClosingAnimation {
|
||||
fn advance(&mut self, delta: Duration) -> bool {
|
||||
fn advance(&mut self, delta: Duration, animations_enabled: bool) -> bool {
|
||||
if self.close_sent {
|
||||
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 {
|
||||
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(crate) session_lock_state: SessionLockManagerState,
|
||||
pub(crate) session_lock: Lock,
|
||||
@@ -2125,7 +2137,7 @@ impl Ferese {
|
||||
|
||||
let mut ready_to_close = Vec::new();
|
||||
for (id, animation) in &mut self.closing_windows {
|
||||
if animation.advance(delta) {
|
||||
if animation.advance(delta, self.animations_enabled) {
|
||||
ready_to_close.push(*id);
|
||||
} else if !animation.close_sent {
|
||||
active_animation = true;
|
||||
@@ -2321,6 +2333,10 @@ impl Ferese {
|
||||
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) {
|
||||
let Some((window, id)) = self
|
||||
.window_ids
|
||||
@@ -3775,6 +3791,35 @@ impl ClientData for ClientState {
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn workspace_drag_cancels_when_focus_or_target_ownership_changes() {
|
||||
let mut outputs = OutputWorkspaceMap::default();
|
||||
@@ -4101,11 +4146,11 @@ mod tests {
|
||||
fn close_animation_delays_the_protocol_close_until_it_finishes() {
|
||||
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!(animation.advance(Duration::from_millis(70)));
|
||||
assert!(animation.advance(Duration::from_millis(70), true));
|
||||
assert!(animation.close_sent);
|
||||
assert!(!animation.advance(Duration::from_secs(1)));
|
||||
assert!(!animation.advance(Duration::from_secs(1), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+38
-4
@@ -24,6 +24,14 @@ use crate::{Ferese, RuntimeConfig};
|
||||
const DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
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> {
|
||||
let mut source = String::new();
|
||||
std::fs::File::open(path)
|
||||
@@ -474,10 +482,18 @@ impl Ferese {
|
||||
.as_ref()
|
||||
.filter(|transition| transition.previous_wallpaper.is_some())
|
||||
.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) {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
@@ -535,12 +551,12 @@ impl Ferese {
|
||||
.theme_engine
|
||||
.last_frame
|
||||
.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 {
|
||||
self.theme_engine.last_frame = Some(now);
|
||||
let progress =
|
||||
now.saturating_duration_since(transition.started).as_secs_f64() / (TRANSITION_MS as f64 / 1000.);
|
||||
let progress = transition_progress(now.saturating_duration_since(transition.started), duration);
|
||||
let target = &self.theme_engine.snapshot.theme;
|
||||
let complete = progress >= 1. || target.reduced_motion;
|
||||
let complete = progress >= 1.;
|
||||
let presented = if complete {
|
||||
target.clone()
|
||||
} else {
|
||||
@@ -617,3 +633,21 @@ impl Ferese {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1027,6 +1027,7 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
|
||||
}
|
||||
if accessibility.increase_contrast {
|
||||
t.colors.border = hex(readable(rgba(&t.colors.border).unwrap(), surface, 3.));
|
||||
resolve_material_contrast(t, minimum);
|
||||
}
|
||||
if accessibility.reduce_transparency {
|
||||
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] {
|
||||
surface[3] = opacity;
|
||||
[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 {
|
||||
/// Bound contrast across all backdrops instead of interpolating foregrounds.
|
||||
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();
|
||||
}
|
||||
if progress <= 0.0 || self == to {
|
||||
return self.clone();
|
||||
}
|
||||
let p = progress.clamp(0., 1.);
|
||||
let p = p * p * (3. - 2. * p);
|
||||
let mut frame = to.clone();
|
||||
@@ -1089,6 +1131,42 @@ impl ResolvedTheme {
|
||||
frame.tokens.shadow.soft.opacity =
|
||||
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 };
|
||||
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 raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
|
||||
let app = rgba(&frame.tokens.colors.application_background).unwrap();
|
||||
@@ -1108,7 +1186,6 @@ impl ResolvedTheme {
|
||||
} else {
|
||||
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 {
|
||||
let candidate = opacity + (1. - opacity) * f64::from(step) / 100.;
|
||||
let backgrounds = material_bounds(surface, candidate);
|
||||
@@ -1502,7 +1579,7 @@ mod tests {
|
||||
.theme;
|
||||
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 step in 0..=20 {
|
||||
for step in 1..20 {
|
||||
let frame = from.transition(to, f64::from(step) / 20.);
|
||||
let mut surface = rgba(&frame.tokens.colors.surface_base).unwrap();
|
||||
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]
|
||||
fn reduced_motion_cuts_and_policy_does_not_follow_brightness() {
|
||||
let mut light = candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }")
|
||||
|
||||
@@ -302,6 +302,7 @@ mod tests {
|
||||
|
||||
let palette = Palette {
|
||||
appearance: ferese_config::theme::Appearance::Dark,
|
||||
high_contrast: false,
|
||||
background: Color::from_rgb8(17, 24, 33),
|
||||
sidebar: Color::from_rgb8(17, 24, 33),
|
||||
card: Color::from_rgb8(17, 24, 33),
|
||||
|
||||
@@ -3,6 +3,7 @@ use cosmic::iced::Color;
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Palette {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub high_contrast: bool,
|
||||
pub background: Color,
|
||||
pub sidebar: Color,
|
||||
pub card: Color,
|
||||
@@ -19,6 +20,7 @@ impl Palette {
|
||||
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
high_contrast: theme.accessibility.increase_contrast,
|
||||
background: color(&theme.tokens.colors.application_background),
|
||||
sidebar: color(&theme.tokens.colors.surface_base),
|
||||
card: color(&theme.tokens.colors.surface_raised),
|
||||
@@ -43,10 +45,13 @@ impl Palette {
|
||||
|
||||
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 builder = if self.appearance == ferese_config::theme::Appearance::Light {
|
||||
cosmic::cosmic_theme::ThemeBuilder::light()
|
||||
} else {
|
||||
cosmic::cosmic_theme::ThemeBuilder::dark()
|
||||
use cosmic::cosmic_theme::ThemeBuilder;
|
||||
use ferese_config::theme::Appearance;
|
||||
let builder = match (self.appearance, self.high_contrast) {
|
||||
(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 {
|
||||
radius_xs: [self.radius.min(4.); 4],
|
||||
@@ -108,6 +113,23 @@ pub fn surface_shade(base: Color) -> Color {
|
||||
mod tests {
|
||||
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]
|
||||
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
||||
for preset in crate::PRESETS {
|
||||
|
||||
@@ -53,11 +53,12 @@ and application backgrounds are separate colors. Selected mode,
|
||||
accessibility inputs, requested accent, adjusted accent, warnings, schema version,
|
||||
and revision accompany it. Consumers do not infer appearance from brightness.
|
||||
|
||||
Material tokens differ by appearance. Light glass uses full tint strength and
|
||||
0.94 default opacity to stay pale over dark wallpapers; dark glass uses a softer
|
||||
Material tokens differ by appearance. Light translucent surfaces use full tint strength and
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
switch atomically. Foregrounds remain readable across dark and light backdrops. Near the neutral
|
||||
|
||||
@@ -5,6 +5,7 @@ use ferese_config::Document;
|
||||
#[derive(Clone)]
|
||||
pub struct Appearance {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub high_contrast: bool,
|
||||
pub dim: f32,
|
||||
pub show_clock: bool,
|
||||
pub show_date: bool,
|
||||
@@ -86,6 +87,7 @@ impl Appearance {
|
||||
};
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
high_contrast: theme.accessibility.increase_contrast,
|
||||
dim: doc
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("lock_screen.background_dim"))
|
||||
@@ -117,6 +119,7 @@ impl Appearance {
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
high_contrast: self.high_contrast,
|
||||
background: self.panel,
|
||||
sidebar: self.panel,
|
||||
card: self.panel,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{AuthenticationRequest, Generation, PromptEvent};
|
||||
#[derive(Clone)]
|
||||
struct Appearance {
|
||||
appearance: ferese_config::theme::Appearance,
|
||||
high_contrast: bool,
|
||||
surface: Color,
|
||||
text: Color,
|
||||
muted: Color,
|
||||
@@ -34,6 +35,7 @@ impl Appearance {
|
||||
let family = &theme.tokens.typography.font_family;
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
high_contrast: theme.accessibility.increase_contrast,
|
||||
surface: Color {
|
||||
a: ferese_theme::service::opacity(theme),
|
||||
..palette.sidebar
|
||||
@@ -50,6 +52,7 @@ impl Appearance {
|
||||
fn palette(&self) -> ferese_theme::Palette {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
high_contrast: self.high_contrast,
|
||||
background: self.surface,
|
||||
sidebar: self.surface,
|
||||
card: self.surface,
|
||||
|
||||
@@ -42,6 +42,7 @@ impl Default for StatusConfig {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ShellTheme {
|
||||
pub(crate) appearance: ferese_config::theme::Appearance,
|
||||
pub(crate) high_contrast: bool,
|
||||
pub(crate) bar_background: [u8; 4],
|
||||
pub(crate) bar_text_primary: [u8; 4],
|
||||
pub(crate) bar_text_muted: [u8; 4],
|
||||
@@ -70,6 +71,7 @@ impl Default for ShellTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
appearance: Default::default(),
|
||||
high_contrast: false,
|
||||
bar_background: [28, 32, 46, 255],
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
@@ -102,6 +104,7 @@ impl ShellTheme {
|
||||
let surface = color(self.surface_base);
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
high_contrast: self.high_contrast,
|
||||
background: surface,
|
||||
sidebar: surface,
|
||||
card: surface,
|
||||
@@ -339,11 +342,13 @@ pub(crate) fn load() -> ShellConfig {
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
let snapshot = ferese_theme::service::current();
|
||||
parse_document(
|
||||
let mut config = parse_document(
|
||||
&document.with_theme(&snapshot.presented),
|
||||
snapshot.presented.appearance,
|
||||
snapshot.mode,
|
||||
)
|
||||
)?;
|
||||
config.apply_theme(&snapshot.presented);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn parse_document(
|
||||
@@ -394,6 +399,7 @@ impl ShellConfig {
|
||||
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
|
||||
self.theme = shell_theme(&config);
|
||||
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.bar_radius = self.theme.material_radius;
|
||||
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
||||
@@ -422,6 +428,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
|
||||
ShellTheme {
|
||||
appearance: defaults.appearance,
|
||||
high_contrast: defaults.high_contrast,
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
|
||||
@@ -874,11 +874,14 @@ impl FereseShell {
|
||||
}
|
||||
motion::configure(config.animations, config.theme.material_radius);
|
||||
|
||||
if self.config.animations != config.animations
|
||||
&& let Some(menu) = &mut self.menu
|
||||
{
|
||||
if self.config.animations != config.animations {
|
||||
if let Some(menu) = &mut self.menu {
|
||||
menu.motion.update_settings(config.animations);
|
||||
}
|
||||
if let Some(modal) = &mut self.system_modal {
|
||||
modal.motion.update_settings(config.animations);
|
||||
}
|
||||
}
|
||||
self.status_service
|
||||
.update_settings(config.status.settings_command.clone());
|
||||
|
||||
|
||||
@@ -76,8 +76,10 @@ impl PopupMotion {
|
||||
let now = std::time::Instant::now();
|
||||
self.start = self.progress_at(now);
|
||||
self.settings = settings;
|
||||
if self.started.is_some() {
|
||||
self.started = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new(settings: Settings) -> Self {
|
||||
Self {
|
||||
@@ -95,13 +97,13 @@ impl PopupMotion {
|
||||
}
|
||||
|
||||
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 });
|
||||
if duration.is_zero() {
|
||||
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);
|
||||
if t == 1.0 {
|
||||
return self.target;
|
||||
@@ -422,6 +424,11 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
) {
|
||||
let v = self.translation();
|
||||
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| {
|
||||
self.content
|
||||
.as_widget()
|
||||
@@ -556,12 +563,26 @@ mod tests {
|
||||
},
|
||||
] {
|
||||
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);
|
||||
motion.retarget(0.0, now);
|
||||
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]
|
||||
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
@@ -242,13 +242,15 @@ impl FereseShell {
|
||||
let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else {
|
||||
return;
|
||||
};
|
||||
modal.motion.begin(Instant::now());
|
||||
if entry.primary && entry.effects.is_none() {
|
||||
match EffectsBinding::attach_role(surface, None, 0.0) {
|
||||
Ok(effects) => entry.effects = Some(effects),
|
||||
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) {
|
||||
@@ -422,13 +424,8 @@ impl FereseShell {
|
||||
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
|
||||
let theme = self.config.theme;
|
||||
let material = surface.effects.is_some();
|
||||
let alpha = if material { 1.0 } else { progress };
|
||||
let mut 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);
|
||||
// Opacity belongs to the presented surface, never to semantic colors.
|
||||
let palette = theme.palette();
|
||||
let rows = match &modal.content {
|
||||
Content::Power(action) => {
|
||||
let mut rows = column![
|
||||
@@ -526,7 +523,7 @@ impl FereseShell {
|
||||
.padding(24)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
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),
|
||||
icon_color: Some(palette.text),
|
||||
border: Border {
|
||||
@@ -550,6 +547,7 @@ impl FereseShell {
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let scrim_alpha = if surface.effects.is_some() { 1.0 } else { progress };
|
||||
let centered = container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -557,7 +555,7 @@ impl FereseShell {
|
||||
.align_x(cosmic::iced::Alignment::Center)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.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()
|
||||
}));
|
||||
centered.into()
|
||||
|
||||
Reference in new issue
Block a user