Files
feresaria/compositor/src/config.rs
T
2026-10-01 20:47:05 +01:00

1101 lines
40 KiB
Rust

mod appearance;
mod bindings;
mod input_motion;
mod layout;
mod outputs;
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt;
use std::path::PathBuf;
use appearance::*;
pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings};
use bindings::*;
pub use bindings::{Binding, BindingAction};
pub(crate) use bindings::{BindingSet, physical_keymap};
use ferese_animation::SpringConfig;
use ferese_core::LayoutMode;
use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
pub use input_motion::InputSettings;
use input_motion::*;
use outputs::*;
pub use outputs::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb};
use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
notifications: ferese_core::notifications::NotificationConfig,
#[serde(default)]
lock_screen: crate::session_lock::IdleSettings,
#[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)]
workspaces: WorkspacesConfig,
#[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>>,
}
impl Config {
#[cfg(test)]
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
ferese_config::from_str(source).map_err(|source| ConfigError::Parse {
path: config_path().unwrap_or_default(),
source,
})
}
pub(crate) fn resolved_theme_settings(
theme: &ferese_config::theme::ResolvedTheme,
) -> Result<ThemeSettings, ConfigError> {
let config = Self {
theme: serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap())
.expect("resolved theme matches runtime schema"),
..Self::default()
};
config.theme_settings()
}
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.notifications.validate().map_err(ConfigError::Binding)?;
self.desktop_widgets.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart {
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
return Err(ConfigError::Binding("autostart command must contain a program".into()));
}
}
let input_settings = self.input_settings()?;
let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig {
lock_idle: self.lock_screen.validate()?,
autostart: self.autostart.clone(),
layout_mode: self.layout_mode(),
workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth,
gap_config: self.gap_config()?,
input_settings,
bindings,
window_rules: self.window_rules()?,
theme_settings: self.theme_settings()?,
inactive_dim: self.inactive_dim_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 WorkspacesConfig {
#[serde(default)]
auto_back_and_forth: bool,
}
#[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 {
#[cfg(test)]
Parse {
path: PathBuf,
source: ferese_config::Error,
},
ColumnWidth {
field: &'static str,
value: String,
},
AnimationValue {
field: &'static str,
value: f64,
},
LayoutValue {
field: &'static str,
value: f64,
},
InputValue {
field: &'static str,
value: String,
},
Binding(String),
WindowRule(String),
ThemeValue {
field: &'static str,
value: String,
},
OutputProfile(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
#[cfg(test)]
Self::Parse { path, source } => {
write!(formatter, "failed to parse {}: {source}", path.display())
}
Self::ColumnWidth { field, value } => write!(
formatter,
"invalid scrolling.{field} {value}; expected a positive number or \"full\""
),
Self::AnimationValue { field, value } => {
write!(formatter, "invalid animations.{field} value {value}")
}
Self::LayoutValue { field, value } => {
write!(formatter, "invalid layout.{field} value {value}")
}
Self::InputValue { field, value } => {
write!(formatter, "invalid input.{field} value {value}")
}
Self::Binding(message) => write!(formatter, "invalid binding: {message}"),
Self::WindowRule(message) => write!(formatter, "invalid window rule: {message}"),
Self::ThemeValue { field, value } => {
write!(formatter, "invalid theme.{field} value {value}")
}
Self::OutputProfile(message) => {
write!(formatter, "invalid output profile: {message}")
}
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
#[cfg(test)]
Self::Parse { source, .. } => Some(source),
Self::ColumnWidth { .. }
| Self::AnimationValue { .. }
| Self::LayoutValue { .. }
| Self::InputValue { .. }
| Self::Binding(_)
| Self::WindowRule(_)
| Self::ThemeValue { .. }
| Self::OutputProfile(_) => None,
}
}
}
impl Config {
pub fn window_rules(&self) -> Result<Vec<WindowRule>, ConfigError> {
window_rules::validate(&self.window_rules).map_err(ConfigError::WindowRule)
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct BindingIdentity {
modifiers: BindingModifiers,
match_mode: BindingMatch,
key: u32,
}
const fn enabled_by_default() -> bool {
true
}
const fn default_inner_gap() -> f64 {
10.0
}
const fn default_outer_gap() -> f64 {
4.0
}
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
}
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
}
pub(crate) fn config_path() -> Option<PathBuf> {
ferese_config::config_path()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcut_hint_has_a_default_binding_and_can_be_rebound() {
let bindings = parse("").runtime_config().unwrap().bindings;
assert!(bindings.iter().any(|binding| binding.modifiers.logo
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F1)
&& binding.action == BindingAction::ToggleKeybindingGuide));
let bindings = parse(
"binding keys=\"Super+F1\" disabled=#true\nbinding keys=\"Super+F2\" action=\"toggle-keybinding-guide\"\n",
)
.runtime_config()
.unwrap()
.bindings;
assert!(
!bindings
.iter()
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F1))
);
assert!(
bindings
.iter()
.any(|binding| binding.trigger == BindingTrigger::Keysym(keysyms::KEY_F2)
&& binding.action == BindingAction::ToggleKeybindingGuide)
);
}
#[test]
fn workspace_back_and_forth_config_is_optional_and_bindable() {
let defaults = Config::default().runtime_config().unwrap();
assert!(!defaults.workspace_auto_back_and_forth);
assert!(
defaults
.bindings
.iter()
.any(|binding| binding.action == BindingAction::WorkspaceBackAndForth)
);
let configured = parse(
"workspaces {\n auto-back-and-forth #true\n}\nbinding \"Super+BackSpace\" \"workspace-back-and-forth\"\n",
)
.runtime_config()
.unwrap();
assert!(configured.workspace_auto_back_and_forth);
assert_eq!(
configured
.bindings
.iter()
.filter(|binding| binding.action == BindingAction::WorkspaceBackAndForth)
.count(),
2
);
assert!(parse_action("workspace-back-and-forth", Some("2"), &HashMap::new()).is_err());
assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err());
}
#[test]
fn focus_history_actions_are_bindable_and_have_defaults() {
let defaults = Config::default().runtime_config().unwrap();
for action in [
BindingAction::FocusLastWindow,
BindingAction::FocusMru(false),
BindingAction::FocusMru(true),
] {
assert!(defaults.bindings.iter().any(|binding| binding.action == action));
}
for (name, action) in [
("focus-last-window", BindingAction::FocusLastWindow),
("focus-mru-next", BindingAction::FocusMru(false)),
("focus-mru-previous", BindingAction::FocusMru(true)),
] {
assert_eq!(parse_action(name, None, &HashMap::new()).unwrap(), action);
assert!(parse_action(name, Some("left"), &HashMap::new()).is_err());
}
let bindings = &defaults.bindings;
assert!(bindings.iter().any(|binding| binding.matches(
23_u32.into(),
&[keysyms::KEY_Tab],
false,
false,
true,
false
) && binding.action == BindingAction::FocusMru(false)));
assert!(bindings.iter().any(|binding| binding.matches(
23_u32.into(),
&[keysyms::KEY_Tab],
false,
false,
true,
true
) && binding.action == BindingAction::FocusMru(true)));
}
#[test]
fn packaged_and_custom_kdl_pass_runtime_validation() {
Config::parse_source(include_str!("../../packaging/config.kdl"))
.unwrap()
.runtime_config()
.unwrap();
let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n";
let config = Config::parse_source(source).unwrap();
config.runtime_config().unwrap();
assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96);
assert_eq!(config.desktop_widgets.clock.outputs, ["DP-1"]);
assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0]));
}
#[test]
fn desktop_clock_settings_are_validated_before_live_publication() {
assert!(
parse(
"desktop-widgets {\n clock {\n enabled #true\n anchor \"center\"\n time-format \"%H:%M\"\n }\n}\n"
)
.runtime_config()
.is_ok()
);
for source in [
"desktop-widgets {\n clock {\n width 8192\n }\n}\n",
"desktop-widgets {\n clock {\n opacity #nan\n }\n}\n",
"desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n",
"desktop-widgets {\n clock {\n time-zone \"invalid/zone\"\n }\n}\n",
] {
assert!(
Config::parse_source(source)
.and_then(|config| config.runtime_config())
.is_err()
);
}
assert!(Config::parse_source("desktop-widgets { clock { anchor \"wrong\"; }; }").is_err());
}
fn parse(source: &str) -> Config {
ferese_config::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 {\n default-column-width \"full\"\n}\n");
let numeric = parse("scrolling {\n default-column-width 1.0\n}\n");
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 {\n width-presets 0.5 1.0 \"full\"\n}\n");
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 {\n focus-strategy \"center_on_focus\"\n}\n");
let paged = parse("scrolling {\n focus-strategy \"paged\"\n}\n");
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 {\n default-column-width 0.0\n}\n");
let unknown = parse("scrolling {\n default-column-width \"wide\"\n}\n");
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 {\n speed 1.5\n reduced-motion #true\n spring {\n mass 2.0\n stiffness 500.0\n damping 40.0\n }\n}\n",
);
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 {\n speed 0.0\n}\n");
let damping = parse("animations {\n spring {\n damping -1.0\n }\n}\n");
assert!(speed.animation_speed().is_err());
assert!(damping.spring_config().is_err());
}
#[test]
fn parses_and_validates_layout_gaps() {
let configured = parse("layout {\n inner-gap 6.0\n outer-gap 14.0\n smart-gaps #true\n}\n");
let invalid = parse("layout {\n outer-gap -1.0\n}\n");
assert_eq!(
configured.gap_config().unwrap(),
GapConfig {
inner: 6.0,
outer: 14.0,
smart: true,
}
);
assert!(invalid.gap_config().is_err());
}
#[test]
fn shell_radius_is_independent_of_windows_and_overrides_legacy_keys() {
for radius in [0.0, 18.0] {
let config = parse(&format!(
"appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}"
));
let theme = config.theme_settings().unwrap();
assert_eq!(theme.material_radius, radius);
assert_eq!(theme.panel_radius, radius);
assert_eq!(theme.window_radius, 23.0);
}
assert!(
parse("theme {\n geometry {\n shell-radius -1\n }\n}")
.theme_settings()
.is_err()
);
}
#[test]
fn parses_and_validates_theme_window_tokens() {
let configured = parse(
"theme {\n colors {\n border \"#11223344\"\n accent \"#AABBCC\"\n shadow \"#01020380\"\n }\n geometry {\n border-width 1.5\n focus-ring-width 3.0\n window-radius 12.0\n }\n shadow {\n soft {\n offset-y -2.0\n blur 24.0\n opacity 0.4\n }\n }\n material {\n style \"translucent\"\n }\n}\n",
);
let invalid_color = parse("theme {\n colors {\n accent \"blue\"\n }\n}\n");
let invalid_radius = parse("theme {\n geometry {\n window-radius -1.0\n }\n}\n");
let invalid_opacity =
parse("theme {\n shadow {\n soft {\n opacity 1.1\n }\n }\n}\n");
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,]),
bar_background_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]),
shell_opacity: 0.78,
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_tint_strength: 0.5,
material_radius: 14.0,
panel_radius: 14.0,
}
);
assert!(invalid_color.theme_settings().is_err());
assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err());
assert!(
ferese_config::from_str::<Config>("theme {\n material {\n style \"mist\"\n }\n}\n").is_err()
);
}
#[test]
fn materials_default_to_solid_and_reject_removed_style() {
assert_eq!(parse("").theme_settings().unwrap().material_style, MaterialStyle::Solid);
assert!(
ferese_config::from_str::<Config>("theme {\n material {\n style \"glass\"\n }\n}\n").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 {\n focus-ring {\n gradient {\n from \"#e5c890\"\n to \"#b98d5880\"\n angle -45\n }\n }\n}\n")
.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 {\n border {\n gradient {\n from \"#112233\"\n to \"#445566\"\n }\n }\n}\n")
.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\"\n",
"from \"#112233\"\nto \"invalid\"\n",
"from \"#112233\"\nto \"#445566\"\nangle #nan\n",
"from \"#112233\"\nto \"#445566\"\nangle #inf\n",
] {
assert!(
Config::parse_source(&format!("theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
}
assert!(
ferese_config::from_str::<Config>(
"theme {\n border {\n gradient {\n from \"#112233\"\n }\n }\n}\n"
)
.is_err()
);
}
#[test]
fn material_color_comes_from_surface_base() {
let theme = parse("theme {\n colors {\n surface-base \"#000000\"\n }\n surface {\n bar {\n background \"#FFFFFF\"\n }\n }\n}\n")
.theme_settings()
.unwrap();
assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0]));
assert_eq!(theme.bar_background_color, RgbaColor([1.0, 1.0, 1.0, 1.0]));
}
#[test]
fn shell_opacity_uses_config_and_rejects_invalid_values() {
assert_eq!(parse("").theme_settings().unwrap().shell_opacity, 0.78);
for opacity in [0.0, 0.65, 1.0] {
assert_eq!(
parse(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
.theme_settings()
.unwrap()
.shell_opacity,
opacity
);
}
for opacity in ["-0.1", "1.1", "#nan", "#inf"] {
assert!(
Config::parse_source(&format!("theme {{ material {{ opacity {opacity}; }} }}"))
.and_then(|config| config.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 {\n material {\n style \"translucent\"\n blur-radius 100\n }\n}\n")
.theme_settings()
.unwrap()
.backdrop_blur,
32.0
);
assert_eq!(
parse("theme {\n material {\n blur-radius 0\n }\n}\n")
.theme_settings()
.unwrap()
.backdrop_blur,
0.0
);
for value in ["-1", "#nan", "#inf"] {
assert!(
Config::parse_source(&format!("theme {{ material {{ blur-radius {value}; }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
}
}
#[test]
fn inactive_dimming_is_opt_in_and_configurable() {
let defaults = parse("").inactive_dim_settings().unwrap();
assert!(!defaults.enabled);
assert_eq!(defaults.amount, 0.15);
assert_eq!(defaults.duration_ms, 150.0);
let settings =
parse("appearance {\n inactive-dim {\n enabled #true\n amount 0.25\n duration-ms 100\n }\n}\n")
.inactive_dim_settings()
.unwrap();
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!(
Config::parse_source(&format!("appearance {{ inactive-dim {{ {key} {value}; }} }}"))
.and_then(|config| config.runtime_config())
.is_err()
);
}
}
#[test]
fn parses_input_and_touchpad_settings() {
let config = parse(
"input {\n focus-follows-mouse #true\n xkb-layout \"us,de\"\n xkb-variant \",nodeadkeys\"\n xkb-options \"grp:alt_shift_toggle\"\n repeat-rate 30\n repeat-delay-ms 450\n touchpad {\n tap #false\n natural-scroll #false\n disable-while-typing #true\n }\n}\n",
);
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,
swipe_threshold: 80,
},
}
);
}
#[test]
fn rejects_invalid_input_settings() {
let layout = parse("input {\n xkb-layout \"\"\n}\n");
let rate = parse("input {\n repeat-rate 0\n}\n");
let delay = parse("input {\n repeat-delay-ms -1\n}\n");
assert!(layout.input_settings().is_err());
assert!(rate.input_settings().is_err());
assert!(delay.input_settings().is_err());
}
#[test]
fn swipe_bindings_can_override_actions_disable_defaults_and_set_distance() {
use crate::gestures::SwipeDirection;
let config = parse(
"input {\n touchpad {\n swipe-threshold 120\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\nbinding keys=\"Swipe3Left\" action=\"move\" argument=\"left\"\nbinding keys=\"Swipe3Down\" disabled=#true\n",
);
let input = config.input_settings().unwrap();
assert_eq!(input.touchpad.swipe_threshold, 120);
let bindings = config.bindings(&input).unwrap();
assert!(
bindings
.iter()
.any(|binding| binding.matches_swipe(3, SwipeDirection::Up)
&& binding.action == BindingAction::ToggleOverview)
);
assert!(
bindings
.iter()
.any(|binding| binding.matches_swipe(3, SwipeDirection::Left)
&& binding.action == BindingAction::Move(ferese_layout::Direction::Left))
);
assert!(
!bindings
.iter()
.any(|binding| binding.matches_swipe(3, SwipeDirection::Down))
);
for threshold in [0, 15, 1001] {
assert!(
parse(&format!("input {{ touchpad {{ swipe-threshold {threshold}; }} }}"))
.input_settings()
.is_err()
);
}
}
#[test]
fn gesture_bindings_validate_fingers_directions_and_actions() {
for keys in ["Swipe2Up", "Swipe3Diagonal", "Super+Swipe3Up"] {
let config = parse(&format!("binding \"{keys}\" \"toggle-overview\""));
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
}
let config = parse("binding keys=\"Swipe4Up\" action=\"toggle-overview\"\n");
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
assert!(
bindings
.iter()
.any(|binding| binding.matches_swipe(4, crate::gestures::SwipeDirection::Up))
);
}
#[test]
fn guide_uses_effective_bindings_including_overrides_and_unbindings() {
let config =
parse("binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n");
let input = config.input_settings().unwrap();
let map = physical_keymap(&input).unwrap();
let entries = config
.bindings(&input)
.unwrap()
.iter()
.filter_map(|binding| binding.guide_entry(Some(&map)))
.collect::<Vec<_>>();
assert!(!entries.iter().any(|entry| entry["description"] == "Close window"));
assert!(
entries
.iter()
.any(|entry| entry["keys"] == "Super + Tab" && entry["description"] == "Toggle floating window")
);
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
}
#[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(), 53);
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()].into())
}));
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()].into())
}));
assert!(bindings.iter().any(|binding| {
binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Print)
&& binding.modifiers == BindingModifiers::default()
&& binding.action
== BindingAction::Spawn(vec!["ferese-screenshot".to_owned(), "--full".to_owned()].into())
}));
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 {\n term \"foot\" \"--app-id\" \"work\"\n}\nbinding keys=\"Super+Enter\" action=\"spawn\" argument=\"term\"\n",
);
let unbound = parse("binding keys=\"Super+Q\" disabled=#true\n");
let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 53);
assert!(bindings.iter().any(|binding| {
binding.action
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
}));
let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 52);
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
}
#[test]
fn accepts_physical_xkb_key_names() {
let config = parse("binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n");
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("binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n");
let missing_command = parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument = parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
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-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false\n",
);
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-rule floating=#true\n");
let zero_workspace = parse("window-rule app-id=\"editor\" workspace=0\n");
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(
"output-profile name=\"docked\" {\n output match=\"HDMI-A-1\" mode=\"[email protected]\" scale=1.6 {\n position 0 0\n }\n output match=\"eDP-1\" enabled=#false transform=\"rotate_90\"\n}\n",
);
assert_eq!(
config.output_profiles().unwrap(),
vec![OutputProfile {
name: "docked".to_owned(),
outputs: vec![
OutputSettings {
auto_refresh: false,
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 {
auto_refresh: false,
matcher: "eDP-1".to_owned(),
enabled: false,
mode: None,
scale: 1.0,
transform: OutputTransform::Rotate90,
position: None,
},
],
}]
);
}
#[test]
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
assert!(config.output_profiles().unwrap()[0].outputs[0].auto_refresh);
let manual = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@60\"; }");
assert!(!manual.output_profiles().unwrap()[0].outputs[0].auto_refresh);
assert!(Config::parse_source("output-profile laptop { output eDP-1 auto-refresh=\"yes\"; }").is_err());
}
#[test]
fn rejects_invalid_output_profiles() {
let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
let invalid_mode = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
let duplicate =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n");
assert!(invalid_scale.output_profiles().is_err());
assert!(invalid_mode.output_profiles().is_err());
assert!(duplicate.output_profiles().is_err());
}
}