Share native theme components and fix dialog backdrop blur

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.
This commit is contained in:
Samuel Onoja committed 2026-09-29 21:19:19 +01:00
1 parent 362bf79012
commit b2363a18b0
85 files changed
+2163 -1591

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
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);
}
}
}