diff --git a/Cargo.lock b/Cargo.lock index faa0376..1172727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1709,6 +1709,7 @@ name = "ferese-lock" version = "0.1.0" dependencies = [ "ferese-config", + "ferese-theme", "image", "jiff", "libc", @@ -1737,6 +1738,7 @@ version = "0.1.0" dependencies = [ "ferese-config", "ferese-ipc", + "ferese-theme", "image", "libcosmic", "notify", @@ -1753,6 +1755,7 @@ dependencies = [ "ferese-config", "ferese-core", "ferese-protocols", + "ferese-theme", "iced_accessibility", "jiff", "libcosmic", @@ -1764,6 +1767,13 @@ dependencies = [ "zbus", ] +[[package]] +name = "ferese-theme" +version = "0.1.0" +dependencies = [ + "libcosmic", +] + [[package]] name = "feresectl" version = "0.1.0" @@ -7371,6 +7381,7 @@ version = "0.1.0" dependencies = [ "dirs", "ferese-config", + "ferese-theme", "futures-util", "gstreamer", "jiff", diff --git a/Cargo.toml b/Cargo.toml index 93f0e3f..b52d898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/ferese-animation", "crates/ferese-core", "crates/ferese-config", + "crates/ferese-theme", "crates/ferese-ipc", "crates/ferese-layout", "crates/ferese-protocols", diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml new file mode 100644 index 0000000..9e99bda --- /dev/null +++ b/crates/ferese-theme/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ferese-theme" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version = "1.95" + +[dependencies] +libcosmic.workspace = true diff --git a/crates/ferese-theme/src/lib.rs b/crates/ferese-theme/src/lib.rs new file mode 100644 index 0000000..8c03d23 --- /dev/null +++ b/crates/ferese-theme/src/lib.rs @@ -0,0 +1,226 @@ +//! Ferese owns foreground contrast; toolkit defaults must not choose it separately. +use cosmic::{ + iced::{Background, Color}, + theme, + widget::button, +}; + +pub fn luminance(color: Color) -> f32 { + let linear = |v: f32| { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b) +} + +pub fn contrast(a: Color, b: Color) -> f32 { + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +/// Resolve a control fill against its surface before selecting its foreground. +/// The returned fill is opaque, so wallpaper and translucent panels cannot change +/// the contrast of a small, filled control. +pub fn composite(fill: Color, surface: Color) -> Color { + let a = fill.a.clamp(0., 1.); + Color::from_rgb( + fill.r * a + surface.r * (1. - a), + fill.g * a + surface.g * (1. - a), + fill.b * a + surface.b * (1. - a), + ) +} + +/// Preserve the user's preferred color when it passes normal-text contrast. +/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill. +pub fn foreground(background: Color, preferred: Color) -> Color { + let preferred = composite(preferred, background); + if contrast(background, preferred) >= 4.5 { + return preferred; + } + if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) { + Color::BLACK + } else { + Color::WHITE + } +} + +/// Resolve configured accent transparency before passing an opaque color to a toolkit. +pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb { + rgba(composite(accent, surface)).color +} + +/// Supply the same semantic on-accent colors to native toolkit controls. +pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) { + let surface: Color = native.primary(false).base.into(); + for component in [&mut native.accent, &mut native.accent_button] { + let base = composite(component.base.into(), surface); + let on = foreground(base, preferred); + component.hover = rgba(readable_fill( + composite(component.hover.into(), surface), + base, + on, + )); + component.pressed = rgba(readable_fill( + composite(component.pressed.into(), surface), + base, + on, + )); + component.selected = rgba(composite(component.selected.into(), surface)); + component.base = rgba(base); + component.on = rgba(on); + component.selected_text = rgba(foreground( + composite(component.selected.into(), surface), + on, + )); + component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface)); + component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on)); + } +} + +// Native controls reuse one foreground for normal/hover/pressed. Keep their +// hover feedback, but pull an unreadable fill toward the readable normal fill. +fn readable_fill(candidate: Color, base: Color, on: Color) -> Color { + for step in 0..=20 { + let t = step as f32 / 20.; + let fill = Color::from_rgb( + candidate.r + (base.r - candidate.r) * t, + candidate.g + (base.g - candidate.g) * t, + candidate.b + (base.b - candidate.b) * t, + ); + if contrast(fill, on) >= 4.5 { + return fill; + } + } + base +} + +fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba { + cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a) +} + +#[derive(Clone, Copy)] +enum State { + Active, + Hovered, + Pressed, + Disabled, +} + +fn style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style { + let native = theme.cosmic(); + let component = &native.accent_button; + let fill: Color = match state { + State::Active => component.base.into(), + State::Hovered => component.hover.into(), + State::Pressed => component.pressed.into(), + State::Disabled => Color { + a: 0.5, + ..component.base.into() + }, + }; + let background = composite(fill, native.primary(false).base.into()); + let foreground = foreground(background, component.on.into()); + button::Style { + background: Some(Background::Color(background)), + text_color: Some(foreground), + icon_color: Some(foreground), + border_radius: native.corner_radii.radius_xl.into(), + outline_width: if focused { 1. } else { 0. }, + outline_color: foreground, + ..Default::default() + } +} + +/// Filled accent buttons and selected rows share this per-state contrast policy. +pub fn accent_button() -> theme::Button { + theme::Button::Custom { + active: Box::new(|focused, theme| style(theme, State::Active, focused)), + hovered: Box::new(|focused, theme| style(theme, State::Hovered, focused)), + pressed: Box::new(|focused, theme| style(theme, State::Pressed, focused)), + disabled: Box::new(|theme| style(theme, State::Disabled, false)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reference_contrast_values_use_linear_srgb() { + assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001); + assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001); + } + #[test] + fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() { + let preferred = Color::from_rgb8(240, 242, 246); + assert_eq!(foreground(Color::BLACK, preferred), preferred); + assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK); + assert_eq!( + foreground(Color::from_rgb8(61, 123, 230), preferred), + Color::BLACK + ); + } + #[test] + fn all_accent_hues_and_opacities_have_readable_text() { + for r in (0..=255).step_by(17) { + for g in (0..=255).step_by(17) { + for b in (0..=255).step_by(17) { + for surface in [Color::BLACK, Color::WHITE] { + for alpha in [0., 0.2, 0.5, 1.] { + let fill = composite(Color::from_rgba8(r, g, b, alpha), surface); + let on = foreground(fill, Color::from_rgb8(120, 130, 140)); + assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}"); + } + } + } + } + } + } + #[test] + fn native_button_states_keep_text_and_icons_readable() { + for accent in [ + Color::BLACK, + Color::WHITE, + Color::from_rgb8(61, 123, 230), + Color::from_rgb8(255, 220, 0), + ] { + for mut native in [ + cosmic::cosmic_theme::ThemeBuilder::dark() + .accent(rgba(accent).color) + .build(), + cosmic::cosmic_theme::ThemeBuilder::light() + .accent(rgba(accent).color) + .build(), + ] { + apply(&mut native, Color::from_rgb8(244, 247, 251)); + for component in [&native.accent, &native.accent_button] { + for fill in [component.base, component.hover, component.pressed] { + assert!(contrast(fill.into(), component.on.into()) >= 4.5); + } + assert!( + contrast(component.selected.into(), component.selected_text.into()) >= 4.5 + ); + assert!( + contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5 + ); + } + let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); + for state in [ + State::Active, + State::Hovered, + State::Pressed, + State::Disabled, + ] { + let style = style(&theme, state, false); + let Some(Background::Color(fill)) = style.background else { + panic!("No fill") + }; + assert_eq!(style.text_color, style.icon_color); + assert!(contrast(fill, style.text_color.unwrap()) >= 4.5); + } + } + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 4a79ca5..bbbaff9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -145,6 +145,15 @@ these six visually distinct. | `border` | `"#FFFFFF18"` | Unfocused window border | | `shadow` | `"#00000055"` | Window shadow color | +Ferese derives the text and icon color for filled accent controls from the +configured theme. It keeps the configured text color when it reaches a 4.5:1 +contrast ratio; otherwise it chooses black or white. The same rule is shared by +native dialogs, settings selections, and notification badges. Hover, pressed, +and disabled accent-button states use the same policy. Small filled controls +resolve accent transparency against their theme surface so wallpaper cannot +make their labels unreadable. No separate per-application color setting is needed. +The target follows [normal-text contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html). + | `theme.geometry` key | Type | Default | Meaning | | --- | --- | --- | --- | | `border_width` | number ≥ 0 | `1` | Window border thickness | diff --git a/portal/Cargo.toml b/portal/Cargo.toml index c071341..fa10869 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -14,6 +14,7 @@ name = "ferese-record" path = "src/record_main.rs" [dependencies] +ferese-theme = { path = "../crates/ferese-theme" } gstreamer = "0.24" futures-util = "0.3" dirs = "6" diff --git a/portal/src/picker.rs b/portal/src/picker.rs index c56a8ff..f21f4c1 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -174,7 +174,7 @@ impl cosmic::Application for Picker { sources = sources.push( button::custom(entry) .class(if selected { - cosmic::theme::Button::Suggested + ferese_theme::accent_button() } else { cosmic::theme::Button::Standard }) @@ -215,7 +215,7 @@ impl cosmic::Application for Picker { .spacing(8) .align_y(Alignment::Center), ) - .class(cosmic::theme::Button::Suggested) + .class(ferese_theme::accent_button()) .padding([6, 16]); if !self.selected.is_empty() { share = share.on_press(Message::Share); @@ -288,15 +288,15 @@ fn appearance() -> (cosmic::Theme, cosmic::font::Font) { } else { cosmic::cosmic_theme::ThemeBuilder::dark() }; - let theme = cosmic::Theme::custom(std::sync::Arc::new( - builder - .bg_color(background) - .primary_container_bg(background) - .text_tint(foreground.color) - .accent(accent.color) - .corner_radii(corners) - .build(), - )); + let mut native = builder + .bg_color(background) + .primary_container_bg(background) + .text_tint(foreground.color) + .accent(ferese_theme::accent_color(accent.into(), background.into())) + .corner_radii(corners) + .build(); + ferese_theme::apply(&mut native, foreground.into()); + let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); let family: &'static str = Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str()); diff --git a/shell/ferese-lock/Cargo.toml b/shell/ferese-lock/Cargo.toml index aea2fbb..b7ad8b2 100644 --- a/shell/ferese-lock/Cargo.toml +++ b/shell/ferese-lock/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.95" [dependencies] +ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } libcosmic.workspace = true jiff.workspace = true diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index 850832b..bc34c87 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -115,15 +115,15 @@ impl Appearance { corners.radius_m = [self.radius; 4]; corners.radius_l = [self.radius; 4]; corners.radius_xl = [self.radius; 4]; - cosmic::Theme::custom(std::sync::Arc::new( - builder - .corner_radii(corners) - .bg_color(color(self.panel)) - .primary_container_bg(color(self.panel)) - .text_tint(color(self.text).color) - .accent(color(self.accent).color) - .build(), - )) + let mut native = builder + .corner_radii(corners) + .bg_color(color(self.panel)) + .primary_container_bg(color(self.panel)) + .text_tint(color(self.text).color) + .accent(ferese_theme::accent_color(self.accent, self.panel)) + .build(); + ferese_theme::apply(&mut native, self.text); + cosmic::Theme::custom(std::sync::Arc::new(native)) } } diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index b66114f..2fcd12d 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -362,9 +362,12 @@ impl Locker { .center_x(avatar_size) .center_y(avatar_size) .class(theme::Container::custom(move |_| { + let fill = ferese_theme::composite(a.accent.scale_alpha(0.18), a.panel); + let on = ferese_theme::foreground(fill, a.accent); widget::container::Style { - background: Some(iced::Background::Color(a.accent.scale_alpha(0.18))), - text_color: Some(a.accent), + background: Some(iced::Background::Color(fill)), + text_color: Some(on), + icon_color: Some(on), border: iced::Border { radius: (avatar_size / 2.).into(), width: 1., diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index aa54531..f9dd099 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } ferese-ipc = { path = "../../crates/ferese-ipc" } diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index b19108c..2dd94c7 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -62,16 +62,7 @@ impl Palette { } } -fn luminance(color: Color) -> f32 { - let channel = |value: f32| { - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b) -} +use ferese_theme::luminance; fn surface_shade(base: Color) -> Color { if luminance(base) > 0.5 { @@ -145,8 +136,8 @@ pub fn input_style(p: Palette) -> theme::TextInput { } fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { - let style = move |hover: bool| button::Style { - background: Some(Background::Color(if selected { + let style = move |hover: bool| { + let background = if selected { mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) } else if hover { mix(p.card, surface_shade(p.sidebar), 0.06) @@ -154,20 +145,28 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button p.sidebar } else { p.card - })), - text_color: Some(p.text), - icon_color: None, - border_radius: 9.into(), - border_width: if selected { 1. } else { 0. }, - border_color: if selected { - p.accent + }; + let on = if selected { + ferese_theme::foreground(background, p.text) } else { - Color::TRANSPARENT - }, - outline_width: 0., - outline_color: Color::TRANSPARENT, - overlay: None, - shadow_offset: Vector::ZERO, + p.text + }; + button::Style { + background: Some(Background::Color(background)), + text_color: Some(on), + icon_color: Some(on), + border_radius: 9.into(), + border_width: if selected { 1. } else { 0. }, + border_color: if selected { + p.accent + } else { + Color::TRANSPARENT + }, + outline_width: 0., + outline_color: Color::TRANSPARENT, + overlay: None, + shadow_offset: Vector::ZERO, + } }; theme::Button::Custom { @@ -180,7 +179,10 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { let Some(snapshot) = snapshot else { - return cosmic::Theme::custom(std::sync::Arc::new(cosmic::theme::COSMIC_DARK.clone())); + let mut native = cosmic::theme::COSMIC_DARK.clone(); + let preferred = native.background(false).on.into(); + ferese_theme::apply(&mut native, preferred); + return cosmic::Theme::custom(std::sync::Arc::new(native)); }; let palette = Palette::from(snapshot); let builder = if luminance(palette.sidebar) > 0.5 { @@ -188,12 +190,13 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { } else { cosmic::cosmic_theme::ThemeBuilder::dark() }; - let theme = builder + let mut theme = builder .bg_color(cosmic_color(palette.background)) .primary_container_bg(cosmic_color(palette.card)) .text_tint(cosmic_color(palette.text).color) - .accent(cosmic_color(palette.accent).color) + .accent(ferese_theme::accent_color(palette.accent, palette.card)) .build(); + ferese_theme::apply(&mut theme, palette.text); cosmic::Theme::custom(std::sync::Arc::new(theme)) } diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 4d21f25..e079a2a 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-theme = { path = "../../crates/ferese-theme" } ferese-config = { path = "../../crates/ferese-config" } ferese-core = { path = "../../crates/ferese-core" } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 6ba076c..e620477 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -65,18 +65,6 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color { } } -fn contrast_text(background: Color, opacity: f32) -> Color { - let level = background.r * 0.2126 + background.g * 0.7152 + background.b * 0.0722; - Color { - a: opacity, - ..if level > 0.6 { - Color::BLACK - } else { - Color::WHITE - } - } -} - fn age_label(age: Duration) -> String { match age.as_secs() { 0..60 => "now".into(), @@ -345,16 +333,25 @@ impl FereseShell { ..Default::default() })); let icon: Element<'_, cosmic::Action> = if count > 1 { + let badge_fill = ferese_theme::composite( + color(palette.accent), + color(if history { + palette.surface_base + } else { + palette.surface_popover + }), + ); + let badge_text = ferese_theme::foreground(badge_fill, color(palette.text_primary)); let badge = container( text(count.to_string()) .size(10) .font(bold) - .class(theme::Text::Color(contrast_text(accent, opacity))), + .class(theme::Text::Color(badge_text.scale_alpha(opacity))), ) .center_x(16) .center_y(16) .class(theme::Container::custom(move |_| container::Style { - background: Some(Background::Color(accent)), + background: Some(Background::Color(badge_fill.scale_alpha(opacity))), border: Border { radius: motion::radius(8.0).into(), ..Default::default()