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