GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git
src/config.rs (69.4K)
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` once per process, 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 ///
220 /// Cached because every config getter reaches it (through
221 /// [`config_files_modified`]), many of them per frame; the stripped basename
222 /// cannot change for the life of the process.
223 pub fn get_app_name() -> Option<String> {
224 app_name().map(str::to_string)
225 }
226
227 /// [`get_app_name`] without the allocation.
228 fn app_name() -> Option<&'static str> {
229 static NAME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
230 NAME.get_or_init(|| {
231 std::env::current_exe()
232 .ok()
233 .and_then(|p| p.file_name().and_then(|s| s.to_str().map(app_name_from_exe_basename)))
234 })
235 .as_deref()
236 }
237
238 /// [`get_app_name`]'s normalization: the kernel's ` (deleted)` marker on an
239 /// unlinked executable is not part of the name.
240 fn app_name_from_exe_basename(basename: &str) -> String {
241 basename.strip_suffix(" (deleted)").unwrap_or(basename).to_string()
242 }
243
244 pub fn get_app_config_path(app_name: &str) -> std::path::PathBuf {
245 get_config_path().parent().unwrap().join(app_name).join("config.kdl")
246 }
247
248 fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
249 match (a, b) {
250 (serde_json::Value::Object(a_map), serde_json::Value::Object(b_map)) => {
251 for (k, v) in b_map {
252 if !v.is_null() {
253 merge_json(a_map.entry(k.clone()).or_insert(serde_json::Value::Null), v);
254 }
255 }
256 }
257 (a_val, b_val) => {
258 *a_val = b_val.clone();
259 }
260 }
261 }
262
263 pub fn parse_kdl_to_json(content: &str) -> serde_json::Value {
264 let mut main_val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
265 kdl_to_json(&doc)
266 } else {
267 serde_json::json!({})
268 };
269
270 if let Some(app_name) = app_name() {
271 let app_path = get_app_config_path(app_name);
272 if let Ok(override_content) = std::fs::read_to_string(&app_path) {
273 if let Ok(override_doc) = override_content.parse::<kdl::KdlDocument>() {
274 let override_val = kdl_to_json(&override_doc);
275 merge_json(&mut main_val, &override_val);
276 }
277 }
278 }
279
280 main_val
281 }
282
283 pub fn update_json_in_memory(val_obj: &mut Value, key: &str, value: &str, default_section: &str) -> bool {
284 let j_val = if let Ok(parsed_val) = serde_json::from_str::<Value>(value) {
285 parsed_val
286 } else {
287 serde_json::json!(value)
288 };
289
290 let mut updated = false;
291 if let Some(obj) = val_obj.as_object_mut() {
292 for (_sec_name, sec_val) in obj.iter_mut() {
293 if let Some(sec_obj) = sec_val.as_object_mut() {
294 if sec_obj.contains_key(key) {
295 sec_obj.insert(key.to_string(), j_val.clone());
296 updated = true;
297 break;
298 }
299 }
300 }
301 if !updated {
302 if let Some(sec_obj) = obj.get_mut(default_section).and_then(|s| s.as_object_mut()) {
303 sec_obj.insert(key.to_string(), j_val);
304 updated = true;
305 } else {
306 let mut map = serde_json::Map::new();
307 map.insert(key.to_string(), j_val);
308 obj.insert(default_section.to_string(), Value::Object(map));
309 updated = true;
310 }
311 }
312 }
313 updated
314 }
315
316 pub fn parse_config_path(key: &str, default_section: &str) -> (String, String, Option<String>) {
317 let parts: Vec<&str> = key.split('.').collect();
318 if parts.len() == 3 {
319 (parts[0].to_string(), parts[1].to_string(), Some(parts[2].to_string()))
320 } else if parts.len() == 2 {
321 (parts[0].to_string(), parts[1].to_string(), None)
322 } else {
323 (default_section.to_string(), key.to_string(), None)
324 }
325 }
326
327 const PROP_NODES: &[&str] = &[
328 "gestures", "key_bindings", "pointer_bind", "gesture_bind",
329 "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
330 "status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
331 "menubar", "statusbar", "node", "relief", "frost", "finish",
332 // The relief's two shapes (`relief { wall height=… profile=… knobs=… ; edge … }`).
333 "wall", "edge"
334 ];
335
336 fn get_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
337 if path.is_empty() {
338 return None;
339 }
340 let idx = doc.nodes().iter().position(|n| n.name().value() == path[0])?;
341 let node = &mut doc.nodes_mut()[idx];
342 if path.len() == 1 {
343 Some(node)
344 } else {
345 get_node_mut(node.children_mut().as_mut()?, &path[1..])
346 }
347 }
348
349 /// Remove `key` from the document — a property off its `PROP_NODES` node, or
350 /// the whole node otherwise — creating nothing on the way. `true` when
351 /// something was removed; a key that is not there is `false`, so a writer
352 /// can tell "migrated" from "was already clean".
353 pub fn remove_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str) -> bool {
354 let parts: Vec<&str> = key.split('.').collect();
355 let Some(leaf) = parts.last().copied() else {
356 return false;
357 };
358 // A PROP_NODES parent holds its keys as properties — but it may also
359 // hold child NODES (`relief { wall … }`), so a leaf that is not among
360 // the properties is looked for among the children before giving up.
361 let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
362 if is_property {
363 let Some(node) = get_node_mut(doc, &parts[..parts.len() - 1]) else {
364 return false;
365 };
366 let before = node.entries().len();
367 node.entries_mut().retain(|e| e.name().map(|n| n.value()) != Some(leaf));
368 if node.entries().len() != before {
369 return true;
370 }
371 }
372 let parent: &mut kdl::KdlDocument = if parts.len() == 1 {
373 doc
374 } else {
375 match get_node_mut(doc, &parts[..parts.len() - 1]).and_then(|n| n.children_mut().as_mut()) {
376 Some(children) => children,
377 None => return false,
378 }
379 };
380 let before = parent.nodes().len();
381 parent.nodes_mut().retain(|n| n.name().value() != leaf);
382 parent.nodes().len() != before
383 }
384
385 fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
386 if path.is_empty() {
387 return None;
388 }
389 let segment = path[0];
390 let idx = if let Some(i) = doc.nodes().iter().position(|n| n.name().value() == segment) {
391 i
392 } else {
393 let new_node = format!("{}\n", segment).parse::<kdl::KdlNode>().ok()?;
394 doc.nodes_mut().push(new_node);
395 doc.nodes().len() - 1
396 };
397 if path.len() == 1 {
398 Some(&mut doc.nodes_mut()[idx])
399 } else {
400 let children = doc.nodes_mut()[idx].ensure_children();
401 get_or_create_node_mut(children, &path[1..])
402 }
403 }
404
405 fn get_node_ref<'a>(doc: &'a kdl::KdlDocument, path: &[&str]) -> Option<&'a kdl::KdlNode> {
406 if path.is_empty() {
407 return None;
408 }
409 let segment = path[0];
410 let node = doc.nodes().iter().find(|n| n.name().value() == segment)?;
411 if path.len() == 1 {
412 Some(node)
413 } else {
414 let children = node.children()?;
415 get_node_ref(children, &path[1..])
416 }
417 }
418
419 pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str, default_section: &str) -> bool {
420 update_kdl_in_memory_typed(doc, key, value, default_section, None)
421 }
422
423 /// [`update_kdl_in_memory`] with an explicit type annotation for the written
424 /// entry. `forced_ty` overrides both the value-shape inference and the
425 /// preserved existing annotation — how a writer ESTABLISHES a custom type
426 /// (e.g. cce-relief writing `(bevel)` knob keys into a config that never had
427 /// them; preservation alone can't create the annotation).
428 pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value: &str, _default_section: &str, forced_ty: Option<&str>) -> bool {
429 let parts: Vec<&str> = key.split('.').collect();
430 if parts.is_empty() {
431 return false;
432 }
433
434 let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
435
436 let (node_path, target_prop) = if is_property {
437 (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
438 } else {
439 (&parts[0..parts.len()], None)
440 };
441
442 let child_node = if let Some(node) = get_or_create_node_mut(doc, node_path) {
443 node
444 } else {
445 return false;
446 };
447
448 let existing_ty = if let Some(ref prop_name) = target_prop {
449 child_node.entries().iter()
450 .find(|e| e.name().map(|n| n.value()) == Some(prop_name))
451 .and_then(|e| e.ty().map(|t| t.value().to_string()))
452 } else {
453 child_node.entries().first()
454 .and_then(|e| e.ty().map(|t| t.value().to_string()))
455 };
456
457 let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
458 (kdl::KdlValue::Bool(b), Some("bool".to_string()))
459 } else if let Some(len) = crate::units::Len::parse(value) {
460 // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
461 // stays a number the editor's spinbox can step.
462 (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
463 } else if value.starts_with('#') {
464 let s_clean = value.trim_start_matches('#');
465 let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
466 (kdl::KdlValue::String(value.to_string()), Some(ty.to_string()))
467 } else if value.contains('.') {
468 if let Ok(f) = value.parse::<f64>() {
469 (kdl::KdlValue::Base10Float(f), Some("f64".to_string()))
470 } else {
471 (kdl::KdlValue::String(value.to_string()), None)
472 }
473 } else if let Ok(i) = value.parse::<i64>() {
474 (kdl::KdlValue::Base10(i), Some("i64".to_string()))
475 } else {
476 let s = value.trim_matches('"').to_string();
477 (kdl::KdlValue::String(s), None)
478 };
479
480 if let Some(ref ext_ty) = existing_ty {
481 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" {
482 kdl_ty = Some(ext_ty.clone());
483 }
484 // A bare number written over a unit-annotated slot keeps the unit:
485 // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
486 // to logical px.
487 if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().is_none_or(|t| t == "f64" || t == "i64") {
488 kdl_ty = Some(ext_ty.clone());
489 }
490 }
491 if let Some(f) = forced_ty {
492 kdl_ty = Some(f.to_string());
493 }
494
495 if let Some(prop_name) = target_prop {
496 let mut found = false;
497 for entry in child_node.entries_mut() {
498 if let Some(id) = entry.name() {
499 if id.value() == prop_name {
500 *entry = kdl::KdlEntry::new_prop(prop_name.clone(), kdl_val.clone());
501 if let Some(ref ty) = kdl_ty {
502 entry.set_ty(ty.as_str());
503 }
504 found = true;
505 break;
506 }
507 }
508 }
509 if !found {
510 let mut entry = kdl::KdlEntry::new_prop(prop_name, kdl_val);
511 if let Some(ref ty) = kdl_ty {
512 entry.set_ty(ty.as_str());
513 }
514 child_node.entries_mut().push(entry);
515 }
516 } else {
517 child_node.entries_mut().clear();
518 if kdl_ty.as_deref() == Some("vec2i") {
519 let parts: Vec<&str> = value.split_whitespace().collect();
520 for (idx, part) in parts.iter().enumerate() {
521 if let Ok(i) = part.parse::<i64>() {
522 let mut entry = kdl::KdlEntry::new(kdl::KdlValue::Base10(i));
523 if idx == 0 {
524 entry.set_ty("vec2i");
525 }
526 child_node.entries_mut().push(entry);
527 }
528 }
529 } else {
530 let mut entry = kdl::KdlEntry::new(kdl_val);
531 if let Some(ref ty) = kdl_ty {
532 entry.set_ty(ty.as_str());
533 }
534 child_node.entries_mut().push(entry);
535 }
536 }
537
538 true
539 }
540
541 /// XDG config base directory: `$XDG_CONFIG_HOME`, else `~/.config`.
542 ///
543 /// Under this crate's own tests it is a per-process directory nobody
544 /// creates, so every config read (`config.kdl`, a per-app override,
545 /// `input.kdl`) finds no file and the toolkit's defaults are what a test
546 /// sees. Until 2026-10-07 the tests read the machine's `~/.config/cce`:
547 /// six of them failed on the user's desktop (a `relief edge height` pinned
548 /// the plate rise the heightfield tests set; the frost and blur keys moved
549 /// the material ones) and passed on a machine without that config. A test
550 /// that wants a configuration loads it from a string (`reload_colors`).
551 /// Dependents' tests link the non-test build and are unaffected.
552 pub fn config_home() -> std::path::PathBuf {
553 #[cfg(any(test, feature = "test-isolation"))]
554 {
555 return std::env::temp_dir().join(format!("cce-ui-test-config-{}", std::process::id()));
556 }
557 #[allow(unreachable_code)]
558 match std::env::var("XDG_CONFIG_HOME") {
559 Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
560 _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"),
561 }
562 }
563
564 /// XDG data base directory: `$XDG_DATA_HOME`, else `~/.local/share`.
565 pub fn data_home() -> std::path::PathBuf {
566 match std::env::var("XDG_DATA_HOME") {
567 Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
568 _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
569 .join(".local")
570 .join("share"),
571 }
572 }
573
574 /// XDG state base directory: `$XDG_STATE_HOME`, else `~/.local/state`.
575 ///
576 /// State is what should survive a restart but is not worth backing up or
577 /// syncing like data, and is not the user's to edit like config: history,
578 /// open tabs, logs, a "never ask again" list. A relative `$XDG_STATE_HOME`
579 /// counts as unset, as the XDG spec says it must (an empty one too).
580 pub fn state_home() -> std::path::PathBuf {
581 match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
582 Some(x) if x.is_absolute() => x,
583 _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
584 .join(".local")
585 .join("state"),
586 }
587 }
588
589 /// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
590 ///
591 /// Unlike the other bases there is no `~/...` fallback to construct: the
592 /// runtime dir is `/run/user/UID`, created by pam_systemd at login and mode
593 /// 0700. An unset variable means we are outside a login session, where the
594 /// shared temp dir is the honest answer rather than a path we would have to
595 /// invent (and could not create with the right ownership anyway).
596 pub fn runtime_dir() -> std::path::PathBuf {
597 match std::env::var("XDG_RUNTIME_DIR") {
598 Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
599 _ => std::env::temp_dir(),
600 }
601 }
602
603 /// The cce config directory (`<config_home>/cce`).
604 pub fn cce_config_dir() -> std::path::PathBuf {
605 config_home().join("cce")
606 }
607
608 /// The cce state directory (`<state_home>/cce`); each app keeps its own
609 /// subdirectory under it (`cce/browser`, `cce/mail`, …). Not created here:
610 /// a caller creates the subdirectory it writes, with the mode it needs.
611 pub fn cce_state_dir() -> std::path::PathBuf {
612 state_home().join("cce")
613 }
614
615 /// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
616 ///
617 /// Session-scoped files — logs, sockets, pid files — belong here rather than
618 /// in `/tmp`, which is one namespace shared by every user on the machine: a
619 /// fixed `/tmp/cce-*.log` is a path the first user to log in owns, and the
620 /// sticky bit then denies everyone else. Creating on demand keeps call sites
621 /// to one line; a failure surfaces when the caller opens its file, which it
622 /// already has to handle.
623 pub fn cce_runtime_dir() -> std::path::PathBuf {
624 let dir = runtime_dir().join("cce");
625 let _ = std::fs::create_dir_all(&dir);
626 dir
627 }
628
629 pub fn get_config_path() -> std::path::PathBuf {
630 cce_config_dir().join("config.kdl")
631 }
632
633 struct ConfigCache {
634 last_modified: Option<std::time::SystemTime>,
635 /// Shared, not cloned per read: the getters below run many times a frame,
636 /// and a deep copy of the whole tree to read one key was most of their cost.
637 parsed: Option<std::sync::Arc<serde_json::Value>>,
638 raw_content: String,
639 }
640
641 static CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
642 last_modified: None,
643 parsed: None,
644 raw_content: String::new(),
645 });
646
647 /// The cce config parsed to JSON, cached on the config file's mtime (per process).
648 /// Re-reads and re-parses only when `get_config_path()`'s modification time changes.
649 /// The newest mtime across the shared config and the calling app's override
650 /// file — the cache key for [`cached_config`], and what apps should poll for
651 /// live-reload triggers. Compared by EQUALITY, so an app-file deletion (max
652 /// drops back to the shared mtime) also invalidates.
653 pub fn config_files_modified() -> Option<std::time::SystemTime> {
654 let shared = std::fs::metadata(get_config_path()).ok().and_then(|m| m.modified().ok());
655 let app = app_name()
656 .and_then(|n| std::fs::metadata(get_app_config_path(n)).ok())
657 .and_then(|m| m.modified().ok());
658 match (shared, app) {
659 (Some(a), Some(b)) => Some(a.max(b)),
660 (a, b) => a.or(b),
661 }
662 }
663
664 pub fn cached_config() -> serde_json::Value {
665 (*cached_config_arc()).clone()
666 }
667
668 /// [`cached_config`] without the copy — what every getter here reads.
669 pub fn cached_config_arc() -> std::sync::Arc<serde_json::Value> {
670 let path = get_config_path();
671 // Both files participate in the parse (parse_kdl_to_json merges the
672 // per-app override), so both participate in the cache key.
673 let current_modified = config_files_modified();
674
675 if let Ok(cache) = CONFIG_CACHE.read() {
676 if cache.last_modified.is_some() && cache.last_modified == current_modified {
677 if let Some(ref val) = cache.parsed {
678 return val.clone();
679 }
680 }
681 }
682
683 let content = std::fs::read_to_string(&path).unwrap_or_default();
684 let val = std::sync::Arc::new(parse_kdl_to_json(&content));
685 if let Ok(mut cache) = CONFIG_CACHE.write() {
686 cache.last_modified = current_modified;
687 cache.parsed = Some(val.clone());
688 cache.raw_content = content;
689 }
690 val
691 }
692
693 /// The raw text of the cce config, cached alongside [`cached_config`].
694 pub fn cached_config_content() -> String {
695 let _ = cached_config_arc();
696 CONFIG_CACHE.read().map(|c| c.raw_content.clone()).unwrap_or_default()
697 }
698
699 static SHARED_CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
700 last_modified: None,
701 parsed: None,
702 raw_content: String::new(),
703 });
704
705 /// The SHARED config alone — the per-app override is deliberately NOT merged.
706 /// For values that describe something outside the app (the compositor's
707 /// window silhouette radius), where an app-local override restyles the app
708 /// but must not desynchronize it from the DE. Mtime-cached like
709 /// [`cached_config`].
710 pub fn cached_shared_config() -> serde_json::Value {
711 (*cached_shared_config_arc()).clone()
712 }
713
714 /// [`cached_shared_config`] without the copy.
715 pub fn cached_shared_config_arc() -> std::sync::Arc<serde_json::Value> {
716 let path = get_config_path();
717 let current_modified = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
718
719 if let Ok(cache) = SHARED_CONFIG_CACHE.read() {
720 if cache.last_modified.is_some() && cache.last_modified == current_modified {
721 if let Some(ref val) = cache.parsed {
722 return val.clone();
723 }
724 }
725 }
726
727 let content = std::fs::read_to_string(&path).unwrap_or_default();
728 let val = std::sync::Arc::new(if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
729 kdl_to_json(&doc)
730 } else {
731 serde_json::json!({})
732 });
733 if let Ok(mut cache) = SHARED_CONFIG_CACHE.write() {
734 cache.last_modified = current_modified;
735 cache.parsed = Some(val.clone());
736 cache.raw_content = content;
737 }
738 val
739 }
740
741 /// Read an i64 at `pointer` from the SHARED config only (no per-app merge),
742 /// or `default` — see [`cached_shared_config`].
743 pub fn get_i64_shared(pointer: &str, default: i64) -> i64 {
744 cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
745 }
746
747 /// [`get_i64_shared`] without a default — for canonical-first alias chains
748 /// (RFC Phase 7a) where absence must fall through to the next spelling.
749 pub fn get_i64_shared_opt(pointer: &str) -> Option<i64> {
750 cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64())
751 }
752
753 // ── Typed accessors over the cached config ──────────────────────────────────
754 // Each reads the mtime-cached config and extracts a value at a JSON pointer
755 // (e.g. "/notifications/enable"), returning the default when absent or mistyped.
756
757 /// Read a boolean at `pointer` from the cached config, or `default`.
758 pub fn get_bool(pointer: &str, default: bool) -> bool {
759 cached_config_arc().pointer(pointer).and_then(|v| v.as_bool()).unwrap_or(default)
760 }
761
762 /// Read an f32 at `pointer` from the cached config, or `default`.
763 pub fn get_f32(pointer: &str, default: f32) -> f32 {
764 cached_config_arc()
765 .pointer(pointer)
766 .and_then(|v| v.as_f64())
767 .map(|f| f as f32)
768 .unwrap_or(default)
769 }
770
771 /// Read an i64 at `pointer` from the cached config, or `default`.
772 pub fn get_i64(pointer: &str, default: i64) -> i64 {
773 cached_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
774 }
775
776 /// Read a string at `pointer` from the cached config.
777 pub fn get_string(pointer: &str) -> Option<String> {
778 cached_config_arc().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
779 }
780
781 /// Read a hex color string at `pointer` and parse it to raw sRGB RGBA (`[0,1]`).
782 /// Apply [`crate::color::srgb_to_linear`] if your render target expects linear.
783 pub fn get_color(pointer: &str) -> Option<[f32; 4]> {
784 get_string(pointer).as_deref().and_then(crate::color::parse_hex_rgba)
785 }
786
787 /// Recursively search a JSON value for the first entry whose object key equals
788 /// `key`, returning a reference to its value. Depth-first over objects and arrays.
789 pub fn find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
790 match val {
791 serde_json::Value::Object(map) => {
792 if let Some(found) = map.get(key) {
793 return Some(found);
794 }
795 for v in map.values() {
796 if let Some(found) = find_key(v, key) {
797 return Some(found);
798 }
799 }
800 None
801 }
802 serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_key(v, key)),
803 _ => None,
804 }
805 }
806
807
808
809 fn perform_rolling_backup(path: &str) {
810 let config_path = get_config_path();
811 if std::path::Path::new(path) != config_path {
812 return;
813 }
814 if !std::path::Path::new(path).exists() {
815 return;
816 }
817 let backup_dir = config_path.parent().unwrap().join("backups");
818 if fs::create_dir_all(&backup_dir).is_err() {
819 return;
820 }
821 for i in (1..=4).rev() {
822 let src = backup_dir.join(format!("config.kdl.{}.bak", i));
823 let dst = backup_dir.join(format!("config.kdl.{}.bak", i + 1));
824 if src.exists() {
825 let _ = fs::rename(src, dst);
826 }
827 }
828 let dst = backup_dir.join("config.kdl.1.bak");
829 let _ = fs::copy(path, dst);
830 }
831
832 pub(crate) fn safe_write(path: &str, content: &str) -> bool {
833 perform_rolling_backup(path);
834 if let Some(parent) = std::path::Path::new(path).parent() {
835 let _ = fs::create_dir_all(parent);
836 }
837 let temp_path = format!("{}.tmp", path);
838 if fs::write(&temp_path, content).is_ok() {
839 if fs::rename(&temp_path, path).is_ok() {
840 return true;
841 }
842 let _ = fs::remove_file(&temp_path);
843 }
844 false
845 }
846
847 pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &str) -> bool {
848 write_config_value_typed(path, key, value, default_section, None)
849 }
850
851 /// Remove `key` from the config file at `path` ([`remove_kdl_in_memory`]),
852 /// writing only when something was removed. `true` when the key is gone —
853 /// absent to begin with, or removed and written — so a writer migrating a
854 /// legacy spelling can fold it into its own success.
855 pub fn remove_config_value(path: &str, key: &str) -> bool {
856 let content = fs::read_to_string(path).unwrap_or_default();
857 let mut doc = match content.parse::<kdl::KdlDocument>() {
858 Ok(d) => d,
859 Err(_) => return true,
860 };
861 if remove_kdl_in_memory(&mut doc, key) {
862 return safe_write(path, &doc.to_string());
863 }
864 true
865 }
866
867 /// [`write_config_value`] with an explicit type annotation — see
868 /// [`update_kdl_in_memory_typed`].
869 pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
870 let content = fs::read_to_string(path).unwrap_or_default();
871 let mut doc = match content.parse::<kdl::KdlDocument>() {
872 Ok(d) => d,
873 Err(_) => kdl::KdlDocument::new(),
874 };
875
876 if update_kdl_in_memory_typed(&mut doc, key, value, default_section, forced_ty) {
877 let updated_str = doc.to_string();
878 return safe_write(path, &updated_str);
879 }
880 false
881 }
882
883 pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
884 let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
885 let parts: Vec<&str> = key_path.split('.').collect();
886 if parts.is_empty() {
887 return None;
888 }
889
890 let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
891
892 let (node_path, target_prop) = if is_property {
893 (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
894 } else {
895 (&parts[0..parts.len()], None)
896 };
897
898 let child_node = get_node_ref(&doc, node_path)?;
899
900 if let Some(prop_name) = target_prop {
901 let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
902 entry.ty().map(|t| t.value().to_string())
903 } else {
904 let entry = child_node.entries().first()?;
905 entry.ty().map(|t| t.value().to_string())
906 }
907 }
908
909 pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
910 let doc = kdl_content.parse::<kdl::KdlDocument>().ok();
911 key_paths.iter().map(|key_path| {
912 let doc = doc.as_ref()?;
913 let parts: Vec<&str> = key_path.split('.').collect();
914 if parts.is_empty() {
915 return None;
916 }
917
918 let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
919
920 let (node_path, target_prop) = if is_property {
921 (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
922 } else {
923 (&parts[0..parts.len()], None)
924 };
925
926 let child_node = get_node_ref(doc, node_path)?;
927
928 if let Some(prop_name) = target_prop {
929 let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
930 entry.ty().map(|t| t.value().to_string())
931 } else {
932 let entry = child_node.entries().first()?;
933 entry.ty().map(|t| t.value().to_string())
934 }
935 }).collect()
936 }
937
938 /// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
939 /// reads back with) escapes: a quote, a backslash and the control
940 /// characters. Every string the writer emits goes through here — until
941 /// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
942 /// quote inside a value made a line no parser reads, and an app whose
943 /// settings file fails to parse loads its DEFAULTS.
944 pub fn kdl_quote(s: &str) -> String {
945 let mut out = String::with_capacity(s.len() + 2);
946 out.push('"');
947 for c in s.chars() {
948 match c {
949 '"' => out.push_str("\\\""),
950 '\\' => out.push_str("\\\\"),
951 '\n' => out.push_str("\\n"),
952 '\r' => out.push_str("\\r"),
953 '\t' => out.push_str("\\t"),
954 '\u{08}' => out.push_str("\\b"),
955 '\u{0C}' => out.push_str("\\f"),
956 c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
957 c => out.push(c),
958 }
959 }
960 out.push('"');
961 out
962 }
963
964 fn format_kdl_type(ty: &str) -> String {
965 let is_ident = !ty.is_empty()
966 && !ty.chars().next().unwrap().is_ascii_digit()
967 && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
968 if is_ident {
969 ty.to_string()
970 } else {
971 kdl_quote(ty)
972 }
973 }
974
975 fn format_kdl_identifier(name: &str) -> String {
976 let is_ident = !name.is_empty()
977 && !name.chars().next().unwrap().is_ascii_digit()
978 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
979 if is_ident {
980 name.to_string()
981 } else {
982 kdl_quote(name)
983 }
984 }
985
986 pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
987 value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
988 }
989
990 pub fn value_to_kdl_with_annotations(
991 key: &str,
992 val: &serde_json::Value,
993 indent: usize,
994 parent_path: &str,
995 annotations: &std::collections::HashMap<String, String>,
996 ) -> String {
997 let indent_str = " ".repeat(indent);
998 let current_path = if parent_path.is_empty() {
999 key.to_string()
1000 } else {
1001 format!("{}.{}", parent_path, key)
1002 };
1003
1004 match val {
1005 serde_json::Value::Object(map) => {
1006 let has_objects = map.values().any(|v| v.is_object());
1007 if has_objects {
1008 let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1009 for (k, v) in map {
1010 out.push_str(&value_to_kdl_with_annotations(k, v, indent + 1, ¤t_path, annotations));
1011 }
1012 out.push_str(&format!("{}}}\n", indent_str));
1013 out
1014 } else {
1015 let mut prop_parts = Vec::new();
1016 let mut child_parts = Vec::new();
1017 for (prop_name, prop_val) in map {
1018 let prop_path = format!("{}.{}", current_path, prop_name);
1019 let is_vec2i = annotations.get(&prop_path).is_some_and(|a| a == "vec2i");
1020 if is_vec2i {
1021 if let serde_json::Value::String(ref s) = prop_val {
1022 child_parts.push(format!("{}{} (vec2i){}\n", " ".repeat(indent + 1), format_kdl_identifier(prop_name), s));
1023 }
1024 } else {
1025 let (val_str, val_ty) = match prop_val {
1026 serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1027 serde_json::Value::Number(num) => {
1028 if prop_name == "light_source_position" {
1029 (num.to_string(), Some("radian".to_string()))
1030 } else if num.is_f64() {
1031 (num.to_string(), Some("f64".to_string()))
1032 } else {
1033 (num.to_string(), Some("i64".to_string()))
1034 }
1035 }
1036 serde_json::Value::String(s) => {
1037 if let Some(len) = crate::units::Len::parse(s) {
1038 (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1039 } else if let Some(anno) = annotations.get(&prop_path) {
1040 if anno == "vec2i" {
1041 (s.clone(), Some(anno.clone()))
1042 } else {
1043 (kdl_quote(s), Some(anno.clone()))
1044 }
1045 } else if s.starts_with('#') {
1046 let s_clean = s.trim_start_matches('#');
1047 let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1048 (kdl_quote(s), Some(ty.to_string()))
1049 } 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") {
1050 (kdl_quote(s), Some("keybind".to_string()))
1051 } else {
1052 (kdl_quote(s), None)
1053 }
1054 }
1055 _ => (prop_val.to_string(), None),
1056 };
1057 if let Some(ty) = val_ty {
1058 prop_parts.push(format!("{}=({}){}", format_kdl_identifier(prop_name), format_kdl_type(&ty), val_str));
1059 } else {
1060 prop_parts.push(format!("{}={}", format_kdl_identifier(prop_name), val_str));
1061 }
1062 }
1063 }
1064 if !child_parts.is_empty() {
1065 let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1066 if !prop_parts.is_empty() {
1067 out.push_str(&format!("{}{}\n", " ".repeat(indent + 1), prop_parts.join(" ")));
1068 }
1069 for child in child_parts {
1070 out.push_str(&child);
1071 }
1072 out.push_str(&format!("{}}}\n", indent_str));
1073 out
1074 } else {
1075 format!("{}{} {}\n", indent_str, format_kdl_identifier(key), prop_parts.join(" "))
1076 }
1077 }
1078 }
1079 serde_json::Value::Array(arr) => {
1080 // An array of strings is one node with several positional args
1081 // (the shape `kdl_to_json` reads `rounded_apps "a" "b"` into);
1082 // any other array is one node per item (key_bindings' objects).
1083 if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
1084 let args: Vec<String> = arr
1085 .iter()
1086 .filter_map(|v| v.as_str().map(kdl_quote))
1087 .collect();
1088 return format!("{}{} {}\n", indent_str, format_kdl_identifier(key), args.join(" "));
1089 }
1090 let mut out = String::new();
1091 for item in arr {
1092 out.push_str(&value_to_kdl_with_annotations(key, item, indent, parent_path, annotations));
1093 }
1094 out
1095 }
1096 _ => {
1097 let (val_str, val_ty) = match val {
1098 serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1099 serde_json::Value::Number(num) => {
1100 if key == "light_source_position" {
1101 (num.to_string(), Some("radian".to_string()))
1102 } else if num.is_f64() {
1103 (num.to_string(), Some("f64".to_string()))
1104 } else {
1105 (num.to_string(), Some("i64".to_string()))
1106 }
1107 }
1108 serde_json::Value::String(s) => {
1109 if let Some(len) = crate::units::Len::parse(s) {
1110 (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1111 } else if let Some(anno) = annotations.get(¤t_path) {
1112 if anno == "vec2i" {
1113 (s.clone(), Some(anno.clone()))
1114 } else {
1115 (kdl_quote(s), Some(anno.clone()))
1116 }
1117 } else if s.starts_with('#') {
1118 let s_clean = s.trim_start_matches('#');
1119 let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1120 (kdl_quote(s), Some(ty.to_string()))
1121 } 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") {
1122 (kdl_quote(s), Some("keybind".to_string()))
1123 } else {
1124 (kdl_quote(s), None)
1125 }
1126 }
1127 _ => (val.to_string(), None),
1128 };
1129 if let Some(ty) = val_ty {
1130 format!("{}{} ({}){}\n", indent_str, format_kdl_identifier(key), format_kdl_type(&ty), val_str)
1131 } else {
1132 format!("{}{} {}\n", indent_str, format_kdl_identifier(key), val_str)
1133 }
1134 }
1135 }
1136 }
1137
1138 pub fn json_to_kdl_string(val: &serde_json::Value) -> String {
1139 json_to_kdl_string_with_annotations(val, &std::collections::HashMap::new())
1140 }
1141
1142 pub fn json_to_kdl_string_with_annotations(
1143 val: &serde_json::Value,
1144 annotations: &std::collections::HashMap<String, String>,
1145 ) -> String {
1146 let mut out = String::new();
1147 if let serde_json::Value::Object(map) = val {
1148 for (sec_name, sec_val) in map {
1149 if let serde_json::Value::Object(sec_map) = sec_val {
1150 out.push_str(&format!("{} {{\n", format_kdl_identifier(sec_name)));
1151 for (k, v) in sec_map {
1152 out.push_str(&value_to_kdl_with_annotations(k, v, 1, sec_name, annotations));
1153 }
1154 out.push_str("}\n");
1155 } else {
1156 out.push_str(&value_to_kdl_with_annotations(sec_name, sec_val, 0, "", annotations));
1157 }
1158 }
1159 }
1160 out
1161 }
1162
1163 pub fn get_app_recent_files_path() -> std::path::PathBuf {
1164 let app_name = get_app_name().unwrap_or_else(|| "cce-app".to_string());
1165 get_config_path().parent().unwrap().join(app_name).join("recent-files.kdl")
1166 }
1167
1168 pub fn load_recent_files() -> Vec<String> {
1169 let path = get_app_recent_files_path();
1170 if path.exists() {
1171 if let Ok(content) = std::fs::read_to_string(&path) {
1172 if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
1173 if let Some(recent_node) = doc.get("recent") {
1174 if let Some(children) = recent_node.children() {
1175 let mut files = Vec::new();
1176 for node in children.nodes() {
1177 if node.name().value() == "file" {
1178 if let Some(entry) = node.entries().first() {
1179 if let kdl::KdlValue::String(s) = entry.value() {
1180 files.push(s.clone());
1181 }
1182 }
1183 }
1184 }
1185 return files;
1186 }
1187 }
1188 }
1189 }
1190 }
1191 Vec::new()
1192 }
1193
1194 pub fn save_recent_files(files: &[String]) {
1195 let path = get_app_recent_files_path();
1196 if let Some(parent) = path.parent() {
1197 let _ = std::fs::create_dir_all(parent);
1198 }
1199 let mut kdl_str = "recent {\n".to_string();
1200 for file in files {
1201 kdl_str.push_str(&format!(" file {}\n", kdl_quote(file)));
1202 }
1203 kdl_str.push_str("}\n");
1204 let _ = std::fs::write(path, kdl_str);
1205 }
1206
1207
1208 #[cfg(test)]
1209 mod tests {
1210 /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
1211 /// unset, empty or relative. Nothing else in the crate reads the
1212 /// variable, so setting it here cannot race another test.
1213 #[test]
1214 fn state_home_follows_xdg_and_falls_back() {
1215 let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
1216 let fallback = home.join(".local").join("state");
1217 std::env::set_var("XDG_STATE_HOME", "/srv/state");
1218 assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
1219 assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
1220 for invalid in ["", "relative/state"] {
1221 std::env::set_var("XDG_STATE_HOME", invalid);
1222 assert_eq!(super::state_home(), fallback, "{invalid:?}");
1223 }
1224 std::env::remove_var("XDG_STATE_HOME");
1225 assert_eq!(super::state_home(), fallback);
1226 assert_eq!(super::cce_state_dir(), fallback.join("cce"));
1227 }
1228
1229 /// A string the writer emits comes back as it went in, quotes,
1230 /// backslashes and control characters included — as a section value,
1231 /// a top-level value, a keybind (written with a type annotation), a
1232 /// list item and a key that is not an identifier. Until 2026-10-01
1233 /// none of it was escaped, a quote inside a value made a line the
1234 /// parser refused, and an app whose settings file fails to parse loads
1235 /// its defaults.
1236 #[test]
1237 fn strings_round_trip_through_the_kdl_writer() {
1238 let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
1239 let val = serde_json::json!({
1240 "section": {
1241 "plain": odd,
1242 "shortcut": "ctrl+\"",
1243 "items": [odd, "x"],
1244 "not an ident": odd,
1245 },
1246 "top": odd,
1247 });
1248 let text = super::json_to_kdl_string(&val);
1249 let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
1250 let back = super::kdl_to_json(&doc);
1251 assert_eq!(back["section"]["plain"], odd, "{text}");
1252 assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
1253 assert_eq!(back["section"]["items"][0], odd, "{text}");
1254 assert_eq!(back["section"]["not an ident"], odd, "{text}");
1255 assert_eq!(back["top"], odd, "{text}");
1256 assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
1257 }
1258
1259 /// A material node's frost and finish are written as PROPERTIES of a
1260 /// `frost` / `finish` child (RFC material § 5), created on demand under
1261 /// `style.surface.material.<name>`, and read back through the same
1262 /// pointer the loader uses.
1263 #[test]
1264 fn material_keys_write_as_frost_and_finish_props() {
1265 use super::{parse_kdl_to_json, update_kdl_in_memory};
1266 let mut doc = kdl::KdlDocument::new();
1267 assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
1268 assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
1269 assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
1270 assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
1271 assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
1272 let text = doc.to_string();
1273 let val = parse_kdl_to_json(&text);
1274 assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
1275 assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
1276 assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
1277 assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
1278 assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
1279 // One `frost` node with two props, not two `frost` nodes.
1280 assert_eq!(text.matches("frost").count(), 1, "{text}");
1281 assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
1282 }
1283
1284 #[test]
1285 fn unit_annotations_become_len_strings() {
1286 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");
1287 assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
1288 assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
1289 assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
1290 assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
1291 }
1292
1293 #[test]
1294 fn unit_strings_write_back_annotated() {
1295 let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
1296 let out = json_to_kdl_string(&v);
1297 assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
1298 assert!(out.contains("depth=(f64)0.15"), "{out}");
1299 let back = parse_kdl_to_json(&out);
1300 assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
1301 }
1302
1303 #[test]
1304 fn typed_write_keeps_and_sets_units() {
1305 let mut doc: kdl::KdlDocument = "style {\n relief width=(mm)2.0\n}\n".parse().unwrap();
1306 // A bare number over a (mm) slot stays mm.
1307 assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
1308 let v = parse_kdl_to_json(&doc.to_string());
1309 assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
1310 // A suffixed value sets the unit.
1311 assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
1312 let v = parse_kdl_to_json(&doc.to_string());
1313 assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
1314 }
1315
1316 #[test]
1317 fn app_name_strips_the_kernels_deleted_marker() {
1318 use super::app_name_from_exe_basename as name;
1319 assert_eq!(name("cce-status-interface"), "cce-status-interface");
1320 assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
1321 // Only the exact trailing marker: a name that merely contains the
1322 // word, or an unspaced variant, is left alone.
1323 assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
1324 assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
1325 }
1326
1327 use super::*;
1328
1329 #[test]
1330 fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
1331 // No env mutation: reading the real base keeps this correct both in a
1332 // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
1333 // avoids racing every other test in the process.
1334 let base = runtime_dir();
1335 assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
1336 let dir = cce_runtime_dir();
1337 assert_eq!(dir, base.join("cce"));
1338 // The create-on-demand contract callers depend on: they open a file
1339 // inside this directory without creating it themselves.
1340 assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
1341 }
1342
1343 #[test]
1344 fn relief_annotated_string_passes_through() {
1345 // The (relief) custom value type: an annotated string prop must
1346 // survive kdl_to_json as a plain JSON string at its pointer.
1347 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";
1348 let val = parse_kdl_to_json(content);
1349 assert_eq!(
1350 val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
1351 Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
1352 );
1353 }
1354
1355 #[test]
1356 fn test_nested_parsing() {
1357 let content = "style {\n status box_opacity=(f64)0.75\n}\n";
1358 let val = parse_kdl_to_json(content);
1359 println!("val = {:?}", val);
1360 let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
1361 assert_eq!(sec, "style");
1362 assert_eq!(node, "status");
1363 assert_eq!(prop, Some("box_opacity".to_string()));
1364
1365 let sec_val = val.get(&sec).unwrap();
1366 let node_val = sec_val.get(&node).unwrap();
1367 let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
1368 assert_eq!(prop_val.as_f64().unwrap(), 0.75);
1369 }
1370
1371 /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
1372 /// child nodes of the existing `relief` node — the node keeps its own
1373 /// `depth=` / `width=` properties and grows one child block — and the
1374 /// reload path reads them back at the nested paths the registry maps.
1375 /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
1376 /// which is how cce-relief's Save migrates a file in place.
1377 #[test]
1378 fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
1379 let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\n }\n}\n";
1380 let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
1381 let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
1382 assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
1383 assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
1384 assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
1385 assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
1386 // Migrate: the flat spellings come off, and a spelling that is not
1387 // there reports nothing removed.
1388 assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
1389 assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
1390 assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
1391 assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
1392 let out = doc.to_string();
1393 assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
1394 assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
1395 assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
1396 assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
1397
1398 let val = parse_kdl_to_json(&out);
1399 let relief = val.pointer("/style/surface/relief").unwrap();
1400 assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
1401 assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
1402 assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
1403 assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
1404 assert!(relief.pointer("/edge/height").is_some(), "{relief}");
1405 assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
1406 // A whole node comes off too.
1407 assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
1408 let val = parse_kdl_to_json(&doc.to_string());
1409 assert!(val.pointer("/style/surface/relief/edge").is_none());
1410 assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
1411 }
1412
1413 #[test]
1414 fn relief_keys_write_as_properties_and_round_trip() {
1415 // `relief` is a PROP_NODES member: style.surface.relief.* must land as
1416 // properties on the existing relief node (the config.kdl shape), not
1417 // as duplicate child nodes shadowing the light=/width= properties.
1418 let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3\n }\n}\n";
1419 let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
1420 let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
1421 assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
1422 assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
1423 let out = doc.to_string();
1424 // Still one relief node, no child block grown under it.
1425 assert_eq!(out.matches("relief").count(), 1, "out: {out}");
1426 assert!(!out.contains("relief {"), "out: {out}");
1427
1428 // The reload path reads through parse_kdl_to_json: the new property
1429 // must surface at the same dotted path the style registry maps.
1430 let val = parse_kdl_to_json(&out);
1431 let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
1432 assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
1433 assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
1434 assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
1435 }
1436
1437 #[test]
1438 fn test_get_kdl_type_annotation() {
1439 let content = "input {\n accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n touchpad {\n gestures pinch=(bool)true\n }\n}\n";
1440 let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
1441 assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
1442
1443 let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
1444 assert_eq!(ty2, Some("bool".to_string()));
1445
1446 let keys = vec![
1447 "input.accel_profile".to_string(),
1448 "input.touchpad.gestures.pinch".to_string(),
1449 "input.invalid_key".to_string(),
1450 ];
1451 let tys = get_kdl_type_annotations(content, &keys);
1452 assert_eq!(tys.len(), 3);
1453 assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
1454 assert_eq!(tys[1], Some("bool".to_string()));
1455 assert_eq!(tys[2], None);
1456 }
1457
1458 #[test]
1459 fn test_json_to_kdl_with_special_annotations() {
1460 let mut annotations = std::collections::HashMap::new();
1461 annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
1462
1463 let mut desktop_map = serde_json::Map::new();
1464 desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
1465
1466 let mut surface_map = serde_json::Map::new();
1467 surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
1468
1469 let mut style_map = serde_json::Map::new();
1470 style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
1471
1472 let mut root_map = serde_json::Map::new();
1473 root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
1474
1475 let root = serde_json::Value::Object(root_map);
1476 let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
1477 println!("Generated KDL:\n{}", kdl_str);
1478
1479 let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
1480 assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
1481 }
1482
1483 #[test]
1484 fn test_brightness_annotations() {
1485 let mut edp_map = serde_json::Map::new();
1486 edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
1487 edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
1488 edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
1489 edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
1490
1491 let mut output_map = serde_json::Map::new();
1492 output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
1493
1494 let mut root_map = serde_json::Map::new();
1495 root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
1496
1497 let root = serde_json::Value::Object(root_map);
1498 let kdl_str = json_to_kdl_string(&root);
1499 println!("Generated KDL for brightness:\n{}", kdl_str);
1500
1501 let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1502
1503 let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
1504 let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
1505
1506 let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
1507 assert_eq!(up_entry.ty().unwrap().value(), "keybind");
1508
1509 let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
1510 assert_eq!(down_entry.ty().unwrap().value(), "keybind");
1511
1512 let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
1513 assert_eq!(interval_entry.ty().unwrap().value(), "i64");
1514 }
1515
1516 /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
1517 /// trip cce-data-editor saves through — it used to come back as ONE arg,
1518 /// `rounded_apps "a b"`, and the compositor's allowlist then matched
1519 /// nothing (Claude Desktop lost its corners after every save).
1520 #[test]
1521 fn test_string_list_roundtrip() {
1522 let content = "window_manager {\n rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n corner_shape (f64)4.5\n}\n";
1523 let val = parse_kdl_to_json(content);
1524 let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
1525 assert_eq!(
1526 list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
1527 vec!["claude-desktop", "com.anthropic.Claude"]
1528 );
1529 let kdl_str = json_to_kdl_string(&val);
1530 assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
1531 // And it re-parses to the same two args, not one.
1532 let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1533 let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
1534 let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
1535 assert_eq!(ra.entries().len(), 2);
1536 // A single-arg string node stays a plain string.
1537 let single = parse_kdl_to_json("window_manager {\n rounded_apps \"claude-desktop\"\n}\n");
1538 assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
1539 }
1540
1541 #[test]
1542 fn test_vec2i_lossless_roundtrip() {
1543 let content = "style {\n surface {\n cloud {\n position_default (vec2i)100 200\n }\n }\n}\n";
1544 let val = parse_kdl_to_json(content);
1545 println!("Parsed KDL to JSON: {:?}", val);
1546
1547 let position_default_val = val.get("style").unwrap()
1548 .get("surface").unwrap()
1549 .get("cloud").unwrap()
1550 .get("position_default").unwrap();
1551 assert_eq!(position_default_val.as_str().unwrap(), "100 200");
1552
1553 let mut annotations = std::collections::HashMap::new();
1554 annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
1555
1556 let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
1557 println!("Generated KDL:\n{}", kdl_str);
1558
1559 // Assert that (vec2i)100 200 is preserved without quotes
1560 assert!(kdl_str.contains("position_default (vec2i)100 200"));
1561
1562 // Test update_kdl_in_memory preserves and updates the KDL Document correctly
1563 let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1564 let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
1565 assert!(updated);
1566 let updated_kdl = doc.to_string();
1567 println!("Updated KDL:\n{}", updated_kdl);
1568 assert!(updated_kdl.contains("position_default (vec2i)150 250"));
1569 }
1570 }