Add resolved appearance engine and paired theme settings

Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions.

Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
Samuel Onoja committed 2026-09-30 12:51:49 +01:00
1 parent 2bb4c086f5
commit 3d950a0411
58 files changed
+5556 -644

No files matched your search

+58 -1
View File
@@ -34,6 +34,36 @@ pub fn text_button<'a, M: Clone + 'a>(
.class(button_style(palette, selected))
}
pub fn switch<'a, M: Clone + 'a>(enabled: bool, palette: Palette) -> button::Button<'a, M> {
let paint = move |focused| button::Style {
background: None,
text_color: Some(palette.text),
icon_color: Some(palette.text),
border_radius: 12.into(),
border_width: if focused { 1. } else { 0. },
border_color: palette.accent,
..Default::default()
};
button::custom(crate::menus::switch(enabled, palette, 1.))
.padding(2)
.class(theme::Button::Custom {
active: Box::new(move |focused, _| paint(focused)),
hovered: Box::new(move |focused, _| paint(focused)),
pressed: Box::new(move |focused, _| paint(focused)),
disabled: Box::new(move |_| paint(false)),
})
}
pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (Color, Color) {
let track = if enabled {
palette.accent
} else {
mix(palette.card, palette.text, if hovered { 0.18 } else { 0.12 })
};
let thumb = crate::foreground(track, palette.text);
(track, thumb)
}
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, true)
}
@@ -48,7 +78,7 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
icon_color: Some(p.muted),
text_color: Some(p.text),
placeholder_color: p.muted,
selected_text_color: p.sidebar,
selected_text_color: p.on_accent,
selected_fill: p.accent,
label_color: p.muted,
};
@@ -233,3 +263,30 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32)
disabled: Box::new(move |_| paint(0.)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn switch_thumb_remains_readable_in_every_preset_and_state() {
for preset in crate::PRESETS {
let resolved = ferese_config::theme::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
};
let palette = Palette::from_resolved(&resolved);
for enabled in [false, true] {
for hovered in [false, true] {
let (track, thumb) = switch_colors(palette, enabled, hovered);
assert!(
crate::contrast(track, thumb) >= 4.5,
"{}: enabled={enabled}, hovered={hovered}",
preset.name,
);
}
}
}
}
}
+448
View File
@@ -0,0 +1,448 @@
use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree};
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer};
use cosmic::iced::{Event, Length, Rectangle, Size, keyboard};
use cosmic::{Element, Renderer, Theme};
use ferese_config::families::{Family, Palette};
use ferese_config::theme::Appearance;
use iced_accessibility::{A11yTree, accesskit};
pub fn tile<M: Clone + 'static>(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
palette: crate::Palette,
font: cosmic::font::Font,
id: Id,
on_select: M,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static,
) -> Element<'static, M> {
use cosmic::widget::{button, column, row};
let mut caption = row([])
.spacing(6)
.align_y(cosmic::iced::Alignment::Center)
.push(crate::text(family.name.clone(), font).size(12));
if family.light.is_none() || family.dark.is_none() {
caption = caption.push(
crate::text(
if family.light.is_none() {
"Dark only"
} else {
"Light only"
},
font,
)
.size(10)
.class(cosmic::theme::Text::Color(palette.muted)),
);
}
let image = preview(family, variant, active, selected);
let image =
cosmic::widget::responsive(move |size| image.clone().height(Length::Fixed(size.width * 126. / 200.)).into())
.height(Length::Shrink);
let content = column([]).spacing(2).push(image).push(caption);
let tile = button::custom(content)
.id(id.clone())
.name(format!("{}, {}", family.name, family.availability()))
.width(Length::Fill)
.padding(8)
.class(tile_style(palette, selected))
.on_press(on_select);
radio(tile, id, selected, navigate)
}
fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button {
let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style {
background: Some(if hovered { palette.sidebar } else { palette.card }.into()),
border_width: if selected { 2. } else { 0. },
border_color: palette.accent,
border_radius: 10.into(),
outline_width: if focused { 2. } else { 0. },
outline_color: palette.text,
text_color: Some(palette.text),
icon_color: Some(palette.text),
..Default::default()
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| style(focused, false)),
hovered: Box::new(move |focused, _| style(focused, true)),
pressed: Box::new(move |focused, _| style(focused, true)),
disabled: Box::new(move |_| style(false, false)),
}
}
pub fn preview(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> cosmic::widget::icon::Icon {
use cosmic::widget::icon;
let key = format!("{family:?}/{variant:?}/{active:?}/{selected}");
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, icon::Handle>>> =
std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut cache = cache.lock().unwrap();
let handle = cache
.entry(key)
.or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false))
.clone();
// Bound cached previews when users repeatedly edit imported palettes.
if cache.len() > 128 {
cache.clear();
}
handle
.icon()
.width(Length::Fill)
.height(Length::Fixed(112.))
.content_fit(cosmic::iced::ContentFit::Fill)
}
fn desktop(p: &Palette) -> String {
format!(
r#"<rect width="200" height="126" fill="{}"/>
<rect x="8" y="8" width="184" height="14" rx="4" fill="{}" stroke="{}" stroke-opacity=".18"/>
<path d="M15 15h10m145 0h15" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="24" y="35" width="152" height="77" rx="7" fill="{}"/>
<path d="M24 51h152" stroke="{}" stroke-opacity=".22"/>
<path d="M35 44h34M36 63h105m-105 10h90m-90 10h115" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="76" y="92" width="48" height="10" rx="3" fill="{}"/>"#,
p.background, p.bar, p.muted, p.muted, p.surface, p.muted, p.text, p.accent
)
}
pub fn svg(family: &Family, variant: Option<Appearance>, active: Option<Appearance>, selected: bool) -> String {
let mut result = String::from(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="126" viewBox="0 0 200 126"><defs>
<clipPath id="light"><rect width="100" height="126"/></clipPath><clipPath id="dark"><rect x="100" width="100" height="126"/></clipPath>
<pattern id="missing" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#D9DDE2"/><path d="M-2 2l4-4M0 8l8-8M6 10l4-4" stroke="#C4C9D1"/></pattern></defs>"##,
);
for appearance in [Appearance::Light, Appearance::Dark] {
if variant.is_some_and(|variant| variant != appearance) {
continue;
}
let clip = match (variant, appearance) {
(None, Appearance::Light) => "light",
(None, Appearance::Dark) => "dark",
_ => "",
};
result.push_str(&format!(
"<g{}>",
if clip.is_empty() {
String::new()
} else {
format!(" clip-path=\"url(#{clip})\"")
}
));
if let Some(palette) = family.palette(appearance) {
result.push_str(&desktop(palette));
if active == Some(appearance) {
let (x, width) = if variant.is_some() {
(8, 184)
} else if appearance == Appearance::Light {
(8, 84)
} else {
(108, 84)
};
result.push_str(&format!(
"<rect x=\"{x}\" y=\"120\" width=\"{width}\" height=\"3\" rx=\"1.5\" fill=\"{}\"/>",
palette.accent
));
}
} else {
let x = if variant.is_some() || appearance == Appearance::Light {
0
} else {
100
};
let width = if variant.is_some() { 200 } else { 100 };
let label = if appearance == Appearance::Light {
"No light variant"
} else {
"No dark variant"
};
result.push_str(&format!(r##"<rect x="{x}" width="{width}" height="126" fill="url(#missing)"/><text x="{}" y="68" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#353B45">{label}</text>"##, x + width / 2));
}
result.push_str("</g>");
}
if selected {
let p = family
.palette(active.unwrap_or(Appearance::Dark))
.or(family.dark.as_ref())
.or(family.light.as_ref())
.unwrap();
let foreground = ferese_config::theme::hex(ferese_config::theme::readable(
[1.; 4],
ferese_config::theme::rgba(&p.accent).unwrap(),
4.5,
));
result.push_str(&format!(r#"<circle cx="183" cy="16" r="10" fill="{}"/><path d="M178 16l3 3 6-6" fill="none" stroke="{foreground}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>"#, p.accent));
}
result.push_str("</svg>");
result
}
pub fn neighbor(index: usize, count: usize, key: &keyboard::key::Named) -> Option<usize> {
use keyboard::key::Named::*;
if count == 0 {
return None;
}
Some(match key {
ArrowRight => (index + 1) % count,
ArrowLeft => (index + count - 1) % count,
ArrowDown => {
if index + 3 < count {
index + 3
} else {
index % 3
}
}
ArrowUp => {
if index >= 3 {
index - 3
} else {
((count - 1 - index) / 3) * 3 + index
}
}
Enter => index,
Home => 0,
End => count - 1,
_ => return None,
})
}
#[derive(Default)]
struct Focus(bool);
impl cosmic::iced::advanced::widget::operation::Focusable for Focus {
fn is_focused(&self) -> bool {
self.0
}
fn focus(&mut self) {
self.0 = true;
}
fn unfocus(&mut self) {
self.0 = false;
}
}
pub struct Radio<'a, M> {
content: Element<'a, M>,
id: Id,
selected: bool,
group: bool,
label: String,
navigate: Option<Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>>,
}
pub fn radio<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
selected: bool,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'a,
) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected,
group: false,
label: String::new(),
navigate: Some(Box::new(navigate)),
})
}
pub fn group<'a, M: Clone + 'a>(content: impl Into<Element<'a, M>>, id: Id, label: &str) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected: false,
group: true,
label: label.into(),
navigate: None,
})
}
impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Focus>()
}
fn state(&self) -> tree::State {
tree::State::new(Focus::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation<()>) {
if self.group {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
} else if self.selected {
operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::<Focus>());
} else {
tree.state.downcast_mut::<Focus>().0 = false;
}
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
if !self.group {
if let Event::A11y(id, request) = event {
if !iced_accessibility::IdEq::eq(&id.0, &self.id.0) {
return;
}
if matches!(request.action, accesskit::Action::Focus | accesskit::Action::Click) {
if let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
}
}
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) {
tree.state.downcast_mut::<Focus>().0 = self.selected && cursor.is_over(layout.bounds());
}
}
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(key),
..
}) = event
&& key.as_str() == " "
&& let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
..
}) = event
{
if let Some(message) = self.navigate.as_ref().and_then(|navigate| navigate(key)) {
shell.publish(message);
shell.capture_event();
return;
}
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
use renderer::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: cosmic::iced::Border {
color: style.text_color,
width: 2.,
radius: 10.into(),
},
..Default::default()
},
cosmic::iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn a11y_nodes(&self, layout: Layout<'_>, tree: &Tree, cursor: mouse::Cursor) -> A11yTree {
let children = self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor);
if self.group {
let mut node = accesskit::Node::new(accesskit::Role::RadioGroup);
node.set_label(self.label.clone());
A11yTree::node_with_child_tree(iced_accessibility::A11yNode::new(node, self.id.clone()), children)
} else {
let mut children = children;
for node in children.root_mut() {
node.node_mut().set_role(accesskit::Role::RadioButton);
node.node_mut().set_toggled(if self.selected {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
});
}
children
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arrow_navigation_wraps_rows_and_columns() {
use keyboard::key::Named::*;
assert_eq!(neighbor(0, 6, &ArrowLeft), Some(5));
assert_eq!(neighbor(1, 6, &ArrowUp), Some(4));
assert_eq!(neighbor(4, 6, &ArrowDown), Some(1));
assert_eq!(neighbor(5, 6, &Home), Some(0));
assert_eq!(neighbor(0, 7, &End), Some(6));
}
#[test]
fn paired_previews_share_one_window_across_the_seam() {
let family = ferese_config::families::builtins().remove(1);
let svg = svg(&family, None, Some(Appearance::Dark), true);
assert_eq!(svg.matches("x=\"24\" y=\"35\"").count(), 2);
assert!(svg.contains("clip-path=\"url(#light)\""));
assert!(svg.contains("clip-path=\"url(#dark)\""));
}
}
+3 -1
View File
@@ -3,15 +3,17 @@ mod button;
pub mod calendar;
mod contrast;
pub mod controls;
pub mod gallery;
pub mod icons;
pub mod material;
pub mod menus;
mod palette;
mod presets;
pub mod service;
mod typography;
pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
pub use palette::{Palette, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset};
pub use typography::{font, text};
+6 -28
View File
@@ -77,26 +77,11 @@ pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
}
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
let track = if enabled {
palette.accent
} else {
Color {
a: 0.22,
..palette.text
}
};
let thumb = if enabled {
crate::foreground(track, palette.text)
} else {
palette.text
};
let (track, thumb) = crate::controls::switch_colors(palette, enabled, false);
let knob = container(cosmic::widget::Space::new().width(16).height(16))
.width(16)
.height(16)
.class(crate::controls::surface(
Color { a: opacity, ..thumb },
palette.radius.min(8.),
));
.class(crate::controls::surface(Color { a: opacity, ..thumb }, 8.));
container(knob)
.width(36)
.height(20)
@@ -106,14 +91,11 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
} else {
cosmic::iced::alignment::Horizontal::Left
})
.class(crate::controls::surface(
track.scale_alpha(opacity),
palette.radius.min(10.),
))
.class(crate::controls::surface(track.scale_alpha(opacity), 10.))
.into()
}
pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::iced::Slider {
pub fn slider(foreground: Color, opacity: f32) -> cosmic::theme::iced::Slider {
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
Style {
@@ -128,16 +110,12 @@ pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::ic
),
width: 4.,
border: cosmic::iced::Border {
radius: radius.min(2.).into(),
radius: 2.into(),
..Default::default()
},
},
handle: Handle {
shape: HandleShape::Rectangle {
width: 10,
height: 10,
border_radius: radius.min(5.).into(),
},
shape: HandleShape::Circle { radius: 6. },
background: foreground.into(),
border_width: 0.,
border_color: Color::TRANSPARENT,
+35 -67
View File
@@ -1,12 +1,13 @@
use cosmic::iced::Color;
use ferese_config::Document;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
pub appearance: ferese_config::theme::Appearance,
pub background: Color,
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub on_accent: Color,
pub text: Color,
pub muted: Color,
pub error: Color,
@@ -14,33 +15,23 @@ pub struct Palette {
}
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");
pub fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
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,
appearance: theme.appearance,
background: color(&theme.tokens.colors.application_background),
sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised),
accent: color(&theme.tokens.colors.accent),
on_accent: color(&theme.tokens.colors.on_accent),
text: color(&theme.tokens.colors.text_primary),
muted: color(&theme.tokens.colors.text_muted),
error: mix(
color(&theme.tokens.colors.surface_base),
Color::from_rgb8(210, 80, 80),
0.2,
),
radius: theme.tokens.geometry.shell_radius as f32,
}
}
@@ -52,7 +43,7 @@ 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 crate::luminance(self.sidebar) > 0.5 {
let builder = if self.appearance == ferese_config::theme::Appearance::Light {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
@@ -70,9 +61,9 @@ impl Palette {
.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))
.accent(rgba(self.accent).color)
.build();
crate::apply(&mut native, self.text);
crate::apply(&mut native, self.on_accent);
cosmic::Theme::custom(std::sync::Arc::new(native))
}
@@ -113,22 +104,6 @@ pub fn surface_shade(base: Color) -> Color {
}
}
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::*;
@@ -136,23 +111,15 @@ mod tests {
#[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 mut resolved = ferese_config::theme::ResolvedTheme::default();
resolved.appearance = preset.appearance;
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
resolved.tokens.material.style = "translucent".into();
resolved.tokens.material.opacity = 0.25;
resolved.tokens.geometry.shell_radius = 0.;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
assert_eq!(material_opacity(Some(&document)), 0.25);
assert_eq!(crate::service::opacity(&resolved), 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] {
@@ -172,12 +139,13 @@ mod tests {
for (style, opacity, expected) in [
("solid", 0.4, 1.),
("translucent", 0.4, 0.4),
("translucent", 2., 1.),
("translucent", -1., 0.),
("translucent", 1., 1.),
("translucent", 0., 0.),
] {
let document =
Document::parse(&format!("theme {{ material {{ style {style}; opacity {opacity}; }} }}")).unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
let mut theme = ferese_config::theme::ResolvedTheme::default();
theme.tokens.material.style = style.into();
theme.tokens.material.opacity = opacity;
assert_eq!(crate::service::opacity(&theme), expected);
}
}
}
+1 -73
View File
@@ -1,73 +1 @@
pub struct Preset {
pub name: &'static str,
pub description: &'static str,
pub accent: &'static str,
pub base: &'static str,
pub text: &'static str,
pub muted: &'static str,
pub border: &'static str,
pub gradient_end: &'static str,
}
pub const PRESETS: [Preset; 6] = [
Preset {
name: "Ferese Blue",
description: "Default · logo blue",
accent: "#3D7BE6",
base: "#111821",
text: "#F4F7FB",
muted: "#8793A2",
border: "#FFFFFF18",
gradient_end: "#3D7BE6",
},
Preset {
name: "Monochrome",
description: "Dark · grayscale",
accent: "#E5E5E5",
base: "#101012",
text: "#EDEDF0",
muted: "#97979F",
border: "#FFFFFF18",
gradient_end: "#696973",
},
Preset {
name: "Gruvbox",
description: "Dark · warm orange",
accent: "#FE8019",
base: "#282828",
text: "#EBDBB2",
muted: "#BDAE93",
border: "#504945",
gradient_end: "#FABD2F",
},
Preset {
name: "Dracula",
description: "Dark · purple",
accent: "#BD93F9",
base: "#282A36",
text: "#F8F8F2",
muted: "#A4ADCD",
border: "#44475A",
gradient_end: "#FF79C6",
},
Preset {
name: "Ayu Light",
description: "Light · paper",
accent: "#8F5300",
base: "#F8F9FA",
text: "#5C6166",
muted: "#596574",
border: "#C8CDD3",
gradient_end: "#996000",
},
Preset {
name: "Monokai",
description: "Dark · warm green",
accent: "#A6E22E",
base: "#272822",
text: "#F8F8F2",
muted: "#B2B29F",
border: "#414339",
gradient_end: "#E6DB74",
},
];
pub use ferese_config::presets::{PRESETS, Preset};
+57
View File
@@ -0,0 +1,57 @@
use cosmic::iced::futures::SinkExt;
use ferese_config::theme::Snapshot;
pub fn current() -> Snapshot {
ferese_ipc::theme::current()
}
pub fn opacity(theme: &ferese_config::theme::ResolvedTheme) -> f32 {
if theme.tokens.material.style == "translucent" {
theme.tokens.material.opacity as f32
} else {
1.
}
}
pub fn subscription() -> cosmic::iced::Subscription<Snapshot> {
cosmic::iced::Subscription::run(stream)
}
fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
cosmic::iced::stream::channel(1, async |mut output| {
loop {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::watch::channel(None);
tokio::task::spawn_blocking(move || {
let Ok(mut snapshot) = connection.get() else { return };
loop {
let revision = snapshot.revision;
if send.send(Some(snapshot)).is_err() {
return;
}
match connection.watch(revision) {
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while receive.changed().await.is_ok() {
let snapshot = receive.borrow_and_update().clone();
if let Some(snapshot) = snapshot {
if output.send(snapshot).await.is_err() {
return;
}
}
}
drop(cancellation);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
})
}