Files
feresaria/compositor/src/config.rs
T

2278 lines
69 KiB
Rust

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<DaemonConfig>,
#[serde(default)]
animations: AnimationsConfig,
#[serde(default)]
layout: LayoutConfig,
#[serde(default)]
input: InputConfig,
#[serde(default)]
theme: ThemeConfig,
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
commands: HashMap<String, Vec<String>>,
#[serde(default)]
bindings: Vec<BindingConfig>,
#[serde(default)]
window_rules: Vec<WindowRuleConfig>,
#[serde(default)]
scrolling: ScrollingConfig,
#[serde(default)]
output_profiles: Vec<OutputProfileConfig>,
#[serde(default, rename = "status")]
_status: ShellStatusConfig,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub(crate) struct DaemonConfig {
pub command: Vec<String>,
#[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<bool>,
low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputProfile {
pub name: String,
pub outputs: Vec<OutputSettings>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings {
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
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<u32>,
}
#[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<OutputConfig>,
}
#[derive(Clone, Debug, Deserialize)]
struct OutputConfig {
#[serde(rename = "match")]
matcher: String,
#[serde(default = "default_true")]
enabled: bool,
mode: Option<String>,
#[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<BorderGradientConfig>,
}
#[derive(Debug, Deserialize)]
struct BorderGradientConfig {
from: String,
to: String,
#[serde(default)]
angle: f64,
}
impl BorderPaintConfig {
fn settings(&self, name: &str) -> Result<Option<BorderGradient>, 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<BorderGradient>,
pub focus_ring_gradient: Option<BorderGradient>,
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<f64>,
#[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<Self, ConfigError> {
toml::from_str(source).map_err(|source| ConfigError::Parse {
path: config_path().unwrap_or_default(),
source,
})
}
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
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<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeSurfaceConfig {
#[serde(default)]
bar: ThemeBarConfig,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeBarConfig {
opacity: Option<f64>,
}
#[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<String>),
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<String>,
argument: Option<String>,
#[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<String>,
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<String>,
#[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<LayoutModeValue>,
#[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<ColumnWidthValue>,
focus_strategy: Option<FocusStrategyValue>,
#[serde(default)]
width_presets: Vec<ColumnWidthValue>,
}
#[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<Self, ConfigError> {
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<GapConfig, ConfigError> {
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<ColumnWidth, ConfigError> {
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<Vec<ColumnWidth>, 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<InputSettings, ConfigError> {
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<Vec<Binding>, 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::<Result<Vec<_>, _>>()?;
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<Vec<WindowRule>, ConfigError> {
window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule)
}
pub fn output_profiles(&self) -> Result<Vec<OutputProfile>, 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::<Result<Vec<_>, ConfigError>>()?;
Ok(OutputProfile {
name: name.to_owned(),
outputs,
})
})
.collect()
}
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
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<f64, ConfigError> {
positive_animation_value(self.animations.speed, "speed")
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
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<SpringConfig, ConfigError> {
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<String, Vec<String>>) -> 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<xkb::Keymap, ConfigError> {
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<String, Vec<String>>,
keymap: &xkb::Keymap,
) -> Result<Binding, ConfigError> {
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<BindingIdentity, ConfigError> {
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<u32, ConfigError> {
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<String, Vec<String>>,
) -> Result<BindingAction, ConfigError> {
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<Direction, ConfigError> {
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<u8, ConfigError> {
match argument.parse() {
Ok(workspace) if workspace > 0 => Ok(workspace),
_ => Err(ConfigError::InvalidBinding(format!(
"invalid workspace {argument:?}"
))),
}
}
fn default_bindings() -> Vec<BindingConfig> {
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<String>,
action: impl Into<String>,
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<f64, ConfigError> {
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<f64, ConfigError> {
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<f64, ConfigError> {
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<f64, ConfigError> {
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<f64, ConfigError> {
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<RgbaColor, ConfigError> {
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<OutputModeRequest, String> {
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::<u16>()
.map_err(|_| format!("mode {value:?} has an invalid width"))?;
let height = height
.parse::<u16>()
.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::<f64>()
.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<ColumnWidth, ConfigError> {
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<PathBuf> {
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::<Config>("[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::<Config>("[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::<Config>("[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 = "[email protected]"
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());
}
}