GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor: the GUI-free modules live in cce-core, re-exported here
config, input, motion, units, relief_spec and ipc moved to the new
sibling crate cce-core (github.com/lsgalante/cce-core), with the pure
halves of three more: color's hex/sRGB helpers, the ramp spec functions
(widget::{format,parse}_ramp_spec, layout::sample_ramp_keys) and
scene::paint::DropletSpec. Each is re-exported at its old path, so
`cce_ui::config::…` and `crate::config::…` resolve as before and no app
changes.
What did change:
- DropletSpec::finish() is the extension trait scene::paint::DropletFinish
(a Finish is a renderer type cce-core cannot name).
- The cfg(test) gates in the moved modules are
cfg(any(test, feature = "test-isolation")) there; cce-ui's
dev-dependency enables it, so this suite still reads no machine config.
Two style-layer tests from config.rs's suite are config_style_tests.rs.
The compositor, which used cce-ui only for config, input bindings,
motion::enabled and the relief/droplet parsers, now depends on cce-core
alone and no longer links the toolkit's Wayland/Vulkan/text stack.
613 tests here and 38 in cce-core, the 651 there were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 42 +-
Cargo.toml | 9 +
src/color.rs | 57 +-
src/config.rs | 1662 ---------------------------------------------
src/config_style_tests.rs | 92 +++
src/input.rs | 632 -----------------
src/ipc.rs | 299 --------
src/ipc/instance.rs | 200 ------
src/layout.rs | 72 +-
src/lib.rs | 15 +-
src/motion.rs | 128 ----
src/relief_spec.rs | 143 ----
src/scene/paint.rs | 218 +-----
src/units.rs | 424 ------------
src/widget/input/ramp.rs | 30 +-
tests/plate_golden.rs | 2 +-
16 files changed, 169 insertions(+), 3856 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4ace4ad..f1d9491 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,13 +52,15 @@ cargo run -p cce-ui # run the demo/reference app (n
```
Tests are headless unit tests colocated in `#[cfg(test)]` modules — concentrated in `src/scene/*`
-(the arena/layout/paint/anim engine) and `src/color.rs`, `src/config.rs`, `src/layout.rs`, plus a
+(the arena/layout/paint/anim engine) and `src/color.rs`, `src/layout.rs`, plus a
scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When touching the scene
engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
anything else.
**The tests never read the machine's config** (since 2026-10-07). Under `cfg(test)`,
-`config::config_home()` is a per-process directory nobody creates, so `config.kdl`, the per-app
+`config::config_home()` is a per-process directory nobody creates (`config`, `input` and
+`motion` live in `cce-core` now, where that gate is its `test-isolation` feature, which
+cce-ui's dev-dependency turns on — see "The GUI-free half is cce-core" below), so `config.kdl`, the per-app
overrides and `input.kdl` are all absent and every getter answers its default. A test that needs a
setting loads it from a string (`color::reload_colors`) or pins it on its thread. Before this, six
heightfield and material tests failed on the desktop, whose `relief edge height` and frost keys
@@ -1625,7 +1627,10 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `src/layout.rs` (largest file, ~6.9k lines) — fonts + sizing; many `*_font_parsed()` getters and
the `read_preferred_fonts` / font-family resolution used by the cosmic-text path.
- `color.rs` — color model and named colors (`colors` re-export module in `lib.rs`).
-- `config.rs` — KDL loading and `kdl_to_json` conversion (see workspace `CLAUDE.md` for paths).
+- **`config`, `input`, `motion`, `units`, `relief_spec`, `ipc`** — re-exported from
+ `cce-core` (see "The GUI-free half is cce-core"), as are `color`'s hex/sRGB helpers,
+ `scene::paint::DropletSpec` and the ramp spec functions (`widget::{format,parse}_ramp_spec`,
+ `layout::sample_ramp_keys`).
- `context.rs` — `UiContext`: the retained widget tree, event routing, spatial grid, dirty
tracking, hit-testing.
- `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
@@ -1699,16 +1704,39 @@ cce-system-interface) to confirm behavior, not just the test suite.
for (with the limits a caller names raised to the adapter's).
- `mac/` — macOS only: the AppKit shell, `run` (see "And on a Mac, type-checked only").
- `protocol.rs` — inline-generated Wayland protocol bindings.
-- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
- the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
- claim-or-forward for apps that run once per session.
+- `ipc` (in `cce-core`) — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`,
+ `send_command`, the bounded `read_request_line`, `focus_window`), and `ipc::instance`:
+ single-instance claim-or-forward for apps that run once per session.
- `icon.rs` — XDG icon-theme lookup: a `.desktop` `Icon=` key (or an SNI tray icon
name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
**Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
own name for in-widget use; this one resolves names any installed app may ship.
- `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection, and
`detect_metric` — the display's logical px per mm from its `wl_output` geometry).
-- `units.rs` — lengths with units and the display metric; see the Units section below.
+- `units` (in `cce-core`) — lengths with units and the display metric; see the Units section below.
+
+### The GUI-free half is cce-core (since 2026-10-07)
+
+`config`, `input`, `motion`, `units`, `relief_spec` and `ipc` — and the parsers for the specs the
+DE writes (hex colours, ramps, droplets) — moved to the sibling crate `cce-core`
+(github.com/lsgalante/cce-core). cce-ui depends on it and re-exports each at its old path
+(`pub use cce_core::config;` in `lib.rs`, `pub use cce_core::droplet::DropletSpec` in
+`scene::paint`, …), so `cce_ui::config::…` and `crate::config::…` resolve as they always did and
+no app changed. Two things did change:
+
+- `DropletSpec::finish()` became the extension trait `scene::paint::DropletFinish` (a `Finish` is
+ a renderer type `cce-core` cannot name): a call site writes
+ `use cce_ui::scene::paint::DropletFinish;`.
+- The `cfg(test)` gates in those modules are `cfg(any(test, feature = "test-isolation"))` there,
+ and cce-ui's `[dev-dependencies]` names `cce-core` with that feature, so this suite still never
+ reads the machine. Two tests of the style layer that sat in `config.rs`'s suite are
+ `src/config_style_tests.rs`.
+
+The compositor depends on `cce-core` alone (it used cce-ui only for config, input bindings,
+`motion::enabled` and the relief/droplet parsers, and linked the whole toolkit for it), as do
+`cce-browser-open` (the instance client, no copy any more) and cce-window-manager (the ramp, with
+`default-features = false`: no config half, so no KDL or JSON). A change to these modules is a
+change to `cce-core`; push it, then `bump-revs.sh` repins cce-ui and the rest.
## Markdown: `MarkdownView` and `DocEditor` (features, 2026-10-01)
diff --git a/Cargo.toml b/Cargo.toml
index f31c838..cbf5333 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,9 @@ markdown = ["dep:cce-vault"]
doc_editor = []
[dependencies]
+# The GUI-free half: config, input, motion, units, ipc and the spec parsers,
+# re-exported at their old paths (`cce_ui::config`, …). See lib.rs.
+cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "39e989d6ebc7850a1e5aa96bf228fe8714e538e0" }
cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "41ca52bd51ea3655a791c58005be9fce939e4333", optional = true }
glam = "0.29"
bytemuck = { version = "1", features = ["derive"] }
@@ -225,6 +228,12 @@ crate-type = ["cdylib"]
name = "demo_web"
crate-type = ["cdylib"]
+[dev-dependencies]
+# The same crate with its `cfg(test)` behaviour switched on, so this suite reads
+# no machine config now that config/input/motion live there (cce-core's
+# `test-isolation`; resolver 2 keeps it out of normal builds).
+cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "39e989d6ebc7850a1e5aa96bf228fe8714e538e0", features = ["test-isolation"] }
+
[build-dependencies]
# build.rs compiles the fixed WGSL shaders to SPIR-V once, instead of in every
# process that builds a renderer. Same version as the runtime dependency above,
diff --git a/src/color.rs b/src/color.rs
index c4af422..c98090f 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1115,62 +1115,7 @@ pub fn well_frame_color(hovered: bool, active: bool) -> [f32; 4] {
pub const TEXT_HEADER: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
pub const TEXT_ACCENT: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
-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
- }
-}
+pub use cce_core::color::{linear_to_srgb, parse_hex_bytes, parse_hex_rgb, parse_hex_rgba, parse_hex_rgba_linear, srgb_to_linear};
pub fn to_linear(color: [f32; 4]) -> [f32; 4] {
[
diff --git a/src/config.rs b/src/config.rs
deleted file mode 100644
index af8788c..0000000
--- a/src/config.rs
+++ /dev/null
@@ -1,1662 +0,0 @@
-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(test)]
- {
- 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");
- }
-
- #[test]
- fn test_root_plate_menubar_statusbar_styling() {
- // Global color state: serialize against the other reload_colors tests,
- // and fire both once-per-process live-config loads before our reload
- // so neither can rewrite the state mid-assert.
- let _guard = crate::color::test_color_state_lock();
- let _ = crate::color::root_plate_statusbar_blur();
- crate::layout::lazy_init_style_registry();
-
- let content = r##"
- style {
- surface {
- plate {
- root blur=(f64)0.1 color=(rgba)"#5e657acf" corner_radius=(i64)12 {
- menubar blur=(bool)true color=(rgba)"#1a1d26d0" text_color=(rgba)"#e2e4f0ff"
- }
- }
- statusbar blur=(bool)false color=(rgba)"#12141cd0" text_color=(rgba)"#b5b9c8ff"
- }
- control {
- dropdown color=(rgba)"#08080cff"
- }
- data {
- textbox placeholder_text_color=(rgba)"#60606aff"
- }
- }
- "##;
-
- // Parse into json and set colors
- crate::color::reload_colors(content);
-
- // Verify values are parsed correctly through the root_plate_* getters.
- assert_eq!(crate::color::root_plate_menubar_blur(), true);
-
- let dd_color = crate::color::dropdown_background_color();
- assert!((dd_color[0] - crate::color::srgb_to_linear(8.0 / 255.0)).abs() < 0.0001);
-
- let placeholder_color = crate::color::textbox_placeholder_text_color();
- assert_eq!(placeholder_color, [0x60, 0x60, 0x6a]);
- assert_eq!(crate::color::root_plate_statusbar_blur(), false);
-
- // Colors are in sRGB converted to linear, let's verify text colors
- let menubar_txt = crate::color::root_plate_menubar_text_color();
- assert!(menubar_txt[0] > 0.0);
- let statusbar_txt = crate::color::root_plate_statusbar_text_color();
- assert!(statusbar_txt[0] > 0.0);
- }
-
- /// `style.surface.plate.root.*` is the only root-plate spelling: the
- /// legacy `root plate.*` block is ignored, whether it stands beside the
- /// canonical block or alone (its read-alias was removed 2026-09-06).
- #[test]
- fn test_plate_root_canonical_spelling() {
- // Global color state: serialize against the other reload_colors tests,
- // and fire both once-per-process live-config loads before our reload
- // so neither can rewrite the state mid-assert.
- let _guard = crate::color::test_color_state_lock();
- let _ = crate::color::root_plate_corner_radius();
- crate::layout::lazy_init_style_registry();
-
- let content = r##"
- style {
- surface {
- plate {
- root corner_radius=(i64)17 {
- menubar blur=(bool)true color=(rgba)"#1a1d26d0"
- }
- }
- backplate corner_radius=(i64)9
- }
- }
- "##;
- crate::color::reload_colors(content);
- assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "canonical read; legacy ignored");
- assert_eq!(crate::color::root_plate_menubar_blur(), true);
-
- // A legacy-only spelling no longer feeds the getter: the value from
- // the canonical load above stands.
- let legacy = r##"
- style {
- surface {
- backplate corner_radius=(i64)9
- }
- }
- "##;
- crate::color::reload_colors(legacy);
- assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
- }
-
- /// 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/config_style_tests.rs b/src/config_style_tests.rs
new file mode 100644
index 0000000..e2349ff
--- /dev/null
+++ b/src/config_style_tests.rs
@@ -0,0 +1,92 @@
+//! Tests of how the style layer reads the root plate's config keys. They lived in
+//! `config.rs`'s suite until that module moved to `cce_core::config`; they test
+//! `color` and `layout`, which stay here.
+
+/// `style.surface.plate.root.*` is the only root-plate spelling: the
+/// legacy `root plate.*` block is ignored, whether it stands beside the
+/// canonical block or alone (its read-alias was removed 2026-09-06).
+#[test]
+fn test_plate_root_canonical_spelling() {
+ // Global color state: serialize against the other reload_colors tests,
+ // and fire both once-per-process live-config loads before our reload
+ // so neither can rewrite the state mid-assert.
+ let _guard = crate::color::test_color_state_lock();
+ let _ = crate::color::root_plate_corner_radius();
+ crate::layout::lazy_init_style_registry();
+
+ let content = r##"
+ style {
+ surface {
+ plate {
+ root corner_radius=(i64)17 {
+ menubar blur=(bool)true color=(rgba)"#1a1d26d0"
+ }
+ }
+ backplate corner_radius=(i64)9
+ }
+ }
+ "##;
+ crate::color::reload_colors(content);
+ assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "canonical read; legacy ignored");
+ assert_eq!(crate::color::root_plate_menubar_blur(), true);
+
+ // A legacy-only spelling no longer feeds the getter: the value from
+ // the canonical load above stands.
+ let legacy = r##"
+ style {
+ surface {
+ backplate corner_radius=(i64)9
+ }
+ }
+ "##;
+ crate::color::reload_colors(legacy);
+ assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
+}
+
+#[test]
+fn test_root_plate_menubar_statusbar_styling() {
+ // Global color state: serialize against the other reload_colors tests,
+ // and fire both once-per-process live-config loads before our reload
+ // so neither can rewrite the state mid-assert.
+ let _guard = crate::color::test_color_state_lock();
+ let _ = crate::color::root_plate_statusbar_blur();
+ crate::layout::lazy_init_style_registry();
+
+ let content = r##"
+ style {
+ surface {
+ plate {
+ root blur=(f64)0.1 color=(rgba)"#5e657acf" corner_radius=(i64)12 {
+ menubar blur=(bool)true color=(rgba)"#1a1d26d0" text_color=(rgba)"#e2e4f0ff"
+ }
+ }
+ statusbar blur=(bool)false color=(rgba)"#12141cd0" text_color=(rgba)"#b5b9c8ff"
+ }
+ control {
+ dropdown color=(rgba)"#08080cff"
+ }
+ data {
+ textbox placeholder_text_color=(rgba)"#60606aff"
+ }
+ }
+ "##;
+
+ // Parse into json and set colors
+ crate::color::reload_colors(content);
+
+ // Verify values are parsed correctly through the root_plate_* getters.
+ assert_eq!(crate::color::root_plate_menubar_blur(), true);
+
+ let dd_color = crate::color::dropdown_background_color();
+ assert!((dd_color[0] - crate::color::srgb_to_linear(8.0 / 255.0)).abs() < 0.0001);
+
+ let placeholder_color = crate::color::textbox_placeholder_text_color();
+ assert_eq!(placeholder_color, [0x60, 0x60, 0x6a]);
+ assert_eq!(crate::color::root_plate_statusbar_blur(), false);
+
+ // Colors are in sRGB converted to linear, let's verify text colors
+ let menubar_txt = crate::color::root_plate_menubar_text_color();
+ assert!(menubar_txt[0] > 0.0);
+ let statusbar_txt = crate::color::root_plate_statusbar_text_color();
+ assert!(statusbar_txt[0] > 0.0);
+}
diff --git a/src/input.rs b/src/input.rs
deleted file mode 100644
index 3195719..0000000
--- a/src/input.rs
+++ /dev/null
@@ -1,632 +0,0 @@
-// ~/.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(test)]
- {
- false
- }
- #[cfg(not(test))]
- {
- *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(test))]
-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
deleted file mode 100644
index bc71e92..0000000
--- a/src/ipc.rs
+++ /dev/null
@@ -1,299 +0,0 @@
-//! 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 [`Application`](crate::backend::window_runner::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
deleted file mode 100644
index 34029ee..0000000
--- a/src/ipc/instance.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-//! 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/layout.rs b/src/layout.rs
index a404298..dac56e1 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1968,77 +1968,7 @@ static ROLL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
std::sync::RwLock::new(None);
static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
-/// 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)
-}
+pub use cce_core::ramp::sample_ramp_keys;
#[cfg(test)]
mod ramp_sampling_tests {
diff --git a/src/lib.rs b/src/lib.rs
index c98e1dc..7d54195 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,19 +7,19 @@
pub mod color;
pub mod compute;
pub mod widget;
-pub mod config;
-pub mod input;
+pub use cce_core::config;
+pub use cce_core::input;
pub mod history;
pub mod ime;
pub mod layout;
-pub mod relief_spec;
+pub use cce_core::relief_spec;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod wayland;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod protocol;
pub mod engine;
pub mod scale;
-pub mod units;
+pub use cce_core::units;
pub mod backend;
pub mod context;
pub mod draw;
@@ -28,10 +28,10 @@ pub mod scene;
pub mod file_dialog;
pub mod icon;
#[cfg(not(target_arch = "wasm32"))]
-pub mod ipc;
+pub use cce_core::ipc;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod mcp;
-pub mod motion;
+pub use cce_core::motion;
pub mod text_input;
#[cfg(not(target_arch = "wasm32"))]
pub mod vk;
@@ -40,6 +40,9 @@ pub mod web;
#[cfg(target_os = "macos")]
pub mod mac;
+#[cfg(test)]
+mod config_style_tests;
+
pub mod colors {
pub use crate::color::*;
}
diff --git a/src/motion.rs b/src/motion.rs
deleted file mode 100644
index cbf6e35..0000000
--- a/src/motion.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-//! 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(test)]
-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(test)]
-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(test)]
- {
- return FORCED.with(|f| f.get());
- }
- #[cfg(not(test))]
- 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(test))]
-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/relief_spec.rs b/src/relief_spec.rs
deleted file mode 100644
index 28c299c..0000000
--- a/src/relief_spec.rs
+++ /dev/null
@@ -1,143 +0,0 @@
-//! 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/scene/paint.rs b/src/scene/paint.rs
index e64aafb..3693a94 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -402,212 +402,34 @@ impl ControlPlate {
}
}
-/// Call the family **relief primitives**, not "bevel primitives": `Bevel` is one
-/// specific member — a filled rounded rect plus a lit roll on its lip — and a
-/// groove, a fillet or a sphere is not a bevel in any sense. "Relief" is also
-/// what the rest of the stack already says: `layout::control_relief` gates the
-/// whole family, and the config node is `relief`. The name **bevel** is reserved
-/// for two things: the `Bevel` prim, and the shared *edge treatment* every
-/// relief primitive is shaded with (`bevel_width`, `bevel_depth`,
-/// `bevel_shader`, `bevel_profile` — the lit roll, not the shape).
-/// Shape and material knobs for [`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 DropletSpec {
+// Call the family **relief primitives** — see `Prim`. (This note sat above `DropletSpec`,
+// which moved to `cce_core::droplet`.)
+// Call the family **relief primitives**, not "bevel primitives": `Bevel` is one
+// specific member — a filled rounded rect plus a lit roll on its lip — and a
+// groove, a fillet or a sphere is not a bevel in any sense. "Relief" is also
+// what the rest of the stack already says: `layout::control_relief` gates the
+// whole family, and the config node is `relief`. The name **bevel** is reserved
+// for two things: the `Bevel` prim, and the shared *edge treatment* every
+// relief primitive is shaded with (`bevel_width`, `bevel_depth`,
+// `bevel_shader`, `bevel_profile` — the lit roll, not the shape).
+pub use cce_core::droplet::DropletSpec;
+
+/// What a droplet's material is shaded with — an extension, since [`DropletSpec`] lives in
+/// `cce_core`, which knows no [`Finish`]. `use cce_ui::scene::paint::DropletFinish;` at a
+/// call site that writes `spec.finish()`.
+pub trait DropletFinish {
/// The drop's finish: its own gleam, shine and rim in the specular,
/// shininess and curvature slots of a [`Finish`] (a drop is wetter than
/// the DE's plates), the shading strength the DE's. The material a
/// droplet is emitted with carries this — `Material::from_fill(c)
/// .with_finish(spec.finish())` — and the tessellator reads it from
/// there like any plate's, instead of packing the slots by hand.
- pub fn finish(&self) -> Finish {
- Finish { spec: self.gleam, shininess: self.shine, curvature: self.rim, ..Finish::from_style() }
- }
+ fn finish(&self) -> Finish;
}
-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,
- }
+impl DropletFinish for DropletSpec {
+ fn finish(&self) -> Finish {
+ Finish { spec: self.gleam, shininess: self.shine, curvature: self.rim, ..Finish::from_style() }
}
}
diff --git a/src/units.rs b/src/units.rs
deleted file mode 100644
index 6e04114..0000000
--- a/src/units.rs
+++ /dev/null
@@ -1,424 +0,0 @@
-//! 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");
- }
-}
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 67c9430..5da4064 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -279,35 +279,7 @@ pub const RAMP_PRESETS: &[(&str, &[(f32, f32)])] = &[
("Valley", &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)]),
];
-/// 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))
-}
+pub use cce_core::ramp::{format_ramp_spec, parse_ramp_spec};
diff --git a/tests/plate_golden.rs b/tests/plate_golden.rs
index 92ad843..1927caf 100644
--- a/tests/plate_golden.rs
+++ b/tests/plate_golden.rs
@@ -24,7 +24,7 @@
//! the scene tessellates).
use cce_ui::scene::layout::Rect;
-use cce_ui::scene::paint::{ControlPlate, DropletSpec, PaintCtx, PlateSpec, PlateStance};
+use cce_ui::scene::paint::{ControlPlate, DropletFinish, DropletSpec, PaintCtx, PlateSpec, PlateStance};
use cce_ui::scene::Material;
use std::fmt::Write as _;