Replace user configuration with KDL and improve Rust source spacing

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

No files matched your search

Generated
+72 -72
View File
@@ -837,7 +837,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [ dependencies = [
"serde", "serde",
"termcolor", "termcolor",
"unicode-width", "unicode-width 0.2.2",
] ]
[[package]] [[package]]
@@ -848,9 +848,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.8" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [ dependencies = [
"bytes", "bytes",
"memchr", "memchr",
@@ -1305,7 +1305,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1573,6 +1573,7 @@ dependencies = [
"ab_glyph", "ab_glyph",
"calloop 0.14.4", "calloop 0.14.4",
"ferese-animation", "ferese-animation",
"ferese-config",
"ferese-core", "ferese-core",
"ferese-ipc", "ferese-ipc",
"ferese-layout", "ferese-layout",
@@ -1584,7 +1585,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"smithay", "smithay",
"toml 0.9.12+spec-1.1.0",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"xcursor", "xcursor",
@@ -1597,6 +1597,15 @@ dependencies = [
"ferese-layout", "ferese-layout",
] ]
[[package]]
name = "ferese-config"
version = "0.1.0"
dependencies = [
"kdl",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "ferese-core" name = "ferese-core"
version = "0.1.0" version = "0.1.0"
@@ -1633,6 +1642,7 @@ dependencies = [
name = "ferese-settings" name = "ferese-settings"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-config",
"ferese-ipc", "ferese-ipc",
"image", "image",
"libcosmic", "libcosmic",
@@ -1641,13 +1651,13 @@ dependencies = [
"shlex 1.3.0", "shlex 1.3.0",
"tempfile", "tempfile",
"tokio", "tokio",
"toml_edit",
] ]
[[package]] [[package]]
name = "ferese-shell" name = "ferese-shell"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-config",
"ferese-core", "ferese-core",
"ferese-protocols", "ferese-protocols",
"iced_accessibility", "iced_accessibility",
@@ -1656,8 +1666,6 @@ dependencies = [
"serde", "serde",
"tempfile", "tempfile",
"tokio", "tokio",
"toml 0.9.12+spec-1.1.0",
"toml_edit",
"wayland-client", "wayland-client",
] ]
@@ -1675,7 +1683,7 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [ dependencies = [
"toml 0.5.11", "toml",
] ]
[[package]] [[package]]
@@ -2901,9 +2909,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.105" version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -2919,6 +2927,18 @@ dependencies = [
"mutate_once", "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]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.8.3" version = "0.8.3"
@@ -2952,7 +2972,7 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3076,9 +3096,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.25" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"libc", "libc",
@@ -3306,6 +3326,16 @@ dependencies = [
"paste", "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]] [[package]]
name = "mime" name = "mime"
version = "0.1.0" version = "0.1.0"
@@ -4132,9 +4162,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]] [[package]]
name = "portable-atomic-util" name = "portable-atomic-util"
version = "0.2.8" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [ dependencies = [
"portable-atomic", "portable-atomic",
] ]
@@ -4415,10 +4445,11 @@ dependencies = [
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.3" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -4723,15 +4754,6 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -5351,30 +5373,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -5391,9 +5389,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime",
"toml_parser", "toml_parser",
"toml_writer",
"winnow 1.0.4", "winnow 1.0.4",
] ]
@@ -5406,12 +5403,6 @@ dependencies = [
"winnow 1.0.4", "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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -5615,6 +5606,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -5723,9 +5720,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5736,9 +5733,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.78" version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -5746,9 +5743,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5756,22 +5753,22 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 2.0.119",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.128" version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -5953,9 +5950,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.105" version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -6874,6 +6871,9 @@ name = "winnow"
version = "0.7.15" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
+1 -1
View File
@@ -4,6 +4,7 @@ members = [
"feresectl", "feresectl",
"crates/ferese-animation", "crates/ferese-animation",
"crates/ferese-core", "crates/ferese-core",
"crates/ferese-config",
"crates/ferese-ipc", "crates/ferese-ipc",
"crates/ferese-layout", "crates/ferese-layout",
"crates/ferese-protocols", "crates/ferese-protocols",
@@ -40,7 +41,6 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
tempfile = "3.23.0" tempfile = "3.23.0"
toml = "0.9.7"
xcursor = "0.3.11" xcursor = "0.3.11"
wayland-backend = "0.3.17" wayland-backend = "0.3.17"
wayland-client = "0.31.15" wayland-client = "0.31.15"
+4 -2
View File
@@ -101,10 +101,12 @@ raising the window; clicking raises it within its layer.
## Make it yours ## Make it yours
Open **Control Center → Settings**, or run `ferese-settings`. 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 `$XDG_CONFIG_HOME`). Changes reload automatically. The complete
[configuration reference](docs/configuration.md) covers every supported option; [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 ## Development
+1 -1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-config = { path = "../crates/ferese-config" }
ab_glyph = "0.2.32" ab_glyph = "0.2.32"
fontdb = "0.23.0" fontdb = "0.23.0"
calloop.workspace = true calloop.workspace = true
@@ -20,7 +21,6 @@ image = { version = "0.25.10", default-features = false, features = ["png", "jpe
smithay.workspace = true smithay.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
toml.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
xcursor.workspace = true xcursor.workspace = true
+136 -113
View File
@@ -1,5 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::env;
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
@@ -265,7 +264,7 @@ struct ThemeConfig {
impl Config { impl Config {
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> { pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
toml::from_str(source).map_err(|source| ConfigError::Parse { ferese_config::from_str(source).map_err(|source| ConfigError::Parse {
path: config_path().unwrap_or_default(), path: config_path().unwrap_or_default(),
source, source,
}) })
@@ -308,6 +307,7 @@ impl Config {
overview_font_family: self.overview_font_family(), overview_font_family: self.overview_font_family(),
}) })
} }
pub(crate) fn overview_font_family(&self) -> String { pub(crate) fn overview_font_family(&self) -> String {
self.theme self.theme
.typography .typography
@@ -315,6 +315,7 @@ impl Config {
.clone() .clone()
.unwrap_or_else(|| "sans-serif".into()) .unwrap_or_else(|| "sans-serif".into())
} }
pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig { pub(crate) fn wallpaper_settings(&self) -> crate::wallpaper::WallpaperConfig {
self.theme.background.clone() self.theme.background.clone()
} }
@@ -738,7 +739,7 @@ pub enum ConfigError {
}, },
Parse { Parse {
path: PathBuf, path: PathBuf,
source: toml::de::Error, source: ferese_config::Error,
}, },
InvalidColumnWidth { InvalidColumnWidth {
field: &'static str, field: &'static str,
@@ -827,7 +828,7 @@ impl Config {
Err(source) => return Err(ConfigError::Read { path, source }), 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 { pub fn layout_mode(&self) -> LayoutMode {
@@ -1813,42 +1814,59 @@ fn parse_column_width(
} }
pub(crate) fn config_path() -> Option<PathBuf> { pub(crate) fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { ferese_config::config_path()
return Some(PathBuf::from(directory).join("ferese/config.toml"));
}
env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|home| home.join(".config/ferese/config.toml"))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn desktop_clock_settings_are_validated_before_live_publication() { fn desktop_clock_settings_are_validated_before_live_publication() {
assert!( assert!(
parse( 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() .runtime_config()
.is_ok() .is_ok()
); );
for source in [ for source in [
"[desktop_widgets.clock]\nwidth = 8192", "desktop-widgets {\n clock {\n width 8192\n }\n}\n",
"[desktop_widgets.clock]\nopacity = nan", "desktop-widgets {\n clock {\n opacity #nan\n }\n}\n",
"[desktop_widgets.clock]\ntime_format = '%'", "desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n",
"[desktop_widgets.clock]\ntime_zone = 'invalid/zone'", "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 { fn parse(source: &str) -> Config {
toml::from_str(source).unwrap() ferese_config::from_str(source).unwrap()
} }
#[test] #[test]
@@ -1865,8 +1883,8 @@ mod tests {
#[test] #[test]
fn accepts_full_and_numeric_column_widths() { fn accepts_full_and_numeric_column_widths() {
let full = parse("[scrolling]\ndefault_column_width = \"full\""); let full = parse("scrolling {\n default-column-width \"full\"\n}\n");
let numeric = parse("[scrolling]\ndefault_column_width = 1.0"); let numeric = parse("scrolling {\n default-column-width 1.0\n}\n");
assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full); assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full);
assert_eq!( assert_eq!(
@@ -1878,7 +1896,7 @@ mod tests {
#[test] #[test]
fn parses_width_presets_and_supplies_defaults() { fn parses_width_presets_and_supplies_defaults() {
let defaults = parse(""); 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!(defaults.width_presets().unwrap().len(), 4);
assert_eq!( assert_eq!(
@@ -1894,8 +1912,8 @@ mod tests {
#[test] #[test]
fn parses_scrolling_focus_strategy() { fn parses_scrolling_focus_strategy() {
let minimal = parse(""); let minimal = parse("");
let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\""); let centered = parse("scrolling {\n focus-strategy \"center_on_focus\"\n}\n");
let paged = parse("[scrolling]\nfocus_strategy = \"paged\""); let paged = parse("scrolling {\n focus-strategy \"paged\"\n}\n");
assert_eq!( assert_eq!(
paged.scrolling_focus_strategy(), paged.scrolling_focus_strategy(),
ViewportFocusStrategy::Paged ViewportFocusStrategy::Paged
@@ -1913,8 +1931,8 @@ mod tests {
#[test] #[test]
fn rejects_non_positive_or_unknown_column_widths() { fn rejects_non_positive_or_unknown_column_widths() {
let zero = parse("[scrolling]\ndefault_column_width = 0.0"); let zero = parse("scrolling {\n default-column-width 0.0\n}\n");
let unknown = parse("[scrolling]\ndefault_column_width = \"wide\""); let unknown = parse("scrolling {\n default-column-width \"wide\"\n}\n");
assert!(zero.default_column_width().is_err()); assert!(zero.default_column_width().is_err());
assert!(unknown.default_column_width().is_err()); assert!(unknown.default_column_width().is_err());
@@ -1923,7 +1941,7 @@ mod tests {
#[test] #[test]
fn parses_animation_policy_and_reduced_motion() { fn parses_animation_policy_and_reduced_motion() {
let config = parse( 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()); assert!(!config.animations_enabled());
@@ -1941,8 +1959,8 @@ mod tests {
#[test] #[test]
fn rejects_invalid_animation_numbers() { fn rejects_invalid_animation_numbers() {
let speed = parse("[animations]\nspeed = 0.0"); let speed = parse("animations {\n speed 0.0\n}\n");
let damping = parse("[animations.spring]\ndamping = -1.0"); let damping = parse("animations {\n spring {\n damping -1.0\n }\n}\n");
assert!(speed.animation_speed().is_err()); assert!(speed.animation_speed().is_err());
assert!(damping.spring_config().is_err()); assert!(damping.spring_config().is_err());
@@ -1950,8 +1968,9 @@ mod tests {
#[test] #[test]
fn parses_and_validates_layout_gaps() { fn parses_and_validates_layout_gaps() {
let configured = parse("[layout]\ninner_gap = 6.0\nouter_gap = 14.0\nsmart_gaps = true"); let configured =
let invalid = parse("[layout]\nouter_gap = -1.0"); 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!( assert_eq!(
configured.gap_config().unwrap(), configured.gap_config().unwrap(),
@@ -1967,11 +1986,14 @@ mod tests {
#[test] #[test]
fn parses_and_validates_theme_window_tokens() { fn parses_and_validates_theme_window_tokens() {
let configured = parse( 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!( assert_eq!(
configured.theme_settings().unwrap(), configured.theme_settings().unwrap(),
@@ -2003,7 +2025,12 @@ mod tests {
assert!(invalid_color.theme_settings().is_err()); assert!(invalid_color.theme_settings().is_err());
assert!(invalid_radius.theme_settings().is_err()); assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err()); assert!(invalid_opacity.theme_settings().is_err());
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"mist\"").is_err()); assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"mist\"\n }\n}\n"
)
.is_err()
);
} }
#[test] #[test]
@@ -2012,7 +2039,12 @@ mod tests {
parse("").theme_settings().unwrap().material_style, parse("").theme_settings().unwrap().material_style,
MaterialStyle::Solid MaterialStyle::Solid
); );
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err()); assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"glass\"\n }\n}\n"
)
.is_err()
);
} }
#[test] #[test]
@@ -2021,14 +2053,14 @@ mod tests {
assert_eq!(defaults.border_gradient, None); assert_eq!(defaults.border_gradient, None);
assert_eq!(defaults.focus_ring_gradient, None); assert_eq!(defaults.focus_ring_gradient, None);
let configured = 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() .theme_settings()
.unwrap(); .unwrap();
let gradient = configured.focus_ring_gradient.unwrap(); let gradient = configured.focus_ring_gradient.unwrap();
assert_eq!(configured.border_gradient, None); assert_eq!(configured.border_gradient, None);
assert_eq!(gradient.angle, 315.0); assert_eq!(gradient.angle, 315.0);
assert_eq!(gradient.to.0[3], 128.0 / 255.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() .theme_settings()
.unwrap(); .unwrap();
assert_eq!(border.border_gradient.unwrap().angle, 0.0); assert_eq!(border.border_gradient.unwrap().angle, 0.0);
@@ -2040,23 +2072,25 @@ mod tests {
#[test] #[test]
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() { fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
for settings in [ for settings in [
"from = 'invalid'\nto = '#445566'", "from \"invalid\"\nto \"#445566\"\n",
"from = '#112233'\nto = 'invalid'", "from \"#112233\"\nto \"invalid\"\n",
"from = '#112233'\nto = '#445566'\nangle = nan", "from \"#112233\"\nto \"#445566\"\nangle #nan\n",
"from = '#112233'\nto = '#445566'\nangle = inf", "from \"#112233\"\nto \"#445566\"\nangle #inf\n",
] { ] {
assert!( assert!(
parse(&format!("[theme.focus_ring.gradient]\n{settings}")) Config::parse_source(&format!(
.theme_settings() "theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"
.is_err() ))
.and_then(|config| config.theme_settings())
.is_err()
); );
} }
assert!(toml::from_str::<Config>("[theme.border.gradient]\nfrom = '#112233'").is_err()); assert!(ferese_config::from_str::<Config>("theme {\n border {\n gradient {\n from \"#112233\"\n }\n }\n}\n").is_err());
} }
#[test] #[test]
fn material_color_comes_from_surface_base() { 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() .theme_settings()
.unwrap(); .unwrap();
assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0])); 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); assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78);
for opacity in [0.0, 0.65, 1.0] { for opacity in [0.0, 0.65, 1.0] {
assert_eq!( assert_eq!(
parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) parse(&format!(
.theme_settings() "theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
.unwrap() ))
.panel_opacity, .theme_settings()
.unwrap()
.panel_opacity,
opacity opacity
); );
} }
for opacity in ["-0.1", "1.1", "nan", "inf"] { for opacity in ["-0.1", "1.1", "#nan", "#inf"] {
assert!( assert!(
parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) Config::parse_source(&format!(
.theme_settings() "theme {{ surface {{ bar {{ opacity {opacity}; }} }} }}"
.is_err() ))
.and_then(|config| config.theme_settings())
.is_err()
); );
} }
} }
@@ -2087,23 +2125,23 @@ mod tests {
fn backdrop_blur_is_configurable_without_glass_settings() { fn backdrop_blur_is_configurable_without_glass_settings() {
assert_eq!(parse("").theme_settings().unwrap().backdrop_blur, 12.0); assert_eq!(parse("").theme_settings().unwrap().backdrop_blur, 12.0);
assert_eq!( 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() .theme_settings()
.unwrap() .unwrap()
.backdrop_blur, .backdrop_blur,
32.0 32.0
); );
assert_eq!( assert_eq!(
parse("[theme.material]\nblur_radius = 0") parse("theme {\n material {\n blur-radius 0\n }\n}\n")
.theme_settings() .theme_settings()
.unwrap() .unwrap()
.backdrop_blur, .backdrop_blur,
0.0 0.0
); );
for value in ["-1", "nan", "inf"] { for value in ["-1", "#nan", "#inf"] {
assert!( assert!(
parse(&format!("[theme.material]\nblur_radius = {value}")) Config::parse_source(&format!("theme {{ material {{ blur-radius {value}; }} }}"))
.theme_settings() .and_then(|config| config.theme_settings())
.is_err() .is_err()
); );
} }
@@ -2116,7 +2154,7 @@ mod tests {
assert_eq!(defaults.amount, 0.15); assert_eq!(defaults.amount, 0.15);
assert_eq!(defaults.duration_ms, 150.0); assert_eq!(defaults.duration_ms, 150.0);
let settings = 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() .theme_settings()
.unwrap() .unwrap()
.inactive_dim; .inactive_dim;
@@ -2126,14 +2164,16 @@ mod tests {
for (key, value) in [ for (key, value) in [
("amount", "-0.1"), ("amount", "-0.1"),
("amount", "1.1"), ("amount", "1.1"),
("amount", "nan"), ("amount", "#nan"),
("duration_ms", "-1"), ("duration_ms", "-1"),
("duration_ms", "inf"), ("duration_ms", "#inf"),
] { ] {
assert!( assert!(
parse(&format!("[appearance.inactive_dim]\n{key} = {value}")) Config::parse_source(&format!(
.theme_settings() "appearance {{ inactive-dim {{ {key} {value}; }} }}"
.is_err() ))
.and_then(|config| config.theme_settings())
.is_err()
); );
} }
} }
@@ -2141,7 +2181,7 @@ mod tests {
#[test] #[test]
fn parses_input_and_touchpad_settings() { fn parses_input_and_touchpad_settings() {
let config = parse( 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!( assert_eq!(
@@ -2165,9 +2205,9 @@ mod tests {
#[test] #[test]
fn rejects_invalid_input_settings() { fn rejects_invalid_input_settings() {
let layout = parse("[input]\nxkb_layout = \"\""); let layout = parse("input {\n xkb-layout \"\"\n}\n");
let rate = parse("[input]\nrepeat_rate = 0"); let rate = parse("input {\n repeat-rate 0\n}\n");
let delay = parse("[input]\nrepeat_delay_ms = -1"); let delay = parse("input {\n repeat-delay-ms -1\n}\n");
assert!(layout.input_settings().is_err()); assert!(layout.input_settings().is_err());
assert!(rate.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() { fn swipe_bindings_can_override_actions_disable_defaults_and_set_distance() {
use crate::gestures::SwipeDirection; use crate::gestures::SwipeDirection;
let config = parse( 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(); let input = config.input_settings().unwrap();
assert_eq!(input.touchpad.swipe_threshold, 120); assert_eq!(input.touchpad.swipe_threshold, 120);
@@ -2202,9 +2242,11 @@ mod tests {
); );
for threshold in [0, 15, 1001] { for threshold in [0, 15, 1001] {
assert!( assert!(
parse(&format!("[input.touchpad]\nswipe_threshold = {threshold}")) parse(&format!(
.input_settings() "input {{ touchpad {{ swipe-threshold {threshold}; }} }}"
.is_err() ))
.input_settings()
.is_err()
); );
} }
} }
@@ -2212,12 +2254,10 @@ mod tests {
#[test] #[test]
fn gesture_bindings_validate_fingers_directions_and_actions() { fn gesture_bindings_validate_fingers_directions_and_actions() {
for keys in ["Swipe2Up", "Swipe3Diagonal", "Super+Swipe3Up"] { for keys in ["Swipe2Up", "Swipe3Diagonal", "Super+Swipe3Up"] {
let config = parse(&format!( let config = parse(&format!("binding \"{keys}\" \"toggle-overview\""));
"[[bindings]]\nkeys = '{keys}'\naction = 'toggle-overview'"
));
assert!(config.bindings(&config.input_settings().unwrap()).is_err()); 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(); let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
assert!( assert!(
bindings bindings
@@ -2277,9 +2317,9 @@ mod tests {
#[test] #[test]
fn replaces_and_unbinds_default_bindings() { fn replaces_and_unbinds_default_bindings() {
let replaced = parse( 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 input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
@@ -2306,7 +2346,7 @@ mod tests {
#[test] #[test]
fn accepts_physical_xkb_key_names() { fn accepts_physical_xkb_key_names() {
let config = parse( 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 input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
@@ -2320,13 +2360,12 @@ mod tests {
#[test] #[test]
fn rejects_duplicate_or_invalid_user_bindings() { fn rejects_duplicate_or_invalid_user_bindings() {
let duplicate = parse( let duplicate = parse(
"[[bindings]]\nkeys = \"Super+Q\"\naction = \"close\"\n\n[[bindings]]\nkeys = \"logo+q\"\naction = \"close\"", "binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n",
);
let missing_command = parse(
"[[bindings]]\nkeys = \"Super+Enter\"\naction = \"spawn\"\nargument = \"missing\"",
); );
let missing_command =
parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument = 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] { for config in [duplicate, missing_command, invalid_argument] {
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
@@ -2337,7 +2376,7 @@ mod tests {
#[test] #[test]
fn parses_and_validates_window_rules() { fn parses_and_validates_window_rules() {
let config = parse( 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 rules = config.window_rules().unwrap();
let result = window_rules::resolve( let result = window_rules::resolve(
@@ -2356,8 +2395,8 @@ mod tests {
#[test] #[test]
fn rejects_invalid_window_rule_configuration() { fn rejects_invalid_window_rule_configuration() {
let catch_all = parse("[[window_rules]]\nfloating = true"); let catch_all = parse("window-rule floating=#true\n");
let zero_workspace = parse("[[window_rules]]\napp_id = \"editor\"\nworkspace = 0"); let zero_workspace = parse("window-rule app-id=\"editor\" workspace=0\n");
assert!(catch_all.window_rules().is_err()); assert!(catch_all.window_rules().is_err());
assert!(zero_workspace.window_rules().is_err()); assert!(zero_workspace.window_rules().is_err());
@@ -2376,21 +2415,7 @@ mod tests {
#[test] #[test]
fn parses_output_profiles() { fn parses_output_profiles() {
let config = parse( let config = parse(
r#" "output-profile name=\"docked\" {\n output match=\"HDMI-A-1\" mode=\"[email protected]\" scale=1.6 {\n position 0 0\n }\n output match=\"eDP-1\" enabled=#false transform=\"rotate_90\"\n}\n",
[[output_profiles]]
name = "docked"
[[output_profiles.outputs]]
match = "HDMI-A-1"
mode = "[email protected]"
scale = 1.6
position = [0, 0]
[[output_profiles.outputs]]
match = "eDP-1"
enabled = false
transform = "rotate_90"
"#,
); );
assert_eq!( assert_eq!(
@@ -2425,14 +2450,12 @@ transform = "rotate_90"
#[test] #[test]
fn rejects_invalid_output_profiles() { fn rejects_invalid_output_profiles() {
let invalid_scale = parse( let invalid_scale =
"[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nscale = 0.0", parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
); let invalid_mode =
let invalid_mode = parse( parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
"[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nmode = \"native\"",
);
let duplicate = parse( 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()); assert!(invalid_scale.output_profiles().is_err());
+2
View File
@@ -54,6 +54,7 @@ impl Runner {
} }
} }
} }
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self { pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
Self( Self(
configs configs
@@ -155,6 +156,7 @@ mod tests {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
} }
#[test] #[test]
fn previews_do_not_duplicate_session_daemons() { fn previews_do_not_duplicate_session_daemons() {
let configs = vec![DaemonConfig { let configs = vec![DaemonConfig {
+1
View File
@@ -368,6 +368,7 @@ pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
}) })
}) })
} }
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) { pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
with_states(surface, |states| { with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() { if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
+7
View File
@@ -194,6 +194,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_swipe_begin(data, event); handle.gesture_swipe_begin(data, event);
} }
fn gesture_swipe_update( fn gesture_swipe_update(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -202,6 +203,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_swipe_update(data, event); handle.gesture_swipe_update(data, event);
} }
fn gesture_swipe_end( fn gesture_swipe_end(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -210,6 +212,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_swipe_end(data, event); handle.gesture_swipe_end(data, event);
} }
fn gesture_pinch_begin( fn gesture_pinch_begin(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -218,6 +221,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_pinch_begin(data, event); handle.gesture_pinch_begin(data, event);
} }
fn gesture_pinch_update( fn gesture_pinch_update(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -226,6 +230,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_pinch_update(data, event); handle.gesture_pinch_update(data, event);
} }
fn gesture_pinch_end( fn gesture_pinch_end(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -234,6 +239,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_pinch_end(data, event); handle.gesture_pinch_end(data, event);
} }
fn gesture_hold_begin( fn gesture_hold_begin(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -242,6 +248,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) { ) {
handle.gesture_hold_begin(data, event); handle.gesture_hold_begin(data, event);
} }
fn gesture_hold_end( fn gesture_hold_end(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
+3
View File
@@ -111,6 +111,7 @@ impl OverviewState {
self.labels.clear(); self.labels.clear();
} }
} }
pub(crate) fn with_font_family(font_family: String) -> Self { pub(crate) fn with_font_family(font_family: String) -> Self {
Self { Self {
font_family, font_family,
@@ -199,6 +200,7 @@ impl OverviewState {
self.labels.insert(key, label.clone()); self.labels.insert(key, label.clone());
Some(label) Some(label)
} }
pub(crate) fn is_active(&self) -> bool { pub(crate) fn is_active(&self) -> bool {
self.active self.active
} }
@@ -367,6 +369,7 @@ impl Ferese {
let name = self.workspaces.workspace(workspace)?.name.clone(); let name = self.workspaces.workspace(workspace)?.name.clone();
self.overview.label(&name, scale) self.overview.label(&name, scale)
} }
pub(crate) fn set_overview_active(&mut self, active: bool) { pub(crate) fn set_overview_active(&mut self, active: bool) {
if self.overview.is_active() == active { if self.overview.is_active() == active {
return; return;
+18
View File
@@ -93,15 +93,19 @@ impl Element for PhysicalShaderElement {
fn id(&self) -> &Id { fn id(&self) -> &Id {
self.inner.id() self.inner.id()
} }
fn current_commit(&self) -> CommitCounter { fn current_commit(&self) -> CommitCounter {
self.inner.current_commit() self.inner.current_commit()
} }
fn src(&self) -> Rectangle<f64, Buffer> { fn src(&self) -> Rectangle<f64, Buffer> {
self.inner.src() self.inner.src()
} }
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> { fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry self.geometry
} }
fn damage_since( fn damage_since(
&self, &self,
_: Scale<f64>, _: Scale<f64>,
@@ -114,6 +118,7 @@ impl Element for PhysicalShaderElement {
} }
} }
} }
impl RenderElement<GlesRenderer> for PhysicalShaderElement { impl RenderElement<GlesRenderer> for PhysicalShaderElement {
fn draw( fn draw(
&self, &self,
@@ -140,25 +145,32 @@ pub(crate) struct NativeTextureElement {
pub program: Option<GlesTexProgram>, pub program: Option<GlesTexProgram>,
pub uniforms: Vec<Uniform<'static>>, pub uniforms: Vec<Uniform<'static>>,
} }
impl Element for NativeTextureElement { impl Element for NativeTextureElement {
fn id(&self) -> &Id { fn id(&self) -> &Id {
&self.id &self.id
} }
fn current_commit(&self) -> CommitCounter { fn current_commit(&self) -> CommitCounter {
self.commit self.commit
} }
fn src(&self) -> Rectangle<f64, Buffer> { fn src(&self) -> Rectangle<f64, Buffer> {
self.source self.source
} }
fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> { fn geometry(&self, _: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry self.geometry
} }
fn alpha(&self) -> f32 { fn alpha(&self) -> f32 {
self.alpha self.alpha
} }
fn kind(&self) -> Kind { fn kind(&self) -> Kind {
Kind::Unspecified Kind::Unspecified
} }
fn damage_since( fn damage_since(
&self, &self,
_: Scale<f64>, _: Scale<f64>,
@@ -171,6 +183,7 @@ impl Element for NativeTextureElement {
} }
} }
} }
impl RenderElement<GlesRenderer> for NativeTextureElement { impl RenderElement<GlesRenderer> for NativeTextureElement {
fn draw( fn draw(
&self, &self,
@@ -231,6 +244,7 @@ mod tests {
let growing_target = ferese_layout::Rect::new(0.0, 0.0, 1000.0, 600.0); 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)); assert!(!resize_needs_old_frame(visual, growing_target, 1000, 600));
} }
#[test] #[test]
fn fresh_snapshot_and_retarget_wait_do_not_skip_the_handoff() { fn fresh_snapshot_and_retarget_wait_do_not_skip_the_handoff() {
let mut elapsed = Duration::ZERO; let mut elapsed = Duration::ZERO;
@@ -262,6 +276,7 @@ mod tests {
1.0 1.0
)); ));
} }
#[test] #[test]
fn mixed_refresh_outputs_do_not_double_the_animation_clock() { fn mixed_refresh_outputs_do_not_double_the_animation_clock() {
let start = Instant::now(); let start = Instant::now();
@@ -274,6 +289,7 @@ mod tests {
assert_eq!(frame_delta(&mut last, start), Duration::ZERO); assert_eq!(frame_delta(&mut last, start), Duration::ZERO);
assert_eq!(last, start + Duration::from_millis(32)); assert_eq!(last, start + Duration::from_millis(32));
} }
#[test] #[test]
fn fractional_motion_does_not_quantize_to_logical_pixels() { fn fractional_motion_does_not_quantize_to_logical_pixels() {
let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2); let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2);
@@ -283,6 +299,7 @@ mod tests {
(180, 144).into() (180, 144).into()
); );
} }
#[test] #[test]
fn adjacent_frames_share_edges_at_all_output_scales() { fn adjacent_frames_share_edges_at_all_output_scales() {
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] { 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); assert_eq!(left.loc.x + left.size.w, right.loc.x);
} }
} }
#[test] #[test]
fn handoff_has_bounded_lifetime_and_no_alpha_jump() { fn handoff_has_bounded_lifetime_and_no_alpha_jump() {
assert_eq!(handoff_alpha(Duration::ZERO), 1.0); assert_eq!(handoff_alpha(Duration::ZERO), 1.0);
+27 -12
View File
@@ -26,6 +26,7 @@ pub(crate) struct ConfigMonitor {
dirty: Option<Instant>, dirty: Option<Instant>,
observed: Option<String>, observed: Option<String>,
} }
impl ConfigMonitor { impl ConfigMonitor {
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> { pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
let (sender, events) = mpsc::sync_channel(1); let (sender, events) = mpsc::sync_channel(1);
@@ -64,6 +65,7 @@ impl ConfigMonitor {
observed: None, observed: None,
}) })
} }
pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> { pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> {
if self.events.try_iter().next().is_some() { if self.events.try_iter().next().is_some() {
self.dirty = Some(now); self.dirty = Some(now);
@@ -118,6 +120,7 @@ impl crate::Ferese {
tracing::info!("configuration reloaded live"); tracing::info!("configuration reloaded live");
Ok(()) Ok(())
} }
pub(crate) fn reload_config(&mut self) -> Result<(), String> { pub(crate) fn reload_config(&mut self) -> Result<(), String> {
let path = crate::config::config_path().ok_or("config directory is unavailable")?; let path = crate::config::config_path().ok_or("config directory is unavailable")?;
let source = read_source(&path)?; let source = read_source(&path)?;
@@ -141,18 +144,28 @@ mod tests {
Self(path) Self(path)
} }
} }
impl Drop for Directory { impl Drop for Directory {
fn drop(&mut self) { fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0); let _ = std::fs::remove_dir_all(&self.0);
} }
} }
#[test] #[test]
fn runtime_validation_rejects_bad_edits_before_application() { fn runtime_validation_rejects_bad_edits_before_application() {
for source in [ for source in [
"[animations]\nspeed = 0", "animations {\n speed 0\n}\n",
"[theme.focus_ring.gradient]\nfrom = 'bad'\nto = '#ffffff'", r##"theme {
"[status]\nlow_battery_threshold = 'bad'", focus-ring {
"[layout]\ninner_gap = -1", gradient {
from "bad"
to "#ffffff"
}
}
}
"##,
"status {\n low-battery-threshold \"bad\"\n}\n",
"layout {\n inner-gap -1\n}\n",
] { ] {
assert!( assert!(
crate::config::Config::parse_source(source) crate::config::Config::parse_source(source)
@@ -161,17 +174,18 @@ mod tests {
); );
} }
assert!( assert!(
crate::config::Config::parse_source("[animations]\nspeed = 0.75") crate::config::Config::parse_source("animations {\n speed 0.75\n}\n")
.unwrap() .unwrap()
.runtime_config() .runtime_config()
.is_ok() .is_ok()
); );
} }
#[test] #[test]
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() { fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
let directory = Directory::new(); let directory = Directory::new();
let path = directory.0.join("config.toml"); let path = directory.0.join("config.kdl");
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.clone()).unwrap(); let mut monitor = ConfigMonitor::new(path.clone()).unwrap();
monitor.dirty = Some(Instant::now() - Duration::from_secs(1)); monitor.dirty = Some(Instant::now() - Duration::from_secs(1));
assert!(monitor.poll(Instant::now()).unwrap().is_ok()); assert!(monitor.poll(Instant::now()).unwrap().is_ok());
@@ -180,13 +194,13 @@ mod tests {
monitor.path = directory.0.join("missing"); monitor.path = directory.0.join("missing");
assert!(monitor.poll(Instant::now()).is_none()); assert!(monitor.poll(Instant::now()).is_none());
monitor.path = original_path; monitor.path = original_path;
let temporary = directory.0.join("config.toml.new"); let temporary = directory.0.join("config.kdl.new");
std::fs::write(&temporary, "[animations]\nspeed = 0.75").unwrap(); std::fs::write(&temporary, "animations {\n speed 0.75\n}\n").unwrap();
std::fs::rename(&temporary, &path).unwrap(); std::fs::rename(&temporary, &path).unwrap();
let deadline = Instant::now() + Duration::from_secs(3); let deadline = Instant::now() + Duration::from_secs(3);
loop { loop {
if let Some(result) = monitor.poll(Instant::now()) { 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; break;
} }
assert!( assert!(
@@ -196,13 +210,14 @@ mod tests {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
} }
#[test] #[test]
fn source_size_limit_is_bounded_and_debounce_coalesces_edits() { fn source_size_limit_is_bounded_and_debounce_coalesces_edits() {
let directory = Directory::new(); 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(); std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap();
assert!(read_source(&path).is_err()); 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 mut monitor = ConfigMonitor::new(path).unwrap();
let now = Instant::now(); let now = Instant::now();
monitor.dirty = Some(now); monitor.dirty = Some(now);
+1
View File
@@ -28,6 +28,7 @@ impl ResizeTransaction {
self.source_geometry = Some(geometry); self.source_geometry = Some(geometry);
self self
} }
pub(crate) fn source_geometry(self) -> Option<Rectangle<i32, Logical>> { pub(crate) fn source_geometry(self) -> Option<Rectangle<i32, Logical>> {
self.source_geometry self.source_geometry
} }
+3
View File
@@ -118,6 +118,7 @@ impl SessionLockHandler for Ferese {
fn lock_state(&mut self) -> &mut SessionLockManagerState { fn lock_state(&mut self) -> &mut SessionLockManagerState {
&mut self.session_lock_state &mut self.session_lock_state
} }
fn lock(&mut self, confirmation: SessionLocker) { fn lock(&mut self, confirmation: SessionLocker) {
if self.session_lock.active { if self.session_lock.active {
return; return;
@@ -153,12 +154,14 @@ impl SessionLockHandler for Ferese {
self.intercepted_keys.clear(); self.intercepted_keys.clear();
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
fn unlock(&mut self) { fn unlock(&mut self) {
self.session_lock = Lock::default(); self.session_lock = Lock::default();
self.restore_keyboard_focus(); self.restore_keyboard_focus();
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
tracing::info!("session unlocked by lock owner"); tracing::info!("session unlocked by lock owner");
} }
fn new_surface(&mut self, surface: LockSurface, output: WlOutput) { fn new_surface(&mut self, surface: LockSurface, output: WlOutput) {
if let Some(output) = Output::from_resource(&output) { if let Some(output) = Output::from_resource(&output) {
if let Some(geometry) = self.space.output_geometry(&output) { if let Some(geometry) = self.space.output_geometry(&output) {
+1
View File
@@ -30,6 +30,7 @@ pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
} }
chunks chunks
} }
pub(crate) fn send_shell_config(shell: &FereseShellV1, source: &str) { pub(crate) fn send_shell_config(shell: &FereseShellV1, source: &str) {
shell.config_begin(); shell.config_begin();
for chunk in config_chunks(source) { for chunk in config_chunks(source) {
+1
View File
@@ -322,6 +322,7 @@ impl Ferese {
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
Ok(()) Ok(())
} }
pub fn register_output(&mut self, output: &Output, identity: String) { pub fn register_output(&mut self, output: &Output, identity: String) {
let Some(geometry) = self.space.output_geometry(output) else { let Some(geometry) = self.space.output_geometry(output) else {
tracing::error!(output = %output.name(), "cannot register an unmapped output"); tracing::error!(output = %output.name(), "cannot register an unmapped output");
+9
View File
@@ -23,6 +23,7 @@ pub(crate) struct WallpaperConfig {
#[serde(default)] #[serde(default)]
pub mode: WallpaperMode, pub mode: WallpaperMode,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode { pub(crate) enum WallpaperMode {
@@ -78,10 +79,12 @@ impl WallpaperState {
textures: HashMap::new(), textures: HashMap::new(),
} }
} }
#[cfg(test)] #[cfg(test)]
pub fn owns_background(&self) -> bool { pub fn owns_background(&self) -> bool {
self.owned self.owned
} }
pub fn reload(&mut self, config: WallpaperConfig) { pub fn reload(&mut self, config: WallpaperConfig) {
if self.receiver.is_some() { if self.receiver.is_some() {
self.pending = (self.config != config).then_some(config); self.pending = (self.config != config).then_some(config);
@@ -110,9 +113,11 @@ impl WallpaperState {
self.textures.clear(); self.textures.clear();
} }
} }
pub fn forget_context(&mut self, context: &ErasedContextId) { pub fn forget_context(&mut self, context: &ErasedContextId) {
self.textures.remove(context); self.textures.remove(context);
} }
pub fn poll(&mut self) -> bool { pub fn poll(&mut self) -> bool {
let Some(receiver) = &self.receiver else { let Some(receiver) = &self.receiver else {
return false; return false;
@@ -153,6 +158,7 @@ impl WallpaperState {
Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Empty) => false,
} }
} }
pub fn element( pub fn element(
&mut self, &mut self,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
@@ -270,6 +276,7 @@ mod tests {
state.reload(state.config.clone()); state.reload(state.config.clone());
assert_eq!(state.commit, before); assert_eq!(state.commit, before);
} }
#[test] #[test]
fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() { fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() {
let mut state = WallpaperState::new(WallpaperConfig::default()); let mut state = WallpaperState::new(WallpaperConfig::default());
@@ -287,6 +294,7 @@ mod tests {
assert!(state.pixels.is_some()); assert!(state.pixels.is_some());
assert_eq!(state.mode, WallpaperMode::Fill); assert_eq!(state.mode, WallpaperMode::Fill);
} }
#[test] #[test]
fn fill_crops_and_fit_letterboxes_without_reallocating_image() { fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
let (geometry, source) = image_geometry( let (geometry, source) = image_geometry(
@@ -303,6 +311,7 @@ mod tests {
assert_eq!(geometry.loc, (0, 50).into()); assert_eq!(geometry.loc, (0, 50).into());
assert_eq!(source.size, (3840.0, 2160.0).into()); assert_eq!(source.size, (3840.0, 2160.0).into());
} }
#[test] #[test]
fn missing_wallpaper_preserves_external_shell_fallback() { fn missing_wallpaper_preserves_external_shell_fallback() {
let state = WallpaperState::new(WallpaperConfig::default()); let state = WallpaperState::new(WallpaperConfig::default());
+1
View File
@@ -2530,6 +2530,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] {
rect.map(|v| v.round() as i32) rect.map(|v| v.round() as i32)
} }
fn expanded_blur_region( fn expanded_blur_region(
visible: Rectangle<i32, Logical>, visible: Rectangle<i32, Logical>,
radius: i32, radius: i32,
+11
View File
@@ -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
+791
View File
@@ -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<String> for Error {
fn from(value: String) -> Self {
Self(value)
}
}
pub fn config_path() -> Option<PathBuf> {
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<Value, Error> {
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<String, Value>, 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<Map<String, Value>, 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<Value, Error> {
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::<Result<Vec<_>, _>>()?;
let props = node
.entries()
.iter()
.filter_map(|e| e.name().map(|n| (field(n.value(), key), e)))
.collect::<Vec<_>>();
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<Self, Error> {
let doc = source
.parse::<KdlDocument>()
.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::<usize>() {
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::<Vec<_>>();
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::<usize>().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::<Vec<_>>(),
"",
)?;
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::<Vec<_>>(),
"",
)?;
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<kdl::KdlEntryFormat>>, Option<String>);
fn collect_node_formats(doc: &KdlDocument, formats: &mut Vec<NodeFormat>) {
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<Item = NodeFormat>) {
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::<usize>().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::<String>);
} 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::<usize>()
.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<KdlValue, Error> {
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<KdlNode, Error> {
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<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
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");
}
}
+5
View File
@@ -97,6 +97,7 @@ impl StickyNote {
&& self.width == other.width && self.width == other.width
&& self.height == other.height && self.height == other.height
} }
pub fn validate(&self) -> Result<(), String> { pub fn validate(&self) -> Result<(), String> {
let invalid = |field: &str| { let invalid = |field: &str| {
Err(format!( Err(format!(
@@ -347,12 +348,14 @@ mod tests {
..note.clone() ..note.clone()
})); }));
} }
#[test] #[test]
fn defaults_are_safe_and_opt_in() { fn defaults_are_safe_and_opt_in() {
let clock = Clock::default(); let clock = Clock::default();
clock.validate().unwrap(); clock.validate().unwrap();
assert!(!clock.on_output(Some("HDMI-A-1"))); assert!(!clock.on_output(Some("HDMI-A-1")));
} }
#[test] #[test]
fn output_filters_do_not_guess_monitor_names() { fn output_filters_do_not_guess_monitor_names() {
let clock = Clock { let clock = Clock {
@@ -364,6 +367,7 @@ mod tests {
assert!(!clock.on_output(Some("eDP-1"))); assert!(!clock.on_output(Some("eDP-1")));
assert!(!clock.on_output(None)); assert!(!clock.on_output(None));
} }
#[test] #[test]
fn rejects_bad_sizes_colors_formats_and_zones() { fn rejects_bad_sizes_colors_formats_and_zones() {
for clock in [ for clock in [
@@ -391,6 +395,7 @@ mod tests {
assert!(clock.validate().is_err()); assert!(clock.validate().is_err());
} }
} }
#[test] #[test]
fn formats_timezone_and_lowercase() { fn formats_timezone_and_lowercase() {
let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap(); let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap();
+1
View File
@@ -449,6 +449,7 @@ impl WorkspaceSet {
} }
} }
} }
pub fn new( pub fn new(
default_layout_mode: LayoutMode, default_layout_mode: LayoutMode,
default_column_width: ColumnWidth, default_column_width: ColumnWidth,
+1
View File
@@ -103,6 +103,7 @@ impl ScrollingLayout {
pub fn set_default_width(&mut self, width: ColumnWidth) { pub fn set_default_width(&mut self, width: ColumnWidth) {
self.default_width = normalized_width(width); self.default_width = normalized_width(width);
} }
pub fn with_default_width(default_width: ColumnWidth) -> Self { pub fn with_default_width(default_width: ColumnWidth) -> Self {
Self { Self {
default_width: normalized_width(default_width), default_width: normalized_width(default_width),
+96 -64
View File
@@ -1,16 +1,58 @@
# Configuration # Configuration
Edit `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/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.toml). or open **Control Center → Settings**. Start with the [example](../packaging/config.kdl).
All supported configuration sections are listed below; [desktop widgets](desktop-widgets.md) All supported configuration sections are listed below; [desktop widgets](desktop-widgets.md)
covers the complete clock and sticky-note fields separately. 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 ## Saving and validation
Settings saves automatically: text on Enter/focus loss, sliders when released. Settings saves automatically: text on Enter/focus loss, sliders when released.
Undo restores the previous save; Reload picks up external edits without merging Undo restores the previous save; Reload picks up external edits without merging
unfinished drafts. Wallpaper browsing needs `zenity`; a path can also be entered. 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 File edits reload automatically, including atomic editor saves. Invalid changes
keep the last working configuration. Appearance, wallpaper, motion, input, keep the last working configuration. Appearance, wallpaper, motion, input,
@@ -29,14 +71,14 @@ Live config is limited to 60 KiB.
## Layout ## Layout
| `[layout]` key | Type / values | Default | Meaning | | `layout` key | Type / values | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `mode` | `"scrolling"`, `"tree"` | `"scrolling"` | Workspace layout | | `mode` | `"scrolling"`, `"tree"` | `"scrolling"` | Workspace layout |
| `inner_gap` | number ≥ 0 | `10` | Between windows | | `inner_gap` | number ≥ 0 | `10` | Between windows |
| `outer_gap` | number ≥ 0 | `4` | Around workspace edges | | `outer_gap` | number ≥ 0 | `4` | Around workspace edges |
| `smart_gaps` | boolean | `false` | Remove outer gaps for one tiled window | | `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 | | `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 | | `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.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables | | `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 | | `surface_base` | `"#111821"` | Material/card base color |
| `text_primary` | `"#F4F7FB"` | Main text | | `text_primary` | `"#F4F7FB"` | Main text |
@@ -84,7 +126,7 @@ Changing default width preserves manually resized columns.
| `border` | `"#FFFFFF18"` | Unfocused window border | | `border` | `"#FFFFFF18"` | Unfocused window border |
| `shadow` | `"#00000055"` | Window shadow color | | `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 | | `border_width` | number ≥ 0 | `1` | Window border thickness |
| `focus_ring_width` | number ≥ 0 | `2` | Focused 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. Solid materials omit blur; translucent materials blur behind the surface color.
True fullscreen removes decorations. Focus transitions never scale the content. 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 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 are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section
for solid `accent` or `border` colors. for solid `accent` or `border` colors.
```toml ```kdl
[theme.focus_ring.gradient] theme {
from = "#e5c890" focus-ring {
to = "#b98d58" gradient {
angle = 135.0 from "#e5c890"
to "#b98d58"
angle 135.0
}
}
}
``` ```
## Input ## 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 | | `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer without raising them; click to raise |
| `xkb_layout` | nonempty string | `"us"` | XKB layout | | `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 gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5
fingers are also supported. Changes reload live: fingers are also supported. Changes reload live:
```toml ```kdl
[[bindings]] binding keys="Swipe3Up" action="toggle-overview"
keys = "Swipe3Up" binding keys="Swipe3Left" action="move" argument="left"
action = "toggle-overview" binding keys="Swipe3Down" disabled=#true
[[bindings]]
keys = "Swipe3Left"
action = "move"
argument = "left"
[[bindings]]
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 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 keyboard modifiers or physical key matching. The trigger is automatically
recognized from its name. Gesture navigation uses native touchpad events in a recognized from its name. Gesture navigation uses native touchpad events in a
hardware session. hardware session.
Adjust recognition distance in **Settings → Keyboard & mouse**, or with 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 and bindings
`[commands]` maps arbitrary names to nonempty argument arrays. The built-in `commands` maps arbitrary names to nonempty argument arrays. The built-in
`terminal = ["foot"]` can be overridden. Commands run directly, without a shell. `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 | | `keys` | chord or gesture, e.g. `"Super+Enter"`, `"Swipe3Up"` | required |
| `match` | `"keysym"`, `"physical"` | `"keysym"` | | `match` | `"keysym"`, `"physical"` | `"keysym"` |
@@ -195,21 +233,18 @@ invalid. A disabled binding must omit `action` and `argument`.
| Actions | Argument | | Actions | Argument |
| --- | --- | | --- | --- |
| `spawn` | Name in `[commands]` | | `spawn` | Name in `commands` |
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor |
| `none` | None; ignore this trigger | | `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 | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None |
```toml ```kdl
[commands] commands {
terminal = ["foot"] terminal "foot"
}
[[bindings]] binding keys="Super+Enter" action="spawn" argument="terminal"
keys = "Super+Enter"
action = "spawn"
argument = "terminal"
``` ```
Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; 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
| `[[window_rules]]` key | Type | Default / meaning | | `window-rule` key | Type | Default / meaning |
| --- | --- | --- | | --- | --- | --- |
| `app_id` | nonempty string | Optional exact match; case-insensitive, `.desktop` suffix ignored | | `app_id` | nonempty string | Optional exact match; case-insensitive, `.desktop` suffix ignored |
| `title` | nonempty string | Optional exact, case-sensitive match | | `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; size and open centered on the output. Title changes do not trigger new rules;
config rule changes apply to existing windows. config rule changes apply to existing windows.
```toml ```kdl
[[window_rules]] window-rule app-id="dev.ferese.Settings" floating=#true
app_id = "dev.ferese.Settings"
floating = true
``` ```
## Displays ## Displays
`[[output_profiles]]` has a required unique nonempty `name` and one or more `output-profile` has a required unique nonempty `name` and one or more
`[[output_profiles.outputs]]` entries. The first profile whose listed monitors `output-profile → output` entries. The first profile whose listed monitors
are connected wins. Find connector names and persistent identities using are connected wins. Find connector names and persistent identities using
`feresectl get-outputs`. `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 | | `match` | nonempty connector or persistent identity string | required; unique within profile |
| `enabled` | boolean | `true` | | `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 `flipped_90`, `flipped_180`, `flipped_270`. Unspecified connected displays stay
enabled with defaults. Disabling every usable output is rejected. enabled with defaults. Disabling every usable output is rejected.
```toml ```kdl
[[output_profiles]] output-profile name="docked" {
name = "docked" output match="HDMI-A-1" scale=1.5 {
position 0 0
[[output_profiles.outputs]] }
match = "HDMI-A-1" }
scale = 1.5
position = [0, 0]
``` ```
With a usable external display, closing the lid disables internal panels. 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 controls
| `[status]` key | Type | Default | Meaning | | `status` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `battery_percentage` | boolean | `true` | Show percentage beside icon | | `battery_percentage` | boolean | `true` | Show percentage beside icon |
| `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold | | `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 ## 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 | | `command` | nonempty argument array | required | Foreground process, not a self-daemonizing command |
| `enabled` | boolean | `true` | Start service | | `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 Unchanged services keep running on reload; removed/disabled items stop. To lock
after five minutes and before sleep: after five minutes and before sleep:
```toml ```kdl
[[autostart]] autostart {
command = ["swayidle", "-w", "timeout", "300", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] command "swayidle" "-w" "timeout" "300" "ferese-lock" "before-sleep" "ferese-lock" "lock" "ferese-lock"
}
``` ```
`ferese-lock` uses swaylock/swaylock-effects and optional `ferese-lock` uses swaylock/swaylock-effects and optional
@@ -321,7 +353,7 @@ ending the session from another TTY.
## Optional session protocols ## 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`, trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`,
`FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. `FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`.
The installed session launcher enables screencopy for screenshots unless The installed session launcher enables screencopy for screenshots unless
+32 -40
View File
@@ -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: Enable it in Settings → Desktop widgets or in your Ferese config:
```toml ```kdl
[desktop_widgets.clock] desktop-widgets {
enabled = true clock enabled=#true anchor="top_left" margin-x=64 margin-y=80 {
outputs = [] # every output; or ["DP-2"] using your own connector outputs // every output; or outputs "DP-2"
anchor = "top_left" width 440
margin_x = 64 # distance from anchored left/right edge height 160
margin_y = 80 # distance from anchored top/bottom edge font-family "" // inherit the desktop font
width = 440 bold #false
height = 160 time-size 72.0
font_family = "" # empty/omitted follows your desktop font date-size 18.0
bold = false time-format "%-I:%M %p" // use "%H:%M" for 24-hour time
time_size = 72.0 date-format "%A, %-d %B"
date_size = 18.0 time-zone "" // system zone; or "Africa/Lagos"
time_format = "%-I:%M %p" # 12-hour; "%H:%M" for 24-hour, "%H:%M:%S" for seconds show-date #true
date_format = "%A, %-d %B" lowercase #true
time_zone = "" # system zone; or e.g. "Africa/Lagos", "UTC" color "" // theme primary text
show_date = true date-color "" // theme muted text
lowercase = true opacity 0.9
color = "" # theme primary text; or "#ffffff", "#ffffffcc" alignment "center" // left, center, right
date_color = "" # theme muted text gap 4.0
opacity = 0.9 # multiplies text and optional background alpha padding 12.0
alignment = "center" # left, center, right background "" // transparent; or "#10101080"
gap = 4.0 radius 16.0
padding = 12.0 }
background = "" # transparent; or e.g. "#10101080" for a solid tint }
radius = 16.0 # optional background corner radius
``` ```
Positions: `top_left`, `top_center`, `top_right`, `center_left`, `center`, 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). 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 Seconds update on the shell's existing timer. Without seconds, unchanged labels
produce no additional clock buffer damage; there is no widget animation loop. 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. Sticky notes also support editing directly on the desktop.
## Clock limits ## 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. Title, size and style are also editable in Settings. Notes persist in the config.
These are plain-text notes, not Markdown or HTML. These are plain-text notes, not Markdown or HTML.
```toml ```kdl
[[desktop_widgets.notes]] desktop-widgets {
id = "today" note id="today" title="Today" text="Review the pull request\nTake a break\n" anchor="top_right" margin-x=48 margin-y=80
title = "Today" }
text = """
Review the pull request
Take a break
"""
anchor = "top_right"
margin_x = 48
margin_y = 80
``` ```
Repeat the table for additional notes; IDs must be unique. Cards stay behind windows 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 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 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 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. compositor, not an output-sized canvas. It is released when dragging ends.
+2 -2
View File
@@ -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 Use `--help` for all options. New configs can be copied from
`/usr/local/lib/ferese/current/config.example.toml` to `/usr/local/lib/ferese/current/config.example.kdl` to
`~/.config/ferese/config.toml`. See [Configuration](configuration.md). `~/.config/ferese/config.kdl`. See [Configuration](configuration.md).
## Preview and logs ## Preview and logs
+44
View File
@@ -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
}
}
-51
View File
@@ -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"
+1 -1
View File
@@ -55,7 +55,7 @@ done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" 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-lock" "$release_dir/ferese-lock"
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot" 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 if [[ -L $install_root/current ]]; then
ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous" ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous"
+1 -1
View File
@@ -6,10 +6,10 @@ license.workspace = true
rust-version = "1.93" rust-version = "1.93"
[dependencies] [dependencies]
ferese-config = { path = "../../crates/ferese-config" }
libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true serde_json.workspace = true
toml_edit = "0.25.15"
tempfile.workspace = true tempfile.workspace = true
shlex = "1.3.0" shlex = "1.3.0"
notify = "8.2.0" notify = "8.2.0"
+68 -11
View File
@@ -21,6 +21,7 @@ fn main() -> cosmic::iced::Result {
std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu"); std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu");
} }
} }
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let path = match args.next() { let path = match args.next() {
Some(arg) if arg == "--config" => { Some(arg) if arg == "--config" => {
@@ -35,6 +36,7 @@ fn main() -> cosmic::iced::Result {
let path = path.canonicalize().unwrap_or(path); let path = path.canonicalize().unwrap_or(path);
let initial = Snapshot::read(&path); let initial = Snapshot::read(&path);
let theme = visuals::native_theme(initial.as_ref().ok()); let theme = visuals::native_theme(initial.as_ref().ok());
cosmic::app::run::<App>( cosmic::app::run::<App>(
Settings::default() Settings::default()
.theme(theme) .theme(theme)
@@ -107,14 +109,18 @@ impl cosmic::Application for App {
type Flags = (PathBuf, Result<Snapshot, String>); type Flags = (PathBuf, Result<Snapshot, String>);
type Message = Message; type Message = Message;
const APP_ID: &'static str = "dev.ferese.Settings"; const APP_ID: &'static str = "dev.ferese.Settings";
fn core(&self) -> &Core { fn core(&self) -> &Core {
&self.core &self.core
} }
fn core_mut(&mut self) -> &mut Core { fn core_mut(&mut self) -> &mut Core {
&mut self.core &mut self.core
} }
fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task<Message>) { fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false; core.window.show_headerbar = false;
let status = if path == store::config_path() { let status = if path == store::config_path() {
"Changes save automatically" "Changes save automatically"
} else { } else {
@@ -152,15 +158,18 @@ impl cosmic::Application for App {
.main_window_id() .main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id)) .map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
app.sync_notes(); app.sync_notes();
(app, task) (app, task)
} }
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::NoteAction(id, action) => { Message::NoteAction(id, action) => {
if let Some(editor) = self.note_editors.get_mut(&id) { if let Some(editor) = self.note_editors.get_mut(&id) {
let edited = action.is_edit(); let edited = action.is_edit();
editor.content.perform(action); editor.content.perform(action);
if edited { if edited {
editor.dirty = true; editor.dirty = true;
editor.revision = editor.revision.wrapping_add(1); editor.revision = editor.revision.wrapping_add(1);
@@ -176,6 +185,7 @@ impl cosmic::Application for App {
let Some(index) = self.note_index(&id) else { let Some(index) = self.note_index(&id) else {
return Task::none(); return Task::none();
}; };
if let Some(editor) = self.note_editors.get_mut(&id) if let Some(editor) = self.note_editors.get_mut(&id)
&& editor.dirty && editor.dirty
&& editor.revision == revision && editor.revision == revision
@@ -203,6 +213,7 @@ impl cosmic::Application for App {
("text".into(), "".into()), ("text".into(), "".into()),
], ],
)); ));
self.sync_notes(); self.sync_notes();
return task; return task;
} }
@@ -229,6 +240,7 @@ impl cosmic::Application for App {
self.undo = None; self.undo = None;
self.error = None; self.error = None;
self.status = "Updated from your config".into(); self.status = "Updated from your config".into();
return Task::batch([ return Task::batch([
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))),
self.load_thumbnail(), self.load_thumbnail(),
@@ -266,9 +278,12 @@ impl cosmic::Application for App {
if let Some(value) = self.inputs.remove(&field.path) { if let Some(value) = self.inputs.remove(&field.path) {
let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) { let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) {
match shlex::split(&value).filter(|v| !v.is_empty()) { match shlex::split(&value).filter(|v| !v.is_empty()) {
Some(args) => { Some(args) => set(
set(&field.path, args.into_iter().collect::<toml_edit::Array>()) &field.path,
} args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>(),
),
None => { None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into()); self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
return Task::none(); return Task::none();
@@ -289,11 +304,13 @@ impl cosmic::Application for App {
self.current = snapshot; self.current = snapshot;
self.font = visuals::configured_font(&self.current); self.font = visuals::configured_font(&self.current);
self.draft = self.current.clone(); self.draft = self.current.clone();
for edit in &self.pending { for edit in &self.pending {
if let Err(error) = self.draft.edit(edit) { if let Err(error) = self.draft.edit(edit) {
self.error = Some(error); self.error = Some(error);
} }
} }
self.sync_notes(); self.sync_notes();
self.status = if live { self.status = if live {
"Saved · desktop updated" "Saved · desktop updated"
@@ -303,7 +320,9 @@ impl cosmic::Application for App {
"Saved · could not confirm desktop reload" "Saved · could not confirm desktop reload"
} }
.into(); .into();
let theme = visuals::native_theme(Some(&self.draft)); let theme = visuals::native_theme(Some(&self.draft));
return Task::batch([ return Task::batch([
cosmic::command::set_theme(theme), cosmic::command::set_theme(theme),
self.flush(), self.flush(),
@@ -331,6 +350,7 @@ impl cosmic::Application for App {
self.error = None; self.error = None;
self.undo = None; self.undo = None;
self.status = "Reloaded from your config".into(); self.status = "Reloaded from your config".into();
return Task::batch([ return Task::batch([
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))),
self.load_thumbnail(), self.load_thumbnail(),
@@ -349,6 +369,7 @@ impl cosmic::Application for App {
self.error = Some(error); self.error = Some(error);
return Task::none(); return Task::none();
} }
self.pending.push(edit); self.pending.push(edit);
} }
return self.flush(); return self.flush();
@@ -420,7 +441,10 @@ impl cosmic::Application for App {
vec![ vec![
( (
"command".into(), "command".into(),
args.into_iter().collect::<toml_edit::Array>().into(), args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>()
.into(),
), ),
("enabled".into(), true.into()), ("enabled".into(), true.into()),
("restart".into(), true.into()), ("restart".into(), true.into()),
@@ -444,9 +468,11 @@ impl cosmic::Application for App {
("keys".into(), keys.into()), ("keys".into(), keys.into()),
("action".into(), action.into()), ("action".into(), action.into()),
]; ];
if let Some(argument) = argument { if let Some(argument) = argument {
fields.push(("argument".into(), argument.into())); fields.push(("argument".into(), argument.into()));
} }
return self.change(Edit::Add("bindings".into(), fields)); return self.change(Edit::Add("bindings".into(), fields));
} }
Message::Remove(table, index) if !self.saving => { Message::Remove(table, index) if !self.saving => {
@@ -459,9 +485,11 @@ impl cosmic::Application for App {
} }
Task::none() Task::none()
} }
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes) cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes)
} }
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from(&self.draft); let palette = visuals::Palette::from(&self.draft);
let mut sidebar = column([]) let mut sidebar = column([])
@@ -538,6 +566,7 @@ impl cosmic::Application for App {
.width(Length::Fill), .width(Length::Fill),
); );
let mut body = column([]).spacing(12); let mut body = column([]).spacing(12);
if !self.search.is_empty() { if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
body = body body = body
@@ -586,6 +615,7 @@ impl cosmic::Application for App {
} }
body = body.push(presets); body = body.push(presets);
} }
if self.page == Page::Wallpaper { if self.page == Page::Wallpaper {
if let Some(handle) = &self.thumbnail { if let Some(handle) = &self.thumbnail {
body = body.push( body = body.push(
@@ -607,6 +637,7 @@ impl cosmic::Application for App {
body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
} }
let fields = schema::fields(self.page); let fields = schema::fields(self.page);
if !fields.is_empty() { if !fields.is_empty() {
let mut group = column([]).spacing(1); let mut group = column([]).spacing(1);
for field in fields { for field in fields {
@@ -618,6 +649,7 @@ impl cosmic::Application for App {
.class(visuals::surface(palette.card, 14.)), .class(visuals::surface(palette.card, 14.)),
); );
} }
match self.page { match self.page {
Page::Desktop => { Page::Desktop => {
let can_change_list = let can_change_list =
@@ -629,9 +661,11 @@ impl cosmic::Application for App {
.draft .draft
.string(&format!("desktop_widgets.notes.{index}.id"), "note"); .string(&format!("desktop_widgets.notes.{index}.id"), "note");
let mut group = column([]).spacing(8); let mut group = column([]).spacing(8);
for field in schema::note_fields(index) { for field in schema::note_fields(index) {
group = group.push(self.field(field)); group = group.push(self.field(field));
} }
if let Some(editor) = self.note_editors.get(&id) { if let Some(editor) = self.note_editors.get(&id) {
let id = id.clone(); let id = id.clone();
group = group.push( group = group.push(
@@ -666,6 +700,7 @@ impl cosmic::Application for App {
} }
Page::Startup => { 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.")); 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") { for index in 0..self.draft.records("autostart") {
let prefix = format!("autostart.{index}"); let prefix = format!("autostart.{index}");
let group = column([]) let group = column([])
@@ -735,6 +770,7 @@ impl cosmic::Application for App {
.string(&format!("bindings.{index}.keys"), "") .string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys) .eq_ignore_ascii_case(keys)
}); });
if !exists { if !exists {
gestures = gestures =
gestures.push(button::standard(label).on_press_maybe( gestures.push(button::standard(label).on_press_maybe(
@@ -742,7 +778,9 @@ impl cosmic::Application for App {
)); ));
} }
} }
body = body.push(gestures); body = body.push(gestures);
for index in 0..self.draft.records("bindings") { for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}"); let prefix = format!("bindings.{index}");
let group = column([]) let group = column([])
@@ -772,7 +810,7 @@ impl cosmic::Application for App {
); );
} }
if self.draft.records("bindings") == 0 { 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 => { Page::Displays => {
@@ -786,6 +824,7 @@ impl cosmic::Application for App {
17., 17.,
), ),
); );
for output in 0..self.draft.records(&format!("{prefix}.outputs")) { for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}"); let prefix = format!("{prefix}.outputs.{output}");
let group = column([]).spacing(1) let group = column([]).spacing(1)
@@ -806,7 +845,9 @@ impl cosmic::Application for App {
_ => {} _ => {}
} }
} }
let mut content = column([]).spacing(16).push(heading); let mut content = column([]).spacing(16).push(heading);
if let Some(error) = &self.error { if let Some(error) = &self.error {
content = content.push( content = content.push(
container(self.label(error.clone(), 12.)) container(self.label(error.clone(), 12.))
@@ -815,6 +856,7 @@ impl cosmic::Application for App {
.class(visuals::surface(palette.error, 10.)), .class(visuals::surface(palette.error, 10.)),
); );
} }
content = content.push( content = content.push(
scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill), scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill),
); );
@@ -902,8 +944,10 @@ impl App {
== id == id
}) })
} }
fn sync_notes(&mut self) { fn sync_notes(&mut self) {
let mut ids = Vec::new(); let mut ids = Vec::new();
for index in 0..self.draft.records("desktop_widgets.notes") { for index in 0..self.draft.records("desktop_widgets.notes") {
let prefix = format!("desktop_widgets.notes.{index}"); let prefix = format!("desktop_widgets.notes.{index}");
let id = self.draft.string(&format!("{prefix}.id"), "note"); let id = self.draft.string(&format!("{prefix}.id"), "note");
@@ -921,8 +965,10 @@ impl App {
} }
ids.push(id); ids.push(id);
} }
self.note_editors.retain(|id, _| ids.contains(id)); self.note_editors.retain(|id, _| ids.contains(id));
} }
fn load_thumbnail(&mut self) -> Task<Message> { fn load_thumbnail(&mut self) -> Task<Message> {
let path = self.current.string("theme.background.path", ""); let path = self.current.string("theme.background.path", "");
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path { if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
@@ -934,6 +980,7 @@ impl App {
self.thumbnail_error = None; self.thumbnail_error = None;
return Task::none(); return Task::none();
} }
self.thumbnail_loading = true; self.thumbnail_loading = true;
cosmic::task::future(async move { cosmic::task::future(async move {
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
@@ -970,6 +1017,7 @@ impl App {
) )
}) })
} }
fn label<'a>( fn label<'a>(
&self, &self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a, text: impl Into<std::borrow::Cow<'a, str>> + 'a,
@@ -977,12 +1025,14 @@ impl App {
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
widget::text(text).size(size).font(self.font) widget::text(text).size(size).font(self.font)
} }
fn note(&self, text: &str) -> Element<'static, Message> { fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from(&self.draft); let palette = visuals::Palette::from(&self.draft);
self.label(text.to_owned(), 12.) self.label(text.to_owned(), 12.)
.class(cosmic::theme::Text::Color(palette.muted)) .class(cosmic::theme::Text::Color(palette.muted))
.into() .into()
} }
fn field(&self, field: Field) -> Element<'static, Message> { fn field(&self, field: Field) -> Element<'static, Message> {
let palette = visuals::Palette::from(&self.draft); let palette = visuals::Palette::from(&self.draft);
let mut labels = column([]) let mut labels = column([])
@@ -1096,6 +1146,7 @@ impl App {
.class(visuals::surface(palette.card, 0.)) .class(visuals::surface(palette.card, 0.))
.into() .into()
} }
fn change(&mut self, edit: Edit) -> Task<Message> { fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) { match self.draft.edit(&edit) {
Ok(()) => { Ok(()) => {
@@ -1122,6 +1173,7 @@ impl App {
} }
} }
} }
fn flush(&mut self) -> Task<Message> { fn flush(&mut self) -> Task<Message> {
if self.saving || self.pending.is_empty() { if self.saving || self.pending.is_empty() {
return Task::none(); return Task::none();
@@ -1132,6 +1184,7 @@ impl App {
} }
self.start_save(self.draft.source.clone()) self.start_save(self.draft.source.clone())
} }
fn start_save(&mut self, desired: String) -> Task<Message> { fn start_save(&mut self, desired: String) -> Task<Message> {
self.saving = true; self.saving = true;
self.saving_previous = self.current.source.clone(); self.saving_previous = self.current.source.clone();
@@ -1164,12 +1217,13 @@ mod tests {
App::init( App::init(
Core::default(), Core::default(),
( (
PathBuf::from("/unused/settings-test.toml"), PathBuf::from("/unused/settings-test.kdl"),
Snapshot::parse(String::new()), Snapshot::parse(String::new()),
), ),
) )
.0 .0
} }
#[test] #[test]
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() { fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
let mut app = app(); let mut app = app();
@@ -1210,11 +1264,12 @@ mod tests {
app.sync_notes(); app.sync_notes();
app.note_editors.get_mut("test").unwrap().dirty = true; app.note_editors.get_mut("test").unwrap().dirty = true;
let _ = app.update(Message::ExternalConfig(Snapshot::parse( 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.note_editors.contains_key("test"));
assert!(app.status.contains("externally")); assert!(app.status.contains("externally"));
} }
#[test] #[test]
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() { fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app(); 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("layout.inner_gap", 0.), 6.);
assert_eq!(app.draft.number("animations.speed", 0.), 0.75); assert_eq!(app.draft.number("animations.speed", 0.), 0.75);
} }
#[test] #[test]
fn external_config_refreshes_idle_settings_but_preserves_active_edits() { fn external_config_refreshes_idle_settings_but_preserves_active_edits() {
let mut app = app(); 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()))); let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
assert_eq!(app.current.source, external.source); assert_eq!(app.current.source, external.source);
assert_eq!(app.draft.number("animations.speed", 0.), 0.5); assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
app.inputs app.inputs
.insert("theme.colors.accent".into(), "#ffffff".into()); .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))); let _ = app.update(Message::ExternalConfig(Ok(newer)));
assert_eq!(app.current.source, external.source); assert_eq!(app.current.source, external.source);
assert_eq!(app.inputs["theme.colors.accent"], "#ffffff"); assert_eq!(app.inputs["theme.colors.accent"], "#ffffff");
assert!(app.status.contains("externally")); 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_eq!(app.current.source, external.source);
assert!(app.error.unwrap().contains("invalid TOML")); assert!(app.error.unwrap().contains("invalid KDL"));
} }
#[test] #[test]
fn settings_has_no_native_headerbar() { fn settings_has_no_native_headerbar() {
assert!(!app().core.window.show_headerbar); assert!(!app().core.window.show_headerbar);
+11
View File
@@ -11,6 +11,7 @@ pub enum Page {
Startup, Startup,
Displays, Displays,
} }
impl Page { impl Page {
pub const ALL: [Self; 10] = [ pub const ALL: [Self; 10] = [
Self::Appearance, Self::Appearance,
@@ -24,6 +25,7 @@ impl Page {
Self::Startup, Self::Startup,
Self::Displays, Self::Displays,
]; ];
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
match self { match self {
Self::Appearance => "Appearance", Self::Appearance => "Appearance",
@@ -38,6 +40,7 @@ impl Page {
Self::Displays => "Displays", Self::Displays => "Displays",
} }
} }
pub fn subtitle(self) -> &'static str { pub fn subtitle(self) -> &'static str {
match self { match self {
Self::Appearance => "A desktop that feels like yours.", Self::Appearance => "A desktop that feels like yours.",
@@ -52,6 +55,7 @@ impl Page {
Self::Displays => "A place for every screen.", Self::Displays => "A place for every screen.",
} }
} }
pub fn icon(self) -> &'static str { pub fn icon(self) -> &'static str {
match self { match self {
Self::Appearance => { Self::Appearance => {
@@ -72,6 +76,7 @@ impl Page {
Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8", Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8",
} }
} }
pub fn matches(self, query: &str) -> bool { pub fn matches(self, query: &str) -> bool {
let query = query.to_lowercase(); let query = query.to_lowercase();
self.title().to_lowercase().contains(&query) self.title().to_lowercase().contains(&query)
@@ -103,6 +108,7 @@ pub enum Kind {
argv: bool, argv: bool,
}, },
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Field { pub struct Field {
pub path: String, pub path: String,
@@ -110,6 +116,7 @@ pub struct Field {
pub description: String, pub description: String,
pub kind: Kind, pub kind: Kind,
} }
impl Field { impl Field {
pub fn new( pub fn new(
path: impl Into<String>, path: impl Into<String>,
@@ -125,9 +132,11 @@ impl Field {
} }
} }
} }
fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field { fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field {
Field::new(path, label, description, Kind::Toggle(default)) Field::new(path, label, description, Kind::Toggle(default))
} }
pub fn range( pub fn range(
path: impl Into<String>, path: impl Into<String>,
label: impl Into<String>, label: impl Into<String>,
@@ -153,6 +162,7 @@ pub fn range(
}, },
) )
} }
pub fn text( pub fn text(
path: impl Into<String>, path: impl Into<String>,
label: impl Into<String>, label: impl Into<String>,
@@ -169,6 +179,7 @@ pub fn text(
}, },
) )
} }
fn choice( fn choice(
path: &str, path: &str,
label: &str, label: &str,
+72 -103
View File
@@ -1,3 +1,5 @@
use ferese_config::Document;
use serde_json::Value;
use std::{ use std::{
fs, fs,
io::{Read, Write}, io::{Read, Write},
@@ -5,23 +7,24 @@ use std::{
process::{Command, Stdio}, process::{Command, Stdio},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use toml_edit::{DocumentMut, Item, Value};
pub const LIMIT: usize = 60 * 1024; pub const LIMIT: usize = 60 * 1024;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Snapshot { pub struct Snapshot {
pub source: String, pub source: String,
pub doc: DocumentMut, pub doc: Document,
} }
impl Snapshot { impl Snapshot {
pub fn parse(source: String) -> Result<Self, String> { pub fn parse(source: String) -> Result<Self, String> {
if source.len() > LIMIT { if source.len() > LIMIT {
return Err("Configuration exceeds the 60 KiB limit.".into()); return Err("Configuration exceeds the 60 KiB limit.".into());
} }
let doc = source.parse::<DocumentMut>().map_err(|e| e.to_string())?; let doc = Document::parse(&source).map_err(|e| e.to_string())?;
Ok(Self { source, doc }) Ok(Self { source, doc })
} }
pub fn read(path: &Path) -> Result<Self, String> { pub fn read(path: &Path) -> Result<Self, String> {
let source = match fs::File::open(path) { let source = match fs::File::open(path) {
Ok(file) => { Ok(file) => {
@@ -36,39 +39,35 @@ impl Snapshot {
}; };
Self::parse(source) Self::parse(source)
} }
pub fn item(&self, path: &str) -> Option<&Item> {
let mut item = self.doc.as_item(); pub fn item(&self, path: &str) -> Option<&Value> {
for key in path.split('.') { self.doc.get(path)
item = if let Ok(index) = key.parse::<usize>() {
item.get(index)?
} else {
item.get(key)?
};
}
Some(item)
} }
pub fn string(&self, path: &str, fallback: &str) -> String { pub fn string(&self, path: &str, fallback: &str) -> String {
self.item(path) self.item(path)
.and_then(Item::as_str) .and_then(Value::as_str)
.unwrap_or(fallback) .unwrap_or(fallback)
.to_owned() .to_owned()
} }
pub fn number(&self, path: &str, fallback: f64) -> f64 { pub fn number(&self, path: &str, fallback: f64) -> f64 {
self.item(path) self.item(path).and_then(|v| v.as_f64()).unwrap_or(fallback)
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|v| v as f64)))
.unwrap_or(fallback)
} }
pub fn boolean(&self, path: &str, fallback: bool) -> bool { 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 { pub fn records(&self, path: &str) -> usize {
self.item(path) self.item(path)
.and_then(Item::as_array_of_tables) .and_then(Value::as_array)
.map_or(0, |a| a.len()) .map_or(0, |a| a.len())
} }
pub fn argv(&self, path: &str) -> String { pub fn argv(&self, path: &str) -> String {
self.item(path) self.item(path)
.and_then(Item::as_array) .and_then(Value::as_array)
.map(|a| { .map(|a| {
a.iter() a.iter()
.filter_map(Value::as_str) .filter_map(Value::as_str)
@@ -82,79 +81,26 @@ impl Snapshot {
}) })
.unwrap_or_default() .unwrap_or_default()
} }
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> { pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
match edit { match edit {
Edit::Set(path, value) => { Edit::Set(path, value) => self.doc.set(path, value.clone()),
let mut item = self.doc.as_item_mut(); Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
for key in path.split('.') { Edit::Remove(path, index) => self.doc.remove(path, *index),
if let Ok(index) = key.parse::<usize>() {
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);
}
} }
.map_err(|e| e.to_string())?;
self.source = self.doc.to_string(); self.source = self.doc.to_string();
Ok(()) 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)] #[derive(Clone, Debug)]
pub enum Edit { pub enum Edit {
Set(String, Value), Set(String, Value),
Add(String, Vec<(String, Value)>), Add(String, Vec<(String, Value)>),
Remove(String, usize), Remove(String, usize),
} }
pub fn set(path: &str, value: impl Into<Value>) -> Edit { pub fn set(path: &str, value: impl Into<Value>) -> Edit {
Edit::Set(path.to_owned(), value.into()) Edit::Set(path.to_owned(), value.into())
} }
@@ -164,7 +110,7 @@ pub fn config_path() -> PathBuf {
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
let path = directory.join("ferese/config.toml"); let path = directory.join("ferese/config.kdl");
path.canonicalize().unwrap_or(path) path.canonicalize().unwrap_or(path)
} }
@@ -187,6 +133,7 @@ fn validate(path: &Path) -> Result<(), String> {
.spawn() .spawn()
.map_err(|e| format!("Cannot validate configuration: {e}"))?; .map_err(|e| format!("Cannot validate configuration: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
loop { loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return if status.success() { return if status.success() {
@@ -199,11 +146,13 @@ fn validate(path: &Path) -> Result<(), String> {
.collect()) .collect())
}; };
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
return Err("Configuration validation timed out.".into()); return Err("Configuration validation timed out.".into());
} }
std::thread::sleep(Duration::from_millis(20)); 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<Snapshot, String> { pub fn save(path: &Path, expected: &str, desired: &str) -> Result<Snapshot, String> {
save_with(path, expected, desired, validate) save_with(path, expected, desired, validate)
} }
fn save_with( fn save_with(
path: &Path, path: &Path,
expected: &str, expected: &str,
@@ -251,7 +201,7 @@ fn save_with(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
backup.as_file().sync_all().map_err(|e| e.to_string())?; backup.as_file().sync_all().map_err(|e| e.to_string())?;
backup backup
.persist(path.with_extension("toml.settings-backup")) .persist(path.with_extension("kdl.settings-backup"))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
staged.persist(path).map_err(|e| e.to_string())?; staged.persist(path).map_err(|e| e.to_string())?;
Ok(snapshot) Ok(snapshot)
@@ -285,53 +235,68 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn edits_preserve_comments_custom_keys_and_other_rules() { 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(); let mut snapshot = Snapshot::parse(source.into()).unwrap();
snapshot.edit(&set("animations.speed", 0.8)).unwrap(); snapshot.edit(&set("animations.speed", 0.8)).unwrap();
assert!(snapshot.source.contains("# keep this")); assert!(snapshot.source.contains("// keep this"));
assert!(snapshot.source.contains("# personal theme")); assert!(snapshot.source.contains("// personal theme"));
assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine"); assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine");
assert!(snapshot.boolean("custom.future", false)); assert!(snapshot.boolean("custom.future", false));
} }
#[test] #[test]
fn rejects_conflicts_and_failed_validation_without_overwriting() { fn rejects_conflicts_and_failed_validation_without_overwriting() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml"); let path = dir.path().join("config.kdl");
fs::write(&path, "# external edit\n").unwrap(); fs::write(&path, "// external edit\n").unwrap();
assert!(save_with(&path, "", "[animations]\nspeed=1.0", |_| Ok(())).is_err()); assert!(save_with(&path, "", "animations {\n speed 1.0\n}\n", |_| Ok(())).is_err());
assert!( assert!(
save_with( save_with(
&path, &path,
"# external edit\n", "// external edit\n",
"[animations]\nspeed=1.0", "animations {\n speed 1.0\n}\n",
|_| Err("invalid".into()) |_| Err("invalid".into())
) )
.is_err() .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] #[test]
fn catches_edits_during_validation_and_keeps_a_backup_on_success() { fn catches_edits_during_validation_and_keeps_a_backup_on_success() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml"); let path = dir.path().join("config.kdl");
fs::write(&path, "# initial\n").unwrap(); fs::write(&path, "// initial\n").unwrap();
assert!( assert!(
save_with(&path, "# initial\n", "# desired\n", |_| { save_with(&path, "// initial\n", "// desired\n", |_| {
fs::write(&path, "# another editor\n").unwrap(); fs::write(&path, "// another editor\n").unwrap();
Ok(()) Ok(())
}) })
.is_err() .is_err()
); );
save_with(&path, "# another editor\n", "# desired\n", |_| Ok(())).unwrap(); save_with(&path, "// another editor\n", "// desired\n", |_| Ok(())).unwrap();
assert_eq!( assert_eq!(
fs::read_to_string(path.with_extension("toml.settings-backup")).unwrap(), fs::read_to_string(path.with_extension("kdl.settings-backup")).unwrap(),
"# another editor\n" "// another editor\n"
); );
assert_eq!(fs::read_to_string(path).unwrap(), "# desired\n"); assert_eq!(fs::read_to_string(path).unwrap(), "// desired\n");
} }
#[test] #[test]
fn nested_array_edits_and_login_items_roundtrip() { 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 snapshot
.edit(&set("output_profiles.0.outputs.0.scale", 1.75)) .edit(&set("output_profiles.0.outputs.0.scale", 1.75))
.unwrap(); .unwrap();
@@ -347,7 +312,8 @@ mod tests {
"command".into(), "command".into(),
["program", "argument with space"] ["program", "argument with space"]
.into_iter() .into_iter()
.collect::<toml_edit::Array>() .map(Value::from)
.collect::<Vec<_>>()
.into(), .into(),
), ),
("enabled".into(), false.into()), ("enabled".into(), false.into()),
@@ -362,10 +328,13 @@ mod tests {
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap(); snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0); assert_eq!(snapshot.records("autostart"), 0);
} }
#[test] #[test]
fn nested_note_lists_keep_existing_clock_settings() { fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot = let mut snapshot = Snapshot::parse(
Snapshot::parse("[desktop_widgets.clock]\nenabled = true\n".into()).unwrap(); "desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into(),
)
.unwrap();
snapshot snapshot
.edit(&Edit::Add( .edit(&Edit::Add(
"desktop_widgets.notes".into(), "desktop_widgets.notes".into(),
@@ -392,7 +361,7 @@ mod tests {
fn all_gui_controls_and_presets_pass_compositor_validation() { fn all_gui_controls_and_presets_pass_compositor_validation() {
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml"); let path = directory.path().join("config.kdl");
let validate = |snapshot: &Snapshot| { let validate = |snapshot: &Snapshot| {
fs::write(&path, &snapshot.source).unwrap(); fs::write(&path, &snapshot.source).unwrap();
let result = Command::new(&binary) let result = Command::new(&binary)
+19
View File
@@ -20,17 +20,21 @@ pub struct Palette {
pub muted: Color, pub muted: Color,
pub error: Color, pub error: Color,
} }
pub fn color(value: &str, fallback: Color) -> Color { pub fn color(value: &str, fallback: Color) -> Color {
let Some(value) = value.strip_prefix('#') else { let Some(value) = value.strip_prefix('#') else {
return fallback; return fallback;
}; };
if value.len() != 6 { if value.len() != 6 {
return fallback; return fallback;
} }
u32::from_str_radix(value, 16) u32::from_str_radix(value, 16)
.map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)) .map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8))
.unwrap_or(fallback) .unwrap_or(fallback)
} }
impl Palette { impl Palette {
pub fn from(snapshot: &Snapshot) -> Self { pub fn from(snapshot: &Snapshot) -> Self {
let base = color( let base = color(
@@ -57,6 +61,7 @@ impl Palette {
} }
} }
} }
fn mix(a: Color, b: Color, t: f32) -> Color { fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb( Color::from_rgb(
a.r + (b.r - a.r) * t, 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, a.b + (b.b - a.b) * t,
) )
} }
fn hex(c: Color) -> String { fn hex(c: Color) -> String {
format!( format!(
"#{:02x}{:02x}{:02x}", "#{:02x}{:02x}{:02x}",
@@ -72,6 +78,7 @@ fn hex(c: Color) -> String {
(c.b * 255.) as u8 (c.b * 255.) as u8
) )
} }
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| container::Style { theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)), background: Some(Background::Color(background)),
@@ -82,12 +89,15 @@ pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
..Default::default() ..Default::default()
}) })
} }
pub fn button_style(p: Palette, selected: bool) -> theme::Button { pub fn button_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, false) styled_button(p, selected, false)
} }
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, true) styled_button(p, selected, true)
} }
pub fn input_style(p: Palette) -> theme::TextInput { pub fn input_style(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { 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(), 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)), disabled: Box::new(move |_| appearance(false, false)),
} }
} }
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
let style = move |hover: bool| button::Style { let style = move |hover: bool| button::Style {
background: Some(Background::Color(if selected { background: Some(Background::Color(if selected {
@@ -131,6 +142,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
overlay: None, overlay: None,
shadow_offset: Vector::ZERO, shadow_offset: Vector::ZERO,
}; };
theme::Button::Custom { theme::Button::Custom {
active: Box::new(move |_, _| style(false)), active: Box::new(move |_, _| style(false)),
hovered: Box::new(move |_, _| style(true)), 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)), disabled: Box::new(move |_| style(false)),
} }
} }
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
let mut theme = cosmic::theme::COSMIC_DARK.clone(); let mut theme = cosmic::theme::COSMIC_DARK.clone();
if let Some(snapshot) = snapshot { 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)) cosmic::Theme::custom(std::sync::Arc::new(theme))
} }
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font { pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
static FONTS: std::sync::OnceLock< static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>, std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
@@ -165,15 +179,18 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
fonts.insert(family, font); fonts.insert(family, font);
font font
} }
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon { pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19) svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19)
} }
pub fn brand_icon() -> svg_icon::Icon { pub fn brand_icon() -> svg_icon::Icon {
svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice()) svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice())
.symbolic(false) .symbolic(false)
.icon() .icon()
.size(32) .size(32)
} }
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon { pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, hex(tint)).into_bytes()) svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, hex(tint)).into_bytes())
.symbolic(false) .symbolic(false)
@@ -199,6 +216,7 @@ pub fn preset(index: usize) -> Vec<Edit> {
set("theme.focus_ring.gradient.to", end), set("theme.focus_ring.gradient.to", end),
] ]
} }
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool { pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let (accent, base, text, muted, _) = PRESETS[index]; 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) .eq_ignore_ascii_case(value)
}) })
} }
pub fn swatches(index: usize) -> Element<'static, Message> { pub fn swatches(index: usize) -> Element<'static, Message> {
let (accent, base, _, _, end) = PRESETS[index]; let (accent, base, _, _, end) = PRESETS[index];
let mut row = widget::row([]).spacing(3); let mut row = widget::row([]).spacing(3);
+4 -4
View File
@@ -65,22 +65,22 @@ mod tests {
use cosmic::iced::futures::{StreamExt, pin_mut}; use cosmic::iced::futures::{StreamExt, pin_mut};
#[test] #[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() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml"); let path = directory.path().join("config.kdl");
std::fs::write(&path, "[animations]\nspeed = 1").unwrap(); std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
let stream = changes(&path); let stream = changes(&path);
pin_mut!(stream); pin_mut!(stream);
assert!(matches!( assert!(matches!(
stream.next().await, stream.next().await,
Some(Message::ExternalConfig(Ok(_))) 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"); let temporary = directory.path().join("replacement");
std::fs::write(&temporary, source).unwrap(); std::fs::write(&temporary, source).unwrap();
std::fs::rename(temporary, &path).unwrap(); std::fs::rename(temporary, &path).unwrap();
+1 -2
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version = "1.93" rust-version = "1.93"
[dependencies] [dependencies]
ferese-config = { path = "../../crates/ferese-config" }
ferese-core = { path = "../../crates/ferese-core" } ferese-core = { path = "../../crates/ferese-core" }
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
jiff.workspace = true jiff.workspace = true
@@ -14,8 +15,6 @@ jiff.workspace = true
libcosmic = { workspace = true, features = ["wgpu", "a11y"] } libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true serde.workspace = true
toml.workspace = true
toml_edit = "0.25.15"
tempfile.workspace = true tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt"] } tokio = { version = "1", features = ["time", "rt"] }
wayland-client.workspace = true wayland-client.workspace = true
+87 -42
View File
@@ -1,4 +1,3 @@
use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -174,11 +173,13 @@ impl Default for BarConfig {
} }
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PopoverConfig { struct PopoverConfig {
#[serde(default = "default_popover_opacity")] #[serde(default = "default_popover_opacity")]
opacity: f32, opacity: f32,
} }
impl Default for PopoverConfig { impl Default for PopoverConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -186,6 +187,7 @@ impl Default for PopoverConfig {
} }
} }
} }
const fn default_popover_opacity() -> f32 { const fn default_popover_opacity() -> f32 {
0.96 0.96
} }
@@ -299,13 +301,13 @@ pub(crate) fn load() -> ShellConfig {
} }
} }
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> { pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
match toml::from_str::<FereseConfig>(source) { match ferese_config::from_str::<FereseConfig>(source) {
Ok(config) => { Ok(config) => {
config config
.desktop_widgets .desktop_widgets
.validate() .validate()
.map_err(<toml::de::Error as serde::de::Error>::custom)?; .map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme); let mut theme = shell_theme(&config.theme);
theme.material_radius = theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); 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 { fn default_surface_base() -> String {
"#111821".to_owned() "#111821".to_owned()
} }
fn default_text_primary() -> String { fn default_text_primary() -> String {
"#F4F7FB".to_owned() "#F4F7FB".to_owned()
} }
fn default_text_muted() -> String { fn default_text_muted() -> String {
"#7F8A98".to_owned() "#7F8A98".to_owned()
} }
fn default_accent() -> String { fn default_accent() -> String {
"#5B8CFF".to_owned() "#5B8CFF".to_owned()
} }
fn default_border() -> String { fn default_border() -> String {
"#FFFFFF18".to_owned() "#FFFFFF18".to_owned()
} }
fn default_shadow() -> String { fn default_shadow() -> String {
"#00000055".to_owned() "#00000055".to_owned()
} }
const fn default_bar_height() -> f32 { const fn default_bar_height() -> f32 {
28.0 28.0
} }
const fn default_bar_window_gap() -> i32 { const fn default_bar_window_gap() -> i32 {
0 0
} }
const fn default_bar_margin_top() -> i32 { const fn default_bar_margin_top() -> i32 {
0 0
} }
const fn default_bar_margin_horizontal() -> i32 { const fn default_bar_margin_horizontal() -> i32 {
0 0
} }
const fn default_bar_radius() -> f32 { const fn default_bar_radius() -> f32 {
0.0 0.0
} }
const fn default_panel_padding() -> f32 { const fn default_panel_padding() -> f32 {
12.0 12.0
} }
const fn default_control_gap() -> f32 { const fn default_control_gap() -> f32 {
12.0 12.0
} }
const fn default_shadow_offset_y() -> f32 { const fn default_shadow_offset_y() -> f32 {
4.0 4.0
} }
const fn default_shadow_blur() -> f32 { const fn default_shadow_blur() -> f32 {
18.0 18.0
} }
const fn default_shadow_opacity() -> f32 { const fn default_shadow_opacity() -> f32 {
0.20 0.20
} }
@@ -466,42 +483,59 @@ pub(crate) const fn default_background() -> [u8; 3] {
} }
pub(crate) fn config_path() -> Option<PathBuf> { pub(crate) fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { ferese_config::config_path()
return Some(PathBuf::from(directory).join("ferese/config.toml"));
}
env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|home| home.join(".config/ferese/config.toml"))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn desktop_clock_parses_and_rejects_invalid_reload_values() { 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!(clock.enabled);
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight); assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
assert!(super::parse_source("[desktop_widgets.clock]\nopacity = 1.1").is_err()); assert!(
assert!(super::parse_source("[desktop_widgets.clock]\ntime_format = '%'").is_err()); 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::*; use super::*;
#[test] #[test]
fn parses_theme_without_rejecting_compositor_sections() { fn parses_theme_without_rejecting_compositor_sections() {
let config: FereseConfig = toml::from_str( let config: FereseConfig = ferese_config::from_str(
r#" r#"layout {
[layout] mode "scrolling"
mode = "scrolling" }
theme {
[theme.typography] typography {
font_family = "JetBrainsMono Nerd Font" font-family "JetBrainsMono Nerd Font"
}
[theme.background] background {
path = "/tmp/wallpaper.png" path "/tmp/wallpaper.png"
mode = "fit" mode "fit"
"#, }
}
"#,
) )
.unwrap(); .unwrap();
@@ -518,7 +552,7 @@ mod tests {
#[test] #[test]
fn defaults_to_the_ferese_visual_profile() { 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.typography.font_family, None);
assert_eq!(config.theme.background.path, None); assert_eq!(config.theme.background.path, None);
@@ -535,13 +569,17 @@ mod tests {
#[test] #[test]
fn bar_palette_can_be_changed_without_changing_popovers() { fn bar_palette_can_be_changed_without_changing_popovers() {
let config: FereseConfig = toml::from_str( let config: FereseConfig = ferese_config::from_str(
r##" r##"theme {
[theme.surface.bar] surface {
background = "#EAECEEDD" bar {
text_primary = "#222222" background "#EAECEEDD"
text_muted = "#666666" text-primary "#222222"
"##, text-muted "#666666"
}
}
}
"##,
) )
.unwrap(); .unwrap();
let theme = shell_theme(&config.theme); let theme = shell_theme(&config.theme);
@@ -553,13 +591,20 @@ mod tests {
#[test] #[test]
fn bar_transparency_and_shadow_opacity_are_independent() { fn bar_transparency_and_shadow_opacity_are_independent() {
let config: FereseConfig = toml::from_str( let config: FereseConfig = ferese_config::from_str(
r#" r#"theme {
[theme.surface.bar] surface {
opacity = 0.6 bar {
[theme.shadow.soft] opacity 0.6
opacity = 0.07 }
"#, }
shadow {
soft {
opacity 0.07
}
}
}
"#,
) )
.unwrap(); .unwrap();
let theme = shell_theme(&config.theme); let theme = shell_theme(&config.theme);
+5
View File
@@ -689,6 +689,7 @@ impl FereseShell {
} }
Task::batch(tasks) Task::batch(tasks)
} }
fn output_event( fn output_event(
&mut self, &mut self,
event: wayland::OutputEvent, event: wayland::OutputEvent,
@@ -2264,6 +2265,7 @@ impl EffectsBinding {
self.connection.flush()?; self.connection.flush()?;
Ok(()) Ok(())
} }
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> { fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
@@ -2349,6 +2351,7 @@ mod tests {
Point::new(200., 100.) Point::new(200., 100.)
); );
} }
#[test] #[test]
fn note_drag_origin_and_bounds_use_logical_output_coordinates() { fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
use cosmic::iced::Point; use cosmic::iced::Point;
@@ -2371,6 +2374,7 @@ mod tests {
Point::ORIGIN Point::ORIGIN
); );
} }
#[test] #[test]
fn desktop_clock_anchor_margins_match_only_anchored_edges() { fn desktop_clock_anchor_margins_match_only_anchored_edges() {
use ferese_core::desktop::{Anchor as Position, Clock}; use ferese_core::desktop::{Anchor as Position, Clock};
@@ -2396,6 +2400,7 @@ mod tests {
(0, 0, 0, 0) (0, 0, 0, 0)
); );
} }
#[test] #[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() { fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [ for (stamp, expected) in [
+44
View File
@@ -13,6 +13,7 @@ pub(crate) struct Settings {
reduced_motion: bool, reduced_motion: bool,
speed: f64, speed: f64,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -22,10 +23,12 @@ impl Default for Settings {
} }
} }
} }
impl Settings { impl Settings {
fn hover_duration(self) -> std::time::Duration { fn hover_duration(self) -> std::time::Duration {
self.duration(120.0) self.duration(120.0)
} }
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration { pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion { if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO; return std::time::Duration::ZERO;
@@ -52,6 +55,7 @@ pub(crate) struct PopupMotion {
started: Option<std::time::Instant>, started: Option<std::time::Instant>,
settings: Settings, settings: Settings,
} }
impl PopupMotion { impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) { pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -59,6 +63,7 @@ impl PopupMotion {
self.settings = settings; self.settings = settings;
self.started = Some(now); self.started = Some(now);
} }
pub(crate) fn new(settings: Settings) -> Self { pub(crate) fn new(settings: Settings) -> Self {
Self { Self {
start: 0.0, start: 0.0,
@@ -67,11 +72,13 @@ impl PopupMotion {
settings, settings,
} }
} }
pub(crate) fn begin(&mut self, now: std::time::Instant) { pub(crate) fn begin(&mut self, now: std::time::Instant) {
if self.started.is_none() { if self.started.is_none() {
self.started = Some(now); self.started = Some(now);
} }
} }
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self let duration = self
.settings .settings
@@ -89,15 +96,19 @@ impl PopupMotion {
} }
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t) self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
} }
pub(crate) fn progress(&self) -> f32 { pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now()) self.progress_at(std::time::Instant::now())
} }
pub(crate) fn closing(&self) -> bool { pub(crate) fn closing(&self) -> bool {
self.target == 0.0 self.target == 0.0
} }
pub(crate) fn animating(&self) -> bool { pub(crate) fn animating(&self) -> bool {
self.progress() != self.target self.progress() != self.target
} }
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) { pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now); self.start = self.progress_at(now);
self.target = target; self.target = target;
@@ -157,6 +168,7 @@ struct HoverState {
target: f32, target: f32,
started: Option<std::time::Instant>, started: Option<std::time::Instant>,
} }
impl HoverState { impl HoverState {
fn advance( fn advance(
&mut self, &mut self,
@@ -185,11 +197,13 @@ impl HoverState {
self.current != target self.current != target
} }
} }
struct Hover<'a, M> { struct Hover<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
progress: std::rc::Rc<std::cell::Cell<f32>>, progress: std::rc::Rc<std::cell::Cell<f32>>,
duration: std::time::Duration, duration: std::time::Duration,
} }
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> { impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
fn a11y_nodes( fn a11y_nodes(
&self, &self,
@@ -201,21 +215,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
.as_widget() .as_widget()
.a11y_nodes(layout, &tree.children[0], cursor) .a11y_nodes(layout, &tree.children[0], cursor)
} }
fn tag(&self) -> widget::tree::Tag { fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<HoverState>() widget::tree::Tag::of::<HoverState>()
} }
fn state(&self) -> widget::tree::State { fn state(&self) -> widget::tree::State {
widget::tree::State::new(HoverState::default()) widget::tree::State::new(HoverState::default())
} }
fn children(&self) -> Vec<widget::Tree> { fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)] vec![widget::Tree::new(&self.content)]
} }
fn diff(&mut self, tree: &mut widget::Tree) { fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content)); tree.diff_children(std::slice::from_mut(&mut self.content));
} }
fn size(&self) -> Size<Length> { fn size(&self) -> Size<Length> {
self.content.as_widget().size() self.content.as_widget().size()
} }
fn layout( fn layout(
&mut self, &mut self,
tree: &mut widget::Tree, tree: &mut widget::Tree,
@@ -226,6 +246,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
.as_widget_mut() .as_widget_mut()
.layout(&mut tree.children[0], renderer, limits) .layout(&mut tree.children[0], renderer, limits)
} }
fn update( fn update(
&mut self, &mut self,
tree: &mut widget::Tree, tree: &mut widget::Tree,
@@ -255,6 +276,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport, viewport,
); );
} }
fn draw( fn draw(
&self, &self,
tree: &widget::Tree, tree: &widget::Tree,
@@ -277,6 +299,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport, viewport,
); );
} }
fn mouse_interaction( fn mouse_interaction(
&self, &self,
tree: &widget::Tree, tree: &widget::Tree,
@@ -293,6 +316,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
renderer, renderer,
) )
} }
fn operate( fn operate(
&mut self, &mut self,
tree: &mut widget::Tree, tree: &mut widget::Tree,
@@ -304,6 +328,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
.as_widget_mut() .as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation); .operate(&mut tree.children[0], layout, renderer, operation);
} }
fn overlay<'a>( fn overlay<'a>(
&'a mut self, &'a mut self,
tree: &'a mut widget::Tree, tree: &'a mut widget::Tree,
@@ -333,37 +358,46 @@ pub fn animated<'a, M: 'a>(
regions, regions,
}) })
} }
struct Motion<'a, M> { struct Motion<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
progress: f32, progress: f32,
regions: Regions, regions: Regions,
} }
impl<M> Motion<'_, M> { impl<M> Motion<'_, M> {
fn translation(&self) -> Vector { fn translation(&self) -> Vector {
Vector::new(0.0, -4.0 * (1.0 - self.progress)) Vector::new(0.0, -4.0 * (1.0 - self.progress))
} }
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor { fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| { cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(p - self.translation()) mouse::Cursor::Available(p - self.translation())
}) })
} }
} }
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> { impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
fn tag(&self) -> widget::tree::Tag { fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag() self.content.as_widget().tag()
} }
fn state(&self) -> widget::tree::State { fn state(&self) -> widget::tree::State {
self.content.as_widget().state() self.content.as_widget().state()
} }
fn children(&self) -> Vec<widget::Tree> { fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children() self.content.as_widget().children()
} }
fn diff(&mut self, tree: &mut widget::Tree) { fn diff(&mut self, tree: &mut widget::Tree) {
self.content.as_widget_mut().diff(tree); self.content.as_widget_mut().diff(tree);
} }
fn size(&self) -> Size<Length> { fn size(&self) -> Size<Length> {
self.content.as_widget().size() self.content.as_widget().size()
} }
fn layout( fn layout(
&mut self, &mut self,
tree: &mut widget::Tree, tree: &mut widget::Tree,
@@ -372,6 +406,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
) -> layout::Node { ) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits) self.content.as_widget_mut().layout(tree, renderer, limits)
} }
fn update( fn update(
&mut self, &mut self,
tree: &mut widget::Tree, tree: &mut widget::Tree,
@@ -417,6 +452,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
tree, event, layout, cursor, renderer, clipboard, shell, viewport, tree, event, layout, cursor, renderer, clipboard, shell, viewport,
); );
} }
fn draw( fn draw(
&self, &self,
tree: &widget::Tree, tree: &widget::Tree,
@@ -441,6 +477,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
) )
}); });
} }
fn mouse_interaction( fn mouse_interaction(
&self, &self,
tree: &widget::Tree, tree: &widget::Tree,
@@ -457,6 +494,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
renderer, renderer,
) )
} }
fn operate( fn operate(
&mut self, &mut self,
tree: &mut widget::Tree, 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)) { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self); children(self);
} }
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("ferese-blur-card")) { if id == Some(&widget::Id::new("ferese-blur-card")) {
let contains = |outer: &Rectangle, inner: &Rectangle| { let contains = |outer: &Rectangle, inner: &Rectangle| {
@@ -524,6 +563,7 @@ mod tests {
0.0 0.0
); );
} }
#[test] #[test]
fn popup_reversal_and_motion_policy_are_consistent() { fn popup_reversal_and_motion_policy_are_consistent() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -560,6 +600,7 @@ mod tests {
assert_eq!(motion.progress_at(now), 0.0); assert_eq!(motion.progress_at(now), 0.0);
} }
} }
#[test] #[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() { fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -578,6 +619,7 @@ mod tests {
assert!(!hover.advance(0.0, start + duration * 12, duration)); assert!(!hover.advance(0.0, start + duration * 12, duration));
assert_eq!(hover.current, 0.0); assert_eq!(hover.current, 0.0);
} }
#[test] #[test]
fn hover_reversals_preserve_current_paint() { fn hover_reversals_preserve_current_paint() {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -591,6 +633,7 @@ mod tests {
hover.advance(0.0, now + duration, duration); hover.advance(0.0, now + duration, duration);
assert!(hover.current > 0.0 && hover.current < before); assert!(hover.current > 0.0 && hover.current < before);
} }
#[test] #[test]
fn hover_obeys_global_motion_policy() { fn hover_obeys_global_motion_policy() {
assert_eq!( assert_eq!(
@@ -617,6 +660,7 @@ mod tests {
assert_eq!(hover.current, 1.0); assert_eq!(hover.current, 1.0);
} }
} }
#[test] #[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() { fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new()); let mut collector = CollectRegions(Vec::new());
+48 -37
View File
@@ -12,48 +12,45 @@ pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> {
let canonical = path.canonicalize().map_err(|e| e.to_string())?; let canonical = path.canonicalize().map_err(|e| e.to_string())?;
let path = canonical.as_path(); let path = canonical.as_path();
let source = fs::read_to_string(path).map_err(|e| e.to_string())?; let source = fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut doc: toml_edit::DocumentMut = source let mut doc = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?;
.parse()
.map_err(|e: toml_edit::TomlError| e.to_string())?;
for edit in edits { for edit in edits {
if let Edit::ClockPosition(x, y) = edit { if let Edit::ClockPosition(x, y) = edit {
let clock = doc if doc.get("desktop_widgets.clock").is_none() {
.get_mut("desktop_widgets") return Err("Clock was removed from the config.".into());
.and_then(|item| item.get_mut("clock")) }
.ok_or("Clock was removed from the config.")?; for (field, value) in [
clock["anchor"] = toml_edit::value("top_left"); ("anchor", "top_left".into()),
clock["margin_x"] = toml_edit::value(i64::from(*x)); ("margin_x", i64::from(*x).into()),
clock["margin_y"] = toml_edit::value(i64::from(*y)); ("margin_y", i64::from(*y).into()),
] {
doc.set(&format!("desktop_widgets.clock.{field}"), value)
.map_err(|e| e.to_string())?;
}
continue; 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 { let id = match edit {
Edit::Text(id, _) | Edit::Position(id, _, _) => id, Edit::Text(id, _) | Edit::Position(id, _, _) => id,
Edit::ClockPosition(..) => unreachable!(), Edit::ClockPosition(..) => unreachable!(),
}; };
let note = notes let index = doc
.iter_mut() .get("desktop_widgets.notes")
.find(|note| { .and_then(|v| v.as_array())
note.get("id") .ok_or("Notes were removed from the config.")?
.and_then(toml_edit::Item::as_str) .iter()
.unwrap_or("note") .position(|note| note.get("id").and_then(|v| v.as_str()).unwrap_or("note") == id)
== id
})
.ok_or("This note no longer exists in the config.")?; .ok_or("This note no longer exists in the config.")?;
match edit { let fields = match edit {
Edit::Text(_, value) => { Edit::Text(_, value) => vec![("text", value.clone().into())],
note["text"] = toml_edit::value(value); Edit::Position(_, x, y) => vec![
} ("anchor", "top_left".into()),
Edit::Position(_, x, y) => { ("margin_x", i64::from(*x).into()),
note["anchor"] = toml_edit::value("top_left"); ("margin_y", i64::from(*y).into()),
note["margin_x"] = toml_edit::value(i64::from(*x)); ],
note["margin_y"] = toml_edit::value(i64::from(*y));
}
Edit::ClockPosition(..) => unreachable!(), 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(); let updated = doc.to_string();
@@ -87,10 +84,10 @@ mod tests {
#[test] #[test]
fn clock_position_saves_without_a_notes_table() { fn clock_position_saves_without_a_notes_table() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml"); let path = dir.path().join("config.kdl");
fs::write( fs::write(
&path, &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(); .unwrap();
save(&path, &[Edit::ClockPosition(120, 240)]).unwrap(); save(&path, &[Edit::ClockPosition(120, 240)]).unwrap();
@@ -104,11 +101,25 @@ mod tests {
); );
assert_eq!(config.desktop_widgets.clock.time_size, 80.); assert_eq!(config.desktop_widgets.clock.time_size, 80.);
} }
#[test] #[test]
fn note_edits_preserve_unrelated_config_and_multiline_content() { fn note_edits_preserve_unrelated_config_and_multiline_content() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml"); let path = dir.path().join("config.kdl");
fs::write(&path, "# keep this\n[theme.colors]\naccent = '#123456'\n[[desktop_widgets.notes]]\nid = 'a'\ntext = 'old'\n").unwrap(); fs::write(
&path,
r##"// keep this
theme {
colors {
accent "#123456"
}
}
desktop-widgets {
note id="a" text="old"
}
"##,
)
.unwrap();
save( save(
&path, &path,
&[ &[
@@ -118,7 +129,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let source = fs::read_to_string(&path).unwrap(); let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("# keep this")); assert!(source.contains("// keep this"));
assert!(source.contains("#123456")); assert!(source.contains("#123456"));
let notes = crate::config::parse_source(&source) let notes = crate::config::parse_source(&source)
.unwrap() .unwrap()
+23
View File
@@ -23,28 +23,33 @@ pub struct Snapshot {
pub reboot: bool, pub reboot: bool,
pub suspend: bool, pub suspend: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Network { pub struct Network {
pub enabled: bool, pub enabled: bool,
pub connection: Option<String>, pub connection: Option<String>,
pub signal: u8, pub signal: u8,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Bluetooth { pub struct Bluetooth {
pub enabled: bool, pub enabled: bool,
pub devices: Vec<String>, pub devices: Vec<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Audio { pub struct Audio {
pub volume: u8, pub volume: u8,
pub muted: bool, pub muted: bool,
pub output: String, pub output: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Battery { pub struct Battery {
pub percent: u8, pub percent: u8,
pub status: String, pub status: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Notifications { pub struct Notifications {
pub count: u32, pub count: u32,
@@ -65,22 +70,26 @@ pub enum Action {
Reboot, Reboot,
Suspend, Suspend,
} }
impl Action { impl Action {
fn key(&self) -> std::mem::Discriminant<Self> { fn key(&self) -> std::mem::Discriminant<Self> {
std::mem::discriminant(self) std::mem::discriminant(self)
} }
} }
pub struct Update { pub struct Update {
pub snapshot: Snapshot, pub snapshot: Snapshot,
pub generation: u64, pub generation: u64,
pub error: Option<String>, pub error: Option<String>,
} }
pub struct Service { pub struct Service {
settings: Arc<Mutex<Option<Vec<String>>>>, settings: Arc<Mutex<Option<Vec<String>>>>,
tx: SyncSender<(u64, Action)>, tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>, rx: Receiver<Update>,
pub generation: u64, pub generation: u64,
} }
impl Service { impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self { pub fn start(settings: Option<Vec<String>>) -> Self {
let live_settings = Arc::new(Mutex::new(settings)); let live_settings = Arc::new(Mutex::new(settings));
@@ -153,6 +162,7 @@ impl Service {
generation: 0, generation: 0,
} }
} }
pub fn send(&mut self, action: Action) -> Result<(), String> { pub fn send(&mut self, action: Action) -> Result<(), String> {
let generation = self.generation + 1; let generation = self.generation + 1;
self.tx self.tx
@@ -161,9 +171,11 @@ impl Service {
self.generation = generation; self.generation = generation;
Ok(()) Ok(())
} }
pub fn update_settings(&self, settings: Option<Vec<String>>) { pub fn update_settings(&self, settings: Option<Vec<String>>) {
*self.settings.lock().unwrap() = settings; *self.settings.lock().unwrap() = settings;
} }
pub fn poll(&self) -> Option<Update> { pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last() self.rx.try_iter().last()
} }
@@ -222,6 +234,7 @@ fn poll() -> Snapshot {
suspend: can_power("CanSuspend"), suspend: can_power("CanSuspend"),
} }
} }
fn network() -> Option<Network> { fn network() -> Option<Network> {
// No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi). // 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()?; let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?;
@@ -261,6 +274,7 @@ fn network() -> Option<Network> {
signal, signal,
}) })
} }
fn bluetooth() -> Option<Bluetooth> { fn bluetooth() -> Option<Bluetooth> {
let info = run("bluetoothctl", &["show"]).ok()?; let info = run("bluetoothctl", &["show"]).ok()?;
let enabled = info let enabled = info
@@ -282,6 +296,7 @@ fn bluetooth() -> Option<Bluetooth> {
}; };
Some(Bluetooth { enabled, devices }) Some(Bluetooth { enabled, devices })
} }
pub fn parse_audio(value: &str) -> Option<(u8, bool)> { pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
let volume = value let volume = value
.strip_prefix("Volume: ")? .strip_prefix("Volume: ")?
@@ -297,6 +312,7 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
value.contains("[MUTED]"), value.contains("[MUTED]"),
)) ))
} }
fn audio() -> Option<Audio> { fn audio() -> Option<Audio> {
let (volume, muted) = let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?; parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
@@ -312,11 +328,13 @@ fn audio() -> Option<Audio> {
output, output,
}) })
} }
fn read(path: &Path, name: &str) -> Option<String> { fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name)) fs::read_to_string(path.join(name))
.ok() .ok()
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
} }
fn battery() -> Option<Battery> { fn battery() -> Option<Battery> {
let entries = fs::read_dir("/sys/class/power_supply").ok()?; let entries = fs::read_dir("/sys/class/power_supply").ok()?;
// Prefer a system battery, not a mouse/headset battery. // Prefer a system battery, not a mouse/headset battery.
@@ -336,6 +354,7 @@ fn battery() -> Option<Battery> {
}) })
}) })
} }
fn brightness() -> Option<u8> { fn brightness() -> Option<u8> {
let max = run("brightnessctl", &["--class=backlight", "max"]) let max = run("brightnessctl", &["--class=backlight", "max"])
.ok()? .ok()?
@@ -348,6 +367,7 @@ fn brightness() -> Option<u8> {
(max > 0.0 && current.is_finite()) (max > 0.0 && current.is_finite())
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8) .then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
} }
fn notifications() -> Option<Notifications> { fn notifications() -> Option<Notifications> {
// Check service ownership first: swaync-client otherwise waits indefinitely. // Check service ownership first: swaync-client otherwise waits indefinitely.
let owner = run( let owner = run(
@@ -375,6 +395,7 @@ fn notifications() -> Option<Notifications> {
}; };
Some(Notifications { count, dnd }) Some(Notifications { count, dnd })
} }
fn can_power(method: &str) -> bool { fn can_power(method: &str) -> bool {
run( run(
"busctl", "busctl",
@@ -389,6 +410,7 @@ fn can_power(method: &str) -> bool {
) )
.is_ok_and(|s| s == "s \"yes\"" || s == "s \"challenge\"") .is_ok_and(|s| s == "s \"yes\"" || s == "s \"challenge\"")
} }
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
match action { match action {
Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]), Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]),
@@ -462,6 +484,7 @@ mod tests {
assert_eq!(parse_audio(value), None); assert_eq!(parse_audio(value), None);
} }
} }
#[test] #[test]
fn default_snapshot_does_not_invent_services() { fn default_snapshot_does_not_invent_services() {
let s = Snapshot::default(); let s = Snapshot::default();
+13
View File
@@ -19,6 +19,7 @@ pub enum Menu {
Notifications, Notifications,
System, System,
} }
impl Menu { impl Menu {
fn width(self) -> f32 { fn width(self) -> f32 {
match self { match self {
@@ -28,10 +29,12 @@ impl Menu {
Self::Audio | Self::Notifications => 290.0, Self::Audio | Self::Notifications => 290.0,
} }
} }
pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> { pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
// Popovers contain independent cards, with no shared material backing. // Popovers contain independent cards, with no shared material backing.
None None
} }
fn available(self, status: &Snapshot) -> bool { fn available(self, status: &Snapshot) -> bool {
match self { match self {
Self::Network => status.network.is_some(), Self::Network => status.network.is_some(),
@@ -52,10 +55,12 @@ pub struct OpenMenu {
pub effects: Option<EffectsBinding>, pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions, pub regions: super::motion::Regions,
} }
impl OpenMenu { impl OpenMenu {
pub fn progress(&self) -> f32 { pub fn progress(&self) -> f32 {
self.motion.progress() self.motion.progress()
} }
pub fn animating(&self) -> bool { pub fn animating(&self) -> bool {
self.motion.animating() self.motion.animating()
} }
@@ -115,6 +120,7 @@ impl FereseShell {
); );
destroy.chain(cosmic::task::message(cosmic::Action::Surface(action))) destroy.chain(cosmic::task::message(cosmic::Action::Surface(action)))
} }
pub fn destroy_menu(&mut self) -> Task<Message> { pub fn destroy_menu(&mut self) -> Task<Message> {
super::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed); super::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
self.menu.take().map_or_else(Task::none, |menu| { self.menu.take().map_or_else(Task::none, |menu| {
@@ -123,6 +129,7 @@ impl FereseShell {
)) ))
}) })
} }
pub fn close_menu(&mut self) -> Task<Message> { pub fn close_menu(&mut self) -> Task<Message> {
let Some(menu) = &mut self.menu else { let Some(menu) = &mut self.menu else {
return Task::none(); return Task::none();
@@ -142,6 +149,7 @@ impl FereseShell {
} }
self.destroy_menu() self.destroy_menu()
} }
pub fn animate_menu(&mut self) -> Task<Message> { pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu { if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects { if let Some(effects) = &menu.effects {
@@ -153,6 +161,7 @@ impl FereseShell {
} }
Task::none() Task::none()
} }
pub fn optimistic_status(&mut self, action: &Action) { pub fn optimistic_status(&mut self, action: &Action) {
match action { match action {
Action::Volume(v) => { Action::Volume(v) => {
@@ -184,6 +193,7 @@ impl FereseShell {
_ => {} _ => {}
} }
} }
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> { pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar(); let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(theme); let metrics = BarMetrics::from(theme);
@@ -861,6 +871,7 @@ fn menu_button<'a>(
opacity, opacity,
) )
} }
fn toggle_row<'a>( fn toggle_row<'a>(
label: &'a str, label: &'a str,
on: bool, on: bool,
@@ -987,6 +998,7 @@ fn audio_icon(volume: u8, muted: bool) -> &'static [u8] {
include_bytes!("../assets/icons/status/volume-high.svg") include_bytes!("../assets/icons/status/volume-high.svg")
} }
} }
fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
match kind { match kind {
Menu::Network => { Menu::Network => {
@@ -1111,6 +1123,7 @@ mod tests {
assert!(!Menu::Notifications.available(&s)); assert!(!Menu::Notifications.available(&s));
assert!(Menu::System.available(&s)); assert!(Menu::System.available(&s));
} }
#[test] #[test]
fn icons_follow_real_states() { fn icons_follow_real_states() {
let mut s = Snapshot::default(); let mut s = Snapshot::default();