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, ¤t_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(¤t_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 }