use std::env; use std::fs; use std::path::PathBuf; use serde::Deserialize; const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug, Default)] pub(crate) struct ShellConfig { pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, pub(crate) theme: ShellTheme, pub(crate) status: StatusConfig, } #[derive(Clone, Debug, Deserialize)] #[serde(default)] pub(crate) struct StatusConfig { pub(crate) battery_percentage: bool, pub(crate) low_battery_threshold: u8, pub(crate) settings_command: Option>, } impl Default for StatusConfig { fn default() -> Self { Self { battery_percentage: true, low_battery_threshold: 20, settings_command: None, } } } #[derive(Clone, Copy, Debug)] pub(crate) struct ShellTheme { pub(crate) bar_background: [u8; 4], pub(crate) bar_text_primary: [u8; 4], pub(crate) bar_text_muted: [u8; 4], pub(crate) surface_base: [u8; 4], pub(crate) surface_popover: [u8; 4], pub(crate) text_primary: [u8; 4], pub(crate) text_muted: [u8; 4], pub(crate) accent: [u8; 4], pub(crate) border: [u8; 4], pub(crate) shadow: [u8; 4], pub(crate) bar_height: f32, pub(crate) bar_margin_top: i32, pub(crate) bar_window_gap: i32, pub(crate) bar_margin_horizontal: i32, pub(crate) bar_radius: f32, pub(crate) material_radius: f32, pub(crate) panel_padding: f32, pub(crate) control_gap: f32, pub(crate) shadow_offset_y: f32, pub(crate) shadow_blur: f32, pub(crate) shadow_opacity: f32, } impl Default for ShellTheme { fn default() -> Self { Self { bar_background: [28, 32, 46, 242], bar_text_primary: [240, 243, 250, 255], bar_text_muted: [170, 180, 199, 255], surface_base: [17, 24, 33, 255], surface_popover: [17, 24, 33, 245], text_primary: [244, 247, 251, 255], text_muted: [127, 138, 152, 255], accent: [91, 140, 255, 255], border: [255, 255, 255, 24], shadow: [0, 0, 0, 85], bar_height: 28.0, bar_margin_top: 0, bar_window_gap: 0, bar_margin_horizontal: 0, bar_radius: 0.0, material_radius: 14.0, panel_padding: 12.0, control_gap: 12.0, shadow_offset_y: 4.0, shadow_blur: 18.0, shadow_opacity: 0.20, } } } impl ShellTheme { // The bar has its own foreground tokens, matched to its dark glass surface. pub(crate) fn for_bar(mut self) -> Self { self.text_primary = self.bar_text_primary; self.text_muted = self.bar_text_muted; self } } #[derive(Clone, Debug, Default, Deserialize)] pub(crate) struct WallpaperConfig { pub(crate) path: Option, #[serde(default)] pub(crate) mode: WallpaperMode, } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub(crate) enum WallpaperMode { #[default] Fill, Fit, } #[derive(Debug, Default, Deserialize)] struct FereseConfig { #[serde(default)] appearance: AppearanceConfig, #[serde(default)] theme: ThemeConfig, #[serde(default)] status: StatusConfig, } #[derive(Debug, Default, Deserialize)] struct AppearanceConfig { corner_radius: Option, } #[derive(Debug, Default, Deserialize)] struct ThemeConfig { #[serde(default)] surface: SurfaceConfig, #[serde(default)] colors: ThemeColorsConfig, #[serde(default)] geometry: ThemeGeometryConfig, #[serde(default)] shadow: ThemeShadowConfig, #[serde(default)] typography: TypographyConfig, #[serde(default)] background: WallpaperConfig, } #[derive(Debug, Default, Deserialize)] struct SurfaceConfig { #[serde(default)] bar: BarConfig, #[serde(default)] popover: PopoverConfig, } #[derive(Debug, Deserialize)] #[serde(default)] struct BarConfig { background: String, // If omitted, preserve the alpha supplied in `background`. opacity: Option, text_primary: String, text_muted: String, } impl Default for BarConfig { fn default() -> Self { Self { background: "#1C202EF2".to_owned(), opacity: None, text_primary: "#F0F3FA".to_owned(), text_muted: "#AAB4C7".to_owned(), } } } #[derive(Debug, Deserialize)] struct PopoverConfig { #[serde(default = "default_popover_opacity")] opacity: f32, } impl Default for PopoverConfig { fn default() -> Self { Self { opacity: default_popover_opacity(), } } } const fn default_popover_opacity() -> f32 { 0.96 } #[derive(Debug, Default, Deserialize)] struct TypographyConfig { font_family: Option, } #[derive(Debug, Deserialize)] struct ThemeColorsConfig { #[serde(default = "default_surface_base")] surface_base: String, #[serde(default = "default_text_primary")] text_primary: String, #[serde(default = "default_text_muted")] text_muted: String, #[serde(default = "default_accent")] accent: String, #[serde(default = "default_border")] border: String, #[serde(default = "default_shadow")] shadow: String, } impl Default for ThemeColorsConfig { fn default() -> Self { Self { surface_base: default_surface_base(), text_primary: default_text_primary(), text_muted: default_text_muted(), accent: default_accent(), border: default_border(), shadow: default_shadow(), } } } #[derive(Debug, Deserialize)] struct ThemeGeometryConfig { #[serde(default = "default_bar_height")] top_bar_height: f32, #[serde(default = "default_bar_margin_top")] top_bar_margin_top: i32, #[serde(default = "default_bar_window_gap")] top_bar_window_gap: i32, #[serde(default = "default_bar_margin_horizontal")] top_bar_margin_horizontal: i32, #[serde(default = "default_bar_radius")] top_bar_radius: f32, #[serde(default = "default_panel_padding")] panel_padding: f32, #[serde(default = "default_control_gap")] control_gap: f32, } impl Default for ThemeGeometryConfig { fn default() -> Self { Self { top_bar_height: default_bar_height(), top_bar_margin_top: default_bar_margin_top(), top_bar_window_gap: default_bar_window_gap(), top_bar_margin_horizontal: default_bar_margin_horizontal(), top_bar_radius: default_bar_radius(), panel_padding: default_panel_padding(), control_gap: default_control_gap(), } } } #[derive(Debug, Default, Deserialize)] struct ThemeShadowConfig { #[serde(default)] soft: SoftShadowConfig, } #[derive(Debug, Deserialize)] struct SoftShadowConfig { #[serde(default = "default_shadow_offset_y")] offset_y: f32, #[serde(default = "default_shadow_blur")] blur: f32, #[serde(default = "default_shadow_opacity")] opacity: f32, } impl Default for SoftShadowConfig { fn default() -> Self { Self { offset_y: default_shadow_offset_y(), blur: default_shadow_blur(), opacity: default_shadow_opacity(), } } } pub(crate) fn load() -> ShellConfig { let Some(path) = config_path() else { return ShellConfig::default(); }; let Ok(source) = fs::read_to_string(&path) else { return ShellConfig::default(); }; match toml::from_str::(&source) { Ok(config) => { let mut theme = shell_theme(&config.theme); theme.material_radius = nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); ShellConfig { font_family: config.theme.typography.font_family, wallpaper: config.theme.background, theme, status: StatusConfig { low_battery_threshold: config.status.low_battery_threshold.min(100), ..config.status }, } } Err(error) => { eprintln!("ferese-shell: failed to read {}: {error}", path.display()); ShellConfig::default() } } } fn shell_theme(theme: &ThemeConfig) -> ShellTheme { let defaults = ShellTheme::default(); ShellTheme { material_radius: defaults.material_radius, bar_background: { let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background); if let Some(opacity) = theme.surface.bar.opacity { background[3] = (finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0) .round() as u8; } background }, bar_text_primary: parse_color(&theme.surface.bar.text_primary) .unwrap_or(defaults.bar_text_primary), bar_text_muted: parse_color(&theme.surface.bar.text_muted) .unwrap_or(defaults.bar_text_muted), surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base), surface_popover: { let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base); color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity()) .clamp(0.0, 1.0) * 255.0) .round() as u8; color }, text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary), text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted), accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent), border: parse_color(&theme.colors.border).unwrap_or(defaults.border), shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow), bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height), bar_margin_top: theme.geometry.top_bar_margin_top.max(0), bar_window_gap: theme.geometry.top_bar_window_gap.max(0), bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0), bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius), panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding), control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap), shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y), shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur), shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity) .clamp(0.0, 1.0), } } fn parse_color(value: &str) -> Option<[u8; 4]> { let value = value.strip_prefix('#')?; if value.len() != 6 && value.len() != 8 { return None; } let red = u8::from_str_radix(&value[0..2], 16).ok()?; let green = u8::from_str_radix(&value[2..4], 16).ok()?; let blue = u8::from_str_radix(&value[4..6], 16).ok()?; let alpha = if value.len() == 8 { u8::from_str_radix(&value[6..8], 16).ok()? } else { 255 }; Some([red, green, blue, alpha]) } fn positive_or(value: f32, fallback: f32) -> f32 { if value.is_finite() && value > 0.0 { value } else { fallback } } fn nonnegative_or(value: f32, fallback: f32) -> f32 { if value.is_finite() && value >= 0.0 { value } else { fallback } } fn finite_or(value: f32, fallback: f32) -> f32 { if value.is_finite() { value } else { fallback } } fn default_surface_base() -> String { "#111821".to_owned() } fn default_text_primary() -> String { "#F4F7FB".to_owned() } fn default_text_muted() -> String { "#7F8A98".to_owned() } fn default_accent() -> String { "#5B8CFF".to_owned() } fn default_border() -> String { "#FFFFFF18".to_owned() } fn default_shadow() -> String { "#00000055".to_owned() } const fn default_bar_height() -> f32 { 28.0 } const fn default_bar_window_gap() -> i32 { 0 } const fn default_bar_margin_top() -> i32 { 0 } const fn default_bar_margin_horizontal() -> i32 { 0 } const fn default_bar_radius() -> f32 { 0.0 } const fn default_panel_padding() -> f32 { 12.0 } const fn default_control_gap() -> f32 { 12.0 } const fn default_shadow_offset_y() -> f32 { 4.0 } const fn default_shadow_blur() -> f32 { 18.0 } const fn default_shadow_opacity() -> f32 { 0.20 } pub(crate) const fn default_background() -> [u8; 3] { DEFAULT_BACKGROUND } fn config_path() -> Option { if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { return Some(PathBuf::from(directory).join("ferese/config.toml")); } env::var_os("HOME") .filter(|value| !value.is_empty()) .map(PathBuf::from) .map(|home| home.join(".config/ferese/config.toml")) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_theme_without_rejecting_compositor_sections() { let config: FereseConfig = toml::from_str( r#" [layout] mode = "scrolling" [theme.typography] font_family = "JetBrainsMono Nerd Font" [theme.background] path = "/tmp/wallpaper.png" mode = "fit" "#, ) .unwrap(); assert_eq!( config.theme.typography.font_family.as_deref(), Some("JetBrainsMono Nerd Font") ); assert_eq!( config.theme.background.path, Some(PathBuf::from("/tmp/wallpaper.png")) ); assert_eq!(config.theme.background.mode, WallpaperMode::Fit); } #[test] fn defaults_to_the_ferese_visual_profile() { let config: FereseConfig = toml::from_str("").unwrap(); assert_eq!(config.theme.typography.font_family, None); assert_eq!(config.theme.background.path, None); assert_eq!(config.theme.background.mode, WallpaperMode::Fill); let theme = shell_theme(&config.theme); assert_eq!(theme.bar_margin_top, 0); assert_eq!(theme.bar_height, 28.0); assert_eq!(theme.bar_margin_horizontal, 0); assert_eq!(theme.bar_radius, 0.0); assert_eq!(theme.bar_background, ShellTheme::default().bar_background); assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary); assert_ne!(theme.for_bar().text_primary, theme.text_primary); } #[test] fn bar_palette_can_be_changed_without_changing_popovers() { let config: FereseConfig = toml::from_str( r##" [theme.surface.bar] background = "#EAECEEDD" text_primary = "#222222" text_muted = "#666666" "##, ) .unwrap(); let theme = shell_theme(&config.theme); assert_eq!(theme.bar_background, [234, 236, 238, 221]); assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]); assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]); assert_eq!(theme.text_primary, ShellTheme::default().text_primary); } #[test] fn bar_transparency_and_shadow_opacity_are_independent() { let config: FereseConfig = toml::from_str( r#" [theme.surface.bar] opacity = 0.6 [theme.shadow.soft] opacity = 0.07 "#, ) .unwrap(); let theme = shell_theme(&config.theme); assert_eq!(theme.bar_background[3], 153); assert_eq!(theme.shadow_opacity, 0.07); } }