diff --git a/Cargo.lock b/Cargo.lock index 38c161f..c0487be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,7 +837,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -848,9 +848,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", @@ -1305,7 +1305,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1573,6 +1573,7 @@ dependencies = [ "ab_glyph", "calloop 0.14.4", "ferese-animation", + "ferese-config", "ferese-core", "ferese-ipc", "ferese-layout", @@ -1584,7 +1585,6 @@ dependencies = [ "serde", "serde_json", "smithay", - "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "xcursor", @@ -1597,6 +1597,15 @@ dependencies = [ "ferese-layout", ] +[[package]] +name = "ferese-config" +version = "0.1.0" +dependencies = [ + "kdl", + "serde", + "serde_json", +] + [[package]] name = "ferese-core" version = "0.1.0" @@ -1633,6 +1642,7 @@ dependencies = [ name = "ferese-settings" version = "0.1.0" dependencies = [ + "ferese-config", "ferese-ipc", "image", "libcosmic", @@ -1641,13 +1651,13 @@ dependencies = [ "shlex 1.3.0", "tempfile", "tokio", - "toml_edit", ] [[package]] name = "ferese-shell" version = "0.1.0" dependencies = [ + "ferese-config", "ferese-core", "ferese-protocols", "iced_accessibility", @@ -1656,8 +1666,6 @@ dependencies = [ "serde", "tempfile", "tokio", - "toml 0.9.12+spec-1.1.0", - "toml_edit", "wayland-client", ] @@ -1675,7 +1683,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" dependencies = [ - "toml 0.5.11", + "toml", ] [[package]] @@ -2901,9 +2909,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2919,6 +2927,18 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "kdl" +version = "6.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "082ddf81b2acd76fe04412655d17befedfff1837db772f8e74c38050d25ed670" +dependencies = [ + "miette", + "num-traits", + "serde", + "winnow 0.7.15", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -2952,7 +2972,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3076,9 +3096,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.25" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "bitflags 2.13.2", "libc", @@ -3306,6 +3326,16 @@ dependencies = [ "paste", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "unicode-width 0.1.14", +] + [[package]] name = "mime" version = "0.1.0" @@ -4132,9 +4162,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.8" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -4415,10 +4445,11 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ + "getrandom 0.2.17", "libredox", "thiserror 2.0.21", ] @@ -4723,15 +4754,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "sha2" version = "0.10.9" @@ -5351,30 +5373,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -5391,9 +5389,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "toml_writer", "winnow 1.0.4", ] @@ -5406,12 +5403,6 @@ dependencies = [ "winnow 1.0.4", ] -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - [[package]] name = "tracing" version = "0.1.44" @@ -5615,6 +5606,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -5723,9 +5720,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5736,9 +5733,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5746,9 +5743,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5756,22 +5753,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5953,9 +5950,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -6874,6 +6871,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" diff --git a/Cargo.toml b/Cargo.toml index fa9e931..209015f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "feresectl", "crates/ferese-animation", "crates/ferese-core", + "crates/ferese-config", "crates/ferese-ipc", "crates/ferese-layout", "crates/ferese-protocols", @@ -40,7 +41,6 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" tempfile = "3.23.0" -toml = "0.9.7" xcursor = "0.3.11" wayland-backend = "0.3.17" wayland-client = "0.31.15" diff --git a/README.md b/README.md index dbab1d9..f20eb50 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,12 @@ raising the window; clicking raises it within its layer. ## Make it yours Open **Control Center → Settings**, or run `ferese-settings`. -Advanced options live in `~/.config/ferese/config.toml` (or under +Advanced options live in `~/.config/ferese/config.kdl` (or under `$XDG_CONFIG_HOME`). Changes reload automatically. The complete [configuration reference](docs/configuration.md) covers every supported option; -the [example config](packaging/config.toml) is a starting point. +the [example config](packaging/config.kdl) is a starting point. KDL uses compact nested sections +and bindings such as `binding "Swipe3Up" "toggle-overview"`. See the +[configuration guide](docs/configuration.md#kdl-syntax). ## Development diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 6b1269e..b2246fc 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -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 diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 81d0a00..09ba67d 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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 { - 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 { - 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::("[theme.material]\nstyle = \"mist\"").is_err()); + assert!( + ferese_config::from_str::( + "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::("[theme.material]\nstyle = \"glass\"").is_err()); + assert!( + ferese_config::from_str::( + "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::("[theme.border.gradient]\nfrom = '#112233'").is_err()); + assert!(ferese_config::from_str::("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 = "3840x2160@119.998" -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=\"3840x2160@119.998\" 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()); diff --git a/compositor/src/daemon.rs b/compositor/src/daemon.rs index d22a423..3f7270c 100644 --- a/compositor/src/daemon.rs +++ b/compositor/src/daemon.rs @@ -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 { diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index e6a6a5e..3075b1d 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -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::() { diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index 64b22e5..7891eb2 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -194,6 +194,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_swipe_begin(data, event); } + fn gesture_swipe_update( &mut self, data: &mut Ferese, @@ -202,6 +203,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_swipe_update(data, event); } + fn gesture_swipe_end( &mut self, data: &mut Ferese, @@ -210,6 +212,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_swipe_end(data, event); } + fn gesture_pinch_begin( &mut self, data: &mut Ferese, @@ -218,6 +221,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_pinch_begin(data, event); } + fn gesture_pinch_update( &mut self, data: &mut Ferese, @@ -226,6 +230,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_pinch_update(data, event); } + fn gesture_pinch_end( &mut self, data: &mut Ferese, @@ -234,6 +239,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_pinch_end(data, event); } + fn gesture_hold_begin( &mut self, data: &mut Ferese, @@ -242,6 +248,7 @@ impl PointerGrab for ResizeSurfaceGrab { ) { handle.gesture_hold_begin(data, event); } + fn gesture_hold_end( &mut self, data: &mut Ferese, diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 609a36d..69965f3 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -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; diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index c5b97e0..c8cb8d5 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -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 { self.inner.src() } + fn geometry(&self, _: Scale) -> Rectangle { self.geometry } + fn damage_since( &self, _: Scale, @@ -114,6 +118,7 @@ impl Element for PhysicalShaderElement { } } } + impl RenderElement for PhysicalShaderElement { fn draw( &self, @@ -140,25 +145,32 @@ pub(crate) struct NativeTextureElement { pub program: Option, pub uniforms: Vec>, } + impl Element for NativeTextureElement { fn id(&self) -> &Id { &self.id } + fn current_commit(&self) -> CommitCounter { self.commit } + fn src(&self) -> Rectangle { self.source } + fn geometry(&self, _: Scale) -> Rectangle { self.geometry } + fn alpha(&self) -> f32 { self.alpha } + fn kind(&self) -> Kind { Kind::Unspecified } + fn damage_since( &self, _: Scale, @@ -171,6 +183,7 @@ impl Element for NativeTextureElement { } } } + impl RenderElement 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); diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index 66dc5ab..9e95e6e 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -26,6 +26,7 @@ pub(crate) struct ConfigMonitor { dirty: Option, observed: Option, } + impl ConfigMonitor { pub(crate) fn new(path: PathBuf) -> notify::Result { let (sender, events) = mpsc::sync_channel(1); @@ -64,6 +65,7 @@ impl ConfigMonitor { observed: None, }) } + pub(crate) fn poll(&mut self, now: Instant) -> Option> { 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); diff --git a/compositor/src/resize_transaction.rs b/compositor/src/resize_transaction.rs index b718ee0..31b57f1 100644 --- a/compositor/src/resize_transaction.rs +++ b/compositor/src/resize_transaction.rs @@ -28,6 +28,7 @@ impl ResizeTransaction { self.source_geometry = Some(geometry); self } + pub(crate) fn source_geometry(self) -> Option> { self.source_geometry } diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 7938b88..3def132 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -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) { diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 1687dc5..6f4d4bd 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -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) { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 92839bb..c9efbf5 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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"); diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs index 593bf01..595f78a 100644 --- a/compositor/src/wallpaper.rs +++ b/compositor/src/wallpaper.rs @@ -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()); diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index efbbe6d..3782a26 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -2530,6 +2530,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec> { fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { rect.map(|v| v.round() as i32) } + fn expanded_blur_region( visible: Rectangle, radius: i32, diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml new file mode 100644 index 0000000..1933dda --- /dev/null +++ b/crates/ferese-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ferese-config" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version = "1.95" + +[dependencies] +kdl = "6.7.1" +serde.workspace = true +serde_json.workspace = true diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs new file mode 100644 index 0000000..723fdc9 --- /dev/null +++ b/crates/ferese-config/src/lib.rs @@ -0,0 +1,791 @@ +use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; +use std::{fmt, path::PathBuf}; + +#[derive(Debug)] +pub struct Error(String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: String) -> Self { + Self(value) + } +} + +pub fn config_path() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".config"))) + .map(|p| p.join("ferese/config.kdl")) +} + +fn field(name: &str, parent: &str) -> String { + if parent == "commands" { + return name.to_owned(); + } + match name { + "binding" => "bindings".into(), + "window-rule" => "window_rules".into(), + "output-profile" => "output_profiles".into(), + "output" => "outputs".into(), + "note" => "notes".into(), + _ => name.replace('-', "_"), + } +} + +fn is_records(key: &str, parent: &str) -> bool { + match (parent, key) { + ("", "bindings" | "window_rules" | "output_profiles" | "autostart") => true, + ("output_profiles", "outputs") | ("desktop_widgets", "notes") => true, + _ => false, + } +} + +fn is_array(key: &str, parent: &str) -> bool { + parent == "commands" + || matches!( + key, + "command" + | "position" + | "width_presets" + | "xkb_options" + | "settings_command" + | "outputs" + ) +} + +fn scalar(value: &KdlValue) -> Result { + Ok(match value { + KdlValue::String(s) => Value::String(s.clone()), + KdlValue::Bool(v) => Value::Bool(*v), + KdlValue::Null => { + return Err(Error( + "Omit optional settings instead of using #null".into(), + )); + } + KdlValue::Integer(n) => { + if let Ok(n) = i64::try_from(*n) { + n.into() + } else if let Ok(n) = u64::try_from(*n) { + n.into() + } else { + return Err(Error("Integer is outside the supported range".into())); + } + } + KdlValue::Float(n) => serde_json::Number::from_f64(*n) + .map(Value::Number) + .ok_or_else(|| Error("Configuration numbers must be finite".into()))?, + }) +} + +fn insert(map: &mut Map, key: String, value: Value) -> Result<(), Error> { + if map.insert(key.clone(), value).is_some() { + return Err(Error(format!("Duplicate setting: {key}"))); + } + Ok(()) +} + +fn object(doc: &KdlDocument, parent: &str) -> Result, Error> { + let mut result = Map::new(); + + for node in doc.nodes() { + let key = field(node.name().value(), parent); + let value = node_value(node, &key, parent)?; + if is_records(&key, parent) { + let records = result + .entry(key) + .or_insert_with(|| Value::Array(Vec::new())); + records.as_array_mut().unwrap().push(value); + } else { + insert(&mut result, key, value)?; + } + } + + Ok(result) +} + +fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result { + if node.ty().is_some() || node.entries().iter().any(|e| e.ty().is_some()) { + return Err(Error( + "Type annotations are not supported in configuration".into(), + )); + } + + let args = node + .entries() + .iter() + .filter(|e| e.name().is_none()) + .map(|e| scalar(e.value())) + .collect::, _>>()?; + let props = node + .entries() + .iter() + .filter_map(|e| e.name().map(|n| (field(n.value(), key), e))) + .collect::>(); + + if is_records(key, parent) { + let mut map = node + .children() + .map(|d| object(d, key)) + .transpose()? + .unwrap_or_default(); + for (name, entry) in props { + insert(&mut map, name, scalar(entry.value())?)?; + } + let positional: &[&str] = match key { + "bindings" => &["keys", "action", "argument"], + "output_profiles" => &["name"], + "outputs" => &["match"], + "notes" => &["id"], + "autostart" => &[], + "window_rules" => &[], + _ => unreachable!(), + }; + + if key == "autostart" && !args.is_empty() { + insert(&mut map, "command".into(), args.into())?; + } else { + if args.len() > positional.len() { + return Err(Error(format!("Too many arguments for {}", node.name()))); + } + for (name, value) in positional.iter().zip(args) { + insert(&mut map, (*name).into(), value)?; + } + } + + return Ok(Value::Object(map)); + } + + if node.children().is_some() || !props.is_empty() { + if !args.is_empty() { + return Err(Error(format!( + "{} cannot combine scalar arguments with a section", + node.name() + ))); + } + let mut map = node + .children() + .map(|d| object(d, key)) + .transpose()? + .unwrap_or_default(); + for (name, entry) in props { + insert(&mut map, name, scalar(entry.value())?)?; + } + return Ok(Value::Object(map)); + } + + if is_array(key, parent) { + return Ok(Value::Array(args)); + } + if args.len() != 1 { + return Err(Error(format!("{} requires one value", node.name()))); + } + + Ok(args.into_iter().next().unwrap()) +} + +#[derive(Clone, Debug)] +pub struct Document { + doc: KdlDocument, + value: Value, +} + +impl Document { + pub fn parse(source: &str) -> Result { + let doc = source + .parse::() + .map_err(|e| Error(format!("{e:?}")))?; + let value = Value::Object(object(&doc, "")?); + + Ok(Self { doc, value }) + } + + pub fn value(&self) -> &Value { + &self.value + } + + pub fn get(&self, path: &str) -> Option<&Value> { + let mut value = &self.value; + + for part in path.split('.') { + value = if let Ok(index) = part.parse::() { + value.get(index)? + } else { + value.get(part)? + }; + } + Some(value) + } + + fn refresh(&mut self) -> Result<(), Error> { + self.value = Value::Object(object(&self.doc, "")?); + Ok(()) + } + + pub fn set(&mut self, path: &str, value: Value) -> Result<(), Error> { + let mut candidate = self.clone(); + let parts = path.split('.').collect::>(); + set_in(&mut candidate.doc, &parts, "", value)?; + format_document(&mut candidate.doc); + candidate.refresh()?; + *self = candidate; + Ok(()) + } + + pub fn add(&mut self, path: &str, fields: Vec<(String, Value)>) -> Result<(), Error> { + let mut candidate = self.clone(); + let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p)); + let parent = parents + .rsplit('.') + .find(|s| s.parse::().is_err()) + .unwrap_or(""); + + if !is_records(last, parent) { + return Err(Error("Expected a list of records".into())); + } + + let doc = section_mut( + &mut candidate.doc, + &parents + .split('.') + .filter(|s| !s.is_empty()) + .collect::>(), + "", + )?; + + doc.nodes_mut().push(value_node( + last, + &Value::Object(fields.into_iter().collect()), + parent, + )?); + format_document(&mut candidate.doc); + candidate.refresh()?; + *self = candidate; + + Ok(()) + } + + pub fn remove(&mut self, path: &str, index: usize) -> Result<(), Error> { + let mut candidate = self.clone(); + let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p)); + let doc = section_mut( + &mut candidate.doc, + &parents + .split('.') + .filter(|s| !s.is_empty()) + .collect::>(), + "", + )?; + let i = doc + .nodes() + .iter() + .enumerate() + .filter(|(_, n)| { + field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last + }) + .nth(index) + .map(|(i, _)| i) + .ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?; + + doc.nodes_mut().remove(i); + format_document(&mut candidate.doc); + candidate.refresh()?; + *self = candidate; + + Ok(()) + } +} + +impl fmt::Display for Document { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.doc.fmt(f) + } +} + +fn set_entry_value(entry: &mut KdlEntry, value: KdlValue) { + let representation = value.to_string(); + entry.set_value(value); + if let Some(format) = entry.format_mut() { + format.value_repr = representation; + } +} + +type NodeFormat = (Vec>, Option); + +fn collect_node_formats(doc: &KdlDocument, formats: &mut Vec) { + for node in doc.nodes() { + let entries = node + .entries() + .iter() + .map(|entry| entry.format().cloned()) + .collect(); + let terminator = node + .format() + .map(|format| format.terminator.clone()) + .filter(|text| text.contains("//") || text.contains("/*")); + formats.push((entries, terminator)); + if let Some(children) = node.children() { + collect_node_formats(children, formats); + } + } +} + +fn restore_node_formats(doc: &mut KdlDocument, formats: &mut impl Iterator) { + for node in doc.nodes_mut() { + if let Some((entries, terminator)) = formats.next() { + for (entry, format) in node.entries_mut().iter_mut().zip(entries) { + if let Some(format) = format { + entry.set_format(format); + } + } + if let Some(terminator) = terminator { + if let Some(format) = node.format_mut() { + format.before_terminator = " ".into(); + format.terminator = terminator; + } + } + } + if let Some(children) = node.children_mut() { + restore_node_formats(children, formats); + } + } +} + +fn format_document(doc: &mut KdlDocument) { + let mut formats = Vec::new(); + collect_node_formats(doc, &mut formats); + doc.autoformat(); + restore_node_formats(doc, &mut formats.into_iter()); +} + +fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize { + if let Some(i) = doc + .nodes() + .iter() + .position(|n| field(n.name().value(), parent) == key) + { + return i; + } + + doc.nodes_mut().push(KdlNode::new(node_name(key, parent))); + doc.nodes().len() - 1 +} + +fn section_mut<'a>( + doc: &'a mut KdlDocument, + parts: &[&str], + parent: &str, +) -> Result<&'a mut KdlDocument, Error> { + if parts.is_empty() { + return Ok(doc); + } + + let key = parts[0]; + let i = node_index(doc, key, parent); + let mut used = 1; + let i = if is_records(key, parent) { + let index = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| Error("Record index missing".into()))?; + used = 2; + doc.nodes() + .iter() + .enumerate() + .filter(|(_, n)| field(n.name().value(), parent) == key) + .nth(index) + .map(|(i, _)| i) + .ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))? + } else { + i + }; + let node = &mut doc.nodes_mut()[i]; + let child = node.children_mut().get_or_insert_with(KdlDocument::new); + + section_mut(child, &parts[used..], key) +} + +fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> Result<(), Error> { + if parts.is_empty() { + return Err(Error("Missing setting path".into())); + } + + let key = parts[0]; + let i = node_index(doc, key, parent); + + if parts.len() == 1 { + let new = value_node(key, &value, parent)?; + let node = &mut doc.nodes_mut()[i]; + if new.entries().len() == 1 && node.entries().len() == 1 && node.children().is_none() { + set_entry_value(&mut node.entries_mut()[0], new.entries()[0].value().clone()); + node.entries_mut()[0].set_name(None::); + } else { + *node.entries_mut() = new.entries().to_vec(); + *node.children_mut() = new.children().cloned(); + } + + return Ok(()); + } + + if is_records(key, parent) { + let index = parts[1] + .parse::() + .map_err(|_| Error("Record index missing".into()))?; + let i = doc + .nodes() + .iter() + .enumerate() + .filter(|(_, n)| field(n.name().value(), parent) == key) + .nth(index) + .map(|(i, _)| i) + .ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?; + let node = &mut doc.nodes_mut()[i]; + let rest = &parts[2..]; + + if rest.len() == 1 { + let positions: &[&str] = match key { + "bindings" => &["keys", "action", "argument"], + "outputs" => &["match"], + "output_profiles" => &["name"], + "notes" => &["id"], + _ => &[], + }; + let position = positions.iter().position(|p| *p == rest[0]); + let mut arg_index = 0; + + for entry in node.entries_mut() { + let matches = if let Some(name) = entry.name() { + field(name.value(), key) == rest[0] + } else { + let matches = Some(arg_index) == position; + arg_index += 1; + matches + }; + + if matches { + set_entry_value(entry, kdl_value(&value)?); + return Ok(()); + } + } + + if key == "autostart" + && rest[0] == "command" + && node.children().is_none_or(|d| d.get("command").is_none()) + { + node.entries_mut().retain(|e| e.name().is_some()); + for value in value + .as_array() + .ok_or_else(|| Error("Command must be an array".into()))? + { + node.entries_mut().push(KdlEntry::new(kdl_value(value)?)); + } + return Ok(()); + } + } + + return set_in( + node.children_mut().get_or_insert_with(KdlDocument::new), + rest, + key, + value, + ); + } + let node = &mut doc.nodes_mut()[i]; + + if parts.len() == 2 { + if let Some(entry) = node + .entries_mut() + .iter_mut() + .find(|e| e.name().is_some_and(|n| field(n.value(), key) == parts[1])) + { + set_entry_value(entry, kdl_value(&value)?); + return Ok(()); + } + } + + set_in( + node.children_mut().get_or_insert_with(KdlDocument::new), + &parts[1..], + key, + value, + ) +} + +fn kdl_value(value: &Value) -> Result { + Ok(match value { + Value::String(s) => KdlValue::String(s.clone()), + Value::Bool(b) => KdlValue::Bool(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + KdlValue::Integer(i.into()) + } else if let Some(i) = n.as_u64() { + KdlValue::Integer(i.into()) + } else { + KdlValue::Float(n.as_f64().unwrap()) + } + } + _ => return Err(Error("Expected a scalar value".into())), + }) +} + +fn node_name(key: &str, parent: &str) -> String { + if parent == "commands" { + return key.into(); + } + + if !is_records(key, parent) { + return key.replace('_', "-"); + } + + match key { + "bindings" => "binding".into(), + "window_rules" => "window-rule".into(), + "output_profiles" => "output-profile".into(), + "outputs" => "output".into(), + "notes" => "note".into(), + _ => key.replace('_', "-"), + } +} + +fn value_node(key: &str, value: &Value, parent: &str) -> Result { + let mut node = KdlNode::new(node_name(key, parent)); + + match value { + Value::Object(map) => { + let positions: &[&str] = match key { + "bindings" => &["keys", "action", "argument"], + "output_profiles" => &["name"], + "outputs" => &["match"], + "notes" => &["id"], + _ => &[], + }; + let mut child = KdlDocument::new(); + let mut consumed = Vec::new(); + + for name in positions { + if let Some(value) = map.get(*name) { + node.entries_mut().push(KdlEntry::new(kdl_value(value)?)); + consumed.push(*name); + } else { + break; + } + } + + for (name, value) in map { + if consumed.contains(&name.as_str()) { + continue; + } + if key == "autostart" && name == "command" { + for value in value + .as_array() + .ok_or_else(|| Error("Command must be an array".into()))? + { + node.entries_mut().push(KdlEntry::new(kdl_value(value)?)); + } + } else if is_records(key, parent) + && map.len() <= 4 + && !value.is_array() + && !value.is_object() + { + node.entries_mut().push(KdlEntry::new_prop( + name.replace('_', "-"), + kdl_value(value)?, + )); + } else { + append(&mut child, name, value, key)?; + } + } + + if !child.nodes().is_empty() || node.entries().is_empty() { + node.set_children(child); + } + } + Value::Array(array) => { + for value in array { + node.entries_mut().push(KdlEntry::new(kdl_value(value)?)); + } + } + _ => node.entries_mut().push(KdlEntry::new(kdl_value(value)?)), + } + Ok(node) +} + +fn append(doc: &mut KdlDocument, key: &str, value: &Value, parent: &str) -> Result<(), Error> { + if is_records(key, parent) { + for record in value + .as_array() + .ok_or_else(|| Error("Expected records".into()))? + { + doc.nodes_mut().push(value_node(key, record, parent)?); + } + } else { + doc.nodes_mut().push(value_node(key, value, parent)?); + } + Ok(()) +} + +pub fn from_str(source: &str) -> Result { + serde_json::from_value(Document::parse(source)?.value).map_err(|e| Error(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn widget_output_arrays_and_command_names_survive_edits() { + let source = r#"commands { + my_command "program" +} +desktop-widgets { + clock enabled=#true { + outputs "DP-1" + } + note "n" text="x" { + outputs "DP-2" + } +} +"#; + let mut doc = Document::parse(source).unwrap(); + assert_eq!( + doc.get("commands.my_command").unwrap(), + &serde_json::json!(["program"]) + ); + assert_eq!( + doc.get("desktop_widgets.clock.outputs").unwrap(), + &serde_json::json!(["DP-1"]) + ); + assert_eq!( + doc.get("desktop_widgets.notes.0.outputs").unwrap(), + &serde_json::json!(["DP-2"]) + ); + doc.set("commands.new_command", serde_json::json!(["two", "words"])) + .unwrap(); + doc.set("desktop_widgets.clock.outputs", serde_json::json!([])) + .unwrap(); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!( + reparsed.get("commands.new_command").unwrap(), + &serde_json::json!(["two", "words"]) + ); + assert_eq!( + reparsed.get("desktop_widgets.clock.outputs").unwrap(), + &serde_json::json!([]) + ); + } + + #[test] + fn inline_comments_and_failed_edits_leave_document_intact() { + let mut doc = + Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap(); + doc.set("animations.speed", 0.8.into()).unwrap(); + assert!(doc.to_string().contains("// keep inline")); + assert_eq!( + Document::parse(&doc.to_string()) + .unwrap() + .get("animations.speed") + .unwrap(), + 0.8 + ); + let previous = doc.to_string(); + assert!(doc.set("bindings.4.keys", "X".into()).is_err()); + assert_eq!(doc.to_string(), previous); + } + + #[test] + fn editing_autostart_commands_handles_both_representations() { + for source in [ + "autostart \"old\" enabled=#true\n", + "autostart { command \"old\"; enabled #true; }\n", + ] { + let mut doc = Document::parse(source).unwrap(); + doc.set("autostart.0.command", serde_json::json!(["new", "arg"])) + .unwrap(); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!( + reparsed.get("autostart.0.command").unwrap(), + &serde_json::json!(["new", "arg"]) + ); + } + } + + #[test] + fn compact_syntax_and_nested_arrays_roundtrip() { + let source = r#"input repeat-rate=30 { xkb-options "compose:ralt"; } +binding "Swipe3Up" "toggle-overview" +output-profile "desk" { output "HDMI-A-1" scale=1.5 { position 0 0; }; } +autostart "program" "two words" enabled=#false +"#; + let mut doc = Document::parse(source).unwrap(); + assert_eq!(doc.get("bindings.0.action").unwrap(), "toggle-overview"); + doc.set("bindings.0.action", "none".into()).unwrap(); + doc.set("input.repeat_rate", 25.into()).unwrap(); + doc.set("output_profiles.0.outputs.0.scale", 2.0.into()) + .unwrap(); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!(reparsed.get("bindings.0.action").unwrap(), "none"); + assert_eq!(reparsed.get("input.repeat_rate").unwrap(), 25); + assert_eq!( + reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), + 2.0 + ); + } + + #[test] + fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() { + for source in [ + "input { repeat-rate 25; repeat_rate 30; }", + "binding \"X\" keys=\"Y\"", + "input 1 { repeat-rate 25; }", + "layout { inner-gap #inf; }", + "layout { inner-gap #null; }", + ] { + assert!(Document::parse(source).is_err(), "{source}"); + } + } + + #[test] + fn edits_preserve_comments_commands_and_note_contents() { + let source = r#"// personal comment +commands { + screenshot-full "ferese-screenshot" "--full" +} +binding "Swipe3Left" "move" "left" +desktop-widgets { + note "a" text="line one" +} +"#; + let mut doc = Document::parse(source).unwrap(); + doc.set("desktop_widgets.notes.0.text", "one\ntwo".into()) + .unwrap(); + assert!(doc.to_string().contains("// personal comment")); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!( + reparsed.get("commands.screenshot-full").unwrap(), + &serde_json::json!(["ferese-screenshot", "--full"]) + ); + assert_eq!( + reparsed.get("desktop_widgets.notes.0.text").unwrap(), + "one\ntwo" + ); + doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())]) + .unwrap(); + assert_eq!( + doc.get("desktop_widgets.notes") + .unwrap() + .as_array() + .unwrap() + .len(), + 2 + ); + doc.remove("desktop_widgets.notes", 0).unwrap(); + assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b"); + } +} diff --git a/crates/ferese-core/src/desktop.rs b/crates/ferese-core/src/desktop.rs index 78cb55c..c29abb7 100644 --- a/crates/ferese-core/src/desktop.rs +++ b/crates/ferese-core/src/desktop.rs @@ -97,6 +97,7 @@ impl StickyNote { && self.width == other.width && self.height == other.height } + pub fn validate(&self) -> Result<(), String> { let invalid = |field: &str| { Err(format!( @@ -347,12 +348,14 @@ mod tests { ..note.clone() })); } + #[test] fn defaults_are_safe_and_opt_in() { let clock = Clock::default(); clock.validate().unwrap(); assert!(!clock.on_output(Some("HDMI-A-1"))); } + #[test] fn output_filters_do_not_guess_monitor_names() { let clock = Clock { @@ -364,6 +367,7 @@ mod tests { assert!(!clock.on_output(Some("eDP-1"))); assert!(!clock.on_output(None)); } + #[test] fn rejects_bad_sizes_colors_formats_and_zones() { for clock in [ @@ -391,6 +395,7 @@ mod tests { assert!(clock.validate().is_err()); } } + #[test] fn formats_timezone_and_lowercase() { let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap(); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 09e03b7..00dd455 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -449,6 +449,7 @@ impl WorkspaceSet { } } } + pub fn new( default_layout_mode: LayoutMode, default_column_width: ColumnWidth, diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index c588be7..92c1292 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -103,6 +103,7 @@ impl ScrollingLayout { pub fn set_default_width(&mut self, width: ColumnWidth) { self.default_width = normalized_width(width); } + pub fn with_default_width(default_width: ColumnWidth) -> Self { Self { default_width: normalized_width(default_width), diff --git a/docs/configuration.md b/docs/configuration.md index 433f748..f321bf9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,16 +1,58 @@ # Configuration -Edit `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`), -or open **Control Center → Settings**. Start with the [example](../packaging/config.toml). +Edit `~/.config/ferese/config.kdl` (or `$XDG_CONFIG_HOME/ferese/config.kdl`), +or open **Control Center → Settings**. Start with the [example](../packaging/config.kdl). All supported configuration sections are listed below; [desktop widgets](desktop-widgets.md) covers the complete clock and sticky-note fields separately. +## KDL syntax + +Sections use nested braces, values follow their names, and booleans are `#true` +or `#false`. Multiple values represent arrays. Hyphenated keys are preferred; +underscore spellings remain accepted. Enum values retain their documented spelling. +A newline or semicolon ends each node. Comments use `//` or `/* ... */`. +For example: + +```kdl +input { + repeat-rate 30 + touchpad { + swipe-threshold 80 + } +} + +commands { + terminal "foot" +} + +binding "Super+Enter" "spawn" "terminal" +binding "Swipe3Up" "toggle-overview" +binding "Swipe3Down" disabled=#true +output-profile "docked" { + output "HDMI-A-1" { + scale 1.5 + position 0 0 + } +} +``` + +Repeated `binding`, `window-rule`, `output-profile`, `output`, `note` and +`autostart` nodes describe lists of settings. Bindings accept positional +keys/action/argument; profiles, outputs and notes accept a positional name, +match or ID. Other fields can be properties or child nodes. Autostart accepts +its command as positional arguments. Duplicate fields are rejected. +The dotted paths in the reference tables below describe nested sections. + +Settings preserves comments and custom fields when editing, while normalizing +indentation. Runtime configuration uses KDL only. KDL parsing requires Rust 1.95 +or newer to build. + ## Saving and validation Settings saves automatically: text on Enter/focus loss, sliders when released. Undo restores the previous save; Reload picks up external edits without merging unfinished drafts. Wallpaper browsing needs `zenity`; a path can also be entered. -Settings keeps `config.toml.settings-backup` before saving. +Settings keeps `config.kdl.settings-backup` before saving. File edits reload automatically, including atomic editor saves. Invalid changes keep the last working configuration. Appearance, wallpaper, motion, input, @@ -29,14 +71,14 @@ Live config is limited to 60 KiB. ## Layout -| `[layout]` key | Type / values | Default | Meaning | +| `layout` key | Type / values | Default | Meaning | | --- | --- | --- | --- | | `mode` | `"scrolling"`, `"tree"` | `"scrolling"` | Workspace layout | | `inner_gap` | number ≥ 0 | `10` | Between windows | | `outer_gap` | number ≥ 0 | `4` | Around workspace edges | | `smart_gaps` | boolean | `false` | Remove outer gaps for one tiled window | -| `[scrolling]` key | Type / values | Default | Meaning | +| `scrolling` key | Type / values | Default | Meaning | | --- | --- | --- | --- | | `default_column_width` | proportion > 0; or `"full"` | `0.5` | Width of new columns | | `focus_strategy` | `"minimal"`, `"center_on_focus"`, `"paged"` | `"minimal"` | Viewport movement on focus | @@ -75,7 +117,7 @@ Changing default width preserves manually resized columns. | `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material | | `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables | -| `[theme.colors]` key | Default | Meaning | +| `theme.colors` key | Default | Meaning | | --- | --- | --- | | `surface_base` | `"#111821"` | Material/card base color | | `text_primary` | `"#F4F7FB"` | Main text | @@ -84,7 +126,7 @@ Changing default width preserves manually resized columns. | `border` | `"#FFFFFF18"` | Unfocused window border | | `shadow` | `"#00000055"` | Window shadow color | -| `[theme.geometry]` key | Type | Default | Meaning | +| `theme.geometry` key | Type | Default | Meaning | | --- | --- | --- | --- | | `border_width` | number ≥ 0 | `1` | Window border thickness | | `focus_ring_width` | number ≥ 0 | `2` | Focused border thickness | @@ -111,21 +153,26 @@ Changing default width preserves manually resized columns. Solid materials omit blur; translucent materials blur behind the surface color. True fullscreen removes decorations. Focus transitions never scale the content. -Optional `[theme.focus_ring.gradient]` and `[theme.border.gradient]` use the same +Optional `theme.focus_ring.gradient` and `theme.border.gradient` use the same keys: required `from` and `to` colors, and `angle` (number, default `0`). Angles are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section for solid `accent` or `border` colors. -```toml -[theme.focus_ring.gradient] -from = "#e5c890" -to = "#b98d58" -angle = 135.0 +```kdl +theme { + focus-ring { + gradient { + from "#e5c890" + to "#b98d58" + angle 135.0 + } + } +} ``` ## Input -| `[input]` key | Type | Default | Meaning | +| `input` key | Type | Default | Meaning | | --- | --- | --- | --- | | `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer without raising them; click to raise | | `xkb_layout` | nonempty string | `"us"` | XKB layout | @@ -150,37 +197,28 @@ keyboard bindings. Choose a "Customize swipe" button to add an override. The gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5 fingers are also supported. Changes reload live: -```toml -[[bindings]] -keys = "Swipe3Up" -action = "toggle-overview" - -[[bindings]] -keys = "Swipe3Left" -action = "move" -argument = "left" - -[[bindings]] -keys = "Swipe3Down" -disabled = true +```kdl +binding keys="Swipe3Up" action="toggle-overview" +binding keys="Swipe3Left" action="move" argument="left" +binding keys="Swipe3Down" disabled=#true ``` -`spawn` uses a named entry from `[commands]`, just like keyboard shortcuts. To +`spawn` uses a named entry from `commands`, just like keyboard shortcuts. To disable a direction in Settings, set its action to `none` and leave Argument -empty. In TOML, `disabled = true` removes the default binding. Swipes do not use +empty. In KDL, `disabled=#true` removes the default binding. Swipes do not use keyboard modifiers or physical key matching. The trigger is automatically recognized from its name. Gesture navigation uses native touchpad events in a hardware session. Adjust recognition distance in **Settings → Keyboard & mouse**, or with -`swipe_threshold = 80` under `[input.touchpad]` (integer 16–1000 logical pixels). +`swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels). ## Commands and bindings -`[commands]` maps arbitrary names to nonempty argument arrays. The built-in -`terminal = ["foot"]` can be overridden. Commands run directly, without a shell. +`commands` maps arbitrary names to nonempty argument arrays. The built-in +`terminal "foot"` can be overridden. Commands run directly, without a shell. -| `[[bindings]]` key | Type / values | Default | +| `binding` key | Type / values | Default | | --- | --- | --- | | `keys` | chord or gesture, e.g. `"Super+Enter"`, `"Swipe3Up"` | required | | `match` | `"keysym"`, `"physical"` | `"keysym"` | @@ -195,21 +233,18 @@ invalid. A disabled binding must omit `action` and `argument`. | Actions | Argument | | --- | --- | -| `spawn` | Name in `[commands]` | +| `spawn` | Name in `commands` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `none` | None; ignore this trigger | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | -```toml -[commands] -terminal = ["foot"] - -[[bindings]] -keys = "Super+Enter" -action = "spawn" -argument = "terminal" +```kdl +commands { + terminal "foot" +} +binding keys="Super+Enter" action="spawn" argument="terminal" ``` Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; @@ -233,7 +268,7 @@ this command. Both modes open Satty with the same save and copy workflow. ## Window rules -| `[[window_rules]]` key | Type | Default / meaning | +| `window-rule` key | Type | Default / meaning | | --- | --- | --- | | `app_id` | nonempty string | Optional exact match; case-insensitive, `.desktop` suffix ignored | | `title` | nonempty string | Optional exact, case-sensitive match | @@ -249,20 +284,18 @@ when `floating` is omitted. Floating apps without dimensions choose their own size and open centered on the output. Title changes do not trigger new rules; config rule changes apply to existing windows. -```toml -[[window_rules]] -app_id = "dev.ferese.Settings" -floating = true +```kdl +window-rule app-id="dev.ferese.Settings" floating=#true ``` ## Displays -`[[output_profiles]]` has a required unique nonempty `name` and one or more -`[[output_profiles.outputs]]` entries. The first profile whose listed monitors +`output-profile` has a required unique nonempty `name` and one or more +`output-profile → output` entries. The first profile whose listed monitors are connected wins. Find connector names and persistent identities using `feresectl get-outputs`. -| `[[output_profiles.outputs]]` key | Type | Default | +| `output-profile → output` key | Type | Default | | --- | --- | --- | | `match` | nonempty connector or persistent identity string | required; unique within profile | | `enabled` | boolean | `true` | @@ -275,14 +308,12 @@ Transforms: `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, `flipped_90`, `flipped_180`, `flipped_270`. Unspecified connected displays stay enabled with defaults. Disabling every usable output is rejected. -```toml -[[output_profiles]] -name = "docked" - -[[output_profiles.outputs]] -match = "HDMI-A-1" -scale = 1.5 -position = [0, 0] +```kdl +output-profile name="docked" { + output match="HDMI-A-1" scale=1.5 { + position 0 0 + } +} ``` With a usable external display, closing the lid disables internal panels. @@ -291,7 +322,7 @@ returns. Ferese does not change the system's suspend policy. ## Status controls -| `[status]` key | Type | Default | Meaning | +| `status` key | Type | Default | Meaning | | --- | --- | --- | --- | | `battery_percentage` | boolean | `true` | Show percentage beside icon | | `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold | @@ -299,7 +330,7 @@ returns. Ferese does not change the system's suspend policy. ## Login items and locking -| `[[autostart]]` key | Type | Default | Meaning | +| `autostart` key | Type | Default | Meaning | | --- | --- | --- | --- | | `command` | nonempty argument array | required | Foreground process, not a self-daemonizing command | | `enabled` | boolean | `true` | Start service | @@ -309,9 +340,10 @@ returns. Ferese does not change the system's suspend policy. Unchanged services keep running on reload; removed/disabled items stop. To lock after five minutes and before sleep: -```toml -[[autostart]] -command = ["swayidle", "-w", "timeout", "300", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] +```kdl +autostart { + command "swayidle" "-w" "timeout" "300" "ferese-lock" "before-sleep" "ferese-lock" "lock" "ferese-lock" +} ``` `ferese-lock` uses swaylock/swaylock-effects and optional @@ -321,7 +353,7 @@ ending the session from another TTY. ## Optional session protocols -These are launch-time environment switches, not TOML keys. Enable only for +These are launch-time environment switches, not KDL keys. Enable only for trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`, `FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. The installed session launcher enables screencopy for screenshots unless diff --git a/docs/desktop-widgets.md b/docs/desktop-widgets.md index 3796232..3c6dbbc 100644 --- a/docs/desktop-widgets.md +++ b/docs/desktop-widgets.md @@ -11,32 +11,31 @@ Lock-screen rendering remains separate and does not expose the desktop clock. Enable it in Settings → Desktop widgets or in your Ferese config: -```toml -[desktop_widgets.clock] -enabled = true -outputs = [] # every output; or ["DP-2"] using your own connector -anchor = "top_left" -margin_x = 64 # distance from anchored left/right edge -margin_y = 80 # distance from anchored top/bottom edge -width = 440 -height = 160 -font_family = "" # empty/omitted follows your desktop font -bold = false -time_size = 72.0 -date_size = 18.0 -time_format = "%-I:%M %p" # 12-hour; "%H:%M" for 24-hour, "%H:%M:%S" for seconds -date_format = "%A, %-d %B" -time_zone = "" # system zone; or e.g. "Africa/Lagos", "UTC" -show_date = true -lowercase = true -color = "" # theme primary text; or "#ffffff", "#ffffffcc" -date_color = "" # theme muted text -opacity = 0.9 # multiplies text and optional background alpha -alignment = "center" # left, center, right -gap = 4.0 -padding = 12.0 -background = "" # transparent; or e.g. "#10101080" for a solid tint -radius = 16.0 # optional background corner radius +```kdl +desktop-widgets { + clock enabled=#true anchor="top_left" margin-x=64 margin-y=80 { + outputs // every output; or outputs "DP-2" + width 440 + height 160 + font-family "" // inherit the desktop font + bold #false + time-size 72.0 + date-size 18.0 + time-format "%-I:%M %p" // use "%H:%M" for 24-hour time + date-format "%A, %-d %B" + time-zone "" // system zone; or "Africa/Lagos" + show-date #true + lowercase #true + color "" // theme primary text + date-color "" // theme muted text + opacity 0.9 + alignment "center" // left, center, right + gap 4.0 + padding 12.0 + background "" // transparent; or "#10101080" + radius 16.0 + } +} ``` Positions: `top_left`, `top_center`, `top_right`, `center_left`, `center`, @@ -55,7 +54,7 @@ configuration. Theme changes apply to colors/fonts that have no explicit overrid Formatting uses [Jiff's strftime-compatible formats](https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html). Seconds update on the shell's existing timer. Without seconds, unchanged labels produce no additional clock buffer damage; there is no widget animation loop. -Widget placement can be changed by dragging or through Settings and TOML. +Widget placement can be changed by dragging or through Settings and KDL. Sticky notes also support editing directly on the desktop. ## Clock limits @@ -87,21 +86,14 @@ on release; dragging changes the anchor to `top_left` and saves pixel margins. Title, size and style are also editable in Settings. Notes persist in the config. These are plain-text notes, not Markdown or HTML. -```toml -[[desktop_widgets.notes]] -id = "today" -title = "Today" -text = """ -Review the pull request -Take a break -""" -anchor = "top_right" -margin_x = 48 -margin_y = 80 +```kdl +desktop-widgets { + note id="today" title="Today" text="Review the pull request\nTake a break\n" anchor="top_right" margin-x=48 margin-y=80 +} ``` -Repeat the table for additional notes; IDs must be unique. Cards stay behind windows -and take keyboard focus only when clicked. Set `interactive = false` for a fully +Repeat the `note` node for additional notes; IDs must be unique. Cards stay behind windows +and take keyboard focus only when clicked. Set `interactive #false` for a fully click-through card. Long text wraps; increase height if needed. Notes have no idle animation loop. Clock and notes share a compact drag preview moved by the compositor, not an output-sized canvas. It is released when dragging ends. diff --git a/docs/installation.md b/docs/installation.md index 83ca220..adb5fce 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -24,8 +24,8 @@ It preserves your configuration and other desktop sessions. Log out and select ``` Use `--help` for all options. New configs can be copied from -`/usr/local/lib/ferese/current/config.example.toml` to -`~/.config/ferese/config.toml`. See [Configuration](configuration.md). +`/usr/local/lib/ferese/current/config.example.kdl` to +`~/.config/ferese/config.kdl`. See [Configuration](configuration.md). ## Preview and logs diff --git a/packaging/config.kdl b/packaging/config.kdl new file mode 100644 index 0000000..f2b49ee --- /dev/null +++ b/packaging/config.kdl @@ -0,0 +1,44 @@ +commands { + terminal "foot" + screenshot "ferese-screenshot" + screenshot-full "ferese-screenshot" "--full" +} + +input { + xkb-layout "us" + focus-follows-mouse #false + + touchpad { + swipe-threshold 80 + } +} + +layout { + smart-gaps #false +} + +theme { + surface { + bar { + background "#1C202E" + opacity 0.95 + text-primary "#F0F3FA" + text-muted "#AAB4C7" + } + } +} + +window-rule app-id="dev.ferese.Settings" floating=#true +window-rule title="Choose a wallpaper" floating=#true + +desktop-widgets { + clock { + enabled #false + anchor "top_left" + margin-x 64 + margin-y 80 + time-size 72.0 + date-size 18.0 + opacity 0.9 + } +} diff --git a/packaging/config.toml b/packaging/config.toml deleted file mode 100644 index 6c4c642..0000000 --- a/packaging/config.toml +++ /dev/null @@ -1,51 +0,0 @@ -# User configuration: ~/.config/ferese/config.toml -# Unspecified monitors use automatic placement and preferred modes. -[commands] -terminal = ["foot"] -screenshot = ["ferese-screenshot"] -screenshot-full = ["ferese-screenshot", "--full"] - -[input] -xkb_layout = "us" -focus_follows_mouse = false - -[input.touchpad] -swipe_threshold = 80 - -[layout] -smart_gaps = false - -# Edge-to-edge menu bar; its foreground palette is independent of popup surfaces. -[theme.surface.bar] -background = "#1C202E" -opacity = 0.95 -text_primary = "#F0F3FA" -text_muted = "#AAB4C7" - -# Super+Enter opens Foot. Super+Shift+S selects a screenshot. Super+Shift+E logs out. -# See README.md for monitor profiles and additional bindings. - -[[window_rules]] -app_id = "dev.ferese.Settings" -floating = true - -[[window_rules]] -title = "Choose a wallpaper" -floating = true - -# Optional desktop clock; all dimensions are logical pixels. -[desktop_widgets.clock] -enabled = false -anchor = "top_left" -margin_x = 64 -margin_y = 80 -time_size = 72.0 -date_size = 18.0 -opacity = 0.9 - -# Swipe bindings use the same actions and arguments as keyboard bindings. -# Defaults: up/down switch workspaces; left/right focus windows. -# Example override: -# [[bindings]] -# keys = "Swipe3Up" -# action = "toggle-overview" diff --git a/scripts/install-session.sh b/scripts/install-session.sh index cfb5852..7b3a531 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -55,7 +55,7 @@ done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock" install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot" -install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml" +install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl" if [[ -L $install_root/current ]]; then ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous" diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index 22d471b..aa54531 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -6,10 +6,10 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-config = { path = "../../crates/ferese-config" } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } ferese-ipc = { path = "../../crates/ferese-ipc" } serde_json.workspace = true -toml_edit = "0.25.15" tempfile.workspace = true shlex = "1.3.0" notify = "8.2.0" diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 2c61741..3c40f19 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -21,6 +21,7 @@ fn main() -> cosmic::iced::Result { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu"); } } + let mut args = std::env::args_os().skip(1); let path = match args.next() { Some(arg) if arg == "--config" => { @@ -35,6 +36,7 @@ fn main() -> cosmic::iced::Result { let path = path.canonicalize().unwrap_or(path); let initial = Snapshot::read(&path); let theme = visuals::native_theme(initial.as_ref().ok()); + cosmic::app::run::( Settings::default() .theme(theme) @@ -107,14 +109,18 @@ impl cosmic::Application for App { type Flags = (PathBuf, Result); type Message = Message; const APP_ID: &'static str = "dev.ferese.Settings"; + fn core(&self) -> &Core { &self.core } + fn core_mut(&mut self) -> &mut Core { &mut self.core } + fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task) { core.window.show_headerbar = false; + let status = if path == store::config_path() { "Changes save automatically" } else { @@ -152,15 +158,18 @@ impl cosmic::Application for App { .main_window_id() .map(|id| app.set_window_title("Ferese Settings".into(), id)) .unwrap_or_else(Task::none); + app.sync_notes(); (app, task) } + fn update(&mut self, message: Message) -> Task { match message { Message::NoteAction(id, action) => { if let Some(editor) = self.note_editors.get_mut(&id) { let edited = action.is_edit(); editor.content.perform(action); + if edited { editor.dirty = true; editor.revision = editor.revision.wrapping_add(1); @@ -176,6 +185,7 @@ impl cosmic::Application for App { let Some(index) = self.note_index(&id) else { return Task::none(); }; + if let Some(editor) = self.note_editors.get_mut(&id) && editor.dirty && editor.revision == revision @@ -203,6 +213,7 @@ impl cosmic::Application for App { ("text".into(), "".into()), ], )); + self.sync_notes(); return task; } @@ -229,6 +240,7 @@ impl cosmic::Application for App { self.undo = None; self.error = None; self.status = "Updated from your config".into(); + return Task::batch([ cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), self.load_thumbnail(), @@ -266,9 +278,12 @@ impl cosmic::Application for App { if let Some(value) = self.inputs.remove(&field.path) { let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) { match shlex::split(&value).filter(|v| !v.is_empty()) { - Some(args) => { - set(&field.path, args.into_iter().collect::()) - } + Some(args) => set( + &field.path, + args.into_iter() + .map(serde_json::Value::from) + .collect::>(), + ), None => { self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into()); return Task::none(); @@ -289,11 +304,13 @@ impl cosmic::Application for App { self.current = snapshot; self.font = visuals::configured_font(&self.current); self.draft = self.current.clone(); + for edit in &self.pending { if let Err(error) = self.draft.edit(edit) { self.error = Some(error); } } + self.sync_notes(); self.status = if live { "Saved · desktop updated" @@ -303,7 +320,9 @@ impl cosmic::Application for App { "Saved · could not confirm desktop reload" } .into(); + let theme = visuals::native_theme(Some(&self.draft)); + return Task::batch([ cosmic::command::set_theme(theme), self.flush(), @@ -331,6 +350,7 @@ impl cosmic::Application for App { self.error = None; self.undo = None; self.status = "Reloaded from your config".into(); + return Task::batch([ cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), self.load_thumbnail(), @@ -349,6 +369,7 @@ impl cosmic::Application for App { self.error = Some(error); return Task::none(); } + self.pending.push(edit); } return self.flush(); @@ -420,7 +441,10 @@ impl cosmic::Application for App { vec![ ( "command".into(), - args.into_iter().collect::().into(), + args.into_iter() + .map(serde_json::Value::from) + .collect::>() + .into(), ), ("enabled".into(), true.into()), ("restart".into(), true.into()), @@ -444,9 +468,11 @@ impl cosmic::Application for App { ("keys".into(), keys.into()), ("action".into(), action.into()), ]; + if let Some(argument) = argument { fields.push(("argument".into(), argument.into())); } + return self.change(Edit::Add("bindings".into(), fields)); } Message::Remove(table, index) if !self.saving => { @@ -459,9 +485,11 @@ impl cosmic::Application for App { } Task::none() } + fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes) } + fn view(&self) -> Element<'_, Message> { let palette = visuals::Palette::from(&self.draft); let mut sidebar = column([]) @@ -538,6 +566,7 @@ impl cosmic::Application for App { .width(Length::Fill), ); let mut body = column([]).spacing(12); + if !self.search.is_empty() { if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { body = body @@ -586,6 +615,7 @@ impl cosmic::Application for App { } body = body.push(presets); } + if self.page == Page::Wallpaper { if let Some(handle) = &self.thumbnail { body = body.push( @@ -607,6 +637,7 @@ impl cosmic::Application for App { body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); } let fields = schema::fields(self.page); + if !fields.is_empty() { let mut group = column([]).spacing(1); for field in fields { @@ -618,6 +649,7 @@ impl cosmic::Application for App { .class(visuals::surface(palette.card, 14.)), ); } + match self.page { Page::Desktop => { let can_change_list = @@ -629,9 +661,11 @@ impl cosmic::Application for App { .draft .string(&format!("desktop_widgets.notes.{index}.id"), "note"); let mut group = column([]).spacing(8); + for field in schema::note_fields(index) { group = group.push(self.field(field)); } + if let Some(editor) = self.note_editors.get(&id) { let id = id.clone(); group = group.push( @@ -666,6 +700,7 @@ impl cosmic::Application for App { } Page::Startup => { body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); + for index in 0..self.draft.records("autostart") { let prefix = format!("autostart.{index}"); let group = column([]) @@ -735,6 +770,7 @@ impl cosmic::Application for App { .string(&format!("bindings.{index}.keys"), "") .eq_ignore_ascii_case(keys) }); + if !exists { gestures = gestures.push(button::standard(label).on_press_maybe( @@ -742,7 +778,9 @@ impl cosmic::Application for App { )); } } + body = body.push(gestures); + for index in 0..self.draft.records("bindings") { let prefix = format!("bindings.{index}"); let group = column([]) @@ -772,7 +810,7 @@ impl cosmic::Application for App { ); } if self.draft.records("bindings") == 0 { - body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.toml; they will appear here after Reload.")); + body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); } } Page::Displays => { @@ -786,6 +824,7 @@ impl cosmic::Application for App { 17., ), ); + for output in 0..self.draft.records(&format!("{prefix}.outputs")) { let prefix = format!("{prefix}.outputs.{output}"); let group = column([]).spacing(1) @@ -806,7 +845,9 @@ impl cosmic::Application for App { _ => {} } } + let mut content = column([]).spacing(16).push(heading); + if let Some(error) = &self.error { content = content.push( container(self.label(error.clone(), 12.)) @@ -815,6 +856,7 @@ impl cosmic::Application for App { .class(visuals::surface(palette.error, 10.)), ); } + content = content.push( scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill), ); @@ -902,8 +944,10 @@ impl App { == id }) } + fn sync_notes(&mut self) { let mut ids = Vec::new(); + for index in 0..self.draft.records("desktop_widgets.notes") { let prefix = format!("desktop_widgets.notes.{index}"); let id = self.draft.string(&format!("{prefix}.id"), "note"); @@ -921,8 +965,10 @@ impl App { } ids.push(id); } + self.note_editors.retain(|id, _| ids.contains(id)); } + fn load_thumbnail(&mut self) -> Task { let path = self.current.string("theme.background.path", ""); if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path { @@ -934,6 +980,7 @@ impl App { self.thumbnail_error = None; return Task::none(); } + self.thumbnail_loading = true; cosmic::task::future(async move { let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); @@ -970,6 +1017,7 @@ impl App { ) }) } + fn label<'a>( &self, text: impl Into> + 'a, @@ -977,12 +1025,14 @@ impl App { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { widget::text(text).size(size).font(self.font) } + fn note(&self, text: &str) -> Element<'static, Message> { let palette = visuals::Palette::from(&self.draft); self.label(text.to_owned(), 12.) .class(cosmic::theme::Text::Color(palette.muted)) .into() } + fn field(&self, field: Field) -> Element<'static, Message> { let palette = visuals::Palette::from(&self.draft); let mut labels = column([]) @@ -1096,6 +1146,7 @@ impl App { .class(visuals::surface(palette.card, 0.)) .into() } + fn change(&mut self, edit: Edit) -> Task { match self.draft.edit(&edit) { Ok(()) => { @@ -1122,6 +1173,7 @@ impl App { } } } + fn flush(&mut self) -> Task { if self.saving || self.pending.is_empty() { return Task::none(); @@ -1132,6 +1184,7 @@ impl App { } self.start_save(self.draft.source.clone()) } + fn start_save(&mut self, desired: String) -> Task { self.saving = true; self.saving_previous = self.current.source.clone(); @@ -1164,12 +1217,13 @@ mod tests { App::init( Core::default(), ( - PathBuf::from("/unused/settings-test.toml"), + PathBuf::from("/unused/settings-test.kdl"), Snapshot::parse(String::new()), ), ) .0 } + #[test] fn notes_save_only_the_latest_revision_and_preserve_multiline_text() { let mut app = app(); @@ -1210,11 +1264,12 @@ mod tests { app.sync_notes(); app.note_editors.get_mut("test").unwrap().dirty = true; let _ = app.update(Message::ExternalConfig(Snapshot::parse( - "[animations]\nspeed = 0.5".into(), + "animations {\n speed 0.5\n}\n".into(), ))); assert!(app.note_editors.contains_key("test")); assert!(app.status.contains("externally")); } + #[test] fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() { let mut app = app(); @@ -1230,24 +1285,26 @@ mod tests { assert_eq!(app.draft.number("layout.inner_gap", 0.), 6.); assert_eq!(app.draft.number("animations.speed", 0.), 0.75); } + #[test] fn external_config_refreshes_idle_settings_but_preserves_active_edits() { let mut app = app(); - let external = Snapshot::parse("[animations]\nspeed = 0.5".into()).unwrap(); + let external = Snapshot::parse("animations {\n speed 0.5\n}\n".into()).unwrap(); let _ = app.update(Message::ExternalConfig(Ok(external.clone()))); assert_eq!(app.current.source, external.source); assert_eq!(app.draft.number("animations.speed", 0.), 0.5); app.inputs .insert("theme.colors.accent".into(), "#ffffff".into()); - let newer = Snapshot::parse("[animations]\nspeed = 0.8".into()).unwrap(); + let newer = Snapshot::parse("animations {\n speed 0.8\n}\n".into()).unwrap(); let _ = app.update(Message::ExternalConfig(Ok(newer))); assert_eq!(app.current.source, external.source); assert_eq!(app.inputs["theme.colors.accent"], "#ffffff"); assert!(app.status.contains("externally")); - let _ = app.update(Message::ExternalConfig(Err("invalid TOML".into()))); + let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into()))); assert_eq!(app.current.source, external.source); - assert!(app.error.unwrap().contains("invalid TOML")); + assert!(app.error.unwrap().contains("invalid KDL")); } + #[test] fn settings_has_no_native_headerbar() { assert!(!app().core.window.show_headerbar); diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 1ab9508..f162a43 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -11,6 +11,7 @@ pub enum Page { Startup, Displays, } + impl Page { pub const ALL: [Self; 10] = [ Self::Appearance, @@ -24,6 +25,7 @@ impl Page { Self::Startup, Self::Displays, ]; + pub fn title(self) -> &'static str { match self { Self::Appearance => "Appearance", @@ -38,6 +40,7 @@ impl Page { Self::Displays => "Displays", } } + pub fn subtitle(self) -> &'static str { match self { Self::Appearance => "A desktop that feels like yours.", @@ -52,6 +55,7 @@ impl Page { Self::Displays => "A place for every screen.", } } + pub fn icon(self) -> &'static str { match self { Self::Appearance => { @@ -72,6 +76,7 @@ impl Page { Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8", } } + pub fn matches(self, query: &str) -> bool { let query = query.to_lowercase(); self.title().to_lowercase().contains(&query) @@ -103,6 +108,7 @@ pub enum Kind { argv: bool, }, } + #[derive(Clone, Debug)] pub struct Field { pub path: String, @@ -110,6 +116,7 @@ pub struct Field { pub description: String, pub kind: Kind, } + impl Field { pub fn new( path: impl Into, @@ -125,9 +132,11 @@ impl Field { } } } + fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field { Field::new(path, label, description, Kind::Toggle(default)) } + pub fn range( path: impl Into, label: impl Into, @@ -153,6 +162,7 @@ pub fn range( }, ) } + pub fn text( path: impl Into, label: impl Into, @@ -169,6 +179,7 @@ pub fn text( }, ) } + fn choice( path: &str, label: &str, diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index 243c228..dad0dfa 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -1,3 +1,5 @@ +use ferese_config::Document; +use serde_json::Value; use std::{ fs, io::{Read, Write}, @@ -5,23 +7,24 @@ use std::{ process::{Command, Stdio}, time::{Duration, Instant}, }; -use toml_edit::{DocumentMut, Item, Value}; pub const LIMIT: usize = 60 * 1024; #[derive(Clone, Debug)] pub struct Snapshot { pub source: String, - pub doc: DocumentMut, + pub doc: Document, } + impl Snapshot { pub fn parse(source: String) -> Result { if source.len() > LIMIT { return Err("Configuration exceeds the 60 KiB limit.".into()); } - let doc = source.parse::().map_err(|e| e.to_string())?; + let doc = Document::parse(&source).map_err(|e| e.to_string())?; Ok(Self { source, doc }) } + pub fn read(path: &Path) -> Result { let source = match fs::File::open(path) { Ok(file) => { @@ -36,39 +39,35 @@ impl Snapshot { }; Self::parse(source) } - pub fn item(&self, path: &str) -> Option<&Item> { - let mut item = self.doc.as_item(); - for key in path.split('.') { - item = if let Ok(index) = key.parse::() { - item.get(index)? - } else { - item.get(key)? - }; - } - Some(item) + + pub fn item(&self, path: &str) -> Option<&Value> { + self.doc.get(path) } + pub fn string(&self, path: &str, fallback: &str) -> String { self.item(path) - .and_then(Item::as_str) + .and_then(Value::as_str) .unwrap_or(fallback) .to_owned() } + pub fn number(&self, path: &str, fallback: f64) -> f64 { - self.item(path) - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|v| v as f64))) - .unwrap_or(fallback) + self.item(path).and_then(|v| v.as_f64()).unwrap_or(fallback) } + pub fn boolean(&self, path: &str, fallback: bool) -> bool { - self.item(path).and_then(Item::as_bool).unwrap_or(fallback) + self.item(path).and_then(Value::as_bool).unwrap_or(fallback) } + pub fn records(&self, path: &str) -> usize { self.item(path) - .and_then(Item::as_array_of_tables) + .and_then(Value::as_array) .map_or(0, |a| a.len()) } + pub fn argv(&self, path: &str) -> String { self.item(path) - .and_then(Item::as_array) + .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(Value::as_str) @@ -82,79 +81,26 @@ impl Snapshot { }) .unwrap_or_default() } + pub fn edit(&mut self, edit: &Edit) -> Result<(), String> { match edit { - Edit::Set(path, value) => { - let mut item = self.doc.as_item_mut(); - for key in path.split('.') { - if let Ok(index) = key.parse::() { - item = item - .get_mut(index) - .ok_or("This item no longer exists. Reload settings.")?; - } else { - if item.is_none() { - *item = Item::Table(toml_edit::Table::new()); - } - let table = item - .as_table_like_mut() - .ok_or("This setting is not a table.")?; - item = table.entry(key).or_insert(Item::None); - } - } - let mut value = value.clone(); - if let Some(previous) = item.as_value() { - *value.decor_mut() = previous.decor().clone(); - } - *item = Item::Value(value); - } - Edit::Add(table, fields) => { - let item = nested_item_mut(self.doc.as_item_mut(), table)?; - if item.is_none() { - *item = Item::ArrayOfTables(Default::default()); - } - let array = item - .as_array_of_tables_mut() - .ok_or("Expected a list of settings.")?; - let mut record = toml_edit::Table::new(); - for (key, value) in fields { - record[key] = Item::Value(value.clone()); - } - array.push(record); - } - Edit::Remove(table, index) => { - let array = nested_item_mut(self.doc.as_item_mut(), table)? - .as_array_of_tables_mut() - .ok_or("This list no longer exists.")?; - if *index >= array.len() { - return Err("This item no longer exists. Reload settings.".into()); - } - array.remove(*index); - } + Edit::Set(path, value) => self.doc.set(path, value.clone()), + Edit::Add(path, fields) => self.doc.add(path, fields.clone()), + Edit::Remove(path, index) => self.doc.remove(path, *index), } + .map_err(|e| e.to_string())?; self.source = self.doc.to_string(); Ok(()) } } -fn nested_item_mut<'a>(mut item: &'a mut Item, path: &str) -> Result<&'a mut Item, String> { - for key in path.split('.') { - if item.is_none() { - *item = Item::Table(toml_edit::Table::new()); - } - let table = item - .as_table_like_mut() - .ok_or("Expected a settings table.")?; - item = table.entry(key).or_insert(Item::None); - } - Ok(item) -} - #[derive(Clone, Debug)] pub enum Edit { Set(String, Value), Add(String, Vec<(String, Value)>), Remove(String, usize), } + pub fn set(path: &str, value: impl Into) -> Edit { Edit::Set(path.to_owned(), value.into()) } @@ -164,7 +110,7 @@ pub fn config_path() -> PathBuf { .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")); - let path = directory.join("ferese/config.toml"); + let path = directory.join("ferese/config.kdl"); path.canonicalize().unwrap_or(path) } @@ -187,6 +133,7 @@ fn validate(path: &Path) -> Result<(), String> { .spawn() .map_err(|e| format!("Cannot validate configuration: {e}"))?; let deadline = Instant::now() + Duration::from_secs(5); + loop { if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { return if status.success() { @@ -199,11 +146,13 @@ fn validate(path: &Path) -> Result<(), String> { .collect()) }; } + if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); return Err("Configuration validation timed out.".into()); } + std::thread::sleep(Duration::from_millis(20)); } } @@ -213,6 +162,7 @@ fn validate(path: &Path) -> Result<(), String> { pub fn save(path: &Path, expected: &str, desired: &str) -> Result { save_with(path, expected, desired, validate) } + fn save_with( path: &Path, expected: &str, @@ -251,7 +201,7 @@ fn save_with( .map_err(|e| e.to_string())?; backup.as_file().sync_all().map_err(|e| e.to_string())?; backup - .persist(path.with_extension("toml.settings-backup")) + .persist(path.with_extension("kdl.settings-backup")) .map_err(|e| e.to_string())?; staged.persist(path).map_err(|e| e.to_string())?; Ok(snapshot) @@ -285,53 +235,68 @@ mod tests { use super::*; #[test] fn edits_preserve_comments_custom_keys_and_other_rules() { - let source = "# personal theme\n[animations]\nspeed = 0.75 # keep this\n[custom]\nfuture = true\n[[window_rules]]\napp_id = 'mine'\nfloating = true\n"; + let source = r#"// personal theme +// keep this +animations { + speed 0.75 +} +custom { + future #true +} +window-rule app-id="mine" floating=#true +"#; let mut snapshot = Snapshot::parse(source.into()).unwrap(); snapshot.edit(&set("animations.speed", 0.8)).unwrap(); - assert!(snapshot.source.contains("# keep this")); - assert!(snapshot.source.contains("# personal theme")); + assert!(snapshot.source.contains("// keep this")); + assert!(snapshot.source.contains("// personal theme")); assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine"); assert!(snapshot.boolean("custom.future", false)); } + #[test] fn rejects_conflicts_and_failed_validation_without_overwriting() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "# external edit\n").unwrap(); - assert!(save_with(&path, "", "[animations]\nspeed=1.0", |_| Ok(())).is_err()); + let path = dir.path().join("config.kdl"); + fs::write(&path, "// external edit\n").unwrap(); + assert!(save_with(&path, "", "animations {\n speed 1.0\n}\n", |_| Ok(())).is_err()); assert!( save_with( &path, - "# external edit\n", - "[animations]\nspeed=1.0", + "// external edit\n", + "animations {\n speed 1.0\n}\n", |_| Err("invalid".into()) ) .is_err() ); - assert_eq!(fs::read_to_string(&path).unwrap(), "# external edit\n"); + assert_eq!(fs::read_to_string(&path).unwrap(), "// external edit\n"); } + #[test] fn catches_edits_during_validation_and_keeps_a_backup_on_success() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "# initial\n").unwrap(); + let path = dir.path().join("config.kdl"); + fs::write(&path, "// initial\n").unwrap(); assert!( - save_with(&path, "# initial\n", "# desired\n", |_| { - fs::write(&path, "# another editor\n").unwrap(); + save_with(&path, "// initial\n", "// desired\n", |_| { + fs::write(&path, "// another editor\n").unwrap(); Ok(()) }) .is_err() ); - save_with(&path, "# another editor\n", "# desired\n", |_| Ok(())).unwrap(); + save_with(&path, "// another editor\n", "// desired\n", |_| Ok(())).unwrap(); assert_eq!( - fs::read_to_string(path.with_extension("toml.settings-backup")).unwrap(), - "# another editor\n" + fs::read_to_string(path.with_extension("kdl.settings-backup")).unwrap(), + "// another editor\n" ); - assert_eq!(fs::read_to_string(path).unwrap(), "# desired\n"); + assert_eq!(fs::read_to_string(path).unwrap(), "// desired\n"); } + #[test] fn nested_array_edits_and_login_items_roundtrip() { - let mut snapshot = Snapshot::parse("[[output_profiles]]\nname='desk'\n[[output_profiles.outputs]]\nmatch='HDMI-A-1'\nscale=1.5\n".into()).unwrap(); + let mut snapshot = Snapshot::parse( + "output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(), + ) + .unwrap(); snapshot .edit(&set("output_profiles.0.outputs.0.scale", 1.75)) .unwrap(); @@ -347,7 +312,8 @@ mod tests { "command".into(), ["program", "argument with space"] .into_iter() - .collect::() + .map(Value::from) + .collect::>() .into(), ), ("enabled".into(), false.into()), @@ -362,10 +328,13 @@ mod tests { snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap(); assert_eq!(snapshot.records("autostart"), 0); } + #[test] fn nested_note_lists_keep_existing_clock_settings() { - let mut snapshot = - Snapshot::parse("[desktop_widgets.clock]\nenabled = true\n".into()).unwrap(); + let mut snapshot = Snapshot::parse( + "desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into(), + ) + .unwrap(); snapshot .edit(&Edit::Add( "desktop_widgets.notes".into(), @@ -392,7 +361,7 @@ mod tests { fn all_gui_controls_and_presets_pass_compositor_validation() { let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("config.toml"); + let path = directory.path().join("config.kdl"); let validate = |snapshot: &Snapshot| { fs::write(&path, &snapshot.source).unwrap(); let result = Command::new(&binary) diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index a4c1ebf..5bafbb2 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -20,17 +20,21 @@ pub struct Palette { pub muted: Color, pub error: Color, } + pub fn color(value: &str, fallback: Color) -> Color { let Some(value) = value.strip_prefix('#') else { return fallback; }; + if value.len() != 6 { return fallback; } + u32::from_str_radix(value, 16) .map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)) .unwrap_or(fallback) } + impl Palette { pub fn from(snapshot: &Snapshot) -> Self { let base = color( @@ -57,6 +61,7 @@ impl Palette { } } } + fn mix(a: Color, b: Color, t: f32) -> Color { Color::from_rgb( a.r + (b.r - a.r) * t, @@ -64,6 +69,7 @@ fn mix(a: Color, b: Color, t: f32) -> Color { a.b + (b.b - a.b) * t, ) } + fn hex(c: Color) -> String { format!( "#{:02x}{:02x}{:02x}", @@ -72,6 +78,7 @@ fn hex(c: Color) -> String { (c.b * 255.) as u8 ) } + pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { theme::Container::custom(move |_| container::Style { background: Some(Background::Color(background)), @@ -82,12 +89,15 @@ pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { ..Default::default() }) } + pub fn button_style(p: Palette, selected: bool) -> theme::Button { styled_button(p, selected, false) } + pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { styled_button(p, selected, true) } + pub fn input_style(p: Palette) -> theme::TextInput { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(), @@ -110,6 +120,7 @@ pub fn input_style(p: Palette) -> theme::TextInput { disabled: Box::new(move |_| appearance(false, false)), } } + fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { let style = move |hover: bool| button::Style { background: Some(Background::Color(if selected { @@ -131,6 +142,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button overlay: None, shadow_offset: Vector::ZERO, }; + theme::Button::Custom { active: Box::new(move |_, _| style(false)), hovered: Box::new(move |_, _| style(true)), @@ -138,6 +150,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button disabled: Box::new(move |_| style(false)), } } + pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { let mut theme = cosmic::theme::COSMIC_DARK.clone(); if let Some(snapshot) = snapshot { @@ -149,6 +162,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { } cosmic::Theme::custom(std::sync::Arc::new(theme)) } + pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font { static FONTS: std::sync::OnceLock< std::sync::Mutex>, @@ -165,15 +179,18 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font { fonts.insert(family, font); font } + pub fn icon(page: Page, tint: Color) -> svg_icon::Icon { svg_icon::from_svg_bytes(format!(r##""##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19) } + pub fn brand_icon() -> svg_icon::Icon { svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice()) .symbolic(false) .icon() .size(32) } + pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon { svg_icon::from_svg_bytes(format!(r##""##, hex(tint)).into_bytes()) .symbolic(false) @@ -199,6 +216,7 @@ pub fn preset(index: usize) -> Vec { set("theme.focus_ring.gradient.to", end), ] } + pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool { let (accent, base, text, muted, _) = PRESETS[index]; [ @@ -214,6 +232,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool { .eq_ignore_ascii_case(value) }) } + pub fn swatches(index: usize) -> Element<'static, Message> { let (accent, base, _, _, end) = PRESETS[index]; let mut row = widget::row([]).spacing(3); diff --git a/shell/ferese-settings/src/watch.rs b/shell/ferese-settings/src/watch.rs index d388e91..af7ffb3 100644 --- a/shell/ferese-settings/src/watch.rs +++ b/shell/ferese-settings/src/watch.rs @@ -65,22 +65,22 @@ mod tests { use cosmic::iced::futures::{StreamExt, pin_mut}; #[test] - fn watches_atomic_replacement_and_retains_subscription_after_bad_toml() { + fn watches_atomic_replacement_and_retains_subscription_after_bad_kdl() { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(async { let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("config.toml"); - std::fs::write(&path, "[animations]\nspeed = 1").unwrap(); + let path = directory.path().join("config.kdl"); + std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap(); let stream = changes(&path); pin_mut!(stream); assert!(matches!( stream.next().await, Some(Message::ExternalConfig(Ok(_))) )); - for source in ["bad [", "[animations]\nspeed = 0.75"] { + for source in ["bad [", "animations {\n speed 0.75\n}\n"] { let temporary = directory.path().join("replacement"); std::fs::write(&temporary, source).unwrap(); std::fs::rename(temporary, &path).unwrap(); diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index bfe4851..aa7682f 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-config = { path = "../../crates/ferese-config" } ferese-core = { path = "../../crates/ferese-core" } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } jiff.workspace = true @@ -14,8 +15,6 @@ jiff.workspace = true libcosmic = { workspace = true, features = ["wgpu", "a11y"] } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } serde.workspace = true -toml.workspace = true -toml_edit = "0.25.15" tempfile.workspace = true tokio = { version = "1", features = ["time", "rt"] } wayland-client.workspace = true diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index e84290a..2131155 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -1,4 +1,3 @@ -use std::env; use std::fs; use std::path::PathBuf; @@ -174,11 +173,13 @@ impl Default for BarConfig { } } } + #[derive(Debug, Deserialize)] struct PopoverConfig { #[serde(default = "default_popover_opacity")] opacity: f32, } + impl Default for PopoverConfig { fn default() -> Self { Self { @@ -186,6 +187,7 @@ impl Default for PopoverConfig { } } } + const fn default_popover_opacity() -> f32 { 0.96 } @@ -299,13 +301,13 @@ pub(crate) fn load() -> ShellConfig { } } -pub(crate) fn parse_source(source: &str) -> Result { - match toml::from_str::(source) { +pub(crate) fn parse_source(source: &str) -> Result { + match ferese_config::from_str::(source) { Ok(config) => { config .desktop_widgets .validate() - .map_err(::custom)?; + .map_err(ferese_config::Error::from)?; let mut theme = shell_theme(&config.theme); theme.material_radius = nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); @@ -415,48 +417,63 @@ fn finite_or(value: f32, fallback: f32) -> f32 { fn default_surface_base() -> String { "#111821".to_owned() } + fn default_text_primary() -> String { "#F4F7FB".to_owned() } + fn default_text_muted() -> String { "#7F8A98".to_owned() } + fn default_accent() -> String { "#5B8CFF".to_owned() } + fn default_border() -> String { "#FFFFFF18".to_owned() } + fn default_shadow() -> String { "#00000055".to_owned() } + const fn default_bar_height() -> f32 { 28.0 } + const fn default_bar_window_gap() -> i32 { 0 } + const fn default_bar_margin_top() -> i32 { 0 } + const fn default_bar_margin_horizontal() -> i32 { 0 } + const fn default_bar_radius() -> f32 { 0.0 } + const fn default_panel_padding() -> f32 { 12.0 } + const fn default_control_gap() -> f32 { 12.0 } + const fn default_shadow_offset_y() -> f32 { 4.0 } + const fn default_shadow_blur() -> f32 { 18.0 } + const fn default_shadow_opacity() -> f32 { 0.20 } @@ -466,42 +483,59 @@ pub(crate) const fn default_background() -> [u8; 3] { } pub(crate) fn config_path() -> Option { - if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { - return Some(PathBuf::from(directory).join("ferese/config.toml")); - } - - env::var_os("HOME") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .map(|home| home.join(".config/ferese/config.toml")) + ferese_config::config_path() } #[cfg(test)] mod tests { #[test] fn desktop_clock_parses_and_rejects_invalid_reload_values() { - let clock = super::parse_source("[desktop_widgets.clock]\nenabled = true\nanchor = 'bottom_right'\nfont_family = ''\ncolor = ''\ntime_zone = ''").unwrap().desktop_widgets.clock; + let clock = super::parse_source( + r#"desktop-widgets { + clock { + enabled #true + anchor "bottom_right" + font-family "" + color "" + time-zone "" + } +} +"#, + ) + .unwrap() + .desktop_widgets + .clock; assert!(clock.enabled); assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight); - assert!(super::parse_source("[desktop_widgets.clock]\nopacity = 1.1").is_err()); - assert!(super::parse_source("[desktop_widgets.clock]\ntime_format = '%'").is_err()); + assert!( + super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n") + .is_err() + ); + assert!( + super::parse_source( + "desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n" + ) + .is_err() + ); } use super::*; #[test] fn parses_theme_without_rejecting_compositor_sections() { - let config: FereseConfig = toml::from_str( - r#" - [layout] - mode = "scrolling" - - [theme.typography] - font_family = "JetBrainsMono Nerd Font" - - [theme.background] - path = "/tmp/wallpaper.png" - mode = "fit" - "#, + let config: FereseConfig = ferese_config::from_str( + r#"layout { + mode "scrolling" +} +theme { + typography { + font-family "JetBrainsMono Nerd Font" + } + background { + path "/tmp/wallpaper.png" + mode "fit" + } +} +"#, ) .unwrap(); @@ -518,7 +552,7 @@ mod tests { #[test] fn defaults_to_the_ferese_visual_profile() { - let config: FereseConfig = toml::from_str("").unwrap(); + let config: FereseConfig = ferese_config::from_str("").unwrap(); assert_eq!(config.theme.typography.font_family, None); assert_eq!(config.theme.background.path, None); @@ -535,13 +569,17 @@ mod tests { #[test] fn bar_palette_can_be_changed_without_changing_popovers() { - let config: FereseConfig = toml::from_str( - r##" - [theme.surface.bar] - background = "#EAECEEDD" - text_primary = "#222222" - text_muted = "#666666" - "##, + let config: FereseConfig = ferese_config::from_str( + r##"theme { + surface { + bar { + background "#EAECEEDD" + text-primary "#222222" + text-muted "#666666" + } + } +} +"##, ) .unwrap(); let theme = shell_theme(&config.theme); @@ -553,13 +591,20 @@ mod tests { #[test] fn bar_transparency_and_shadow_opacity_are_independent() { - let config: FereseConfig = toml::from_str( - r#" - [theme.surface.bar] - opacity = 0.6 - [theme.shadow.soft] - opacity = 0.07 - "#, + let config: FereseConfig = ferese_config::from_str( + r#"theme { + surface { + bar { + opacity 0.6 + } + } + shadow { + soft { + opacity 0.07 + } + } +} +"#, ) .unwrap(); let theme = shell_theme(&config.theme); diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 81fae07..9192326 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -689,6 +689,7 @@ impl FereseShell { } Task::batch(tasks) } + fn output_event( &mut self, event: wayland::OutputEvent, @@ -2264,6 +2265,7 @@ impl EffectsBinding { self.connection.flush()?; Ok(()) } + fn set_opacity(&self, opacity: f32) -> Result<(), Box> { let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { @@ -2349,6 +2351,7 @@ mod tests { Point::new(200., 100.) ); } + #[test] fn note_drag_origin_and_bounds_use_logical_output_coordinates() { use cosmic::iced::Point; @@ -2371,6 +2374,7 @@ mod tests { Point::ORIGIN ); } + #[test] fn desktop_clock_anchor_margins_match_only_anchored_edges() { use ferese_core::desktop::{Anchor as Position, Clock}; @@ -2396,6 +2400,7 @@ mod tests { (0, 0, 0, 0) ); } + #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index dca6226..824289c 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -13,6 +13,7 @@ pub(crate) struct Settings { reduced_motion: bool, speed: f64, } + impl Default for Settings { fn default() -> Self { Self { @@ -22,10 +23,12 @@ impl Default for Settings { } } } + impl Settings { fn hover_duration(self) -> std::time::Duration { self.duration(120.0) } + pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration { if !self.enabled || self.reduced_motion { return std::time::Duration::ZERO; @@ -52,6 +55,7 @@ pub(crate) struct PopupMotion { started: Option, settings: Settings, } + impl PopupMotion { pub(crate) fn update_settings(&mut self, settings: Settings) { let now = std::time::Instant::now(); @@ -59,6 +63,7 @@ impl PopupMotion { self.settings = settings; self.started = Some(now); } + pub(crate) fn new(settings: Settings) -> Self { Self { start: 0.0, @@ -67,11 +72,13 @@ impl PopupMotion { settings, } } + pub(crate) fn begin(&mut self, now: std::time::Instant) { if self.started.is_none() { self.started = Some(now); } } + pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { let duration = self .settings @@ -89,15 +96,19 @@ impl PopupMotion { } self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t) } + pub(crate) fn progress(&self) -> f32 { self.progress_at(std::time::Instant::now()) } + pub(crate) fn closing(&self) -> bool { self.target == 0.0 } + pub(crate) fn animating(&self) -> bool { self.progress() != self.target } + pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) { self.start = self.progress_at(now); self.target = target; @@ -157,6 +168,7 @@ struct HoverState { target: f32, started: Option, } + impl HoverState { fn advance( &mut self, @@ -185,11 +197,13 @@ impl HoverState { self.current != target } } + struct Hover<'a, M> { content: Element<'a, M>, progress: std::rc::Rc>, duration: std::time::Duration, } + impl Widget for Hover<'_, M> { fn a11y_nodes( &self, @@ -201,21 +215,27 @@ impl Widget for Hover<'_, M> { .as_widget() .a11y_nodes(layout, &tree.children[0], cursor) } + fn tag(&self) -> widget::tree::Tag { widget::tree::Tag::of::() } + fn state(&self) -> widget::tree::State { widget::tree::State::new(HoverState::default()) } + fn children(&self) -> Vec { vec![widget::Tree::new(&self.content)] } + fn diff(&mut self, tree: &mut widget::Tree) { tree.diff_children(std::slice::from_mut(&mut self.content)); } + fn size(&self) -> Size { self.content.as_widget().size() } + fn layout( &mut self, tree: &mut widget::Tree, @@ -226,6 +246,7 @@ impl Widget for Hover<'_, M> { .as_widget_mut() .layout(&mut tree.children[0], renderer, limits) } + fn update( &mut self, tree: &mut widget::Tree, @@ -255,6 +276,7 @@ impl Widget for Hover<'_, M> { viewport, ); } + fn draw( &self, tree: &widget::Tree, @@ -277,6 +299,7 @@ impl Widget for Hover<'_, M> { viewport, ); } + fn mouse_interaction( &self, tree: &widget::Tree, @@ -293,6 +316,7 @@ impl Widget for Hover<'_, M> { renderer, ) } + fn operate( &mut self, tree: &mut widget::Tree, @@ -304,6 +328,7 @@ impl Widget for Hover<'_, M> { .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); } + fn overlay<'a>( &'a mut self, tree: &'a mut widget::Tree, @@ -333,37 +358,46 @@ pub fn animated<'a, M: 'a>( regions, }) } + struct Motion<'a, M> { content: Element<'a, M>, progress: f32, regions: Regions, } + impl Motion<'_, M> { fn translation(&self) -> Vector { Vector::new(0.0, -4.0 * (1.0 - self.progress)) } + fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor { cursor.position().map_or(mouse::Cursor::Unavailable, |p| { mouse::Cursor::Available(p - self.translation()) }) } } + impl Widget for Motion<'_, M> { fn tag(&self) -> widget::tree::Tag { self.content.as_widget().tag() } + fn state(&self) -> widget::tree::State { self.content.as_widget().state() } + fn children(&self) -> Vec { self.content.as_widget().children() } + fn diff(&mut self, tree: &mut widget::Tree) { self.content.as_widget_mut().diff(tree); } + fn size(&self) -> Size { self.content.as_widget().size() } + fn layout( &mut self, tree: &mut widget::Tree, @@ -372,6 +406,7 @@ impl Widget for Motion<'_, M> { ) -> layout::Node { self.content.as_widget_mut().layout(tree, renderer, limits) } + fn update( &mut self, tree: &mut widget::Tree, @@ -417,6 +452,7 @@ impl Widget for Motion<'_, M> { tree, event, layout, cursor, renderer, clipboard, shell, viewport, ); } + fn draw( &self, tree: &widget::Tree, @@ -441,6 +477,7 @@ impl Widget for Motion<'_, M> { ) }); } + fn mouse_interaction( &self, tree: &widget::Tree, @@ -457,6 +494,7 @@ impl Widget for Motion<'_, M> { renderer, ) } + fn operate( &mut self, tree: &mut widget::Tree, @@ -475,6 +513,7 @@ impl widget::Operation for CollectRegions { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { children(self); } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { if id == Some(&widget::Id::new("ferese-blur-card")) { let contains = |outer: &Rectangle, inner: &Rectangle| { @@ -524,6 +563,7 @@ mod tests { 0.0 ); } + #[test] fn popup_reversal_and_motion_policy_are_consistent() { let now = std::time::Instant::now(); @@ -560,6 +600,7 @@ mod tests { assert_eq!(motion.progress_at(now), 0.0); } } + #[test] fn hover_fades_settle_and_idle_does_not_schedule_frames() { let now = std::time::Instant::now(); @@ -578,6 +619,7 @@ mod tests { assert!(!hover.advance(0.0, start + duration * 12, duration)); assert_eq!(hover.current, 0.0); } + #[test] fn hover_reversals_preserve_current_paint() { let now = std::time::Instant::now(); @@ -591,6 +633,7 @@ mod tests { hover.advance(0.0, now + duration, duration); assert!(hover.current > 0.0 && hover.current < before); } + #[test] fn hover_obeys_global_motion_policy() { assert_eq!( @@ -617,6 +660,7 @@ mod tests { assert_eq!(hover.current, 1.0); } } + #[test] fn material_regions_include_cards_but_not_the_parent_or_gaps() { let mut collector = CollectRegions(Vec::new()); diff --git a/shell/ferese-shell/src/note_store.rs b/shell/ferese-shell/src/note_store.rs index 52ea17d..9b41fa5 100644 --- a/shell/ferese-shell/src/note_store.rs +++ b/shell/ferese-shell/src/note_store.rs @@ -12,48 +12,45 @@ pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> { let canonical = path.canonicalize().map_err(|e| e.to_string())?; let path = canonical.as_path(); let source = fs::read_to_string(path).map_err(|e| e.to_string())?; - let mut doc: toml_edit::DocumentMut = source - .parse() - .map_err(|e: toml_edit::TomlError| e.to_string())?; + let mut doc = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?; for edit in edits { if let Edit::ClockPosition(x, y) = edit { - let clock = doc - .get_mut("desktop_widgets") - .and_then(|item| item.get_mut("clock")) - .ok_or("Clock was removed from the config.")?; - clock["anchor"] = toml_edit::value("top_left"); - clock["margin_x"] = toml_edit::value(i64::from(*x)); - clock["margin_y"] = toml_edit::value(i64::from(*y)); + if doc.get("desktop_widgets.clock").is_none() { + return Err("Clock was removed from the config.".into()); + } + for (field, value) in [ + ("anchor", "top_left".into()), + ("margin_x", i64::from(*x).into()), + ("margin_y", i64::from(*y).into()), + ] { + doc.set(&format!("desktop_widgets.clock.{field}"), value) + .map_err(|e| e.to_string())?; + } continue; } - let notes = doc - .get_mut("desktop_widgets") - .and_then(|item| item.get_mut("notes")) - .and_then(toml_edit::Item::as_array_of_tables_mut) - .ok_or("Notes were removed from the config.")?; let id = match edit { Edit::Text(id, _) | Edit::Position(id, _, _) => id, Edit::ClockPosition(..) => unreachable!(), }; - let note = notes - .iter_mut() - .find(|note| { - note.get("id") - .and_then(toml_edit::Item::as_str) - .unwrap_or("note") - == id - }) + let index = doc + .get("desktop_widgets.notes") + .and_then(|v| v.as_array()) + .ok_or("Notes were removed from the config.")? + .iter() + .position(|note| note.get("id").and_then(|v| v.as_str()).unwrap_or("note") == id) .ok_or("This note no longer exists in the config.")?; - match edit { - Edit::Text(_, value) => { - note["text"] = toml_edit::value(value); - } - Edit::Position(_, x, y) => { - note["anchor"] = toml_edit::value("top_left"); - note["margin_x"] = toml_edit::value(i64::from(*x)); - note["margin_y"] = toml_edit::value(i64::from(*y)); - } + let fields = match edit { + Edit::Text(_, value) => vec![("text", value.clone().into())], + Edit::Position(_, x, y) => vec![ + ("anchor", "top_left".into()), + ("margin_x", i64::from(*x).into()), + ("margin_y", i64::from(*y).into()), + ], Edit::ClockPosition(..) => unreachable!(), + }; + for (field, value) in fields { + doc.set(&format!("desktop_widgets.notes.{index}.{field}"), value) + .map_err(|e| e.to_string())?; } } let updated = doc.to_string(); @@ -87,10 +84,10 @@ mod tests { #[test] fn clock_position_saves_without_a_notes_table() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); + let path = dir.path().join("config.kdl"); fs::write( &path, - "[desktop_widgets.clock]\nenabled = true\ntime_size = 80\n", + "desktop-widgets {\n clock {\n enabled #true\n time-size 80\n }\n}\n", ) .unwrap(); save(&path, &[Edit::ClockPosition(120, 240)]).unwrap(); @@ -104,11 +101,25 @@ mod tests { ); assert_eq!(config.desktop_widgets.clock.time_size, 80.); } + #[test] fn note_edits_preserve_unrelated_config_and_multiline_content() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "# keep this\n[theme.colors]\naccent = '#123456'\n[[desktop_widgets.notes]]\nid = 'a'\ntext = 'old'\n").unwrap(); + let path = dir.path().join("config.kdl"); + fs::write( + &path, + r##"// keep this +theme { + colors { + accent "#123456" + } +} +desktop-widgets { + note id="a" text="old" +} +"##, + ) + .unwrap(); save( &path, &[ @@ -118,7 +129,7 @@ mod tests { ) .unwrap(); let source = fs::read_to_string(&path).unwrap(); - assert!(source.contains("# keep this")); + assert!(source.contains("// keep this")); assert!(source.contains("#123456")); let notes = crate::config::parse_source(&source) .unwrap() diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index a71c1e0..a1aac33 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -23,28 +23,33 @@ pub struct Snapshot { pub reboot: bool, pub suspend: bool, } + #[derive(Clone, Debug)] pub struct Network { pub enabled: bool, pub connection: Option, pub signal: u8, } + #[derive(Clone, Debug)] pub struct Bluetooth { pub enabled: bool, pub devices: Vec, } + #[derive(Clone, Debug)] pub struct Audio { pub volume: u8, pub muted: bool, pub output: String, } + #[derive(Clone, Debug)] pub struct Battery { pub percent: u8, pub status: String, } + #[derive(Clone, Debug)] pub struct Notifications { pub count: u32, @@ -65,22 +70,26 @@ pub enum Action { Reboot, Suspend, } + impl Action { fn key(&self) -> std::mem::Discriminant { std::mem::discriminant(self) } } + pub struct Update { pub snapshot: Snapshot, pub generation: u64, pub error: Option, } + pub struct Service { settings: Arc>>>, tx: SyncSender<(u64, Action)>, rx: Receiver, pub generation: u64, } + impl Service { pub fn start(settings: Option>) -> Self { let live_settings = Arc::new(Mutex::new(settings)); @@ -153,6 +162,7 @@ impl Service { generation: 0, } } + pub fn send(&mut self, action: Action) -> Result<(), String> { let generation = self.generation + 1; self.tx @@ -161,9 +171,11 @@ impl Service { self.generation = generation; Ok(()) } + pub fn update_settings(&self, settings: Option>) { *self.settings.lock().unwrap() = settings; } + pub fn poll(&self) -> Option { self.rx.try_iter().last() } @@ -222,6 +234,7 @@ fn poll() -> Snapshot { suspend: can_power("CanSuspend"), } } + fn network() -> Option { // No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi). let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?; @@ -261,6 +274,7 @@ fn network() -> Option { signal, }) } + fn bluetooth() -> Option { let info = run("bluetoothctl", &["show"]).ok()?; let enabled = info @@ -282,6 +296,7 @@ fn bluetooth() -> Option { }; Some(Bluetooth { enabled, devices }) } + pub fn parse_audio(value: &str) -> Option<(u8, bool)> { let volume = value .strip_prefix("Volume: ")? @@ -297,6 +312,7 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> { value.contains("[MUTED]"), )) } + fn audio() -> Option