Centralize native palettes, presets, typography, controls, menus, calendar rendering, and icon assets in ferese-theme. Reuse the shared components across the shell, settings, lock screen, authentication prompt, and screen-sharing chooser. Attach themed dialogs to compositor-owned materials, correct backdrop capture and sampling across output transforms, smooth patterned backgrounds, and prevent clipped picker buttons with the configured shell font.
190 lines
6.2 KiB
Rust
190 lines
6.2 KiB
Rust
use cosmic::iced::Color;
|
|
use ferese_config::Document;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Palette {
|
|
pub background: Color,
|
|
pub sidebar: Color,
|
|
pub card: Color,
|
|
pub accent: Color,
|
|
pub text: Color,
|
|
pub muted: Color,
|
|
pub error: Color,
|
|
pub radius: f32,
|
|
}
|
|
|
|
impl Palette {
|
|
pub fn from_document(document: Option<&Document>) -> Self {
|
|
let color = |key, fallback| {
|
|
document
|
|
.and_then(|document| document.get(key))
|
|
.and_then(|value| value.as_str())
|
|
.and_then(parse_color)
|
|
.unwrap_or_else(|| parse_color(fallback).expect("valid default theme color"))
|
|
};
|
|
let base = color("theme.colors.surface_base", "#111821");
|
|
Self {
|
|
background: mix(base, surface_shade(base), 0.025),
|
|
sidebar: base,
|
|
card: mix(base, surface_shade(base), 0.055),
|
|
accent: color("theme.colors.accent", "#3D7BE6"),
|
|
text: color("theme.colors.text_primary", "#F4F7FB"),
|
|
muted: color("theme.colors.text_muted", "#8793A2"),
|
|
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
|
|
radius: document
|
|
.and_then(|document| {
|
|
document
|
|
.get("theme.geometry.shell_radius")
|
|
.or_else(|| document.get("appearance.corner_radius"))
|
|
})
|
|
.and_then(|value| value.as_f64())
|
|
.filter(|value| value.is_finite())
|
|
.unwrap_or(14.)
|
|
.clamp(0., 64.) as f32,
|
|
}
|
|
}
|
|
|
|
pub fn flat(mut self) -> Self {
|
|
self.background = self.sidebar;
|
|
self.card = self.sidebar;
|
|
self
|
|
}
|
|
|
|
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 crate::luminance(self.sidebar) > 0.5 {
|
|
cosmic::cosmic_theme::ThemeBuilder::light()
|
|
} else {
|
|
cosmic::cosmic_theme::ThemeBuilder::dark()
|
|
};
|
|
let corners = cosmic::cosmic_theme::CornerRadii {
|
|
radius_xs: [self.radius.min(4.); 4],
|
|
radius_s: [self.radius.min(8.); 4],
|
|
radius_m: [self.radius; 4],
|
|
radius_l: [self.radius; 4],
|
|
radius_xl: [self.radius; 4],
|
|
radius_0: Default::default(),
|
|
};
|
|
let mut native = builder
|
|
.corner_radii(corners)
|
|
.bg_color(rgba(self.background))
|
|
.primary_container_bg(rgba(self.card))
|
|
.text_tint(rgba(self.text).color)
|
|
.accent(crate::accent_color(self.accent, self.card))
|
|
.build();
|
|
crate::apply(&mut native, self.text);
|
|
cosmic::Theme::custom(std::sync::Arc::new(native))
|
|
}
|
|
|
|
pub fn application_style(self, background: Color) -> cosmic::iced::theme::Style {
|
|
cosmic::iced::theme::Style {
|
|
background_color: background,
|
|
text_color: self.text,
|
|
icon_color: self.text,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn parse_color(value: &str) -> Option<Color> {
|
|
let hex = value.strip_prefix('#')?;
|
|
let packed = u32::from_str_radix(hex, 16).ok()?;
|
|
let rgba = match hex.len() {
|
|
6 => (packed << 8) | 255,
|
|
8 => packed,
|
|
_ => return None,
|
|
};
|
|
Some(Color::from_rgba8(
|
|
(rgba >> 24) as u8,
|
|
(rgba >> 16) as u8,
|
|
(rgba >> 8) as u8,
|
|
(rgba & 255) as f32 / 255.,
|
|
))
|
|
}
|
|
|
|
pub fn mix(a: Color, b: Color, t: f32) -> Color {
|
|
Color::from_rgb(
|
|
a.r + (b.r - a.r) * t,
|
|
a.g + (b.g - a.g) * t,
|
|
a.b + (b.b - a.b) * t,
|
|
)
|
|
}
|
|
|
|
pub fn surface_shade(base: Color) -> Color {
|
|
if crate::luminance(base) > 0.5 {
|
|
Color::BLACK
|
|
} else {
|
|
Color::WHITE
|
|
}
|
|
}
|
|
|
|
pub fn material_opacity(document: Option<&Document>) -> f32 {
|
|
let style = document
|
|
.and_then(|document| document.get("theme.material.style"))
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or("solid");
|
|
if style != "translucent" {
|
|
return 1.;
|
|
}
|
|
document
|
|
.and_then(|document| document.get("theme.material.opacity"))
|
|
.and_then(|value| value.as_f64())
|
|
.filter(|value| value.is_finite())
|
|
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
|
|
.clamp(0., 1.) as f32
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
|
for preset in crate::PRESETS {
|
|
let document = Document::parse(&format!(
|
|
r##"theme {{
|
|
colors {{
|
|
surface-base "{}"
|
|
accent "{}"
|
|
text-primary "{}"
|
|
text-muted "{}"
|
|
}}
|
|
material {{ style translucent; opacity 0.25; }}
|
|
geometry {{ shell-radius 0; }}
|
|
}}"##,
|
|
preset.base, preset.accent, preset.text, preset.muted,
|
|
))
|
|
.unwrap();
|
|
let palette = Palette::from_document(Some(&document));
|
|
let native = palette.native_theme();
|
|
assert_eq!(material_opacity(Some(&document)), 0.25);
|
|
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
|
|
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
|
|
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
|
|
for fill in [component.base, component.hover, component.pressed] {
|
|
assert!(
|
|
crate::contrast(fill.into(), component.on.into()) >= 4.5,
|
|
"{}",
|
|
preset.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn solid_materials_ignore_opacity_and_translucent_materials_bound_it() {
|
|
for (style, opacity, expected) in [
|
|
("solid", 0.4, 1.),
|
|
("translucent", 0.4, 0.4),
|
|
("translucent", 2., 1.),
|
|
("translucent", -1., 0.),
|
|
] {
|
|
let document = Document::parse(&format!(
|
|
"theme {{ material {{ style {style}; opacity {opacity}; }} }}"
|
|
))
|
|
.unwrap();
|
|
assert_eq!(material_opacity(Some(&document)), expected);
|
|
}
|
|
}
|
|
}
|