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) -> 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) -> Vec { 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 { 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 { 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##" "##, 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 ); } } }