git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/config.rs (61.2K)

   1 use std::fs;
   2 use serde_json::Value;
   3 
   4 /// A unit-annotated number (`width=(mm)2.0`, `(px)9.3`, `(in)0.5`,
   5 /// `(pt)6`) becomes the JSON string `"2mm"` — the form
   6 /// `crate::units::Len::parse` reads — so the unit survives the JSON hop
   7 /// into the style registry, where it resolves against the live metric at
   8 /// every read. A bare number stays a number: a logical px, as always.
   9 fn unit_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> Option<serde_json::Value> {
  10     let ty = entry.ty()?.value();
  11     let len = crate::units::Len::from_annotated(value as f32, ty)?;
  12     Some(serde_json::Value::String(len.serialize()))
  13 }
  14 
  15 fn int_entry_to_json(value: i64, entry: &kdl::KdlEntry) -> serde_json::Value {
  16     unit_entry_to_json(value as f64, entry)
  17         .unwrap_or_else(|| serde_json::Value::Number(serde_json::Number::from(value)))
  18 }
  19 
  20 fn float_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> serde_json::Value {
  21     if let Some(v) = unit_entry_to_json(value, entry) {
  22         return v;
  23     }
  24     let mut val_f = value;
  25     if let Some(ty) = entry.ty() {
  26         let ty_str = ty.value();
  27         if let Some(range_str) = ty_str.strip_prefix("f64:") {
  28             if let Some(dash_idx) = range_str.find('-') {
  29                 let min_str = range_str[..dash_idx].trim();
  30                 let max_str = range_str[dash_idx + 1..].trim();
  31                 if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
  32                     val_f = val_f.clamp(min_f, max_f);
  33                 }
  34             }
  35         }
  36     }
  37     match serde_json::Number::from_f64(val_f) {
  38         Some(num) => serde_json::Value::Number(num),
  39         None => serde_json::Value::Null,
  40     }
  41 }
  42 
  43 fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
  44     let mut map = serde_json::Map::new();
  45     for node in doc.nodes() {
  46         let name = node.name().value().to_string();
  47         
  48         let mut node_map = serde_json::Map::new();
  49         let mut has_props = false;
  50         for entry in node.entries() {
  51             if let Some(prop_name) = entry.name() {
  52                 has_props = true;
  53                 let j_val = match entry.value() {
  54                     kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
  55                     kdl::KdlValue::Base2(i) |
  56                     kdl::KdlValue::Base8(i) |
  57                     kdl::KdlValue::Base10(i) |
  58                     kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
  59                     kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
  60                     kdl::KdlValue::String(s) |
  61                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
  62                     kdl::KdlValue::Null => serde_json::Value::Null,
  63                 };
  64                 node_map.insert(prop_name.value().to_string(), j_val);
  65             }
  66         }
  67 
  68         let val = if name == "key_bindings" {
  69             if let Some(children) = node.children() {
  70                 let mut binds = Vec::new();
  71                 for child in children.nodes() {
  72                     let mut child_map = serde_json::Map::new();
  73                     for entry in child.entries() {
  74                         if let Some(prop_name) = entry.name() {
  75                             let j_val = match entry.value() {
  76                                 kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
  77                                 kdl::KdlValue::Base2(i) |
  78                                 kdl::KdlValue::Base8(i) |
  79                                 kdl::KdlValue::Base10(i) |
  80                                 kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
  81                                 kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
  82                                 kdl::KdlValue::String(s) |
  83                                 kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
  84                                 kdl::KdlValue::Null => serde_json::Value::Null,
  85                             };
  86                             child_map.insert(prop_name.value().to_string(), j_val);
  87                         }
  88                     }
  89                     binds.push(serde_json::Value::Object(child_map));
  90                 }
  91                 serde_json::Value::Array(binds)
  92             } else if has_props {
  93                 serde_json::Value::Object(node_map)
  94             } else {
  95                 serde_json::Value::Null
  96             }
  97         } else {
  98             let mut node_val = serde_json::Value::Null;
  99             if has_props {
 100                 node_val = serde_json::Value::Object(node_map);
 101             } else if node.entries().len() > 1
 102                 && node.entries().iter().all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
 103             {
 104                 // A string LIST (`rounded_apps "a" "b"`) is an array, so the
 105                 // writer can put the args back. Joined into one string, as the
 106                 // numeric multi-arg case below still is (`(vec2i)100 200`),
 107                 // it came back as ONE quoted arg — `rounded_apps "a b"` — and
 108                 // a compositor allowlist silently matched nothing after
 109                 // every cce-data-editor save.
 110                 node_val = serde_json::Value::Array(
 111                     node.entries()
 112                         .iter()
 113                         .filter_map(|e| e.value().as_string().map(|s| serde_json::Value::String(s.to_string())))
 114                         .collect(),
 115                 );
 116             } else if node.entries().len() > 1 {
 117                 let parts: Vec<String> = node.entries().iter().map(|entry| {
 118                     match entry.value() {
 119                         kdl::KdlValue::Bool(b) => b.to_string(),
 120                         kdl::KdlValue::Base2(i) |
 121                         kdl::KdlValue::Base8(i) |
 122                         kdl::KdlValue::Base10(i) |
 123                         kdl::KdlValue::Base16(i) => i.to_string(),
 124                         kdl::KdlValue::Base10Float(f) => f.to_string(),
 125                         kdl::KdlValue::String(s) |
 126                         kdl::KdlValue::RawString(s) => s.clone(),
 127                         kdl::KdlValue::Null => "null".to_string(),
 128                     }
 129                 }).collect();
 130                 node_val = serde_json::Value::String(parts.join(" "));
 131             } else if let Some(entry) = node.entries().first() {
 132                 node_val = match entry.value() {
 133                     kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
 134                     kdl::KdlValue::Base2(i) |
 135                     kdl::KdlValue::Base8(i) |
 136                     kdl::KdlValue::Base10(i) |
 137                     kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
 138                     kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
 139                     kdl::KdlValue::String(s) |
 140                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
 141                     kdl::KdlValue::Null => serde_json::Value::Null,
 142                 };
 143             }
 144 
 145             if let Some(children) = node.children() {
 146                 let children_val = kdl_to_json(children);
 147                 if let serde_json::Value::Object(children_map) = children_val {
 148                     if let serde_json::Value::Object(mut nm) = node_val {
 149                         for (k, v) in children_map {
 150                             nm.insert(k, v);
 151                         }
 152                         serde_json::Value::Object(nm)
 153                     } else {
 154                         serde_json::Value::Object(children_map)
 155                     }
 156                 } else {
 157                     node_val
 158                 }
 159             } else {
 160                 node_val
 161             }
 162         };
 163 
 164         if let Some(existing) = map.remove(&name) {
 165             match existing {
 166                 serde_json::Value::Array(mut arr) => {
 167                     match val {
 168                         serde_json::Value::Array(new_arr) => {
 169                             arr.extend(new_arr);
 170                         }
 171                         _ => {
 172                             arr.push(val);
 173                         }
 174                     }
 175                     map.insert(name, serde_json::Value::Array(arr));
 176                 }
 177                 other => {
 178                     match val {
 179                         serde_json::Value::Array(new_arr) => {
 180                             let mut combined = vec![other];
 181                             combined.extend(new_arr);
 182                             map.insert(name, serde_json::Value::Array(combined));
 183                         }
 184                         _ => {
 185                             map.insert(name, serde_json::Value::Array(vec![other, val]));
 186                         }
 187                     }
 188                 }
 189             }
 190         } else {
 191             let list_names = ["key_bindings", "pointer_bind", "gesture_bind", "mode_rule", "tag_layout", "startup", "device"];
 192             if list_names.contains(&name.as_str()) {
 193                 match val {
 194                     serde_json::Value::Array(_) => {
 195                         map.insert(name, val);
 196                     }
 197                     _ => {
 198                         map.insert(name, serde_json::Value::Array(vec![val]));
 199                     }
 200                 }
 201             } else {
 202                 map.insert(name, val);
 203             }
 204         }
 205     }
 206     serde_json::Value::Object(map)
 207 }
 208 
 209 /// The calling app's name — the basename of its executable — used to locate
 210 /// its per-app config (`~/.config/cce/<name>/config.kdl`) and its `input.kdl`
 211 /// domain.
 212 ///
 213 /// Derived from `/proc/self/exe` on every call, which the kernel renders as
 214 /// `<path> (deleted)` once the binary on disk has been replaced (`ccebuild
 215 /// install` unlinks before writing). Without the strip, a still-running client
 216 /// would resolve its override to `~/.config/cce/<name> (deleted)/config.kdl`
 217 /// on its next config reload and silently lose the whole file — the status
 218 /// bar's droplet bubbles reverted to square boxes this way on 2026-09-03.
 219 pub fn get_app_name() -> Option<String> {
 220     std::env::current_exe()
 221         .ok()
 222         .and_then(|p| p.file_name().and_then(|s| s.to_str().map(app_name_from_exe_basename)))
 223 }
 224 
 225 /// [`get_app_name`]'s normalization: the kernel's ` (deleted)` marker on an
 226 /// unlinked executable is not part of the name.
 227 fn app_name_from_exe_basename(basename: &str) -> String {
 228     basename.strip_suffix(" (deleted)").unwrap_or(basename).to_string()
 229 }
 230 
 231 pub fn get_app_config_path(app_name: &str) -> std::path::PathBuf {
 232     get_config_path().parent().unwrap().join(app_name).join("config.kdl")
 233 }
 234 
 235 fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
 236     match (a, b) {
 237         (serde_json::Value::Object(a_map), serde_json::Value::Object(b_map)) => {
 238             for (k, v) in b_map {
 239                 if !v.is_null() {
 240                     merge_json(a_map.entry(k.clone()).or_insert(serde_json::Value::Null), v);
 241                 }
 242             }
 243         }
 244         (a_val, b_val) => {
 245             *a_val = b_val.clone();
 246         }
 247     }
 248 }
 249 
 250 pub fn parse_kdl_to_json(content: &str) -> serde_json::Value {
 251     let mut main_val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
 252         kdl_to_json(&doc)
 253     } else {
 254         serde_json::json!({})
 255     };
 256 
 257     if let Some(app_name) = get_app_name() {
 258         let app_path = get_app_config_path(&app_name);
 259         if let Ok(override_content) = std::fs::read_to_string(&app_path) {
 260             if let Ok(override_doc) = override_content.parse::<kdl::KdlDocument>() {
 261                 let override_val = kdl_to_json(&override_doc);
 262                 merge_json(&mut main_val, &override_val);
 263             }
 264         }
 265     }
 266 
 267     main_val
 268 }
 269 
 270 pub fn update_json_in_memory(val_obj: &mut Value, key: &str, value: &str, default_section: &str) -> bool {
 271     let j_val = if let Ok(parsed_val) = serde_json::from_str::<Value>(value) {
 272         parsed_val
 273     } else {
 274         serde_json::json!(value)
 275     };
 276 
 277     let mut updated = false;
 278     if let Some(obj) = val_obj.as_object_mut() {
 279         for (_sec_name, sec_val) in obj.iter_mut() {
 280             if let Some(sec_obj) = sec_val.as_object_mut() {
 281                 if sec_obj.contains_key(key) {
 282                     sec_obj.insert(key.to_string(), j_val.clone());
 283                     updated = true;
 284                     break;
 285                 }
 286             }
 287         }
 288         if !updated {
 289             if let Some(sec_obj) = obj.get_mut(default_section).and_then(|s| s.as_object_mut()) {
 290                 sec_obj.insert(key.to_string(), j_val);
 291                 updated = true;
 292             } else {
 293                 let mut map = serde_json::Map::new();
 294                 map.insert(key.to_string(), j_val);
 295                 obj.insert(default_section.to_string(), Value::Object(map));
 296                 updated = true;
 297             }
 298         }
 299     }
 300     updated
 301 }
 302 
 303 pub fn parse_config_path(key: &str, default_section: &str) -> (String, String, Option<String>) {
 304     let parts: Vec<&str> = key.split('.').collect();
 305     if parts.len() == 3 {
 306         (parts[0].to_string(), parts[1].to_string(), Some(parts[2].to_string()))
 307     } else if parts.len() == 2 {
 308         (parts[0].to_string(), parts[1].to_string(), None)
 309     } else {
 310         (default_section.to_string(), key.to_string(), None)
 311     }
 312 }
 313 
 314 const PROP_NODES: &[&str] = &[
 315     "gestures", "key_bindings", "pointer_bind", "gesture_bind",
 316     "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
 317     "status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
 318     "menubar", "statusbar", "node", "relief", "frost", "finish"
 319 ];
 320 
 321 fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
 322     if path.is_empty() {
 323         return None;
 324     }
 325     let segment = path[0];
 326     let idx = if let Some(i) = doc.nodes().iter().position(|n| n.name().value() == segment) {
 327         i
 328     } else {
 329         let new_node = format!("{}\n", segment).parse::<kdl::KdlNode>().ok()?;
 330         doc.nodes_mut().push(new_node);
 331         doc.nodes().len() - 1
 332     };
 333     if path.len() == 1 {
 334         Some(&mut doc.nodes_mut()[idx])
 335     } else {
 336         let children = doc.nodes_mut()[idx].ensure_children();
 337         get_or_create_node_mut(children, &path[1..])
 338     }
 339 }
 340 
 341 fn get_node_ref<'a>(doc: &'a kdl::KdlDocument, path: &[&str]) -> Option<&'a kdl::KdlNode> {
 342     if path.is_empty() {
 343         return None;
 344     }
 345     let segment = path[0];
 346     let node = doc.nodes().iter().find(|n| n.name().value() == segment)?;
 347     if path.len() == 1 {
 348         Some(node)
 349     } else {
 350         let children = node.children()?;
 351         get_node_ref(children, &path[1..])
 352     }
 353 }
 354 
 355 pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str, default_section: &str) -> bool {
 356     update_kdl_in_memory_typed(doc, key, value, default_section, None)
 357 }
 358 
 359 /// [`update_kdl_in_memory`] with an explicit type annotation for the written
 360 /// entry. `forced_ty` overrides both the value-shape inference and the
 361 /// preserved existing annotation — how a writer ESTABLISHES a custom type
 362 /// (e.g. cce-relief writing `(bevel)` knob keys into a config that never had
 363 /// them; preservation alone can't create the annotation).
 364 pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value: &str, _default_section: &str, forced_ty: Option<&str>) -> bool {
 365     let parts: Vec<&str> = key.split('.').collect();
 366     if parts.is_empty() {
 367         return false;
 368     }
 369 
 370     let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 371 
 372     let (node_path, target_prop) = if is_property {
 373         (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 374     } else {
 375         (&parts[0..parts.len()], None)
 376     };
 377 
 378     let child_node = if let Some(node) = get_or_create_node_mut(doc, node_path) {
 379         node
 380     } else {
 381         return false;
 382     };
 383 
 384     let existing_ty = if let Some(ref prop_name) = target_prop {
 385         child_node.entries().iter()
 386             .find(|e| e.name().map(|n| n.value()) == Some(prop_name))
 387             .and_then(|e| e.ty().map(|t| t.value().to_string()))
 388     } else {
 389         child_node.entries().first()
 390             .and_then(|e| e.ty().map(|t| t.value().to_string()))
 391     };
 392 
 393     let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
 394         (kdl::KdlValue::Bool(b), Some("bool".to_string()))
 395     } else if let Some(len) = crate::units::Len::parse(value) {
 396         // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
 397         // stays a number the editor's spinbox can step.
 398         (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
 399     } else if value.starts_with('#') {
 400         let s_clean = value.trim_start_matches('#');
 401         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
 402         (kdl::KdlValue::String(value.to_string()), Some(ty.to_string()))
 403     } else if value.contains('.') {
 404         if let Ok(f) = value.parse::<f64>() {
 405             (kdl::KdlValue::Base10Float(f), Some("f64".to_string()))
 406         } else {
 407             (kdl::KdlValue::String(value.to_string()), None)
 408         }
 409     } else if let Ok(i) = value.parse::<i64>() {
 410         (kdl::KdlValue::Base10(i), Some("i64".to_string()))
 411     } else {
 412         let s = value.trim_matches('"').to_string();
 413         (kdl::KdlValue::String(s), None)
 414     };
 415 
 416     if let Some(ref ext_ty) = existing_ty {
 417         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" {
 418             kdl_ty = Some(ext_ty.clone());
 419         }
 420         // A bare number written over a unit-annotated slot keeps the unit:
 421         // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
 422         // to logical px.
 423         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") {
 424             kdl_ty = Some(ext_ty.clone());
 425         }
 426     }
 427     if let Some(f) = forced_ty {
 428         kdl_ty = Some(f.to_string());
 429     }
 430 
 431     if let Some(prop_name) = target_prop {
 432         let mut found = false;
 433         for entry in child_node.entries_mut() {
 434             if let Some(id) = entry.name() {
 435                 if id.value() == prop_name {
 436                     *entry = kdl::KdlEntry::new_prop(prop_name.clone(), kdl_val.clone());
 437                     if let Some(ref ty) = kdl_ty {
 438                         entry.set_ty(ty.as_str());
 439                     }
 440                     found = true;
 441                     break;
 442                 }
 443             }
 444         }
 445         if !found {
 446             let mut entry = kdl::KdlEntry::new_prop(prop_name, kdl_val);
 447             if let Some(ref ty) = kdl_ty {
 448                 entry.set_ty(ty.as_str());
 449             }
 450             child_node.entries_mut().push(entry);
 451         }
 452     } else {
 453         child_node.entries_mut().clear();
 454         if kdl_ty.as_deref() == Some("vec2i") {
 455             let parts: Vec<&str> = value.split_whitespace().collect();
 456             for (idx, part) in parts.iter().enumerate() {
 457                 if let Ok(i) = part.parse::<i64>() {
 458                     let mut entry = kdl::KdlEntry::new(kdl::KdlValue::Base10(i));
 459                     if idx == 0 {
 460                         entry.set_ty("vec2i");
 461                     }
 462                     child_node.entries_mut().push(entry);
 463                 }
 464             }
 465         } else {
 466             let mut entry = kdl::KdlEntry::new(kdl_val);
 467             if let Some(ref ty) = kdl_ty {
 468                 entry.set_ty(ty.as_str());
 469             }
 470             child_node.entries_mut().push(entry);
 471         }
 472     }
 473 
 474     true
 475 }
 476 
 477 /// XDG config base directory: `$XDG_CONFIG_HOME`, else `~/.config`.
 478 pub fn config_home() -> std::path::PathBuf {
 479     match std::env::var("XDG_CONFIG_HOME") {
 480         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 481         _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"),
 482     }
 483 }
 484 
 485 /// XDG data base directory: `$XDG_DATA_HOME`, else `~/.local/share`.
 486 pub fn data_home() -> std::path::PathBuf {
 487     match std::env::var("XDG_DATA_HOME") {
 488         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 489         _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
 490             .join(".local")
 491             .join("share"),
 492     }
 493 }
 494 
 495 /// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
 496 ///
 497 /// Unlike the other bases there is no `~/...` fallback to construct: the
 498 /// runtime dir is `/run/user/UID`, created by pam_systemd at login and mode
 499 /// 0700. An unset variable means we are outside a login session, where the
 500 /// shared temp dir is the honest answer rather than a path we would have to
 501 /// invent (and could not create with the right ownership anyway).
 502 pub fn runtime_dir() -> std::path::PathBuf {
 503     match std::env::var("XDG_RUNTIME_DIR") {
 504         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 505         _ => std::env::temp_dir(),
 506     }
 507 }
 508 
 509 /// The cce config directory (`<config_home>/cce`).
 510 pub fn cce_config_dir() -> std::path::PathBuf {
 511     config_home().join("cce")
 512 }
 513 
 514 /// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
 515 ///
 516 /// Session-scoped files — logs, sockets, pid files — belong here rather than
 517 /// in `/tmp`, which is one namespace shared by every user on the machine: a
 518 /// fixed `/tmp/cce-*.log` is a path the first user to log in owns, and the
 519 /// sticky bit then denies everyone else. Creating on demand keeps call sites
 520 /// to one line; a failure surfaces when the caller opens its file, which it
 521 /// already has to handle.
 522 pub fn cce_runtime_dir() -> std::path::PathBuf {
 523     let dir = runtime_dir().join("cce");
 524     let _ = std::fs::create_dir_all(&dir);
 525     dir
 526 }
 527 
 528 pub fn get_config_path() -> std::path::PathBuf {
 529     cce_config_dir().join("config.kdl")
 530 }
 531 
 532 struct ConfigCache {
 533     last_modified: Option<std::time::SystemTime>,
 534     parsed: Option<serde_json::Value>,
 535     raw_content: String,
 536 }
 537 
 538 static CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
 539     last_modified: None,
 540     parsed: None,
 541     raw_content: String::new(),
 542 });
 543 
 544 /// The cce config parsed to JSON, cached on the config file's mtime (per process).
 545 /// Re-reads and re-parses only when `get_config_path()`'s modification time changes.
 546 /// The newest mtime across the shared config and the calling app's override
 547 /// file — the cache key for [`cached_config`], and what apps should poll for
 548 /// live-reload triggers. Compared by EQUALITY, so an app-file deletion (max
 549 /// drops back to the shared mtime) also invalidates.
 550 pub fn config_files_modified() -> Option<std::time::SystemTime> {
 551     let shared = std::fs::metadata(get_config_path()).ok().and_then(|m| m.modified().ok());
 552     let app = get_app_name()
 553         .and_then(|n| std::fs::metadata(get_app_config_path(&n)).ok())
 554         .and_then(|m| m.modified().ok());
 555     match (shared, app) {
 556         (Some(a), Some(b)) => Some(a.max(b)),
 557         (a, b) => a.or(b),
 558     }
 559 }
 560 
 561 pub fn cached_config() -> serde_json::Value {
 562     let path = get_config_path();
 563     // Both files participate in the parse (parse_kdl_to_json merges the
 564     // per-app override), so both participate in the cache key.
 565     let current_modified = config_files_modified();
 566 
 567     if let Ok(cache) = CONFIG_CACHE.read() {
 568         if cache.last_modified.is_some() && cache.last_modified == current_modified {
 569             if let Some(ref val) = cache.parsed {
 570                 return val.clone();
 571             }
 572         }
 573     }
 574 
 575     let content = std::fs::read_to_string(&path).unwrap_or_default();
 576     let val = parse_kdl_to_json(&content);
 577     if let Ok(mut cache) = CONFIG_CACHE.write() {
 578         cache.last_modified = current_modified;
 579         cache.parsed = Some(val.clone());
 580         cache.raw_content = content;
 581     }
 582     val
 583 }
 584 
 585 /// The raw text of the cce config, cached alongside [`cached_config`].
 586 pub fn cached_config_content() -> String {
 587     let _ = cached_config();
 588     CONFIG_CACHE.read().map(|c| c.raw_content.clone()).unwrap_or_default()
 589 }
 590 
 591 static SHARED_CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
 592     last_modified: None,
 593     parsed: None,
 594     raw_content: String::new(),
 595 });
 596 
 597 /// The SHARED config alone — the per-app override is deliberately NOT merged.
 598 /// For values that describe something outside the app (the compositor's
 599 /// window silhouette radius), where an app-local override restyles the app
 600 /// but must not desynchronize it from the DE. Mtime-cached like
 601 /// [`cached_config`].
 602 pub fn cached_shared_config() -> serde_json::Value {
 603     let path = get_config_path();
 604     let current_modified = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
 605 
 606     if let Ok(cache) = SHARED_CONFIG_CACHE.read() {
 607         if cache.last_modified.is_some() && cache.last_modified == current_modified {
 608             if let Some(ref val) = cache.parsed {
 609                 return val.clone();
 610             }
 611         }
 612     }
 613 
 614     let content = std::fs::read_to_string(&path).unwrap_or_default();
 615     let val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
 616         kdl_to_json(&doc)
 617     } else {
 618         serde_json::json!({})
 619     };
 620     if let Ok(mut cache) = SHARED_CONFIG_CACHE.write() {
 621         cache.last_modified = current_modified;
 622         cache.parsed = Some(val.clone());
 623         cache.raw_content = content;
 624     }
 625     val
 626 }
 627 
 628 /// Read an i64 at `pointer` from the SHARED config only (no per-app merge),
 629 /// or `default` — see [`cached_shared_config`].
 630 pub fn get_i64_shared(pointer: &str, default: i64) -> i64 {
 631     cached_shared_config().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
 632 }
 633 
 634 /// [`get_i64_shared`] without a default — for canonical-first alias chains
 635 /// (RFC Phase 7a) where absence must fall through to the next spelling.
 636 pub fn get_i64_shared_opt(pointer: &str) -> Option<i64> {
 637     cached_shared_config().pointer(pointer).and_then(|v| v.as_i64())
 638 }
 639 
 640 // ── Typed accessors over the cached config ──────────────────────────────────
 641 // Each reads the mtime-cached config and extracts a value at a JSON pointer
 642 // (e.g. "/notifications/enable"), returning the default when absent or mistyped.
 643 
 644 /// Read a boolean at `pointer` from the cached config, or `default`.
 645 pub fn get_bool(pointer: &str, default: bool) -> bool {
 646     cached_config().pointer(pointer).and_then(|v| v.as_bool()).unwrap_or(default)
 647 }
 648 
 649 /// Read an f32 at `pointer` from the cached config, or `default`.
 650 pub fn get_f32(pointer: &str, default: f32) -> f32 {
 651     cached_config()
 652         .pointer(pointer)
 653         .and_then(|v| v.as_f64())
 654         .map(|f| f as f32)
 655         .unwrap_or(default)
 656 }
 657 
 658 /// Read an i64 at `pointer` from the cached config, or `default`.
 659 pub fn get_i64(pointer: &str, default: i64) -> i64 {
 660     cached_config().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
 661 }
 662 
 663 /// Read a string at `pointer` from the cached config.
 664 pub fn get_string(pointer: &str) -> Option<String> {
 665     cached_config().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
 666 }
 667 
 668 /// Read a hex color string at `pointer` and parse it to raw sRGB RGBA (`[0,1]`).
 669 /// Apply [`crate::color::srgb_to_linear`] if your render target expects linear.
 670 pub fn get_color(pointer: &str) -> Option<[f32; 4]> {
 671     get_string(pointer).as_deref().and_then(crate::color::parse_hex_rgba)
 672 }
 673 
 674 /// Recursively search a JSON value for the first entry whose object key equals
 675 /// `key`, returning a reference to its value. Depth-first over objects and arrays.
 676 pub fn find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
 677     match val {
 678         serde_json::Value::Object(map) => {
 679             if let Some(found) = map.get(key) {
 680                 return Some(found);
 681             }
 682             for v in map.values() {
 683                 if let Some(found) = find_key(v, key) {
 684                     return Some(found);
 685                 }
 686             }
 687             None
 688         }
 689         serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_key(v, key)),
 690         _ => None,
 691     }
 692 }
 693 
 694 
 695 
 696 fn perform_rolling_backup(path: &str) {
 697     let config_path = get_config_path();
 698     if std::path::Path::new(path) != config_path {
 699         return;
 700     }
 701     if !std::path::Path::new(path).exists() {
 702         return;
 703     }
 704     let backup_dir = config_path.parent().unwrap().join("backups");
 705     if let Err(_) = fs::create_dir_all(&backup_dir) {
 706         return;
 707     }
 708     for i in (1..=4).rev() {
 709         let src = backup_dir.join(format!("config.kdl.{}.bak", i));
 710         let dst = backup_dir.join(format!("config.kdl.{}.bak", i + 1));
 711         if src.exists() {
 712             let _ = fs::rename(src, dst);
 713         }
 714     }
 715     let dst = backup_dir.join("config.kdl.1.bak");
 716     let _ = fs::copy(path, dst);
 717 }
 718 
 719 pub(crate) fn safe_write(path: &str, content: &str) -> bool {
 720     perform_rolling_backup(path);
 721     if let Some(parent) = std::path::Path::new(path).parent() {
 722         let _ = fs::create_dir_all(parent);
 723     }
 724     let temp_path = format!("{}.tmp", path);
 725     if fs::write(&temp_path, content).is_ok() {
 726         if fs::rename(&temp_path, path).is_ok() {
 727             return true;
 728         }
 729         let _ = fs::remove_file(&temp_path);
 730     }
 731     false
 732 }
 733 
 734 pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &str) -> bool {
 735     write_config_value_typed(path, key, value, default_section, None)
 736 }
 737 
 738 /// [`write_config_value`] with an explicit type annotation — see
 739 /// [`update_kdl_in_memory_typed`].
 740 pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
 741     let content = fs::read_to_string(path).unwrap_or_default();
 742     let mut doc = match content.parse::<kdl::KdlDocument>() {
 743         Ok(d) => d,
 744         Err(_) => kdl::KdlDocument::new(),
 745     };
 746 
 747     if update_kdl_in_memory_typed(&mut doc, key, value, default_section, forced_ty) {
 748         let updated_str = doc.to_string();
 749         return safe_write(path, &updated_str);
 750     }
 751     false
 752 }
 753 
 754 pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
 755     let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
 756     let parts: Vec<&str> = key_path.split('.').collect();
 757     if parts.is_empty() {
 758         return None;
 759     }
 760 
 761     let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 762 
 763     let (node_path, target_prop) = if is_property {
 764         (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 765     } else {
 766         (&parts[0..parts.len()], None)
 767     };
 768 
 769     let child_node = get_node_ref(&doc, node_path)?;
 770 
 771     if let Some(prop_name) = target_prop {
 772         let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
 773         entry.ty().map(|t| t.value().to_string())
 774     } else {
 775         let entry = child_node.entries().first()?;
 776         entry.ty().map(|t| t.value().to_string())
 777     }
 778 }
 779 
 780 pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
 781     let doc = match kdl_content.parse::<kdl::KdlDocument>() {
 782         Ok(d) => Some(d),
 783         Err(_) => None,
 784     };
 785     key_paths.iter().map(|key_path| {
 786         let doc = doc.as_ref()?;
 787         let parts: Vec<&str> = key_path.split('.').collect();
 788         if parts.is_empty() {
 789             return None;
 790         }
 791 
 792         let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 793 
 794         let (node_path, target_prop) = if is_property {
 795             (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 796         } else {
 797             (&parts[0..parts.len()], None)
 798         };
 799 
 800         let child_node = get_node_ref(doc, node_path)?;
 801 
 802         if let Some(prop_name) = target_prop {
 803             let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
 804             entry.ty().map(|t| t.value().to_string())
 805         } else {
 806             let entry = child_node.entries().first()?;
 807             entry.ty().map(|t| t.value().to_string())
 808         }
 809     }).collect()
 810 }
 811 
 812 
 813 #[cfg(test)]
 814 mod tests {
 815     /// A material node's frost and finish are written as PROPERTIES of a
 816     /// `frost` / `finish` child (RFC material § 5), created on demand under
 817     /// `style.surface.material.<name>`, and read back through the same
 818     /// pointer the loader uses.
 819     #[test]
 820     fn material_keys_write_as_frost_and_finish_props() {
 821         use super::{parse_kdl_to_json, update_kdl_in_memory};
 822         let mut doc = kdl::KdlDocument::new();
 823         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.backdrop_compression", "0.6", "style"));
 824         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
 825         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
 826         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
 827         assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
 828         let text = doc.to_string();
 829         let val = parse_kdl_to_json(&text);
 830         assert_eq!(val.pointer("/style/surface/material/glass/frost/backdrop_compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
 831         assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
 832         assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
 833         assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
 834         assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
 835         // One `frost` node with two props, not two `frost` nodes.
 836         assert_eq!(text.matches("frost").count(), 1, "{text}");
 837         assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
 838     }
 839 
 840     #[test]
 841     fn unit_annotations_become_len_strings() {
 842         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");
 843         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
 844         assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
 845         assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
 846         assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
 847     }
 848 
 849     #[test]
 850     fn unit_strings_write_back_annotated() {
 851         let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
 852         let out = json_to_kdl_string(&v);
 853         assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
 854         assert!(out.contains("depth=(f64)0.15"), "{out}");
 855         let back = parse_kdl_to_json(&out);
 856         assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
 857     }
 858 
 859     #[test]
 860     fn typed_write_keeps_and_sets_units() {
 861         let mut doc: kdl::KdlDocument = "style {\n    relief width=(mm)2.0\n}\n".parse().unwrap();
 862         // A bare number over a (mm) slot stays mm.
 863         assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
 864         let v = parse_kdl_to_json(&doc.to_string());
 865         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
 866         // A suffixed value sets the unit.
 867         assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
 868         let v = parse_kdl_to_json(&doc.to_string());
 869         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
 870     }
 871 
 872     #[test]
 873     fn app_name_strips_the_kernels_deleted_marker() {
 874         use super::app_name_from_exe_basename as name;
 875         assert_eq!(name("cce-status-interface"), "cce-status-interface");
 876         assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
 877         // Only the exact trailing marker: a name that merely contains the
 878         // word, or an unspaced variant, is left alone.
 879         assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
 880         assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
 881     }
 882 
 883     use super::*;
 884 
 885     #[test]
 886     fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
 887         // No env mutation: reading the real base keeps this correct both in a
 888         // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
 889         // avoids racing every other test in the process.
 890         let base = runtime_dir();
 891         assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
 892         let dir = cce_runtime_dir();
 893         assert_eq!(dir, base.join("cce"));
 894         // The create-on-demand contract callers depend on: they open a file
 895         // inside this directory without creating it themselves.
 896         assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
 897     }
 898 
 899     #[test]
 900     fn relief_annotated_string_passes_through() {
 901         // The (relief) custom value type: an annotated string prop must
 902         // survive kdl_to_json as a plain JSON string at its pointer.
 903         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";
 904         let val = parse_kdl_to_json(content);
 905         assert_eq!(
 906             val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
 907             Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
 908         );
 909     }
 910 
 911     #[test]
 912     fn test_nested_parsing() {
 913         let content = "style {\n    status box_opacity=(f64)0.75\n}\n";
 914         let val = parse_kdl_to_json(content);
 915         println!("val = {:?}", val);
 916         let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
 917         assert_eq!(sec, "style");
 918         assert_eq!(node, "status");
 919         assert_eq!(prop, Some("box_opacity".to_string()));
 920         
 921         let sec_val = val.get(&sec).unwrap();
 922         let node_val = sec_val.get(&node).unwrap();
 923         let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
 924         assert_eq!(prop_val.as_f64().unwrap(), 0.75);
 925     }
 926 
 927     #[test]
 928     fn relief_keys_write_as_properties_and_round_trip() {
 929         // `relief` is a PROP_NODES member: style.surface.relief.* must land as
 930         // properties on the existing relief node (the config.kdl shape), not
 931         // as duplicate child nodes shadowing the depth=/width= properties.
 932         let content = "style {\n    surface {\n        relief depth=(f64)0.15 width=(f64)9.3\n    }\n}\n";
 933         let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
 934         let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
 935         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
 936         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.depth", "0.3", "style"));
 937         let out = doc.to_string();
 938         // Still one relief node, no child block grown under it.
 939         assert_eq!(out.matches("relief").count(), 1, "out: {out}");
 940         assert!(!out.contains("relief {"), "out: {out}");
 941 
 942         // The reload path reads through parse_kdl_to_json: the new property
 943         // must surface at the same dotted path the style registry maps.
 944         let val = parse_kdl_to_json(&out);
 945         let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
 946         assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
 947         assert_eq!(relief.get("depth").unwrap().as_f64().unwrap(), 0.3);
 948         assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
 949     }
 950 
 951     #[test]
 952     fn test_get_kdl_type_annotation() {
 953         let content = "input {\n    accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n    touchpad {\n        gestures pinch=(bool)true\n    }\n}\n";
 954         let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
 955         assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
 956         
 957         let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
 958         assert_eq!(ty2, Some("bool".to_string()));
 959 
 960         let keys = vec![
 961             "input.accel_profile".to_string(),
 962             "input.touchpad.gestures.pinch".to_string(),
 963             "input.invalid_key".to_string(),
 964         ];
 965         let tys = get_kdl_type_annotations(content, &keys);
 966         assert_eq!(tys.len(), 3);
 967         assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
 968         assert_eq!(tys[1], Some("bool".to_string()));
 969         assert_eq!(tys[2], None);
 970     }
 971 
 972     #[test]
 973     fn test_json_to_kdl_with_special_annotations() {
 974         let mut annotations = std::collections::HashMap::new();
 975         annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
 976         
 977         let mut desktop_map = serde_json::Map::new();
 978         desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
 979         
 980         let mut surface_map = serde_json::Map::new();
 981         surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
 982         
 983         let mut style_map = serde_json::Map::new();
 984         style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
 985         
 986         let mut root_map = serde_json::Map::new();
 987         root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
 988         
 989         let root = serde_json::Value::Object(root_map);
 990         let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
 991         println!("Generated KDL:\n{}", kdl_str);
 992         
 993         let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
 994         assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
 995     }
 996 
 997     #[test]
 998     fn test_brightness_annotations() {
 999         let mut edp_map = serde_json::Map::new();
1000         edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
1001         edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
1002         edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
1003         edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
1004 
1005         let mut output_map = serde_json::Map::new();
1006         output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
1007 
1008         let mut root_map = serde_json::Map::new();
1009         root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
1010 
1011         let root = serde_json::Value::Object(root_map);
1012         let kdl_str = json_to_kdl_string(&root);
1013         println!("Generated KDL for brightness:\n{}", kdl_str);
1014 
1015         let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1016         
1017         let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
1018         let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
1019         
1020         let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
1021         assert_eq!(up_entry.ty().unwrap().value(), "keybind");
1022 
1023         let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
1024         assert_eq!(down_entry.ty().unwrap().value(), "keybind");
1025 
1026         let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
1027         assert_eq!(interval_entry.ty().unwrap().value(), "i64");
1028     }
1029 
1030     #[test]
1031     fn test_root_plate_menubar_statusbar_styling() {
1032         // Global color state: serialize against the other reload_colors tests,
1033         // and fire both once-per-process live-config loads before our reload
1034         // so neither can rewrite the state mid-assert.
1035         let _guard = crate::color::test_color_state_lock();
1036         let _ = crate::color::root_plate_statusbar_blur();
1037         crate::layout::lazy_init_style_registry();
1038 
1039         let content = r##"
1040             style {
1041                 surface {
1042                     plate {
1043                         root blur=(f64)0.1 color=(rgba)"#5e657acf" corner_radius=(i64)12 {
1044                             menubar blur=(bool)true color=(rgba)"#1a1d26d0" text_color=(rgba)"#e2e4f0ff"
1045                         }
1046                     }
1047                     statusbar blur=(bool)false color=(rgba)"#12141cd0" text_color=(rgba)"#b5b9c8ff"
1048                 }
1049                 control {
1050                     dropdown color=(rgba)"#08080cff"
1051                 }
1052                 data {
1053                     textbox placeholder_text_color=(rgba)"#60606aff"
1054                 }
1055             }
1056         "##;
1057         
1058         // Parse into json and set colors
1059         crate::color::reload_colors(content);
1060 
1061         // Verify values are parsed correctly through the root_plate_* getters.
1062         assert_eq!(crate::color::root_plate_menubar_blur(), true);
1063         
1064         let dd_color = crate::color::dropdown_background_color();
1065         assert!((dd_color[0] - crate::color::srgb_to_linear(8.0 / 255.0)).abs() < 0.0001);
1066         
1067         let placeholder_color = crate::color::textbox_placeholder_text_color();
1068         assert_eq!(placeholder_color, [0x60, 0x60, 0x6a]);
1069         assert_eq!(crate::color::root_plate_statusbar_blur(), false);
1070 
1071         // Colors are in sRGB converted to linear, let's verify text colors
1072         let menubar_txt = crate::color::root_plate_menubar_text_color();
1073         assert!(menubar_txt[0] > 0.0);
1074         let statusbar_txt = crate::color::root_plate_statusbar_text_color();
1075         assert!(statusbar_txt[0] > 0.0);
1076     }
1077 
1078     /// `style.surface.plate.root.*` is the only root-plate spelling: the
1079     /// legacy `root plate.*` block is ignored, whether it stands beside the
1080     /// canonical block or alone (its read-alias was removed 2026-09-06).
1081     #[test]
1082     fn test_plate_root_canonical_spelling() {
1083         // Global color state: serialize against the other reload_colors tests,
1084         // and fire both once-per-process live-config loads before our reload
1085         // so neither can rewrite the state mid-assert.
1086         let _guard = crate::color::test_color_state_lock();
1087         let _ = crate::color::root_plate_corner_radius();
1088         crate::layout::lazy_init_style_registry();
1089 
1090         let content = r##"
1091             style {
1092                 surface {
1093                     plate {
1094                         root corner_radius=(i64)17 {
1095                             menubar blur=(bool)true color=(rgba)"#1a1d26d0"
1096                         }
1097                     }
1098                     backplate corner_radius=(i64)9
1099                 }
1100             }
1101         "##;
1102         crate::color::reload_colors(content);
1103         assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "canonical read; legacy ignored");
1104         assert_eq!(crate::color::root_plate_menubar_blur(), true);
1105 
1106         // A legacy-only spelling no longer feeds the getter: the value from
1107         // the canonical load above stands.
1108         let legacy = r##"
1109             style {
1110                 surface {
1111                     backplate corner_radius=(i64)9
1112                 }
1113             }
1114         "##;
1115         crate::color::reload_colors(legacy);
1116         assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
1117     }
1118 
1119     #[test]
1120     /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
1121     /// trip cce-data-editor saves through — it used to come back as ONE arg,
1122     /// `rounded_apps "a b"`, and the compositor's allowlist then matched
1123     /// nothing (Claude Desktop lost its corners after every save).
1124     #[test]
1125     fn test_string_list_roundtrip() {
1126         let content = "window_manager {\n    rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n    corner_shape (f64)4.5\n}\n";
1127         let val = parse_kdl_to_json(content);
1128         let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
1129         assert_eq!(
1130             list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
1131             vec!["claude-desktop", "com.anthropic.Claude"]
1132         );
1133         let kdl_str = json_to_kdl_string(&val);
1134         assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
1135         // And it re-parses to the same two args, not one.
1136         let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1137         let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
1138         let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
1139         assert_eq!(ra.entries().len(), 2);
1140         // A single-arg string node stays a plain string.
1141         let single = parse_kdl_to_json("window_manager {\n    rounded_apps \"claude-desktop\"\n}\n");
1142         assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
1143     }
1144 
1145     #[test]
1146     fn test_vec2i_lossless_roundtrip() {
1147         let content = "style {\n    surface {\n        cloud {\n            position_default (vec2i)100 200\n        }\n    }\n}\n";
1148         let val = parse_kdl_to_json(content);
1149         println!("Parsed KDL to JSON: {:?}", val);
1150         
1151         let position_default_val = val.get("style").unwrap()
1152             .get("surface").unwrap()
1153             .get("cloud").unwrap()
1154             .get("position_default").unwrap();
1155         assert_eq!(position_default_val.as_str().unwrap(), "100 200");
1156 
1157         let mut annotations = std::collections::HashMap::new();
1158         annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
1159         
1160         let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
1161         println!("Generated KDL:\n{}", kdl_str);
1162         
1163         // Assert that (vec2i)100 200 is preserved without quotes
1164         assert!(kdl_str.contains("position_default (vec2i)100 200"));
1165         
1166         // Test update_kdl_in_memory preserves and updates the KDL Document correctly
1167         let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1168         let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
1169         assert!(updated);
1170         let updated_kdl = doc.to_string();
1171         println!("Updated KDL:\n{}", updated_kdl);
1172         assert!(updated_kdl.contains("position_default (vec2i)150 250"));
1173     }
1174 }
1175 
1176 fn format_kdl_type(ty: &str) -> String {
1177     let is_ident = !ty.is_empty()
1178         && !ty.chars().next().unwrap().is_ascii_digit()
1179         && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
1180     if is_ident {
1181         ty.to_string()
1182     } else {
1183         format!("\"{}\"", ty.replace('\\', "\\\\").replace('"', "\\\""))
1184     }
1185 }
1186 
1187 fn format_kdl_identifier(name: &str) -> String {
1188     let is_ident = !name.is_empty()
1189         && !name.chars().next().unwrap().is_ascii_digit()
1190         && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
1191     if is_ident {
1192         name.to_string()
1193     } else {
1194         format!("\"{}\"", name.replace('\\', "\\\\").replace('"', "\\\""))
1195     }
1196 }
1197 
1198 pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
1199     value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
1200 }
1201 
1202 pub fn value_to_kdl_with_annotations(
1203     key: &str,
1204     val: &serde_json::Value,
1205     indent: usize,
1206     parent_path: &str,
1207     annotations: &std::collections::HashMap<String, String>,
1208 ) -> String {
1209     let indent_str = "    ".repeat(indent);
1210     let current_path = if parent_path.is_empty() {
1211         key.to_string()
1212     } else {
1213         format!("{}.{}", parent_path, key)
1214     };
1215 
1216     match val {
1217         serde_json::Value::Object(map) => {
1218             let has_objects = map.values().any(|v| v.is_object());
1219             if has_objects {
1220                 let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1221                 for (k, v) in map {
1222                     out.push_str(&value_to_kdl_with_annotations(k, v, indent + 1, &current_path, annotations));
1223                 }
1224                 out.push_str(&format!("{}}}\n", indent_str));
1225                 out
1226             } else {
1227                 let mut prop_parts = Vec::new();
1228                 let mut child_parts = Vec::new();
1229                 for (prop_name, prop_val) in map {
1230                     let prop_path = format!("{}.{}", current_path, prop_name);
1231                     let is_vec2i = annotations.get(&prop_path).map_or(false, |a| a == "vec2i");
1232                     if is_vec2i {
1233                         if let serde_json::Value::String(ref s) = prop_val {
1234                             child_parts.push(format!("{}{} (vec2i){}\n", "    ".repeat(indent + 1), prop_name, s));
1235                         }
1236                     } else {
1237                         let (val_str, val_ty) = match prop_val {
1238                             serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1239                             serde_json::Value::Number(num) => {
1240                                 if prop_name == "light_source_position" {
1241                                     (num.to_string(), Some("radian".to_string()))
1242                                 } else if num.is_f64() {
1243                                     (num.to_string(), Some("f64".to_string()))
1244                                 } else {
1245                                     (num.to_string(), Some("i64".to_string()))
1246                                 }
1247                             }
1248                             serde_json::Value::String(s) => {
1249                                 if let Some(len) = crate::units::Len::parse(s) {
1250                                     (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1251                                 } else if let Some(anno) = annotations.get(&prop_path) {
1252                                     if anno == "vec2i" {
1253                                         (s.clone(), Some(anno.clone()))
1254                                     } else {
1255                                         (format!("\"{}\"", s), Some(anno.clone()))
1256                                     }
1257                                 } else if s.starts_with('#') {
1258                                     let s_clean = s.trim_start_matches('#');
1259                                     let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1260                                     (format!("\"{}\"", s), Some(ty.to_string()))
1261                                 } 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") {
1262                                     (format!("\"{}\"", s), Some("keybind".to_string()))
1263                                 } else {
1264                                     (format!("\"{}\"", s), None)
1265                                 }
1266                             }
1267                             _ => (prop_val.to_string(), None),
1268                         };
1269                         if let Some(ty) = val_ty {
1270                             prop_parts.push(format!("{}=({}){}", prop_name, format_kdl_type(&ty), val_str));
1271                         } else {
1272                             prop_parts.push(format!("{}={}", prop_name, val_str));
1273                         }
1274                     }
1275                 }
1276                 if !child_parts.is_empty() {
1277                     let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1278                     if !prop_parts.is_empty() {
1279                         out.push_str(&format!("{}{}\n", "    ".repeat(indent + 1), prop_parts.join(" ")));
1280                     }
1281                     for child in child_parts {
1282                         out.push_str(&child);
1283                     }
1284                     out.push_str(&format!("{}}}\n", indent_str));
1285                     out
1286                 } else {
1287                     format!("{}{} {}\n", indent_str, key, prop_parts.join(" "))
1288                 }
1289             }
1290         }
1291         serde_json::Value::Array(arr) => {
1292             // An array of strings is one node with several positional args
1293             // (the shape `kdl_to_json` reads `rounded_apps "a" "b"` into);
1294             // any other array is one node per item (key_bindings' objects).
1295             if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
1296                 let args: Vec<String> = arr
1297                     .iter()
1298                     .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", s)))
1299                     .collect();
1300                 return format!("{}{} {}\n", indent_str, key, args.join(" "));
1301             }
1302             let mut out = String::new();
1303             for item in arr {
1304                 out.push_str(&value_to_kdl_with_annotations(key, item, indent, parent_path, annotations));
1305             }
1306             out
1307         }
1308         _ => {
1309             let (val_str, val_ty) = match val {
1310                 serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1311                 serde_json::Value::Number(num) => {
1312                     if key == "light_source_position" {
1313                         (num.to_string(), Some("radian".to_string()))
1314                     } else if num.is_f64() {
1315                         (num.to_string(), Some("f64".to_string()))
1316                     } else {
1317                         (num.to_string(), Some("i64".to_string()))
1318                     }
1319                 }
1320                 serde_json::Value::String(s) => {
1321                     if let Some(len) = crate::units::Len::parse(s) {
1322                         (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1323                     } else if let Some(anno) = annotations.get(&current_path) {
1324                         if anno == "vec2i" {
1325                             (s.clone(), Some(anno.clone()))
1326                         } else {
1327                             (format!("\"{}\"", s), Some(anno.clone()))
1328                         }
1329                     } else if s.starts_with('#') {
1330                         let s_clean = s.trim_start_matches('#');
1331                         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1332                         (format!("\"{}\"", s), Some(ty.to_string()))
1333                     } 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") {
1334                         (format!("\"{}\"", s), Some("keybind".to_string()))
1335                     } else {
1336                         (format!("\"{}\"", s), None)
1337                     }
1338                 }
1339                 _ => (val.to_string(), None),
1340             };
1341             if let Some(ty) = val_ty {
1342                 format!("{}{} ({}){}\n", indent_str, key, format_kdl_type(&ty), val_str)
1343             } else {
1344                 format!("{}{} {}\n", indent_str, key, val_str)
1345             }
1346         }
1347     }
1348 }
1349 
1350 pub fn json_to_kdl_string(val: &serde_json::Value) -> String {
1351     json_to_kdl_string_with_annotations(val, &std::collections::HashMap::new())
1352 }
1353 
1354 pub fn json_to_kdl_string_with_annotations(
1355     val: &serde_json::Value,
1356     annotations: &std::collections::HashMap<String, String>,
1357 ) -> String {
1358     let mut out = String::new();
1359     if let serde_json::Value::Object(map) = val {
1360         for (sec_name, sec_val) in map {
1361             if let serde_json::Value::Object(sec_map) = sec_val {
1362                 out.push_str(&format!("{} {{\n", format_kdl_identifier(sec_name)));
1363                 for (k, v) in sec_map {
1364                     out.push_str(&value_to_kdl_with_annotations(k, v, 1, sec_name, annotations));
1365                 }
1366                 out.push_str("}\n");
1367             } else {
1368                 out.push_str(&value_to_kdl_with_annotations(sec_name, sec_val, 0, "", annotations));
1369             }
1370         }
1371     }
1372     out
1373 }
1374 
1375 pub fn get_app_recent_files_path() -> std::path::PathBuf {
1376     let app_name = get_app_name().unwrap_or_else(|| "cce-app".to_string());
1377     get_config_path().parent().unwrap().join(app_name).join("recent-files.kdl")
1378 }
1379 
1380 pub fn load_recent_files() -> Vec<String> {
1381     let path = get_app_recent_files_path();
1382     if path.exists() {
1383         if let Ok(content) = std::fs::read_to_string(&path) {
1384             if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
1385                 if let Some(recent_node) = doc.get("recent") {
1386                     if let Some(children) = recent_node.children() {
1387                         let mut files = Vec::new();
1388                         for node in children.nodes() {
1389                             if node.name().value() == "file" {
1390                                 if let Some(entry) = node.entries().first() {
1391                                     if let kdl::KdlValue::String(s) = entry.value() {
1392                                         files.push(s.clone());
1393                                     }
1394                                 }
1395                             }
1396                         }
1397                         return files;
1398                     }
1399                 }
1400             }
1401         }
1402     }
1403     Vec::new()
1404 }
1405 
1406 pub fn save_recent_files(files: &[String]) {
1407     let path = get_app_recent_files_path();
1408     if let Some(parent) = path.parent() {
1409         let _ = std::fs::create_dir_all(parent);
1410     }
1411     let mut kdl_str = "recent {\n".to_string();
1412     for file in files {
1413         kdl_str.push_str(&format!("    file \"{}\"\n", file));
1414     }
1415     kdl_str.push_str("}\n");
1416     let _ = std::fs::write(path, kdl_str);
1417 }