Files
feresaria/shell/ferese-shell/src/config.rs
T
Samuel OnojaandSamuel Onoja 6b0d903ad0 Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-12 22:52:25 +01:00

900 lines
31 KiB
Rust

use std::fs;
use std::io::Read;
use std::path::PathBuf;
use serde::Deserialize;
const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)]
pub(crate) struct ShellConfig {
pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
pub(crate) theme_mode: ferese_config::theme::Mode,
pub(crate) status: StatusConfig,
pub(crate) panels: Vec<crate::panel::Panel>,
}
impl Default for ShellConfig {
fn default() -> Self {
let status = StatusConfig::default();
Self {
activities: Default::default(),
notifications: Default::default(),
animations: Default::default(),
font_family: None,
wallpaper: Default::default(),
theme: Default::default(),
theme_mode: Default::default(),
panels: vec![crate::panel::Panel::default()],
status,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct StatusConfig {
pub(crate) keybinding_guide: bool,
pub(crate) low_battery_threshold: u8,
pub(crate) settings_command: Option<Vec<String>>,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
keybinding_guide: true,
low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]),
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme {
pub(crate) appearance: ferese_config::theme::Appearance,
pub(crate) high_contrast: bool,
pub(crate) reduce_transparency: bool,
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_raised: [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) accent_gradient: Option<cosmic::iced::gradient::Linear>,
pub(crate) on_accent: [u8; 4],
pub(crate) border: [u8; 4],
pub(crate) shadow: [u8; 4],
pub(crate) bar_radius: f32,
pub(crate) material_radius: 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 {
appearance: Default::default(),
high_contrast: false,
reduce_transparency: false,
bar_background: [28, 32, 46, 255],
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised)
.expect("valid theme surface color"),
surface_popover: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
accent: [61, 123, 230, 255],
accent_gradient: None,
on_accent: [244, 247, 251, 255],
border: parse_color(&default_border()).expect("valid theme border color"),
shadow: [0, 0, 0, 85],
bar_radius: 14.0,
material_radius: 14.0,
control_gap: 12.0,
shadow_offset_y: 4.0,
shadow_blur: 18.0,
shadow_opacity: 0.20,
}
}
}
impl ShellTheme {
pub(crate) fn panel_opacity(self, custom: Option<f32>) -> f32 {
ferese_config::theme::effective_panel_opacity(
f32::from(self.bar_background[3]) / 255.,
custom,
self.reduce_transparency,
)
}
pub(crate) fn material_opacity(self) -> f32 {
if self.high_contrast {
1.0
} else {
f32::from(self.surface_popover[3]) / 255.0
}
}
pub(crate) fn palette(self) -> ferese_theme::Palette {
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base);
ferese_theme::Palette {
appearance: self.appearance,
high_contrast: self.high_contrast,
background: surface,
sidebar: surface,
card: surface,
text: color(self.text_primary),
muted: color(self.text_muted),
accent: color(self.accent),
accent_gradient: self.accent_gradient,
on_accent: color(self.on_accent),
radius: self.material_radius,
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
}
}
// The bar has its own foreground tokens, matched to its 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, Deserialize)]
pub(crate) struct WallpaperConfig {
#[serde(default = "default_wallpaper_path")]
pub(crate) path: Option<PathBuf>,
#[serde(default)]
pub(crate) mode: WallpaperMode,
}
fn default_wallpaper_path() -> Option<PathBuf> {
Some(PathBuf::from(ferese_config::default_wallpaper()))
}
impl Default for WallpaperConfig {
fn default() -> Self {
Self {
path: default_wallpaper_path(),
mode: WallpaperMode::default(),
}
}
}
#[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)]
activities: ferese_config::activities::Settings,
#[serde(default)]
panels: Option<Vec<crate::panel::Panel>>,
#[serde(default)]
notifications: ferese_config::notifications::NotificationConfig,
#[serde(default)]
animations: crate::motion::Settings,
#[serde(default)]
theme: ThemeConfig,
#[serde(default)]
status: StatusConfig,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
material: MaterialConfig,
#[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,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct BarConfig {
background: String,
text_primary: String,
text_muted: String,
}
impl Default for BarConfig {
fn default() -> Self {
Self {
background: "#1C202EF2".to_owned(),
text_primary: "#F0F3FA".to_owned(),
text_muted: "#AAB4C7".to_owned(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct MaterialConfig {
#[serde(default)]
style: String,
opacity: Option<f32>,
}
#[derive(Debug, Default, Deserialize)]
struct TypographyConfig {
font_family: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ThemeColorsConfig {
#[serde(default = "default_surface_base")]
surface_base: String,
#[serde(default = "default_surface_raised")]
surface_raised: 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_text_primary")]
on_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(),
surface_raised: default_surface_raised(),
text_primary: default_text_primary(),
text_muted: default_text_muted(),
accent: default_accent(),
on_accent: default_text_primary(),
border: default_border(),
shadow: default_shadow(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ThemeGeometryConfig {
#[serde(default, rename = "border_width")]
_border_width: Option<f64>,
#[serde(default, rename = "focus_ring_width")]
_focus_ring_width: Option<f64>,
#[serde(default, rename = "window_radius")]
_window_radius: Option<f64>,
#[serde(default)]
shell_radius: Option<f32>,
#[serde(default = "default_control_gap")]
control_gap: f32,
}
impl Default for ThemeGeometryConfig {
fn default() -> Self {
Self {
_border_width: None,
_focus_ring_width: None,
_window_radius: None,
shell_radius: None,
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() -> Result<ShellConfig, String> {
let Some(path) = config_path() else {
return Ok(ShellConfig::default());
};
load_path(&path)
}
fn load_path(path: &std::path::Path) -> Result<ShellConfig, String> {
match fs::symlink_metadata(path) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
}
let mut source = String::new();
fs::File::open(path)
.and_then(|file| {
file.take((ferese_config::file::LIMIT + 1) as u64)
.read_to_string(&mut source)
})
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
if source.len() > ferese_config::file::LIMIT {
return Err(format!("{} exceeds 60 KiB", path.display()));
}
parse_source(&source).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
}
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
let document = ferese_config::Document::parse(source)?;
ferese_config::theme::validate_overrides(&document).map_err(ferese_config::Error::from)?;
let snapshot = ferese_theme_client::service::current();
let mut config = parse_document(
&document.with_theme(&snapshot.presented),
snapshot.presented.appearance,
snapshot.mode,
)?;
config.apply_theme(&snapshot.presented);
Ok(config)
}
fn parse_document(
document: &ferese_config::Document,
appearance: ferese_config::theme::Appearance,
mode: ferese_config::theme::Mode,
) -> Result<ShellConfig, ferese_config::Error> {
match serde_json::from_value::<FereseConfig>(document.value().clone())
.map_err(|error| ferese_config::Error::from(error.to_string()))
{
Ok(config) => {
if let Some(panels) = &config.panels {
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
}
config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme);
theme.appearance = appearance;
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
theme.bar_radius = theme.material_radius;
Ok(ShellConfig {
activities: config.activities,
notifications: config.notifications,
animations: config.animations,
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
theme_mode: mode,
panels: config.panels.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
},
})
}
Err(error) => Err(error),
}
}
impl ShellConfig {
pub(crate) fn apply_theme(&mut self, theme: &ferese_config::theme::ResolvedTheme) {
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
self.theme = shell_theme(&config);
self.theme.appearance = theme.appearance;
self.theme.high_contrast = theme.accessibility.increase_contrast;
self.theme.reduce_transparency = theme.accessibility.reduce_transparency;
self.theme.accent_gradient = ferese_theme::Palette::from_resolved(theme).accent_gradient;
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
self.theme.bar_radius = self.theme.material_radius;
self.font_family = Some(theme.tokens.typography.font_family.clone());
self.wallpaper = config.background;
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
self.wallpaper.path = None;
}
}
}
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
let opacity = if theme.material.style == "translucent" {
finite_or(
theme
.material
.opacity
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY as f32),
ferese_config::DEFAULT_MATERIAL_OPACITY as f32,
)
.clamp(0.0, 1.0)
} else {
1.0
};
let alpha = (opacity * 255.0).round() as u8;
ShellTheme {
appearance: defaults.appearance,
high_contrast: defaults.high_contrast,
reduce_transparency: defaults.reduce_transparency,
material_radius: defaults.material_radius,
bar_background: {
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
background[3] = alpha;
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_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised),
surface_popover: {
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = alpha;
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),
accent_gradient: defaults.accent_gradient,
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_radius: nonnegative_or(theme.geometry.shell_radius.unwrap_or(14.0), defaults.bar_radius),
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),
}
}
pub(crate) 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 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_surface_raised() -> String {
ferese_config::theme::default_tokens().colors.surface_raised
}
fn default_text_primary() -> String {
"#F4F7FB".to_owned()
}
fn default_text_muted() -> String {
"#8793A2".to_owned()
}
fn default_accent() -> String {
"#3D7BE6".to_owned()
}
fn default_border() -> String {
ferese_config::theme::default_tokens().colors.border
}
fn default_shadow() -> String {
"#00000055".to_owned()
}
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
}
pub(crate) fn config_path() -> Option<PathBuf> {
ferese_config::config_path()
}
#[cfg(test)]
mod tests {
#[test]
fn morph_profile_uses_the_current_strict_schema() {
let config = parse_test_source(
"panel main { style morph; morph { idle-width 180; media-height 184; peek-on-hover #false; }; }\n",
)
.unwrap();
assert_eq!(config.panels[0].morph.idle_width, 180.);
assert_eq!(config.panels[0].morph.media_height, 184.);
assert!(!config.panels[0].morph.peek_on_hover);
assert!(
parse_test_source("panel main { style morph; }\n").unwrap().panels[0]
.morph
.peek_on_hover
);
assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err());
assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err());
}
#[test]
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
for key in [
"top-bar-radius",
"top-bar-height",
"top-bar-margin-top",
"top-bar-margin-horizontal",
"top-bar-window-clearance",
"shell-raduis",
] {
let source = format!("theme {{ geometry {{ {key} 8; }}; }}");
assert!(
parse_test_source(&source)
.unwrap_err()
.to_string()
.contains("unknown field")
);
for section in ["", "light", "dark"] {
let layer = if section.is_empty() {
format!("geometry {{ {key} 8; }}")
} else {
format!("{section} {{ geometry {{ {key} 8; }}; }}")
};
assert!(
parse_source(&format!("theme {{ {layer}; }}"))
.unwrap_err()
.to_string()
.contains(&key.replace('-', "_"))
);
}
}
parse_test_source("theme { geometry { border-width 1; focus-ring-width 2; window-radius 12; shell-radius 14; control-gap 8; }; }").unwrap();
}
#[test]
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
for surface in ["flat", "floating"] {
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
let source = format!(
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}"
);
let document = ferese_config::Document::parse(&source).unwrap();
let resolved = ferese_config::theme::resolve(
&document,
std::path::Path::new("/tmp"),
jiff::Timestamp::now(),
|_| unreachable!(),
)
.unwrap()
.theme;
let mut config = parse_test_source(&source).unwrap();
config.apply_theme(&resolved);
let effective = config.theme.panel_opacity(config.panels[0].background_opacity);
assert_eq!(effective, if reduce { 1. } else { opacity });
assert_eq!(effective, resolved.panel_opacity(config.panels[0].background_opacity));
}
}
}
}
#[test]
fn rejects_removed_physics_keys_before_publication() {
for property in ["spring", "viewport-spring"] {
for coefficient in ["mass", "stiffness", "damping", "damping-ratio"] {
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
let error = parse_test_source(&source).unwrap_err().to_string();
assert!(error.contains("unknown field"), "{error}");
}
}
}
use super::*;
#[test]
fn removed_desktop_widgets_allow_startup_and_reload() {
let source = "desktop-widgets { clock { enabled #true; }; }";
parse_source(source).unwrap();
parse_test_source(source).unwrap();
}
fn parse_test_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
let document = ferese_config::Document::parse(source)?;
parse_document(&document, Default::default(), Default::default())
}
#[test]
fn keybinding_guide_is_enabled_until_explicitly_disabled() {
assert!(parse_test_source("").unwrap().status.keybinding_guide);
assert!(
!parse_test_source("status { keybinding-guide #false; }")
.unwrap()
.status
.keybinding_guide
);
}
#[test]
fn config_loading_uses_only_authored_panel_settings() {
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
let defaults = parse_test_source("").unwrap();
assert_eq!(ShellConfig::default().panels, defaults.panels);
let source = "panel main { style floating; geometry { height 36; edge-margin 6; }; center { group title { item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
let current = parse_test_source(source).unwrap();
let panel = &current.panels[0];
assert_eq!(panel.surface(), PanelSurface::None);
assert_eq!(panel.group_surface(), GroupSurface::Island);
assert_eq!(panel.resolved_geometry().height, 36.);
assert_eq!(panel.resolved_geometry().edge_margin, 6);
assert_eq!(panel.end.groups[0].island_padding, 9.5);
assert!(!panel.center.groups[0].items[0].visible);
assert_eq!(
panel.end.groups[0].items[0].kind,
ItemKind::Battery { percentage: false }
);
assert_eq!(parse_test_source("").unwrap().panels, defaults.panels);
}
#[test]
fn authored_composition_rejects_invalid_reload_data() {
let source = r#"panel "custom" { style "floating"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
let config = parse_test_source(source).unwrap();
assert_eq!(config.panels[0].id.0, "custom");
assert_eq!(config.panels[0].surface(), crate::panel::PanelSurface::None);
assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
assert!(parse_test_source("panel one; panel two;").is_err());
assert!(parse_test_source("panel one { edge left; }").is_err());
}
#[test]
fn group_surfaces_do_not_change_modal_opacity() {
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
let islands = parse_test_source(
"panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }",
)
.unwrap();
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
assert!(parse_test_source("panel main { surface islands; }").is_err());
assert!(parse_test_source("status { bar-layout islands; }").is_err());
}
#[test]
fn island_padding_accepts_fractional_values_independently_of_panel_geometry() {
for padding in [0., 4., 4.5, 32.] {
let source = format!(
"panel main {{ geometry {{ inner-padding 12; }}; end {{ group status {{ island-padding {padding}; }}; }}; }}"
);
let config = parse_test_source(&source).unwrap();
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
assert_eq!(config.panels[0].resolved_geometry().inner_padding, 12.);
}
for value in ["-1", "32.5", "\"small\""] {
assert!(
parse_test_source(&format!(
"panel main {{ end {{ group status {{ island-padding {value}; }}; }}; }}"
))
.is_err()
);
}
}
#[test]
fn parses_theme_without_rejecting_compositor_sections() {
let config: FereseConfig = ferese_config::from_str(
r#"layout {
mode "scrolling"
}
theme {
typography {
font-family "JetBrainsMono Nerd Font"
}
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 shell_radius_unifies_surfaces() {
for radius in [0.0, 18.0] {
let config = parse_test_source(&format!(
"theme {{\n geometry {{\n shell-radius {radius}\n window-radius 23\n }}\n}}"
))
.unwrap();
assert_eq!(config.theme.material_radius, radius);
assert_eq!(config.theme.bar_radius, radius);
}
}
#[test]
fn loader_reports_invalid_configuration_without_replacing_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let source =
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
std::fs::write(&path, source).unwrap();
let error = super::load_path(&path).unwrap_err();
assert!(error.contains("bar_layout"), "{error}");
assert!(error.contains(path.to_str().unwrap()), "{error}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
std::fs::write(&path, "panel main {").unwrap();
assert!(super::load_path(&path).is_err());
let oversized = " ".repeat(ferese_config::file::LIMIT + 1);
std::fs::write(&path, &oversized).unwrap();
assert!(super::load_path(&path).unwrap_err().contains("60 KiB"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), oversized);
}
#[test]
fn startup_distinguishes_absent_configuration_from_read_errors() {
let directory = tempfile::tempdir().unwrap();
assert!(super::load_path(&directory.path().join("missing.kdl")).is_ok());
let error = super::load_path(directory.path()).unwrap_err();
assert!(error.contains("cannot read"), "{error}");
let path = directory.path().join("config.kdl");
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
assert!(
super::load_path(&path).is_err(),
"a broken config symlink is a read error"
);
}
#[test]
fn defaults_to_the_ferese_visual_profile() {
let config: FereseConfig = ferese_config::from_str("").unwrap();
assert_eq!(config.theme.typography.font_family, None);
assert_eq!(
config.theme.background.path,
Some(PathBuf::from(ferese_config::default_wallpaper()))
);
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme);
assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
assert_eq!(crate::panel::PanelGeometry::default().height, 36.0);
assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
assert_eq!(theme.bar_radius, 14.0);
assert_eq!(theme.bar_background[3], 255);
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 = ferese_config::from_str(
r##"theme {
colors { surface-raised "#314159"; }
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, 255]);
assert_eq!(theme.surface_raised, [49, 65, 89, 255]);
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 shell_opacity_is_shared_and_solid_remains_opaque() {
let config: FereseConfig = ferese_config::from_str(
r#"theme {
material { style "translucent"; opacity 0.6; }
shadow {
soft {
opacity 0.07
}
}
}
"#,
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background[3], 153);
assert_eq!(theme.surface_popover[3], 153);
assert_eq!(theme.shadow_opacity, 0.07);
let mut config = config;
config.theme.material.style = "solid".into();
let solid = shell_theme(&config.theme);
assert_eq!(solid.bar_background[3], 255);
assert_eq!(solid.surface_popover[3], 255);
}
}