Respect motion settings and improve accessible theme transitions
This commit is contained in:
1 parent
53bad8c672
commit
7a9fd5c864
13 files changed
+390
-67
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\"; }; }")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -874,10 +874,13 @@ 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());
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ 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;
|
||||||
self.started = Some(now);
|
if self.started.is_some() {
|
||||||
|
self.started = Some(now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(settings: Settings) -> Self {
|
pub(crate) fn new(settings: Settings) -> 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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user