Replace user configuration with KDL and improve Rust source spacing

This commit is contained in:
Samuel Onoja committed 2026-09-27 05:46:51 +01:00
1 parent dbdfc5688e
commit 9e90de4e74
41 files changed
+1668 -559

No files matched your search

+1 -1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-config = { path = "../crates/ferese-config" }
ab_glyph = "0.2.32"
fontdb = "0.23.0"
calloop.workspace = true
@@ -20,7 +21,6 @@ image = { version = "0.25.10", default-features = false, features = ["png", "jpe
smithay.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
xcursor.workspace = true
+136 -113
View File
@@ -1,5 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::env;
use std::error::Error;
use std::fmt;
use std::fs;
@@ -265,7 +264,7 @@ struct ThemeConfig {
impl Config {
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
toml::from_str(source).map_err(|source| ConfigError::Parse {
ferese_config::from_str(source).map_err(|source| ConfigError::Parse {
path: config_path().unwrap_or_default(),
source,
})
@@ -308,6 +307,7 @@ impl Config {
overview_font_family: self.overview_font_family(),
})
}
pub(crate) fn overview_font_family(&self) -> String {
self.theme
.typography
@@ -315,6 +315,7 @@ impl Config {
.clone()
.unwrap_or_else(|| "sans-serif".into())
}
pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig {
self.theme.background.clone()
}
@@ -738,7 +739,7 @@ pub enum ConfigError {
},
Parse {
path: PathBuf,
source: toml::de::Error,
source: ferese_config::Error,
},
InvalidColumnWidth {
field: &'static str,
@@ -827,7 +828,7 @@ impl Config {
Err(source) => return Err(ConfigError::Read { path, source }),
};
toml::from_str(&source).map_err(|source| ConfigError::Parse { path, source })
ferese_config::from_str(&source).map_err(|source| ConfigError::Parse { path, source })
}
pub fn layout_mode(&self) -> LayoutMode {
@@ -1813,42 +1814,59 @@ fn parse_column_width(
}
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"))
ferese_config::config_path()
}
#[cfg(test)]
mod tests {
use super::*;
#[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.clock]\nenabled = true\nanchor = 'center'\ntime_format = '%H:%M'"
"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.clock]\nwidth = 8192",
"[desktop_widgets.clock]\nopacity = nan",
"[desktop_widgets.clock]\ntime_format = '%'",
"[desktop_widgets.clock]\ntime_zone = 'invalid/zone'",
"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!(parse(source).runtime_config().is_err());
assert!(
Config::parse_source(source)
.and_then(|config| config.runtime_config())
.is_err()
);
}
assert!(Config::parse_source("[desktop_widgets.clock]\nanchor = 'wrong'").is_err());
assert!(Config::parse_source("desktop-widgets { clock { anchor \"wrong\"; }; }").is_err());
}
fn parse(source: &str) -> Config {
toml::from_str(source).unwrap()
ferese_config::from_str(source).unwrap()
}
#[test]
@@ -1865,8 +1883,8 @@ mod tests {
#[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");
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!(
@@ -1878,7 +1896,7 @@ mod tests {
#[test]
fn parses_width_presets_and_supplies_defaults() {
let defaults = parse("");
let configured = parse("[scrolling]\nwidth_presets = [0.5, 1.0, \"full\"]");
let configured = parse("scrolling {\n width-presets 0.5 1.0 \"full\"\n}\n");
assert_eq!(defaults.width_presets().unwrap().len(), 4);
assert_eq!(
@@ -1894,8 +1912,8 @@ mod tests {
#[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\"");
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
@@ -1913,8 +1931,8 @@ mod tests {
#[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\"");
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());
@@ -1923,7 +1941,7 @@ mod tests {
#[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",
"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());
@@ -1941,8 +1959,8 @@ mod tests {
#[test]
fn rejects_invalid_animation_numbers() {
let speed = parse("[animations]\nspeed = 0.0");
let damping = parse("[animations.spring]\ndamping = -1.0");
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());
@@ -1950,8 +1968,9 @@ mod tests {
#[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");
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(),
@@ -1967,11 +1986,14 @@ mod tests {
#[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\"",
"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",
);
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(),
@@ -2003,7 +2025,12 @@ mod tests {
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());
assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"mist\"\n }\n}\n"
)
.is_err()
);
}
#[test]
@@ -2012,7 +2039,12 @@ mod tests {
parse("").theme_settings().unwrap().material_style,
MaterialStyle::Solid
);
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err());
assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"glass\"\n }\n}\n"
)
.is_err()
);
}
#[test]
@@ -2021,14 +2053,14 @@ mod tests {
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")
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.border.gradient]\nfrom = '#112233'\nto = '#445566'")
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);
@@ -2040,23 +2072,25 @@ mod tests {
#[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",
"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!(
parse(&format!("[theme.focus_ring.gradient]\n{settings}"))
.theme_settings()
.is_err()
Config::parse_source(&format!(
"theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"
))
.and_then(|config| config.theme_settings())
.is_err()
);
}
assert!(toml::from_str::<Config>("[theme.border.gradient]\nfrom = '#112233'").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.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"")
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]));
@@ -2067,18 +2101,22 @@ mod tests {
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,
parse(&format!(
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
))
.theme_settings()
.unwrap()
.panel_opacity,
opacity
);
}
for opacity in ["-0.1", "1.1", "nan", "inf"] {
for opacity in ["-0.1", "1.1", "#nan", "#inf"] {
assert!(
parse(&format!("[theme.surface.bar]\nopacity = {opacity}"))
.theme_settings()
.is_err()
Config::parse_source(&format!(
"theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
))
.and_then(|config| config.theme_settings())
.is_err()
);
}
}
@@ -2087,23 +2125,23 @@ mod tests {
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")
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.material]\nblur_radius = 0")
parse("theme {\n material {\n blur-radius 0\n }\n}\n")
.theme_settings()
.unwrap()
.backdrop_blur,
0.0
);
for value in ["-1", "nan", "inf"] {
for value in ["-1", "#nan", "#inf"] {
assert!(
parse(&format!("[theme.material]\nblur_radius = {value}"))
.theme_settings()
Config::parse_source(&format!("theme {{ material {{ blur-radius {value}; }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
}
@@ -2116,7 +2154,7 @@ mod tests {
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")
parse("appearance {\n inactive-dim {\n enabled #true\n amount 0.25\n duration-ms 100\n }\n}\n")
.theme_settings()
.unwrap()
.inactive_dim;
@@ -2126,14 +2164,16 @@ mod tests {
for (key, value) in [
("amount", "-0.1"),
("amount", "1.1"),
("amount", "nan"),
("amount", "#nan"),
("duration_ms", "-1"),
("duration_ms", "inf"),
("duration_ms", "#inf"),
] {
assert!(
parse(&format!("[appearance.inactive_dim]\n{key} = {value}"))
.theme_settings()
.is_err()
Config::parse_source(&format!(
"appearance {{ inactive-dim {{ {key} {value}; }} }}"
))
.and_then(|config| config.theme_settings())
.is_err()
);
}
}
@@ -2141,7 +2181,7 @@ mod tests {
#[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",
"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!(
@@ -2165,9 +2205,9 @@ mod tests {
#[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");
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());
@@ -2178,7 +2218,7 @@ mod tests {
fn swipe_bindings_can_override_actions_disable_defaults_and_set_distance() {
use crate::gestures::SwipeDirection;
let config = parse(
"[input.touchpad]\nswipe_threshold = 120\n[[bindings]]\nkeys = 'Swipe3Up'\naction = 'toggle-overview'\n[[bindings]]\nkeys = 'Swipe3Left'\naction = 'move'\nargument = 'left'\n[[bindings]]\nkeys = 'Swipe3Down'\ndisabled = true",
"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);
@@ -2202,9 +2242,11 @@ mod tests {
);
for threshold in [0, 15, 1001] {
assert!(
parse(&format!("[input.touchpad]\nswipe_threshold = {threshold}"))
.input_settings()
.is_err()
parse(&format!(
"input {{ touchpad {{ swipe-threshold {threshold}; }} }}"
))
.input_settings()
.is_err()
);
}
}
@@ -2212,12 +2254,10 @@ mod tests {
#[test]
fn gesture_bindings_validate_fingers_directions_and_actions() {
for keys in ["Swipe2Up", "Swipe3Diagonal", "Super+Swipe3Up"] {
let config = parse(&format!(
"[[bindings]]\nkeys = '{keys}'\naction = 'toggle-overview'"
));
let config = parse(&format!("binding \"{keys}\" \"toggle-overview\""));
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
}
let config = parse("[[bindings]]\nkeys = 'Swipe4Up'\naction = 'toggle-overview'");
let config = parse("binding keys=\"Swipe4Up\" action=\"toggle-overview\"\n");
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
assert!(
bindings
@@ -2277,9 +2317,9 @@ mod tests {
#[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\"",
"commands {\n term \"foot\" \"--app-id\" \"work\"\n}\nbinding keys=\"Super+Enter\" action=\"spawn\" argument=\"term\"\n",
);
let unbound = parse("[[bindings]]\nkeys = \"Super+Q\"\ndisabled = true");
let unbound = parse("binding keys=\"Super+Q\" disabled=#true\n");
let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap();
@@ -2306,7 +2346,7 @@ mod tests {
#[test]
fn accepts_physical_xkb_key_names() {
let config = parse(
"[[bindings]]\nkeys = \"Super+AD06\"\nmatch = \"physical\"\naction = \"focus\"\nargument = \"left\"",
"binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n",
);
let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap();
@@ -2320,13 +2360,12 @@ mod tests {
#[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\"",
"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("[[bindings]]\nkeys = \"Super+Q\"\naction = \"close\"\nargument = \"left\"");
parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
for config in [duplicate, missing_command, invalid_argument] {
let input = config.input_settings().unwrap();
@@ -2337,7 +2376,7 @@ mod tests {
#[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",
"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(
@@ -2356,8 +2395,8 @@ mod tests {
#[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");
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());
@@ -2376,21 +2415,7 @@ mod tests {
#[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"
"#,
"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!(
@@ -2425,14 +2450,12 @@ transform = "rotate_90"
#[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 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_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"",
"output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n",
);
assert!(invalid_scale.output_profiles().is_err());
+2
View File
@@ -54,6 +54,7 @@ impl Runner {
}
}
}
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
Self(
configs
@@ -155,6 +156,7 @@ mod tests {
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn previews_do_not_duplicate_session_daemons() {
let configs = vec![DaemonConfig {
+1
View File
@@ -368,6 +368,7 @@ pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
})
})
}
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
+7
View File
@@ -194,6 +194,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_swipe_begin(data, event);
}
fn gesture_swipe_update(
&mut self,
data: &mut Ferese,
@@ -202,6 +203,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_swipe_update(data, event);
}
fn gesture_swipe_end(
&mut self,
data: &mut Ferese,
@@ -210,6 +212,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_swipe_end(data, event);
}
fn gesture_pinch_begin(
&mut self,
data: &mut Ferese,
@@ -218,6 +221,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_pinch_begin(data, event);
}
fn gesture_pinch_update(
&mut self,
data: &mut Ferese,
@@ -226,6 +230,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_pinch_update(data, event);
}
fn gesture_pinch_end(
&mut self,
data: &mut Ferese,
@@ -234,6 +239,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_pinch_end(data, event);
}
fn gesture_hold_begin(
&mut self,
data: &mut Ferese,
@@ -242,6 +248,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.gesture_hold_begin(data, event);
}
fn gesture_hold_end(
&mut self,
data: &mut Ferese,
+3
View File
@@ -111,6 +111,7 @@ impl OverviewState {
self.labels.clear();
}
}
pub(crate) fn with_font_family(font_family: String) -> Self {
Self {
font_family,
@@ -199,6 +200,7 @@ impl OverviewState {
self.labels.insert(key, label.clone());
Some(label)
}
pub(crate) fn is_active(&self) -> bool {
self.active
}
@@ -367,6 +369,7 @@ impl Ferese {
let name = self.workspaces.workspace(workspace)?.name.clone();
self.overview.label(&name, scale)
}
pub(crate) fn set_overview_active(&mut self, active: bool) {
if self.overview.is_active() == active {
return;
+18
View File
@@ -93,15 +93,19 @@ impl Element for PhysicalShaderElement {
fn id(&self) -> &Id {
self.inner.id()
}
fn current_commit(&self) -> CommitCounter {
self.inner.current_commit()
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.inner.src()
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn damage_since(
&self,
_: Scale<f64>,
@@ -114,6 +118,7 @@ impl Element for PhysicalShaderElement {
}
}
}
impl RenderElement<GlesRenderer> for PhysicalShaderElement {
fn draw(
&self,
@@ -140,25 +145,32 @@ pub(crate) struct NativeTextureElement {
pub program: Option<GlesTexProgram>,
pub uniforms: Vec<Uniform<'static>>,
}
impl Element for NativeTextureElement {
fn id(&self) -> &Id {
&self.id
}
fn current_commit(&self) -> CommitCounter {
self.commit
}
fn src(&self) -> Rectangle<f64, Buffer> {
self.source
}
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry
}
fn alpha(&self) -> f32 {
self.alpha
}
fn kind(&self) -> Kind {
Kind::Unspecified
}
fn damage_since(
&self,
_: Scale<f64>,
@@ -171,6 +183,7 @@ impl Element for NativeTextureElement {
}
}
}
impl RenderElement<GlesRenderer> for NativeTextureElement {
fn draw(
&self,
@@ -231,6 +244,7 @@ mod tests {
let growing_target = ferese_layout::Rect::new(0.0, 0.0, 1000.0, 600.0);
assert!(!resize_needs_old_frame(visual, growing_target, 1000, 600));
}
#[test]
fn fresh_snapshot_and_retarget_wait_do_not_skip_the_handoff() {
let mut elapsed = Duration::ZERO;
@@ -262,6 +276,7 @@ mod tests {
1.0
));
}
#[test]
fn mixed_refresh_outputs_do_not_double_the_animation_clock() {
let start = Instant::now();
@@ -274,6 +289,7 @@ mod tests {
assert_eq!(frame_delta(&mut last, start), Duration::ZERO);
assert_eq!(last, start + Duration::from_millis(32));
}
#[test]
fn fractional_motion_does_not_quantize_to_logical_pixels() {
let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2);
@@ -283,6 +299,7 @@ mod tests {
(180, 144).into()
);
}
#[test]
fn adjacent_frames_share_edges_at_all_output_scales() {
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] {
@@ -299,6 +316,7 @@ mod tests {
assert_eq!(left.loc.x + left.size.w, right.loc.x);
}
}
#[test]
fn handoff_has_bounded_lifetime_and_no_alpha_jump() {
assert_eq!(handoff_alpha(Duration::ZERO), 1.0);
+27 -12
View File
@@ -26,6 +26,7 @@ pub(crate) struct ConfigMonitor {
dirty: Option<Instant>,
observed: Option<String>,
}
impl ConfigMonitor {
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
let (sender, events) = mpsc::sync_channel(1);
@@ -64,6 +65,7 @@ impl ConfigMonitor {
observed: None,
})
}
pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> {
if self.events.try_iter().next().is_some() {
self.dirty = Some(now);
@@ -118,6 +120,7 @@ impl crate::Ferese {
tracing::info!("configuration reloaded live");
Ok(())
}
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
let source = read_source(&path)?;
@@ -141,18 +144,28 @@ mod tests {
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn runtime_validation_rejects_bad_edits_before_application() {
for source in [
"[animations]\nspeed = 0",
"[theme.focus_ring.gradient]\nfrom = 'bad'\nto = '#ffffff'",
"[status]\nlow_battery_threshold = 'bad'",
"[layout]\ninner_gap = -1",
"animations {\n speed 0\n}\n",
r##"theme {
focus-ring {
gradient {
from "bad"
to "#ffffff"
}
}
}
"##,
"status {\n low-battery-threshold \"bad\"\n}\n",
"layout {\n inner-gap -1\n}\n",
] {
assert!(
crate::config::Config::parse_source(source)
@@ -161,17 +174,18 @@ mod tests {
);
}
assert!(
crate::config::Config::parse_source("[animations]\nspeed = 0.75")
crate::config::Config::parse_source("animations {\n speed 0.75\n}\n")
.unwrap()
.runtime_config()
.is_ok()
);
}
#[test]
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
let path = directory.0.join("config.kdl");
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
let mut monitor = ConfigMonitor::new(path.clone()).unwrap();
monitor.dirty = Some(Instant::now() - Duration::from_secs(1));
assert!(monitor.poll(Instant::now()).unwrap().is_ok());
@@ -180,13 +194,13 @@ mod tests {
monitor.path = directory.0.join("missing");
assert!(monitor.poll(Instant::now()).is_none());
monitor.path = original_path;
let temporary = directory.0.join("config.toml.new");
std::fs::write(&temporary, "[animations]\nspeed = 0.75").unwrap();
let temporary = directory.0.join("config.kdl.new");
std::fs::write(&temporary, "animations {\n speed 0.75\n}\n").unwrap();
std::fs::rename(&temporary, &path).unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if let Some(result) = monitor.poll(Instant::now()) {
assert_eq!(result.unwrap(), "[animations]\nspeed = 0.75");
assert_eq!(result.unwrap(), "animations {\n speed 0.75\n}\n");
break;
}
assert!(
@@ -196,13 +210,14 @@ mod tests {
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn source_size_limit_is_bounded_and_debounce_coalesces_edits() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
let path = directory.0.join("config.kdl");
std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap();
assert!(read_source(&path).is_err());
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
let mut monitor = ConfigMonitor::new(path).unwrap();
let now = Instant::now();
monitor.dirty = Some(now);
+1
View File
@@ -28,6 +28,7 @@ impl ResizeTransaction {
self.source_geometry = Some(geometry);
self
}
pub(crate) fn source_geometry(self) -> Option<Rectangle<i32, Logical>> {
self.source_geometry
}
+3
View File
@@ -118,6 +118,7 @@ impl SessionLockHandler for Ferese {
fn lock_state(&mut self) -> &mut SessionLockManagerState {
&mut self.session_lock_state
}
fn lock(&mut self, confirmation: SessionLocker) {
if self.session_lock.active {
return;
@@ -153,12 +154,14 @@ impl SessionLockHandler for Ferese {
self.intercepted_keys.clear();
crate::backends::direct::render_all(self);
}
fn unlock(&mut self) {
self.session_lock = Lock::default();
self.restore_keyboard_focus();
crate::backends::direct::render_all(self);
tracing::info!("session unlocked by lock owner");
}
fn new_surface(&mut self, surface: LockSurface, output: WlOutput) {
if let Some(output) = Output::from_resource(&output) {
if let Some(geometry) = self.space.output_geometry(&output) {
+1
View File
@@ -30,6 +30,7 @@ pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
}
chunks
}
pub(crate) fn send_shell_config(shell: &FereseShellV1, source: &str) {
shell.config_begin();
for chunk in config_chunks(source) {
+1
View File
@@ -322,6 +322,7 @@ impl Ferese {
crate::backends::direct::render_all(self);
Ok(())
}
pub fn register_output(&mut self, output: &Output, identity: String) {
let Some(geometry) = self.space.output_geometry(output) else {
tracing::error!(output = %output.name(), "cannot register an unmapped output");
+9
View File
@@ -23,6 +23,7 @@ pub(crate) struct WallpaperConfig {
#[serde(default)]
pub mode: WallpaperMode,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode {
@@ -78,10 +79,12 @@ impl WallpaperState {
textures: HashMap::new(),
}
}
#[cfg(test)]
pub fn owns_background(&self) -> bool {
self.owned
}
pub fn reload(&mut self, config: WallpaperConfig) {
if self.receiver.is_some() {
self.pending = (self.config != config).then_some(config);
@@ -110,9 +113,11 @@ impl WallpaperState {
self.textures.clear();
}
}
pub fn forget_context(&mut self, context: &ErasedContextId) {
self.textures.remove(context);
}
pub fn poll(&mut self) -> bool {
let Some(receiver) = &self.receiver else {
return false;
@@ -153,6 +158,7 @@ impl WallpaperState {
Err(mpsc::TryRecvError::Empty) => false,
}
}
pub fn element(
&mut self,
renderer: &mut GlesRenderer,
@@ -270,6 +276,7 @@ mod tests {
state.reload(state.config.clone());
assert_eq!(state.commit, before);
}
#[test]
fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() {
let mut state = WallpaperState::new(WallpaperConfig::default());
@@ -287,6 +294,7 @@ mod tests {
assert!(state.pixels.is_some());
assert_eq!(state.mode, WallpaperMode::Fill);
}
#[test]
fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
let (geometry, source) = image_geometry(
@@ -303,6 +311,7 @@ mod tests {
assert_eq!(geometry.loc, (0, 50).into());
assert_eq!(source.size, (3840.0, 2160.0).into());
}
#[test]
fn missing_wallpaper_preserves_external_shell_fallback() {
let state = WallpaperState::new(WallpaperConfig::default());
+1
View File
@@ -2530,6 +2530,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] {
rect.map(|v| v.round() as i32)
}
fn expanded_blur_region(
visible: Rectangle<i32, Logical>,
radius: i32,