GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/layout/registry.rs (25.3K)
1 //! The style registry: the config flattened into one process-wide map of style keys, the
2 //! per-thread test overlay over it, and the font-string helpers its readers share. Every
3 //! getter in `layout` and `color` reads through it.
4
5 use super::*;
6 use std::collections::HashMap;
7
8 /// Per-thread overrides for runtime style writes, under `cfg(test)` only.
9 ///
10 /// Every `graph_*`, corner-radius and control-height slot in this file is
11 /// backed by the one process-wide [`STYLE_REGISTRY`], so a test that pins any
12 /// of them pins it for every test running beside it. That is the same defect
13 /// as the font flake fixed in 1dc0ab1, and it was live:
14 /// `test_graph_style_configuration` sets ~20 style values and restores none,
15 /// while `dual_geometry_views_stay_consistent` bakes quads with
16 /// `graph_node_corner_radius` and then re-reads that getter to compare — so a
17 /// write landing between the two makes them disagree. Widening the write
18 /// window to 300ms reproduced it on demand.
19 ///
20 /// Fixing it at the registry rather than per accessor covers every slot it
21 /// holds in one place, including ones no test pins yet.
22 /// The family fontconfig matches for `monospace` (`fc-match`), asked once.
23 pub fn get_system_monospace_font() -> &'static str {
24 static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
25 MONOSPACE_FONT.get_or_init(|| {
26 if let Ok(output) = std::process::Command::new("fc-match")
27 .args(["-f", "%{family}", "monospace"])
28 .output()
29 {
30 let name = String::from_utf8_lossy(&output.stdout);
31 let parsed = name.split(',').next().unwrap_or("monospace").trim();
32 if !parsed.is_empty() {
33 return parsed.to_string();
34 }
35 }
36 "monospace".to_string()
37 })
38 }
39
40 /// The family fontconfig matches for `sans-serif`, asked once.
41 pub fn get_system_sans_serif_font() -> &'static str {
42 static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
43 SANS_SERIF_FONT.get_or_init(|| {
44 if let Ok(output) = std::process::Command::new("fc-match")
45 .args(["-f", "%{family}", "sans-serif"])
46 .output()
47 {
48 let name = String::from_utf8_lossy(&output.stdout);
49 let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
50 if !parsed.is_empty() {
51 return parsed.to_string();
52 }
53 }
54 "sans-serif".to_string()
55 })
56 }
57
58 #[cfg(test)]
59 mod test_overlay {
60 use std::cell::RefCell;
61 use std::collections::HashMap;
62 thread_local! {
63 static FLOATS: RefCell<HashMap<String, f32>> = RefCell::new(HashMap::new());
64 static LENS: RefCell<HashMap<String, crate::units::Len>> = RefCell::new(HashMap::new());
65 static STRINGS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
66 }
67 pub fn set_float(k: &str, v: f32) {
68 LENS.with(|m| m.borrow_mut().remove(k));
69 FLOATS.with(|m| m.borrow_mut().insert(k.to_string(), v));
70 }
71 pub fn set_len(k: &str, v: crate::units::Len) {
72 FLOATS.with(|m| m.borrow_mut().remove(k));
73 LENS.with(|m| m.borrow_mut().insert(k.to_string(), v));
74 }
75 pub fn set_string(k: &str, v: String) {
76 STRINGS.with(|m| m.borrow_mut().insert(k.to_string(), v));
77 }
78 pub fn get_float(k: &str) -> Option<f32> {
79 if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
80 return Some(l.to_px());
81 }
82 FLOATS.with(|m| m.borrow().get(k).copied())
83 }
84 pub fn get_len(k: &str) -> Option<crate::units::Len> {
85 if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
86 return Some(l);
87 }
88 FLOATS.with(|m| m.borrow().get(k).copied()).map(crate::units::Len::px)
89 }
90 pub fn get_string(k: &str) -> Option<String> {
91 STRINGS.with(|m| m.borrow().get(k).cloned())
92 }
93 }
94
95 #[derive(Debug, Clone, Default)]
96 pub struct StyleRegistry {
97 pub floats: HashMap<String, f32>,
98 pub strings: HashMap<String, String>,
99 /// Slots whose config value carried a unit (`width=(mm)2.0`). Read
100 /// through `get_float` like any other number, resolved against the
101 /// process metric (`crate::units::metric`) at EVERY read, so a metric
102 /// that arrives after config load — outputs come in after the first
103 /// style read — or changes with the display is honoured live.
104 pub lens: HashMap<String, crate::units::Len>,
105 }
106
107 impl StyleRegistry {
108 pub fn new() -> Self {
109 Self {
110 floats: HashMap::new(),
111 strings: HashMap::new(),
112 lens: HashMap::new(),
113 }
114 }
115
116 pub fn get_float(&self, key: &str) -> Option<f32> {
117 #[cfg(test)]
118 if let Some(v) = test_overlay::get_float(key) {
119 return Some(v);
120 }
121 if let Some(len) = self.lens.get(key) {
122 return Some(len.to_px());
123 }
124 self.floats.get(key).copied()
125 }
126
127 /// The slot as a length with its unit: the configured `Len` when one was
128 /// given, else the plain number as logical px. For editors that show the
129 /// unit the user chose rather than the resolved pixel count.
130 pub fn get_len(&self, key: &str) -> Option<crate::units::Len> {
131 #[cfg(test)]
132 if let Some(v) = test_overlay::get_len(key) {
133 return Some(v);
134 }
135 if let Some(len) = self.lens.get(key) {
136 return Some(*len);
137 }
138 self.floats.get(key).map(|v| crate::units::Len::px(*v))
139 }
140
141 pub fn get_string(&self, key: &str) -> Option<String> {
142 #[cfg(test)]
143 if let Some(v) = test_overlay::get_string(key) {
144 return Some(v);
145 }
146 self.strings.get(key).cloned()
147 }
148
149 /// A plain number wins over any earlier unit value for the slot — a
150 /// runtime `set_float` is the newest opinion.
151 pub fn set_float(&mut self, key: &str, val: f32) {
152 #[cfg(test)]
153 {
154 test_overlay::set_float(key, val);
155 }
156 #[cfg(not(test))]
157 self.load_float(key, val);
158 }
159
160 pub fn set_len(&mut self, key: &str, len: crate::units::Len) {
161 #[cfg(test)]
162 {
163 test_overlay::set_len(key, len);
164 }
165 #[cfg(not(test))]
166 self.load_len(key, len);
167 }
168
169 pub fn set_string(&mut self, key: &str, val: String) {
170 #[cfg(test)]
171 {
172 test_overlay::set_string(key, val);
173 }
174 #[cfg(not(test))]
175 self.load_string(key, val);
176 }
177
178 /// The CONFIG-LOAD writes, as opposed to the runtime `set_*` ones above.
179 /// Kept apart because under `cfg(test)` a runtime set goes to a per-thread
180 /// overlay while the loaded config must stay the shared base every test
181 /// reads — routing config through `set_*` would put one thread's config
182 /// in its overlay and leave every other thread with an empty registry.
183 pub fn load_float(&mut self, key: &str, val: f32) {
184 self.lens.remove(key);
185 self.floats.insert(key.to_string(), val);
186 }
187
188 pub fn load_len(&mut self, key: &str, len: crate::units::Len) {
189 self.floats.remove(key);
190 self.lens.insert(key.to_string(), len);
191 }
192
193 pub fn load_string(&mut self, key: &str, val: String) {
194 self.strings.insert(key.to_string(), val);
195 }
196 }
197
198 /// The registry is a field of the one style snapshot (`crate::style`); this is its handle,
199 /// with the `RwLock` API it had.
200 pub static STYLE_REGISTRY: crate::style::StyleCell<StyleRegistry> =
201 crate::style::StyleCell::new(|s| &s.registry, |s| &mut s.registry);
202
203 pub fn get_style_registry() -> &'static crate::style::StyleCell<StyleRegistry> {
204 &STYLE_REGISTRY
205 }
206
207 pub fn lazy_init_style_registry() {
208 use std::sync::Once;
209 static INIT: Once = Once::new();
210 INIT.call_once(|| {
211 reload_config();
212 });
213 }
214
215 pub(super) fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props: &mut String) {
216 match val {
217 serde_json::Value::Object(map) => {
218 for (k, v) in map {
219 let next_prefix = if prefix.is_empty() {
220 k.clone()
221 } else {
222 format!("{}.{}", prefix, k)
223 };
224 flatten_json_to_flat_props(v, &next_prefix, flat_props);
225 }
226 }
227 _ => {
228 // A retired `window_manager` spelling of a relief key emits NO
229 // line: the fall-through below strips a path's first segment, so
230 // `window_manager.bevel_depth` would otherwise land on the live
231 // `bevel_depth` registry key by accident, alias or not. The
232 // `style.surface.*` retirements need no guard — stripped, they
233 // become `surface.relief.depth` and the like, which nothing reads.
234 if matches!(prefix, "window_manager.bevel_depth" | "window_manager.bevel_width" | "window_manager.bevel_shader") {
235 return;
236 }
237 let flat_key = match prefix {
238 "style.list.font" | "style.data.list.font" => "list_font",
239 "style.list.font_color" | "style.data.list.font_color" => "list_font_color",
240 "style.control.breadcrumb.font" => "breadcrumb_font",
241
242 // The control rung's default radius: every control-scale
243 // `corner_radius` below falls back to it when its own key is unset.
244 "style.control.corner_radius" => "control_corner_radius",
245 "style.control.button.padding" => "button_padding",
246 "style.control.button.height" => "button_height",
247 "style.control.button.corner_radius" => "button_corner_radius",
248 "style.control.button.font" => "button_font",
249 "style.list.corner_radius" | "style.data.list.corner_radius" => "list_corner_radius",
250 "style.control.textbox.corner_radius" | "style.textbox.corner_radius" | "style.data.textbox.corner_radius" => "textbox_corner_radius",
251 "style.control.dropdown.color" => "dropdown_color",
252 "style.control.font_selector.font" => "font_selector_font",
253 "style.container.section.font" | "style.section.font" => "section_label_font",
254 "style.container.section.padding" | "style.section.padding" => "section_padding",
255 "style.control.button_strip.font" => "button_strip_font",
256 "style.control.button_strip.spacing" => "button_strip_spacing",
257 "style.control.dropdown.height" => "dropdown_height",
258 "style.control.dropdown.corner_radius" => "dropdown_corner_radius",
259 "style.control.font_selector.height" => "font_selector_height",
260 "style.control.font_selector.corner_radius" => "font_selector_corner_radius",
261 "style.control.label.font" => "control_label_font",
262 "style.control.label.font_detached" => "control_label_font_detached",
263 "style.control.label.margin" => "control_label_margin",
264 "style.control.slider.height" => "slider_height",
265 "style.control.slider.corner_radius" => "slider_corner_radius",
266 "style.control.slider.band_thickness" => "slider_band_thickness",
267 "style.control.slider.bulge_width" => "slider_bulge_width",
268 "style.control.slider.bulge_height" => "slider_bulge_height",
269 "style.control.progressbar.height" => "progressbar_height",
270 "style.control.rangeslider.height" => "rangeslider_height",
271 "style.control.scrollbar.width" => "scrollbar_width",
272 "style.control.scrollbar.inset" => "scrollbar_inset",
273 "style.control.spinbox.height" => "spinbox_height",
274 "style.control.spinbox.button_padding" => "spinbox_button_padding",
275 "style.control.spinbox.corner_radius" => "spinbox_corner_radius",
276 "style.control.textbox.height" | "style.textbox.height" | "style.data.textbox.height" => "textbox_height",
277 "style.control.textbox.placeholder_text_color" | "style.textbox.placeholder_text_color" | "style.data.textbox.placeholder_text_color" => "textbox_placeholder_text_color",
278 "style.control.textbox.background_color" | "style.textbox.background_color" | "style.data.textbox.background_color" => "textbox_background_color",
279 "style.control.textbox.background_edit_color" | "style.textbox.background_edit_color" | "style.data.textbox.background_edit_color" => "textbox_background_edit_color",
280 "style.control.textbox.multiline.line_wrap" | "style.textbox.multiline.line_wrap" | "style.data.textbox.multiline.line_wrap" => "textbox_line_wrap",
281 "style.control.textbox.multiline.border_width" | "style.textbox.multiline.border_width" | "style.data.textbox.multiline.border_width" => "textbox_multiline_border_width",
282 "style.control.toggle.height" => "toggle_height",
283 "style.control.toggle.border_width" => "toggle_border_width",
284 "style.control.toggle.disabled_color" => "toggle_disabled_color",
285 "style.control.toggle.border_color" => "toggle_border_color",
286 "style.control.toggle.corner_radius" => "toggle_corner_radius",
287 "window_manager.light_source_position" => "light_source_position",
288 // The DE's relief material, home style.surface.relief: these
289 // shade every bevel/boss/recess in the toolkit. `light` is
290 // the spelling (since 2026-09-28): it is the light strength,
291 // not a length. Two older spellings are RETIRED — not read,
292 // reported by path (`color::retired_surface_keys`), removed
293 // by cce-relief's Save: `relief.depth`, what every config
294 // said until that day, and `window_manager.bevel_depth` /
295 // `bevel_width`, the block these keys were born in before
296 // they had a home of their own (the compositor never read
297 // them; the spelling survived as a compat alias until the
298 // evening of 2026-09-28).
299 "style.surface.relief.light" => "bevel_depth",
300 "style.surface.relief.width" => "bevel_width",
301 // The two SHAPES of the relief, each a node under it
302 // (2026-09-28): `wall` is a carve's wall — a recess, boss,
303 // ridge or trough cut into a surface — and `edge` is the
304 // plate's perimeter roll. Each carries `height` (a length:
305 // the carve's drop, the roll's rise; unset = follow the
306 // width) and `profile` (the ramp spec of its curve, written
307 // by cce-relief, installed by reload_config). The registry
308 // keys keep their old names. The pre-rename flat spellings
309 // (`height` / `profile` were the wall's, `edge_height` /
310 // `edge_profile` the edge's) were aliases for the rest of
311 // that day and are RETIRED: not read, reported by path
312 // (`color::retired_surface_keys`), removed by cce-relief's
313 // Save, which also reads them once as a seed. cce-relief's
314 // slider knobs are NOT a style key any more
315 // (`relief.wall.knobs` / `edge.knobs`, before that
316 // `profile_knobs` / `edge_knobs`): editor state, kept in
317 // that app's own state.kdl, and read off a config only by
318 // the editor itself, as a one-time seed.
319 "style.surface.relief.wall.height" => "bevel_height",
320 "style.surface.relief.edge.height" => "roll_height",
321 "style.surface.relief.wall.profile" => "bevel_profile_spec",
322 "style.surface.relief.edge.profile" => "roll_profile_spec",
323 "style.container.section.depth" => "section_depth",
324 "style.surface.param.backdrop_compression" => "param_compression",
325 "style.surface.param.label_layout" => "param_label_layout",
326 // The relief's shader toggle lives with the relief
327 // (`relief shader=(bool)false` is the legacy banded look);
328 // `window_manager.bevel_shader`, its old home, is retired
329 // and reported like the block's other bevel keys.
330 "style.surface.relief.shader" => "bevel_shader",
331 "window_manager.control_relief" => "control_relief",
332 "window_manager.corner_shape" => "corner_shape",
333 "style.control.ramp.height" => "ramp_height",
334 "style.layout.column.gap" => "column_gap",
335 "style.control.control_panel.padding" => "control_panel_padding",
336 "style.control.control_panel.gap" => "control_panel_gap",
337 "style.status.normal_color" => "status_normal_color",
338 "style.status.background_color" => "status_background_color",
339 "style.status.background_blur" => "status_background_blur",
340 "style.highlight.primary" => "primary_highlight_color",
341 "style.window.page_opacity" => "page_opacity",
342 "style.window.page_margin" => "page_margin",
343 "style.window.plate_padding" => "plate_padding",
344 "style.window.transition_duration" => "transition_duration",
345 "style.overlay.behavior" => "overlay_behavior",
346 "style.overlay.width" => "overlay_width",
347 "style.overlay.position" => "overlay_position",
348 "style.overlay.border_gap" => "overlay_border_gap",
349 "style.editor.last_page" | "style.data.editor.last_page" => "last_page",
350 "style.data.tree.corner_radius" => "tree_corner_radius",
351 "style.data.tree.opacity" => "tree_opacity",
352 "style.data.tree.blur" => "tree_blur",
353 "style.data.tree.font" => "tree_font",
354 "style.surface.desktop.gap_color" => "desktop_gap_color",
355 "style.surface.desktop.cell_color" => "desktop_cell_color",
356 "style.surface.desktop.gap_width" => "desktop_gap_width",
357 "style.surface.desktop.cell_fade_inset" => "desktop_cell_fade_inset",
358 "style.surface.desktop.mode" => "desktop_mode",
359 "style.surface.desktop.solid_color" => "desktop_solid_color",
360 "style.surface.desktop.grid_cell_size" => "desktop_grid_scale",
361 "style.surface.desktop.grid_cell_width" => "grid_cell_width",
362 "style.surface.desktop.grid_cell_height" => "grid_cell_height",
363 "style.surface.plate.padding" => "plate_padding",
364 "style.surface.plate.gap" => "plate_gap",
365 "style.control.gap" => "control_gap",
366 "style.control.list_gap" => "list_gap",
367 // The context menu's own radius (`menu_corner_radius`): a
368 // popover's corner is control-scale, not pane-scale.
369 "style.surface.menu.corner_radius" => "menu_corner_radius",
370 // `style.surface.plate.root.*` is the one spelling of the
371 // root-plate style (RFC Phase 7a). The slot names keep the
372 // historical `root_plate_` prefix; the legacy `root plate.*`
373 // config read-alias was removed 2026-09-06.
374 "style.surface.plate.root.padding" => "root_plate_padding",
375 "style.surface.plate.root.gap" => "root_plate_gap",
376 "style.surface.plate.root.color" => "root_plate_color",
377 "style.surface.plate.root.blur" => "root_plate_blur",
378 "style.surface.plate.root.corner_radius" => "root_plate_corner_radius",
379 "style.surface.plate.root.menubar.color" => "root_plate_menubar_color",
380 "style.surface.plate.root.menubar.text_color" => "root_plate_menubar_text_color",
381 "style.surface.plate.root.menubar.blur" => "root_plate_menubar_blur",
382 "style.surface.plate.root.menubar.font" => "menubar_font",
383 "style.surface.statusbar.color" => "root_plate_statusbar_color",
384 "style.surface.statusbar.text_color" => "root_plate_statusbar_text_color",
385 "style.surface.statusbar.blur" => "root_plate_statusbar_blur",
386 "style.surface.statusbar.font" => "statusbar_font",
387 "style.surface.page.opacity" => "page_opacity",
388 "style.surface.page.margin" => "page_margin",
389 "style.surface.graph.grid_color" => "graph_grid_color",
390 "style.surface.graph.opacity" => "graph_opacity",
391 "style.surface.graph.node.opacity" => "graph_node_opacity",
392 "style.surface.graph.spacing_x" => "graph_spacing_x",
393 "style.surface.graph.spacing_y" => "graph_spacing_y",
394 "style.surface.graph.line_width" => "graph_line_width",
395 "style.surface.graph.node.width" => "graph_node_width",
396 "style.surface.graph.node.height" => "graph_node_height",
397 "style.surface.graph.grid_snap" => "graph_grid_snap",
398 "style.surface.graph.blur" => "graph_blur",
399 "style.surface.graph.font" => "graph_font",
400 "style.surface.graph.node.color" => "graph_node_color",
401 "style.surface.graph.node.font" => "graph_node_font",
402 "style.surface.graph.node.delete" => "graph_node_delete",
403 "style.surface.graph.node.selected_color" => "graph_node_selected_color",
404 "style.surface.graph.node.drag_color" => "graph_node_drag_color",
405 "style.surface.graph.node.corner_radius" => "graph_node_corner_radius",
406 "style.surface.graph.node.wire_color" => "graph_wire_color",
407 "style.surface.graph.node.wire_highlight_color" => "graph_wire_highlight_color",
408 "style.surface.graph.node.wire_size" => "graph_wire_size",
409 "style.surface.graph.node.wire_style" => "graph_wire_style",
410 "style.surface.graph.node.wire_activation_radius" => "graph_wire_activation_radius",
411 "style.surface.graph.node.connector_color" => "graph_connector_color",
412 "style.surface.graph.node.connector_highlight_color" => "graph_connector_highlight_color",
413 "style.surface.graph.node.connector_size" => "graph_connector_size",
414 "style.surface.graph.node.connector_activation_radius" => "graph_connector_activation_radius",
415 "style.surface.plate.color" => "plate_color",
416 "style.surface.plate.border_color" => "plate_border_color",
417 "style.surface.plate.border_thickness" => "plate_border_thickness",
418 "input.touchpad.natural_scroll" => "touchpad_natural_scroll",
419
420 // Anything else keeps its whole path. A key the toolkit reads is mapped above
421 // (or is a flat top-level key, which has no path to strip); the rest belong
422 // to other programs. Until 2026-10-08 an unmapped path was cut down — the
423 // `layout.` and `transparency.` blocks (the compositor's own) to their bare
424 // keys, anything else past its first segment — so the compositor's
425 // `layout { grid_gap 18 }`, its window-tiling gap, arrived as the toolkit's
426 // `grid_gap`, and any block's key could stand in for a toolkit one.
427 other => other,
428 };
429
430 if let Some(s) = val.as_str() {
431 flat_props.push_str(&format!("{} = \"{}\"\n", flat_key, s));
432 } else if let Some(b) = val.as_bool() {
433 flat_props.push_str(&format!("{} = {}\n", flat_key, b));
434 } else if let Some(n) = val.as_f64() {
435 flat_props.push_str(&format!("{} = {}\n", flat_key, n));
436 } else if let Some(n) = val.as_i64() {
437 flat_props.push_str(&format!("{} = {}\n", flat_key, n));
438 }
439 }
440 }
441 }
442
443 // There is no alias-precedence pass any more. For the rest of 2026-09-28
444 // `prefer_relief_spellings` ran here, dropping a relief key's legacy
445 // spelling whenever its current one was present (`height` under
446 // `wall.height`, `depth` under `light`), because two spellings of one
447 // registry key would otherwise be decided by the order the JSON handed
448 // the lines out. Every legacy spelling of the relief is retired now — not
449 // read at all, reported by `color::retired_surface_keys` — so there is
450 // nothing left to prefer, and a config is what it says.
451
452 pub(super) fn read_config() -> Option<String> {
453 let path = crate::config::get_config_path();
454 if let Ok(content) = std::fs::read_to_string(&path) {
455 let val = crate::config::parse_kdl_to_json(&content);
456 let mut flat_props = String::new();
457 flatten_json_to_flat_props(&val, "", &mut flat_props);
458 return Some(flat_props);
459 }
460 None
461 }
462
463 pub fn parse_font_string(s: &str) -> (String, Option<f32>) {
464 let (family, size) = split_font_string(s);
465 (family.to_string(), size)
466 }
467
468 /// [`parse_font_string`] borrowing the family from `s` instead of copying it.
469 pub fn split_font_string(s: &str) -> (&str, Option<f32>) {
470 let s = s.trim();
471 if let Some(last_space_idx) = s.rfind(' ') {
472 let (family, size_str) = s.split_at(last_space_idx);
473 let size_str = size_str.trim();
474 if let Ok(size) = size_str.parse::<f32>() {
475 return (family.trim(), Some(size));
476 }
477 }
478 (s, None)
479 }