GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git
feat: cce-core, the GUI-free half of the cce toolkit
Moved out of cce-ui, which re-exports every module at its old path:
config (the KDL config and its paths), input (input.kdl bindings and
settings), motion (the animations switch), units (lengths and the display
metric), ipc (the socket convention and ipc::instance), and the parsers
for the specs the DE writes — color (hex and sRGB), ramp (the ramp spec
and its monotone cubic), relief_spec, droplet (DropletSpec).
A process that does not draw — the compositor, a sync daemon, a CLI
helper — can depend on this crate alone and link none of the toolkit's
Wayland, Vulkan or text stack; the compositor linked all of it for its
config, bindings and two spec parsers.
The cfg(test) gates that keep a suite off the machine's config are
cfg(any(test, feature = "test-isolation")), and cce-ui turns the feature
on through its dev-dependency, so its suite stays isolated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.github/workflows/ci.yml | 19 +
.gitignore | 2 +
CLAUDE.md | 47 ++
Cargo.lock | 334 ++++++++++
Cargo.toml | 29 +
src/color.rs | 59 ++
src/config.rs | 1573 ++++++++++++++++++++++++++++++++++++++++++++++
src/droplet.rs | 193 ++++++
src/input.rs | 632 +++++++++++++++++++
src/ipc.rs | 299 +++++++++
src/ipc/instance.rs | 200 ++++++
src/lib.rs | 23 +
src/motion.rs | 128 ++++
src/ramp.rs | 105 ++++
src/relief_spec.rs | 143 +++++
src/units.rs | 424 +++++++++++++
16 files changed, 4210 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..02dc891
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,19 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+env:
+ CARGO_TERM_COLOR: always
+ RUSTFLAGS: -D warnings
+
+jobs:
+ test:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - run: cargo build --all-targets
+ - run: cargo test
+ - run: cargo test --features test-isolation
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c17da7f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..58417b1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,47 @@
+# CLAUDE.md
+
+This is the `cce-core` crate, the GUI-free half of the cce toolkit. Read the
+workspace guide `../cce-compositor/WORKSPACE.md` first: the multi-repo layout,
+the standalone-build rule, `ccebuild`, and the concurrent-sessions rules. This
+crate is SHARED like `cce-ui`. Run `git status` before editing it, and treat
+foreign dirt as another session's.
+
+## What lives here
+
+| Module | What it is |
+|---|---|
+| `config` | the KDL config: paths (`~/.config/cce`, XDG), reading, writing with rolling backups, the JSON view |
+| `input` | `input.kdl`: domain-scoped bindings and pointer settings |
+| `motion` | the DE-wide animations switch (`/run/cce/animations`) |
+| `units` | lengths with units (`(mm)2.0`) and the display metric |
+| `ipc` | the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` convention, `ipc::instance` (not wasm) |
+| `color`, `ramp`, `relief_spec`, `droplet` | the parsers for the specs the DE writes: hex colours, ramp curves, relief, droplets |
+
+`cce-ui` re-exports every module at its old path (`cce_ui::config`,
+`cce_ui::motion`, `cce_ui::scene::paint::DropletSpec`, `cce_ui::layout::sample_ramp_keys`,
+…), so apps never name this crate. A process that does not draw (the compositor,
+a sync daemon, a CLI helper) depends on it directly and links none of the
+toolkit's Wayland, Vulkan or text stack. Nothing here may depend on cce-ui; that
+is the point of the split.
+
+## The test-isolation feature
+
+Several functions answer differently under `cfg(test)`, so a suite never reads
+the machine. `config::config_home()` is a per-process directory nobody creates,
+`input::natural_scroll()` reads false, and `motion::enabled()` is on unless
+`motion::force_for_test` says otherwise. Since those modules live here,
+`cfg(test)` would only cover this crate's own suite. Every such gate is
+`cfg(any(test, feature = "test-isolation"))` instead, and cce-ui turns the
+feature on through its dev-dependency. Resolver 2 keeps it out of normal builds,
+and a dependent's test build that does not build cce-ui's dev-dependencies does
+not get it either, which is what those builds had before the split.
+
+## Build, test
+
+```sh
+cargo test -p cce-core
+cargo test -p cce-core --features test-isolation
+```
+
+The crate builds for `wasm32-unknown-unknown` too (minus `ipc`), as cce-ui does.
+After pushing, run `../bump-revs.sh cce-core` to repin cce-ui and the compositor.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..a63ebe2
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,334 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cce-core"
+version = "0.1.0"
+dependencies = [
+ "kdl",
+ "libc",
+ "log",
+ "serde_json",
+ "web-time",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kdl"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117"
+dependencies = [
+ "miette",
+ "nom",
+ "thiserror",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror",
+ "unicode-width",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..43b5972
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "cce-core"
+version = "0.1.0"
+edition = "2021"
+description = "The GUI-free half of the cce toolkit: config, input bindings, the animations switch, units, IPC and the DE's spec parsers"
+
+[lib]
+name = "cce_core"
+path = "src/lib.rs"
+
+[features]
+# What `cfg(test)` means inside this crate, for a DEPENDENT's test build: the
+# config home is a per-process directory nobody creates, natural scrolling
+# reads false and the animations switch is on unless a test forces it, so a
+# suite never reads the machine's ~/.config/cce or /run/cce. cce-ui turns it on
+# through its dev-dependency only (resolver 2 keeps dev-dependency features out
+# of normal builds), which is what keeps its suite isolated now that these
+# modules live here; a shipped binary never has it.
+test-isolation = []
+
+[dependencies]
+kdl = "4.6"
+serde_json = "1.0"
+log = "0.4"
+# `Instant` that works in the browser: std's panics on wasm32-unknown-unknown.
+web-time = "1"
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+libc = "0.2"
diff --git a/src/color.rs b/src/color.rs
new file mode 100644
index 0000000..82d58b6
--- /dev/null
+++ b/src/color.rs
@@ -0,0 +1,59 @@
+//! sRGB/linear conversion and the DE's hex colour strings — the pure half of
+//! `cce_ui::color`, which re-exports every item here.
+
+pub fn srgb_to_linear(c: f32) -> f32 {
+ if c <= 0.04045 {
+ c / 12.92
+ } else {
+ ((c + 0.055) / 1.055).powf(2.4)
+ }
+}
+
+/// Parse a hex color string into raw RGBA bytes.
+///
+/// Accepts `#RRGGBB` or `#RRGGBBAA`, tolerating surrounding quotes/whitespace and
+/// an optional leading `#`. 6-digit input yields alpha `255`. Returns `None` for
+/// any shorter/invalid input. This is the primitive the `[f32;_]` parsers build on.
+pub fn parse_hex_bytes(s: &str) -> Option<[u8; 4]> {
+ let hex = s
+ .trim_matches(|c| c == '"' || c == '\'' || c == ' ')
+ .trim_start_matches('#');
+ let b = |i: usize| u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok();
+ if hex.len() >= 8 {
+ Some([b(0)?, b(1)?, b(2)?, b(3)?])
+ } else if hex.len() >= 6 {
+ Some([b(0)?, b(1)?, b(2)?, 255])
+ } else {
+ None
+ }
+}
+
+/// Parse a hex color string into raw sRGB RGBA in `[0,1]` (no gamma conversion).
+/// See [`parse_hex_bytes`] for the accepted formats. Apply [`srgb_to_linear`] to
+/// the RGB channels yourself if your render target expects linear color.
+pub fn parse_hex_rgba(s: &str) -> Option<[f32; 4]> {
+ parse_hex_bytes(s).map(|[r, g, b, a]| {
+ [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0]
+ })
+}
+
+/// Like [`parse_hex_rgba`] but drops alpha, returning raw sRGB RGB in `[0,1]`.
+pub fn parse_hex_rgb(s: &str) -> Option<[f32; 3]> {
+ parse_hex_rgba(s).map(|[r, g, b, _]| [r, g, b])
+}
+
+/// Like [`parse_hex_rgba`] but converts RGB from sRGB to linear (alpha kept as-is).
+/// Use when your render target samples colors in linear space.
+pub fn parse_hex_rgba_linear(s: &str) -> Option<[f32; 4]> {
+ parse_hex_rgba(s).map(|[r, g, b, a]| {
+ [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), a]
+ })
+}
+
+pub fn linear_to_srgb(c: f32) -> f32 {
+ if c <= 0.0031308 {
+ c * 12.92
+ } else {
+ 1.055 * c.powf(1.0 / 2.4) - 0.055
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..5743b59
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,1573 @@
+use std::fs;
+use serde_json::Value;
+
+/// A unit-annotated number (`width=(mm)2.0`, `(px)9.3`, `(in)0.5`,
+/// `(pt)6`) becomes the JSON string `"2mm"` — the form
+/// `crate::units::Len::parse` reads — so the unit survives the JSON hop
+/// into the style registry, where it resolves against the live metric at
+/// every read. A bare number stays a number: a logical px, as always.
+fn unit_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> Option<serde_json::Value> {
+ let ty = entry.ty()?.value();
+ let len = crate::units::Len::from_annotated(value as f32, ty)?;
+ Some(serde_json::Value::String(len.serialize()))
+}
+
+fn int_entry_to_json(value: i64, entry: &kdl::KdlEntry) -> serde_json::Value {
+ unit_entry_to_json(value as f64, entry)
+ .unwrap_or_else(|| serde_json::Value::Number(serde_json::Number::from(value)))
+}
+
+fn float_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> serde_json::Value {
+ if let Some(v) = unit_entry_to_json(value, entry) {
+ return v;
+ }
+ let mut val_f = value;
+ if let Some(ty) = entry.ty() {
+ let ty_str = ty.value();
+ if let Some(range_str) = ty_str.strip_prefix("f64:") {
+ if let Some(dash_idx) = range_str.find('-') {
+ let min_str = range_str[..dash_idx].trim();
+ let max_str = range_str[dash_idx + 1..].trim();
+ if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
+ val_f = val_f.clamp(min_f, max_f);
+ }
+ }
+ }
+ }
+ match serde_json::Number::from_f64(val_f) {
+ Some(num) => serde_json::Value::Number(num),
+ None => serde_json::Value::Null,
+ }
+}
+
+fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
+ let mut map = serde_json::Map::new();
+ for node in doc.nodes() {
+ let name = node.name().value().to_string();
+
+ let mut node_map = serde_json::Map::new();
+ let mut has_props = false;
+ for entry in node.entries() {
+ if let Some(prop_name) = entry.name() {
+ has_props = true;
+ let j_val = match entry.value() {
+ kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
+ kdl::KdlValue::Base2(i) |
+ kdl::KdlValue::Base8(i) |
+ kdl::KdlValue::Base10(i) |
+ kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+ kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
+ kdl::KdlValue::String(s) |
+ kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
+ kdl::KdlValue::Null => serde_json::Value::Null,
+ };
+ node_map.insert(prop_name.value().to_string(), j_val);
+ }
+ }
+
+ let val = if name == "key_bindings" {
+ if let Some(children) = node.children() {
+ let mut binds = Vec::new();
+ for child in children.nodes() {
+ let mut child_map = serde_json::Map::new();
+ for entry in child.entries() {
+ if let Some(prop_name) = entry.name() {
+ let j_val = match entry.value() {
+ kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
+ kdl::KdlValue::Base2(i) |
+ kdl::KdlValue::Base8(i) |
+ kdl::KdlValue::Base10(i) |
+ kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+ kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
+ kdl::KdlValue::String(s) |
+ kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
+ kdl::KdlValue::Null => serde_json::Value::Null,
+ };
+ child_map.insert(prop_name.value().to_string(), j_val);
+ }
+ }
+ binds.push(serde_json::Value::Object(child_map));
+ }
+ serde_json::Value::Array(binds)
+ } else if has_props {
+ serde_json::Value::Object(node_map)
+ } else {
+ serde_json::Value::Null
+ }
+ } else {
+ let mut node_val = serde_json::Value::Null;
+ if has_props {
+ node_val = serde_json::Value::Object(node_map);
+ } else if node.entries().len() > 1
+ && node.entries().iter().all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
+ {
+ // A string LIST (`rounded_apps "a" "b"`) is an array, so the
+ // writer can put the args back. Joined into one string, as the
+ // numeric multi-arg case below still is (`(vec2i)100 200`),
+ // it came back as ONE quoted arg — `rounded_apps "a b"` — and
+ // a compositor allowlist silently matched nothing after
+ // every cce-data-editor save.
+ node_val = serde_json::Value::Array(
+ node.entries()
+ .iter()
+ .filter_map(|e| e.value().as_string().map(|s| serde_json::Value::String(s.to_string())))
+ .collect(),
+ );
+ } else if node.entries().len() > 1 {
+ let parts: Vec<String> = node.entries().iter().map(|entry| {
+ match entry.value() {
+ kdl::KdlValue::Bool(b) => b.to_string(),
+ kdl::KdlValue::Base2(i) |
+ kdl::KdlValue::Base8(i) |
+ kdl::KdlValue::Base10(i) |
+ kdl::KdlValue::Base16(i) => i.to_string(),
+ kdl::KdlValue::Base10Float(f) => f.to_string(),
+ kdl::KdlValue::String(s) |
+ kdl::KdlValue::RawString(s) => s.clone(),
+ kdl::KdlValue::Null => "null".to_string(),
+ }
+ }).collect();
+ node_val = serde_json::Value::String(parts.join(" "));
+ } else if let Some(entry) = node.entries().first() {
+ node_val = match entry.value() {
+ kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
+ kdl::KdlValue::Base2(i) |
+ kdl::KdlValue::Base8(i) |
+ kdl::KdlValue::Base10(i) |
+ kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+ kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
+ kdl::KdlValue::String(s) |
+ kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
+ kdl::KdlValue::Null => serde_json::Value::Null,
+ };
+ }
+
+ if let Some(children) = node.children() {
+ let children_val = kdl_to_json(children);
+ if let serde_json::Value::Object(children_map) = children_val {
+ if let serde_json::Value::Object(mut nm) = node_val {
+ for (k, v) in children_map {
+ nm.insert(k, v);
+ }
+ serde_json::Value::Object(nm)
+ } else {
+ serde_json::Value::Object(children_map)
+ }
+ } else {
+ node_val
+ }
+ } else {
+ node_val
+ }
+ };
+
+ if let Some(existing) = map.remove(&name) {
+ match existing {
+ serde_json::Value::Array(mut arr) => {
+ match val {
+ serde_json::Value::Array(new_arr) => {
+ arr.extend(new_arr);
+ }
+ _ => {
+ arr.push(val);
+ }
+ }
+ map.insert(name, serde_json::Value::Array(arr));
+ }
+ other => {
+ match val {
+ serde_json::Value::Array(new_arr) => {
+ let mut combined = vec![other];
+ combined.extend(new_arr);
+ map.insert(name, serde_json::Value::Array(combined));
+ }
+ _ => {
+ map.insert(name, serde_json::Value::Array(vec![other, val]));
+ }
+ }
+ }
+ }
+ } else {
+ let list_names = ["key_bindings", "pointer_bind", "gesture_bind", "mode_rule", "tag_layout", "startup", "device"];
+ if list_names.contains(&name.as_str()) {
+ match val {
+ serde_json::Value::Array(_) => {
+ map.insert(name, val);
+ }
+ _ => {
+ map.insert(name, serde_json::Value::Array(vec![val]));
+ }
+ }
+ } else {
+ map.insert(name, val);
+ }
+ }
+ }
+ serde_json::Value::Object(map)
+}
+
+/// The calling app's name — the basename of its executable — used to locate
+/// its per-app config (`~/.config/cce/<name>/config.kdl`) and its `input.kdl`
+/// domain.
+///
+/// Derived from `/proc/self/exe` once per process, which the kernel renders as
+/// `<path> (deleted)` once the binary on disk has been replaced (`ccebuild
+/// install` unlinks before writing). Without the strip, a still-running client
+/// would resolve its override to `~/.config/cce/<name> (deleted)/config.kdl`
+/// on its next config reload and silently lose the whole file — the status
+/// bar's droplet bubbles reverted to square boxes this way on 2026-09-03.
+///
+/// Cached because every config getter reaches it (through
+/// [`config_files_modified`]), many of them per frame; the stripped basename
+/// cannot change for the life of the process.
+pub fn get_app_name() -> Option<String> {
+ app_name().map(str::to_string)
+}
+
+/// [`get_app_name`] without the allocation.
+fn app_name() -> Option<&'static str> {
+ static NAME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
+ NAME.get_or_init(|| {
+ std::env::current_exe()
+ .ok()
+ .and_then(|p| p.file_name().and_then(|s| s.to_str().map(app_name_from_exe_basename)))
+ })
+ .as_deref()
+}
+
+/// [`get_app_name`]'s normalization: the kernel's ` (deleted)` marker on an
+/// unlinked executable is not part of the name.
+fn app_name_from_exe_basename(basename: &str) -> String {
+ basename.strip_suffix(" (deleted)").unwrap_or(basename).to_string()
+}
+
+pub fn get_app_config_path(app_name: &str) -> std::path::PathBuf {
+ get_config_path().parent().unwrap().join(app_name).join("config.kdl")
+}
+
+fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
+ match (a, b) {
+ (serde_json::Value::Object(a_map), serde_json::Value::Object(b_map)) => {
+ for (k, v) in b_map {
+ if !v.is_null() {
+ merge_json(a_map.entry(k.clone()).or_insert(serde_json::Value::Null), v);
+ }
+ }
+ }
+ (a_val, b_val) => {
+ *a_val = b_val.clone();
+ }
+ }
+}
+
+pub fn parse_kdl_to_json(content: &str) -> serde_json::Value {
+ let mut main_val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
+ kdl_to_json(&doc)
+ } else {
+ serde_json::json!({})
+ };
+
+ if let Some(app_name) = app_name() {
+ let app_path = get_app_config_path(app_name);
+ if let Ok(override_content) = std::fs::read_to_string(&app_path) {
+ if let Ok(override_doc) = override_content.parse::<kdl::KdlDocument>() {
+ let override_val = kdl_to_json(&override_doc);
+ merge_json(&mut main_val, &override_val);
+ }
+ }
+ }
+
+ main_val
+}
+
+pub fn update_json_in_memory(val_obj: &mut Value, key: &str, value: &str, default_section: &str) -> bool {
+ let j_val = if let Ok(parsed_val) = serde_json::from_str::<Value>(value) {
+ parsed_val
+ } else {
+ serde_json::json!(value)
+ };
+
+ let mut updated = false;
+ if let Some(obj) = val_obj.as_object_mut() {
+ for (_sec_name, sec_val) in obj.iter_mut() {
+ if let Some(sec_obj) = sec_val.as_object_mut() {
+ if sec_obj.contains_key(key) {
+ sec_obj.insert(key.to_string(), j_val.clone());
+ updated = true;
+ break;
+ }
+ }
+ }
+ if !updated {
+ if let Some(sec_obj) = obj.get_mut(default_section).and_then(|s| s.as_object_mut()) {
+ sec_obj.insert(key.to_string(), j_val);
+ updated = true;
+ } else {
+ let mut map = serde_json::Map::new();
+ map.insert(key.to_string(), j_val);
+ obj.insert(default_section.to_string(), Value::Object(map));
+ updated = true;
+ }
+ }
+ }
+ updated
+}
+
+pub fn parse_config_path(key: &str, default_section: &str) -> (String, String, Option<String>) {
+ let parts: Vec<&str> = key.split('.').collect();
+ if parts.len() == 3 {
+ (parts[0].to_string(), parts[1].to_string(), Some(parts[2].to_string()))
+ } else if parts.len() == 2 {
+ (parts[0].to_string(), parts[1].to_string(), None)
+ } else {
+ (default_section.to_string(), key.to_string(), None)
+ }
+}
+
+const PROP_NODES: &[&str] = &[
+ "gestures", "key_bindings", "pointer_bind", "gesture_bind",
+ "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
+ "status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
+ "menubar", "statusbar", "node", "relief", "frost", "finish",
+ // The relief's two shapes (`relief { wall height=… profile=… knobs=… ; edge … }`).
+ "wall", "edge"
+];
+
+fn get_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
+ if path.is_empty() {
+ return None;
+ }
+ let idx = doc.nodes().iter().position(|n| n.name().value() == path[0])?;
+ let node = &mut doc.nodes_mut()[idx];
+ if path.len() == 1 {
+ Some(node)
+ } else {
+ get_node_mut(node.children_mut().as_mut()?, &path[1..])
+ }
+}
+
+/// Remove `key` from the document — a property off its `PROP_NODES` node, or
+/// the whole node otherwise — creating nothing on the way. `true` when
+/// something was removed; a key that is not there is `false`, so a writer
+/// can tell "migrated" from "was already clean".
+pub fn remove_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str) -> bool {
+ let parts: Vec<&str> = key.split('.').collect();
+ let Some(leaf) = parts.last().copied() else {
+ return false;
+ };
+ // A PROP_NODES parent holds its keys as properties — but it may also
+ // hold child NODES (`relief { wall … }`), so a leaf that is not among
+ // the properties is looked for among the children before giving up.
+ let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
+ if is_property {
+ let Some(node) = get_node_mut(doc, &parts[..parts.len() - 1]) else {
+ return false;
+ };
+ let before = node.entries().len();
+ node.entries_mut().retain(|e| e.name().map(|n| n.value()) != Some(leaf));
+ if node.entries().len() != before {
+ return true;
+ }
+ }
+ let parent: &mut kdl::KdlDocument = if parts.len() == 1 {
+ doc
+ } else {
+ match get_node_mut(doc, &parts[..parts.len() - 1]).and_then(|n| n.children_mut().as_mut()) {
+ Some(children) => children,
+ None => return false,
+ }
+ };
+ let before = parent.nodes().len();
+ parent.nodes_mut().retain(|n| n.name().value() != leaf);
+ parent.nodes().len() != before
+}
+
+fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
+ if path.is_empty() {
+ return None;
+ }
+ let segment = path[0];
+ let idx = if let Some(i) = doc.nodes().iter().position(|n| n.name().value() == segment) {
+ i
+ } else {
+ let new_node = format!("{}\n", segment).parse::<kdl::KdlNode>().ok()?;
+ doc.nodes_mut().push(new_node);
+ doc.nodes().len() - 1
+ };
+ if path.len() == 1 {
+ Some(&mut doc.nodes_mut()[idx])
+ } else {
+ let children = doc.nodes_mut()[idx].ensure_children();
+ get_or_create_node_mut(children, &path[1..])
+ }
+}
+
+fn get_node_ref<'a>(doc: &'a kdl::KdlDocument, path: &[&str]) -> Option<&'a kdl::KdlNode> {
+ if path.is_empty() {
+ return None;
+ }
+ let segment = path[0];
+ let node = doc.nodes().iter().find(|n| n.name().value() == segment)?;
+ if path.len() == 1 {
+ Some(node)
+ } else {
+ let children = node.children()?;
+ get_node_ref(children, &path[1..])
+ }
+}
+
+pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str, default_section: &str) -> bool {
+ update_kdl_in_memory_typed(doc, key, value, default_section, None)
+}
+
+/// [`update_kdl_in_memory`] with an explicit type annotation for the written
+/// entry. `forced_ty` overrides both the value-shape inference and the
+/// preserved existing annotation — how a writer ESTABLISHES a custom type
+/// (e.g. cce-relief writing `(bevel)` knob keys into a config that never had
+/// them; preservation alone can't create the annotation).
+pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value: &str, _default_section: &str, forced_ty: Option<&str>) -> bool {
+ let parts: Vec<&str> = key.split('.').collect();
+ if parts.is_empty() {
+ return false;
+ }
+
+ let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
+
+ let (node_path, target_prop) = if is_property {
+ (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
+ } else {
+ (&parts[0..parts.len()], None)
+ };
+
+ let child_node = if let Some(node) = get_or_create_node_mut(doc, node_path) {
+ node
+ } else {
+ return false;
+ };
+
+ let existing_ty = if let Some(ref prop_name) = target_prop {
+ child_node.entries().iter()
+ .find(|e| e.name().map(|n| n.value()) == Some(prop_name))
+ .and_then(|e| e.ty().map(|t| t.value().to_string()))
+ } else {
+ child_node.entries().first()
+ .and_then(|e| e.ty().map(|t| t.value().to_string()))
+ };
+
+ let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
+ (kdl::KdlValue::Bool(b), Some("bool".to_string()))
+ } else if let Some(len) = crate::units::Len::parse(value) {
+ // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
+ // stays a number the editor's spinbox can step.
+ (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
+ } else if value.starts_with('#') {
+ let s_clean = value.trim_start_matches('#');
+ let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
+ (kdl::KdlValue::String(value.to_string()), Some(ty.to_string()))
+ } else if value.contains('.') {
+ if let Ok(f) = value.parse::<f64>() {
+ (kdl::KdlValue::Base10Float(f), Some("f64".to_string()))
+ } else {
+ (kdl::KdlValue::String(value.to_string()), None)
+ }
+ } else if let Ok(i) = value.parse::<i64>() {
+ (kdl::KdlValue::Base10(i), Some("i64".to_string()))
+ } else {
+ let s = value.trim_matches('"').to_string();
+ (kdl::KdlValue::String(s), None)
+ };
+
+ if let Some(ref ext_ty) = existing_ty {
+ if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" || ext_ty == "bevel" || ext_ty == "keybind" {
+ kdl_ty = Some(ext_ty.clone());
+ }
+ // A bare number written over a unit-annotated slot keeps the unit:
+ // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
+ // to logical px.
+ if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().map_or(true, |t| t == "f64" || t == "i64") {
+ kdl_ty = Some(ext_ty.clone());
+ }
+ }
+ if let Some(f) = forced_ty {
+ kdl_ty = Some(f.to_string());
+ }
+
+ if let Some(prop_name) = target_prop {
+ let mut found = false;
+ for entry in child_node.entries_mut() {
+ if let Some(id) = entry.name() {
+ if id.value() == prop_name {
+ *entry = kdl::KdlEntry::new_prop(prop_name.clone(), kdl_val.clone());
+ if let Some(ref ty) = kdl_ty {
+ entry.set_ty(ty.as_str());
+ }
+ found = true;
+ break;
+ }
+ }
+ }
+ if !found {
+ let mut entry = kdl::KdlEntry::new_prop(prop_name, kdl_val);
+ if let Some(ref ty) = kdl_ty {
+ entry.set_ty(ty.as_str());
+ }
+ child_node.entries_mut().push(entry);
+ }
+ } else {
+ child_node.entries_mut().clear();
+ if kdl_ty.as_deref() == Some("vec2i") {
+ let parts: Vec<&str> = value.split_whitespace().collect();
+ for (idx, part) in parts.iter().enumerate() {
+ if let Ok(i) = part.parse::<i64>() {
+ let mut entry = kdl::KdlEntry::new(kdl::KdlValue::Base10(i));
+ if idx == 0 {
+ entry.set_ty("vec2i");
+ }
+ child_node.entries_mut().push(entry);
+ }
+ }
+ } else {
+ let mut entry = kdl::KdlEntry::new(kdl_val);
+ if let Some(ref ty) = kdl_ty {
+ entry.set_ty(ty.as_str());
+ }
+ child_node.entries_mut().push(entry);
+ }
+ }
+
+ true
+}
+
+/// XDG config base directory: `$XDG_CONFIG_HOME`, else `~/.config`.
+///
+/// Under this crate's own tests it is a per-process directory nobody
+/// creates, so every config read (`config.kdl`, a per-app override,
+/// `input.kdl`) finds no file and the toolkit's defaults are what a test
+/// sees. Until 2026-10-07 the tests read the machine's `~/.config/cce`:
+/// six of them failed on the user's desktop (a `relief edge height` pinned
+/// the plate rise the heightfield tests set; the frost and blur keys moved
+/// the material ones) and passed on a machine without that config. A test
+/// that wants a configuration loads it from a string (`reload_colors`).
+/// Dependents' tests link the non-test build and are unaffected.
+pub fn config_home() -> std::path::PathBuf {
+ #[cfg(any(test, feature = "test-isolation"))]
+ {
+ return std::env::temp_dir().join(format!("cce-ui-test-config-{}", std::process::id()));
+ }
+ #[allow(unreachable_code)]
+ match std::env::var("XDG_CONFIG_HOME") {
+ Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
+ _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"),
+ }
+}
+
+/// XDG data base directory: `$XDG_DATA_HOME`, else `~/.local/share`.
+pub fn data_home() -> std::path::PathBuf {
+ match std::env::var("XDG_DATA_HOME") {
+ Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
+ _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
+ .join(".local")
+ .join("share"),
+ }
+}
+
+/// XDG state base directory: `$XDG_STATE_HOME`, else `~/.local/state`.
+///
+/// State is what should survive a restart but is not worth backing up or
+/// syncing like data, and is not the user's to edit like config: history,
+/// open tabs, logs, a "never ask again" list. A relative `$XDG_STATE_HOME`
+/// counts as unset, as the XDG spec says it must (an empty one too).
+pub fn state_home() -> std::path::PathBuf {
+ match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
+ Some(x) if x.is_absolute() => x,
+ _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
+ .join(".local")
+ .join("state"),
+ }
+}
+
+/// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
+///
+/// Unlike the other bases there is no `~/...` fallback to construct: the
+/// runtime dir is `/run/user/UID`, created by pam_systemd at login and mode
+/// 0700. An unset variable means we are outside a login session, where the
+/// shared temp dir is the honest answer rather than a path we would have to
+/// invent (and could not create with the right ownership anyway).
+pub fn runtime_dir() -> std::path::PathBuf {
+ match std::env::var("XDG_RUNTIME_DIR") {
+ Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
+ _ => std::env::temp_dir(),
+ }
+}
+
+/// The cce config directory (`<config_home>/cce`).
+pub fn cce_config_dir() -> std::path::PathBuf {
+ config_home().join("cce")
+}
+
+/// The cce state directory (`<state_home>/cce`); each app keeps its own
+/// subdirectory under it (`cce/browser`, `cce/mail`, …). Not created here:
+/// a caller creates the subdirectory it writes, with the mode it needs.
+pub fn cce_state_dir() -> std::path::PathBuf {
+ state_home().join("cce")
+}
+
+/// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
+///
+/// Session-scoped files — logs, sockets, pid files — belong here rather than
+/// in `/tmp`, which is one namespace shared by every user on the machine: a
+/// fixed `/tmp/cce-*.log` is a path the first user to log in owns, and the
+/// sticky bit then denies everyone else. Creating on demand keeps call sites
+/// to one line; a failure surfaces when the caller opens its file, which it
+/// already has to handle.
+pub fn cce_runtime_dir() -> std::path::PathBuf {
+ let dir = runtime_dir().join("cce");
+ let _ = std::fs::create_dir_all(&dir);
+ dir
+}
+
+pub fn get_config_path() -> std::path::PathBuf {
+ cce_config_dir().join("config.kdl")
+}
+
+struct ConfigCache {
+ last_modified: Option<std::time::SystemTime>,
+ /// Shared, not cloned per read: the getters below run many times a frame,
+ /// and a deep copy of the whole tree to read one key was most of their cost.
+ parsed: Option<std::sync::Arc<serde_json::Value>>,
+ raw_content: String,
+}
+
+static CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
+ last_modified: None,
+ parsed: None,
+ raw_content: String::new(),
+});
+
+/// The cce config parsed to JSON, cached on the config file's mtime (per process).
+/// Re-reads and re-parses only when `get_config_path()`'s modification time changes.
+/// The newest mtime across the shared config and the calling app's override
+/// file — the cache key for [`cached_config`], and what apps should poll for
+/// live-reload triggers. Compared by EQUALITY, so an app-file deletion (max
+/// drops back to the shared mtime) also invalidates.
+pub fn config_files_modified() -> Option<std::time::SystemTime> {
+ let shared = std::fs::metadata(get_config_path()).ok().and_then(|m| m.modified().ok());
+ let app = app_name()
+ .and_then(|n| std::fs::metadata(get_app_config_path(n)).ok())
+ .and_then(|m| m.modified().ok());
+ match (shared, app) {
+ (Some(a), Some(b)) => Some(a.max(b)),
+ (a, b) => a.or(b),
+ }
+}
+
+pub fn cached_config() -> serde_json::Value {
+ (*cached_config_arc()).clone()
+}
+
+/// [`cached_config`] without the copy — what every getter here reads.
+pub fn cached_config_arc() -> std::sync::Arc<serde_json::Value> {
+ let path = get_config_path();
+ // Both files participate in the parse (parse_kdl_to_json merges the
+ // per-app override), so both participate in the cache key.
+ let current_modified = config_files_modified();
+
+ if let Ok(cache) = CONFIG_CACHE.read() {
+ if cache.last_modified.is_some() && cache.last_modified == current_modified {
+ if let Some(ref val) = cache.parsed {
+ return val.clone();
+ }
+ }
+ }
+
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
+ let val = std::sync::Arc::new(parse_kdl_to_json(&content));
+ if let Ok(mut cache) = CONFIG_CACHE.write() {
+ cache.last_modified = current_modified;
+ cache.parsed = Some(val.clone());
+ cache.raw_content = content;
+ }
+ val
+}
+
+/// The raw text of the cce config, cached alongside [`cached_config`].
+pub fn cached_config_content() -> String {
+ let _ = cached_config_arc();
+ CONFIG_CACHE.read().map(|c| c.raw_content.clone()).unwrap_or_default()
+}
+
+static SHARED_CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
+ last_modified: None,
+ parsed: None,
+ raw_content: String::new(),
+});
+
+/// The SHARED config alone — the per-app override is deliberately NOT merged.
+/// For values that describe something outside the app (the compositor's
+/// window silhouette radius), where an app-local override restyles the app
+/// but must not desynchronize it from the DE. Mtime-cached like
+/// [`cached_config`].
+pub fn cached_shared_config() -> serde_json::Value {
+ (*cached_shared_config_arc()).clone()
+}
+
+/// [`cached_shared_config`] without the copy.
+pub fn cached_shared_config_arc() -> std::sync::Arc<serde_json::Value> {
+ let path = get_config_path();
+ let current_modified = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
+
+ if let Ok(cache) = SHARED_CONFIG_CACHE.read() {
+ if cache.last_modified.is_some() && cache.last_modified == current_modified {
+ if let Some(ref val) = cache.parsed {
+ return val.clone();
+ }
+ }
+ }
+
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
+ let val = std::sync::Arc::new(if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
+ kdl_to_json(&doc)
+ } else {
+ serde_json::json!({})
+ });
+ if let Ok(mut cache) = SHARED_CONFIG_CACHE.write() {
+ cache.last_modified = current_modified;
+ cache.parsed = Some(val.clone());
+ cache.raw_content = content;
+ }
+ val
+}
+
+/// Read an i64 at `pointer` from the SHARED config only (no per-app merge),
+/// or `default` — see [`cached_shared_config`].
+pub fn get_i64_shared(pointer: &str, default: i64) -> i64 {
+ cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
+}
+
+/// [`get_i64_shared`] without a default — for canonical-first alias chains
+/// (RFC Phase 7a) where absence must fall through to the next spelling.
+pub fn get_i64_shared_opt(pointer: &str) -> Option<i64> {
+ cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64())
+}
+
+// ── Typed accessors over the cached config ──────────────────────────────────
+// Each reads the mtime-cached config and extracts a value at a JSON pointer
+// (e.g. "/notifications/enable"), returning the default when absent or mistyped.
+
+/// Read a boolean at `pointer` from the cached config, or `default`.
+pub fn get_bool(pointer: &str, default: bool) -> bool {
+ cached_config_arc().pointer(pointer).and_then(|v| v.as_bool()).unwrap_or(default)
+}
+
+/// Read an f32 at `pointer` from the cached config, or `default`.
+pub fn get_f32(pointer: &str, default: f32) -> f32 {
+ cached_config_arc()
+ .pointer(pointer)
+ .and_then(|v| v.as_f64())
+ .map(|f| f as f32)
+ .unwrap_or(default)
+}
+
+/// Read an i64 at `pointer` from the cached config, or `default`.
+pub fn get_i64(pointer: &str, default: i64) -> i64 {
+ cached_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
+}
+
+/// Read a string at `pointer` from the cached config.
+pub fn get_string(pointer: &str) -> Option<String> {
+ cached_config_arc().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
+}
+
+/// Read a hex color string at `pointer` and parse it to raw sRGB RGBA (`[0,1]`).
+/// Apply [`crate::color::srgb_to_linear`] if your render target expects linear.
+pub fn get_color(pointer: &str) -> Option<[f32; 4]> {
+ get_string(pointer).as_deref().and_then(crate::color::parse_hex_rgba)
+}
+
+/// Recursively search a JSON value for the first entry whose object key equals
+/// `key`, returning a reference to its value. Depth-first over objects and arrays.
+pub fn find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
+ match val {
+ serde_json::Value::Object(map) => {
+ if let Some(found) = map.get(key) {
+ return Some(found);
+ }
+ for v in map.values() {
+ if let Some(found) = find_key(v, key) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_key(v, key)),
+ _ => None,
+ }
+}
+
+
+
+fn perform_rolling_backup(path: &str) {
+ let config_path = get_config_path();
+ if std::path::Path::new(path) != config_path {
+ return;
+ }
+ if !std::path::Path::new(path).exists() {
+ return;
+ }
+ let backup_dir = config_path.parent().unwrap().join("backups");
+ if let Err(_) = fs::create_dir_all(&backup_dir) {
+ return;
+ }
+ for i in (1..=4).rev() {
+ let src = backup_dir.join(format!("config.kdl.{}.bak", i));
+ let dst = backup_dir.join(format!("config.kdl.{}.bak", i + 1));
+ if src.exists() {
+ let _ = fs::rename(src, dst);
+ }
+ }
+ let dst = backup_dir.join("config.kdl.1.bak");
+ let _ = fs::copy(path, dst);
+}
+
+pub(crate) fn safe_write(path: &str, content: &str) -> bool {
+ perform_rolling_backup(path);
+ if let Some(parent) = std::path::Path::new(path).parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ let temp_path = format!("{}.tmp", path);
+ if fs::write(&temp_path, content).is_ok() {
+ if fs::rename(&temp_path, path).is_ok() {
+ return true;
+ }
+ let _ = fs::remove_file(&temp_path);
+ }
+ false
+}
+
+pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &str) -> bool {
+ write_config_value_typed(path, key, value, default_section, None)
+}
+
+/// Remove `key` from the config file at `path` ([`remove_kdl_in_memory`]),
+/// writing only when something was removed. `true` when the key is gone —
+/// absent to begin with, or removed and written — so a writer migrating a
+/// legacy spelling can fold it into its own success.
+pub fn remove_config_value(path: &str, key: &str) -> bool {
+ let content = fs::read_to_string(path).unwrap_or_default();
+ let mut doc = match content.parse::<kdl::KdlDocument>() {
+ Ok(d) => d,
+ Err(_) => return true,
+ };
+ if remove_kdl_in_memory(&mut doc, key) {
+ return safe_write(path, &doc.to_string());
+ }
+ true
+}
+
+/// [`write_config_value`] with an explicit type annotation — see
+/// [`update_kdl_in_memory_typed`].
+pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
+ let content = fs::read_to_string(path).unwrap_or_default();
+ let mut doc = match content.parse::<kdl::KdlDocument>() {
+ Ok(d) => d,
+ Err(_) => kdl::KdlDocument::new(),
+ };
+
+ if update_kdl_in_memory_typed(&mut doc, key, value, default_section, forced_ty) {
+ let updated_str = doc.to_string();
+ return safe_write(path, &updated_str);
+ }
+ false
+}
+
+pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
+ let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
+ let parts: Vec<&str> = key_path.split('.').collect();
+ if parts.is_empty() {
+ return None;
+ }
+
+ let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
+
+ let (node_path, target_prop) = if is_property {
+ (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
+ } else {
+ (&parts[0..parts.len()], None)
+ };
+
+ let child_node = get_node_ref(&doc, node_path)?;
+
+ if let Some(prop_name) = target_prop {
+ let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
+ entry.ty().map(|t| t.value().to_string())
+ } else {
+ let entry = child_node.entries().first()?;
+ entry.ty().map(|t| t.value().to_string())
+ }
+}
+
+pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
+ let doc = match kdl_content.parse::<kdl::KdlDocument>() {
+ Ok(d) => Some(d),
+ Err(_) => None,
+ };
+ key_paths.iter().map(|key_path| {
+ let doc = doc.as_ref()?;
+ let parts: Vec<&str> = key_path.split('.').collect();
+ if parts.is_empty() {
+ return None;
+ }
+
+ let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
+
+ let (node_path, target_prop) = if is_property {
+ (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
+ } else {
+ (&parts[0..parts.len()], None)
+ };
+
+ let child_node = get_node_ref(doc, node_path)?;
+
+ if let Some(prop_name) = target_prop {
+ let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
+ entry.ty().map(|t| t.value().to_string())
+ } else {
+ let entry = child_node.entries().first()?;
+ entry.ty().map(|t| t.value().to_string())
+ }
+ }).collect()
+}
+
+
+#[cfg(test)]
+mod tests {
+ /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
+ /// unset, empty or relative. Nothing else in the crate reads the
+ /// variable, so setting it here cannot race another test.
+ #[test]
+ fn state_home_follows_xdg_and_falls_back() {
+ let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
+ let fallback = home.join(".local").join("state");
+ std::env::set_var("XDG_STATE_HOME", "/srv/state");
+ assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
+ assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
+ for invalid in ["", "relative/state"] {
+ std::env::set_var("XDG_STATE_HOME", invalid);
+ assert_eq!(super::state_home(), fallback, "{invalid:?}");
+ }
+ std::env::remove_var("XDG_STATE_HOME");
+ assert_eq!(super::state_home(), fallback);
+ assert_eq!(super::cce_state_dir(), fallback.join("cce"));
+ }
+
+ /// A string the writer emits comes back as it went in, quotes,
+ /// backslashes and control characters included — as a section value,
+ /// a top-level value, a keybind (written with a type annotation), a
+ /// list item and a key that is not an identifier. Until 2026-10-01
+ /// none of it was escaped, a quote inside a value made a line the
+ /// parser refused, and an app whose settings file fails to parse loads
+ /// its defaults.
+ #[test]
+ fn strings_round_trip_through_the_kdl_writer() {
+ let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
+ let val = serde_json::json!({
+ "section": {
+ "plain": odd,
+ "shortcut": "ctrl+\"",
+ "items": [odd, "x"],
+ "not an ident": odd,
+ },
+ "top": odd,
+ });
+ let text = super::json_to_kdl_string(&val);
+ let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
+ let back = super::kdl_to_json(&doc);
+ assert_eq!(back["section"]["plain"], odd, "{text}");
+ assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
+ assert_eq!(back["section"]["items"][0], odd, "{text}");
+ assert_eq!(back["section"]["not an ident"], odd, "{text}");
+ assert_eq!(back["top"], odd, "{text}");
+ assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
+ }
+
+ /// A material node's frost and finish are written as PROPERTIES of a
+ /// `frost` / `finish` child (RFC material § 5), created on demand under
+ /// `style.surface.material.<name>`, and read back through the same
+ /// pointer the loader uses.
+ #[test]
+ fn material_keys_write_as_frost_and_finish_props() {
+ use super::{parse_kdl_to_json, update_kdl_in_memory};
+ let mut doc = kdl::KdlDocument::new();
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
+ let text = doc.to_string();
+ let val = parse_kdl_to_json(&text);
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
+ assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
+ assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
+ assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
+ // One `frost` node with two props, not two `frost` nodes.
+ assert_eq!(text.matches("frost").count(), 1, "{text}");
+ assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
+ }
+
+ #[test]
+ fn unit_annotations_become_len_strings() {
+ let v = parse_kdl_to_json("style {\n relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n ruler (in)0.5\n}\n");
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
+ assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
+ assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
+ assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
+ }
+
+ #[test]
+ fn unit_strings_write_back_annotated() {
+ let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
+ let out = json_to_kdl_string(&v);
+ assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
+ assert!(out.contains("depth=(f64)0.15"), "{out}");
+ let back = parse_kdl_to_json(&out);
+ assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
+ }
+
+ #[test]
+ fn typed_write_keeps_and_sets_units() {
+ let mut doc: kdl::KdlDocument = "style {\n relief width=(mm)2.0\n}\n".parse().unwrap();
+ // A bare number over a (mm) slot stays mm.
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
+ let v = parse_kdl_to_json(&doc.to_string());
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
+ // A suffixed value sets the unit.
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
+ let v = parse_kdl_to_json(&doc.to_string());
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
+ }
+
+ #[test]
+ fn app_name_strips_the_kernels_deleted_marker() {
+ use super::app_name_from_exe_basename as name;
+ assert_eq!(name("cce-status-interface"), "cce-status-interface");
+ assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
+ // Only the exact trailing marker: a name that merely contains the
+ // word, or an unspaced variant, is left alone.
+ assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
+ assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
+ }
+
+ use super::*;
+
+ #[test]
+ fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
+ // No env mutation: reading the real base keeps this correct both in a
+ // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
+ // avoids racing every other test in the process.
+ let base = runtime_dir();
+ assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
+ let dir = cce_runtime_dir();
+ assert_eq!(dir, base.join("cce"));
+ // The create-on-demand contract callers depend on: they open a file
+ // inside this directory without creating it themselves.
+ assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
+ }
+
+ #[test]
+ fn relief_annotated_string_passes_through() {
+ // The (relief) custom value type: an annotated string prop must
+ // survive kdl_to_json as a plain JSON string at its pointer.
+ let content = "style {\n surface {\n desktop gap_width=(i64)16 line_relief=(relief)\"w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000\"\n }\n}\n";
+ let val = parse_kdl_to_json(content);
+ assert_eq!(
+ val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
+ Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
+ );
+ }
+
+ #[test]
+ fn test_nested_parsing() {
+ let content = "style {\n status box_opacity=(f64)0.75\n}\n";
+ let val = parse_kdl_to_json(content);
+ println!("val = {:?}", val);
+ let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
+ assert_eq!(sec, "style");
+ assert_eq!(node, "status");
+ assert_eq!(prop, Some("box_opacity".to_string()));
+
+ let sec_val = val.get(&sec).unwrap();
+ let node_val = sec_val.get(&node).unwrap();
+ let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
+ assert_eq!(prop_val.as_f64().unwrap(), 0.75);
+ }
+
+ /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
+ /// child nodes of the existing `relief` node — the node keeps its own
+ /// `depth=` / `width=` properties and grows one child block — and the
+ /// reload path reads them back at the nested paths the registry maps.
+ /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
+ /// which is how cce-relief's Save migrates a file in place.
+ #[test]
+ fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
+ let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\n }\n}\n";
+ let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
+ let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
+ // Migrate: the flat spellings come off, and a spelling that is not
+ // there reports nothing removed.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
+ let out = doc.to_string();
+ assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
+ assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
+ assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
+ assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
+
+ let val = parse_kdl_to_json(&out);
+ let relief = val.pointer("/style/surface/relief").unwrap();
+ assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
+ assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
+ assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
+ assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
+ assert!(relief.pointer("/edge/height").is_some(), "{relief}");
+ assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
+ // A whole node comes off too.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
+ let val = parse_kdl_to_json(&doc.to_string());
+ assert!(val.pointer("/style/surface/relief/edge").is_none());
+ assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
+ }
+
+ #[test]
+ fn relief_keys_write_as_properties_and_round_trip() {
+ // `relief` is a PROP_NODES member: style.surface.relief.* must land as
+ // properties on the existing relief node (the config.kdl shape), not
+ // as duplicate child nodes shadowing the light=/width= properties.
+ let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3\n }\n}\n";
+ let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
+ let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
+ let out = doc.to_string();
+ // Still one relief node, no child block grown under it.
+ assert_eq!(out.matches("relief").count(), 1, "out: {out}");
+ assert!(!out.contains("relief {"), "out: {out}");
+
+ // The reload path reads through parse_kdl_to_json: the new property
+ // must surface at the same dotted path the style registry maps.
+ let val = parse_kdl_to_json(&out);
+ let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
+ assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
+ assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
+ assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
+ }
+
+ #[test]
+ fn test_get_kdl_type_annotation() {
+ let content = "input {\n accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n touchpad {\n gestures pinch=(bool)true\n }\n}\n";
+ let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
+ assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
+
+ let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
+ assert_eq!(ty2, Some("bool".to_string()));
+
+ let keys = vec![
+ "input.accel_profile".to_string(),
+ "input.touchpad.gestures.pinch".to_string(),
+ "input.invalid_key".to_string(),
+ ];
+ let tys = get_kdl_type_annotations(content, &keys);
+ assert_eq!(tys.len(), 3);
+ assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
+ assert_eq!(tys[1], Some("bool".to_string()));
+ assert_eq!(tys[2], None);
+ }
+
+ #[test]
+ fn test_json_to_kdl_with_special_annotations() {
+ let mut annotations = std::collections::HashMap::new();
+ annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
+
+ let mut desktop_map = serde_json::Map::new();
+ desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
+
+ let mut surface_map = serde_json::Map::new();
+ surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
+
+ let mut style_map = serde_json::Map::new();
+ style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
+
+ let mut root_map = serde_json::Map::new();
+ root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
+
+ let root = serde_json::Value::Object(root_map);
+ let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
+ println!("Generated KDL:\n{}", kdl_str);
+
+ let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
+ assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
+ }
+
+ #[test]
+ fn test_brightness_annotations() {
+ let mut edp_map = serde_json::Map::new();
+ edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
+ edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
+ edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
+ edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
+
+ let mut output_map = serde_json::Map::new();
+ output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
+
+ let mut root_map = serde_json::Map::new();
+ root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
+
+ let root = serde_json::Value::Object(root_map);
+ let kdl_str = json_to_kdl_string(&root);
+ println!("Generated KDL for brightness:\n{}", kdl_str);
+
+ let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+
+ let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
+ let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
+
+ let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
+ assert_eq!(up_entry.ty().unwrap().value(), "keybind");
+
+ let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
+ assert_eq!(down_entry.ty().unwrap().value(), "keybind");
+
+ let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
+ assert_eq!(interval_entry.ty().unwrap().value(), "i64");
+ }
+
+ /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
+ /// trip cce-data-editor saves through — it used to come back as ONE arg,
+ /// `rounded_apps "a b"`, and the compositor's allowlist then matched
+ /// nothing (Claude Desktop lost its corners after every save).
+ #[test]
+ fn test_string_list_roundtrip() {
+ let content = "window_manager {\n rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n corner_shape (f64)4.5\n}\n";
+ let val = parse_kdl_to_json(content);
+ let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
+ assert_eq!(
+ list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
+ vec!["claude-desktop", "com.anthropic.Claude"]
+ );
+ let kdl_str = json_to_kdl_string(&val);
+ assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
+ // And it re-parses to the same two args, not one.
+ let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+ let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
+ let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
+ assert_eq!(ra.entries().len(), 2);
+ // A single-arg string node stays a plain string.
+ let single = parse_kdl_to_json("window_manager {\n rounded_apps \"claude-desktop\"\n}\n");
+ assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
+ }
+
+ #[test]
+ fn test_vec2i_lossless_roundtrip() {
+ let content = "style {\n surface {\n cloud {\n position_default (vec2i)100 200\n }\n }\n}\n";
+ let val = parse_kdl_to_json(content);
+ println!("Parsed KDL to JSON: {:?}", val);
+
+ let position_default_val = val.get("style").unwrap()
+ .get("surface").unwrap()
+ .get("cloud").unwrap()
+ .get("position_default").unwrap();
+ assert_eq!(position_default_val.as_str().unwrap(), "100 200");
+
+ let mut annotations = std::collections::HashMap::new();
+ annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
+
+ let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
+ println!("Generated KDL:\n{}", kdl_str);
+
+ // Assert that (vec2i)100 200 is preserved without quotes
+ assert!(kdl_str.contains("position_default (vec2i)100 200"));
+
+ // Test update_kdl_in_memory preserves and updates the KDL Document correctly
+ let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+ let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
+ assert!(updated);
+ let updated_kdl = doc.to_string();
+ println!("Updated KDL:\n{}", updated_kdl);
+ assert!(updated_kdl.contains("position_default (vec2i)150 250"));
+ }
+}
+
+/// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
+/// reads back with) escapes: a quote, a backslash and the control
+/// characters. Every string the writer emits goes through here — until
+/// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
+/// quote inside a value made a line no parser reads, and an app whose
+/// settings file fails to parse loads its DEFAULTS.
+pub fn kdl_quote(s: &str) -> String {
+ let mut out = String::with_capacity(s.len() + 2);
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ '\u{08}' => out.push_str("\\b"),
+ '\u{0C}' => out.push_str("\\f"),
+ c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+fn format_kdl_type(ty: &str) -> String {
+ let is_ident = !ty.is_empty()
+ && !ty.chars().next().unwrap().is_ascii_digit()
+ && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
+ if is_ident {
+ ty.to_string()
+ } else {
+ kdl_quote(ty)
+ }
+}
+
+fn format_kdl_identifier(name: &str) -> String {
+ let is_ident = !name.is_empty()
+ && !name.chars().next().unwrap().is_ascii_digit()
+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
+ if is_ident {
+ name.to_string()
+ } else {
+ kdl_quote(name)
+ }
+}
+
+pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
+ value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
+}
+
+pub fn value_to_kdl_with_annotations(
+ key: &str,
+ val: &serde_json::Value,
+ indent: usize,
+ parent_path: &str,
+ annotations: &std::collections::HashMap<String, String>,
+) -> String {
+ let indent_str = " ".repeat(indent);
+ let current_path = if parent_path.is_empty() {
+ key.to_string()
+ } else {
+ format!("{}.{}", parent_path, key)
+ };
+
+ match val {
+ serde_json::Value::Object(map) => {
+ let has_objects = map.values().any(|v| v.is_object());
+ if has_objects {
+ let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
+ for (k, v) in map {
+ out.push_str(&value_to_kdl_with_annotations(k, v, indent + 1, ¤t_path, annotations));
+ }
+ out.push_str(&format!("{}}}\n", indent_str));
+ out
+ } else {
+ let mut prop_parts = Vec::new();
+ let mut child_parts = Vec::new();
+ for (prop_name, prop_val) in map {
+ let prop_path = format!("{}.{}", current_path, prop_name);
+ let is_vec2i = annotations.get(&prop_path).map_or(false, |a| a == "vec2i");
+ if is_vec2i {
+ if let serde_json::Value::String(ref s) = prop_val {
+ child_parts.push(format!("{}{} (vec2i){}\n", " ".repeat(indent + 1), format_kdl_identifier(prop_name), s));
+ }
+ } else {
+ let (val_str, val_ty) = match prop_val {
+ serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
+ serde_json::Value::Number(num) => {
+ if prop_name == "light_source_position" {
+ (num.to_string(), Some("radian".to_string()))
+ } else if num.is_f64() {
+ (num.to_string(), Some("f64".to_string()))
+ } else {
+ (num.to_string(), Some("i64".to_string()))
+ }
+ }
+ serde_json::Value::String(s) => {
+ if let Some(len) = crate::units::Len::parse(s) {
+ (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
+ } else if let Some(anno) = annotations.get(&prop_path) {
+ if anno == "vec2i" {
+ (s.clone(), Some(anno.clone()))
+ } else {
+ (kdl_quote(s), Some(anno.clone()))
+ }
+ } else if s.starts_with('#') {
+ let s_clean = s.trim_start_matches('#');
+ let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
+ (kdl_quote(s), Some(ty.to_string()))
+ } else if prop_name == "key" || prop_name == "keybind" || prop_name == "shortcut" || prop_name == "open_search" || prop_name == "close_search" || prop_name == "delete" || prop_name.ends_with("_key") || prop_name.ends_with(".key") || prop_name.ends_with(".keybind") || prop_name.ends_with(".open_search") || prop_name.ends_with(".close_search") || prop_name == "brightness_up" || prop_name == "brightness_down" || prop_name.ends_with(".brightness_up") || prop_name.ends_with(".brightness_down") {
+ (kdl_quote(s), Some("keybind".to_string()))
+ } else {
+ (kdl_quote(s), None)
+ }
+ }
+ _ => (prop_val.to_string(), None),
+ };
+ if let Some(ty) = val_ty {
+ prop_parts.push(format!("{}=({}){}", format_kdl_identifier(prop_name), format_kdl_type(&ty), val_str));
+ } else {
+ prop_parts.push(format!("{}={}", format_kdl_identifier(prop_name), val_str));
+ }
+ }
+ }
+ if !child_parts.is_empty() {
+ let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
+ if !prop_parts.is_empty() {
+ out.push_str(&format!("{}{}\n", " ".repeat(indent + 1), prop_parts.join(" ")));
+ }
+ for child in child_parts {
+ out.push_str(&child);
+ }
+ out.push_str(&format!("{}}}\n", indent_str));
+ out
+ } else {
+ format!("{}{} {}\n", indent_str, format_kdl_identifier(key), prop_parts.join(" "))
+ }
+ }
+ }
+ serde_json::Value::Array(arr) => {
+ // An array of strings is one node with several positional args
+ // (the shape `kdl_to_json` reads `rounded_apps "a" "b"` into);
+ // any other array is one node per item (key_bindings' objects).
+ if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
+ let args: Vec<String> = arr
+ .iter()
+ .filter_map(|v| v.as_str().map(|s| kdl_quote(s)))
+ .collect();
+ return format!("{}{} {}\n", indent_str, format_kdl_identifier(key), args.join(" "));
+ }
+ let mut out = String::new();
+ for item in arr {
+ out.push_str(&value_to_kdl_with_annotations(key, item, indent, parent_path, annotations));
+ }
+ out
+ }
+ _ => {
+ let (val_str, val_ty) = match val {
+ serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
+ serde_json::Value::Number(num) => {
+ if key == "light_source_position" {
+ (num.to_string(), Some("radian".to_string()))
+ } else if num.is_f64() {
+ (num.to_string(), Some("f64".to_string()))
+ } else {
+ (num.to_string(), Some("i64".to_string()))
+ }
+ }
+ serde_json::Value::String(s) => {
+ if let Some(len) = crate::units::Len::parse(s) {
+ (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
+ } else if let Some(anno) = annotations.get(¤t_path) {
+ if anno == "vec2i" {
+ (s.clone(), Some(anno.clone()))
+ } else {
+ (kdl_quote(s), Some(anno.clone()))
+ }
+ } else if s.starts_with('#') {
+ let s_clean = s.trim_start_matches('#');
+ let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
+ (kdl_quote(s), Some(ty.to_string()))
+ } else if key == "key" || key == "keybind" || key == "shortcut" || key == "open_search" || key == "close_search" || key == "delete" || key.ends_with("_key") || key.ends_with(".key") || key.ends_with(".keybind") || key.ends_with(".open_search") || key.ends_with(".close_search") || key == "brightness_up" || key == "brightness_down" || key.ends_with(".brightness_up") || key.ends_with(".brightness_down") {
+ (kdl_quote(s), Some("keybind".to_string()))
+ } else {
+ (kdl_quote(s), None)
+ }
+ }
+ _ => (val.to_string(), None),
+ };
+ if let Some(ty) = val_ty {
+ format!("{}{} ({}){}\n", indent_str, format_kdl_identifier(key), format_kdl_type(&ty), val_str)
+ } else {
+ format!("{}{} {}\n", indent_str, format_kdl_identifier(key), val_str)
+ }
+ }
+ }
+}
+
+pub fn json_to_kdl_string(val: &serde_json::Value) -> String {
+ json_to_kdl_string_with_annotations(val, &std::collections::HashMap::new())
+}
+
+pub fn json_to_kdl_string_with_annotations(
+ val: &serde_json::Value,
+ annotations: &std::collections::HashMap<String, String>,
+) -> String {
+ let mut out = String::new();
+ if let serde_json::Value::Object(map) = val {
+ for (sec_name, sec_val) in map {
+ if let serde_json::Value::Object(sec_map) = sec_val {
+ out.push_str(&format!("{} {{\n", format_kdl_identifier(sec_name)));
+ for (k, v) in sec_map {
+ out.push_str(&value_to_kdl_with_annotations(k, v, 1, sec_name, annotations));
+ }
+ out.push_str("}\n");
+ } else {
+ out.push_str(&value_to_kdl_with_annotations(sec_name, sec_val, 0, "", annotations));
+ }
+ }
+ }
+ out
+}
+
+pub fn get_app_recent_files_path() -> std::path::PathBuf {
+ let app_name = get_app_name().unwrap_or_else(|| "cce-app".to_string());
+ get_config_path().parent().unwrap().join(app_name).join("recent-files.kdl")
+}
+
+pub fn load_recent_files() -> Vec<String> {
+ let path = get_app_recent_files_path();
+ if path.exists() {
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
+ if let Some(recent_node) = doc.get("recent") {
+ if let Some(children) = recent_node.children() {
+ let mut files = Vec::new();
+ for node in children.nodes() {
+ if node.name().value() == "file" {
+ if let Some(entry) = node.entries().first() {
+ if let kdl::KdlValue::String(s) = entry.value() {
+ files.push(s.clone());
+ }
+ }
+ }
+ }
+ return files;
+ }
+ }
+ }
+ }
+ }
+ Vec::new()
+}
+
+pub fn save_recent_files(files: &[String]) {
+ let path = get_app_recent_files_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let mut kdl_str = "recent {\n".to_string();
+ for file in files {
+ kdl_str.push_str(&format!(" file {}\n", kdl_quote(file)));
+ }
+ kdl_str.push_str("}\n");
+ let _ = std::fs::write(path, kdl_str);
+}
diff --git a/src/droplet.rs b/src/droplet.rs
new file mode 100644
index 0000000..c01fd5f
--- /dev/null
+++ b/src/droplet.rs
@@ -0,0 +1,193 @@
+//! The droplet spec: the shape and material knobs of the DE's droplet — the status bar's
+//! module drops and the compositor's scenefx droplet node read the same string, so the two
+//! sides can never disagree on a field. Moved here from `cce_ui::scene::paint`, which
+//! re-exports it, so the compositor can parse a spec without linking the toolkit.
+
+/// Shape and material knobs for `cce_ui::scene::paint::Prim::Droplet`. Fractions are of the
+/// droplet rect's height unless said otherwise, so a spec is resolution- and
+/// module-size-independent; the tessellator resolves and clamps them against
+/// the concrete rect.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct DropletSpec {
+ /// How far the sheet's bottom lifts above the rect bottom (the waist the
+ /// sides pull up into), fraction of height. 0 = no waist (a capsule).
+ pub sag: f32,
+ /// Belly capsule radius, fraction of height. **≤ 0 disables the belly**:
+ /// the drop is the sheet alone — with `attach` and `sheet_r` rounding its
+ /// top and bottom this is the oval dewdrop, and the default.
+ pub belly: f32,
+ /// Belly half-width, fraction of the half-width left after the belly
+ /// radius (1 = the belly spans the whole bottom).
+ pub belly_w: f32,
+ /// Smooth-union blend distance, fraction of height — bigger = softer neck
+ /// between sheet and belly.
+ pub blend: f32,
+ /// Sheet bottom-corner radius, fraction of height.
+ pub sheet_r: f32,
+ /// Sheet TOP-corner radius (the meniscus taper at the attach line),
+ /// fraction of height. 0 = the sides meet the attach edge square (the
+ /// clinging-pool look); larger values narrow the contact span so the
+ /// silhouette curves into the edge like a dewdrop. When `attach + sheet_r`
+ /// exceeds the sheet height the pair scales down proportionally, so 0.5 +
+ /// 0.5 is the fully continuous egg curve with no straight side segment.
+ pub attach: f32,
+ /// Tint opacity at the deep interior relative to the color's own alpha;
+ /// the rim falls toward `clarity` × that (thin water is clearer). 1 = flat.
+ pub clarity: f32,
+ /// Dome slope amplitude: scales the surface tilt the shading sees.
+ pub dome: f32,
+ /// Shaded band width (the dome's curved skirt), fraction of height.
+ pub band: f32,
+ /// Specular (gleam) strength — replaces the DE material's slot.
+ pub gleam: f32,
+ /// Wet-surface shininess exponent.
+ pub shine: f32,
+ /// Fresnel rim crest amplitude (the glass-edge brightening).
+ pub rim: f32,
+ /// Bottom bow: the drop's bottom boundary becomes ONE continuous circular
+ /// arc — lowest at center, rising by `bow` (fraction of height) at the
+ /// drop's side extents. The arc's radius is derived per drop from that
+ /// fixed edge rise, so a wide drop gets a huge radius and the curvature
+ /// stays subtle at the middle while a narrow drop curves visibly. 0
+ /// disables it (flat bottom run between the corner arcs).
+ pub bow: f32,
+ /// Corner-curve exponent for the silhouette (and the dome profile riding
+ /// it): 2 = circular arcs, above 2 = superellipse quadrants whose
+ /// curvature ramps to ZERO at both ends of each arc — every junction
+ /// (attach↔side, side↔bottom, curve↔flat top) becomes curvature-
+ /// continuous, so unequal attach/sheet_r radii read as ONE flowing curve
+ /// instead of two arcs meeting, and the contact eases out of the flat
+ /// top like a meniscus. Clamped to [2, 6].
+ pub curve: f32,
+ /// Extra tint density at the drop's deep interior: the body opacity ramps
+ /// from `clarity` at the rim up to `1 + core` (× the color's own alpha,
+ /// clamped to opaque) inside — the water reads thickest in the middle,
+ /// which is also where a module's text sits, so glyphs get a calmer
+ /// field without giving up the watery rim. 0 = the original flat
+ /// interior falloff.
+ pub core: f32,
+ /// Refraction strength in logical px — how far the COMPOSITOR's droplet
+ /// backdrop pass bends the image behind the drop at the rim. Client-side
+ /// rendering ignores it (a Wayland client cannot see behind its own
+ /// window); the compositor reads the same spec and drives its scenefx
+ /// droplet node with it. 0 disables the backdrop pass.
+ pub refr: f32,
+ /// Strength (0-1) of the compositor pass's inverted lens ghost — the
+ /// faint upside-down image of the scene a real hanging drop shows in its
+ /// belly. Client-side ignored, like `refr`.
+ pub ghost: f32,
+ /// Contact-shadow strength (0-1): a soft dark falloff cast below the
+ /// drop's lower arc, outside the silhouette — the volume cue of a bead
+ /// sitting proud of the surface. The host must leave room beneath the
+ /// drop box for it (the status bar insets the box by
+ /// [`DropletSpec::shadow_gap`]). 0 disables it.
+ pub shadow: f32,
+}
+
+impl DropletSpec {
+ /// Parse the DE's droplet spec string — whitespace-separated `k=v` pairs
+ /// onto the defaults (an empty string is all defaults). Unknown keys and
+ /// non-numeric values `log::warn!` and are skipped, so a typo surfaces in
+ /// the log instead of silently reverting one knob. Shared by the status
+ /// bar (which draws the drop) and the compositor (whose scenefx droplet
+ /// node refracts the backdrop behind it) so the two sides can never
+ /// disagree about a spec's meaning.
+ pub fn parse(raw: &str) -> Self {
+ let mut spec = Self::default();
+ for tok in raw.split_whitespace() {
+ let Some((key, val)) = tok.split_once('=') else {
+ log::warn!("droplet spec: token '{}' is not k=v — skipped", tok);
+ continue;
+ };
+ let Ok(v) = val.parse::<f32>() else {
+ log::warn!("droplet spec: '{}' has a non-numeric value — skipped", tok);
+ continue;
+ };
+ match key {
+ "sag" => spec.sag = v,
+ "belly" => spec.belly = v,
+ "belly_w" => spec.belly_w = v,
+ "blend" => spec.blend = v,
+ "sheet_r" => spec.sheet_r = v,
+ "attach" => spec.attach = v,
+ "clarity" => spec.clarity = v,
+ "dome" => spec.dome = v,
+ "band" => spec.band = v,
+ "gleam" => spec.gleam = v,
+ "shine" => spec.shine = v,
+ "rim" => spec.rim = v,
+ "bow" => spec.bow = v,
+ "curve" => spec.curve = v,
+ "core" => spec.core = v,
+ "refr" => spec.refr = v,
+ "ghost" => spec.ghost = v,
+ "shadow" => spec.shadow = v,
+ _ => log::warn!("droplet spec: unknown key '{}' — skipped", key),
+ }
+ }
+ spec
+ }
+
+ /// Resolve the silhouette's height-fraction knobs against a concrete rect
+ /// (logical px) with the SAME clamps the tessellator applies: returns
+ /// `(sheet_r, attach_r, bow_rise)` in logical px, the attach/sheet pair
+ /// proportionally scaled down when it overfills the height. The
+ /// compositor's droplet backdrop node uses this so its refracting
+ /// silhouette and the client-drawn drop are the same shape.
+ pub fn resolve_silhouette(&self, w: f32, h: f32) -> (f32, f32, f32) {
+ let hx = w * 0.5;
+ let hy = h * 0.5;
+ let mut sr = (self.sheet_r.clamp(0.0, 1.0) * h).min(hx);
+ let mut ar = (self.attach.clamp(0.0, 1.0) * h).min(hx);
+ let sheet_h = 2.0 * hy;
+ if sr + ar > sheet_h && sr + ar > 0.0 {
+ let f = sheet_h / (sr + ar);
+ sr *= f;
+ ar *= f;
+ }
+ let bow = (self.bow.clamp(0.0, 0.5) * h).min(hy * 0.9);
+ (sr, ar, bow)
+ }
+
+ /// Vertical room (logical px) a host should leave BELOW the drop box for
+ /// the contact shadow, given the full slot height. One place, so the
+ /// bar's reserved gap and the shader's falloff reach stay proportioned.
+ pub fn shadow_gap(&self, slot_h: f32) -> f32 {
+ if self.shadow > 0.0 {
+ (0.16 * slot_h).ceil()
+ } else {
+ 0.0
+ }
+ }
+}
+
+impl Default for DropletSpec {
+ fn default() -> Self {
+ // The oval dewdrop: no belly, no sag — one continuous curve from a
+ // tapered attach line to a fully round bottom. attach + sheet_r fill
+ // the whole height (no straight side segment), biased bottom-heavy,
+ // and the superellipse curve exponent keeps the unequal pair
+ // curvature-continuous. The pendant-pool look is reachable by
+ // setting `belly` > 0 (and usually some `sag`).
+ Self {
+ sag: 0.0,
+ belly: 0.0,
+ belly_w: 0.5,
+ blend: 0.35,
+ sheet_r: 0.58,
+ attach: 0.42,
+ clarity: 0.5,
+ dome: 0.9,
+ band: 0.9,
+ gleam: 1.4,
+ shine: 32.0,
+ rim: 0.5,
+ bow: 0.12,
+ curve: 2.6,
+ core: 0.35,
+ refr: 0.0,
+ ghost: 0.0,
+ shadow: 0.35,
+ }
+ }
+}
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..fc2f7bc
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,632 @@
+// ~/.config/cce/input.kdl — domain-scoped keybindings and pointer input
+// settings for the whole desktop.
+//
+// Top-level nodes are DOMAINS; their children are bindings. One top-level
+// node is special: `input { }` holds the global hardware pointer defaults
+// (accel, scroll factors per device class), consumed by the compositor.
+// Inside a domain, an `input { }` child holds that app's behavior
+// overrides, consumed client-side by this module:
+//
+// input { // global hardware defaults (compositor)
+// accel_profile "flat"
+// accel_speed 1.0
+// mouse { scroll_factor 1.0 }
+// trackpad { tap_to_click true; natural_scroll true; scroll_factor 1.5 }
+// trackpoint { accel_speed 0.5 }
+// }
+// cce-window-manager {
+// close_window "super+q"
+// spawn "super+d" command="cce-cloud --apps"
+// focus_left "swipe3_left" // a touchpad gesture chord: swipe|pinch,
+// // optional finger count, direction
+// }
+// cce-ui {
+// open_search "ctrl+f" // toolkit-wide widget defaults
+// input {
+// scroll_factor 1.0 // toolkit-wide scroll default
+// smooth_scroll true // wheel notches glide (widget::scroll_motion)
+// scroll_ease 12.0 // glide rate, 1/s
+// kinetic_scroll true // trackpad flicks coast after the lift
+// scroll_friction 6.0 // coast decay, 1/s
+// }
+// }
+// cce-files {
+// open_search "/" // per-app override of the cce-ui default
+// input {
+// scroll_factor 0.8 // both device kinds
+// trackpad { scroll_factor 0.6 }
+// }
+// }
+//
+// The chord is the first string argument (a `(keybind)` or `(gesture)` type
+// annotation is accepted and ignored); a `key="..."` property works too.
+// Extra properties (e.g. `command=` for spawn) ride along on the entry.
+//
+// Resolution order for an app is `<app>.<name>` → `cce-ui.<name>`; widgets
+// match the resolved chord string with `widget::match_key_shortcut`. The
+// `cce-window-manager` domain is consumed by the compositor, which maps
+// names to policy `Action`s — chords never get interpreted here.
+//
+// Per-app scroll factors compose with the compositor's device scaling: the
+// compositor applies the global `input` block at the event source; a client
+// then scales its own wheel deltas by the resolved app factor (pixel deltas
+// count as `trackpad`, discrete wheel clicks as `mouse`).
+
+use std::collections::BTreeMap;
+
+/// Domain holding toolkit-wide default widget bindings.
+pub const UI_DOMAIN: &str = "cce-ui";
+/// Domain holding compositor / window-management bindings.
+pub const WINDOW_MANAGER_DOMAIN: &str = "cce-window-manager";
+
+/// `~/.config/cce/input.kdl` (honoring `XDG_CONFIG_HOME`).
+pub fn get_input_path() -> std::path::PathBuf {
+ crate::config::cce_config_dir().join("input.kdl")
+}
+
+/// One binding line inside a domain block.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BindingEntry {
+ /// Node name, e.g. `open_search`. Names may repeat (several `spawn`s).
+ pub name: String,
+ /// The chord string, e.g. `"super+shift+h"`.
+ pub chord: String,
+ /// `command="..."` property, for entries that launch something.
+ pub command: Option<String>,
+}
+
+/// A typed value inside an `input { }` settings block.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SettingValue {
+ Float(f64),
+ Bool(bool),
+ Str(String),
+}
+
+impl SettingValue {
+ pub fn as_f64(&self) -> Option<f64> {
+ match self {
+ SettingValue::Float(f) => Some(*f),
+ _ => None,
+ }
+ }
+
+ pub fn as_bool(&self) -> Option<bool> {
+ match self {
+ SettingValue::Bool(b) => Some(*b),
+ _ => None,
+ }
+ }
+
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ SettingValue::Str(s) => Some(s),
+ _ => None,
+ }
+ }
+}
+
+/// Device classes an `input { }` block may scope settings to.
+pub const DEVICE_CLASSES: [&str; 3] = ["mouse", "trackpad", "trackpoint"];
+
+/// One `input { }` block: generic `name value` settings plus per-device-class
+/// sub-blocks (`mouse` / `trackpad` / `trackpoint`).
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct InputSettings {
+ values: BTreeMap<String, SettingValue>,
+ classes: BTreeMap<String, BTreeMap<String, SettingValue>>,
+}
+
+impl InputSettings {
+ fn parse(node: &kdl::KdlNode) -> InputSettings {
+ let mut settings = InputSettings::default();
+ let Some(children) = node.children() else { return settings };
+ for child in children.nodes() {
+ let name = child.name().value();
+ if DEVICE_CLASSES.contains(&name) {
+ let class = settings.classes.entry(name.to_string()).or_default();
+ if let Some(class_children) = child.children() {
+ for leaf in class_children.nodes() {
+ if let Some(v) = setting_value(leaf) {
+ class.insert(leaf.name().value().to_string(), v);
+ }
+ }
+ }
+ } else if let Some(v) = setting_value(child) {
+ settings.values.insert(name.to_string(), v);
+ }
+ }
+ settings
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.values.is_empty() && self.classes.values().all(|c| c.is_empty())
+ }
+
+ /// A generic (class-independent) setting.
+ pub fn get(&self, key: &str) -> Option<&SettingValue> {
+ self.values.get(key)
+ }
+
+ /// A setting for one device class, falling back to the generic value.
+ pub fn get_class(&self, class: &str, key: &str) -> Option<&SettingValue> {
+ self.classes.get(class).and_then(|c| c.get(key)).or_else(|| self.get(key))
+ }
+}
+
+/// First-argument value of a settings leaf node, if it is a scalar.
+fn setting_value(node: &kdl::KdlNode) -> Option<SettingValue> {
+ let entry = node.entries().iter().find(|e| e.name().is_none())?;
+ match entry.value() {
+ kdl::KdlValue::Base10Float(f) => Some(SettingValue::Float(*f)),
+ kdl::KdlValue::Base2(i) | kdl::KdlValue::Base8(i) | kdl::KdlValue::Base10(i) | kdl::KdlValue::Base16(i) => {
+ Some(SettingValue::Float(*i as f64))
+ }
+ kdl::KdlValue::Bool(b) => Some(SettingValue::Bool(*b)),
+ kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => Some(SettingValue::Str(s.clone())),
+ kdl::KdlValue::Null => None,
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct InputConfig {
+ domains: BTreeMap<String, Vec<BindingEntry>>,
+ /// Per-domain `input { }` behavior overrides.
+ settings: BTreeMap<String, InputSettings>,
+ /// The top-level `input { }` block: global hardware defaults, consumed
+ /// by the compositor.
+ global: InputSettings,
+}
+
+impl InputConfig {
+ /// Parse the file content. Domain blocks with no children are ignored;
+ /// a child with no chord (no string argument and no `key=`) is skipped.
+ pub fn parse(content: &str) -> Result<InputConfig, String> {
+ let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("{}", e))?;
+ let mut domains: BTreeMap<String, Vec<BindingEntry>> = BTreeMap::new();
+ let mut settings: BTreeMap<String, InputSettings> = BTreeMap::new();
+ let mut global = InputSettings::default();
+ for domain_node in doc.nodes() {
+ // The top-level `input { }` block is global hardware defaults,
+ // not a domain.
+ if domain_node.name().value() == "input" {
+ global = InputSettings::parse(domain_node);
+ continue;
+ }
+ let Some(children) = domain_node.children() else { continue };
+ let domain = domain_node.name().value().to_string();
+ let entries = domains.entry(domain.clone()).or_default();
+ for node in children.nodes() {
+ // A domain's `input { }` child is its settings block.
+ if node.name().value() == "input" {
+ settings.insert(domain.clone(), InputSettings::parse(node));
+ continue;
+ }
+ let mut chord: Option<String> = None;
+ let mut command: Option<String> = None;
+ for entry in node.entries() {
+ let value = match entry.value() {
+ kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => s.clone(),
+ _ => continue,
+ };
+ match entry.name().map(|n| n.value()) {
+ None | Some("key") => {
+ if chord.is_none() {
+ chord = Some(value);
+ }
+ }
+ Some("command") => command = Some(value),
+ Some(_) => {}
+ }
+ }
+ if let Some(chord) = chord {
+ entries.push(BindingEntry {
+ name: node.name().value().to_string(),
+ chord,
+ command,
+ });
+ }
+ }
+ }
+ Ok(InputConfig { domains, settings, global })
+ }
+
+ /// Load `input.kdl`. Missing file → empty config; a parse error is
+ /// logged and also yields an empty config, so callers fall back to
+ /// their defaults instead of losing all input.
+ pub fn load() -> InputConfig {
+ let path = get_input_path();
+ let Ok(content) = std::fs::read_to_string(&path) else {
+ return InputConfig::default();
+ };
+ match InputConfig::parse(&content) {
+ Ok(config) => config,
+ Err(e) => {
+ eprintln!("[cce-ui] failed to parse {}: {}", path.display(), e);
+ InputConfig::default()
+ }
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.domains.values().all(|v| v.is_empty())
+ }
+
+ /// All entries of one domain, in file order.
+ pub fn domain(&self, domain: &str) -> &[BindingEntry] {
+ self.domains.get(domain).map(Vec::as_slice).unwrap_or(&[])
+ }
+
+ /// First entry named `name` in `domain`, no fallback.
+ pub fn get(&self, domain: &str, name: &str) -> Option<&BindingEntry> {
+ self.domain(domain).iter().find(|e| e.name == name)
+ }
+
+ /// Domain resolution for apps: `<app>.<name>`, falling back to
+ /// `cce-ui.<name>`.
+ pub fn resolve(&self, app: &str, name: &str) -> Option<&BindingEntry> {
+ self.get(app, name).or_else(|| self.get(UI_DOMAIN, name))
+ }
+
+ /// The top-level `input { }` block (global hardware defaults).
+ pub fn global_input(&self) -> &InputSettings {
+ &self.global
+ }
+
+ /// One domain's `input { }` behavior overrides.
+ pub fn domain_input(&self, domain: &str) -> Option<&InputSettings> {
+ self.settings.get(domain)
+ }
+
+ /// Setting resolution for apps, most specific first: the app domain's
+ /// class value → its generic value → the cce-ui domain's class value →
+ /// its generic value. The global `input` block is deliberately NOT in
+ /// the chain — the compositor already applies it at the event source.
+ pub fn resolve_setting(&self, app: &str, class: &str, key: &str) -> Option<&SettingValue> {
+ self.domain_input(app)
+ .and_then(|s| s.get_class(class, key))
+ .or_else(|| self.domain_input(UI_DOMAIN).and_then(|s| s.get_class(class, key)))
+ }
+
+ /// Resolved chord string for widgets, with a compiled-in default as the
+ /// last resort.
+ pub fn resolve_chord(&self, app: &str, name: &str, default: &str) -> String {
+ self.resolve(app, name).map(|e| e.chord.clone()).unwrap_or_else(|| default.to_string())
+ }
+}
+
+/// Replace (or append) one domain block in `input.kdl` content, leaving all
+/// other domains and their formatting untouched. `entries` becomes the whole
+/// new block, in order; an empty slice removes the domain. Pure — the I/O
+/// wrapper is `write_domain`.
+pub fn upsert_domain(content: &str, domain: &str, entries: &[BindingEntry]) -> Result<String, String> {
+ let mut doc: kdl::KdlDocument = if content.trim().is_empty() {
+ kdl::KdlDocument::new()
+ } else {
+ content.parse().map_err(|e| format!("{}", e))?
+ };
+
+ let mut block = format!("{} {{\n", kdl_ident(domain));
+ for entry in entries {
+ block.push_str(&format!(" {} (keybind){:?}", kdl_ident(&entry.name), entry.chord));
+ if let Some(ref command) = entry.command {
+ block.push_str(&format!(" command={:?}", command));
+ }
+ block.push('\n');
+ }
+ block.push_str("}\n");
+
+ doc.nodes_mut().retain(|n| n.name().value() != domain);
+ if !entries.is_empty() {
+ let node: kdl::KdlNode = block.parse().map_err(|e| format!("{}", e))?;
+ doc.nodes_mut().push(node);
+ }
+ let mut out = doc.to_string();
+ if !out.ends_with('\n') {
+ out.push('\n');
+ }
+ Ok(out)
+}
+
+/// Quote a node name if it isn't a bare KDL identifier.
+fn kdl_ident(name: &str) -> String {
+ let bare = !name.is_empty()
+ && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
+ && !name.starts_with(|c: char| c.is_ascii_digit());
+ if bare { name.to_string() } else { format!("{:?}", name) }
+}
+
+/// Rewrite one domain of the file at `path` (created if missing). This is
+/// the editor API for settings UIs and migration tools.
+pub fn write_domain(path: &std::path::Path, domain: &str, entries: &[BindingEntry]) -> Result<(), String> {
+ let content = std::fs::read_to_string(path).unwrap_or_default();
+ let updated = upsert_domain(&content, domain, entries)?;
+ let path_str = path.to_string_lossy();
+ if crate::config::safe_write(&path_str, &updated) {
+ Ok(())
+ } else {
+ Err(format!("failed to write {}", path_str))
+ }
+}
+
+static CACHED: std::sync::OnceLock<InputConfig> = std::sync::OnceLock::new();
+
+/// Process-wide cached `input.kdl`, loaded on first use. Widget-default
+/// getters go through this so the file is read once per app.
+pub fn cached() -> &'static InputConfig {
+ CACHED.get_or_init(InputConfig::load)
+}
+
+/// Chord for one of this app's bindings: `<app>.<name>` → `cce-ui.<name>` →
+/// the compiled-in default. The app domain is the binary name. This is the
+/// standard way for a client to resolve its shortcuts at startup:
+///
+/// ```text
+/// let open = cce_ui::input::app_chord("open_file", "enter");
+/// ... cce_ui::widget::match_key_shortcut(event, &open) ...
+/// ```
+pub fn app_chord(name: &str, default: &str) -> String {
+ let app = crate::config::get_app_name().unwrap_or_default();
+ cached().resolve_chord(&app, name, default)
+}
+
+/// Whether the trackpad scrolls NATURALLY — the content following the
+/// fingers — per input.kdl's `trackpad { natural_scroll }`, as the
+/// compositor applies it. Read once per process; a thread's override
+/// (`force_natural_scroll`, for a test) wins over it, and under `cfg(test)`
+/// with no override the answer is `false`, so the toolkit's own suite does
+/// not read the machine.
+///
+/// What it is for: a VALUE control — a slider, a spinbox, a menu's slider
+/// row — takes the wheel as "up is more", and a finger under natural
+/// scrolling as the same thing, which is the opposite sign of the pixel
+/// delta the runner hands it (the delta is what a LIST scrolls by, and a
+/// list under natural scrolling moves its content the way the fingers
+/// went). See `MouseScrollDelta::value_notches_y`.
+pub fn natural_scroll() -> bool {
+ if let Some(forced) = NATURAL_OVERRIDE.with(|f| f.get()) {
+ return forced;
+ }
+ #[cfg(any(test, feature = "test-isolation"))]
+ {
+ false
+ }
+ #[cfg(not(any(test, feature = "test-isolation")))]
+ {
+ *NATURAL_SCROLL.get_or_init(|| {
+ let input = cached();
+ let app = crate::config::get_app_name().unwrap_or_default();
+ input.resolve_setting(&app, "trackpad", "natural_scroll").and_then(SettingValue::as_bool).unwrap_or(false)
+ })
+ }
+}
+
+#[cfg(not(any(test, feature = "test-isolation")))]
+static NATURAL_SCROLL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+
+thread_local! {
+ static NATURAL_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
+}
+
+/// Force what [`natural_scroll`] answers on this thread, for a test that
+/// drives a value control with a finger; `None` lifts it. Thread-local,
+/// because a suite runs its tests in parallel and a process-wide override
+/// set by one would race every other test's read. The macOS shell sets it
+/// too, on the main thread its events arrive on, from each trackpad
+/// event's `isDirectionInvertedFromDevice`: there the system's setting is
+/// the one in force, not input.kdl's.
+pub fn force_natural_scroll(natural: Option<bool>) {
+ NATURAL_OVERRIDE.with(|f| f.set(natural));
+}
+
+/// Run `f` with [`natural_scroll`] answering `natural`, then put back
+/// whatever override was in force. For an input whose direction is known
+/// regardless of the trackpad setting: a touchscreen finger is always
+/// natural (the runner's touch scroll, `backend/touch.rs`).
+pub fn with_natural_scroll<R>(natural: bool, f: impl FnOnce() -> R) -> R {
+ let prev = NATURAL_OVERRIDE.with(|o| o.replace(Some(natural)));
+ let out = f();
+ NATURAL_OVERRIDE.with(|o| o.set(prev));
+ out
+}
+
+/// This app's effective wheel-delta multipliers, resolved once per process.
+/// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ScrollFactors {
+ pub mouse: f64,
+ pub trackpad: f64,
+}
+
+static SCROLL_FACTORS: std::sync::OnceLock<ScrollFactors> = std::sync::OnceLock::new();
+
+pub fn scroll_factors() -> ScrollFactors {
+ *SCROLL_FACTORS.get_or_init(|| {
+ let input = cached();
+ let app = crate::config::get_app_name().unwrap_or_default();
+ let factor = |class: &str| {
+ input
+ .resolve_setting(&app, class, "scroll_factor")
+ .and_then(SettingValue::as_f64)
+ .filter(|f| f.is_finite() && *f > 0.0)
+ .unwrap_or(1.0)
+ };
+ ScrollFactors { mouse: factor("mouse"), trackpad: factor("trackpad") }
+ })
+}
+
+/// Chord for a widget binding, resolved by specificity: the app's own
+/// `input.kdl` domain, then the caller-supplied legacy value (per-widget
+/// `config.kdl` props), then the toolkit-wide `cce-ui` domain, then the
+/// compiled-in default.
+pub fn widget_chord(name: &str, legacy: &str, default: &str) -> String {
+ let input = cached();
+ if let Some(app) = crate::config::get_app_name() {
+ if let Some(e) = input.get(&app, name) {
+ return e.chord.clone();
+ }
+ }
+ if !legacy.is_empty() {
+ return legacy.to_string();
+ }
+ if let Some(e) = input.get(UI_DOMAIN, name) {
+ return e.chord.clone();
+ }
+ default.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SAMPLE: &str = r#"
+cce-window-manager {
+ close_window "super+q"
+ toggle_fullscreen (keybind)"super+f"
+ spawn "super+d" command="cce-cloud --apps"
+ spawn "super+t" command="foot"
+}
+cce-ui {
+ open_search "ctrl+f"
+ close_search "escape"
+}
+cce-files {
+ open_file key="enter"
+ open_search "/"
+}
+"#;
+
+ #[test]
+ fn parses_domains_and_entries() {
+ let c = InputConfig::parse(SAMPLE).unwrap();
+ assert_eq!(c.domain(WINDOW_MANAGER_DOMAIN).len(), 4);
+ assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "close_window").unwrap().chord, "super+q");
+ // Type annotations are transparent.
+ assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "toggle_fullscreen").unwrap().chord, "super+f");
+ // Repeated names keep every entry, in order, with their commands.
+ let spawns: Vec<_> =
+ c.domain(WINDOW_MANAGER_DOMAIN).iter().filter(|e| e.name == "spawn").collect();
+ assert_eq!(spawns.len(), 2);
+ assert_eq!(spawns[0].command.as_deref(), Some("cce-cloud --apps"));
+ assert_eq!(spawns[1].chord, "super+t");
+ // key= property form.
+ assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
+ }
+
+ #[test]
+ fn resolution_prefers_app_over_ui_domain() {
+ let c = InputConfig::parse(SAMPLE).unwrap();
+ // Overridden in cce-files.
+ assert_eq!(c.resolve("cce-files", "open_search").unwrap().chord, "/");
+ // Not overridden: falls back to cce-ui.
+ assert_eq!(c.resolve("cce-files", "close_search").unwrap().chord, "escape");
+ // Unknown app: pure cce-ui fallback.
+ assert_eq!(c.resolve("cce-mail", "open_search").unwrap().chord, "ctrl+f");
+ // Nowhere: compiled-in default.
+ assert_eq!(c.resolve_chord("cce-mail", "save", "ctrl+s"), "ctrl+s");
+ }
+
+ const SETTINGS_SAMPLE: &str = r#"
+input {
+ accel_profile "flat"
+ accel_speed 1.0
+ mouse {
+ accel_speed 0.5
+ scroll_factor 2.0
+ }
+ trackpad {
+ tap_to_click true
+ scroll_factor 1.5
+ }
+}
+cce-ui {
+ open_search "ctrl+f"
+ input {
+ scroll_factor 1.25
+ }
+}
+cce-files {
+ open_file "enter"
+ input {
+ scroll_factor 0.8
+ trackpad {
+ scroll_factor 0.6
+ }
+ }
+}
+"#;
+
+ #[test]
+ fn parses_settings_blocks() {
+ let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
+ // The top-level input block is global, not a domain.
+ assert!(c.domain("input").is_empty());
+ let g = c.global_input();
+ assert_eq!(g.get("accel_profile").and_then(SettingValue::as_str), Some("flat"));
+ assert_eq!(g.get("accel_speed").and_then(SettingValue::as_f64), Some(1.0));
+ // Class value wins over generic; missing class value falls back.
+ assert_eq!(g.get_class("mouse", "accel_speed").and_then(SettingValue::as_f64), Some(0.5));
+ assert_eq!(g.get_class("trackpad", "accel_speed").and_then(SettingValue::as_f64), Some(1.0));
+ assert_eq!(g.get_class("trackpad", "tap_to_click").and_then(SettingValue::as_bool), Some(true));
+ // Settings blocks don't pollute the binding lists.
+ assert_eq!(c.domain("cce-files").len(), 1);
+ assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
+ }
+
+ #[test]
+ fn setting_resolution_prefers_app_then_ui_domain() {
+ let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
+ // App class value → app generic → cce-ui.
+ let f = |app: &str, class: &str| {
+ c.resolve_setting(app, class, "scroll_factor").and_then(SettingValue::as_f64)
+ };
+ assert_eq!(f("cce-files", "trackpad"), Some(0.6));
+ assert_eq!(f("cce-files", "mouse"), Some(0.8)); // generic app value
+ assert_eq!(f("cce-mail", "trackpad"), Some(1.25)); // cce-ui fallback
+ // The global input block is not in the client chain.
+ assert_eq!(c.resolve_setting("cce-mail", "mouse", "accel_speed"), None);
+ }
+
+ #[test]
+ fn upsert_domain_round_trips() {
+ let entries = vec![
+ BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
+ BindingEntry {
+ name: "spawn".into(),
+ chord: "super+d".into(),
+ command: Some("cce-cloud --apps".into()),
+ },
+ ];
+ // Insert into empty content, then read back.
+ let out = upsert_domain("", WINDOW_MANAGER_DOMAIN, &entries).unwrap();
+ let parsed = InputConfig::parse(&out).unwrap();
+ assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).to_vec(), entries);
+
+ // Replace the domain without touching other domains.
+ let combined = format!("{}\n{}", SAMPLE, ""); // SAMPLE already has the domain
+ let replaced = upsert_domain(
+ &combined,
+ WINDOW_MANAGER_DOMAIN,
+ &entries[..1],
+ )
+ .unwrap();
+ let parsed = InputConfig::parse(&replaced).unwrap();
+ assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).len(), 1);
+ assert_eq!(parsed.get("cce-files", "open_search").unwrap().chord, "/");
+
+ // Empty entries removes the block entirely.
+ let removed = upsert_domain(&replaced, WINDOW_MANAGER_DOMAIN, &[]).unwrap();
+ let parsed = InputConfig::parse(&removed).unwrap();
+ assert!(parsed.domain(WINDOW_MANAGER_DOMAIN).is_empty());
+ assert_eq!(parsed.resolve("cce-files", "close_search").unwrap().chord, "escape");
+ }
+
+ #[test]
+ fn empty_and_invalid_input() {
+ assert!(InputConfig::parse("").unwrap().is_empty());
+ // Chord-less entries are skipped, childless nodes ignored.
+ let c = InputConfig::parse("cce-ui {\n broken\n}\nstray-node\n").unwrap();
+ assert!(c.is_empty());
+ assert!(InputConfig::parse("cce-ui {").is_err());
+ }
+}
diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 0000000..c26a6ef
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,299 @@
+//! Helpers for the CCE Unix-socket IPC convention: `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock`.
+
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+
+pub mod instance;
+
+/// Path of a CCE IPC socket for `prefix`, keyed by `$WAYLAND_DISPLAY`.
+///
+/// `socket_path("cce")` → `/tmp/cce-<display>.sock` (the compositor control socket);
+/// `socket_path("cce-status-interface")` → the status socket. Falls back to
+/// `/tmp/<prefix>.sock` when `$WAYLAND_DISPLAY` is unset.
+pub fn socket_path(prefix: &str) -> String {
+ match std::env::var("WAYLAND_DISPLAY") {
+ Ok(d) if !d.is_empty() => format!("/tmp/{}-{}.sock", prefix, d),
+ _ => format!("/tmp/{}.sock", prefix),
+ }
+}
+
+/// Connect to the `prefix` socket, send `command` (newline-terminated), and
+/// return the reply text. Errors if the socket can't be reached.
+pub fn send_command(prefix: &str, command: &str) -> std::io::Result<String> {
+ let mut stream = UnixStream::connect(socket_path(prefix))?;
+ stream.write_all(command.as_bytes())?;
+ if !command.ends_with('\n') {
+ stream.write_all(b"\n")?;
+ }
+ let mut reply = String::new();
+ stream.read_to_string(&mut reply)?;
+ Ok(reply)
+}
+
+/// Ask the compositor to dissolve this client's surfaces out, and return how
+/// long it says that will take.
+///
+/// The fade is the compositor's, not the app's: it ramps the opacity of the
+/// scene subtree, which carries the backdrop blur, the drop shadow and the
+/// bevel down with the window. A client fading its own pixels instead leaves
+/// its surface fully present, so the blur behind it hangs at full strength
+/// over a dissolving window — and any part of its drawing that is not plain
+/// vertex alpha (shader-lit plate rims, specular) does not fade at all.
+///
+/// The contract is that the caller keeps its surfaces mapped and its process
+/// alive for the returned duration and only then exits. `window_runner` does
+/// that for every cce-ui `Application`;
+/// an app driving its own event loop calls this itself. Zero — no compositor,
+/// nothing of ours on screen, or fading configured off — means exit now.
+pub fn request_close_fade() -> std::time::Duration {
+ // This runs on the exit path of every cce-ui app, so it does its own
+ // socket call rather than `send_command`: that one reads to EOF with no
+ // deadline, and a compositor wedged mid-frame would hang the quit
+ // forever. A second is far longer than an IPC round trip and short
+ // enough that a user who hit Close still sees the window go.
+ const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
+ let Ok(mut stream) = UnixStream::connect(socket_path("cce")) else {
+ return std::time::Duration::ZERO;
+ };
+ let _ = stream.set_write_timeout(Some(REPLY_TIMEOUT));
+ let _ = stream.set_read_timeout(Some(REPLY_TIMEOUT));
+ if stream.write_all(b"fade-out\n").is_err() {
+ return std::time::Duration::ZERO;
+ }
+ // The duration is the compositor's to decide (`surface { fade out_ms }`),
+ // so it is read back rather than assumed: the two sides would otherwise
+ // drift apart the moment the config changed, and the visible failure —
+ // the window vanishing partway through its own dissolve — reads as a
+ // rendering bug rather than a disagreement about a number.
+ let mut reply = String::new();
+ if stream.read_to_string(&mut reply).is_err() {
+ return std::time::Duration::ZERO;
+ }
+ let ms: u64 = reply.trim().parse().unwrap_or(0);
+ // The compositor clamps this already; clamped again here because a
+ // client must never be made to hang on a number from the other side.
+ std::time::Duration::from_millis(ms.min(2000))
+}
+
+/// Ask the compositor to bring a window to the user: un-minimize it, focus
+/// it, raise it, and pan the camera to it — `ccectl focus-window <query>`.
+///
+/// `query` is what the compositor resolves: a numeric window id, else an
+/// app_id (an exact match beats a substring one). An app bringing *itself*
+/// forward passes its own app_id — which is what a single-instance app does
+/// when a later launch forwards to it, or the work lands in a window parked
+/// off-camera and the launch looks like it did nothing. (xdg-activation is
+/// not the route to this: the compositor deliberately answers it with an
+/// attention notification, not focus.)
+///
+/// Blocks for one round trip, bounded at a second for the same reason as
+/// [`request_close_fade`] — `send_command` reads with no deadline, and a
+/// compositor wedged mid-frame must not hang the caller. `Err` when there is
+/// no compositor to ask, it does not answer in time, it answers `error: …`
+/// (no such window, no seat), or `query` is empty or spans lines.
+pub fn focus_window(query: &str) -> std::io::Result<()> {
+ focus_window_at(&socket_path("cce"), query)
+}
+
+fn focus_window_at(path: &str, query: &str) -> std::io::Result<()> {
+ use std::io::{Error, ErrorKind};
+ const REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
+ let query = query.trim();
+ // A newline would end this command and start another on the control
+ // socket; refuse rather than pass an injection along.
+ if query.is_empty() || query.contains(['\n', '\r']) {
+ return Err(Error::new(ErrorKind::InvalidInput, format!("not a window query: {query:?}")));
+ }
+ let mut stream = UnixStream::connect(path)?;
+ stream.set_write_timeout(Some(REPLY_TIMEOUT))?;
+ stream.set_read_timeout(Some(REPLY_TIMEOUT))?;
+ stream.write_all(format!("focus-window {query}\n").as_bytes())?;
+ let mut reply = String::new();
+ stream.read_to_string(&mut reply)?;
+ match reply.trim() {
+ "ok" => Ok(()),
+ other => Err(Error::new(ErrorKind::Other, format!("focus-window {query}: {other}"))),
+ }
+}
+
+/// One request line from a socket client, bounded in size and in TOTAL time.
+///
+/// For a listener thread that serves one client after another: a plain
+/// `BufReader::read_line` with no timeout lets a client that connects and
+/// says nothing (or trickles a byte at a time) hold the thread for good, and
+/// every later client waits behind it. A per-read timeout alone is not
+/// enough, since a trickle resets it with every byte; the deadline here is
+/// for the whole line.
+///
+/// Returns the line with its `\n` (or what arrived before EOF). `None` on
+/// EOF before any byte, the deadline, more than `limit` bytes without a
+/// newline, a read error, or invalid UTF-8. The stream's read timeout is
+/// cleared again on success.
+///
+/// Nothing past the newline is consumed: each chunk is PEEKED first and only
+/// the bytes up to the newline are read, so whatever the client sent after
+/// the line is still on the socket for the caller to read. Until 2026-10-06
+/// the whole chunk was read and the tail truncated away — cce-cloud's
+/// switcher client sends its request line and then the window list down the
+/// same connection, and when both had arrived by the time the daemon read,
+/// the list went with the tail and the switcher opened empty.
+pub fn read_request_line(conn: &UnixStream, limit: usize, deadline: std::time::Duration) -> Option<String> {
+ let until = std::time::Instant::now() + deadline;
+ let mut buf: Vec<u8> = Vec::new();
+ let mut chunk = [0u8; 4096];
+ let mut reader = conn;
+ loop {
+ let left = until.checked_duration_since(std::time::Instant::now()).filter(|d| !d.is_zero())?;
+ conn.set_read_timeout(Some(left)).ok()?;
+ let n = match peek(conn, &mut chunk) {
+ Ok(n) => n,
+ Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => return None,
+ };
+ if n == 0 {
+ if buf.is_empty() {
+ return None;
+ }
+ break;
+ }
+ // Consume through the newline if the peek holds one, else all of it.
+ let take = chunk[..n].iter().position(|&b| b == b'\n').map_or(n, |end| end + 1);
+ // Already queued, so this returns at once with exactly `take` bytes.
+ reader.read_exact(&mut chunk[..take]).ok()?;
+ buf.extend_from_slice(&chunk[..take]);
+ if buf.last() == Some(&b'\n') {
+ break;
+ }
+ if buf.len() > limit {
+ return None;
+ }
+ }
+ let _ = conn.set_read_timeout(None);
+ String::from_utf8(buf).ok()
+}
+
+/// `recv(MSG_PEEK)`: what is queued on the socket, left there. Blocks (up to
+/// the read timeout) like `read` when nothing is. `UnixStream::peek` is
+/// still unstable.
+fn peek(conn: &UnixStream, buf: &mut [u8]) -> std::io::Result<usize> {
+ use std::os::fd::AsRawFd;
+ // SAFETY: `buf` is a live, writable slice of `buf.len()` bytes.
+ let n = unsafe { libc::recv(conn.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len(), libc::MSG_PEEK) };
+ if n < 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(n as usize)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::read_request_line;
+ use std::io::{Read, Write};
+ use std::os::unix::net::UnixStream;
+ use std::time::{Duration, Instant};
+
+ #[test]
+ fn a_request_line_is_bounded_in_time_and_size() {
+ let (mut client, server) = UnixStream::pair().unwrap();
+ client.write_all(b"open note\nmore").unwrap();
+ assert_eq!(read_request_line(&server, 1024, Duration::from_secs(1)).as_deref(), Some("open note\n"));
+
+ // Only the line is consumed: what the client sent after it in the
+ // same burst (cce-cloud's switcher list) is still there to read.
+ let (mut client, mut server) = UnixStream::pair().unwrap();
+ client.write_all(b"{\"args\":[]}\nWindow A (a)\nWindow B (b)\n").unwrap();
+ drop(client);
+ assert_eq!(read_request_line(&server, 1024, Duration::from_secs(1)).as_deref(), Some("{\"args\":[]}\n"));
+ let mut rest = String::new();
+ server.read_to_string(&mut rest).unwrap();
+ assert_eq!(rest, "Window A (a)\nWindow B (b)\n");
+
+ // A line longer than one peek, then a tail: read across chunks,
+ // and the tail is still left.
+ let (mut client, mut server) = UnixStream::pair().unwrap();
+ let long = format!("{}\ntail", "y".repeat(10_000));
+ let writer = std::thread::spawn(move || client.write_all(long.as_bytes()).unwrap());
+ let line = read_request_line(&server, 64 * 1024, Duration::from_secs(1)).unwrap();
+ writer.join().unwrap();
+ assert_eq!(line.len(), 10_001);
+ let mut rest = String::new();
+ server.read_to_string(&mut rest).unwrap();
+ assert_eq!(rest, "tail");
+
+ // Silent: given up at the deadline, not held forever.
+ let (_quiet, server) = UnixStream::pair().unwrap();
+ let t = Instant::now();
+ assert_eq!(read_request_line(&server, 1024, Duration::from_millis(100)), None);
+ assert!(t.elapsed() < Duration::from_millis(500));
+
+ // Trickling a byte at a time: the TOTAL deadline still ends it.
+ let (mut client, server) = UnixStream::pair().unwrap();
+ let trickle = std::thread::spawn(move || {
+ for _ in 0..40 {
+ if client.write_all(b"x").is_err() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(25));
+ }
+ });
+ let t = Instant::now();
+ assert_eq!(read_request_line(&server, 1024, Duration::from_millis(150)), None);
+ assert!(t.elapsed() < Duration::from_millis(500), "took {:?}", t.elapsed());
+ drop(server);
+ trickle.join().unwrap();
+
+ // Over the size cap.
+ let (mut client, server) = UnixStream::pair().unwrap();
+ client.write_all(&[b'z'; 2000]).unwrap();
+ assert_eq!(read_request_line(&server, 1024, Duration::from_millis(200)), None);
+ }
+
+ /// Against a stand-in compositor on a private path (never the session's
+ /// control socket): the command it sends, `ok` as success, `error: …`
+ /// as an error, a silent compositor bounded, a bad query never sent.
+ #[test]
+ fn focus_window_sends_one_command_and_reads_the_verdict() {
+ use super::focus_window_at;
+ use std::io::Read;
+ use std::os::unix::net::UnixListener;
+
+ let path = format!("/tmp/cce-ui-focus-test-{}.sock", std::process::id());
+ let _ = std::fs::remove_file(&path);
+ let listener = UnixListener::bind(&path).unwrap();
+ let replies: [&[u8]; 3] = [b"ok\n", b"error: window not found\n", b""];
+ let server = std::thread::spawn(move || {
+ let mut got = Vec::new();
+ for reply in replies {
+ let (mut conn, _) = listener.accept().unwrap();
+ let line = read_request_line(&conn, 1024, Duration::from_secs(1)).unwrap();
+ got.push(line);
+ if reply.is_empty() {
+ // Say nothing and keep the connection open: a wedged compositor.
+ let mut rest = Vec::new();
+ let _ = conn.read_to_end(&mut rest);
+ } else {
+ conn.write_all(reply).unwrap();
+ }
+ }
+ got
+ });
+
+ assert!(focus_window_at(&path, "cce-browser").is_ok());
+ let err = focus_window_at(&path, " nothing-here ").unwrap_err();
+ assert!(err.to_string().contains("window not found"), "{err}");
+ let t = Instant::now();
+ assert!(focus_window_at(&path, "12").is_err(), "a silent compositor is an error");
+ assert!(t.elapsed() < Duration::from_secs(3), "and a bounded one: {:?}", t.elapsed());
+ for bad in ["", " ", "a\nquit", "a\rb"] {
+ assert_eq!(
+ focus_window_at(&path, bad).unwrap_err().kind(),
+ std::io::ErrorKind::InvalidInput,
+ "{bad:?}"
+ );
+ }
+ let got = server.join().unwrap();
+ assert_eq!(got, ["focus-window cce-browser\n", "focus-window nothing-here\n", "focus-window 12\n"]);
+ let _ = std::fs::remove_file(&path);
+ }
+}
diff --git a/src/ipc/instance.rs b/src/ipc/instance.rs
new file mode 100644
index 0000000..34029ee
--- /dev/null
+++ b/src/ipc/instance.rs
@@ -0,0 +1,200 @@
+//! Single-instance apps over the CCE socket convention: claim or forward.
+//!
+//! An app that should run once per session (a link opened from elsewhere
+//! becomes a tab, `cce-notes open X` shows X in the running window) listens
+//! on `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` — keyed by display, so shadow
+//! sessions stay apart for free — and every later launch hands it one line
+//! and exits before any Wayland or engine work.
+//!
+//! ```ignore
+//! fn main() {
+//! if cce_ui::ipc::instance::forward_or_claim("cce-foo", &launch_line()) {
+//! return; // the running instance took it
+//! }
+//! cce_ui::engine::run::<Foo>();
+//! cce_ui::ipc::instance::cleanup();
+//! }
+//!
+//! // in Application::new, once the loop's sender exists:
+//! cce_ui::ipc::instance::serve(move |line| {
+//! let msg = parse(line)?; // None: hang up without a reply
+//! sender.send(msg).ok()?;
+//! Some("ok".into())
+//! });
+//! ```
+//!
+//! The order in [`forward_or_claim`] is what closes the startup race: try to
+//! connect, and only bind after a connect has failed. A refused connection
+//! means the socket file outlived a crashed instance and is removed before
+//! binding; losing the bind to a simultaneous launch falls back to one more
+//! connect. If that also fails the launch proceeds un-listened rather than
+//! not at all.
+//!
+//! The claimed listener has to survive from `main()` (before the engine
+//! starts) to `Application::new` (where the app's loop sender first exists),
+//! and `engine::run` takes no arguments, so it parks in this module until
+//! [`serve`] adopts it. One claim per process.
+//!
+//! The wire protocol is the app's: one request line in, one reply line out.
+//! Anything a forwarded argument needs to mean the same thing in the
+//! instance — a relative path made absolute against the *sender's* cwd —
+//! is the caller's to do before building the line.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::Mutex;
+use std::time::Duration;
+
+/// The listener claimed by [`forward_or_claim`], waiting for [`serve`].
+static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
+/// The socket path this process bound (and must unlink on exit), if any.
+static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
+
+/// How long a launch waits for the running instance to answer. Bounded so an
+/// instance whose listener is stuck cannot hang every later launch forever;
+/// unanswered, the launch is not forwarded.
+const FORWARD_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// How long, and how much, the listener reads from one client before giving
+/// up on it and serving the next (see [`super::read_request_line`]).
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
+const REQUEST_LIMIT: usize = 64 * 1024;
+
+/// Send `line` to the instance listening on `prefix`'s socket and return its
+/// reply line, trimmed.
+///
+/// `None` when nothing is listening, the write fails, or no reply arrives
+/// within the timeout. `Some("")` when the instance read the line and hung
+/// up without answering — it still received it. The client half alone, for
+/// an app that talks to another app's instance.
+pub fn forward(prefix: &str, line: &str) -> Option<String> {
+ forward_to(&super::socket_path(prefix), line)
+}
+
+fn forward_to(path: &str, line: &str) -> Option<String> {
+ let mut stream = UnixStream::connect(path).ok()?;
+ let mut msg = line.trim_end_matches('\n').to_string();
+ msg.push('\n');
+ stream.write_all(msg.as_bytes()).ok()?;
+ // Wait for the answer: returning (and exiting) on the write alone races
+ // the instance actually reading the line.
+ stream.set_read_timeout(Some(FORWARD_TIMEOUT)).ok()?;
+ let mut reply = String::new();
+ BufReader::new(stream).read_line(&mut reply).ok()?;
+ Some(reply.trim().to_string())
+}
+
+/// Hand `line` to a running instance, or claim the instance socket.
+///
+/// `true` when a running instance took the launch: the caller should exit
+/// without starting. `false` when this process is now the instance — the
+/// listener parked for [`serve`] — or when single-instance handling failed
+/// entirely and the launch should proceed standalone.
+pub fn forward_or_claim(prefix: &str, line: &str) -> bool {
+ let path = super::socket_path(prefix);
+ if forward_to(&path, line).is_some() {
+ return true;
+ }
+ // Nothing answered. A socket file that still exists is a leftover from a
+ // crashed instance; binding needs it gone.
+ if std::path::Path::new(&path).exists() {
+ let _ = std::fs::remove_file(&path);
+ }
+ match UnixListener::bind(&path) {
+ Ok(listener) => {
+ *CLAIMED.lock().unwrap_or_else(|e| e.into_inner()) = Some(listener);
+ *OWNED_PATH.lock().unwrap_or_else(|e| e.into_inner()) = Some(path);
+ false
+ }
+ // Lost the bind race to a simultaneous launch: it is the instance.
+ Err(_) => forward_to(&path, line).is_some(),
+ }
+}
+
+/// Serve the listener [`forward_or_claim`] claimed, on a thread of its own.
+///
+/// `handle` gets each request line, trimmed, and returns the reply line to
+/// write back (a trailing newline is added), or `None` to hang up without
+/// one. It runs on the listener thread, so it should hand work to the app's
+/// loop (a calloop channel) rather than do it; answering straight from
+/// shared state is fine for a query the loop need not see.
+///
+/// Each client is read under a total deadline and size cap, so one that
+/// connects and says nothing cannot wedge the listener for every launch
+/// after it. `false` when this process claimed nothing (it runs standalone,
+/// or `serve` already took the listener).
+pub fn serve<F>(mut handle: F) -> bool
+where
+ F: FnMut(&str) -> Option<String> + Send + 'static,
+{
+ let Some(listener) = CLAIMED.lock().unwrap_or_else(|e| e.into_inner()).take() else {
+ return false;
+ };
+ std::thread::spawn(move || {
+ for conn in listener.incoming() {
+ let Ok(conn) = conn else { continue };
+ let Some(line) = super::read_request_line(&conn, REQUEST_LIMIT, REQUEST_TIMEOUT) else {
+ continue;
+ };
+ if let Some(mut reply) = handle(line.trim()) {
+ reply.push('\n');
+ let _ = (&conn).write_all(reply.as_bytes());
+ }
+ }
+ });
+ true
+}
+
+/// Unlink the socket if this process bound it. Call it after the engine loop
+/// returns; a crash skips it, which is what the stale-socket removal in
+/// [`forward_or_claim`] exists for.
+pub fn cleanup() {
+ if let Some(path) = OWNED_PATH.lock().unwrap_or_else(|e| e.into_inner()).take() {
+ let _ = std::fs::remove_file(path);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The whole round trip in one process: the first call claims, `serve`
+ /// answers, a second launch is forwarded and sees the reply, and
+ /// `cleanup` removes the socket. One test, because the claim is
+ /// process-wide.
+ #[test]
+ fn claim_serve_forward_cleanup() {
+ let prefix = format!("cce-ui-instance-test-{}", std::process::id());
+ let path = crate::ipc::socket_path(&prefix);
+ // A stale file from a "crashed" instance is replaced, not fatal.
+ std::fs::write(&path, b"").unwrap();
+
+ assert!(!forward_or_claim(&prefix, "first"), "nothing was running: this launch is the instance");
+ let (tx, rx) = std::sync::mpsc::channel();
+ assert!(serve(move |line| {
+ tx.send(line.to_string()).ok()?;
+ match line {
+ "silent" => None,
+ l => Some(format!("ok {l}")),
+ }
+ }));
+ assert!(!serve(|_| None), "the listener is served once");
+
+ assert_eq!(forward(&prefix, "open x\n").as_deref(), Some("ok open x"));
+ assert!(forward_or_claim(&prefix, "second"), "a running instance takes the launch");
+ // Read and hung up on: still received.
+ assert_eq!(forward(&prefix, "silent").as_deref(), Some(""));
+ let got: Vec<String> = rx.try_iter().collect();
+ assert_eq!(got, ["open x", "second", "silent"]);
+
+ // A client that connects and says nothing does not wedge the listener.
+ let _quiet = UnixStream::connect(&path).unwrap();
+ let t = std::time::Instant::now();
+ assert_eq!(forward(&prefix, "after").as_deref(), Some("ok after"));
+ assert!(t.elapsed() < FORWARD_TIMEOUT, "took {:?}", t.elapsed());
+
+ cleanup();
+ assert!(!std::path::Path::new(&path).exists());
+ assert_eq!(forward(&prefix, "gone"), None);
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..76ca4bb
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,23 @@
+//! The GUI-free half of the cce toolkit.
+//!
+//! What a cce process needs whether or not it draws anything: the KDL config
+//! (`config`) and input bindings (`input`), the DE-wide animations switch
+//! (`motion`), lengths with units and the display metric (`units`), the
+//! Unix-socket IPC convention (`ipc`), and the parsers for the specs the DE
+//! writes in its config — colours (`color`), ramps (`ramp`), relief
+//! (`relief_spec`) and droplets (`droplet`).
+//!
+//! `cce-ui` re-exports every module here at its old path (`cce_ui::config`,
+//! `cce_ui::motion`, …), so an app never names this crate. A process that does
+//! not draw — the compositor, a sync daemon, a CLI helper — depends on it
+//! directly and links none of the toolkit's Wayland, Vulkan or text stack.
+pub mod color;
+pub mod config;
+pub mod droplet;
+pub mod input;
+#[cfg(not(target_arch = "wasm32"))]
+pub mod ipc;
+pub mod motion;
+pub mod ramp;
+pub mod relief_spec;
+pub mod units;
diff --git a/src/motion.rs b/src/motion.rs
new file mode 100644
index 0000000..78fcc0b
--- /dev/null
+++ b/src/motion.rs
@@ -0,0 +1,128 @@
+//! The DE-wide animations switch.
+//!
+//! One flag, followed by every cce-ui widget that eases and by the
+//! compositor: when it is off, anything that would glide, fade or slide
+//! lands on its target in the same frame instead. Disabled means
+//! "snap", never "freeze" — a dropdown still opens, a scroll still moves.
+//! A trackpad flick's coast is NOT under it (`widget::scroll_motion`): that
+//! is the hand's gesture carried on, not an animation the toolkit adds.
+//!
+//! The switch is a file, [`STATE_PATH`], holding `on` or `off`. The System
+//! Interface's Power page sets it per power mode and `cce-power-apply`
+//! writes it as root whenever the mode changes (plug, unplug, boot), which
+//! is why it lives under /run rather than in `~/.config/cce`: the writer has
+//! no session and no `$HOME`. No file means on.
+//!
+//! [`enabled`] is cheap enough for per-frame use: it re-reads the file at
+//! most every [`RECHECK`], so a running client follows a mode change within
+//! half a second and nothing needs restarting or reloading. `CCE_ANIMATIONS`
+//! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
+
+use std::time::Duration;
+
+/// Where the switch lives. Shared with `cce-power-apply`, the writer.
+pub const STATE_PATH: &str = "/run/cce/animations";
+
+/// How stale [`enabled`] may be. A mode change is a plug or an unplug, so
+/// half a second is instant to a person, and the stat stays off the frame.
+pub const RECHECK: Duration = Duration::from_millis(500);
+
+/// `on` / `off` (surrounding whitespace ignored); anything else says nothing.
+pub fn parse(text: &str) -> Option<bool> {
+ match text.trim() {
+ "on" | "1" | "true" => Some(true),
+ "off" | "0" | "false" => Some(false),
+ _ => None,
+ }
+}
+
+/// What the state file says right now, uncached. `None` when there is no
+/// file or it holds nothing recognizable — both of which mean "animate".
+pub fn read_state() -> Option<bool> {
+ parse(&std::fs::read_to_string(STATE_PATH).ok()?)
+}
+
+// Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
+// unless a test forces it with [`force_for_test`]. Until 2026-09-28
+// `enabled` read `/run/cce/animations` in the test binary too, so three
+// glide and fade tests passed or failed with the laptop's power mode —
+// off on battery, on when plugged in — and read as a broken glide rather
+// than a borrowed switch. Thread-local rather than the shared cache,
+// because libtest runs tests in parallel and a process-wide override set
+// by one test would race every other test's read; a test that forces it
+// does so for its own thread only, and the value resets with the thread.
+#[cfg(any(test, feature = "test-isolation"))]
+thread_local! {
+ static FORCED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
+}
+
+/// Set what [`enabled`] answers on this thread, for a test that exercises
+/// the snap-instead-of-ease path. Tests never set `CCE_ANIMATIONS`, since
+/// an environment variable is process-wide.
+#[cfg(any(test, feature = "test-isolation"))]
+pub fn force_for_test(value: bool) {
+ FORCED.with(|f| f.set(value));
+}
+
+/// Whether to animate. Every easing in the toolkit asks this before it
+/// steps, and snaps to its target when the answer is no.
+pub fn enabled() -> bool {
+ #[cfg(any(test, feature = "test-isolation"))]
+ {
+ return FORCED.with(|f| f.get());
+ }
+ #[cfg(not(any(test, feature = "test-isolation")))]
+ enabled_on_this_machine()
+}
+
+/// [`enabled`] as the shipped binary answers it: the `CCE_ANIMATIONS`
+/// override for this process, else the state file, re-read at most every
+/// [`RECHECK`].
+#[cfg(not(any(test, feature = "test-isolation")))]
+fn enabled_on_this_machine() -> bool {
+ static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
+ if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
+ return forced;
+ }
+ use std::sync::Mutex;
+ use web_time::Instant;
+ static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
+ let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
+ let now = Instant::now();
+ match *cache {
+ Some((at, value)) if now.duration_since(at) < RECHECK => value,
+ _ => {
+ let value = read_state().unwrap_or(true);
+ *cache = Some((now, value));
+ value
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_reads_the_helpers_spelling_and_nothing_else() {
+ assert_eq!(parse("off\n"), Some(false));
+ assert_eq!(parse("on"), Some(true));
+ assert_eq!(parse(" 0 "), Some(false));
+ assert_eq!(parse(""), None);
+ assert_eq!(parse("disabled"), None);
+ }
+
+ /// The suite's switch is on whatever the machine's file says, a test
+ /// can force it off for its own thread only, and another thread still
+ /// sees it on.
+ #[test]
+ fn the_suite_animates_whatever_the_machine_says() {
+ assert!(enabled(), "on by default, not read off {STATE_PATH}");
+ force_for_test(false);
+ assert!(!enabled(), "a test can force the snap path");
+ let elsewhere = std::thread::spawn(enabled).join().unwrap();
+ assert!(elsewhere, "the force is this thread's alone");
+ force_for_test(true);
+ assert!(enabled());
+ }
+}
diff --git a/src/ramp.rs b/src/ramp.rs
new file mode 100644
index 0000000..66ecb23
--- /dev/null
+++ b/src/ramp.rs
@@ -0,0 +1,105 @@
+//! The DE's ramp spec — `"smooth;0.000:0.500,0.200:1.000,…"` — and the curve it draws:
+//! what cce-ui's Ramp widget writes, its relief profiles read, and the window manager's
+//! camera transitions evaluate. `cce_ui::widget` and `cce_ui::layout` re-export these.
+
+/// Serialize ramp keys + line type as the DE's ramp spec string:
+/// `"smooth;0.000:0.500,0.200:1.000,…"` (`"linear;…"` for straight segments) —
+/// the format ramp-valued params travel in (`ParametersBg` "ramp" rows,
+/// project files, `cce_ui::layout::set_bevel_profile_keys` consumers).
+pub fn format_ramp_spec(keys: &[(f32, f32)], smooth: bool) -> String {
+ let body: Vec<String> =
+ keys.iter().map(|(p, v)| format!("{:.3}:{:.3}", p, v)).collect();
+ format!("{};{}", if smooth { "smooth" } else { "linear" }, body.join(","))
+}
+
+/// Parse a ramp spec string ([`format_ramp_spec`]) into `(keys, smooth)`.
+/// `None` for anything that doesn't yield at least two keys.
+pub fn parse_ramp_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
+ let (head, body) = spec.split_once(';')?;
+ let smooth = head.trim() == "smooth";
+ let mut keys = Vec::new();
+ for part in body.split(',') {
+ let (p, v) = part.split_once(':')?;
+ keys.push((
+ p.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+ v.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+ ));
+ }
+ if keys.len() < 2 {
+ return None;
+ }
+ keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
+ Some((keys, smooth))
+}
+
+/// Evaluate a ramp key list at `t` — THE ramp interpolation of the DE.
+/// `cce_ui::widget::Ramp` draws it, `RampPreview` previews it, the relief
+/// profile LUTs sample it, and cce-window-manager's camera speed ramp mirrors
+/// it verbatim (that crate stays dependency-minimal), so a curve sculpted in
+/// the widget is exactly the curve every consumer evaluates. Keys are
+/// `(pos, value)` sorted by pos; outside the key range the end values hold.
+///
+/// `smooth` is the widget's curved line type: a **monotone cubic** through
+/// the keys (Fritsch–Butland tangents, cubic Hermite segments) — C1, passes
+/// through every key, never overshoots a key, and flattens only at the ends
+/// and at genuine local extrema. It used to be a smoothstep blend PER
+/// SEGMENT, which forces zero slope at every key: a curve with more than two
+/// keys came out as a chain of little bumps, and a wall profile built from
+/// it read as jagged and uneven where a smooth slope was drawn. A two-key
+/// ramp is unchanged — zero tangents at both ends make the single Hermite
+/// segment exactly the old smoothstep — so the identity sentinel and every
+/// simple ease keep their look. `false` is straight segments.
+pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
+ let Some(first) = keys.first() else { return 0.0 };
+ let last = keys.last().unwrap();
+ if t <= first.0 {
+ return first.1;
+ }
+ if t >= last.0 {
+ return last.1;
+ }
+ for i in 0..keys.len() - 1 {
+ let ((x0, y0), (x1, y1)) = (keys[i], keys[i + 1]);
+ if t < x0 || t > x1 {
+ continue;
+ }
+ let h = x1 - x0;
+ if h.abs() < 0.0001 {
+ return y0;
+ }
+ let s = (t - x0) / h;
+ if !smooth {
+ return y0 + (y1 - y0) * s;
+ }
+ let (m0, m1) = (ramp_key_tangent(keys, i), ramp_key_tangent(keys, i + 1));
+ let (s2, s3) = (s * s, s * s * s);
+ let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
+ let h10 = s3 - 2.0 * s2 + s;
+ let h01 = -2.0 * s3 + 3.0 * s2;
+ let h11 = s3 - s2;
+ return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
+ }
+ first.1
+}
+
+/// The monotone cubic's tangent (dy/dpos) at key `i`: zero at either end and
+/// at any local extremum (so the curve never overshoots a key), otherwise the
+/// Fritsch–Butland weighted harmonic mean of the two neighbouring secants —
+/// the shape-preserving choice, which keeps every segment monotone whenever
+/// its keys are.
+fn ramp_key_tangent(keys: &[(f32, f32)], i: usize) -> f32 {
+ if i == 0 || i + 1 >= keys.len() {
+ return 0.0;
+ }
+ let ((xp, yp), (x, y), (xn, yn)) = (keys[i - 1], keys[i], keys[i + 1]);
+ let (h0, h1) = (x - xp, xn - x);
+ if h0 <= 0.0001 || h1 <= 0.0001 {
+ return 0.0;
+ }
+ let (d0, d1) = ((y - yp) / h0, (yn - y) / h1);
+ if d0 * d1 <= 0.0 {
+ return 0.0;
+ }
+ let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
+ (w0 + w1) / (w0 / d0 + w1 / d1)
+}
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
new file mode 100644
index 0000000..28c299c
--- /dev/null
+++ b/src/relief_spec.rs
@@ -0,0 +1,143 @@
+//! The `(relief)` config value type: one relief material folded into a
+//! single string, so any config key can carry its own material and
+//! `cce-relief --key <dotted.key>` can edit it in place.
+//!
+//! Format: whitespace-separated `name=value` pairs, e.g.
+//!
+//! ```text
+//! w=6.0 d=0.350 k=0.500,0.500,0.500 p=0.000:0.000,0.032:0.001,...,1.000:1.000
+//! ```
+//!
+//! - `w` — wall/roll width in logical px (required)
+//! - `h` — the wall's geometric drop, a length: `h=0.5mm`, `h=4px`, or a
+//! bare number of logical px (optional; absent = follow the width at the
+//! analytic ratio, `relief_shade::RECESS_DEPTH` × width)
+//! - `d` — light strength across the wall (`bevel_depth`; optional). Kept
+//! as `d` on the wire for every reader already out there; `l` is read as
+//! an alias. It is NOT a length — `h` is.
+//! - `k` — the editor's Shoulder/Base/Bias knob triple, a ride-along seed
+//! so `cce-relief` reopens where it was left (optional)
+//! - `p` — the wall's height curve as the same ramp spec
+//! `style.surface.relief.wall.profile` carries (optional; absent or the
+//! identity sentinel = the analytic profile)
+//!
+//! No value contains whitespace (ramp specs are `;`/`:`/`,`-delimited), so
+//! parsing is a plain split. Unknown pairs are skipped, not errors —
+//! forward compatibility for future fields.
+
+/// One parsed `(relief)` value. See the module doc for the string format.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ReliefSpec {
+ /// Wall/roll width in logical px.
+ pub width: f32,
+ /// The wall's drop, a length; `None` = follow the width at the analytic
+ /// ratio.
+ pub height: Option<crate::units::Len>,
+ /// Light strength (`bevel_depth`); `None` = keep the process's material.
+ pub light: Option<f32>,
+ /// Shoulder/Base/Bias editor knobs behind `profile` — seed only, the
+ /// renderer never reads them.
+ pub knobs: Option<(f32, f32, f32)>,
+ /// Wall profile ramp spec; `None` = the analytic profile. May carry the
+ /// identity sentinel verbatim — installers filter it like the config
+ /// loader does.
+ pub profile: Option<String>,
+}
+
+impl ReliefSpec {
+ /// Parse a `(relief)` value. `None` when `w=` is absent or malformed —
+ /// a spec without a width says nothing drawable.
+ pub fn parse(s: &str) -> Option<Self> {
+ let mut width = None;
+ let mut height = None;
+ let mut light = None;
+ let mut knobs = None;
+ let mut profile = None;
+ for tok in s.split_whitespace() {
+ let Some((k, v)) = tok.split_once('=') else { continue };
+ match k {
+ "w" => width = v.parse::<f32>().ok().filter(|w| w.is_finite() && *w >= 0.0),
+ "d" | "l" => light = v.parse::<f32>().ok().filter(|d| d.is_finite()),
+ "h" => {
+ height = crate::units::Len::parse(v)
+ .or_else(|| v.parse::<f32>().ok().map(crate::units::Len::px))
+ .filter(|l| l.value.is_finite() && l.value > 0.0);
+ }
+ "k" => {
+ let mut it = v.splitn(3, ',').map(|p| p.parse::<f32>().ok());
+ if let (Some(Some(a)), Some(Some(b)), Some(Some(c))) =
+ (it.next(), it.next(), it.next())
+ {
+ knobs = Some((a, b, c));
+ }
+ }
+ "p" => profile = Some(v.to_string()),
+ _ => {}
+ }
+ }
+ Some(Self { width: width?, height, light, knobs, profile })
+ }
+
+ /// The string `parse` reads back — what `cce-relief --key` saves.
+ pub fn serialize(&self) -> String {
+ let mut out = format!("w={:.2}", self.width);
+ if let Some(h) = self.height {
+ out.push_str(&format!(" h={}", h.serialize()));
+ }
+ if let Some(d) = self.light {
+ out.push_str(&format!(" d={d:.3}"));
+ }
+ if let Some((a, b, c)) = self.knobs {
+ out.push_str(&format!(" k={a:.3},{b:.3},{c:.3}"));
+ }
+ if let Some(p) = &self.profile {
+ out.push_str(&format!(" p={p}"));
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_full() {
+ let spec = ReliefSpec {
+ width: 6.0,
+ height: Some(crate::units::Len::mm(0.5)),
+ light: Some(0.35),
+ knobs: Some((0.8, 0.2, 0.5)),
+ profile: Some("0.000:0.000,0.500:0.700,1.000:1.000".to_string()),
+ };
+ assert_eq!(ReliefSpec::parse(&spec.serialize()), Some(spec));
+ }
+
+ #[test]
+ fn width_only_and_unknown_pairs_skip() {
+ let spec = ReliefSpec::parse("w=4 future=stuff junk").unwrap();
+ assert_eq!(spec.width, 4.0);
+ assert_eq!(spec.height, None);
+ assert_eq!(spec.light, None);
+ assert_eq!(spec.knobs, None);
+ assert_eq!(spec.profile, None);
+ }
+
+ #[test]
+ fn height_and_light_aliases() {
+ let s = ReliefSpec::parse("w=6 h=3 l=0.2").unwrap();
+ assert_eq!(s.height, Some(crate::units::Len::px(3.0)), "bare h is logical px");
+ assert_eq!(s.light, Some(0.2), "l is read as d");
+ let s = ReliefSpec::parse("w=6 h=0.5mm d=0.1").unwrap();
+ assert_eq!(s.height, Some(crate::units::Len::mm(0.5)));
+ assert_eq!(s.serialize(), "w=6.00 h=0.5mm d=0.100");
+ assert_eq!(ReliefSpec::parse("w=6 h=-1").unwrap().height, None);
+ }
+
+ #[test]
+ fn missing_or_bad_width_rejects() {
+ assert_eq!(ReliefSpec::parse("d=0.3"), None);
+ assert_eq!(ReliefSpec::parse("w=-1"), None);
+ assert_eq!(ReliefSpec::parse(""), None);
+ }
+}
diff --git a/src/units.rs b/src/units.rs
new file mode 100644
index 0000000..6e04114
--- /dev/null
+++ b/src/units.rs
@@ -0,0 +1,424 @@
+//! Lengths with units, and the one bridge between them and the screen.
+//!
+//! The toolkit's working unit is and stays the **logical pixel**: every
+//! layout node, style slot and widget measure is an `f32` of logical px, as
+//! it always was. This module adds the two things that were missing:
+//!
+//! - [`Len`] — a length that remembers its unit (`px`, `mm`, `cm`, `in`,
+//! `pt`), parsed from config (`width=(mm)2.0`, or the string `"2mm"`)
+//! and resolved to logical px through a [`Metric`].
+//! - [`Metric`] — how many logical px one millimetre covers on the display
+//! this process is on, and where that number came from. Measured from the
+//! output's EDID size when the compositor reports one, configured by the
+//! user when EDID lies, forced by `CCE_FORCE_PPI` for headless shadows,
+//! or *assumed* at the CSS convention of 96 logical px per inch when
+//! nothing better is known. The source is carried, not hidden: an
+//! assumed metric is a guess, and anything fabricating from it should
+//! say so.
+//!
+//! Why the toolkit is not converted to millimetres internally: UI sizes are
+//! perceptual and angular, not physical. A hit target should not become 8 mm
+//! on a projector three metres away. Documents and fabrication content are
+//! the things that live in real units, and they convert at view time. Two
+//! domains, one bridge — this one.
+//!
+//! The process-wide metric lives here ([`metric`] / [`set_metric`]), fed by
+//! the window runner from the Wayland output the surface is on, exactly as
+//! `scale::scale_factor` is. Style slots carrying a unit resolve through it
+//! at every read, so a metric arriving after config load, or changing when
+//! the window moves to another display, is honoured without a reload.
+
+use std::fmt;
+use std::sync::{OnceLock, RwLock};
+
+/// Millimetres per inch.
+pub const MM_PER_INCH: f32 = 25.4;
+/// Points per inch (PostScript/CSS points).
+pub const PT_PER_INCH: f32 = 72.0;
+/// The CSS reference pixel: what a logical px is taken to measure when the
+/// display's real size is unknown. Same convention as the `Xft.dpi 96×scale`
+/// the compositor writes for Xwayland, so a bare pixel keeps its meaning
+/// under the fallback.
+pub const ASSUMED_PPI: f32 = 96.0;
+
+/// A length unit. `Px` is the logical pixel; the rest are real-world.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Unit {
+ Px,
+ Mm,
+ Cm,
+ M,
+ In,
+ Pt,
+}
+
+impl Unit {
+ /// Every unit, in the order a unit toggle should cycle them.
+ pub const ALL: [Unit; 6] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::M, Unit::In, Unit::Pt];
+
+ /// The config suffix / KDL type annotation: `px`, `mm`, `cm`, `in`, `pt`.
+ pub fn suffix(self) -> &'static str {
+ match self {
+ Unit::Px => "px",
+ Unit::Mm => "mm",
+ Unit::Cm => "cm",
+ Unit::M => "m",
+ Unit::In => "in",
+ Unit::Pt => "pt",
+ }
+ }
+
+ /// Parse a suffix or KDL type annotation. `None` for anything else, so a
+ /// caller can tell "not a unit" from a unit — `(f64)` is not a length.
+ pub fn parse(s: &str) -> Option<Unit> {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "px" => Some(Unit::Px),
+ "mm" => Some(Unit::Mm),
+ "cm" => Some(Unit::Cm),
+ "m" | "metre" | "meter" | "metres" | "meters" => Some(Unit::M),
+ "in" | "inch" | "inches" => Some(Unit::In),
+ "pt" => Some(Unit::Pt),
+ _ => None,
+ }
+ }
+
+ /// Whether this unit is a real-world length (everything but `Px`).
+ pub fn is_physical(self) -> bool {
+ !matches!(self, Unit::Px)
+ }
+
+ /// Millimetres per one of this unit. `None` for `Px`, whose size depends
+ /// on the metric.
+ fn mm_per_unit(self) -> Option<f32> {
+ match self {
+ Unit::Px => None,
+ Unit::Mm => Some(1.0),
+ Unit::Cm => Some(10.0),
+ Unit::M => Some(1000.0),
+ Unit::In => Some(MM_PER_INCH),
+ Unit::Pt => Some(MM_PER_INCH / PT_PER_INCH),
+ }
+ }
+}
+
+impl fmt::Display for Unit {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.suffix())
+ }
+}
+
+/// Where a [`Metric`]'s px-per-mm came from — carried so a consumer can tell
+/// a measurement from a guess.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MetricSource {
+ /// Computed from the output's reported physical size (EDID via
+ /// `wl_output` geometry) and its logical size.
+ Measured,
+ /// The user's per-output `size_mm` override, forwarded by the compositor
+ /// in place of the EDID value.
+ Configured,
+ /// `CCE_FORCE_PPI` in the environment.
+ Forced,
+ /// Nothing known: the CSS 96 px/in convention. A guess.
+ Assumed,
+}
+
+impl MetricSource {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ MetricSource::Measured => "measured",
+ MetricSource::Configured => "configured",
+ MetricSource::Forced => "forced",
+ MetricSource::Assumed => "assumed",
+ }
+ }
+}
+
+/// The bridge between logical pixels and real lengths for one display.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Metric {
+ /// The output scale (logical → physical px), as `scale::scale_factor`.
+ pub scale: f32,
+ /// Logical px per millimetre.
+ pub px_per_mm: f32,
+ pub source: MetricSource,
+}
+
+impl Metric {
+ /// The fallback metric: 96 logical px per inch, flagged as assumed.
+ pub fn assumed(scale: f32) -> Self {
+ Metric { scale, px_per_mm: ASSUMED_PPI / MM_PER_INCH, source: MetricSource::Assumed }
+ }
+
+ /// A metric from a display's logical size and physical size in mm.
+ /// `None` when either is unusable (zero, negative, or an implausible
+ /// density outside 25–1000 logical px per inch — an EDID that reports
+ /// the 16×9 cm a TV likes to claim is a lie, not a measurement).
+ pub fn from_sizes(scale: f32, logical_px: (f32, f32), mm: (f32, f32), source: MetricSource) -> Option<Self> {
+ if logical_px.0 <= 0.0 || logical_px.1 <= 0.0 || mm.0 <= 0.0 || mm.1 <= 0.0 {
+ return None;
+ }
+ // Average the two axes: EDID rounds each to the millimetre, and a
+ // panel's pixels are square, so the mean is closer than either.
+ let px_per_mm = 0.5 * (logical_px.0 / mm.0 + logical_px.1 / mm.1);
+ Self::from_px_per_mm(scale, px_per_mm, source)
+ }
+
+ /// A metric from a density directly, with the same plausibility gate.
+ pub fn from_px_per_mm(scale: f32, px_per_mm: f32, source: MetricSource) -> Option<Self> {
+ let ppi = px_per_mm * MM_PER_INCH;
+ if !ppi.is_finite() || !(25.0..=1000.0).contains(&ppi) {
+ return None;
+ }
+ Some(Metric { scale, px_per_mm, source })
+ }
+
+ /// Logical px per inch.
+ pub fn ppi(&self) -> f32 {
+ self.px_per_mm * MM_PER_INCH
+ }
+
+ /// Physical (buffer) px per millimetre.
+ pub fn physical_px_per_mm(&self) -> f32 {
+ self.px_per_mm * self.scale
+ }
+
+ /// Millimetres per logical px.
+ pub fn mm_per_px(&self) -> f32 {
+ 1.0 / self.px_per_mm
+ }
+
+ /// Whether this metric was measured or configured, i.e. safe to
+ /// dimension real objects from.
+ pub fn is_real(&self) -> bool {
+ matches!(self.source, MetricSource::Measured | MetricSource::Configured)
+ }
+
+ /// Logical px for `value` of `unit`.
+ pub fn to_px(&self, value: f32, unit: Unit) -> f32 {
+ match unit.mm_per_unit() {
+ None => value,
+ Some(mm) => value * mm * self.px_per_mm,
+ }
+ }
+
+ /// `unit` for a length of `px` logical px.
+ pub fn from_px(&self, px: f32, unit: Unit) -> f32 {
+ match unit.mm_per_unit() {
+ None => px,
+ Some(mm) => px / (mm * self.px_per_mm),
+ }
+ }
+}
+
+/// A length that remembers its unit. Resolve it with [`Len::resolve`] (or
+/// [`Len::px`] against the process metric) exactly once, at the boundary
+/// where a config or document value becomes a layout number.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Len {
+ pub value: f32,
+ pub unit: Unit,
+}
+
+impl Len {
+ pub const fn new(value: f32, unit: Unit) -> Self {
+ Len { value, unit }
+ }
+ pub const fn px(value: f32) -> Self {
+ Len::new(value, Unit::Px)
+ }
+ pub const fn mm(value: f32) -> Self {
+ Len::new(value, Unit::Mm)
+ }
+ pub const fn cm(value: f32) -> Self {
+ Len::new(value, Unit::Cm)
+ }
+ pub const fn m(value: f32) -> Self {
+ Len::new(value, Unit::M)
+ }
+ pub const fn inches(value: f32) -> Self {
+ Len::new(value, Unit::In)
+ }
+ pub const fn pt(value: f32) -> Self {
+ Len::new(value, Unit::Pt)
+ }
+
+ /// Parse `"2mm"`, `"0.5 in"`, `"12px"`, `"6pt"`. A bare number is
+ /// `None`: the caller decides what an unsuffixed number means (in
+ /// config it is a logical px and takes the fast path), and this parser
+ /// only ever claims a value that *said* its unit.
+ pub fn parse(s: &str) -> Option<Len> {
+ let s = s.trim();
+ let split = s.find(|c: char| c.is_ascii_alphabetic())?;
+ let (num, suffix) = s.split_at(split);
+ let value = num.trim().parse::<f32>().ok().filter(|v| v.is_finite())?;
+ let unit = Unit::parse(suffix)?;
+ Some(Len { value, unit })
+ }
+
+ /// Parse a number plus a KDL type annotation (`(mm)2.0`): `None` when
+ /// the annotation is not a unit.
+ pub fn from_annotated(value: f32, annotation: &str) -> Option<Len> {
+ Unit::parse(annotation).map(|unit| Len { value, unit })
+ }
+
+ /// Logical px under `metric`.
+ pub fn resolve(&self, metric: &Metric) -> f32 {
+ metric.to_px(self.value, self.unit)
+ }
+
+ /// Logical px under the process metric.
+ pub fn to_px(&self) -> f32 {
+ self.resolve(&metric())
+ }
+
+ /// The same length expressed in `unit` under `metric`.
+ pub fn convert(&self, unit: Unit, metric: &Metric) -> Len {
+ Len { value: metric.from_px(self.resolve(metric), unit), unit }
+ }
+
+ /// The compact form `parse` reads back: `2mm`, `9.3px`. Trailing zeros
+ /// trimmed so a config line stays as the user typed it.
+ pub fn serialize(&self) -> String {
+ format!("{}{}", fmt_num(self.value), self.unit.suffix())
+ }
+}
+
+impl fmt::Display for Len {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.serialize())
+ }
+}
+
+/// A number with up to four decimals, trailing zeros dropped.
+pub fn fmt_num(v: f32) -> String {
+ let s = format!("{:.4}", v);
+ let s = s.trim_end_matches('0').trim_end_matches('.');
+ if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
+}
+
+static METRIC: RwLock<Metric> = RwLock::new(Metric {
+ scale: 1.0,
+ px_per_mm: ASSUMED_PPI / MM_PER_INCH,
+ source: MetricSource::Assumed,
+});
+static FORCED_PPI: OnceLock<Option<f32>> = OnceLock::new();
+
+/// `CCE_FORCE_PPI=<logical px per inch>`: pin the metric regardless of what
+/// the outputs report — a headless shadow has no EDID and would otherwise
+/// run assumed, so a test that measures a millimetre sets this to the live
+/// panel's figure (141.8 on the 3840×2400 / 344 mm laptop at scale 2).
+pub fn forced_ppi() -> Option<f32> {
+ *FORCED_PPI.get_or_init(|| {
+ std::env::var("CCE_FORCE_PPI")
+ .ok()
+ .and_then(|v| v.parse::<f32>().ok())
+ .filter(|p| p.is_finite() && *p > 0.0)
+ })
+}
+
+/// The process-wide metric.
+pub fn metric() -> Metric {
+ *METRIC.read().unwrap()
+}
+
+/// Install the process-wide metric. A forced PPI overrides everything but
+/// keeps the caller's scale. Called by the window runner as outputs come and
+/// go; apps only read.
+pub fn set_metric(m: Metric) {
+ let m = match forced_ppi() {
+ Some(ppi) => Metric { scale: m.scale, px_per_mm: ppi / MM_PER_INCH, source: MetricSource::Forced },
+ None => m,
+ };
+ if let Ok(mut lock) = METRIC.write() {
+ if *lock != m {
+ log::info!(
+ "[units] metric: {:.3} logical px/mm ({:.1} ppi, scale {}) — {}",
+ m.px_per_mm,
+ m.ppi(),
+ m.scale,
+ m.source.as_str()
+ );
+ }
+ *lock = m;
+ }
+}
+
+/// Logical px per millimetre under the process metric.
+pub fn px_per_mm() -> f32 {
+ metric().px_per_mm
+}
+
+/// Logical px for `v` millimetres under the process metric.
+pub fn mm(v: f32) -> f32 {
+ metric().to_px(v, Unit::Mm)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The live laptop panel: 3840×2400 over 344×215 mm at scale 2.
+ fn panel() -> Metric {
+ Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap()
+ }
+
+ #[test]
+ fn panel_metric_is_about_5_6_px_per_mm() {
+ let m = panel();
+ assert!((m.px_per_mm - 5.58).abs() < 0.02, "{}", m.px_per_mm);
+ assert!((m.ppi() - 141.8).abs() < 0.5);
+ assert!((m.physical_px_per_mm() - 11.16).abs() < 0.05);
+ assert!(m.is_real());
+ }
+
+ #[test]
+ fn assumed_is_css_px() {
+ let m = Metric::assumed(1.0);
+ assert_eq!(Len::inches(1.0).resolve(&m), 96.0);
+ assert!((Len::pt(72.0).resolve(&m) - 96.0).abs() < 1e-4);
+ assert!(!m.is_real());
+ }
+
+ #[test]
+ fn implausible_sizes_reject() {
+ // An EDID claiming 16×9 mm at 4K: 240 px/mm, nonsense. (The gate is
+ // deliberately wide — a 4K panel over 16×9 *cm* is 610 ppi, which a
+ // phone-class panel can be — so only the absurd is refused; the
+ // `size_mm` override exists for the merely wrong.)
+ assert!(Metric::from_sizes(1.0, (3840.0, 2160.0), (16.0, 9.0), MetricSource::Measured).is_none());
+ assert!(Metric::from_sizes(1.0, (1920.0, 1080.0), (0.0, 0.0), MetricSource::Measured).is_none());
+ }
+
+ #[test]
+ fn parse_and_serialize_roundtrip() {
+ for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm", "0.3m"] {
+ let l = Len::parse(s).unwrap();
+ assert_eq!(l.serialize(), s, "{s}");
+ }
+ assert_eq!(Len::parse("2 mm"), Some(Len::mm(2.0)));
+ assert_eq!(Len::parse("2"), None, "bare numbers are the caller's");
+ assert_eq!(Len::parse("2em"), None);
+ assert_eq!(Len::parse("mm"), None);
+ assert_eq!(Len::from_annotated(2.0, "mm"), Some(Len::mm(2.0)));
+ assert_eq!(Len::from_annotated(2.0, "f64"), None);
+ }
+
+ #[test]
+ fn resolve_and_convert() {
+ let m = panel();
+ let roll = Len::px(9.3);
+ let in_mm = roll.convert(Unit::Mm, &m);
+ assert!((in_mm.value - 1.67).abs() < 0.01, "{in_mm}");
+ assert!((Len::mm(1.0).resolve(&m) - 5.58).abs() < 0.02);
+ assert_eq!(Len::px(4.0).resolve(&m), 4.0);
+ }
+
+ #[test]
+ fn fmt_num_trims() {
+ assert_eq!(fmt_num(2.0), "2");
+ assert_eq!(fmt_num(9.3), "9.3");
+ assert_eq!(fmt_num(0.0), "0");
+ assert_eq!(fmt_num(1.23456), "1.2346");
+ }
+}