use std::collections::{HashMap, HashSet}; use std::env; use std::error::Error; use std::fmt; use std::fs; use std::io; use std::path::PathBuf; use ferese_animation::SpringConfig; use ferese_core::LayoutMode; use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy}; use serde::Deserialize; use smithay::input::keyboard::{Keycode, keysyms, xkb}; use crate::window_rules::{self, WindowRule, WindowRuleConfig}; #[derive(Debug, Default, Deserialize)] pub struct Config { #[serde(default)] desktop_widgets: ferese_core::desktop::DesktopWidgets, #[serde(default)] pub(crate) autostart: Vec, #[serde(default)] animations: AnimationsConfig, #[serde(default)] layout: LayoutConfig, #[serde(default)] input: InputConfig, #[serde(default)] theme: ThemeConfig, #[serde(default)] appearance: AppearanceConfig, #[serde(default)] commands: HashMap>, #[serde(default)] bindings: Vec, #[serde(default)] window_rules: Vec, #[serde(default)] scrolling: ScrollingConfig, #[serde(default)] output_profiles: Vec, #[serde(default, rename = "status")] _status: ShellStatusConfig, } #[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct DaemonConfig { pub command: Vec, #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_true")] pub restart: bool, #[serde(default)] pub nested: bool, } // Validate shell-only fields too, before publishing an accepted source. #[derive(Debug, Default, Deserialize)] #[allow(dead_code)] struct ShellStatusConfig { battery_percentage: Option, low_battery_threshold: Option, settings_command: Option>, } #[derive(Clone, Debug, PartialEq)] pub struct OutputProfile { pub name: String, pub outputs: Vec, } #[derive(Clone, Debug, PartialEq)] pub struct OutputSettings { pub matcher: String, pub enabled: bool, pub mode: Option, pub scale: f64, pub transform: OutputTransform, pub position: Option<[i32; 2]>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OutputModeRequest { pub width: u16, pub height: u16, pub refresh_millihertz: Option, } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum OutputTransform { #[default] Normal, #[serde(rename = "rotate_90")] Rotate90, #[serde(rename = "rotate_180")] Rotate180, #[serde(rename = "rotate_270")] Rotate270, Flipped, #[serde(rename = "flipped_90")] Flipped90, #[serde(rename = "flipped_180")] Flipped180, #[serde(rename = "flipped_270")] Flipped270, } #[derive(Clone, Debug, Deserialize)] struct OutputProfileConfig { name: String, #[serde(default)] outputs: Vec, } #[derive(Clone, Debug, Deserialize)] struct OutputConfig { #[serde(rename = "match")] matcher: String, #[serde(default = "default_true")] enabled: bool, mode: Option, #[serde(default = "default_output_scale")] scale: f64, #[serde(default)] transform: OutputTransform, position: Option<[i32; 2]>, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct RgbaColor(pub [f32; 4]); #[derive(Clone, Copy, Debug, PartialEq)] pub struct BorderGradient { pub from: RgbaColor, pub to: RgbaColor, pub angle: f64, } #[derive(Debug, Default, Deserialize)] struct BorderPaintConfig { gradient: Option, } #[derive(Debug, Deserialize)] struct BorderGradientConfig { from: String, to: String, #[serde(default)] angle: f64, } impl BorderPaintConfig { fn settings(&self, name: &str) -> Result, ConfigError> { let (from_name, to_name, angle_name) = if name == "focus_ring" { ( "focus_ring.gradient.from", "focus_ring.gradient.to", "focus_ring.gradient.angle", ) } else { ( "border.gradient.from", "border.gradient.to", "border.gradient.angle", ) }; self.gradient .as_ref() .map(|gradient| { Ok(BorderGradient { from: parse_color(&gradient.from, from_name)?, to: parse_color(&gradient.to, to_name)?, angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0), }) }) .transpose() } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct ThemeSettings { pub border_width: f64, pub focus_ring_width: f64, pub border_color: RgbaColor, pub accent_color: RgbaColor, pub border_gradient: Option, pub focus_ring_gradient: Option, pub shadow_color: RgbaColor, pub surface_base_color: RgbaColor, pub panel_opacity: f64, pub inactive_dim: InactiveDimSettings, pub window_radius: f64, pub shadow_offset_y: f64, pub shadow_blur: f64, pub shadow_opacity: f64, pub material_style: MaterialStyle, pub backdrop_blur: f64, pub material_radius: f64, pub panel_radius: f64, } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum MaterialStyle { Translucent, #[default] Solid, } #[derive(Debug, Default, Deserialize)] struct AppearanceConfig { corner_radius: Option, #[serde(default)] inactive_dim: InactiveDimConfig, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct InactiveDimSettings { pub enabled: bool, pub amount: f64, pub duration_ms: f64, } #[derive(Debug, Deserialize)] #[serde(default)] struct InactiveDimConfig { enabled: bool, amount: f64, duration_ms: f64, } impl Default for InactiveDimConfig { fn default() -> Self { Self { enabled: false, amount: 0.15, duration_ms: 150.0, } } } #[derive(Debug, Default, Deserialize)] struct ThemeConfig { #[serde(default)] border: BorderPaintConfig, #[serde(default)] focus_ring: BorderPaintConfig, #[serde(default)] typography: OverviewTypographyConfig, #[serde(default)] background: crate::wallpaper::WallpaperConfig, #[serde(default)] surface: ThemeSurfaceConfig, #[serde(default)] colors: ThemeColorsConfig, #[serde(default)] geometry: ThemeGeometryConfig, #[serde(default)] shadow: ThemeShadowConfig, #[serde(default)] material: ThemeMaterialConfig, } impl Config { pub(crate) fn parse_source(source: &str) -> Result { toml::from_str(source).map_err(|source| ConfigError::Parse { path: config_path().unwrap_or_default(), source, }) } pub(crate) fn runtime_config(&self) -> Result { self.desktop_widgets .validate() .map_err(ConfigError::InvalidBinding)?; for daemon in &self.autostart { if daemon .command .first() .is_none_or(|program| program.trim().is_empty()) { return Err(ConfigError::InvalidBinding( "autostart command must contain a program".into(), )); } } let input_settings = self.input_settings()?; let bindings = self.bindings(&input_settings)?; Ok(crate::RuntimeConfig { autostart: self.autostart.clone(), layout_mode: self.layout_mode(), gap_config: self.gap_config()?, input_settings, bindings, window_rules: self.window_rules()?, theme_settings: self.theme_settings()?, default_column_width: self.default_column_width()?, scrolling_focus_strategy: self.scrolling_focus_strategy(), column_width_presets: self.width_presets()?, animations_enabled: self.animations_enabled(), animation_speed: self.animation_speed()?, spring_config: self.spring_config()?, viewport_spring_config: self.viewport_spring_config()?, output_profiles: self.output_profiles()?, wallpaper: self.wallpaper_settings(), overview_font_family: self.overview_font_family(), }) } pub(crate) fn overview_font_family(&self) -> String { self.theme .typography .font_family .clone() .unwrap_or_else(|| "sans-serif".into()) } pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig { self.theme.background.clone() } } #[derive(Debug, Default, Deserialize)] struct OverviewTypographyConfig { font_family: Option, } #[derive(Debug, Default, Deserialize)] struct ThemeSurfaceConfig { #[serde(default)] bar: ThemeBarConfig, } #[derive(Debug, Default, Deserialize)] struct ThemeBarConfig { opacity: Option, } #[derive(Debug, Deserialize)] struct ThemeMaterialConfig { #[serde(default)] style: MaterialStyle, #[serde(default = "default_backdrop_blur")] blur_radius: f64, } impl Default for ThemeMaterialConfig { fn default() -> Self { Self { style: MaterialStyle::Solid, blur_radius: default_backdrop_blur(), } } } fn default_backdrop_blur() -> f64 { 12.0 } #[derive(Debug, Deserialize)] struct ThemeColorsConfig { #[serde(default = "default_surface_base_color")] surface_base: String, #[serde(default = "default_border_color")] border: String, #[serde(default = "default_accent_color")] accent: String, #[serde(default = "default_shadow_color")] shadow: String, } impl Default for ThemeColorsConfig { fn default() -> Self { Self { surface_base: default_surface_base_color(), border: default_border_color(), accent: default_accent_color(), shadow: default_shadow_color(), } } } #[derive(Debug, Default, Deserialize)] struct ThemeShadowConfig { #[serde(default)] soft: SoftShadowConfig, } #[derive(Debug, Deserialize)] struct SoftShadowConfig { #[serde(default = "default_shadow_offset_y")] offset_y: f64, #[serde(default = "default_shadow_blur")] blur: f64, #[serde(default = "default_shadow_opacity")] opacity: f64, } impl Default for SoftShadowConfig { fn default() -> Self { Self { offset_y: default_shadow_offset_y(), blur: default_shadow_blur(), opacity: default_shadow_opacity(), } } } #[derive(Debug, Deserialize)] struct ThemeGeometryConfig { #[serde(default = "default_border_width")] border_width: f64, #[serde(default = "default_focus_ring_width")] focus_ring_width: f64, #[serde(default = "default_window_radius")] window_radius: f64, #[serde(default)] top_bar_radius: f64, } impl Default for ThemeGeometryConfig { fn default() -> Self { Self { border_width: default_border_width(), focus_ring_width: default_focus_ring_width(), window_radius: default_window_radius(), top_bar_radius: 0.0, } } } #[derive(Clone, Debug, PartialEq)] pub struct Binding { modifiers: BindingModifiers, trigger: BindingTrigger, pub action: BindingAction, } #[derive(Clone, Debug, PartialEq)] enum BindingTrigger { Keysym(u32), Physical(Keycode), } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] struct BindingModifiers { logo: bool, ctrl: bool, alt: bool, shift: bool, } #[derive(Clone, Debug, PartialEq)] pub enum BindingAction { Spawn(Vec), Close, Exit, Focus(ferese_layout::Direction), Move(ferese_layout::Direction), Resize(ferese_layout::Direction), SwitchWorkspace(u8), MoveToWorkspace(u8), ToggleFullscreen, ToggleMaximized, ToggleLayout, CycleColumnWidth, CenterColumn, Consume, Expel, ToggleFloating, ToggleOverview, } impl Binding { pub fn matches( &self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool, ) -> bool { self.modifiers == BindingModifiers { logo, ctrl, alt, shift, } && match self.trigger { BindingTrigger::Keysym(expected) => keysyms.contains(&expected), BindingTrigger::Physical(expected) => expected == keycode, } } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] #[serde(rename_all = "lowercase")] enum BindingMatch { #[default] Keysym, Physical, } #[derive(Clone, Debug, Deserialize)] struct BindingConfig { keys: String, #[serde(default, rename = "match")] match_mode: BindingMatch, action: Option, argument: Option, #[serde(default)] disabled: bool, } #[derive(Clone, Debug, PartialEq)] pub struct InputSettings { pub focus_follows_mouse: bool, pub xkb_layout: String, pub xkb_variant: String, pub xkb_options: Vec, pub repeat_rate: i32, pub repeat_delay_ms: i32, pub touchpad: TouchpadSettings, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct TouchpadSettings { pub tap: bool, pub natural_scroll: bool, pub disable_while_typing: bool, } #[derive(Debug, Deserialize)] struct InputConfig { #[serde(default)] focus_follows_mouse: bool, #[serde(default = "default_xkb_layout")] xkb_layout: String, #[serde(default)] xkb_variant: String, #[serde(default)] xkb_options: Vec, #[serde(default = "default_repeat_rate")] repeat_rate: i32, #[serde(default = "default_repeat_delay")] repeat_delay_ms: i32, #[serde(default)] touchpad: TouchpadConfig, } impl Default for InputConfig { fn default() -> Self { Self { focus_follows_mouse: false, xkb_layout: default_xkb_layout(), xkb_variant: String::new(), xkb_options: Vec::new(), repeat_rate: default_repeat_rate(), repeat_delay_ms: default_repeat_delay(), touchpad: TouchpadConfig::default(), } } } #[derive(Debug, Deserialize)] struct TouchpadConfig { #[serde(default = "enabled_by_default")] tap: bool, #[serde(default = "enabled_by_default")] natural_scroll: bool, #[serde(default = "enabled_by_default")] disable_while_typing: bool, } impl Default for TouchpadConfig { fn default() -> Self { Self { tap: true, natural_scroll: true, disable_while_typing: true, } } } #[derive(Debug, Deserialize)] struct AnimationsConfig { #[serde(default = "enabled_by_default")] enabled: bool, #[serde(default)] reduced_motion: bool, #[serde(default = "default_animation_speed")] speed: f64, #[serde(default)] spring: SpringSettings, #[serde(default)] viewport_spring: ViewportSpringSettings, } impl Default for AnimationsConfig { fn default() -> Self { Self { enabled: true, reduced_motion: false, speed: 1.0, spring: SpringSettings::default(), viewport_spring: ViewportSpringSettings::default(), } } } #[derive(Debug, Deserialize)] struct ViewportSpringSettings { #[serde(default = "default_viewport_mass")] mass: f64, #[serde(default = "default_viewport_stiffness")] stiffness: f64, #[serde(default = "default_viewport_damping_ratio")] damping_ratio: f64, } impl Default for ViewportSpringSettings { fn default() -> Self { Self { mass: default_viewport_mass(), stiffness: default_viewport_stiffness(), damping_ratio: default_viewport_damping_ratio(), } } } #[derive(Debug, Deserialize)] struct SpringSettings { #[serde(default = "default_spring_mass")] mass: f64, #[serde(default = "default_spring_stiffness")] stiffness: f64, #[serde(default = "default_spring_damping")] damping: f64, } impl Default for SpringSettings { fn default() -> Self { Self { mass: default_spring_mass(), stiffness: default_spring_stiffness(), damping: default_spring_damping(), } } } #[derive(Debug, Deserialize)] struct LayoutConfig { mode: Option, #[serde(default = "default_inner_gap")] inner_gap: f64, #[serde(default = "default_outer_gap")] outer_gap: f64, #[serde(default)] smart_gaps: bool, } impl Default for LayoutConfig { fn default() -> Self { Self { mode: None, inner_gap: default_inner_gap(), outer_gap: default_outer_gap(), smart_gaps: false, } } } #[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "lowercase")] enum LayoutModeValue { Scrolling, Tree, } #[derive(Debug, Default, Deserialize)] struct ScrollingConfig { default_column_width: Option, focus_strategy: Option, #[serde(default)] width_presets: Vec, } #[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "snake_case")] enum FocusStrategyValue { Minimal, CenterOnFocus, Paged, } #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] enum ColumnWidthValue { Proportion(f64), Named(String), } #[derive(Debug)] pub enum ConfigError { Read { path: PathBuf, source: io::Error, }, Parse { path: PathBuf, source: toml::de::Error, }, InvalidColumnWidth { field: &'static str, value: String, }, InvalidAnimationValue { field: &'static str, value: f64, }, InvalidLayoutValue { field: &'static str, value: f64, }, InvalidInputValue { field: &'static str, value: String, }, InvalidBinding(String), InvalidWindowRule(String), InvalidThemeValue { field: &'static str, value: String, }, InvalidOutputProfile(String), } impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Read { path, source } => { write!(formatter, "failed to read {}: {source}", path.display()) } Self::Parse { path, source } => { write!(formatter, "failed to parse {}: {source}", path.display()) } Self::InvalidColumnWidth { field, value } => write!( formatter, "invalid scrolling.{field} {value}; expected a positive number or \"full\"" ), Self::InvalidAnimationValue { field, value } => { write!(formatter, "invalid animations.{field} value {value}") } Self::InvalidLayoutValue { field, value } => { write!(formatter, "invalid layout.{field} value {value}") } Self::InvalidInputValue { field, value } => { write!(formatter, "invalid input.{field} value {value}") } Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"), Self::InvalidWindowRule(message) => write!(formatter, "invalid window rule: {message}"), Self::InvalidThemeValue { field, value } => { write!(formatter, "invalid theme.{field} value {value}") } Self::InvalidOutputProfile(message) => { write!(formatter, "invalid output profile: {message}") } } } } impl Error for ConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Read { source, .. } => Some(source), Self::Parse { source, .. } => Some(source), Self::InvalidColumnWidth { .. } | Self::InvalidAnimationValue { .. } | Self::InvalidLayoutValue { .. } | Self::InvalidInputValue { .. } | Self::InvalidBinding(_) | Self::InvalidWindowRule(_) | Self::InvalidThemeValue { .. } | Self::InvalidOutputProfile(_) => None, } } } impl Config { pub fn load() -> Result { let Some(path) = config_path() else { return Ok(Self::default()); }; let source = match fs::read_to_string(&path) { Ok(source) => source, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), Err(source) => return Err(ConfigError::Read { path, source }), }; toml::from_str(&source).map_err(|source| ConfigError::Parse { path, source }) } pub fn layout_mode(&self) -> LayoutMode { match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) { LayoutModeValue::Scrolling => LayoutMode::Scrolling, LayoutModeValue::Tree => LayoutMode::Tree, } } pub fn gap_config(&self) -> Result { let inner = nonnegative_layout_value(self.layout.inner_gap, "inner_gap")?; let outer = nonnegative_layout_value(self.layout.outer_gap, "outer_gap")?; Ok(GapConfig { inner, outer, smart: self.layout.smart_gaps, }) } pub fn default_column_width(&self) -> Result { parse_column_width( self.scrolling .default_column_width .as_ref() .unwrap_or(&ColumnWidthValue::Proportion(0.5)), "default_column_width", ) } pub fn width_presets(&self) -> Result, ConfigError> { if self.scrolling.width_presets.is_empty() { return Ok(vec![ ColumnWidth::Proportion(1.0 / 3.0), ColumnWidth::Proportion(0.5), ColumnWidth::Proportion(2.0 / 3.0), ColumnWidth::Full, ]); } self.scrolling .width_presets .iter() .map(|value| parse_column_width(value, "width_presets")) .collect() } pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy { match self .scrolling .focus_strategy .unwrap_or(FocusStrategyValue::Minimal) { FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal, FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center, FocusStrategyValue::Paged => ViewportFocusStrategy::Paged, } } pub fn input_settings(&self) -> Result { if self.input.xkb_layout.trim().is_empty() { return Err(ConfigError::InvalidInputValue { field: "xkb_layout", value: self.input.xkb_layout.clone(), }); } if self.input.repeat_rate <= 0 { return Err(ConfigError::InvalidInputValue { field: "repeat_rate", value: self.input.repeat_rate.to_string(), }); } if self.input.repeat_delay_ms < 0 { return Err(ConfigError::InvalidInputValue { field: "repeat_delay_ms", value: self.input.repeat_delay_ms.to_string(), }); } Ok(InputSettings { focus_follows_mouse: self.input.focus_follows_mouse, xkb_layout: self.input.xkb_layout.clone(), xkb_variant: self.input.xkb_variant.clone(), xkb_options: self.input.xkb_options.clone(), repeat_rate: self.input.repeat_rate, repeat_delay_ms: self.input.repeat_delay_ms, touchpad: TouchpadSettings { tap: self.input.touchpad.tap, natural_scroll: self.input.touchpad.natural_scroll, disable_while_typing: self.input.touchpad.disable_while_typing, }, }) } pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { let mut commands = HashMap::from([ ("terminal".to_owned(), vec!["foot".to_owned()]), ( "screenshot".to_owned(), vec!["ferese-screenshot".to_owned()], ), ]); commands.extend(self.commands.clone()); validate_commands(&commands)?; let keymap = physical_keymap(input)?; let mut bindings = default_bindings() .into_iter() .map(|binding| parse_binding(&binding, &commands, &keymap)) .collect::, _>>()?; let mut supplied = HashSet::new(); for configured in &self.bindings { let identity = binding_identity(configured, &keymap)?; if !supplied.insert(identity.clone()) { return Err(ConfigError::InvalidBinding(format!( "duplicate binding {:?}", configured.keys ))); } bindings.retain(|binding| binding_identity_for_runtime(binding) != identity); if configured.disabled { if configured.action.is_some() || configured.argument.is_some() { return Err(ConfigError::InvalidBinding(format!( "disabled binding {:?} cannot have an action or argument", configured.keys ))); } } else { bindings.push(parse_binding(configured, &commands, &keymap)?); } } Ok(bindings) } pub fn window_rules(&self) -> Result, ConfigError> { window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule) } pub fn output_profiles(&self) -> Result, ConfigError> { let mut names = HashSet::new(); self.output_profiles .iter() .map(|profile| { let name = profile.name.trim(); if name.is_empty() { return Err(ConfigError::InvalidOutputProfile( "profile names cannot be empty".to_owned(), )); } if !names.insert(name.to_owned()) { return Err(ConfigError::InvalidOutputProfile(format!( "duplicate profile name {name:?}" ))); } if profile.outputs.is_empty() { return Err(ConfigError::InvalidOutputProfile(format!( "profile {name:?} has no outputs" ))); } let mut matchers = HashSet::new(); let outputs = profile .outputs .iter() .map(|output| { let matcher = output.matcher.trim(); if matcher.is_empty() { return Err(ConfigError::InvalidOutputProfile(format!( "profile {name:?} contains an empty output matcher" ))); } if !matchers.insert(matcher.to_owned()) { return Err(ConfigError::InvalidOutputProfile(format!( "profile {name:?} repeats output matcher {matcher:?}" ))); } if !output.scale.is_finite() || output.scale <= 0.0 { return Err(ConfigError::InvalidOutputProfile(format!( "profile {name:?} output {matcher:?} has invalid scale {}", output.scale ))); } Ok(OutputSettings { matcher: matcher.to_owned(), enabled: output.enabled, mode: output .mode .as_deref() .map(parse_output_mode) .transpose() .map_err(ConfigError::InvalidOutputProfile)?, scale: output.scale, transform: output.transform, position: output.position, }) }) .collect::, ConfigError>>()?; Ok(OutputProfile { name: name.to_owned(), outputs, }) }) .collect() } pub fn theme_settings(&self) -> Result { let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?; let focus_ring_width = nonnegative_theme_value( self.theme.geometry.focus_ring_width, "geometry.focus_ring_width", )?; let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?; let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?; let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?; let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; let material_radius = nonnegative_theme_value( self.appearance.corner_radius.unwrap_or(14.0), "appearance.corner_radius", )?; Ok(ThemeSettings { border_width, focus_ring_width, border_color: parse_color(&self.theme.colors.border, "colors.border")?, accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, border_gradient: self.theme.border.settings("border")?, focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?, shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, surface_base_color: parse_color( &self.theme.colors.surface_base, "colors.surface_base", )?, panel_opacity: unit_theme_value( self.theme.surface.bar.opacity.unwrap_or(0.78), "surface.bar.opacity", )?, inactive_dim: InactiveDimSettings { enabled: self.appearance.inactive_dim.enabled, amount: unit_theme_value( self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount", )?, duration_ms: nonnegative_theme_value( self.appearance.inactive_dim.duration_ms, "appearance.inactive_dim.duration_ms", )?, }, window_radius, shadow_offset_y, shadow_blur, shadow_opacity, material_style: self.theme.material.style, backdrop_blur: nonnegative_theme_value( self.theme.material.blur_radius, "material.blur_radius", )? .min(32.0), material_radius, panel_radius: nonnegative_theme_value( self.theme.geometry.top_bar_radius, "geometry.top_bar_radius", )?, }) } pub fn animations_enabled(&self) -> bool { self.animations.enabled && !self.animations.reduced_motion } pub fn animation_speed(&self) -> Result { positive_animation_value(self.animations.speed, "speed") } pub fn spring_config(&self) -> Result { let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?; let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; let damping = self.animations.spring.damping; if !damping.is_finite() || damping < 0.0 { return Err(ConfigError::InvalidAnimationValue { field: "spring.damping", value: damping, }); } Ok(SpringConfig { mass, stiffness, damping, ..SpringConfig::default() }) } pub fn viewport_spring_config(&self) -> Result { let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; let stiffness = positive_animation_value( self.animations.viewport_spring.stiffness, "viewport_spring.stiffness", )?; let damping_ratio = positive_animation_value( self.animations.viewport_spring.damping_ratio, "viewport_spring.damping_ratio", )?; Ok(SpringConfig { mass, stiffness, damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(), ..SpringConfig::default() }) } } #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct BindingIdentity { modifiers: BindingModifiers, match_mode: BindingMatch, key: u32, } fn validate_commands(commands: &HashMap>) -> Result<(), ConfigError> { for (name, argv) in commands { if name.trim().is_empty() { return Err(ConfigError::InvalidBinding( "command names cannot be empty".to_owned(), )); } if argv.is_empty() || argv[0].is_empty() { return Err(ConfigError::InvalidBinding(format!( "command {name:?} must contain a program" ))); } } Ok(()) } fn physical_keymap(input: &InputSettings) -> Result { let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); xkb::Keymap::new_from_names( &context, "", "", &input.xkb_layout, &input.xkb_variant, options, xkb::KEYMAP_COMPILE_NO_FLAGS, ) .ok_or_else(|| ConfigError::InvalidBinding("failed to compile the XKB keymap".to_owned())) } fn parse_binding( configured: &BindingConfig, commands: &HashMap>, keymap: &xkb::Keymap, ) -> Result { if configured.disabled { return Err(ConfigError::InvalidBinding(format!( "disabled binding {:?} cannot be executed", configured.keys ))); } let identity = binding_identity(configured, keymap)?; let action = configured.action.as_deref().ok_or_else(|| { ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)) })?; let action = parse_action(action, configured.argument.as_deref(), commands)?; let trigger = match identity.match_mode { BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), }; Ok(Binding { modifiers: identity.modifiers, trigger, action, }) } fn binding_identity( configured: &BindingConfig, keymap: &xkb::Keymap, ) -> Result { let (modifiers, key) = parse_chord(&configured.keys)?; let key = match configured.match_mode { BindingMatch::Keysym => parse_keysym(&key)?, BindingMatch::Physical => keymap .key_by_name(&key.to_ascii_uppercase()) .map(Keycode::raw) .ok_or_else(|| { ConfigError::InvalidBinding(format!( "unknown XKB physical key name {key:?} in {:?}", configured.keys )) })?, }; Ok(BindingIdentity { modifiers, match_mode: configured.match_mode, key, }) } fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity { let (match_mode, key) = match binding.trigger { BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), }; BindingIdentity { modifiers: binding.modifiers, match_mode, key, } } fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> { let mut modifiers = BindingModifiers::default(); let mut key = None; for component in chord.split('+').map(str::trim) { if component.is_empty() { return Err(ConfigError::InvalidBinding(format!( "invalid key chord {chord:?}" ))); } let slot = match component.to_ascii_lowercase().as_str() { "super" | "logo" | "mod4" => Some(&mut modifiers.logo), "ctrl" | "control" => Some(&mut modifiers.ctrl), "alt" => Some(&mut modifiers.alt), "shift" => Some(&mut modifiers.shift), _ => None, }; if let Some(slot) = slot { if *slot { return Err(ConfigError::InvalidBinding(format!( "duplicate modifier in {chord:?}" ))); } *slot = true; } else if key.replace(component.to_owned()).is_some() { return Err(ConfigError::InvalidBinding(format!( "key chord {chord:?} contains more than one key" ))); } } let key = key.ok_or_else(|| { ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")) })?; Ok((modifiers, key)) } fn parse_keysym(name: &str) -> Result { let normalized = match name { "Enter" | "enter" => "Return".to_owned(), "Space" | "space" => "space".to_owned(), "[" => "bracketleft".to_owned(), "]" => "bracketright".to_owned(), name if name.len() == 1 => name.to_ascii_lowercase(), name => name.to_owned(), }; let mut symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_NO_FLAGS); if symbol.raw() == keysyms::KEY_NoSymbol { symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE); } if symbol.raw() == keysyms::KEY_NoSymbol { Err(ConfigError::InvalidBinding(format!( "unknown keysym {name:?}" ))) } else { Ok(symbol.raw()) } } fn parse_action( action: &str, argument: Option<&str>, commands: &HashMap>, ) -> Result { let no_argument = || { if argument.is_some() { Err(ConfigError::InvalidBinding(format!( "action {action:?} does not accept an argument" ))) } else { Ok(()) } }; let required_argument = || { argument.ok_or_else(|| { ConfigError::InvalidBinding(format!("action {action:?} requires an argument")) }) }; match action { "spawn" => { let command = required_argument()?; let argv = commands.get(command).ok_or_else(|| { ConfigError::InvalidBinding(format!("unknown command {command:?}")) })?; Ok(BindingAction::Spawn(argv.clone())) } "close" => { no_argument()?; Ok(BindingAction::Close) } "exit" => { no_argument()?; Ok(BindingAction::Exit) } "focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)), "move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)), "resize" => Ok(BindingAction::Resize( parse_direction(required_argument()?)?, )), "workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace( required_argument()?, )?)), "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace( required_argument()?, )?)), "toggle-maximized" => { no_argument()?; Ok(BindingAction::ToggleMaximized) } "toggle-fullscreen" => { no_argument()?; Ok(BindingAction::ToggleFullscreen) } "toggle-layout" => { no_argument()?; Ok(BindingAction::ToggleLayout) } "cycle-column-width" => { no_argument()?; Ok(BindingAction::CycleColumnWidth) } "center-column" => { no_argument()?; Ok(BindingAction::CenterColumn) } "consume" => { no_argument()?; Ok(BindingAction::Consume) } "expel" => { no_argument()?; Ok(BindingAction::Expel) } "toggle-floating" => { no_argument()?; Ok(BindingAction::ToggleFloating) } "toggle-overview" => { no_argument()?; Ok(BindingAction::ToggleOverview) } _ => Err(ConfigError::InvalidBinding(format!( "unknown action {action:?}" ))), } } fn parse_direction(argument: &str) -> Result { match argument { "left" => Ok(Direction::Left), "right" => Ok(Direction::Right), "up" => Ok(Direction::Up), "down" => Ok(Direction::Down), _ => Err(ConfigError::InvalidBinding(format!( "invalid direction {argument:?}" ))), } } fn parse_workspace(argument: &str) -> Result { match argument.parse() { Ok(workspace) if workspace > 0 => Ok(workspace), _ => Err(ConfigError::InvalidBinding(format!( "invalid workspace {argument:?}" ))), } } fn default_bindings() -> Vec { let mut bindings = vec![ binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Shift+S", "spawn", Some("screenshot")), binding("Super+Q", "close", None), binding("Super+F", "toggle-maximized", None), binding("Super+Shift+F", "toggle-fullscreen", None), binding("Super+M", "toggle-layout", None), binding("Super+R", "cycle-column-width", None), binding("Super+C", "center-column", None), binding("Super+[", "consume", None), binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), binding("Super+Shift+E", "exit", None), ]; for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction))); bindings.push(binding( "Super+Shift+".to_owned() + key, "move", Some(direction), )); bindings.push(binding( "Super+Ctrl+".to_owned() + key, "resize", Some(direction), )); } for workspace in 1..=9 { let workspace = workspace.to_string(); bindings.push(binding( format!("Super+{workspace}"), "workspace", Some(&workspace), )); bindings.push(binding( format!("Super+Shift+{workspace}"), "move-to-workspace", Some(&workspace), )); } bindings } fn binding( keys: impl Into, action: impl Into, argument: Option<&str>, ) -> BindingConfig { BindingConfig { keys: keys.into(), match_mode: BindingMatch::Keysym, action: Some(action.into()), argument: argument.map(str::to_owned), disabled: false, } } fn positive_animation_value(value: f64, field: &'static str) -> Result { if value.is_finite() && value > 0.0 { Ok(value) } else { Err(ConfigError::InvalidAnimationValue { field, value }) } } fn nonnegative_layout_value(value: f64, field: &'static str) -> Result { if value.is_finite() && value >= 0.0 { Ok(value) } else { Err(ConfigError::InvalidLayoutValue { field, value }) } } fn nonnegative_theme_value(value: f64, field: &'static str) -> Result { if value.is_finite() && value >= 0.0 { Ok(value) } else { Err(ConfigError::InvalidThemeValue { field, value: value.to_string(), }) } } fn finite_theme_value(value: f64, field: &'static str) -> Result { if value.is_finite() { Ok(value) } else { Err(ConfigError::InvalidThemeValue { field, value: value.to_string(), }) } } fn unit_theme_value(value: f64, field: &'static str) -> Result { if value.is_finite() && (0.0..=1.0).contains(&value) { Ok(value) } else { Err(ConfigError::InvalidThemeValue { field, value: value.to_string(), }) } } fn parse_color(value: &str, field: &'static str) -> Result { let digits = value.strip_prefix('#').unwrap_or(value); if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) { return Err(ConfigError::InvalidThemeValue { field, value: value.to_owned(), }); } let parse_channel = |offset| u8::from_str_radix(&digits[offset..offset + 2], 16).ok(); let Some((red, green, blue)) = parse_channel(0) .zip(parse_channel(2)) .zip(parse_channel(4)) .map(|((red, green), blue)| (red, green, blue)) else { return Err(ConfigError::InvalidThemeValue { field, value: value.to_owned(), }); }; let alpha = if digits.len() == 8 { parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue { field, value: value.to_owned(), })? } else { u8::MAX }; Ok(RgbaColor([ f32::from(red) / 255.0, f32::from(green) / 255.0, f32::from(blue) / 255.0, f32::from(alpha) / 255.0, ])) } const fn enabled_by_default() -> bool { true } const fn default_inner_gap() -> f64 { 10.0 } const fn default_outer_gap() -> f64 { 4.0 } fn default_border_color() -> String { "#FFFFFF18".to_owned() } fn default_surface_base_color() -> String { "#111821".to_owned() } fn default_accent_color() -> String { "#5B8CFF".to_owned() } fn default_shadow_color() -> String { "#00000055".to_owned() } const fn default_border_width() -> f64 { 1.0 } const fn default_focus_ring_width() -> f64 { 2.0 } const fn default_window_radius() -> f64 { 14.0 } const fn default_shadow_offset_y() -> f64 { 4.0 } const fn default_shadow_blur() -> f64 { 18.0 } const fn default_shadow_opacity() -> f64 { 0.20 } fn default_xkb_layout() -> String { "us".to_owned() } const fn default_repeat_rate() -> i32 { 25 } const fn default_repeat_delay() -> i32 { 600 } const fn default_animation_speed() -> f64 { 1.0 } const fn default_spring_mass() -> f64 { 1.0 } const fn default_spring_stiffness() -> f64 { 700.0 } const fn default_spring_damping() -> f64 { 53.0 } const fn default_viewport_mass() -> f64 { 1.0 } const fn default_viewport_stiffness() -> f64 { 320.0 } const fn default_viewport_damping_ratio() -> f64 { 1.0 } const fn default_true() -> bool { true } const fn default_output_scale() -> f64 { 1.0 } fn parse_output_mode(value: &str) -> Result { let (size, refresh) = value .trim() .split_once('@') .map_or((value.trim(), None), |(size, refresh)| { (size, Some(refresh)) }); let (width, height) = size .split_once('x') .ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?; let width = width .parse::() .map_err(|_| format!("mode {value:?} has an invalid width"))?; let height = height .parse::() .map_err(|_| format!("mode {value:?} has an invalid height"))?; if width == 0 || height == 0 { return Err(format!("mode {value:?} dimensions must be positive")); } let refresh_millihertz = refresh .map(|refresh| { let hertz = refresh .parse::() .map_err(|_| format!("mode {value:?} has an invalid refresh rate"))?; if !hertz.is_finite() || hertz <= 0.0 || hertz > u32::MAX as f64 / 1_000.0 { return Err(format!("mode {value:?} has an invalid refresh rate")); } Ok((hertz * 1_000.0).round() as u32) }) .transpose()?; Ok(OutputModeRequest { width, height, refresh_millihertz, }) } fn parse_column_width( value: &ColumnWidthValue, field: &'static str, ) -> Result { match value { ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => { Ok(ColumnWidth::Proportion(*value)) } ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => { Ok(ColumnWidth::Full) } value => Err(ConfigError::InvalidColumnWidth { field, value: match value { ColumnWidthValue::Proportion(value) => value.to_string(), ColumnWidthValue::Named(value) => format!("{value:?}"), }, }), } } pub(crate) 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 desktop_clock_settings_are_validated_before_live_publication() { assert!( parse( "[desktop_widgets.clock]\nenabled = true\nanchor = 'center'\ntime_format = '%H:%M'" ) .runtime_config() .is_ok() ); for source in [ "[desktop_widgets.clock]\nwidth = 8192", "[desktop_widgets.clock]\nopacity = nan", "[desktop_widgets.clock]\ntime_format = '%'", "[desktop_widgets.clock]\ntime_zone = 'invalid/zone'", ] { assert!(parse(source).runtime_config().is_err()); } assert!(Config::parse_source("[desktop_widgets.clock]\nanchor = 'wrong'").is_err()); } fn parse(source: &str) -> Config { toml::from_str(source).unwrap() } #[test] fn defaults_to_half_width_scrolling_columns() { let config = parse(""); assert_eq!(config.layout_mode(), LayoutMode::Scrolling); assert_eq!( config.default_column_width().unwrap(), ColumnWidth::Proportion(0.5) ); assert_eq!(config.gap_config().unwrap(), GapConfig::default()); } #[test] fn accepts_full_and_numeric_column_widths() { let full = parse("[scrolling]\ndefault_column_width = \"full\""); let numeric = parse("[scrolling]\ndefault_column_width = 1.0"); assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full); assert_eq!( numeric.default_column_width().unwrap(), ColumnWidth::Proportion(1.0) ); } #[test] fn parses_width_presets_and_supplies_defaults() { let defaults = parse(""); let configured = parse("[scrolling]\nwidth_presets = [0.5, 1.0, \"full\"]"); assert_eq!(defaults.width_presets().unwrap().len(), 4); assert_eq!( configured.width_presets().unwrap(), vec![ ColumnWidth::Proportion(0.5), ColumnWidth::Proportion(1.0), ColumnWidth::Full ] ); } #[test] fn parses_scrolling_focus_strategy() { let minimal = parse(""); let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\""); let paged = parse("[scrolling]\nfocus_strategy = \"paged\""); assert_eq!( paged.scrolling_focus_strategy(), ViewportFocusStrategy::Paged ); assert_eq!( minimal.scrolling_focus_strategy(), ViewportFocusStrategy::Minimal ); assert_eq!( centered.scrolling_focus_strategy(), ViewportFocusStrategy::Center ); } #[test] fn rejects_non_positive_or_unknown_column_widths() { let zero = parse("[scrolling]\ndefault_column_width = 0.0"); let unknown = parse("[scrolling]\ndefault_column_width = \"wide\""); assert!(zero.default_column_width().is_err()); assert!(unknown.default_column_width().is_err()); } #[test] fn parses_animation_policy_and_reduced_motion() { let config = parse( "[animations]\nspeed = 1.5\nreduced_motion = true\n\n[animations.spring]\nmass = 2.0\nstiffness = 500.0\ndamping = 40.0", ); assert!(!config.animations_enabled()); assert_eq!(config.animation_speed().unwrap(), 1.5); assert_eq!( config.spring_config().unwrap(), SpringConfig { mass: 2.0, stiffness: 500.0, damping: 40.0, ..SpringConfig::default() } ); } #[test] fn rejects_invalid_animation_numbers() { let speed = parse("[animations]\nspeed = 0.0"); let damping = parse("[animations.spring]\ndamping = -1.0"); assert!(speed.animation_speed().is_err()); assert!(damping.spring_config().is_err()); } #[test] fn parses_and_validates_layout_gaps() { let configured = parse("[layout]\ninner_gap = 6.0\nouter_gap = 14.0\nsmart_gaps = true"); let invalid = parse("[layout]\nouter_gap = -1.0"); assert_eq!( configured.gap_config().unwrap(), GapConfig { inner: 6.0, outer: 14.0, smart: true, } ); assert!(invalid.gap_config().is_err()); } #[test] fn parses_and_validates_theme_window_tokens() { let configured = parse( "[theme.colors]\nborder = \"#11223344\"\naccent = \"#AABBCC\"\nshadow = \"#01020380\"\n\n[theme.geometry]\nborder_width = 1.5\nfocus_ring_width = 3.0\nwindow_radius = 12.0\n\n[theme.shadow.soft]\noffset_y = -2.0\nblur = 24.0\nopacity = 0.4\n\n[theme.material]\nstyle = \"translucent\"", ); let invalid_color = parse("[theme.colors]\naccent = \"blue\""); let invalid_radius = parse("[theme.geometry]\nwindow_radius = -1.0"); let invalid_opacity = parse("[theme.shadow.soft]\nopacity = 1.1"); assert_eq!( configured.theme_settings().unwrap(), ThemeSettings { border_width: 1.5, focus_ring_width: 3.0, border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]), accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]), border_gradient: None, focus_ring_gradient: None, shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), panel_opacity: 0.78, inactive_dim: InactiveDimSettings { enabled: false, amount: 0.15, duration_ms: 150.0 }, window_radius: 12.0, shadow_offset_y: -2.0, shadow_blur: 24.0, shadow_opacity: 0.4, material_style: MaterialStyle::Translucent, backdrop_blur: 12.0, material_radius: 14.0, panel_radius: 0.0, } ); assert!(invalid_color.theme_settings().is_err()); assert!(invalid_radius.theme_settings().is_err()); assert!(invalid_opacity.theme_settings().is_err()); assert!(toml::from_str::("[theme.material]\nstyle = \"mist\"").is_err()); } #[test] fn materials_default_to_solid_and_reject_removed_style() { assert_eq!( parse("").theme_settings().unwrap().material_style, MaterialStyle::Solid ); assert!(toml::from_str::("[theme.material]\nstyle = \"glass\"").is_err()); } #[test] fn border_gradients_are_optional_and_independent() { let defaults = parse("").theme_settings().unwrap(); assert_eq!(defaults.border_gradient, None); assert_eq!(defaults.focus_ring_gradient, None); let configured = parse("[theme.focus_ring.gradient]\nfrom = '#e5c890'\nto = '#b98d5880'\nangle = -45") .theme_settings() .unwrap(); let gradient = configured.focus_ring_gradient.unwrap(); assert_eq!(configured.border_gradient, None); assert_eq!(gradient.angle, 315.0); assert_eq!(gradient.to.0[3], 128.0 / 255.0); let border = parse("[theme.border.gradient]\nfrom = '#112233'\nto = '#445566'") .theme_settings() .unwrap(); assert_eq!(border.border_gradient.unwrap().angle, 0.0); assert_eq!(border.focus_ring_gradient, None); assert_eq!(configured.border_color, defaults.border_color); assert_eq!(configured.accent_color, defaults.accent_color); } #[test] fn border_gradients_reject_invalid_colors_and_nonfinite_angles() { for settings in [ "from = 'invalid'\nto = '#445566'", "from = '#112233'\nto = 'invalid'", "from = '#112233'\nto = '#445566'\nangle = nan", "from = '#112233'\nto = '#445566'\nangle = inf", ] { assert!( parse(&format!("[theme.focus_ring.gradient]\n{settings}")) .theme_settings() .is_err() ); } assert!(toml::from_str::("[theme.border.gradient]\nfrom = '#112233'").is_err()); } #[test] fn material_color_comes_from_surface_base() { let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"") .theme_settings() .unwrap(); assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0])); } #[test] fn bar_opacity_uses_config_and_rejects_invalid_values() { assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78); for opacity in [0.0, 0.65, 1.0] { assert_eq!( parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) .theme_settings() .unwrap() .panel_opacity, opacity ); } for opacity in ["-0.1", "1.1", "nan", "inf"] { assert!( parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) .theme_settings() .is_err() ); } } #[test] fn backdrop_blur_is_configurable_without_glass_settings() { assert_eq!(parse("").theme_settings().unwrap().backdrop_blur, 12.0); assert_eq!( parse("[theme.material]\nstyle = \"translucent\"\nblur_radius = 100") .theme_settings() .unwrap() .backdrop_blur, 32.0 ); assert_eq!( parse("[theme.material]\nblur_radius = 0") .theme_settings() .unwrap() .backdrop_blur, 0.0 ); for value in ["-1", "nan", "inf"] { assert!( parse(&format!("[theme.material]\nblur_radius = {value}")) .theme_settings() .is_err() ); } } #[test] fn inactive_dimming_is_opt_in_and_configurable() { let defaults = parse("").theme_settings().unwrap().inactive_dim; assert!(!defaults.enabled); assert_eq!(defaults.amount, 0.15); assert_eq!(defaults.duration_ms, 150.0); let settings = parse("[appearance.inactive_dim]\nenabled = true\namount = 0.25\nduration_ms = 100") .theme_settings() .unwrap() .inactive_dim; assert!(settings.enabled); assert_eq!(settings.amount, 0.25); assert_eq!(settings.duration_ms, 100.0); for (key, value) in [ ("amount", "-0.1"), ("amount", "1.1"), ("amount", "nan"), ("duration_ms", "-1"), ("duration_ms", "inf"), ] { assert!( parse(&format!("[appearance.inactive_dim]\n{key} = {value}")) .theme_settings() .is_err() ); } } #[test] fn parses_input_and_touchpad_settings() { let config = parse( "[input]\nfocus_follows_mouse = true\nxkb_layout = \"us,de\"\nxkb_variant = \",nodeadkeys\"\nxkb_options = [\"grp:alt_shift_toggle\"]\nrepeat_rate = 30\nrepeat_delay_ms = 450\n\n[input.touchpad]\ntap = false\nnatural_scroll = false\ndisable_while_typing = true", ); assert_eq!( config.input_settings().unwrap(), InputSettings { focus_follows_mouse: true, xkb_layout: "us,de".to_owned(), xkb_variant: ",nodeadkeys".to_owned(), xkb_options: vec!["grp:alt_shift_toggle".to_owned()], repeat_rate: 30, repeat_delay_ms: 450, touchpad: TouchpadSettings { tap: false, natural_scroll: false, disable_while_typing: true, }, } ); } #[test] fn rejects_invalid_input_settings() { let layout = parse("[input]\nxkb_layout = \"\""); let rate = parse("[input]\nrepeat_rate = 0"); let delay = parse("[input]\nrepeat_delay_ms = -1"); assert!(layout.input_settings().is_err()); assert!(rate.input_settings().is_err()); assert!(delay.input_settings().is_err()); } #[test] fn supplies_complete_v0_bindings_and_terminal_command() { let config = parse(""); let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); assert_eq!(bindings.len(), 43); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), ] { assert!(bindings.iter().any(|binding| binding.modifiers.logo && binding.modifiers.shift == shift && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_f) && binding.action == action)); } assert!( bindings .iter() .any(|binding| binding.action == BindingAction::Exit) ); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return) && binding.action == BindingAction::Spawn(vec!["foot".to_owned()]) })); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.modifiers.shift && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s) && binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned()]) })); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Tab) && binding.action == BindingAction::ToggleOverview })); } #[test] fn replaces_and_unbinds_default_bindings() { let replaced = parse( "[commands]\nterm = [\"foot\", \"--app-id\", \"work\"]\n\n[[bindings]]\nkeys = \"Super+Enter\"\naction = \"spawn\"\nargument = \"term\"", ); let unbound = parse("[[bindings]]\nkeys = \"Super+Q\"\ndisabled = true"); let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); assert_eq!(bindings.len(), 43); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec![ "foot".to_owned(), "--app-id".to_owned(), "work".to_owned(), ]) })); let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); assert_eq!(bindings.len(), 42); assert!( !bindings .iter() .any(|binding| binding.action == BindingAction::Close) ); } #[test] fn accepts_physical_xkb_key_names() { let config = parse( "[[bindings]]\nkeys = \"Super+AD06\"\nmatch = \"physical\"\naction = \"focus\"\nargument = \"left\"", ); let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); assert!(bindings.iter().any(|binding| { matches!(binding.trigger, BindingTrigger::Physical(_)) && binding.action == BindingAction::Focus(Direction::Left) })); } #[test] fn rejects_duplicate_or_invalid_user_bindings() { let duplicate = parse( "[[bindings]]\nkeys = \"Super+Q\"\naction = \"close\"\n\n[[bindings]]\nkeys = \"logo+q\"\naction = \"close\"", ); let missing_command = parse( "[[bindings]]\nkeys = \"Super+Enter\"\naction = \"spawn\"\nargument = \"missing\"", ); let invalid_argument = parse("[[bindings]]\nkeys = \"Super+Q\"\naction = \"close\"\nargument = \"left\""); for config in [duplicate, missing_command, invalid_argument] { let input = config.input_settings().unwrap(); assert!(config.bindings(&input).is_err()); } } #[test] fn parses_and_validates_window_rules() { let config = parse( "[[window_rules]]\napp_id = \"org.example.Editor\"\nworkspace = 3\nfloating = true\nwidth = 900.0\nheight = 600.0\nfullscreen = false", ); let rules = config.window_rules().unwrap(); let result = window_rules::resolve( &rules, Some("org.example.editor.desktop"), Some("Document"), false, ); assert_eq!(result.workspace, Some(3)); assert_eq!(result.floating, Some(true)); assert_eq!(result.width, Some(900.0)); assert_eq!(result.height, Some(600.0)); assert_eq!(result.fullscreen, Some(false)); } #[test] fn rejects_invalid_window_rule_configuration() { let catch_all = parse("[[window_rules]]\nfloating = true"); let zero_workspace = parse("[[window_rules]]\napp_id = \"editor\"\nworkspace = 0"); assert!(catch_all.window_rules().is_err()); assert!(zero_workspace.window_rules().is_err()); } #[test] fn derives_critical_viewport_damping() { let config = parse(""); let spring = config.viewport_spring_config().unwrap(); assert_eq!(spring.mass, 1.0); assert_eq!(spring.stiffness, 320.0); assert!((spring.damping - 35.777_087_64).abs() < 0.000_001); } #[test] fn parses_output_profiles() { let config = parse( r#" [[output_profiles]] name = "docked" [[output_profiles.outputs]] match = "HDMI-A-1" mode = "3840x2160@119.998" scale = 1.6 position = [0, 0] [[output_profiles.outputs]] match = "eDP-1" enabled = false transform = "rotate_90" "#, ); assert_eq!( config.output_profiles().unwrap(), vec![OutputProfile { name: "docked".to_owned(), outputs: vec![ OutputSettings { matcher: "HDMI-A-1".to_owned(), enabled: true, mode: Some(OutputModeRequest { width: 3840, height: 2160, refresh_millihertz: Some(119_998), }), scale: 1.6, transform: OutputTransform::Normal, position: Some([0, 0]), }, OutputSettings { matcher: "eDP-1".to_owned(), enabled: false, mode: None, scale: 1.0, transform: OutputTransform::Rotate90, position: None, }, ], }] ); } #[test] fn rejects_invalid_output_profiles() { let invalid_scale = parse( "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nscale = 0.0", ); let invalid_mode = parse( "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nmode = \"native\"", ); let duplicate = parse( "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"", ); assert!(invalid_scale.output_profiles().is_err()); assert!(invalid_mode.output_profiles().is_err()); assert!(duplicate.output_profiles().is_err()); } }