diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index ba91b82..cb0bfe5 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -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!( diff --git a/compositor/src/state.rs b/compositor/src/state.rs index db7f475..1d5cea3 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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] diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index db0288e..b9d809c 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -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 { 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 + ); + } +} diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index bb70b46..9ffb0c5 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -1027,6 +1027,7 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec= 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\"; }; }") diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index e0dff11..788722d 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -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), diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 24b9c18..7b25f3d 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -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 { diff --git a/docs/theme-engine.md b/docs/theme-engine.md index a3daed0..fe72259 100644 --- a/docs/theme-engine.md +++ b/docs/theme-engine.md @@ -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 diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index c1638b0..4e89344 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -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, diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 7b14e40..591cb12 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -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, diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index acf6a5f..995729c 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -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 { 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); diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 5c95537..71281e9 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -874,10 +874,13 @@ impl FereseShell { } motion::configure(config.animations, config.theme.material_radius); - if self.config.animations != config.animations - && let Some(menu) = &mut self.menu - { - menu.motion.update_settings(config.animations); + 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()); diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index c3a8979..7efb91c 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -76,7 +76,9 @@ impl PopupMotion { let now = std::time::Instant::now(); self.start = self.progress_at(now); self.settings = settings; - self.started = Some(now); + if self.started.is_some() { + self.started = Some(now); + } } pub(crate) fn new(settings: Settings) -> 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 Widget 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(); diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index ba467cd..c538077 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -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> = 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()