Files
feresaria/shell/ferese-settings/src/visuals.rs
T
Samuel Onoja 3d950a0411 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.
2026-09-30 12:51:49 +01:00

356 lines
13 KiB
Rust

use cosmic::Element;
use cosmic::iced::{Color, Length};
use cosmic::widget::icon as svg_icon;
pub use ferese_theme::Palette;
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
use crate::Message;
use crate::schema::Page;
use crate::store::{Edit, Snapshot, set};
pub fn color(value: &str, fallback: Color) -> Color {
ferese_theme::parse_color(value).unwrap_or(fallback)
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(c.r * 255.) as u8,
(c.g * 255.) as u8,
(c.b * 255.) as u8
)
}
pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme {
Palette::from_resolved(&ferese_theme::service::current().presented).native_theme()
}
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
ferese_theme::icons::outline(page.icon(), tint, 19)
}
pub fn brand_icon() -> svg_icon::Icon {
cosmic::widget::icon::from_svg_bytes(ferese_theme::icons::APPLICATION)
.symbolic(false)
.icon()
.size(32)
}
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
ferese_theme::icons::outline(path, tint, 16)
}
#[cfg(test)]
pub use ferese_theme::PRESETS;
pub fn split(snapshot: &Snapshot) -> bool {
snapshot
.item("theme.split")
.and_then(serde_json::Value::as_bool)
.unwrap_or_else(|| {
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
})
}
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
let Some(appearance) = appearance else {
return snapshot.string(
"theme.family",
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
);
};
let kind = if appearance == ferese_config::theme::Appearance::Light {
"light"
} else {
"dark"
};
let preset = snapshot.string(
&format!("theme.{kind}.preset"),
if kind == "light" {
"ferese-blue-light"
} else {
"ferese-blue"
},
);
snapshot.string(
&format!("theme.{kind}.family"),
ferese_config::families::family_id(&preset),
)
}
pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> Vec<Edit> {
let kinds: &[&str] = match appearance {
Some(ferese_config::theme::Appearance::Light) => &["light"],
Some(ferese_config::theme::Appearance::Dark) => &["dark"],
None => &[],
};
let mut edits = vec![set(
&appearance.map_or_else(
|| "theme.family".into(),
|a| {
format!(
"theme.{}.family",
if a == ferese_config::theme::Appearance::Light {
"light"
} else {
"dark"
}
)
},
),
id,
)];
for kind in kinds {
for token in ["colors", "surface", "border", "focus_ring"] {
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
}
}
edits
}
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
let mut edits = vec![];
if enabled {
let id = family_selection(snapshot, None);
for kind in ["light", "dark"] {
if snapshot.item(&format!("theme.{kind}.family")).is_none() {
let selection = if snapshot.item("theme.family").is_some() {
id.clone()
} else {
family_selection(
snapshot,
Some(if kind == "light" {
ferese_config::theme::Appearance::Light
} else {
ferese_config::theme::Appearance::Dark
}),
)
};
edits.push(set(&format!("theme.{kind}.family"), selection));
}
}
} else {
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
}
edits.push(set("theme.split", enabled));
edits
}
#[cfg(test)]
pub fn preset(index: usize) -> Vec<Edit> {
let Some(preset) = PRESETS.get(index) else {
return vec![];
};
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
"light"
} else {
"dark"
};
let mut edits = vec![set(&format!("theme.{kind}.preset"), preset.id)];
for token in ["colors", "surface", "border", "focus_ring"] {
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
}
edits
}
#[cfg(test)]
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let Some(preset) = PRESETS.get(index) else { return false };
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
"light"
} else {
"dark"
};
snapshot.string(
&format!("theme.{kind}.preset"),
if kind == "dark" {
"ferese-blue"
} else {
"ferese-blue-light"
},
) == preset.id
&& ["colors", "surface", "border", "focus_ring"]
.iter()
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
}
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
let mut snapshot = snapshot.clone();
snapshot.doc = snapshot.doc.with_theme(theme);
preview(&snapshot, theme)
}
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
let p = Palette::from_resolved(theme);
let base = hex(p.sidebar);
let card = hex(p.card);
let accent = hex(p.accent);
let muted = hex(p.muted);
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot
.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.)
} else {
1.
};
let right = 340. + gap / 2.;
let left_width = 288. - gap / 2.;
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="680" height="210" viewBox="0 0 680 210">
<defs><linearGradient id="wall" x2="1" y2="1"><stop stop-color="{accent}" stop-opacity=".20"/><stop offset="1" stop-color="{base}"/></linearGradient></defs>
<rect width="680" height="210" rx="14" fill="{base}"/><rect width="680" height="210" rx="14" fill="url(#wall)"/>
<path d="M0 180Q160 30 350 145T680 100V210H0Z" fill="{accent}" opacity=".055"/>
<rect x="{bar_margin}" y="{bar_y}" width="{}" height="{bar_height}" rx="{bar_radius}" fill="{base}" opacity="{opacity}"/>
<rect x="30" y="{}" width="12" height="5" rx="2.5" fill="{accent}"/><circle cx="51" cy="{}" r="2.5" fill="{muted}"/>
<path d="M595 {}h10m10 0h10m10 0h10" stroke="{muted}" stroke-width="3" stroke-linecap="round"/>
<rect x="44" y="60" width="{left_width}" height="132" rx="{radius}" fill="{card}" stroke="{accent}" stroke-width="1.2"/>
<rect x="{right}" y="60" width="{left_width}" height="132" rx="{radius}" fill="{card}"/>
<path d="M64 80h65M64 100h170M64 115h120M64 130h152" stroke="{muted}" opacity=".45" stroke-width="4" stroke-linecap="round"/>
<rect x="{}" y="78" width="50" height="94" rx="5" fill="{base}"/>
<path d="M420 82h85M420 103h175M420 121h150M420 139h160" stroke="{muted}" opacity=".32" stroke-width="4" stroke-linecap="round"/>
</svg>"##,
680. - 2. * bar_margin,
bar_y + bar_height / 2. - 2.5,
bar_y + bar_height / 2.,
bar_y + bar_height / 2.,
right + 16.
);
svg_icon::from_svg_bytes(svg.into_bytes())
.symbolic(false)
.icon()
.width(Length::Fill)
.height(Length::Fixed(132.))
.content_fit(cosmic::iced::ContentFit::Contain)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_preset(index: usize) -> Snapshot {
let mut snapshot = Snapshot::parse(String::new()).unwrap();
for edit in preset(index) {
snapshot.edit(&edit).unwrap();
}
snapshot
}
fn contrast(a: Color, b: Color) -> f32 {
ferese_theme::contrast(a, b)
}
#[test]
fn galleries_follow_mode_and_keep_selection_independent() {
let mut snapshot = Snapshot::parse(String::from(r#"theme { mode "auto"; }"#)).unwrap();
for index in 0..PRESETS.len() {
for edit in preset(index) {
snapshot.edit(&edit).unwrap();
}
assert_eq!(snapshot.string("theme.mode", ""), "auto");
}
}
#[test]
fn split_toggle_restores_the_pair_and_uses_the_active_family_when_linked() {
use ferese_config::theme::Appearance::{Dark, Light};
let mut snapshot = Snapshot::parse(r#"theme { family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }"#.into()).unwrap();
for edit in toggle_split(&snapshot, false, Light) {
snapshot.edit(&edit).unwrap();
}
assert!(!split(&snapshot));
assert_eq!(family_selection(&snapshot, None), "gruvbox");
for edit in choose_family("everforest", None) {
snapshot.edit(&edit).unwrap();
}
for edit in toggle_split(&snapshot, true, Dark) {
snapshot.edit(&edit).unwrap();
}
assert!(split(&snapshot));
assert_eq!(family_selection(&snapshot, Some(Light)), "gruvbox");
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin");
}
#[test]
fn the_first_split_starts_with_both_variants_of_the_single_family() {
use ferese_config::theme::Appearance::{Dark, Light};
let mut snapshot = Snapshot::parse(r#"theme { family "tokyo-night"; }"#.into()).unwrap();
for edit in toggle_split(&snapshot, true, Dark) {
snapshot.edit(&edit).unwrap();
}
assert_eq!(family_selection(&snapshot, Some(Light)), "tokyo-night");
assert_eq!(family_selection(&snapshot, Some(Dark)), "tokyo-night");
}
#[test]
fn presets_are_distinct_and_readable_on_settings_surfaces() {
for (index, preset) in PRESETS.iter().enumerate() {
let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
let mut theme = ferese_config::theme::ResolvedTheme::default();
theme.appearance = preset.appearance;
theme.tokens = tokens;
let palette = Palette::from_resolved(&theme);
for background in [palette.background, palette.sidebar, palette.card] {
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
}
for other in PRESETS.iter().skip(index + 1) {
if preset.appearance == other.appearance {
assert_ne!(preset.accent, other.accent);
}
assert_ne!(preset.base, other.base);
}
}
}
#[test]
fn selection_tracks_all_preset_colors_and_gradient_changes() {
for index in 0..PRESETS.len() {
let mut snapshot = configured_preset(index);
assert!(preset_selected(&snapshot, index));
let kind = if PRESETS[index].appearance == ferese_config::theme::Appearance::Light {
"light"
} else {
"dark"
};
snapshot
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
.unwrap();
assert!(!preset_selected(&snapshot, index));
let mut snapshot = configured_preset(index);
snapshot
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
.unwrap();
assert!(!preset_selected(&snapshot, index));
}
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
assert!(preset(usize::MAX).is_empty());
assert!(!preset_selected(&Snapshot::parse(String::new()).unwrap(), usize::MAX));
}
#[test]
fn default_uses_logo_blue_and_native_controls_follow_lightness() {
let logo = include_str!("../../../packaging/icons/ferese.svg").to_ascii_uppercase();
assert!(logo.contains(PRESETS[0].accent));
for (index, item) in PRESETS.iter().enumerate() {
let _ = index;
let mut resolved = ferese_config::theme::ResolvedTheme::default();
resolved.appearance = item.appearance;
resolved.tokens = ferese_config::theme::preset(item.id, item.appearance).unwrap();
assert_eq!(
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
item.appearance == ferese_config::theme::Appearance::Dark
);
}
}
}