GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/layout.rs (276.2K)
1 use crate::widget::WidgetHost;
2 use crate::context::UiContext;
3 use std::sync::RwLock;
4 use std::collections::HashMap;
5 use std::sync::OnceLock;
6
7 /// Per-thread overrides for runtime style writes, under `cfg(test)` only.
8 ///
9 /// Every `graph_*`, corner-radius and control-height slot in this file is
10 /// backed by the one process-wide [`STYLE_REGISTRY`], so a test that pins any
11 /// of them pins it for every test running beside it. That is the same defect
12 /// as the font flake fixed in 1dc0ab1, and it was live:
13 /// `test_graph_style_configuration` sets ~20 style values and restores none,
14 /// while `dual_geometry_views_stay_consistent` bakes quads with
15 /// `graph_node_corner_radius` and then re-reads that getter to compare — so a
16 /// write landing between the two makes them disagree. Widening the write
17 /// window to 300ms reproduced it on demand.
18 ///
19 /// Fixing it at the registry rather than per accessor covers every slot it
20 /// holds in one place, including ones no test pins yet.
21 #[cfg(test)]
22 mod test_overlay {
23 use std::cell::RefCell;
24 use std::collections::HashMap;
25 thread_local! {
26 static FLOATS: RefCell<HashMap<String, f32>> = RefCell::new(HashMap::new());
27 static LENS: RefCell<HashMap<String, crate::units::Len>> = RefCell::new(HashMap::new());
28 static STRINGS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
29 }
30 pub fn set_float(k: &str, v: f32) {
31 LENS.with(|m| m.borrow_mut().remove(k));
32 FLOATS.with(|m| m.borrow_mut().insert(k.to_string(), v));
33 }
34 pub fn set_len(k: &str, v: crate::units::Len) {
35 FLOATS.with(|m| m.borrow_mut().remove(k));
36 LENS.with(|m| m.borrow_mut().insert(k.to_string(), v));
37 }
38 pub fn set_string(k: &str, v: String) {
39 STRINGS.with(|m| m.borrow_mut().insert(k.to_string(), v));
40 }
41 pub fn get_float(k: &str) -> Option<f32> {
42 if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
43 return Some(l.to_px());
44 }
45 FLOATS.with(|m| m.borrow().get(k).copied())
46 }
47 pub fn get_len(k: &str) -> Option<crate::units::Len> {
48 if let Some(l) = LENS.with(|m| m.borrow().get(k).copied()) {
49 return Some(l);
50 }
51 FLOATS.with(|m| m.borrow().get(k).copied()).map(crate::units::Len::px)
52 }
53 pub fn get_string(k: &str) -> Option<String> {
54 STRINGS.with(|m| m.borrow().get(k).cloned())
55 }
56 }
57
58 #[derive(Debug)]
59 pub struct StyleRegistry {
60 pub floats: HashMap<String, f32>,
61 pub strings: HashMap<String, String>,
62 /// Slots whose config value carried a unit (`width=(mm)2.0`). Read
63 /// through `get_float` like any other number, resolved against the
64 /// process metric (`crate::units::metric`) at EVERY read, so a metric
65 /// that arrives after config load — outputs come in after the first
66 /// style read — or changes with the display is honoured live.
67 pub lens: HashMap<String, crate::units::Len>,
68 }
69
70 impl StyleRegistry {
71 pub fn new() -> Self {
72 Self {
73 floats: HashMap::new(),
74 strings: HashMap::new(),
75 lens: HashMap::new(),
76 }
77 }
78
79 pub fn get_float(&self, key: &str) -> Option<f32> {
80 #[cfg(test)]
81 if let Some(v) = test_overlay::get_float(key) {
82 return Some(v);
83 }
84 if let Some(len) = self.lens.get(key) {
85 return Some(len.to_px());
86 }
87 self.floats.get(key).copied()
88 }
89
90 /// The slot as a length with its unit: the configured `Len` when one was
91 /// given, else the plain number as logical px. For editors that show the
92 /// unit the user chose rather than the resolved pixel count.
93 pub fn get_len(&self, key: &str) -> Option<crate::units::Len> {
94 #[cfg(test)]
95 if let Some(v) = test_overlay::get_len(key) {
96 return Some(v);
97 }
98 if let Some(len) = self.lens.get(key) {
99 return Some(*len);
100 }
101 self.floats.get(key).map(|v| crate::units::Len::px(*v))
102 }
103
104 pub fn get_string(&self, key: &str) -> Option<String> {
105 #[cfg(test)]
106 if let Some(v) = test_overlay::get_string(key) {
107 return Some(v);
108 }
109 self.strings.get(key).cloned()
110 }
111
112 /// A plain number wins over any earlier unit value for the slot — a
113 /// runtime `set_float` is the newest opinion.
114 pub fn set_float(&mut self, key: &str, val: f32) {
115 #[cfg(test)]
116 {
117 test_overlay::set_float(key, val);
118 return;
119 }
120 #[cfg(not(test))]
121 self.load_float(key, val);
122 }
123
124 pub fn set_len(&mut self, key: &str, len: crate::units::Len) {
125 #[cfg(test)]
126 {
127 test_overlay::set_len(key, len);
128 return;
129 }
130 #[cfg(not(test))]
131 self.load_len(key, len);
132 }
133
134 pub fn set_string(&mut self, key: &str, val: String) {
135 #[cfg(test)]
136 {
137 test_overlay::set_string(key, val);
138 return;
139 }
140 #[cfg(not(test))]
141 self.load_string(key, val);
142 }
143
144 /// The CONFIG-LOAD writes, as opposed to the runtime `set_*` ones above.
145 /// Kept apart because under `cfg(test)` a runtime set goes to a per-thread
146 /// overlay while the loaded config must stay the shared base every test
147 /// reads — routing config through `set_*` would put one thread's config
148 /// in its overlay and leave every other thread with an empty registry.
149 pub fn load_float(&mut self, key: &str, val: f32) {
150 self.lens.remove(key);
151 self.floats.insert(key.to_string(), val);
152 }
153
154 pub fn load_len(&mut self, key: &str, len: crate::units::Len) {
155 self.floats.remove(key);
156 self.lens.insert(key.to_string(), len);
157 }
158
159 pub fn load_string(&mut self, key: &str, val: String) {
160 self.strings.insert(key.to_string(), val);
161 }
162 }
163
164 pub static STYLE_REGISTRY: OnceLock<RwLock<StyleRegistry>> = OnceLock::new();
165
166 pub fn get_style_registry() -> &'static RwLock<StyleRegistry> {
167 STYLE_REGISTRY.get_or_init(|| RwLock::new(StyleRegistry::new()))
168 }
169
170 pub fn lazy_init_style_registry() {
171 use std::sync::Once;
172 static INIT: Once = Once::new();
173 INIT.call_once(|| {
174 reload_config();
175 });
176 }
177
178 fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props: &mut String) {
179 match val {
180 serde_json::Value::Object(map) => {
181 for (k, v) in map {
182 let next_prefix = if prefix.is_empty() {
183 k.clone()
184 } else {
185 format!("{}.{}", prefix, k)
186 };
187 flatten_json_to_flat_props(v, &next_prefix, flat_props);
188 }
189 }
190 _ => {
191 let flat_key = match prefix {
192 "style.list.font" | "style.data.list.font" => "list_font",
193 "style.list.font_color" | "style.data.list.font_color" => "list_font_color",
194 "style.control.breadcrumb.font" => "breadcrumb_font",
195
196 // The control rung's default radius: every control-scale
197 // `corner_radius` below falls back to it when its own key is unset.
198 "style.control.corner_radius" => "control_corner_radius",
199 "style.control.button.padding" => "button_padding",
200 "style.control.button.height" => "button_height",
201 "style.control.button.corner_radius" => "button_corner_radius",
202 "style.control.button.font" => "button_font",
203 "style.list.corner_radius" | "style.data.list.corner_radius" => "list_corner_radius",
204 "style.control.textbox.corner_radius" | "style.textbox.corner_radius" | "style.data.textbox.corner_radius" => "textbox_corner_radius",
205 "style.control.dropdown.color" => "dropdown_color",
206 "style.control.font_selector.font" => "font_selector_font",
207 "style.container.section.font" | "style.section.font" => "section_label_font",
208 "style.container.section.padding" | "style.section.padding" => "section_padding",
209 "style.control.button_strip.font" => "button_strip_font",
210 "style.control.button_strip.spacing" => "button_strip_spacing",
211 "style.control.dropdown.height" => "dropdown_height",
212 "style.control.dropdown.corner_radius" => "dropdown_corner_radius",
213 "style.control.font_selector.height" => "font_selector_height",
214 "style.control.font_selector.corner_radius" => "font_selector_corner_radius",
215 "style.control.label.font" => "control_label_font",
216 "style.control.label.font_detached" => "control_label_font_detached",
217 "style.control.label.margin" => "control_label_margin",
218 "style.control.slider.height" => "slider_height",
219 "style.control.slider.corner_radius" => "slider_corner_radius",
220 "style.control.slider.band_thickness" => "slider_band_thickness",
221 "style.control.slider.bulge_width" => "slider_bulge_width",
222 "style.control.slider.bulge_height" => "slider_bulge_height",
223 "style.control.progressbar.height" => "progressbar_height",
224 "style.control.rangeslider.height" => "rangeslider_height",
225 "style.control.scrollbar.width" => "scrollbar_width",
226 "style.control.scrollbar.inset" => "scrollbar_inset",
227 "style.control.spinbox.height" => "spinbox_height",
228 "style.control.spinbox.button_padding" => "spinbox_button_padding",
229 "style.control.spinbox.corner_radius" => "spinbox_corner_radius",
230 "style.control.textbox.height" | "style.textbox.height" | "style.data.textbox.height" => "textbox_height",
231 "style.control.textbox.placeholder_text_color" | "style.textbox.placeholder_text_color" | "style.data.textbox.placeholder_text_color" => "textbox_placeholder_text_color",
232 "style.control.textbox.background_color" | "style.textbox.background_color" | "style.data.textbox.background_color" => "textbox_background_color",
233 "style.control.textbox.background_edit_color" | "style.textbox.background_edit_color" | "style.data.textbox.background_edit_color" => "textbox_background_edit_color",
234 "style.control.textbox.multiline.line_wrap" | "style.textbox.multiline.line_wrap" | "style.data.textbox.multiline.line_wrap" => "textbox_line_wrap",
235 "style.control.textbox.multiline.border_width" | "style.textbox.multiline.border_width" | "style.data.textbox.multiline.border_width" => "textbox_multiline_border_width",
236 "style.control.toggle.height" => "toggle_height",
237 "style.control.toggle.border_width" => "toggle_border_width",
238 "style.control.toggle.disabled_color" => "toggle_disabled_color",
239 "style.control.toggle.border_color" => "toggle_border_color",
240 "style.control.toggle.corner_radius" => "toggle_corner_radius",
241 "window_manager.light_source_position" => "light_source_position",
242 // The DE's relief material: canonical home style.surface.relief
243 // (these shade every bevel/boss/recess in the toolkit — the
244 // compositor never read them, so the old window_manager
245 // spelling survives only as a compat alias).
246 // `depth` is the light strength, not a length — `light` is
247 // the honest spelling, `depth` the one every config has.
248 "style.surface.relief.depth" | "style.surface.relief.light" | "window_manager.bevel_depth" => "bevel_depth",
249 "style.surface.relief.width" | "window_manager.bevel_width" => "bevel_width",
250 // The geometric heights, both lengths (unit-aware): a carve's
251 // drop and the plate roll's rise. Unset = follow the width.
252 "style.surface.relief.height" => "bevel_height",
253 "style.surface.relief.edge_height" => "roll_height",
254 // Ramp-spec strings for the custom wall/roll profiles
255 // (written by cce-relief, installed by reload_config).
256 "style.surface.relief.profile" => "bevel_profile_spec",
257 "style.surface.relief.edge_profile" => "roll_profile_spec",
258 // cce-relief's slider positions behind those specs
259 // ("shoulder,base,bias" — only the editor reads these).
260 "style.surface.relief.profile_knobs" => "bevel_profile_knobs",
261 "style.surface.relief.edge_knobs" => "roll_profile_knobs",
262 "style.container.section.depth" => "section_depth",
263 "style.surface.param.backdrop_compression" => "param_compression",
264 "style.surface.param.label_layout" => "param_label_layout",
265 "window_manager.bevel_shader" => "bevel_shader",
266 "window_manager.control_relief" => "control_relief",
267 "window_manager.corner_shape" => "corner_shape",
268 "style.control.ramp.height" => "ramp_height",
269 "style.layout.column.gap" => "column_gap",
270 "style.control.control_panel.padding" => "control_panel_padding",
271 "style.control.control_panel.gap" => "control_panel_gap",
272 "style.status.normal_color" => "status_normal_color",
273 "style.status.background_color" => "status_background_color",
274 "style.status.background_blur" => "status_background_blur",
275 "style.highlight.primary" => "primary_highlight_color",
276 "style.window.page_opacity" => "page_opacity",
277 "style.window.page_margin" => "page_margin",
278 "style.window.plate_padding" => "plate_padding",
279 "style.window.transition_duration" => "transition_duration",
280 "style.overlay.behavior" => "overlay_behavior",
281 "style.overlay.width" => "overlay_width",
282 "style.overlay.position" => "overlay_position",
283 "style.overlay.border_gap" => "overlay_border_gap",
284 "style.editor.last_page" | "style.data.editor.last_page" => "last_page",
285 "style.data.tree.corner_radius" => "tree_corner_radius",
286 "style.data.tree.opacity" => "tree_opacity",
287 "style.data.tree.blur" => "tree_blur",
288 "style.data.tree.font" => "tree_font",
289 "style.surface.desktop.gap_color" => "desktop_gap_color",
290 "style.surface.desktop.cell_color" => "desktop_cell_color",
291 "style.surface.desktop.gap_width" => "desktop_gap_width",
292 "style.surface.desktop.cell_fade_inset" => "desktop_cell_fade_inset",
293 "style.surface.desktop.mode" => "desktop_mode",
294 "style.surface.desktop.solid_color" => "desktop_solid_color",
295 "style.surface.desktop.grid_cell_size" => "desktop_grid_scale",
296 "style.surface.desktop.grid_cell_width" => "grid_cell_width",
297 "style.surface.desktop.grid_cell_height" => "grid_cell_height",
298 "style.surface.plate.padding" => "plate_padding",
299 "style.surface.plate.gap" => "plate_gap",
300 "style.control.gap" => "control_gap",
301 // The context menu's own radius (`menu_corner_radius`): a
302 // popover's corner is control-scale, not pane-scale.
303 "style.surface.menu.corner_radius" => "menu_corner_radius",
304 // `style.surface.plate.root.*` is the one spelling of the
305 // root-plate style (RFC Phase 7a). The slot names keep the
306 // historical `root_plate_` prefix; the legacy `root plate.*`
307 // config read-alias was removed 2026-09-06.
308 "style.surface.plate.root.padding" => "root_plate_padding",
309 "style.surface.plate.root.gap" => "root_plate_gap",
310 "style.surface.plate.root.color" => "root_plate_color",
311 "style.surface.plate.root.blur" => "root_plate_blur",
312 "style.surface.plate.root.corner_radius" => "root_plate_corner_radius",
313 "style.surface.plate.root.menubar.color" => "root_plate_menubar_color",
314 "style.surface.plate.root.menubar.text_color" => "root_plate_menubar_text_color",
315 "style.surface.plate.root.menubar.blur" => "root_plate_menubar_blur",
316 "style.surface.plate.root.menubar.font" => "menubar_font",
317 "style.surface.statusbar.color" => "root_plate_statusbar_color",
318 "style.surface.statusbar.text_color" => "root_plate_statusbar_text_color",
319 "style.surface.statusbar.blur" => "root_plate_statusbar_blur",
320 "style.surface.statusbar.font" => "statusbar_font",
321 "style.surface.page.opacity" => "page_opacity",
322 "style.surface.page.margin" => "page_margin",
323 "style.surface.graph.cell_color" => "graph_cell_color",
324 "style.surface.graph.gap_color" => "graph_gap_color",
325 "style.surface.graph.opacity" => "graph_opacity",
326 "style.surface.graph.node.opacity" => "graph_node_opacity",
327 "style.surface.graph.spacing_x" => "graph_spacing_x",
328 "style.surface.graph.spacing_y" => "graph_spacing_y",
329 "style.surface.graph.line_width" => "graph_line_width",
330 "style.surface.graph.node.width" => "graph_node_width",
331 "style.surface.graph.node.height" => "graph_node_height",
332 "style.surface.graph.grid_snap" => "graph_grid_snap",
333 "style.surface.graph.blur" => "graph_blur",
334 "style.surface.graph.font" => "graph_font",
335 "style.surface.graph.node.color" => "graph_node_color",
336 "style.surface.graph.node.font" => "graph_node_font",
337 "style.surface.graph.node.delete" => "graph_node_delete",
338 "style.surface.graph.node.selected_color" => "graph_node_selected_color",
339 "style.surface.graph.node.drag_color" => "graph_node_drag_color",
340 "style.surface.graph.node.corner_radius" => "graph_node_corner_radius",
341 "style.surface.graph.node.wire_color" => "graph_wire_color",
342 "style.surface.graph.node.wire_highlight_color" => "graph_wire_highlight_color",
343 "style.surface.graph.node.wire_size" => "graph_wire_size",
344 "style.surface.graph.node.wire_activation_radius" => "graph_wire_activation_radius",
345 "style.surface.graph.node.connector_color" => "graph_connector_color",
346 "style.surface.graph.node.connector_highlight_color" => "graph_connector_highlight_color",
347 "style.surface.graph.node.connector_size" => "graph_connector_size",
348 "style.surface.graph.node.connector_activation_radius" => "graph_connector_activation_radius",
349 "style.surface.plate.color" => "plate_color",
350 "style.surface.plate.border_color" => "plate_border_color",
351 "style.surface.plate.border_thickness" => "plate_border_thickness",
352 "style.surface.plate.blur" => "plate_blur",
353 "input.touchpad.natural_scroll" => "touchpad_natural_scroll",
354
355 other => {
356 if let Some(rest) = other.strip_prefix("layout.") {
357 rest
358 } else if let Some(rest) = other.strip_prefix("transparency.") {
359 rest
360 } else {
361 if let Some(idx) = other.find('.') {
362 &other[idx + 1..]
363 } else {
364 other
365 }
366 }
367 }
368 };
369
370 if let Some(s) = val.as_str() {
371 flat_props.push_str(&format!("{} = \"{}\"\n", flat_key, s));
372 } else if let Some(b) = val.as_bool() {
373 flat_props.push_str(&format!("{} = {}\n", flat_key, b));
374 } else if let Some(n) = val.as_f64() {
375 flat_props.push_str(&format!("{} = {}\n", flat_key, n));
376 } else if let Some(n) = val.as_i64() {
377 flat_props.push_str(&format!("{} = {}\n", flat_key, n));
378 }
379 }
380 }
381 }
382
383 fn read_config() -> Option<String> {
384 let path = crate::config::get_config_path();
385 if let Ok(content) = std::fs::read_to_string(&path) {
386 let val = crate::config::parse_kdl_to_json(&content);
387 let mut flat_props = String::new();
388 flatten_json_to_flat_props(&val, "", &mut flat_props);
389 return Some(flat_props);
390 }
391 None
392 }
393
394 pub fn read_config_value(target_key: &str) -> Option<String> {
395 let path = crate::config::get_config_path();
396 if let Ok(content) = std::fs::read_to_string(&path) {
397 let val = crate::config::parse_kdl_to_json(&content);
398 let mut flat_props = String::new();
399 flatten_json_to_flat_props(&val, "", &mut flat_props);
400 for line in flat_props.lines() {
401 let trimmed = line.trim();
402 if let Some(eq_idx) = trimmed.find('=') {
403 let key = trimmed[..eq_idx].trim();
404 if key == target_key {
405 let val_str = trimmed[eq_idx + 1..].trim().trim_matches('"').trim();
406 return Some(val_str.to_string());
407 }
408 }
409 }
410 }
411 None
412 }
413
414 pub fn parse_font_string(s: &str) -> (String, Option<f32>) {
415 let s = s.trim();
416 if let Some(last_space_idx) = s.rfind(' ') {
417 let (family, size_str) = s.split_at(last_space_idx);
418 let size_str = size_str.trim();
419 if let Ok(size) = size_str.parse::<f32>() {
420 return (family.trim().to_string(), Some(size));
421 }
422 }
423 (s.to_string(), None)
424 }
425
426 static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
427
428 /// Per-thread overrides for the style values tests pin, active only under
429 /// `cfg(test)`.
430 ///
431 /// These values are process-global by design: the toolkit reads them from
432 /// config once and every widget sees the same style. That also makes them
433 /// shared mutable state BETWEEN tests, and `cargo test` runs tests on
434 /// parallel threads. `test_vstack_flow` pins a known font and margin so its
435 /// pixel assertions do not depend on the developer's config — and for as
436 /// long as it ran, every other test measuring text saw that font. That is
437 /// the whole "cce-ui parallel test flake": `a_members_wide_label_is_in_the_hull`
438 /// and two `layout::tests` siblings failing perhaps one run in three on a
439 /// clean tree, always green at `--test-threads=1`, and blamed on innocent
440 /// diffs for weeks.
441 ///
442 /// A lock around the three known victims would have fixed those three. This
443 /// fixes the class: a `set_*` is invisible to tests running beside it, and a
444 /// future test that pins a style needs no lock and no discipline to remember.
445 /// Production is untouched — `cfg(test)` is set only while compiling this
446 /// crate's own unit tests, never for downstream crates.
447 #[cfg(test)]
448 mod test_style {
449 use std::cell::RefCell;
450 thread_local! {
451 pub static CONTROL_LABEL_MARGIN: RefCell<Option<f32>> = const { RefCell::new(None) };
452 pub static SECTION_PADDING: RefCell<Option<f32>> = const { RefCell::new(None) };
453 pub static CONTROL_LABEL_FONT: RefCell<Option<String>> = const { RefCell::new(None) };
454 pub static CONTROL_LABEL_FONT_DETACHED: RefCell<Option<String>> = const { RefCell::new(None) };
455 }
456 }
457 /// The height every text-bearing control falls back to when its own
458 /// `style.control.<name>.height` is unset: button, toggle (and the checkbox
459 /// row), dropdown, textbox (and the keybind recorder), spinbox, font selector,
460 /// colour selector, button strip, breadcrumb. One number, so a form built from
461 /// defaults lines up; a per-control key is the deliberate exception.
462 pub const DEFAULT_CONTROL_HEIGHT: f32 = 24.0;
463
464 /// The one gap between controls — one control height — in BOTH axes: what every
465 /// layout strategy's `Default` puts between children's blocks (a detached label
466 /// and the control below it, see `WidgetHost::label_strip`) and around them, and
467 /// what the legacy row builders advance by. One number, one module, so the space
468 /// beside a control and the space below it read the same.
469 pub const CONTROL_GAP: f32 = DEFAULT_CONTROL_HEIGHT;
470
471 /// The one inset from a control's edge to its text: the field text of a TextBox,
472 /// Dropdown, Spinbox, FontSelector, KeybindRecorder or ColorSelector, a left-
473 /// justified Button or Toggle label, a Slider's readout. Fields in a column line
474 /// their text up because they all use this.
475 pub const CONTROL_TEXT_INSET: f32 = 8.0;
476
477 /// A carve that stays INSIDE its rect. A recess, boss or trough wall straddles the
478 /// rect edge it is given — half its depth outside — so a well carved at a widget's
479 /// rect edge painted past the widget's box, and the gap beside a well read up to
480 /// half a depth smaller than the gap beside a raised plate (whose roll is inside).
481 /// Every widget carves the rect this returns instead: inset by half the depth, the
482 /// radii reduced by the same so the OUTER silhouette keeps the configured radius.
483 /// The widget's footprint is then its rect, and the gap is the gap.
484 pub fn carve_inside(rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, depth: f32) -> (crate::scene::layout::Rect, crate::scene::paint::Radii) {
485 let g = depth * 0.5;
486 let r = |r: f32| if r > 0.0 { (r - g).max(0.0) } else { 0.0 };
487 (
488 crate::scene::layout::Rect { x: rect.x + g, y: rect.y + g, width: (rect.width - depth).max(0.0), height: (rect.height - depth).max(0.0) },
489 (r(radii.0), r(radii.1), r(radii.2), r(radii.3)),
490 )
491 }
492
493 /// The one inset from a control's left edge to its detached label above it — the
494 /// x offset the adapter draws the label at, and the tab hugging that label in the
495 /// carve-out compositions (Slider, Dropdown, RangeSlider). Every labeled control
496 /// uses it, so a column of labels is one line.
497 pub const DETACHED_LABEL_INSET: f32 = 4.0;
498 /// The same for the track-shaped controls: slider, range slider, progress bar,
499 /// usage bar.
500 pub const DEFAULT_TRACK_HEIGHT: f32 = 16.0;
501
502 static SPINBOX_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
503 static SPINBOX_BUTTON_PADDING: RwLock<f32> = RwLock::new(0.0);
504 static COLOR_SELECTOR_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
505 static TEXTBOX_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
506 static FONT_SELECTOR_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
507 static SLIDER_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_TRACK_HEIGHT);
508 static PROGRESSBAR_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_TRACK_HEIGHT);
509 static RANGESLIDER_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_TRACK_HEIGHT);
510 static TOGGLE_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
511 static COLOR_SELECTOR_FONT: RwLock<String> = RwLock::new(String::new());
512 static COLOR_SELECTOR_PREVIEW_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
513 static COLOR_SELECTOR_PREVIEW_MARGIN: RwLock<f32> = RwLock::new(0.0);
514 static COLOR_SELECTOR_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
515 static MENUBAR_FONT: RwLock<String> = RwLock::new(String::new());
516 static MENUBAR_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
517 static STATUSBAR_FONT: RwLock<String> = RwLock::new(String::new());
518 static STATUSBAR_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
519 static SECTION_LABEL_FONT: RwLock<String> = RwLock::new(String::new());
520 static NESTED_SECTION_LABEL_FONT: RwLock<String> = RwLock::new(String::new());
521 static BREADCRUMB_FONT: RwLock<String> = RwLock::new(String::new());
522 static BUTTON_FONT: RwLock<String> = RwLock::new(String::new());
523
524 static PAGINATOR_TAB_PADDING_X: RwLock<f32> = RwLock::new(10.0);
525 static BUTTON_PADDING: RwLock<f32> = RwLock::new(14.0);
526 static BUTTON_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
527 static RAMP_HEIGHT: RwLock<f32> = RwLock::new(32.0);
528 static BUTTON_STRIP_SPACING: RwLock<f32> = RwLock::new(8.0);
529 static SCROLLBAR_WIDTH: RwLock<f32> = RwLock::new(4.0);
530 static SCROLLBAR_INSET: RwLock<f32> = RwLock::new(16.0);
531 static COLUMN_GAP: RwLock<Option<f32>> = RwLock::new(None);
532 static CONTROL_PANEL_PADDING: RwLock<Option<f32>> = RwLock::new(None);
533 static CONTROL_PANEL_GAP: RwLock<Option<f32>> = RwLock::new(None);
534 static TREE_OPACITY: RwLock<f32> = RwLock::new(1.0);
535 static TREE_BLUR: RwLock<f32> = RwLock::new(0.0);
536
537 static PLATE_PADDING: RwLock<f32> = RwLock::new(20.0);
538 static DROPDOWN_HEIGHT: RwLock<f32> = RwLock::new(DEFAULT_CONTROL_HEIGHT);
539 static NESTED_SECTION_LABEL_ALIGNMENT: RwLock<u8> = RwLock::new(0);
540 static TOUCHPAD_NATURAL_SCROLL: RwLock<bool> = RwLock::new(false);
541
542
543 static BUTTON_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
544 static SPINBOX_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
545 static TEXTBOX_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
546 static FONT_SELECTOR_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
547 static DROPDOWN_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
548 static TOGGLE_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
549 static SLIDER_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
550 static RANGESLIDER_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
551 static LIST_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
552 static TREE_CORNER_RADIUS: RwLock<f32> = RwLock::new(4.0);
553 static TOGGLE_BORDER_WIDTH: RwLock<f32> = RwLock::new(1.0);
554 static FONT_SELECTOR_FONT: RwLock<String> = RwLock::new(String::new());
555 static FONT_SELECTOR_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
556 static BUTTON_STRIP_FONT: RwLock<String> = RwLock::new(String::new());
557 static BUTTON_STRIP_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
558 static CONTROL_LABEL_FONT: RwLock<String> = RwLock::new(String::new());
559 static CONTROL_LABEL_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
560 static CONTROL_LABEL_FONT_DETACHED: RwLock<String> = RwLock::new(String::new());
561 static CONTROL_LABEL_FONT_DETACHED_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
562 static CONTROL_LABEL_MARGIN: RwLock<f32> = RwLock::new(6.0);
563 static PLATE_CORNER_RADIUS: RwLock<f32> = RwLock::new(12.0);
564 static LIST_FONT: RwLock<String> = RwLock::new(String::new());
565 static LIST_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
566 static TREE_FONT: RwLock<String> = RwLock::new(String::new());
567 static TREE_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
568 static GRAPH_FONT: RwLock<String> = RwLock::new(String::new());
569 static GRAPH_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
570 static GRAPH_NODE_FONT: RwLock<String> = RwLock::new(String::new());
571 static GRAPH_NODE_FONT_CACHED: RwLock<Option<(String, f32)>> = RwLock::new(None);
572 static LIST_JUSTIFICATION: RwLock<u8> = RwLock::new(0);
573 static PLATE_OPACITY: RwLock<f32> = RwLock::new(1.0);
574 static PAGE_OPACITY: RwLock<f32> = RwLock::new(1.0);
575 static LAYER_OPACITY: RwLock<f32> = RwLock::new(1.0);
576 static TEXTBOX_LINE_WRAP: RwLock<bool> = RwLock::new(true);
577 static TEXTBOX_MULTILINE_BORDER_WIDTH: RwLock<f32> = RwLock::new(1.0);
578
579
580
581
582 /// Standard line height multiplier for text layout in cce-ui.
583 pub const TEXT_LINE_HEIGHT_MULTIPLIER: f32 = 1.0;
584
585 /// Standard line height based on font size.
586 pub fn line_height(font_size: f32) -> f32 {
587 font_size * TEXT_LINE_HEIGHT_MULTIPLIER
588 }
589
590 /// Aligns a text label's top coordinate (`y`) so it is centered vertically
591 /// inside a container of height `container_h` starting at `y`.
592 pub fn center_text_y(y: f32, container_h: f32, font_size: f32) -> f32 {
593 y + (container_h - line_height(font_size)) / 2.0
594 }
595
596 /// Standardized vertical text alignment calculation based on Spinbox widget alignment.
597 pub fn align_text_y(y: f32, height: f32, font_size: f32, top_offset: f32) -> f32 {
598 y + top_offset + (height - top_offset - font_size) / 2.0
599 }
600
601 pub fn reload_config() {
602 if let Some(content) = read_config() {
603 let mut menubar_font_changed = false;
604 let mut statusbar_font_changed = false;
605 let mut font_selector_font_changed = false;
606 let mut button_strip_font_changed = false;
607 let mut label_font_changed = false;
608 let mut label_font_detached_changed = false;
609 let mut list_font_changed = false;
610 let mut tree_font_changed = false;
611 let mut graph_font_changed = false;
612 let mut graph_node_font_changed = false;
613 for line in content.lines() {
614 let trimmed = line.trim();
615 let mut key = String::new();
616 let mut val_str = "";
617 if let Some(eq_idx) = trimmed.find('=') {
618 key = trimmed[..eq_idx].trim().to_string();
619 val_str = trimmed[eq_idx + 1..].trim().trim_matches('"').trim();
620 if let Ok(mut registry) = get_style_registry().write() {
621 if let Ok(f_val) = val_str.parse::<f32>() {
622 registry.load_float(&key, f_val);
623 } else if let Some(len) = crate::units::Len::parse(val_str) {
624 // `(mm)2.0` arrived as the string `2mm`.
625 registry.load_len(&key, len);
626 } else {
627 registry.load_string(&key, val_str.to_string());
628 }
629 }
630 }
631 if let Some(rest) = trimmed.strip_prefix("control_label_margin") {
632 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
633 let val_str = rest.trim_end_matches('"').trim();
634 if let Ok(val) = val_str.parse::<f32>() {
635 if let Ok(mut lock) = CONTROL_LABEL_MARGIN.write() {
636 *lock = val;
637 }
638 }
639 }
640 if let Some(rest) = trimmed.strip_prefix("nested_section_label_alignment") {
641 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
642 let val_str = rest.trim_end_matches('"').trim();
643 if let Ok(val) = val_str.parse::<u8>() {
644 if let Ok(mut lock) = NESTED_SECTION_LABEL_ALIGNMENT.write() {
645 *lock = val;
646 }
647 }
648 }
649 if let Some(rest) = trimmed.strip_prefix("nested_section_label_offset") {
650 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
651 let val_str = rest.trim_end_matches('"').trim();
652 if let Ok(val) = val_str.parse::<f32>() {
653 if let Ok(mut lock) = NESTED_SECTION_LABEL_OFFSET.write() {
654 *lock = val;
655 }
656 }
657 }
658 if let Some(rest) = trimmed.strip_prefix("plate_padding") {
659 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
660 let val_str = rest.trim_end_matches('"').trim();
661 if let Ok(val) = val_str.parse::<f32>() {
662 if let Ok(mut lock) = PLATE_PADDING.write() {
663 *lock = val;
664 }
665 }
666 }
667 if let Some(rest) = trimmed.strip_prefix("page_margin") {
668 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
669 let val_str = rest.trim_end_matches('"').trim();
670 if let Ok(val) = val_str.parse::<f32>() {
671 if let Ok(mut lock) = PAGE_MARGIN.write() {
672 *lock = Some(val);
673 }
674 }
675 }
676 if let Some(rest) = trimmed.strip_prefix("grid_min_col_width") {
677 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
678 let val_str = rest.trim_end_matches('"').trim();
679 if let Ok(val) = val_str.parse::<f32>() {
680 if let Ok(mut lock) = GRID_MIN_COL_WIDTH.write() {
681 *lock = val;
682 }
683 }
684 }
685 if let Some(rest) = trimmed.strip_prefix("grid_gap") {
686 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
687 let val_str = rest.trim_end_matches('"').trim();
688 if let Ok(val) = val_str.parse::<f32>() {
689 if let Ok(mut lock) = GRID_GAP.write() {
690 *lock = val;
691 }
692 }
693 }
694 if let Some(rest) = trimmed.strip_prefix("column_gap") {
695 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
696 let val_str = rest.trim_end_matches('"').trim();
697 if let Ok(val) = val_str.parse::<f32>() {
698 if let Ok(mut lock) = COLUMN_GAP.write() {
699 *lock = Some(val);
700 }
701 }
702 }
703 if let Some(rest) = trimmed.strip_prefix("control_panel_padding") {
704 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
705 let val_str = rest.trim_end_matches('"').trim();
706 if let Ok(val) = val_str.parse::<f32>() {
707 if let Ok(mut lock) = CONTROL_PANEL_PADDING.write() {
708 *lock = Some(val);
709 }
710 }
711 }
712 if let Some(rest) = trimmed.strip_prefix("control_panel_gap") {
713 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
714 let val_str = rest.trim_end_matches('"').trim();
715 if let Ok(val) = val_str.parse::<f32>() {
716 if let Ok(mut lock) = CONTROL_PANEL_GAP.write() {
717 *lock = Some(val);
718 }
719 }
720 }
721 if let Some(rest) = trimmed.strip_prefix("section_padding") {
722 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
723 let val_str = rest.trim_end_matches('"').trim();
724 if let Ok(val) = val_str.parse::<f32>() {
725 if let Ok(mut lock) = SECTION_PADDING.write() {
726 *lock = val;
727 }
728 }
729 }
730 if let Some(rest) = trimmed.strip_prefix("scrollbar_width") {
731 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
732 let val_str = rest.trim_end_matches('"').trim();
733 if let Ok(val) = val_str.parse::<f32>() {
734 if let Ok(mut lock) = SCROLLBAR_WIDTH.write() {
735 *lock = val;
736 }
737 }
738 }
739 if let Some(rest) = trimmed.strip_prefix("scrollbar_inset") {
740 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
741 let val_str = rest.trim_end_matches('"').trim();
742 if let Ok(val) = val_str.parse::<f32>() {
743 if let Ok(mut lock) = SCROLLBAR_INSET.write() {
744 *lock = val;
745 }
746 }
747 }
748 if let Some(rest) = trimmed.strip_prefix("tree_opacity") {
749 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
750 let val_str = rest.trim_end_matches('"').trim();
751 if let Ok(val) = val_str.parse::<f32>() {
752 if let Ok(mut lock) = TREE_OPACITY.write() {
753 *lock = val;
754 }
755 }
756 }
757 if let Some(rest) = trimmed.strip_prefix("tree_blur") {
758 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
759 let val_str = rest.trim_end_matches('"').trim();
760 if let Ok(val) = val_str.parse::<f32>() {
761 if let Ok(mut lock) = TREE_BLUR.write() {
762 *lock = val;
763 }
764 }
765 }
766 if let Some(rest) = trimmed.strip_prefix("spinbox_height") {
767 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
768 let val_str = rest.trim_end_matches('"').trim();
769 if let Ok(val) = val_str.parse::<f32>() {
770 if let Ok(mut lock) = SPINBOX_HEIGHT.write() {
771 *lock = val;
772 }
773 }
774 }
775 if let Some(rest) = trimmed.strip_prefix("spinbox_button_padding") {
776 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
777 let val_str = rest.trim_end_matches('"').trim();
778 if let Ok(val) = val_str.parse::<f32>() {
779 if let Ok(mut lock) = SPINBOX_BUTTON_PADDING.write() {
780 *lock = val;
781 }
782 }
783 }
784 if let Some(rest) = trimmed.strip_prefix("spinbox_corner_radius") {
785 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
786 let val_str = rest.trim_end_matches('"').trim();
787 if let Ok(val) = val_str.parse::<f32>() {
788 if let Ok(mut lock) = SPINBOX_CORNER_RADIUS.write() {
789 *lock = val;
790 }
791 }
792 }
793 if let Some(rest) = trimmed.strip_prefix("textbox_corner_radius") {
794 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
795 let val_str = rest.trim_end_matches('"').trim();
796 if let Ok(val) = val_str.parse::<f32>() {
797 if let Ok(mut lock) = TEXTBOX_CORNER_RADIUS.write() {
798 *lock = val;
799 }
800 }
801 }
802 if let Some(rest) = trimmed.strip_prefix("textbox_line_wrap") {
803 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
804 let val_str = rest.trim_end_matches('"').trim();
805 let wrap = val_str == "true" || val_str == "1" || val_str == "1.0";
806 if let Ok(mut lock) = TEXTBOX_LINE_WRAP.write() {
807 *lock = wrap;
808 }
809 }
810 if let Some(rest) = trimmed.strip_prefix("touchpad_natural_scroll") {
811 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
812 let val_str = rest.trim_end_matches('"').trim();
813 let enabled = val_str == "true" || val_str == "1" || val_str == "1.0";
814 if let Ok(mut lock) = TOUCHPAD_NATURAL_SCROLL.write() {
815 *lock = enabled;
816 }
817 }
818 if let Some(rest) = trimmed.strip_prefix("textbox_multiline_border_width") {
819 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
820 let val_str = rest.trim_end_matches('"').trim();
821 if let Ok(val) = val_str.parse::<f32>() {
822 if let Ok(mut lock) = TEXTBOX_MULTILINE_BORDER_WIDTH.write() {
823 *lock = val;
824 }
825 }
826 }
827 if let Some(rest) = trimmed.strip_prefix("list_corner_radius") {
828 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
829 let val_str = rest.trim_end_matches('"').trim();
830 if let Ok(val) = val_str.parse::<f32>() {
831 if let Ok(mut lock) = LIST_CORNER_RADIUS.write() {
832 *lock = val;
833 }
834 }
835 }
836 if let Some(rest) = trimmed.strip_prefix("tree_corner_radius") {
837 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
838 let val_str = rest.trim_end_matches('"').trim();
839 if let Ok(val) = val_str.parse::<f32>() {
840 if let Ok(mut lock) = TREE_CORNER_RADIUS.write() {
841 *lock = val;
842 }
843 }
844 }
845 if let Some(rest) = trimmed.strip_prefix("font_selector_corner_radius") {
846 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
847 let val_str = rest.trim_end_matches('"').trim();
848 if let Ok(val) = val_str.parse::<f32>() {
849 if let Ok(mut lock) = FONT_SELECTOR_CORNER_RADIUS.write() {
850 *lock = val;
851 }
852 }
853 }
854 if let Some(rest) = trimmed.strip_prefix("dropdown_corner_radius") {
855 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
856 let val_str = rest.trim_end_matches('"').trim();
857 if let Ok(val) = val_str.parse::<f32>() {
858 if let Ok(mut lock) = DROPDOWN_CORNER_RADIUS.write() {
859 *lock = val;
860 }
861 }
862 }
863 if let Some(rest) = trimmed.strip_prefix("toggle_corner_radius") {
864 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
865 let val_str = rest.trim_end_matches('"').trim();
866 if let Ok(val) = val_str.parse::<f32>() {
867 if let Ok(mut lock) = TOGGLE_CORNER_RADIUS.write() {
868 *lock = val;
869 }
870 }
871 }
872 if let Some(rest) = trimmed.strip_prefix("plate_corner_radius") {
873 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
874 let val_str = rest.trim_end_matches('"').trim();
875 if let Ok(val) = val_str.parse::<f32>() {
876 if let Ok(mut lock) = PLATE_CORNER_RADIUS.write() {
877 *lock = val;
878 }
879 }
880 }
881 if let Some(rest) = trimmed.strip_prefix("plate_opacity") {
882 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
883 let val_str = rest.trim_end_matches('"').trim();
884 if let Ok(val) = val_str.parse::<f32>() {
885 if let Ok(mut lock) = PLATE_OPACITY.write() {
886 *lock = val;
887 }
888 }
889 }
890 if let Some(rest) = trimmed.strip_prefix("page_opacity") {
891 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
892 let val_str = rest.trim_end_matches('"').trim();
893 if let Ok(val) = val_str.parse::<f32>() {
894 if let Ok(mut lock) = PAGE_OPACITY.write() {
895 *lock = val;
896 }
897 }
898 }
899 if let Some(rest) = trimmed.strip_prefix("layer_opacity") {
900 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
901 let val_str = rest.trim_end_matches('"').trim();
902 if let Ok(val) = val_str.parse::<f32>() {
903 if let Ok(mut lock) = LAYER_OPACITY.write() {
904 *lock = val;
905 }
906 }
907 }
908
909
910 if let Some(rest) = trimmed.strip_prefix("toggle_height") {
911
912 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
913 let val_str = rest.trim_end_matches('"').trim();
914 if let Ok(val) = val_str.parse::<f32>() {
915 if let Ok(mut lock) = TOGGLE_HEIGHT.write() {
916 *lock = val;
917 }
918 }
919 }
920 if let Some(rest) = trimmed.strip_prefix("color_selector_height") {
921 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
922 let val_str = rest.trim_end_matches('"').trim();
923 if let Ok(val) = val_str.parse::<f32>() {
924 if let Ok(mut lock) = COLOR_SELECTOR_HEIGHT.write() {
925 *lock = val;
926 }
927 }
928 }
929 if let Some(rest) = trimmed.strip_prefix("font_selector_height") {
930 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
931 let val_str = rest.trim_end_matches('"').trim();
932 if let Ok(val) = val_str.parse::<f32>() {
933 if let Ok(mut lock) = FONT_SELECTOR_HEIGHT.write() {
934 *lock = val;
935 }
936 }
937 }
938 if let Some(rest) = trimmed.strip_prefix("color_selector_font") {
939 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
940 let rest = rest.trim();
941 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
942 &rest[1..rest.len() - 1]
943 } else {
944 rest
945 };
946 let font = val_str.trim().to_string();
947 if let Ok(mut lock) = COLOR_SELECTOR_FONT.write() {
948 *lock = font;
949 }
950 }
951 if let Some(rest) = trimmed.strip_prefix("menubar_font") {
952 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
953 let rest = rest.trim();
954 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
955 &rest[1..rest.len() - 1]
956 } else {
957 rest
958 };
959 let font = val_str.trim().to_string();
960 let mut changed = false;
961 if let Ok(mut lock) = MENUBAR_FONT.write() {
962 if *lock != font {
963 *lock = font;
964 changed = true;
965 }
966 }
967 if changed {
968 menubar_font_changed = true;
969 }
970 }
971 if let Some(rest) = trimmed.strip_prefix("statusbar_font") {
972 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
973 let rest = rest.trim();
974 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
975 &rest[1..rest.len() - 1]
976 } else {
977 rest
978 };
979 let font = val_str.trim().to_string();
980 let mut changed = false;
981 if let Ok(mut lock) = STATUSBAR_FONT.write() {
982 if *lock != font {
983 *lock = font;
984 changed = true;
985 }
986 }
987 if changed {
988 statusbar_font_changed = true;
989 }
990 }
991 if let Some(rest) = trimmed.strip_prefix("section_label_font") {
992 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
993 let rest = mod_rest(rest);
994 let font = rest.trim().to_string();
995 if let Ok(mut lock) = SECTION_LABEL_FONT.write() {
996 *lock = font;
997 }
998 }
999 if let Some(rest) = trimmed.strip_prefix("nested_section_label_font") {
1000 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
1001 let rest = mod_rest(rest);
1002 let font = rest.trim().to_string();
1003 if let Ok(mut lock) = NESTED_SECTION_LABEL_FONT.write() {
1004 *lock = font;
1005 }
1006 }
1007 if let Some(rest) = trimmed.strip_prefix("breadcrumb_font") {
1008 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
1009 let rest = mod_rest(rest);
1010 let font = rest.trim().to_string();
1011 if let Ok(mut lock) = BREADCRUMB_FONT.write() {
1012 *lock = font;
1013 }
1014 }
1015 if let Some(rest) = trimmed.strip_prefix("button_font") {
1016 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
1017 let rest = mod_rest(rest);
1018 let font = rest.trim().to_string();
1019 if let Ok(mut lock) = BUTTON_FONT.write() {
1020 *lock = font;
1021 }
1022 }
1023 if let Some(rest) = trimmed.strip_prefix("color_selector_preview_corner_radius") {
1024 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1025 let val_str = rest.trim_end_matches('"').trim();
1026 if let Ok(val) = val_str.parse::<f32>() {
1027 if let Ok(mut lock) = COLOR_SELECTOR_PREVIEW_CORNER_RADIUS.write() {
1028 *lock = val;
1029 }
1030 }
1031 }
1032 if let Some(rest) = trimmed.strip_prefix("color_selector_corner_radius") {
1033 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1034 let val_str = rest.trim_end_matches('"').trim();
1035 if let Ok(val) = val_str.parse::<f32>() {
1036 if let Ok(mut lock) = COLOR_SELECTOR_CORNER_RADIUS.write() {
1037 *lock = val;
1038 }
1039 }
1040 }
1041 if let Some(rest) = trimmed.strip_prefix("color_selector_preview_margin") {
1042 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1043 let val_str = rest.trim_end_matches('"').trim();
1044 if let Ok(val) = val_str.parse::<f32>() {
1045 if let Ok(mut lock) = COLOR_SELECTOR_PREVIEW_MARGIN.write() {
1046 *lock = val;
1047 }
1048 }
1049 }
1050 if let Some(rest) = trimmed.strip_prefix("paginator_tab_padding_x") {
1051 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1052 let val_str = rest.trim_end_matches('"').trim();
1053 if let Ok(val) = val_str.parse::<f32>() {
1054 if let Ok(mut lock) = PAGINATOR_TAB_PADDING_X.write() {
1055 *lock = val;
1056 }
1057 }
1058 }
1059 if let Some(rest) = trimmed.strip_prefix("button_padding") {
1060 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1061 let val_str = rest.trim_end_matches('"').trim();
1062 if let Ok(val) = val_str.parse::<f32>() {
1063 if let Ok(mut lock) = BUTTON_PADDING.write() {
1064 *lock = val;
1065 }
1066 }
1067 } else if let Some(rest) = trimmed.strip_prefix("paginator_tab_padding_y") {
1068 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1069 let val_str = rest.trim_end_matches('"').trim();
1070 if let Ok(val) = val_str.parse::<f32>() {
1071 if let Ok(mut lock) = BUTTON_PADDING.write() {
1072 *lock = val;
1073 }
1074 }
1075 }
1076 if let Some(rest) = trimmed.strip_prefix("button_height") {
1077 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1078 let val_str = rest.trim_end_matches('"').trim();
1079 if let Ok(val) = val_str.parse::<f32>() {
1080 if let Ok(mut lock) = BUTTON_HEIGHT.write() {
1081 *lock = val;
1082 }
1083 }
1084 }
1085 if let Some(rest) = trimmed.strip_prefix("button_strip_spacing") {
1086 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1087 let val_str = rest.trim_end_matches('"').trim();
1088 if let Ok(val) = val_str.parse::<f32>() {
1089 if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
1090 *lock = val;
1091 }
1092 }
1093 }
1094 if let Some(rest) = trimmed.strip_prefix("textbox_height") {
1095 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1096 let val_str = rest.trim_end_matches('"').trim();
1097 if let Ok(val) = val_str.parse::<f32>() {
1098 if let Ok(mut lock) = TEXTBOX_HEIGHT.write() {
1099 *lock = val;
1100 }
1101 }
1102 }
1103 if let Some(rest) = trimmed.strip_prefix("dropdown_height") {
1104 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1105 let val_str = rest.trim_end_matches('"').trim();
1106 if let Ok(val) = val_str.parse::<f32>() {
1107 if let Ok(mut lock) = DROPDOWN_HEIGHT.write() {
1108 *lock = val;
1109 }
1110 }
1111 }
1112 if let Some(rest) = trimmed.strip_prefix("slider_height") {
1113 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1114 let val_str = rest.trim_end_matches('"').trim();
1115 if let Ok(val) = val_str.parse::<f32>() {
1116 if let Ok(mut lock) = SLIDER_HEIGHT.write() {
1117 *lock = val;
1118 }
1119 }
1120 }
1121 if let Some(rest) = trimmed.strip_prefix("rangeslider_height") {
1122 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1123 let val_str = rest.trim_end_matches('"').trim();
1124 if let Ok(val) = val_str.parse::<f32>() {
1125 if let Ok(mut lock) = RANGESLIDER_HEIGHT.write() {
1126 *lock = val;
1127 }
1128 }
1129 }
1130 if let Some(rest) = trimmed.strip_prefix("button_corner_radius") {
1131 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1132 let val_str = rest.trim_end_matches('"').trim();
1133 if let Ok(val) = val_str.parse::<f32>() {
1134 if let Ok(mut lock) = BUTTON_CORNER_RADIUS.write() {
1135 *lock = val;
1136 }
1137 }
1138 }
1139 if let Some(rest) = trimmed.strip_prefix("slider_corner_radius") {
1140 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1141 let val_str = rest.trim_end_matches('"').trim();
1142 if let Ok(val) = val_str.parse::<f32>() {
1143 if let Ok(mut lock) = SLIDER_CORNER_RADIUS.write() {
1144 *lock = val;
1145 }
1146 }
1147 }
1148 if let Some(rest) = trimmed.strip_prefix("rangeslider_corner_radius") {
1149 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1150 let val_str = rest.trim_end_matches('"').trim();
1151 if let Ok(val) = val_str.parse::<f32>() {
1152 if let Ok(mut lock) = RANGESLIDER_CORNER_RADIUS.write() {
1153 *lock = val;
1154 }
1155 }
1156 }
1157 if let Some(rest) = trimmed.strip_prefix("toggle_border_width") {
1158 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1159 let val_str = rest.trim_end_matches('"').trim();
1160 if let Ok(val) = val_str.parse::<f32>() {
1161 if let Ok(mut lock) = TOGGLE_BORDER_WIDTH.write() {
1162 *lock = val;
1163 }
1164 }
1165 }
1166 if key == "control_label_font_detached" {
1167 let font = val_str.to_string();
1168 let mut changed = false;
1169 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED.write() {
1170 if *lock != font {
1171 *lock = font;
1172 changed = true;
1173 }
1174 }
1175 if changed {
1176 label_font_detached_changed = true;
1177 }
1178 }
1179 if key == "control_label_font" {
1180 let font = val_str.to_string();
1181 let mut changed = false;
1182 if let Ok(mut lock) = CONTROL_LABEL_FONT.write() {
1183 if *lock != font {
1184 *lock = font;
1185 changed = true;
1186 }
1187 }
1188 if changed {
1189 label_font_changed = true;
1190 }
1191 }
1192 if key == "font_selector_font" {
1193 let font = val_str.to_string();
1194 let mut changed = false;
1195 if let Ok(mut lock) = FONT_SELECTOR_FONT.write() {
1196 if *lock != font {
1197 *lock = font;
1198 changed = true;
1199 }
1200 }
1201 if changed {
1202 font_selector_font_changed = true;
1203 }
1204 }
1205 if key == "button_strip_font" {
1206 let font = val_str.to_string();
1207 let mut changed = false;
1208 if let Ok(mut lock) = BUTTON_STRIP_FONT.write() {
1209 if *lock != font {
1210 *lock = font;
1211 changed = true;
1212 }
1213 }
1214 if changed {
1215 button_strip_font_changed = true;
1216 }
1217 }
1218 if key == "list_font" {
1219 let font = val_str.to_string();
1220 let mut changed = false;
1221 if let Ok(mut lock) = LIST_FONT.write() {
1222 if *lock != font {
1223 *lock = font;
1224 changed = true;
1225 }
1226 }
1227 if changed {
1228 list_font_changed = true;
1229 }
1230 }
1231 if key == "tree_font" {
1232 let font = val_str.to_string();
1233 let mut changed = false;
1234 if let Ok(mut lock) = TREE_FONT.write() {
1235 if *lock != font {
1236 *lock = font;
1237 changed = true;
1238 }
1239 }
1240 if changed {
1241 tree_font_changed = true;
1242 }
1243 }
1244 if key == "graph_font" {
1245 let font = val_str.to_string();
1246 let mut changed = false;
1247 if let Ok(mut lock) = GRAPH_FONT.write() {
1248 if *lock != font {
1249 *lock = font;
1250 changed = true;
1251 }
1252 }
1253 if changed {
1254 graph_font_changed = true;
1255 }
1256 }
1257 if key == "graph_node_font" {
1258 let font = val_str.to_string();
1259 let mut changed = false;
1260 if let Ok(mut lock) = GRAPH_NODE_FONT.write() {
1261 if *lock != font {
1262 *lock = font;
1263 changed = true;
1264 }
1265 }
1266 if changed {
1267 graph_node_font_changed = true;
1268 }
1269 }
1270 if let Some(rest) = trimmed.strip_prefix("list_justification") {
1271 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1272 let val_str = rest.trim_end_matches('"').trim();
1273 if let Ok(val) = val_str.parse::<u8>() {
1274 if let Ok(mut lock) = LIST_JUSTIFICATION.write() {
1275 *lock = val;
1276 }
1277 }
1278 }
1279 }
1280 if menubar_font_changed {
1281 if let Ok(mut lock) = MENUBAR_FONT_CACHED.write() {
1282 *lock = None;
1283 }
1284 }
1285 if statusbar_font_changed {
1286 if let Ok(mut lock) = STATUSBAR_FONT_CACHED.write() {
1287 *lock = None;
1288 }
1289 }
1290 if label_font_detached_changed {
1291 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED_CACHED.write() {
1292 *lock = None;
1293 }
1294 }
1295 if font_selector_font_changed {
1296 if let Ok(mut lock) = FONT_SELECTOR_FONT_CACHED.write() {
1297 *lock = None;
1298 }
1299 }
1300 if button_strip_font_changed {
1301 if let Ok(mut lock) = BUTTON_STRIP_FONT_CACHED.write() {
1302 *lock = None;
1303 }
1304 }
1305 if label_font_changed {
1306 if let Ok(mut lock) = CONTROL_LABEL_FONT_CACHED.write() {
1307 *lock = None;
1308 }
1309 }
1310
1311 if list_font_changed {
1312 if let Ok(mut lock) = LIST_FONT_CACHED.write() {
1313 *lock = None;
1314 }
1315 }
1316 if tree_font_changed {
1317 if let Ok(mut lock) = TREE_FONT_CACHED.write() {
1318 *lock = None;
1319 }
1320 }
1321 if graph_font_changed {
1322 if let Ok(mut lock) = GRAPH_FONT_CACHED.write() {
1323 *lock = None;
1324 }
1325 }
1326 if graph_node_font_changed {
1327 if let Ok(mut lock) = GRAPH_NODE_FONT_CACHED.write() {
1328 *lock = None;
1329 }
1330 }
1331 if let Ok(raw_kdl) = std::fs::read_to_string(crate::config::get_config_path()) {
1332 crate::color::reload_colors(&raw_kdl);
1333 }
1334 // The configured relief profiles, applied last so every process (not
1335 // just the editor that wrote them) starts with the styled walls.
1336 apply_relief_profile_config();
1337 }
1338 }
1339
1340 /// The untouched editor curve — the "analytic" sentinel in the config'd
1341 /// profile specs (cce-designer's Edge Profile convention). For the wall curve
1342 /// identity-smooth IS the analytic smoothstep, so skipping it changes
1343 /// nothing; for the roll it would be a straight chamfer, not the analytic
1344 /// superellipse quadrant, so it must read as "no custom profile".
1345 pub const RELIEF_PROFILE_IDENTITY_SPEC: &str = "smooth;0.000:0.000,1.000:1.000";
1346
1347 /// Parse-and-install the relief profiles config carries as ramp specs
1348 /// (`style.surface.relief.profile` / `edge_profile` → the style registry's
1349 /// `bevel_profile_spec` / `roll_profile_spec`). Absent, identity, or
1350 /// unparseable specs clear back to the analytic profiles.
1351 fn apply_relief_profile_config() {
1352 let (wall, edge) = {
1353 let reg = get_style_registry().read().unwrap();
1354 (reg.get_string("bevel_profile_spec"), reg.get_string("roll_profile_spec"))
1355 };
1356 match parse_relief_profile_spec(wall.as_deref()) {
1357 Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
1358 None => clear_bevel_profile(),
1359 }
1360 match parse_relief_profile_spec(edge.as_deref()) {
1361 Some((keys, smooth)) => set_roll_profile_keys(&keys, smooth),
1362 None => clear_roll_profile(),
1363 }
1364 }
1365
1366 /// A config'd profile spec → installable keys. `None` (falling back to the
1367 /// analytic profile) for absent, identity-sentinel, or unparseable specs.
1368 fn parse_relief_profile_spec(spec: Option<&str>) -> Option<(Vec<(f32, f32)>, bool)> {
1369 spec.filter(|s| *s != RELIEF_PROFILE_IDENTITY_SPEC).and_then(crate::widget::parse_ramp_spec)
1370 }
1371
1372 /// Install (or clear back to analytic) the WALL profile from a ramp spec —
1373 /// the entry point for a `(relief)` config value's profile
1374 /// ([`crate::relief_spec::ReliefSpec`]): an app whose feature carries its
1375 /// own material installs it process-wide here. Same identity/unparseable
1376 /// filtering as the config path above.
1377 pub fn install_wall_profile_spec(spec: Option<&str>) {
1378 match parse_relief_profile_spec(spec) {
1379 Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
1380 None => clear_bevel_profile(),
1381 }
1382 }
1383
1384 fn mod_rest(rest: &str) -> &str {
1385 let rest = rest.trim();
1386 if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
1387 &rest[1..rest.len() - 1]
1388 } else {
1389 rest
1390 }
1391 }
1392
1393 pub fn control_label_margin() -> f32 {
1394 #[cfg(test)]
1395 if let Some(v) = test_style::CONTROL_LABEL_MARGIN.with(|c| *c.borrow()) {
1396 return v;
1397 }
1398 *CONTROL_LABEL_MARGIN.read().unwrap()
1399 }
1400
1401 pub(crate) fn control_label_strip() -> f32 {
1402 let (_, font_size) = control_label_font_detached_parsed();
1403 font_size + control_label_margin()
1404 }
1405
1406 pub fn label_margin() -> f32 {
1407 control_label_margin()
1408 }
1409
1410 pub fn set_control_label_margin(margin: f32) {
1411 #[cfg(test)]
1412 test_style::CONTROL_LABEL_MARGIN.with(|c| *c.borrow_mut() = Some(margin));
1413 #[cfg(not(test))]
1414 if let Ok(mut lock) = CONTROL_LABEL_MARGIN.write() {
1415 *lock = margin;
1416 }
1417 }
1418
1419 pub fn set_label_margin(margin: f32) {
1420 set_control_label_margin(margin);
1421 }
1422
1423 pub fn nested_section_label_alignment() -> u8 {
1424 use std::sync::Once;
1425 static INIT: Once = Once::new();
1426 INIT.call_once(|| {
1427 if let Some(content) = read_config() {
1428 for line in content.lines() {
1429 let trimmed = line.trim();
1430 if let Some(rest) = trimmed.strip_prefix("nested_section_label_alignment") {
1431 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1432 let val_str = rest.trim_end_matches('"').trim();
1433 if let Ok(val) = val_str.parse::<u8>() {
1434 if let Ok(mut lock) = NESTED_SECTION_LABEL_ALIGNMENT.write() {
1435 *lock = val;
1436 }
1437 }
1438 }
1439 }
1440 }
1441 });
1442 *NESTED_SECTION_LABEL_ALIGNMENT.read().unwrap()
1443 }
1444
1445 pub fn set_nested_section_label_alignment(align: u8) {
1446 if let Ok(mut lock) = NESTED_SECTION_LABEL_ALIGNMENT.write() {
1447 *lock = align;
1448 }
1449 }
1450
1451 static NESTED_SECTION_LABEL_OFFSET: RwLock<f32> = RwLock::new(0.0);
1452
1453 pub fn nested_section_label_offset() -> f32 {
1454 use std::sync::Once;
1455 static INIT: Once = Once::new();
1456 INIT.call_once(|| {
1457 if let Some(content) = read_config() {
1458 for line in content.lines() {
1459 let trimmed = line.trim();
1460 if let Some(rest) = trimmed.strip_prefix("nested_section_label_offset") {
1461 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1462 let val_str = rest.trim_end_matches('"').trim();
1463 if let Ok(val) = val_str.parse::<f32>() {
1464 if let Ok(mut lock) = NESTED_SECTION_LABEL_OFFSET.write() {
1465 *lock = val;
1466 }
1467 }
1468 }
1469 }
1470 }
1471 });
1472 *NESTED_SECTION_LABEL_OFFSET.read().unwrap()
1473 }
1474
1475 pub fn set_nested_section_label_offset(offset: f32) {
1476 if let Ok(mut lock) = NESTED_SECTION_LABEL_OFFSET.write() {
1477 *lock = offset;
1478 }
1479 }
1480
1481
1482 /// The pane rung's padding: from a pane plate's rim to its content, in
1483 /// logical px (`style.surface.plate.padding`). The second rung of the
1484 /// spacing ladder — [`root_plate_inset`] / [`root_plate_gap`] on the root
1485 /// plate, this and [`plate_gap`] inside a pane plate, [`control_gap`]
1486 /// between controls. Registry-backed (live-reloadable); the legacy flat
1487 /// `plate_padding = N` line still loads as a fallback.
1488 pub fn plate_padding() -> f32 {
1489 registry_float("plate_padding").unwrap_or_else(|| *PLATE_PADDING.read().unwrap())
1490 }
1491
1492 pub fn set_plate_padding(padding: f32) {
1493 if let Ok(mut lock) = PLATE_PADDING.write() {
1494 *lock = padding;
1495 }
1496 }
1497
1498 static PAGE_MARGIN: RwLock<Option<f32>> = RwLock::new(None);
1499
1500 /// Legacy: the page-level margin (`style.surface.page.margin`). Unset, it
1501 /// IS the pane rung's [`plate_padding`] — a page is a pane — so an app
1502 /// still reading it lands on the ladder. Set, it is honoured as before.
1503 pub fn page_margin() -> f32 {
1504 registry_float("page_margin")
1505 .or_else(|| *PAGE_MARGIN.read().unwrap())
1506 .unwrap_or_else(plate_padding)
1507 }
1508
1509 pub fn set_page_margin(margin: f32) {
1510 if let Ok(mut lock) = PAGE_MARGIN.write() {
1511 *lock = Some(margin);
1512 }
1513 }
1514
1515 static GRID_MIN_COL_WIDTH: RwLock<f32> = RwLock::new(260.0);
1516
1517 pub fn grid_min_col_width() -> f32 {
1518 use std::sync::Once;
1519 static INIT: Once = Once::new();
1520 INIT.call_once(|| {
1521 if let Some(content) = read_config() {
1522 for line in content.lines() {
1523 let trimmed = line.trim();
1524 if let Some(rest) = trimmed.strip_prefix("grid_min_col_width") {
1525 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1526 let val_str = rest.trim_end_matches('"').trim();
1527 if let Ok(val) = val_str.parse::<f32>() {
1528 if let Ok(mut lock) = GRID_MIN_COL_WIDTH.write() {
1529 *lock = val;
1530 }
1531 }
1532 }
1533 }
1534 }
1535 });
1536 *GRID_MIN_COL_WIDTH.read().unwrap()
1537 }
1538
1539 pub fn set_grid_min_col_width(width: f32) {
1540 if let Ok(mut lock) = GRID_MIN_COL_WIDTH.write() {
1541 *lock = width;
1542 }
1543 }
1544
1545 static GRID_GAP: RwLock<f32> = RwLock::new(8.0);
1546
1547 pub fn grid_gap() -> f32 {
1548 use std::sync::Once;
1549 static INIT: Once = Once::new();
1550 INIT.call_once(|| {
1551 if let Some(content) = read_config() {
1552 for line in content.lines() {
1553 let trimmed = line.trim();
1554 if let Some(rest) = trimmed.strip_prefix("grid_gap") {
1555 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1556 let val_str = rest.trim_end_matches('"').trim();
1557 if let Ok(val) = val_str.parse::<f32>() {
1558 if let Ok(mut lock) = GRID_GAP.write() {
1559 *lock = val;
1560 }
1561 }
1562 }
1563 }
1564 }
1565 });
1566 *GRID_GAP.read().unwrap()
1567 }
1568
1569 pub fn set_grid_gap(gap: f32) {
1570 if let Ok(mut lock) = GRID_GAP.write() {
1571 *lock = gap;
1572 }
1573 }
1574
1575 /// Legacy: the inter-column gap (`style.layout.column.gap`). Unset, it is
1576 /// the root plate's [`root_plate_gap`] — columns are siblings on the plate.
1577 pub fn column_gap() -> f32 {
1578 registry_float("column_gap")
1579 .or_else(|| *COLUMN_GAP.read().unwrap())
1580 .unwrap_or_else(root_plate_gap)
1581 }
1582
1583 pub fn set_column_gap(gap: f32) {
1584 if let Ok(mut lock) = COLUMN_GAP.write() {
1585 *lock = Some(gap);
1586 }
1587 }
1588
1589 /// Legacy: a control panel's padding (`style.control.control_panel.padding`).
1590 /// Unset, it is the pane rung's [`plate_padding`] — a control panel is a pane.
1591 pub fn control_panel_padding() -> f32 {
1592 registry_float("control_panel_padding")
1593 .or_else(|| *CONTROL_PANEL_PADDING.read().unwrap())
1594 .unwrap_or_else(plate_padding)
1595 }
1596
1597 pub fn set_control_panel_padding(padding: f32) {
1598 if let Ok(mut lock) = CONTROL_PANEL_PADDING.write() {
1599 *lock = Some(padding);
1600 }
1601 }
1602
1603 /// Legacy: a control panel's gap (`style.control.control_panel.gap`).
1604 /// Unset, it is the pane rung's [`plate_gap`].
1605 pub fn control_panel_gap() -> f32 {
1606 registry_float("control_panel_gap")
1607 .or_else(|| *CONTROL_PANEL_GAP.read().unwrap())
1608 .unwrap_or_else(plate_gap)
1609 }
1610
1611 pub fn set_control_panel_gap(gap: f32) {
1612 if let Ok(mut lock) = CONTROL_PANEL_GAP.write() {
1613 *lock = Some(gap);
1614 }
1615 }
1616
1617 /// The section carves' depth multiplier (`style.container.section.depth`,
1618 /// default 1.0): scales the params pane's section-well wall — width and step
1619 /// together — relative to the DE-wide relief material, so sections can read
1620 /// deeper or shallower than the controls around them. Values past 1.0 let the
1621 /// roll widen across the channel groove between the well wall and its packed
1622 /// controls; tune to taste.
1623 pub fn section_depth() -> f32 {
1624 lazy_init_style_registry();
1625 get_style_registry().read().unwrap().get_float("section_depth").unwrap_or(1.0)
1626 }
1627
1628 /// The parameter rows' backdrop compression
1629 /// (`style.surface.param.backdrop_compression`, 0..1): when set, every
1630 /// parameter row in a params pane is floored with the pane material frosted
1631 /// at THIS compression before its controls paint, so each parameter sits on
1632 /// a tablet that pulls the view toward the tint while the pane around it
1633 /// stays at its own — the row-scale twin of the designer's node
1634 /// compression. `None` (unset) draws no floor — the rows are bare, as they
1635 /// always were.
1636 pub fn param_compression() -> Option<f32> {
1637 lazy_init_style_registry();
1638 get_style_registry().read().unwrap().get_float("param_compression").map(|v| v.clamp(0.0, 1.0))
1639 }
1640
1641 /// Whether a params pane lays each row's label BESIDE its control
1642 /// (`style.surface.param.label_layout = "inline"`, the default) or lets the
1643 /// control carry it in the strip above itself (`"stacked"`, the layout every
1644 /// row had before 2026-09-21). Inline, the pane owns the labels: it measures
1645 /// a label column off the widest label, hands each control the rest of the
1646 /// row, and the controls are built unlabelled — an unlabelled control takes
1647 /// its whole rect (`WidgetHost::label_strip` is zero), so the row is one
1648 /// control tall. Toggles and buttons carry their label as their own face and
1649 /// are inline either way.
1650 pub fn param_labels_inline() -> bool {
1651 lazy_init_style_registry();
1652 get_style_registry()
1653 .read()
1654 .unwrap()
1655 .get_string("param_label_layout")
1656 .map_or(true, |v| v.trim() != "stacked")
1657 }
1658
1659 pub fn section_padding() -> f32 {
1660 #[cfg(test)]
1661 if let Some(v) = test_style::SECTION_PADDING.with(|c| *c.borrow()) {
1662 return v;
1663 }
1664 use std::sync::Once;
1665 static INIT: Once = Once::new();
1666 INIT.call_once(|| {
1667 if let Some(content) = read_config() {
1668 for line in content.lines() {
1669 let trimmed = line.trim();
1670 if let Some(rest) = trimmed.strip_prefix("section_padding") {
1671 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1672 let val_str = rest.trim_end_matches('"').trim();
1673 if let Ok(val) = val_str.parse::<f32>() {
1674 if let Ok(mut lock) = SECTION_PADDING.write() {
1675 *lock = val;
1676 }
1677 }
1678 }
1679 }
1680 }
1681 });
1682 *SECTION_PADDING.read().unwrap()
1683 }
1684
1685 pub fn set_section_padding(padding: f32) {
1686 #[cfg(test)]
1687 test_style::SECTION_PADDING.with(|c| *c.borrow_mut() = Some(padding));
1688 #[cfg(not(test))]
1689 if let Ok(mut lock) = SECTION_PADDING.write() {
1690 *lock = padding;
1691 }
1692 }
1693
1694 pub fn spinbox_height() -> f32 {
1695 use std::sync::Once;
1696 static INIT: Once = Once::new();
1697 INIT.call_once(|| {
1698 if let Some(content) = read_config() {
1699 for line in content.lines() {
1700 let trimmed = line.trim();
1701 if let Some(rest) = trimmed.strip_prefix("spinbox_height") {
1702 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1703 let val_str = rest.trim_end_matches('"').trim();
1704 if let Ok(val) = val_str.parse::<f32>() {
1705 if let Ok(mut lock) = SPINBOX_HEIGHT.write() {
1706 *lock = val;
1707 }
1708 }
1709 }
1710 }
1711 }
1712 });
1713 *SPINBOX_HEIGHT.read().unwrap()
1714 }
1715
1716 pub fn set_spinbox_height(height: f32) {
1717 if let Ok(mut lock) = SPINBOX_HEIGHT.write() {
1718 *lock = height;
1719 }
1720 }
1721
1722 pub fn toggle_height() -> f32 {
1723 use std::sync::Once;
1724 static INIT: Once = Once::new();
1725 INIT.call_once(|| {
1726 if let Some(content) = read_config() {
1727 for line in content.lines() {
1728 let trimmed = line.trim();
1729 if let Some(rest) = trimmed.strip_prefix("toggle_height") {
1730 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
1731 let val_str = rest.trim_end_matches('"').trim();
1732 if let Ok(val) = val_str.parse::<f32>() {
1733 if let Ok(mut lock) = TOGGLE_HEIGHT.write() {
1734 *lock = val;
1735 }
1736 }
1737 }
1738 }
1739 }
1740 });
1741 *TOGGLE_HEIGHT.read().unwrap()
1742 }
1743
1744 pub fn set_toggle_height(height: f32) {
1745 if let Ok(mut lock) = TOGGLE_HEIGHT.write() {
1746 *lock = height;
1747 }
1748 }
1749
1750 pub fn light_source_position() -> f32 {
1751 let val = get_style_registry().read().unwrap().get_float("light_source_position").unwrap_or(2.3561945);
1752 if val > 2.0 * std::f32::consts::PI {
1753 val.to_radians()
1754 } else {
1755 val
1756 }
1757 }
1758
1759 pub fn bevel_depth() -> f32 {
1760 get_style_registry().read().unwrap().get_float("bevel_depth").unwrap_or(0.15)
1761 }
1762
1763 /// Corner shape exponent for SDF-lit plates: 2.0 (the default) is a circular
1764 /// arc; higher values are superellipse "squircle" corners with continuous
1765 /// curvature — ~4.5 is the Apple-like look. Clamped to [2, 16]: below 2 the
1766 /// Lp construction degenerates toward a chamfer, above 16 it is visually a
1767 /// square corner and the pow() terms start flirting with f32 range.
1768 pub fn corner_shape() -> f32 {
1769 lazy_init_style_registry();
1770 get_style_registry().read().unwrap().get_float("corner_shape").unwrap_or(2.0).clamp(2.0, 16.0)
1771 }
1772
1773 /// The window silhouette's nominal corner radius: the SHARED config's
1774 /// root plate corner_radius, never the per-app override. The compositor clips
1775 /// every decorated window with this value (widened by
1776 /// [`corner_span_factor`]), so any window-corner arc an app draws itself must
1777 /// use it too — even when the app restyles its own plates through its
1778 /// override file — or its corners detach from the silhouette (and from the
1779 /// desktop grid's cells, which share the same knob).
1780 pub fn window_corner_radius() -> f32 {
1781 crate::config::get_i64_shared("/style/surface/plate/root/corner_radius", 12) as f32
1782 }
1783
1784 /// The curvature-matched corner-span factor for window-scale squircle corners.
1785 /// A raw superellipse of exponent n at a circle's nominal radius turns tighter
1786 /// at the diagonal than that circle — its radius of curvature there is
1787 /// √2·r / (2^(1/n)·(n − 1)). Scaling the corner span by this factor makes the
1788 /// diagonal curvature equal the configured radius, so the corner reads as the
1789 /// same size as a circular one (the same reason Apple's continuous corners run
1790 /// ~1.5·r along the edge). Exactly 1 at n = 2. Applied to window-scale corners
1791 /// only — `Prim::Plate` and the renderer's window-corner clip — never to
1792 /// widget-scale radii, which must match the nominal-radius squircles around them.
1793 /// The window silhouette's EFFECTIVE corner radius: the shared nominal value
1794 /// widened by the corner-span factor — exactly the arc the compositor clips
1795 /// every decorated window with, and the radius a root plate's corners must
1796 /// wear (RFC Phase 7b; `PlateSpec::radii_for` uses it for window-flagged
1797 /// corners). Apps drawing root-surface geometry through non-Plate prims read
1798 /// this scalar directly.
1799 pub fn window_silhouette_radius() -> f32 {
1800 window_corner_radius() * corner_span_factor()
1801 }
1802
1803 pub fn corner_span_factor() -> f32 {
1804 corner_span_factor_for(corner_shape())
1805 }
1806
1807 /// The span factor for an explicit corner exponent — what a plate carrying
1808 /// its own `shape` (see `scene::paint::Prim::Plate`) scales its radii by.
1809 /// Same clamp as [`corner_shape`], so an override cannot reach an exponent
1810 /// the shader would not accept.
1811 pub fn corner_span_factor_for(n: f32) -> f32 {
1812 let n = n.clamp(2.0, 16.0);
1813 if n > 2.001 {
1814 (n - 1.0) * 2f32.powf(1.0 / n) / std::f32::consts::SQRT_2
1815 } else {
1816 1.0
1817 }
1818 }
1819
1820 /// Whether the relief primitives (see `scene::paint::Prim`) render through
1821 /// shader2d's per-pixel SDF-lit branch (the default) or the legacy banded vertex
1822 /// shading. `bevel_shader 0` in config flips back to the old look for A/B
1823 /// comparison — the key keeps the bevel name because it selects how the shared
1824 /// lit EDGE is computed, not which shapes exist.
1825 pub fn bevel_shader() -> bool {
1826 lazy_init_style_registry();
1827 get_style_registry().read().unwrap().get_float("bevel_shader").map(|v| v != 0.0).unwrap_or(true)
1828 }
1829
1830 /// How wide a rolled edge is, in logical px — the distance over which a plate's perimeter
1831 /// or a recess wall curves away from the flat surface. `bevel_depth` is the companion
1832 /// knob: it sets how hard the light falls across that distance. Wide and shallow reads as
1833 /// thick glass; narrow and deep reads as a stamped metal lip.
1834 pub fn bevel_width() -> f32 {
1835 lazy_init_style_registry();
1836 get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(9.3)
1837 }
1838
1839 /// A carve's geometric drop when the material pins one
1840 /// (`style.surface.relief.height`, a length — `(mm)0.3` resolves through
1841 /// the display metric), in logical px. `None` = follow the wall width at the
1842 /// analytic ratio ([`crate::scene::relief_shade::RECESS_DEPTH`]), the look
1843 /// every config had before heights existed. A configured 0 reads as unset,
1844 /// which is how an editor puts a material back on "follow".
1845 pub fn bevel_height() -> Option<f32> {
1846 lazy_init_style_registry();
1847 get_style_registry()
1848 .read()
1849 .unwrap()
1850 .get_float("bevel_height")
1851 .filter(|h| h.is_finite() && *h > 0.0)
1852 }
1853
1854 /// The plate roll's rise when pinned (`style.surface.relief.edge_height`, a
1855 /// length), logical px. `None` = a quarter-round of radius `bevel_width`.
1856 pub fn roll_height() -> Option<f32> {
1857 lazy_init_style_registry();
1858 get_style_registry()
1859 .read()
1860 .unwrap()
1861 .get_float("roll_height")
1862 .filter(|h| h.is_finite() && *h > 0.0)
1863 }
1864
1865 /// The drop of a carve whose wall runs `wall` logical px: the pinned height
1866 /// when there is one, else the analytic ratio of the wall — saturating at the
1867 /// DE's roll width, so a wall wider than the plate's own perimeter roll
1868 /// spreads the same step over a longer run (a softer transition) instead of
1869 /// cutting proportionally deeper. The tessellator's CSG features and the
1870 /// shader's free carves both derive from this rule.
1871 pub fn carve_depth_px(wall: f32) -> f32 {
1872 match bevel_height() {
1873 Some(h) => h,
1874 None => crate::scene::relief_shade::RECESS_DEPTH * wall.min(bevel_width()),
1875 }
1876 }
1877
1878 /// Drop over run for a wall of the DE roll width — what the shading twin
1879 /// scales its slopes by.
1880 pub fn carve_depth_ratio() -> f32 {
1881 let w = bevel_width().max(0.001);
1882 carve_depth_px(w) / w
1883 }
1884
1885 /// Rise over run of the plate roll: 1 (the quarter-round) unless pinned.
1886 pub fn roll_height_ratio() -> f32 {
1887 roll_height().map_or(1.0, |h| h / bevel_width().max(0.001))
1888 }
1889
1890 /// Sample count of the custom bevel profile LUT ([`set_bevel_profile_keys`]).
1891 pub const BEVEL_PROFILE_SAMPLES: usize = 32;
1892
1893 /// The custom bevel/carve height profile, as the slope LUT the renderer uploads
1894 /// to the 2D shader: slot `i` holds `h'` at `v = (i + 0.5) / N` of the wall's
1895 /// height curve `h(v)` (`v` runs 0 at the surrounding plateau → 1 at the carve
1896 /// floor / boss crest; `h` in units of the feature's depth, so a 0→1 curve is
1897 /// the classic full-depth bevel and a curve ending back at its start height is
1898 /// a pure decorative rim). `None` = the analytic smoothstep profile.
1899 static BEVEL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
1900 std::sync::RwLock::new(None);
1901 /// Bumped on every profile change so renderers know to re-upload their LUT.
1902 static BEVEL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1903
1904 /// The custom EDGE (plate roll) profile — same slope-LUT encoding as
1905 /// [`BEVEL_PROFILE`], but read by the shader's `roll_slope` for the perimeter
1906 /// roll of widget-scale plates: `v` runs 0 at the face join → 1 at the
1907 /// silhouette, and the curve is the roll's descent progress (0 = face height,
1908 /// 1 = fully dropped), so the identity curve is a straight chamfer and `None`
1909 /// is the analytic superellipse quadrant.
1910 static ROLL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
1911 std::sync::RwLock::new(None);
1912 static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1913
1914 /// Evaluate a ramp key list at `t` — THE ramp interpolation of the DE.
1915 /// `cce_ui::widget::Ramp` draws it, `RampPreview` previews it, the relief
1916 /// profile LUTs sample it, and cce-window-manager's camera speed ramp mirrors
1917 /// it verbatim (that crate stays dependency-minimal), so a curve sculpted in
1918 /// the widget is exactly the curve every consumer evaluates. Keys are
1919 /// `(pos, value)` sorted by pos; outside the key range the end values hold.
1920 ///
1921 /// `smooth` is the widget's curved line type: a **monotone cubic** through
1922 /// the keys (Fritsch–Butland tangents, cubic Hermite segments) — C1, passes
1923 /// through every key, never overshoots a key, and flattens only at the ends
1924 /// and at genuine local extrema. It used to be a smoothstep blend PER
1925 /// SEGMENT, which forces zero slope at every key: a curve with more than two
1926 /// keys came out as a chain of little bumps, and a wall profile built from
1927 /// it read as jagged and uneven where a smooth slope was drawn. A two-key
1928 /// ramp is unchanged — zero tangents at both ends make the single Hermite
1929 /// segment exactly the old smoothstep — so the identity sentinel and every
1930 /// simple ease keep their look. `false` is straight segments.
1931 pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
1932 let Some(first) = keys.first() else { return 0.0 };
1933 let last = keys.last().unwrap();
1934 if t <= first.0 {
1935 return first.1;
1936 }
1937 if t >= last.0 {
1938 return last.1;
1939 }
1940 for i in 0..keys.len() - 1 {
1941 let ((x0, y0), (x1, y1)) = (keys[i], keys[i + 1]);
1942 if t < x0 || t > x1 {
1943 continue;
1944 }
1945 let h = x1 - x0;
1946 if h.abs() < 0.0001 {
1947 return y0;
1948 }
1949 let s = (t - x0) / h;
1950 if !smooth {
1951 return y0 + (y1 - y0) * s;
1952 }
1953 let (m0, m1) = (ramp_key_tangent(keys, i), ramp_key_tangent(keys, i + 1));
1954 let (s2, s3) = (s * s, s * s * s);
1955 let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
1956 let h10 = s3 - 2.0 * s2 + s;
1957 let h01 = -2.0 * s3 + 3.0 * s2;
1958 let h11 = s3 - s2;
1959 return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
1960 }
1961 first.1
1962 }
1963
1964 /// The monotone cubic's tangent (dy/dpos) at key `i`: zero at either end and
1965 /// at any local extremum (so the curve never overshoots a key), otherwise the
1966 /// Fritsch–Butland weighted harmonic mean of the two neighbouring secants —
1967 /// the shape-preserving choice, which keeps every segment monotone whenever
1968 /// its keys are.
1969 fn ramp_key_tangent(keys: &[(f32, f32)], i: usize) -> f32 {
1970 if i == 0 || i + 1 >= keys.len() {
1971 return 0.0;
1972 }
1973 let ((xp, yp), (x, y), (xn, yn)) = (keys[i - 1], keys[i], keys[i + 1]);
1974 let (h0, h1) = (x - xp, xn - x);
1975 if h0 <= 0.0001 || h1 <= 0.0001 {
1976 return 0.0;
1977 }
1978 let (d0, d1) = ((y - yp) / h0, (yn - y) / h1);
1979 if d0 * d1 <= 0.0 {
1980 return 0.0;
1981 }
1982 let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
1983 (w0 + w1) / (w0 / d0 + w1 / d1)
1984 }
1985
1986 #[cfg(test)]
1987 mod ramp_sampling_tests {
1988 use super::sample_ramp_keys;
1989
1990 #[test]
1991 fn two_key_smooth_is_exactly_smoothstep() {
1992 let keys = [(0.0, 0.0), (1.0, 1.0)];
1993 for i in 0..=20 {
1994 let t = i as f32 / 20.0;
1995 let ss = t * t * (3.0 - 2.0 * t);
1996 assert!((sample_ramp_keys(&keys, true, t) - ss).abs() < 1e-6, "t={t}");
1997 }
1998 }
1999
2000 #[test]
2001 fn passes_through_every_key_and_holds_the_ends() {
2002 let keys = [(0.0, 0.0), (0.15, 0.45), (0.35, 0.7), (0.55, 0.78), (0.75, 0.85), (1.0, 1.0)];
2003 for &(p, v) in &keys {
2004 assert!((sample_ramp_keys(&keys, true, p) - v).abs() < 1e-6, "key {p}");
2005 }
2006 assert_eq!(sample_ramp_keys(&keys, true, -1.0), 0.0);
2007 assert_eq!(sample_ramp_keys(&keys, true, 2.0), 1.0);
2008 }
2009
2010 #[test]
2011 fn monotone_keys_give_a_monotone_curve_without_wobble() {
2012 // The wall profile that came out as a chain of bumps under the old
2013 // per-segment smoothstep.
2014 let keys = [(0.0, 0.0), (0.15, 0.45), (0.35, 0.7), (0.55, 0.78), (0.75, 0.85), (1.0, 1.0)];
2015 let mut last = -1.0f32;
2016 let mut slopes = Vec::new();
2017 for i in 0..=400 {
2018 let t = i as f32 / 400.0;
2019 let v = sample_ramp_keys(&keys, true, t);
2020 assert!(v >= last - 1e-6, "not monotone at t={t}: {v} < {last}");
2021 slopes.push(v - last);
2022 last = v;
2023 }
2024 // No wobble: the slope at an INTERIOR key is a real slope, not the
2025 // zero the old blend pinned there (0.35: secants 1.25 and 0.4 either
2026 // side — the harmonic mean is well above half the smaller one).
2027 let dv = (sample_ramp_keys(&keys, true, 0.355) - sample_ramp_keys(&keys, true, 0.345)) / 0.01;
2028 assert!(dv > 0.3, "slope at key 0.35 is {dv}");
2029 // …and the slope never flips sign back and forth between keys: at
2030 // most one local slope maximum per segment would be a stretch to
2031 // assert, so pin the direct symptom — the curve stays inside the
2032 // key hull (no overshoot beyond the neighbouring key values).
2033 for i in 0..keys.len() - 1 {
2034 let (a, b) = (keys[i], keys[i + 1]);
2035 for j in 1..10 {
2036 let t = a.0 + (b.0 - a.0) * j as f32 / 10.0;
2037 let v = sample_ramp_keys(&keys, true, t);
2038 assert!(v >= a.1.min(b.1) - 1e-6 && v <= a.1.max(b.1) + 1e-6, "overshoot at t={t}: {v}");
2039 }
2040 }
2041 }
2042
2043 #[test]
2044 fn a_peak_is_flat_at_the_peak_and_never_overshoots() {
2045 // The desktop overview speed ramp: rises then falls.
2046 let keys = [(0.0, 0.15), (0.4, 1.0), (1.0, 0.1)];
2047 assert!((sample_ramp_keys(&keys, true, 0.4) - 1.0).abs() < 1e-6);
2048 for i in 0..=100 {
2049 let v = sample_ramp_keys(&keys, true, i as f32 / 100.0);
2050 assert!(v <= 1.0 + 1e-6 && v >= 0.1 - 1e-6, "overshoot {v}");
2051 }
2052 let near = sample_ramp_keys(&keys, true, 0.39);
2053 assert!(near > 0.99, "flat at the extremum: {near}");
2054 }
2055
2056 #[test]
2057 fn linear_is_untouched() {
2058 let keys = [(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)];
2059 assert!((sample_ramp_keys(&keys, false, 0.25) - 0.5).abs() < 1e-6);
2060 assert!((sample_ramp_keys(&keys, false, 0.75) - 0.5).abs() < 1e-6);
2061 }
2062 }
2063
2064 /// Install a custom bevel/carve profile from ramp keys (`(pos, value)`, both
2065 /// 0..1, sorted by pos). Sampled into the slope LUT the shader's `carve_slope`
2066 /// reads in place of its analytic smoothstep — every recess/boss/ridge wall in
2067 /// this process restyles on the next frame. Empty or single-key lists clear
2068 /// back to the analytic profile ([`clear_bevel_profile`]).
2069 pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
2070 let Some(lut) = ramp_profile_lut(keys, smooth) else {
2071 clear_bevel_profile();
2072 return;
2073 };
2074 *BEVEL_PROFILE.write().unwrap() = Some(lut);
2075 BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
2076 }
2077
2078 /// What a key list installs: its slope LUT, or `None` for a degenerate list
2079 /// (fewer than two keys is no curve at all) — the caller clears back to the
2080 /// analytic profile. The pure half of `set_*_profile_keys`.
2081 fn ramp_profile_lut(keys: &[(f32, f32)], smooth: bool) -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
2082 if keys.len() < 2 {
2083 return None;
2084 }
2085 Some(ramp_slope_lut(keys, smooth))
2086 }
2087
2088 /// A ramp key list sampled into the shader's slope LUT — slot `i` holds the
2089 /// curve's slope at `v = (i + 0.5) / N`.
2090 fn ramp_slope_lut(keys: &[(f32, f32)], smooth: bool) -> [f32; BEVEL_PROFILE_SAMPLES] {
2091 let n = BEVEL_PROFILE_SAMPLES;
2092 let mut slopes = [0.0f32; BEVEL_PROFILE_SAMPLES];
2093 for (i, slot) in slopes.iter_mut().enumerate() {
2094 let h0 = sample_ramp_keys(keys, smooth, i as f32 / n as f32);
2095 let h1 = sample_ramp_keys(keys, smooth, (i + 1) as f32 / n as f32);
2096 *slot = (h1 - h0) * n as f32;
2097 }
2098 slopes
2099 }
2100
2101 /// Install a custom EDGE profile for the plate perimeter roll from ramp keys —
2102 /// the [`set_bevel_profile_keys`] twin for [`ROLL_PROFILE`]. The curve is the
2103 /// roll's descent progress from the face join (0) to the silhouette (1); the
2104 /// shader's `roll_slope` samples it in place of the analytic superellipse
2105 /// quadrant. Empty or single-key lists clear back to the analytic roll.
2106 pub fn set_roll_profile_keys(keys: &[(f32, f32)], smooth: bool) {
2107 let Some(lut) = ramp_profile_lut(keys, smooth) else {
2108 clear_roll_profile();
2109 return;
2110 };
2111 *ROLL_PROFILE.write().unwrap() = Some(lut);
2112 ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
2113 }
2114
2115 /// Drop the custom edge profile — plate rolls return to the analytic quadrant.
2116 pub fn clear_roll_profile() {
2117 let mut guard = ROLL_PROFILE.write().unwrap();
2118 if guard.is_some() {
2119 *guard = None;
2120 ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
2121 }
2122 }
2123
2124 /// The installed edge profile's slope LUT, if any — what the renderer uploads.
2125 pub fn roll_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
2126 *ROLL_PROFILE.read().unwrap()
2127 }
2128
2129 /// Change counter for [`roll_profile_slopes`].
2130 pub fn roll_profile_generation() -> u64 {
2131 ROLL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
2132 }
2133
2134 /// Drop the custom bevel profile — walls return to the analytic smoothstep.
2135 pub fn clear_bevel_profile() {
2136 let mut guard = BEVEL_PROFILE.write().unwrap();
2137 if guard.is_some() {
2138 *guard = None;
2139 BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
2140 }
2141 }
2142
2143 /// The installed profile's slope LUT, if any — what the renderer uploads.
2144 pub fn bevel_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
2145 *BEVEL_PROFILE.read().unwrap()
2146 }
2147
2148 /// Change counter for [`bevel_profile_slopes`] — a renderer re-uploads when it
2149 /// differs from the generation it last wrote.
2150 pub fn bevel_profile_generation() -> u64 {
2151 BEVEL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
2152 }
2153
2154 /// Padding between the window plate's edge and the objects sitting on it, in
2155 /// logical px (`style.surface.plate.root.padding` in config.kdl). DE-wide so
2156 /// every app's content sits the same distance off the plate rim.
2157 pub fn root_plate_padding() -> f32 {
2158 lazy_init_style_registry();
2159 get_style_registry().read().unwrap().get_float("root_plate_padding").unwrap_or(16.0)
2160 }
2161
2162 /// Gap between sibling objects on the window plate, in logical px
2163 /// (`style.surface.plate.root.gap`) — pane splits, control rows. The companion to [`root_plate_padding`]:
2164 /// rim distance vs object spacing.
2165 pub fn root_plate_gap() -> f32 {
2166 lazy_init_style_registry();
2167 get_style_registry().read().unwrap().get_float("root_plate_gap").unwrap_or(12.0)
2168 }
2169
2170 /// Where content starts on the standard root plate, measured from the
2171 /// WINDOW edge: the plate's rolled rim ([`bevel_width`]) plus one
2172 /// [`root_plate_padding`]. The padding is a run of flat plate face, the
2173 /// same run [`root_plate_gap`] leaves between two siblings; but the face
2174 /// only begins where the roll ends, so a bare padding at a window edge
2175 /// leaves most of it on the roll — measured at 4px of visible flat against
2176 /// 12 between panes (cce-mail, 2026-09-19). This is the one number an app
2177 /// on the standard plate insets by at its four edges; between siblings it
2178 /// uses the gap, and everything inside a pane plate uses
2179 /// [`plate_padding`]. An app whose base is NOT the rolled root plate (a
2180 /// transparent surface, a bare fill) has no roll to clear and insets by
2181 /// [`root_plate_padding`] alone.
2182 pub fn root_plate_inset() -> f32 {
2183 bevel_width() + root_plate_padding()
2184 }
2185
2186 /// One style-registry float, initialising the registry on first use — the
2187 /// one read every rung getter goes through.
2188 fn registry_float(slot: &str) -> Option<f32> {
2189 lazy_init_style_registry();
2190 get_style_registry().read().unwrap().get_float(slot)
2191 }
2192
2193 /// Gap between siblings INSIDE a pane plate, in logical px
2194 /// (`style.surface.plate.gap`) — the pane rung's twin of
2195 /// [`root_plate_gap`]. Unset, it is the root gap: one number reads as one
2196 /// rhythm across both rungs unless a config says otherwise.
2197 pub fn plate_gap() -> f32 {
2198 registry_float("plate_gap").unwrap_or_else(root_plate_gap)
2199 }
2200
2201 /// Gap between controls, in logical px (`style.control.gap`) — the control
2202 /// rung of the ladder: what the layout strategies put between a form's
2203 /// controls (and between a detached label's block and the next), in both
2204 /// axes. Unset, it is [`CONTROL_GAP`], one control height, the value every
2205 /// strategy's `Default` carried as a literal.
2206 pub fn control_gap() -> f32 {
2207 registry_float("control_gap").unwrap_or(CONTROL_GAP)
2208 }
2209
2210 /// Roll-off width for the wall where a bar (menubar / status bar / the demo's
2211 /// header band) steps down into the window plate. Wider than the plate's own
2212 /// perimeter roll on purpose: the carve depth saturates at `bevel_width` in the
2213 /// tessellator, so the extra width flattens the wall's slope — a soft, gradual
2214 /// transition into the bar — instead of cutting a proportionally deeper groove.
2215 pub fn bar_wall_width() -> f32 {
2216 bevel_width() * 1.75
2217 }
2218
2219 /// DE-wide default for the controls' relief styling (`window_manager.control_relief`
2220 /// in config.kdl, default on): raised Button/Toggle/Dropdown plates, recessed
2221 /// TextBox/Slider wells, recessed MenuBar/StatusBar bands. Widgets read this
2222 /// LIVE, at paint and layout, so [`set_control_relief`] restyles every control
2223 /// in the process at once; the per-widget `with_raised` / `with_recessed` /
2224 /// `with_recess` builders pin one widget either way. `0` reverts the whole DE
2225 /// to the flat look.
2226 pub fn control_relief() -> bool {
2227 lazy_init_style_registry();
2228 get_style_registry().read().unwrap().get_float("control_relief").map(|v| v != 0.0).unwrap_or(true)
2229 }
2230
2231 /// Switch the controls' relief styling at runtime — the gallery's Style
2232 /// dropdown. Every widget without a per-widget override follows on its next
2233 /// paint; the caller asks for a rebuild. Not persisted: a config reload puts
2234 /// the configured value back.
2235 pub fn set_control_relief(relief: bool) {
2236 lazy_init_style_registry();
2237 get_style_registry().write().unwrap().set_float("control_relief", if relief { 1.0 } else { 0.0 });
2238 }
2239
2240 pub fn toggle_corner_radius() -> f32 {
2241 lazy_init_style_registry();
2242 get_style_registry().read().unwrap().get_float("toggle_corner_radius").unwrap_or_else(control_corner_radius)
2243 }
2244
2245 pub fn set_toggle_corner_radius(radius: f32) {
2246 lazy_init_style_registry();
2247 if let Ok(mut registry) = get_style_registry().write() {
2248 registry.set_float("toggle_corner_radius", radius);
2249 }
2250 }
2251
2252 pub fn toggle_border_width() -> f32 {
2253 use std::sync::Once;
2254 static INIT: Once = Once::new();
2255 INIT.call_once(|| {
2256 if let Some(content) = read_config() {
2257 for line in content.lines() {
2258 let trimmed = line.trim();
2259 if let Some(rest) = trimmed.strip_prefix("toggle_border_width") {
2260 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2261 let val_str = rest.trim_end_matches('"').trim();
2262 if let Ok(val) = val_str.parse::<f32>() {
2263 if let Ok(mut lock) = TOGGLE_BORDER_WIDTH.write() {
2264 *lock = val;
2265 }
2266 }
2267 }
2268 }
2269 }
2270 });
2271 *TOGGLE_BORDER_WIDTH.read().unwrap()
2272 }
2273
2274 pub fn set_toggle_border_width(width: f32) {
2275 if let Ok(mut lock) = TOGGLE_BORDER_WIDTH.write() {
2276 *lock = width;
2277 }
2278 }
2279
2280 pub fn slider_corner_radius() -> f32 {
2281 lazy_init_style_registry();
2282 get_style_registry().read().unwrap().get_float("slider_corner_radius").unwrap_or_else(control_corner_radius)
2283 }
2284
2285 /// The slider band's knobs (`style.control.slider.*`): the flat band's thickness,
2286 /// and the swell's half-span / peak height around the value position. The band —
2287 /// a thin full-range band that swells at the value — is the one slider style.
2288 pub fn slider_band_thickness() -> f32 {
2289 lazy_init_style_registry();
2290 get_style_registry().read().unwrap().get_float("slider_band_thickness").unwrap_or(2.0)
2291 }
2292
2293 pub fn slider_bulge_width() -> f32 {
2294 lazy_init_style_registry();
2295 get_style_registry().read().unwrap().get_float("slider_bulge_width").unwrap_or(26.0)
2296 }
2297
2298 pub fn slider_bulge_height() -> f32 {
2299 lazy_init_style_registry();
2300 get_style_registry().read().unwrap().get_float("slider_bulge_height").unwrap_or(14.0)
2301 }
2302
2303 pub fn set_slider_corner_radius(radius: f32) {
2304 lazy_init_style_registry();
2305 if let Ok(mut registry) = get_style_registry().write() {
2306 registry.set_float("slider_corner_radius", radius);
2307 }
2308 }
2309
2310
2311 pub fn plate_corner_radius() -> f32 {
2312 lazy_init_style_registry();
2313 let r = get_style_registry().read().unwrap();
2314 r.get_float("plate_corner_radius")
2315 .or_else(|| r.get_float("root_plate_corner_radius"))
2316 .unwrap_or(12.0)
2317 }
2318
2319 pub fn set_plate_corner_radius(radius: f32) {
2320 lazy_init_style_registry();
2321 if let Ok(mut registry) = get_style_registry().write() {
2322 registry.set_float("plate_corner_radius", radius);
2323 }
2324 }
2325
2326 pub fn plate_opacity() -> f32 {
2327 use std::sync::Once;
2328 static INIT: Once = Once::new();
2329 INIT.call_once(|| {
2330 if let Some(content) = read_config() {
2331 for line in content.lines() {
2332 let trimmed = line.trim();
2333 if let Some(rest) = trimmed.strip_prefix("plate_opacity") {
2334 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2335 let val_str = rest.trim_end_matches('"').trim();
2336 if let Ok(val) = val_str.parse::<f32>() {
2337 if let Ok(mut lock) = PLATE_OPACITY.write() {
2338 *lock = val;
2339 }
2340 }
2341 }
2342 }
2343 }
2344 });
2345 *PLATE_OPACITY.read().unwrap()
2346 }
2347
2348 pub fn set_plate_opacity(opacity: f32) {
2349 if let Ok(mut lock) = PLATE_OPACITY.write() {
2350 *lock = opacity;
2351 }
2352 }
2353
2354 pub fn page_opacity() -> f32 {
2355 use std::sync::Once;
2356 static INIT: Once = Once::new();
2357 INIT.call_once(|| {
2358 if let Some(content) = read_config() {
2359 for line in content.lines() {
2360 let trimmed = line.trim();
2361 if let Some(rest) = trimmed.strip_prefix("page_opacity") {
2362 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2363 let val_str = rest.trim_end_matches('"').trim();
2364 if let Ok(val) = val_str.parse::<f32>() {
2365 if let Ok(mut lock) = PAGE_OPACITY.write() {
2366 *lock = val;
2367 }
2368 }
2369 }
2370 }
2371 }
2372 });
2373 *PAGE_OPACITY.read().unwrap()
2374 }
2375
2376 pub fn set_page_opacity(opacity: f32) {
2377 if let Ok(mut lock) = PAGE_OPACITY.write() {
2378 *lock = opacity;
2379 }
2380 }
2381
2382 pub fn layer_opacity() -> f32 {
2383 use std::sync::Once;
2384 static INIT: Once = Once::new();
2385 INIT.call_once(|| {
2386 if let Some(content) = read_config() {
2387 for line in content.lines() {
2388 let trimmed = line.trim();
2389 if let Some(rest) = trimmed.strip_prefix("layer_opacity") {
2390 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2391 let val_str = rest.trim_end_matches('"').trim();
2392 if let Ok(val) = val_str.parse::<f32>() {
2393 if let Ok(mut lock) = LAYER_OPACITY.write() {
2394 *lock = val;
2395 }
2396 }
2397 }
2398 }
2399 }
2400 });
2401 *LAYER_OPACITY.read().unwrap()
2402 }
2403
2404 pub fn set_layer_opacity(opacity: f32) {
2405 if let Ok(mut lock) = LAYER_OPACITY.write() {
2406 *lock = opacity;
2407 }
2408 }
2409
2410
2411
2412
2413 pub fn color_selector_height() -> f32 {
2414 use std::sync::Once;
2415 static INIT: Once = Once::new();
2416 INIT.call_once(|| {
2417 if let Some(content) = read_config() {
2418 for line in content.lines() {
2419 let trimmed = line.trim();
2420 if let Some(rest) = trimmed.strip_prefix("color_selector_height") {
2421 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2422 let val_str = rest.trim_end_matches('"').trim();
2423 if let Ok(val) = val_str.parse::<f32>() {
2424 if let Ok(mut lock) = COLOR_SELECTOR_HEIGHT.write() {
2425 *lock = val;
2426 }
2427 }
2428 }
2429 }
2430 }
2431 });
2432 *COLOR_SELECTOR_HEIGHT.read().unwrap()
2433 }
2434
2435 pub fn set_color_selector_height(height: f32) {
2436 if let Ok(mut lock) = COLOR_SELECTOR_HEIGHT.write() {
2437 *lock = height;
2438 }
2439 }
2440
2441 pub fn font_selector_height() -> f32 {
2442 use std::sync::Once;
2443 static INIT: Once = Once::new();
2444 INIT.call_once(|| {
2445 if let Some(content) = read_config() {
2446 for line in content.lines() {
2447 let trimmed = line.trim();
2448 if let Some(rest) = trimmed.strip_prefix("font_selector_height") {
2449 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2450 let val_str = rest.trim_end_matches('"').trim();
2451 if let Ok(val) = val_str.parse::<f32>() {
2452 if let Ok(mut lock) = FONT_SELECTOR_HEIGHT.write() {
2453 *lock = val;
2454 }
2455 }
2456 }
2457 }
2458 }
2459 });
2460 *FONT_SELECTOR_HEIGHT.read().unwrap()
2461 }
2462
2463 pub fn set_font_selector_height(height: f32) {
2464 if let Ok(mut lock) = FONT_SELECTOR_HEIGHT.write() {
2465 *lock = height;
2466 }
2467 }
2468
2469 pub fn color_selector_font() -> String {
2470 use std::sync::Once;
2471 static INIT: Once = Once::new();
2472 INIT.call_once(|| {
2473 let mut font = "monospace".to_string();
2474 if let Some(content) = read_config() {
2475 for line in content.lines() {
2476 let trimmed = line.trim();
2477 if let Some(rest) = trimmed.strip_prefix("color_selector_font") {
2478 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
2479 let rest = rest.trim();
2480 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
2481 &rest[1..rest.len() - 1]
2482 } else {
2483 rest
2484 };
2485 font = val_str.trim().to_string();
2486 }
2487 }
2488 }
2489 if let Ok(mut lock) = COLOR_SELECTOR_FONT.write() {
2490 *lock = font;
2491 }
2492 });
2493 let lock = COLOR_SELECTOR_FONT.read().unwrap();
2494 if lock.is_empty() {
2495 "monospace".to_string()
2496 } else {
2497 lock.clone()
2498 }
2499 }
2500
2501 pub fn set_color_selector_font(font: &str) {
2502 if let Ok(mut lock) = COLOR_SELECTOR_FONT.write() {
2503 *lock = font.to_string();
2504 }
2505 }
2506
2507 pub fn menubar_font() -> String {
2508 use std::sync::Once;
2509 static INIT: Once = Once::new();
2510 INIT.call_once(|| {
2511 let font = read_config_value("menubar_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2512 if let Ok(mut lock) = MENUBAR_FONT.write() {
2513 *lock = font;
2514 }
2515 });
2516 let lock = MENUBAR_FONT.read().unwrap();
2517 if lock.is_empty() {
2518 "Berkeley Mono".to_string()
2519 } else {
2520 lock.clone()
2521 }
2522 }
2523
2524 pub fn menubar_font_parsed() -> (String, f32) {
2525 if let Ok(lock) = MENUBAR_FONT_CACHED.read() {
2526 if let Some(ref val) = *lock {
2527 return val.clone();
2528 }
2529 }
2530 let font_str = menubar_font();
2531 let parsed = parse_font_string(&font_str);
2532 let size = parsed.1.unwrap_or(12.0);
2533 let val = (parsed.0, size);
2534 if let Ok(mut lock) = MENUBAR_FONT_CACHED.write() {
2535 *lock = Some(val.clone());
2536 }
2537 val
2538 }
2539
2540 pub fn set_menubar_font(font: &str) {
2541 if let Ok(mut lock) = MENUBAR_FONT.write() {
2542 *lock = font.to_string();
2543 }
2544 if let Ok(mut lock) = MENUBAR_FONT_CACHED.write() {
2545 *lock = None;
2546 }
2547 }
2548
2549 pub fn statusbar_font() -> String {
2550 use std::sync::Once;
2551 static INIT: Once = Once::new();
2552 INIT.call_once(|| {
2553 let font = read_config_value("statusbar_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2554 if let Ok(mut lock) = STATUSBAR_FONT.write() {
2555 *lock = font;
2556 }
2557 });
2558 let lock = STATUSBAR_FONT.read().unwrap();
2559 if lock.is_empty() {
2560 "Berkeley Mono".to_string()
2561 } else {
2562 lock.clone()
2563 }
2564 }
2565
2566 pub fn statusbar_font_parsed() -> (String, f32) {
2567 if let Ok(lock) = STATUSBAR_FONT_CACHED.read() {
2568 if let Some(ref val) = *lock {
2569 return val.clone();
2570 }
2571 }
2572 let font_str = statusbar_font();
2573 let parsed = parse_font_string(&font_str);
2574 let size = parsed.1.unwrap_or(12.0);
2575 let val = (parsed.0, size);
2576 if let Ok(mut lock) = STATUSBAR_FONT_CACHED.write() {
2577 *lock = Some(val.clone());
2578 }
2579 val
2580 }
2581
2582 pub fn set_statusbar_font(font: &str) {
2583 if let Ok(mut lock) = STATUSBAR_FONT.write() {
2584 *lock = font.to_string();
2585 }
2586 if let Ok(mut lock) = STATUSBAR_FONT_CACHED.write() {
2587 *lock = None;
2588 }
2589 }
2590
2591
2592
2593 pub fn font_selector_font() -> String {
2594 use std::sync::Once;
2595 static INIT: Once = Once::new();
2596 INIT.call_once(|| {
2597 let font = read_config_value("font_selector_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2598 if let Ok(mut lock) = FONT_SELECTOR_FONT.write() {
2599 *lock = font;
2600 }
2601 });
2602 let lock = FONT_SELECTOR_FONT.read().unwrap();
2603 if lock.is_empty() {
2604 "Berkeley Mono".to_string()
2605 } else {
2606 lock.clone()
2607 }
2608 }
2609
2610 pub fn font_selector_font_parsed() -> (String, f32) {
2611 if let Ok(lock) = FONT_SELECTOR_FONT_CACHED.read() {
2612 if let Some(ref val) = *lock {
2613 return val.clone();
2614 }
2615 }
2616 let font_str = font_selector_font();
2617 let parsed = parse_font_string(&font_str);
2618 let size = parsed.1.unwrap_or(12.0);
2619 let val = (parsed.0, size);
2620 if let Ok(mut lock) = FONT_SELECTOR_FONT_CACHED.write() {
2621 *lock = Some(val.clone());
2622 }
2623 val
2624 }
2625
2626 pub fn set_font_selector_font(font: &str) {
2627 if let Ok(mut lock) = FONT_SELECTOR_FONT.write() {
2628 *lock = font.to_string();
2629 }
2630 if let Ok(mut lock) = FONT_SELECTOR_FONT_CACHED.write() {
2631 *lock = None;
2632 }
2633 }
2634
2635 // Button Strip Font
2636 pub fn button_strip_font() -> String {
2637 use std::sync::Once;
2638 static INIT: Once = Once::new();
2639 INIT.call_once(|| {
2640 let font = read_config_value("button_strip_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2641 if let Ok(mut lock) = BUTTON_STRIP_FONT.write() {
2642 *lock = font;
2643 }
2644 });
2645 let lock = BUTTON_STRIP_FONT.read().unwrap();
2646 if lock.is_empty() {
2647 "Berkeley Mono".to_string()
2648 } else {
2649 lock.clone()
2650 }
2651 }
2652
2653 pub fn button_strip_font_parsed() -> (String, f32) {
2654 if let Ok(lock) = BUTTON_STRIP_FONT_CACHED.read() {
2655 if let Some(ref val) = *lock {
2656 return val.clone();
2657 }
2658 }
2659 let font_str = button_strip_font();
2660 let parsed = parse_font_string(&font_str);
2661 let size = parsed.1.unwrap_or(12.0);
2662 let val = (parsed.0, size);
2663 if let Ok(mut lock) = BUTTON_STRIP_FONT_CACHED.write() {
2664 *lock = Some(val.clone());
2665 }
2666 val
2667 }
2668
2669 pub fn set_button_strip_font(font: &str) {
2670 if let Ok(mut lock) = BUTTON_STRIP_FONT.write() {
2671 *lock = font.to_string();
2672 }
2673 if let Ok(mut lock) = BUTTON_STRIP_FONT_CACHED.write() {
2674 *lock = None;
2675 }
2676 }
2677
2678
2679
2680 // Control Label Font
2681 pub fn control_label_font() -> String {
2682 #[cfg(test)]
2683 if let Some(v) = test_style::CONTROL_LABEL_FONT.with(|c| c.borrow().clone()) {
2684 return v;
2685 }
2686 use std::sync::Once;
2687 static INIT: Once = Once::new();
2688 INIT.call_once(|| {
2689 let font = read_config_value("control_label_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2690 if let Ok(mut lock) = CONTROL_LABEL_FONT.write() {
2691 *lock = font;
2692 }
2693 });
2694 let lock = CONTROL_LABEL_FONT.read().unwrap();
2695 if lock.is_empty() {
2696 "Berkeley Mono".to_string()
2697 } else {
2698 lock.clone()
2699 }
2700 }
2701
2702 pub fn control_label_font_parsed() -> (String, f32) {
2703 // The parse cache is process-wide, so under test it would hand back one
2704 // thread's pinned font to every other. Parsing is cheap; skip it there.
2705 #[cfg(not(test))]
2706 if let Ok(lock) = CONTROL_LABEL_FONT_CACHED.read() {
2707 if let Some(ref val) = *lock {
2708 return val.clone();
2709 }
2710 }
2711 let font_str = control_label_font();
2712 let parsed = parse_font_string(&font_str);
2713 let size = parsed.1.unwrap_or(12.0);
2714 let val = (parsed.0, size);
2715 #[cfg(not(test))]
2716 if let Ok(mut lock) = CONTROL_LABEL_FONT_CACHED.write() {
2717 *lock = Some(val.clone());
2718 }
2719 val
2720 }
2721
2722 pub fn set_control_label_font(font: &str) {
2723 #[cfg(test)]
2724 test_style::CONTROL_LABEL_FONT.with(|c| *c.borrow_mut() = Some(font.to_string()));
2725 #[cfg(not(test))]
2726 if let Ok(mut lock) = CONTROL_LABEL_FONT.write() {
2727 *lock = font.to_string();
2728 }
2729 #[cfg(not(test))]
2730 if let Ok(mut lock) = CONTROL_LABEL_FONT_CACHED.write() {
2731 *lock = None;
2732 }
2733 }
2734
2735 // Control Label Font Detached
2736 pub fn control_label_font_detached() -> String {
2737 #[cfg(test)]
2738 if let Some(v) = test_style::CONTROL_LABEL_FONT_DETACHED.with(|c| c.borrow().clone()) {
2739 return v;
2740 }
2741 use std::sync::Once;
2742 static INIT: Once = Once::new();
2743 INIT.call_once(|| {
2744 let font = read_config_value("control_label_font_detached").unwrap_or_else(|| "Berkeley Mono".to_string());
2745 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED.write() {
2746 *lock = font;
2747 }
2748 });
2749 let lock = CONTROL_LABEL_FONT_DETACHED.read().unwrap();
2750 if lock.is_empty() {
2751 "Berkeley Mono".to_string()
2752 } else {
2753 lock.clone()
2754 }
2755 }
2756
2757 pub fn control_label_font_detached_parsed() -> (String, f32) {
2758 // The parse cache is process-wide, so under test it would hand back one
2759 // thread's pinned font to every other. Parsing is cheap; skip it there.
2760 #[cfg(not(test))]
2761 if let Ok(lock) = CONTROL_LABEL_FONT_DETACHED_CACHED.read() {
2762 if let Some(ref val) = *lock {
2763 return val.clone();
2764 }
2765 }
2766 let font_str = control_label_font_detached();
2767 let parsed = parse_font_string(&font_str);
2768 let size = parsed.1.unwrap_or(12.0);
2769 let val = (parsed.0, size);
2770 #[cfg(not(test))]
2771 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED_CACHED.write() {
2772 *lock = Some(val.clone());
2773 }
2774 val
2775 }
2776
2777 pub fn set_control_label_font_detached(font: &str) {
2778 #[cfg(test)]
2779 test_style::CONTROL_LABEL_FONT_DETACHED.with(|c| *c.borrow_mut() = Some(font.to_string()));
2780 #[cfg(not(test))]
2781 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED.write() {
2782 *lock = font.to_string();
2783 }
2784 #[cfg(not(test))]
2785 if let Ok(mut lock) = CONTROL_LABEL_FONT_DETACHED_CACHED.write() {
2786 *lock = None;
2787 }
2788 }
2789
2790
2791
2792 // List Font
2793 pub fn list_font() -> String {
2794 use std::sync::Once;
2795 static INIT: Once = Once::new();
2796 INIT.call_once(|| {
2797 let font = read_config_value("list_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2798 if let Ok(mut lock) = LIST_FONT.write() {
2799 *lock = font;
2800 }
2801 });
2802 let lock = LIST_FONT.read().unwrap();
2803 if lock.is_empty() {
2804 "Berkeley Mono".to_string()
2805 } else {
2806 lock.clone()
2807 }
2808 }
2809
2810 pub fn list_font_parsed() -> (String, f32) {
2811 if let Ok(lock) = LIST_FONT_CACHED.read() {
2812 if let Some(ref val) = *lock {
2813 return val.clone();
2814 }
2815 }
2816 let font_str = list_font();
2817 let parsed = parse_font_string(&font_str);
2818 let size = parsed.1.unwrap_or(12.0);
2819 let val = (parsed.0, size);
2820 if let Ok(mut lock) = LIST_FONT_CACHED.write() {
2821 *lock = Some(val.clone());
2822 }
2823 val
2824 }
2825
2826 pub fn set_list_font(font: &str) {
2827 if let Ok(mut lock) = LIST_FONT.write() {
2828 *lock = font.to_string();
2829 }
2830 if let Ok(mut lock) = LIST_FONT_CACHED.write() {
2831 *lock = None;
2832 }
2833 }
2834
2835 // Tree Font
2836 pub fn tree_font() -> String {
2837 use std::sync::Once;
2838 static INIT: Once = Once::new();
2839 INIT.call_once(|| {
2840 let font = read_config_value("tree_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2841 if let Ok(mut lock) = TREE_FONT.write() {
2842 *lock = font;
2843 }
2844 });
2845 let lock = TREE_FONT.read().unwrap();
2846 if lock.is_empty() {
2847 "Berkeley Mono".to_string()
2848 } else {
2849 lock.clone()
2850 }
2851 }
2852
2853 pub fn tree_font_parsed() -> (String, f32) {
2854 if let Ok(lock) = TREE_FONT_CACHED.read() {
2855 if let Some(ref val) = *lock {
2856 return val.clone();
2857 }
2858 }
2859 let font_str = tree_font();
2860 let parsed = parse_font_string(&font_str);
2861 let size = parsed.1.unwrap_or(12.0);
2862 let val = (parsed.0, size);
2863 if let Ok(mut lock) = TREE_FONT_CACHED.write() {
2864 *lock = Some(val.clone());
2865 }
2866 val
2867 }
2868
2869 pub fn set_tree_font(font: &str) {
2870 if let Ok(mut lock) = TREE_FONT.write() {
2871 *lock = font.to_string();
2872 }
2873 if let Ok(mut lock) = TREE_FONT_CACHED.write() {
2874 *lock = None;
2875 }
2876 }
2877
2878 // Graph Font
2879 pub fn graph_font() -> String {
2880 use std::sync::Once;
2881 static INIT: Once = Once::new();
2882 INIT.call_once(|| {
2883 let font = read_config_value("graph_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2884 if let Ok(mut lock) = GRAPH_FONT.write() {
2885 *lock = font;
2886 }
2887 });
2888 let lock = GRAPH_FONT.read().unwrap();
2889 if lock.is_empty() {
2890 "Berkeley Mono".to_string()
2891 } else {
2892 lock.clone()
2893 }
2894 }
2895
2896 pub fn graph_font_parsed() -> (String, f32) {
2897 if let Ok(lock) = GRAPH_FONT_CACHED.read() {
2898 if let Some(ref val) = *lock {
2899 return val.clone();
2900 }
2901 }
2902 let font_str = graph_font();
2903 let parsed = parse_font_string(&font_str);
2904 let size = parsed.1.unwrap_or(12.0);
2905 let val = (parsed.0, size);
2906 if let Ok(mut lock) = GRAPH_FONT_CACHED.write() {
2907 *lock = Some(val.clone());
2908 }
2909 val
2910 }
2911
2912 pub fn set_graph_font(font: &str) {
2913 if let Ok(mut lock) = GRAPH_FONT.write() {
2914 *lock = font.to_string();
2915 }
2916 if let Ok(mut lock) = GRAPH_FONT_CACHED.write() {
2917 *lock = None;
2918 }
2919 }
2920
2921 // Graph Node Font
2922 pub fn graph_node_font() -> String {
2923 use std::sync::Once;
2924 static INIT: Once = Once::new();
2925 INIT.call_once(|| {
2926 let font = read_config_value("graph_node_font").unwrap_or_else(|| "Berkeley Mono".to_string());
2927 if let Ok(mut lock) = GRAPH_NODE_FONT.write() {
2928 *lock = font;
2929 }
2930 });
2931 let lock = GRAPH_NODE_FONT.read().unwrap();
2932 if lock.is_empty() {
2933 "Berkeley Mono".to_string()
2934 } else {
2935 lock.clone()
2936 }
2937 }
2938
2939 pub fn graph_node_font_parsed() -> (String, f32) {
2940 if let Ok(lock) = GRAPH_NODE_FONT_CACHED.read() {
2941 if let Some(ref val) = *lock {
2942 return val.clone();
2943 }
2944 }
2945 let font_str = graph_node_font();
2946 let parsed = parse_font_string(&font_str);
2947 let size = parsed.1.unwrap_or(12.0);
2948 let val = (parsed.0, size);
2949 if let Ok(mut lock) = GRAPH_NODE_FONT_CACHED.write() {
2950 *lock = Some(val.clone());
2951 }
2952 val
2953 }
2954
2955 pub fn set_graph_node_font(font: &str) {
2956 if let Ok(mut lock) = GRAPH_NODE_FONT.write() {
2957 *lock = font.to_string();
2958 }
2959 if let Ok(mut lock) = GRAPH_NODE_FONT_CACHED.write() {
2960 *lock = None;
2961 }
2962 }
2963
2964 // List Justification
2965 pub fn list_justification() -> u8 {
2966 use std::sync::Once;
2967 static INIT: Once = Once::new();
2968 INIT.call_once(|| {
2969 if let Some(content) = read_config() {
2970 for line in content.lines() {
2971 let trimmed = line.trim();
2972 if let Some(rest) = trimmed.strip_prefix("list_justification") {
2973 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
2974 let val_str = rest.trim_end_matches('"').trim();
2975 if let Ok(val) = val_str.parse::<u8>() {
2976 if let Ok(mut lock) = LIST_JUSTIFICATION.write() {
2977 *lock = val;
2978 }
2979 }
2980 }
2981 }
2982 }
2983 });
2984 *LIST_JUSTIFICATION.read().unwrap()
2985 }
2986
2987 pub fn set_list_justification(just: u8) {
2988 if let Ok(mut lock) = LIST_JUSTIFICATION.write() {
2989 *lock = just;
2990 }
2991 }
2992
2993
2994
2995
2996 pub fn section_label_font() -> String {
2997 use std::sync::Once;
2998 static INIT: Once = Once::new();
2999 INIT.call_once(|| {
3000 let mut font = "Berkeley Mono".to_string();
3001 if let Some(content) = read_config() {
3002 for line in content.lines() {
3003 let trimmed = line.trim();
3004 if let Some(rest) = trimmed.strip_prefix("section_label_font") {
3005 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
3006 let rest = rest.trim();
3007 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
3008 &rest[1..rest.len() - 1]
3009 } else {
3010 rest
3011 };
3012 font = val_str.trim().to_string();
3013 }
3014 }
3015 }
3016 if let Ok(mut lock) = SECTION_LABEL_FONT.write() {
3017 *lock = font;
3018 }
3019 });
3020 let lock = SECTION_LABEL_FONT.read().unwrap();
3021 if lock.is_empty() {
3022 "Berkeley Mono".to_string()
3023 } else {
3024 lock.clone()
3025 }
3026 }
3027
3028 pub fn set_section_label_font(font: &str) {
3029 if let Ok(mut lock) = SECTION_LABEL_FONT.write() {
3030 *lock = font.to_string();
3031 }
3032 }
3033
3034 pub fn nested_section_label_font() -> String {
3035 use std::sync::Once;
3036 static INIT: Once = Once::new();
3037 INIT.call_once(|| {
3038 let mut font = "Berkeley Mono".to_string();
3039 if let Some(content) = read_config() {
3040 for line in content.lines() {
3041 let trimmed = line.trim();
3042 if let Some(rest) = trimmed.strip_prefix("nested_section_label_font") {
3043 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
3044 let rest = rest.trim();
3045 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
3046 &rest[1..rest.len() - 1]
3047 } else {
3048 rest
3049 };
3050 font = val_str.trim().to_string();
3051 }
3052 }
3053 }
3054 if let Ok(mut lock) = NESTED_SECTION_LABEL_FONT.write() {
3055 *lock = font;
3056 }
3057 });
3058 let lock = NESTED_SECTION_LABEL_FONT.read().unwrap();
3059 if lock.is_empty() {
3060 "Berkeley Mono".to_string()
3061 } else {
3062 lock.clone()
3063 }
3064 }
3065
3066 pub fn set_nested_section_label_font(font: &str) {
3067 if let Ok(mut lock) = NESTED_SECTION_LABEL_FONT.write() {
3068 *lock = font.to_string();
3069 }
3070 }
3071
3072 pub fn breadcrumb_font() -> String {
3073 use std::sync::Once;
3074 static INIT: Once = Once::new();
3075 INIT.call_once(|| {
3076 let mut font = "Berkeley Mono".to_string();
3077 if let Some(content) = read_config() {
3078 for line in content.lines() {
3079 let trimmed = line.trim();
3080 if let Some(rest) = trimmed.strip_prefix("breadcrumb_font") {
3081 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
3082 let rest = rest.trim();
3083 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
3084 &rest[1..rest.len() - 1]
3085 } else {
3086 rest
3087 };
3088 font = val_str.trim().to_string();
3089 }
3090 }
3091 }
3092 if let Ok(mut lock) = BREADCRUMB_FONT.write() {
3093 *lock = font;
3094 }
3095 });
3096 let lock = BREADCRUMB_FONT.read().unwrap();
3097 if lock.is_empty() {
3098 "Berkeley Mono".to_string()
3099 } else {
3100 lock.clone()
3101 }
3102 }
3103
3104 pub fn set_breadcrumb_font(font: &str) {
3105 if let Ok(mut lock) = BREADCRUMB_FONT.write() {
3106 *lock = font.to_string();
3107 }
3108 }
3109
3110 pub fn button_font() -> String {
3111 use std::sync::Once;
3112 static INIT: Once = Once::new();
3113 INIT.call_once(|| {
3114 let mut font = "Berkeley Mono".to_string();
3115 if let Some(content) = read_config() {
3116 for line in content.lines() {
3117 let trimmed = line.trim();
3118 if let Some(rest) = trimmed.strip_prefix("button_font") {
3119 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=');
3120 let rest = rest.trim();
3121 let val_str = if rest.starts_with('"') && rest.ends_with('"') && rest.len() >= 2 {
3122 &rest[1..rest.len() - 1]
3123 } else {
3124 rest
3125 };
3126 font = val_str.trim().to_string();
3127 }
3128 }
3129 }
3130 if let Ok(mut lock) = BUTTON_FONT.write() {
3131 *lock = font;
3132 }
3133 });
3134 let lock = BUTTON_FONT.read().unwrap();
3135 if lock.is_empty() {
3136 "Berkeley Mono".to_string()
3137 } else {
3138 lock.clone()
3139 }
3140 }
3141
3142 pub fn set_button_font(font: &str) {
3143 if let Ok(mut lock) = BUTTON_FONT.write() {
3144 *lock = font.to_string();
3145 }
3146 }
3147
3148 pub fn color_selector_preview_corner_radius() -> f32 {
3149 lazy_init_style_registry();
3150 // Unset: the swatch rounds like the text field beside it (the TextBox radius).
3151 get_style_registry().read().unwrap().get_float("color_selector_preview_corner_radius").unwrap_or_else(textbox_corner_radius)
3152 }
3153
3154 pub fn set_color_selector_preview_corner_radius(radius: f32) {
3155 lazy_init_style_registry();
3156 if let Ok(mut registry) = get_style_registry().write() {
3157 registry.set_float("color_selector_preview_corner_radius", radius);
3158 }
3159 }
3160
3161 pub fn color_selector_corner_radius() -> f32 {
3162 lazy_init_style_registry();
3163 // Unset: the selector's frame rounds like the text field it stands in for.
3164 get_style_registry().read().unwrap().get_float("color_selector_corner_radius").unwrap_or_else(textbox_corner_radius)
3165 }
3166
3167 pub fn set_color_selector_corner_radius(radius: f32) {
3168 lazy_init_style_registry();
3169 if let Ok(mut registry) = get_style_registry().write() {
3170 registry.set_float("color_selector_corner_radius", radius);
3171 }
3172 }
3173
3174 /// The control rung's corner radius (`style.control.corner_radius`): the
3175 /// default every control-scale radius getter falls back to when the widget's
3176 /// own `corner_radius` key is unset — buttons, dropdowns, font selectors,
3177 /// sliders, spinboxes, text boxes, toggles, and the list and tree wells. The
3178 /// per-widget keys are overrides on top of it. See "Plates, wells and seams"
3179 /// in `CLAUDE.md`; the pane and root rungs are `plate_corner_radius` and
3180 /// `crate::color::root_plate_corner_radius`.
3181 pub fn control_corner_radius() -> f32 {
3182 lazy_init_style_registry();
3183 get_style_registry().read().unwrap().get_float("control_corner_radius").unwrap_or(8.0)
3184 }
3185
3186 pub fn set_control_corner_radius(radius: f32) {
3187 lazy_init_style_registry();
3188 if let Ok(mut registry) = get_style_registry().write() {
3189 registry.set_float("control_corner_radius", radius);
3190 }
3191 }
3192
3193 pub fn button_corner_radius() -> f32 {
3194 lazy_init_style_registry();
3195 get_style_registry().read().unwrap().get_float("button_corner_radius").unwrap_or_else(control_corner_radius)
3196 }
3197
3198 pub fn set_button_corner_radius(radius: f32) {
3199 lazy_init_style_registry();
3200 if let Ok(mut registry) = get_style_registry().write() {
3201 registry.set_float("button_corner_radius", radius);
3202 }
3203 }
3204
3205 pub fn spinbox_corner_radius() -> f32 {
3206 lazy_init_style_registry();
3207 get_style_registry().read().unwrap().get_float("spinbox_corner_radius").unwrap_or_else(control_corner_radius)
3208 }
3209
3210 pub fn set_spinbox_corner_radius(radius: f32) {
3211 lazy_init_style_registry();
3212 if let Ok(mut registry) = get_style_registry().write() {
3213 registry.set_float("spinbox_corner_radius", radius);
3214 }
3215 }
3216
3217 pub fn spinbox_button_padding() -> f32 {
3218 use std::sync::Once;
3219 static INIT: Once = Once::new();
3220 INIT.call_once(|| {
3221 if let Some(content) = read_config() {
3222 for line in content.lines() {
3223 let trimmed = line.trim();
3224 if let Some(rest) = trimmed.strip_prefix("spinbox_button_padding") {
3225 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3226 let val_str = rest.trim_end_matches('"').trim();
3227 if let Ok(val) = val_str.parse::<f32>() {
3228 if let Ok(mut lock) = SPINBOX_BUTTON_PADDING.write() {
3229 *lock = val;
3230 }
3231 }
3232 }
3233 }
3234 }
3235 });
3236 *SPINBOX_BUTTON_PADDING.read().unwrap()
3237 }
3238
3239 pub fn scrollbar_width() -> f32 {
3240 use std::sync::Once;
3241 static INIT: Once = Once::new();
3242 INIT.call_once(|| {
3243 if let Some(content) = read_config() {
3244 for line in content.lines() {
3245 let trimmed = line.trim();
3246 if let Some(rest) = trimmed.strip_prefix("scrollbar_width") {
3247 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3248 let val_str = rest.trim_end_matches('"').trim();
3249 if let Ok(val) = val_str.parse::<f32>() {
3250 if let Ok(mut lock) = SCROLLBAR_WIDTH.write() {
3251 *lock = val;
3252 }
3253 }
3254 }
3255 }
3256 }
3257 });
3258 *SCROLLBAR_WIDTH.read().unwrap()
3259 }
3260
3261 pub fn set_scrollbar_width(width: f32) {
3262 if let Ok(mut lock) = SCROLLBAR_WIDTH.write() {
3263 *lock = width;
3264 }
3265 }
3266
3267 /// How far a page-level scrollbar stands off its window/plate right edge — the
3268 /// designer parameter-pane look (config `style.control.scrollbar.inset`).
3269 /// Framed inner lists keep their own tight 4px hug; this is for bars floating
3270 /// over a plate.
3271 pub fn scrollbar_inset() -> f32 {
3272 use std::sync::Once;
3273 static INIT: Once = Once::new();
3274 INIT.call_once(|| {
3275 if let Some(content) = read_config() {
3276 for line in content.lines() {
3277 let trimmed = line.trim();
3278 if let Some(rest) = trimmed.strip_prefix("scrollbar_inset") {
3279 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3280 let val_str = rest.trim_end_matches('"').trim();
3281 if let Ok(val) = val_str.parse::<f32>() {
3282 if let Ok(mut lock) = SCROLLBAR_INSET.write() {
3283 *lock = val;
3284 }
3285 }
3286 }
3287 }
3288 }
3289 });
3290 *SCROLLBAR_INSET.read().unwrap()
3291 }
3292
3293 pub fn tree_opacity() -> f32 {
3294 use std::sync::Once;
3295 static INIT: Once = Once::new();
3296 INIT.call_once(|| {
3297 if let Some(content) = read_config() {
3298 for line in content.lines() {
3299 let trimmed = line.trim();
3300 if let Some(rest) = trimmed.strip_prefix("tree_opacity") {
3301 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3302 let val_str = rest.trim_end_matches('"').trim();
3303 if let Ok(val) = val_str.parse::<f32>() {
3304 if let Ok(mut lock) = TREE_OPACITY.write() {
3305 *lock = val;
3306 }
3307 }
3308 }
3309 }
3310 }
3311 });
3312 *TREE_OPACITY.read().unwrap()
3313 }
3314
3315 pub fn set_tree_opacity(opacity: f32) {
3316 if let Ok(mut lock) = TREE_OPACITY.write() {
3317 *lock = opacity;
3318 }
3319 }
3320
3321 pub fn tree_blur() -> f32 {
3322 use std::sync::Once;
3323 static INIT: Once = Once::new();
3324 INIT.call_once(|| {
3325 if let Some(content) = read_config() {
3326 for line in content.lines() {
3327 let trimmed = line.trim();
3328 if let Some(rest) = trimmed.strip_prefix("tree_blur") {
3329 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3330 let val_str = rest.trim_end_matches('"').trim();
3331 if let Ok(val) = val_str.parse::<f32>() {
3332 if let Ok(mut lock) = TREE_BLUR.write() {
3333 *lock = val;
3334 }
3335 }
3336 }
3337 }
3338 }
3339 });
3340 *TREE_BLUR.read().unwrap()
3341 }
3342
3343 pub fn set_tree_blur(blur: f32) {
3344 if let Ok(mut lock) = TREE_BLUR.write() {
3345 *lock = blur;
3346 }
3347 }
3348
3349 pub fn set_spinbox_button_padding(padding: f32) {
3350 if let Ok(mut lock) = SPINBOX_BUTTON_PADDING.write() {
3351 *lock = padding;
3352 }
3353 }
3354
3355 pub fn textbox_corner_radius() -> f32 {
3356 lazy_init_style_registry();
3357 get_style_registry().read().unwrap().get_float("textbox_corner_radius").unwrap_or_else(control_corner_radius)
3358 }
3359
3360 pub fn set_textbox_corner_radius(radius: f32) {
3361 lazy_init_style_registry();
3362 if let Ok(mut registry) = get_style_registry().write() {
3363 registry.set_float("textbox_corner_radius", radius);
3364 }
3365 }
3366
3367 pub fn textbox_line_wrap() -> bool {
3368 lazy_init_style_registry();
3369 *TEXTBOX_LINE_WRAP.read().unwrap()
3370 }
3371
3372 pub fn set_textbox_line_wrap(wrap: bool) {
3373 lazy_init_style_registry();
3374 if let Ok(mut lock) = TEXTBOX_LINE_WRAP.write() {
3375 *lock = wrap;
3376 }
3377 }
3378
3379 pub fn touchpad_natural_scroll() -> bool {
3380 lazy_init_style_registry();
3381 *TOUCHPAD_NATURAL_SCROLL.read().unwrap()
3382 }
3383
3384 pub fn set_touchpad_natural_scroll(enabled: bool) {
3385 lazy_init_style_registry();
3386 if let Ok(mut lock) = TOUCHPAD_NATURAL_SCROLL.write() {
3387 *lock = enabled;
3388 }
3389 }
3390
3391 pub fn textbox_multiline_border_width() -> f32 {
3392 lazy_init_style_registry();
3393 *TEXTBOX_MULTILINE_BORDER_WIDTH.read().unwrap()
3394 }
3395
3396 pub fn set_textbox_multiline_border_width(width: f32) {
3397 lazy_init_style_registry();
3398 if let Ok(mut lock) = TEXTBOX_MULTILINE_BORDER_WIDTH.write() {
3399 *lock = width;
3400 }
3401 }
3402
3403
3404 pub fn list_corner_radius() -> f32 {
3405 lazy_init_style_registry();
3406 get_style_registry().read().unwrap().get_float("list_corner_radius").unwrap_or_else(control_corner_radius)
3407 }
3408
3409 pub fn set_list_corner_radius(radius: f32) {
3410 lazy_init_style_registry();
3411 if let Ok(mut registry) = get_style_registry().write() {
3412 registry.set_float("list_corner_radius", radius);
3413 }
3414 }
3415
3416 pub fn tree_corner_radius() -> f32 {
3417 lazy_init_style_registry();
3418 get_style_registry().read().unwrap().get_float("tree_corner_radius").unwrap_or_else(control_corner_radius)
3419 }
3420
3421 pub fn set_tree_corner_radius(radius: f32) {
3422 lazy_init_style_registry();
3423 if let Ok(mut registry) = get_style_registry().write() {
3424 registry.set_float("tree_corner_radius", radius);
3425 }
3426 }
3427
3428 /// The graph grid's pitch along x: the distance from the centre of one
3429 /// vertical grid line to the centre of the next. It is the grid's ONE size
3430 /// per axis — nodes are centred on the lattice intersections. The node body
3431 /// has a size of its own (`graph_node_width` / `graph_node_height`), so a
3432 /// denser grid does not shrink the nodes. The pitch replaced a cell size
3433 /// plus a gap (`spacing_*` was the cell, `gap_col_w` / `gap_row_h` the gap,
3434 /// and a step was the two added up); the defaults are what those two used
3435 /// to add up to, so a config that set neither draws the same lattice it did.
3436 pub fn graph_spacing_x() -> f32 {
3437 lazy_init_style_registry();
3438 get_style_registry().read().unwrap().get_float("graph_spacing_x").unwrap_or(187.5)
3439 }
3440
3441 pub fn set_graph_spacing_x(spacing: f32) {
3442 lazy_init_style_registry();
3443 if let Ok(mut registry) = get_style_registry().write() {
3444 registry.set_float("graph_spacing_x", spacing);
3445 }
3446 }
3447
3448 /// The graph grid's pitch along y — see [`graph_spacing_x`].
3449 pub fn graph_spacing_y() -> f32 {
3450 lazy_init_style_registry();
3451 get_style_registry().read().unwrap().get_float("graph_spacing_y").unwrap_or(112.5)
3452 }
3453
3454 pub fn set_graph_spacing_y(spacing: f32) {
3455 lazy_init_style_registry();
3456 if let Ok(mut registry) = get_style_registry().write() {
3457 registry.set_float("graph_spacing_y", spacing);
3458 }
3459 }
3460
3461 /// The drawn width of a graph grid line, in logical px. The pitch is
3462 /// measured centre to centre, so this changes how heavy the lattice looks
3463 /// and nothing about where anything sits. Not scaled by zoom — a lattice is
3464 /// a reference, not a thing in the scene.
3465 pub fn graph_line_width() -> f32 {
3466 lazy_init_style_registry();
3467 get_style_registry().read().unwrap().get_float("graph_line_width").unwrap_or(1.0)
3468 }
3469
3470 pub fn set_graph_line_width(width: f32) {
3471 lazy_init_style_registry();
3472 if let Ok(mut registry) = get_style_registry().write() {
3473 registry.set_float("graph_line_width", width);
3474 }
3475 }
3476
3477 /// The node body's width at 100% zoom (`style.surface.graph.node.width`),
3478 /// independent of the grid pitch: a node is a thing of its own size sitting
3479 /// on a crossing, and the grid is a reference under it. The default is the
3480 /// cell the old grid gave a node.
3481 pub fn graph_node_width() -> f32 {
3482 lazy_init_style_registry();
3483 get_style_registry().read().unwrap().get_float("graph_node_width").unwrap_or(150.0)
3484 }
3485
3486 pub fn set_graph_node_width(width: f32) {
3487 lazy_init_style_registry();
3488 if let Ok(mut registry) = get_style_registry().write() {
3489 registry.set_float("graph_node_width", width);
3490 }
3491 }
3492
3493 /// The node body's height at 100% zoom — see [`graph_node_width`].
3494 pub fn graph_node_height() -> f32 {
3495 lazy_init_style_registry();
3496 get_style_registry().read().unwrap().get_float("graph_node_height").unwrap_or(75.0)
3497 }
3498
3499 pub fn set_graph_node_height(height: f32) {
3500 lazy_init_style_registry();
3501 if let Ok(mut registry) = get_style_registry().write() {
3502 registry.set_float("graph_node_height", height);
3503 }
3504 }
3505
3506 pub fn graph_grid_snap() -> bool {
3507 lazy_init_style_registry();
3508 get_style_registry().read().unwrap().get_float("graph_grid_snap").unwrap_or(0.0) != 0.0
3509 }
3510
3511 pub fn set_graph_grid_snap(snap: bool) {
3512 lazy_init_style_registry();
3513 if let Ok(mut registry) = get_style_registry().write() {
3514 registry.set_float("graph_grid_snap", if snap { 1.0 } else { 0.0 });
3515 }
3516 }
3517
3518 pub fn graph_blur() -> f32 {
3519 lazy_init_style_registry();
3520 get_style_registry().read().unwrap().get_float("graph_blur").unwrap_or(0.0)
3521 }
3522
3523 pub fn set_graph_blur(blur: f32) {
3524 lazy_init_style_registry();
3525 if let Ok(mut registry) = get_style_registry().write() {
3526 registry.set_float("graph_blur", blur);
3527 }
3528 }
3529
3530 pub fn graph_node_corner_radius() -> f32 {
3531 lazy_init_style_registry();
3532 get_style_registry().read().unwrap().get_float("graph_node_corner_radius").unwrap_or(4.0)
3533 }
3534
3535 pub fn set_graph_node_corner_radius(radius: f32) {
3536 lazy_init_style_registry();
3537 if let Ok(mut registry) = get_style_registry().write() {
3538 registry.set_float("graph_node_corner_radius", radius);
3539 }
3540 }
3541
3542 pub fn graph_node_delete() -> String {
3543 lazy_init_style_registry();
3544 get_style_registry().read().unwrap().get_string("graph_node_delete").unwrap_or_else(|| "delete".to_string())
3545 }
3546
3547 pub fn set_graph_node_delete(key: String) {
3548 lazy_init_style_registry();
3549 if let Ok(mut registry) = get_style_registry().write() {
3550 registry.set_string("graph_node_delete", key);
3551 }
3552 }
3553
3554 pub fn graph_wire_size() -> f32 {
3555 lazy_init_style_registry();
3556 get_style_registry().read().unwrap().get_float("graph_wire_size").unwrap_or(6.0)
3557 }
3558
3559 pub fn set_graph_wire_size(size: f32) {
3560 lazy_init_style_registry();
3561 if let Ok(mut registry) = get_style_registry().write() {
3562 registry.set_float("graph_wire_size", size);
3563 }
3564 }
3565
3566 pub fn graph_wire_activation_radius() -> f32 {
3567 lazy_init_style_registry();
3568 get_style_registry().read().unwrap().get_float("graph_wire_activation_radius").unwrap_or(9.0)
3569 }
3570
3571 pub fn set_graph_wire_activation_radius(radius: f32) {
3572 lazy_init_style_registry();
3573 if let Ok(mut registry) = get_style_registry().write() {
3574 registry.set_float("graph_wire_activation_radius", radius);
3575 }
3576 }
3577
3578 pub fn graph_connector_size() -> f32 {
3579 lazy_init_style_registry();
3580 get_style_registry().read().unwrap().get_float("graph_connector_size").unwrap_or(8.0)
3581 }
3582
3583 pub fn set_graph_connector_size(size: f32) {
3584 lazy_init_style_registry();
3585 if let Ok(mut registry) = get_style_registry().write() {
3586 registry.set_float("graph_connector_size", size);
3587 }
3588 }
3589
3590 pub fn graph_connector_activation_radius() -> f32 {
3591 lazy_init_style_registry();
3592 get_style_registry().read().unwrap().get_float("graph_connector_activation_radius").unwrap_or(12.0)
3593 }
3594
3595 pub fn set_graph_connector_activation_radius(radius: f32) {
3596 lazy_init_style_registry();
3597 if let Ok(mut registry) = get_style_registry().write() {
3598 registry.set_float("graph_connector_activation_radius", radius);
3599 }
3600 }
3601
3602 pub fn font_selector_corner_radius() -> f32 {
3603 lazy_init_style_registry();
3604 get_style_registry().read().unwrap().get_float("font_selector_corner_radius").unwrap_or_else(control_corner_radius)
3605 }
3606
3607 pub fn set_font_selector_corner_radius(radius: f32) {
3608 lazy_init_style_registry();
3609 if let Ok(mut registry) = get_style_registry().write() {
3610 registry.set_float("font_selector_corner_radius", radius);
3611 }
3612 }
3613
3614 /// The context menu's corner radius (`style.surface.menu.corner_radius`),
3615 /// falling back to the control rung's. It used to take the pane radius
3616 /// (`plate_corner_radius`, 12 in the shipped config), which is a corner
3617 /// too wide for a surface whose labels sit 8px in from its edge: the first
3618 /// row's text ran off the plate through the arc. A menu is a popover, and
3619 /// its corner belongs to the control scale, like the dropdown's.
3620 pub fn menu_corner_radius() -> f32 {
3621 lazy_init_style_registry();
3622 get_style_registry().read().unwrap().get_float("menu_corner_radius").unwrap_or_else(control_corner_radius)
3623 }
3624
3625 pub fn dropdown_corner_radius() -> f32 {
3626 lazy_init_style_registry();
3627 get_style_registry().read().unwrap().get_float("dropdown_corner_radius").unwrap_or_else(control_corner_radius)
3628 }
3629
3630 pub fn set_dropdown_corner_radius(radius: f32) {
3631 lazy_init_style_registry();
3632 if let Ok(mut registry) = get_style_registry().write() {
3633 registry.set_float("dropdown_corner_radius", radius);
3634 }
3635 }
3636
3637 pub fn color_selector_preview_margin() -> f32 {
3638 use std::sync::Once;
3639 static INIT: Once = Once::new();
3640 INIT.call_once(|| {
3641 if let Some(content) = read_config() {
3642 for line in content.lines() {
3643 let trimmed = line.trim();
3644 if let Some(rest) = trimmed.strip_prefix("color_selector_preview_margin") {
3645 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3646 let val_str = rest.trim_end_matches('"').trim();
3647 if let Ok(val) = val_str.parse::<f32>() {
3648 if let Ok(mut lock) = COLOR_SELECTOR_PREVIEW_MARGIN.write() {
3649 *lock = val;
3650 }
3651 }
3652 }
3653 }
3654 }
3655 });
3656 *COLOR_SELECTOR_PREVIEW_MARGIN.read().unwrap()
3657 }
3658
3659 pub fn set_color_selector_preview_margin(margin: f32) {
3660 if let Ok(mut lock) = COLOR_SELECTOR_PREVIEW_MARGIN.write() {
3661 *lock = margin;
3662 }
3663 }
3664
3665
3666
3667 pub fn paginator_tab_padding_x() -> f32 {
3668 use std::sync::Once;
3669 static INIT: Once = Once::new();
3670 INIT.call_once(|| {
3671 if let Some(content) = read_config() {
3672 for line in content.lines() {
3673 let trimmed = line.trim();
3674 if let Some(rest) = trimmed.strip_prefix("paginator_tab_padding_x") {
3675 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3676 let val_str = rest.trim_end_matches('"').trim();
3677 if let Ok(val) = val_str.parse::<f32>() {
3678 if let Ok(mut lock) = PAGINATOR_TAB_PADDING_X.write() {
3679 *lock = val;
3680 }
3681 }
3682 }
3683 }
3684 }
3685 });
3686 *PAGINATOR_TAB_PADDING_X.read().unwrap()
3687 }
3688
3689 pub fn set_paginator_tab_padding_x(padding: f32) {
3690 if let Ok(mut lock) = PAGINATOR_TAB_PADDING_X.write() {
3691 *lock = padding;
3692 }
3693 }
3694
3695 pub fn button_padding() -> f32 {
3696 use std::sync::Once;
3697 static INIT: Once = Once::new();
3698 INIT.call_once(|| {
3699 if let Some(content) = read_config() {
3700 let mut found = false;
3701 for line in content.lines() {
3702 let trimmed = line.trim();
3703 if let Some(rest) = trimmed.strip_prefix("button_padding") {
3704 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3705 let val_str = rest.trim_end_matches('"').trim();
3706 if let Ok(val) = val_str.parse::<f32>() {
3707 if let Ok(mut lock) = BUTTON_PADDING.write() {
3708 *lock = val;
3709 found = true;
3710 }
3711 }
3712 }
3713 }
3714 if !found {
3715 for line in content.lines() {
3716 let trimmed = line.trim();
3717 if let Some(rest) = trimmed.strip_prefix("paginator_tab_padding_y") {
3718 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3719 let val_str = rest.trim_end_matches('"').trim();
3720 if let Ok(val) = val_str.parse::<f32>() {
3721 if let Ok(mut lock) = BUTTON_PADDING.write() {
3722 *lock = val;
3723 }
3724 }
3725 }
3726 }
3727 }
3728 }
3729 });
3730 *BUTTON_PADDING.read().unwrap()
3731 }
3732
3733 pub fn set_button_padding(padding: f32) {
3734 if let Ok(mut lock) = BUTTON_PADDING.write() {
3735 *lock = padding;
3736 }
3737 }
3738
3739 pub fn button_height() -> f32 {
3740 use std::sync::Once;
3741 static INIT: Once = Once::new();
3742 INIT.call_once(|| {
3743 if let Some(content) = read_config() {
3744 for line in content.lines() {
3745 let trimmed = line.trim();
3746 if let Some(rest) = trimmed.strip_prefix("button_height") {
3747 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3748 let val_str = rest.trim_end_matches('"').trim();
3749 if let Ok(val) = val_str.parse::<f32>() {
3750 if let Ok(mut lock) = BUTTON_HEIGHT.write() {
3751 *lock = val;
3752 }
3753 }
3754 }
3755 }
3756 }
3757 });
3758 *BUTTON_HEIGHT.read().unwrap()
3759 }
3760
3761 pub fn ramp_height() -> f32 {
3762 use std::sync::Once;
3763 static INIT: Once = Once::new();
3764 INIT.call_once(|| {
3765 if let Some(content) = read_config() {
3766 for line in content.lines() {
3767 let trimmed = line.trim();
3768 if let Some(rest) = trimmed.strip_prefix("ramp_height") {
3769 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3770 let val_str = rest.trim_end_matches('"').trim();
3771 if let Ok(val) = val_str.parse::<f32>() {
3772 if let Ok(mut lock) = RAMP_HEIGHT.write() {
3773 *lock = val;
3774 }
3775 }
3776 }
3777 }
3778 }
3779 });
3780 *RAMP_HEIGHT.read().unwrap()
3781 }
3782
3783 pub fn set_ramp_height(height: f32) {
3784 if let Ok(mut lock) = RAMP_HEIGHT.write() {
3785 *lock = height;
3786 }
3787 }
3788
3789 pub fn set_button_height(height: f32) {
3790 if let Ok(mut lock) = BUTTON_HEIGHT.write() {
3791 *lock = height;
3792 }
3793 }
3794
3795 pub fn button_strip_spacing() -> f32 {
3796 use std::sync::Once;
3797 static INIT: Once = Once::new();
3798 INIT.call_once(|| {
3799 if let Some(content) = read_config() {
3800 for line in content.lines() {
3801 let trimmed = line.trim();
3802 if let Some(rest) = trimmed.strip_prefix("button_strip_spacing") {
3803 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3804 let val_str = rest.trim_end_matches('"').trim();
3805 if let Ok(val) = val_str.parse::<f32>() {
3806 if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
3807 *lock = val;
3808 }
3809 }
3810 }
3811 }
3812 }
3813 });
3814 *BUTTON_STRIP_SPACING.read().unwrap()
3815 }
3816
3817 pub fn set_button_strip_spacing(spacing: f32) {
3818 if let Ok(mut lock) = BUTTON_STRIP_SPACING.write() {
3819 *lock = spacing;
3820 }
3821 }
3822
3823 pub fn paginator_tab_padding_y() -> f32 {
3824 button_padding()
3825 }
3826
3827 pub fn set_paginator_tab_padding_y(padding: f32) {
3828 set_button_padding(padding);
3829 }
3830
3831 pub fn textbox_height() -> f32 {
3832 use std::sync::Once;
3833 static INIT: Once = Once::new();
3834 INIT.call_once(|| {
3835 if let Some(content) = read_config() {
3836 for line in content.lines() {
3837 let trimmed = line.trim();
3838 if let Some(rest) = trimmed.strip_prefix("textbox_height") {
3839 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3840 let val_str = rest.trim_end_matches('"').trim();
3841 if let Ok(val) = val_str.parse::<f32>() {
3842 if let Ok(mut lock) = TEXTBOX_HEIGHT.write() {
3843 *lock = val;
3844 }
3845 }
3846 }
3847 }
3848 }
3849 });
3850 *TEXTBOX_HEIGHT.read().unwrap()
3851 }
3852
3853 pub fn set_textbox_height(height: f32) {
3854 if let Ok(mut lock) = TEXTBOX_HEIGHT.write() {
3855 *lock = height;
3856 }
3857 }
3858
3859 pub fn dropdown_height() -> f32 {
3860 use std::sync::Once;
3861 static INIT: Once = Once::new();
3862 INIT.call_once(|| {
3863 if let Some(content) = read_config() {
3864 for line in content.lines() {
3865 let trimmed = line.trim();
3866 if let Some(rest) = trimmed.strip_prefix("dropdown_height") {
3867 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3868 let val_str = rest.trim_end_matches('"').trim();
3869 if let Ok(val) = val_str.parse::<f32>() {
3870 if let Ok(mut lock) = DROPDOWN_HEIGHT.write() {
3871 *lock = val;
3872 }
3873 }
3874 }
3875 }
3876 }
3877 });
3878 *DROPDOWN_HEIGHT.read().unwrap()
3879 }
3880
3881 pub fn set_dropdown_height(height: f32) {
3882 if let Ok(mut lock) = DROPDOWN_HEIGHT.write() {
3883 *lock = height;
3884 }
3885 }
3886
3887 pub fn slider_height() -> f32 {
3888 use std::sync::Once;
3889 static INIT: Once = Once::new();
3890 INIT.call_once(|| {
3891 if let Some(content) = read_config() {
3892 for line in content.lines() {
3893 let trimmed = line.trim();
3894 if let Some(rest) = trimmed.strip_prefix("slider_height") {
3895 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3896 let val_str = rest.trim_end_matches('"').trim();
3897 if let Ok(val) = val_str.parse::<f32>() {
3898 if let Ok(mut lock) = SLIDER_HEIGHT.write() {
3899 *lock = val;
3900 }
3901 }
3902 }
3903 }
3904 }
3905 });
3906 *SLIDER_HEIGHT.read().unwrap()
3907 }
3908
3909 pub fn set_slider_height(height: f32) {
3910 if let Ok(mut lock) = SLIDER_HEIGHT.write() {
3911 *lock = height;
3912 }
3913 }
3914
3915 pub fn progressbar_height() -> f32 {
3916 use std::sync::Once;
3917 static INIT: Once = Once::new();
3918 INIT.call_once(|| {
3919 if let Some(content) = read_config() {
3920 for line in content.lines() {
3921 let trimmed = line.trim();
3922 if let Some(rest) = trimmed.strip_prefix("progressbar_height") {
3923 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3924 let val_str = rest.trim_end_matches('"').trim();
3925 if let Ok(val) = val_str.parse::<f32>() {
3926 if let Ok(mut lock) = PROGRESSBAR_HEIGHT.write() {
3927 *lock = val;
3928 }
3929 }
3930 }
3931 }
3932 }
3933 });
3934 *PROGRESSBAR_HEIGHT.read().unwrap()
3935 }
3936
3937 pub fn set_progressbar_height(height: f32) {
3938 if let Ok(mut lock) = PROGRESSBAR_HEIGHT.write() {
3939 *lock = height;
3940 }
3941 }
3942
3943 pub fn rangeslider_height() -> f32 {
3944 use std::sync::Once;
3945 static INIT: Once = Once::new();
3946 INIT.call_once(|| {
3947 if let Some(content) = read_config() {
3948 for line in content.lines() {
3949 let trimmed = line.trim();
3950 if let Some(rest) = trimmed.strip_prefix("rangeslider_height") {
3951 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
3952 let val_str = rest.trim_end_matches('"').trim();
3953 if let Ok(val) = val_str.parse::<f32>() {
3954 if let Ok(mut lock) = RANGESLIDER_HEIGHT.write() {
3955 *lock = val;
3956 }
3957 }
3958 }
3959 }
3960 }
3961 });
3962 *RANGESLIDER_HEIGHT.read().unwrap()
3963 }
3964
3965 pub fn set_rangeslider_height(height: f32) {
3966 if let Ok(mut lock) = RANGESLIDER_HEIGHT.write() {
3967 *lock = height;
3968 }
3969 }
3970
3971
3972
3973 /// One step carve a widget's `paint` draws, handed to a flat-path host through
3974 /// [`RenderTarget::relief_carve`] so it can re-emit it as a real prim.
3975 ///
3976 /// Geometry always comes from the WIDGET (`TextBox::well`, `Toggle::well` /
3977 /// `Toggle::slide_plate`), never re-derived here — a second copy of that math
3978 /// in the bridge is exactly how the flat host's carve and the drawn one drift
3979 /// apart.
3980 #[derive(Clone, Copy, Debug, PartialEq)]
3981 pub struct ReliefCarve {
3982 pub kind: CarveKind,
3983 pub x: f32,
3984 pub y: f32,
3985 pub w: f32,
3986 pub h: f32,
3987 /// Per-corner radii, clockwise from top-left.
3988 pub radii: (f32, f32, f32, f32),
3989 /// Full width of the step's transition band.
3990 pub depth: f32,
3991 /// Which walls the carve has (top, right, bottom, left). A suppressed wall
3992 /// means the step runs flush to its neighbour there — a Spinbox's field
3993 /// running into its button column, where the two meet in ONE step rather
3994 /// than two facing walls.
3995 pub edges: (bool, bool, bool, bool),
3996 }
3997
3998 /// Which way a [`ReliefCarve`] steps.
3999 #[derive(Clone, Copy, Debug, PartialEq)]
4000 pub enum CarveKind {
4001 /// Interior one step DOWN ([`crate::scene::paint::PaintCtx::recess_edges`]).
4002 /// `tint` lights the rim in the focus accent (`recess_tinted`).
4003 Recess { tint: Option<[f32; 3]> },
4004 /// Interior one step UP ([`crate::scene::paint::PaintCtx::boss_edges`]).
4005 Boss { tint: Option<[f32; 3]> },
4006 /// A FLUSH inset ([`crate::scene::paint::PaintCtx::trough_edges`]): the
4007 /// interior stays level with the surface and a valley seam runs the
4008 /// boundary — the closed dropdown's chrome, for a control that is part
4009 /// of the plate rather than a step up or down from it.
4010 Trough,
4011 }
4012
4013 impl ReliefCarve {
4014 /// This carve shifted vertically — the page-scroll adjustment a host
4015 /// applies when it re-emits collected carves.
4016 pub fn shifted_y(self, dy: f32) -> Self {
4017 Self { y: self.y + dy, ..self }
4018 }
4019 }
4020
4021 pub trait RenderTarget {
4022 fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32);
4023 fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, _radius: f32) {
4024 self.rect(color, x, y, w, h);
4025 }
4026 fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _corners: (bool, bool, bool, bool)) {
4027 self.rect_with_radius(color, x, y, w, h, radius);
4028 }
4029 fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]);
4030 fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _font: &str) {
4031 self.text(content, x, y, size, color);
4032 }
4033 fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], _bounds: Option<[f32; 4]>) {
4034 self.text(content, x, y, size, color);
4035 }
4036 fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, _bounds: Option<[f32; 4]>) {
4037 self.text_with_font(content, x, y, size, color, font);
4038 }
4039 fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
4040 fn pop_clip_rect(&mut self) {}
4041 /// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
4042 /// control surface (groove ring down, beveled lip back up). Lets a popover
4043 /// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
4044 /// Hosts without relief prims degrade to a flat rounded fill.
4045 fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
4046 self.rect_with_radius(color, x, y, w, h, radius);
4047 }
4048 /// [`inset_plate`](Self::inset_plate) with the rim lit — the focused
4049 /// control plate's ring (`ControlPlate::with_tint`). Hosts without relief
4050 /// prims draw the plain plate.
4051 fn inset_plate_tinted(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, _tint: [f32; 3]) {
4052 self.inset_plate(color, x, y, w, h, radius, depth);
4053 }
4054 /// One step carve from a widget's `paint` ([`ReliefCarve`]) — offered here
4055 /// for the same reason as `inset_plate`: the legacy `all_quads` stream
4056 /// carries no relief prims, so a flat-path host never sees them.
4057 ///
4058 /// The default is deliberately a NO-OP, not a fill. These controls have
4059 /// transparent faces by design (the host surface IS the well floor / the
4060 /// plate's face), so the carve is their entire decoration — a host that
4061 /// can't carve has nothing truthful to draw, and a solid box here would
4062 /// paint every text field a flat slab it never had.
4063 fn relief_carve(&mut self, _carve: &ReliefCarve) {}
4064 /// Whether this host renders sections as sunken wells (the designer idiom).
4065 /// `SectionContext` then lays the title out left-aligned over its tab box
4066 /// instead of centered on the top border.
4067 fn section_relief_style(&self) -> bool {
4068 false
4069 }
4070 /// The section frame hatch: `SectionContext::finish` offers the frame here
4071 /// before falling back to the legacy 1px outline. A relief-capable host
4072 /// returns true and carves the section into its plate instead (the
4073 /// designer sunken-well idiom); the tuple hosts keep the default.
4074 fn section_relief(&mut self, _frame: &SectionFrame) -> bool {
4075 false
4076 }
4077 }
4078
4079 /// A section frame offered to [`RenderTarget::section_relief`]: the content
4080 /// body box plus, under [`RenderTarget::section_relief_style`], the title tab
4081 /// box the label was laid out in — the tab sits flush on the body's top edge
4082 /// (the designer union-carve shape).
4083 pub struct SectionFrame {
4084 pub x: f32,
4085 pub y: f32,
4086 pub w: f32,
4087 pub h: f32,
4088 pub tab: Option<(f32, f32, f32, f32)>,
4089 pub focused: bool,
4090 pub is_child: bool,
4091 }
4092
4093 pub struct PopoverCollector {
4094 pub rects: Vec<([f32; 4], f32, f32, f32, f32)>,
4095 pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
4096 }
4097
4098 impl PopoverCollector {
4099 pub fn new() -> Self {
4100 Self { rects: Vec::new(), texts: Vec::new() }
4101 }
4102 }
4103
4104 impl RenderTarget for PopoverCollector {
4105 fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
4106 self.rects.push((color, x, y, w, h));
4107 }
4108
4109 fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
4110 self.texts.push((content.to_string(), size, x, y, color, None, None));
4111 }
4112
4113 fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
4114 self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
4115 }
4116
4117 fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
4118 self.texts.push((content.to_string(), size, x, y, color, None, bounds));
4119 }
4120
4121 fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
4122 self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), bounds));
4123 }
4124 }
4125
4126
4127 pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut T, x: f32, y: f32, ww: f32, wh: f32, ctx: &mut UiContext) {
4128 let id = Some(w.base().id());
4129 if let Some(w_id) = id {
4130 ctx.register_widget(w_id, w as *mut T as *mut (dyn WidgetHost + 'static));
4131 }
4132 // The flat-host contract, the same block `set_rect` takes: `(x, y)` is the top of
4133 // the detached label and `wh` the block height, label strip included. `layout`
4134 // takes the CONTENT origin and height, so step down by the strip.
4135 let strip = w.label_strip();
4136 let content_h = (wh - strip).max(0.0);
4137 w.layout(crate::widget::Point { x, y: y + strip }, crate::widget::LayoutConstraints::new(ww, ww, content_h, content_h), ctx);
4138
4139 // Shape, which on this path nobody else does. A flat host consumes
4140 // `all_quads`, so `prepare_text` — where a TextBox records the per-glyph x
4141 // offsets its selection highlight, caret and click->index mapping all read
4142 // — was never called for the widgets it draws. Those three then fell back
4143 // to `measure_text_width("M")`, an SVG-rasterized INKED extent rather than
4144 // an advance, so the highlight under-ran the glyphs by a few px per
4145 // character (a full glyph by the end of "example.com"). Hosts that shape
4146 // for themselves (cce-files, the TreeList) just re-read the shared buffer
4147 // cache here.
4148 if let Ok(mut fs) = crate::geometry_font_system().lock() {
4149 w.prepare_text(&mut fs);
4150 }
4151 let (style_r, corners) = w.corner_style();
4152 let r = if corners != (false, false, false, false) { style_r } else { 0.0 };
4153 let (wx, mut wy, www, mut whh) = w.rect();
4154 let top_room = w.label_strip();
4155 wy += top_room;
4156 whh -= top_room;
4157
4158 // ONE ordered replay of the paint walk — the same walk the live display-
4159 // list render runs (`scene::painter`), every prim in the order the widget
4160 // painted it, each mapped onto the flat host's RenderTarget surface.
4161 //
4162 // This used to be three passes over three typed views of the same paint:
4163 // the relief prims (downcast per widget type and re-derived from the
4164 // widget's accessors — the Dropdown's inset plate, the TextBox's well, the
4165 // Button's face, the Toggle's faces and steps), then every plain quad
4166 // (`all_quads`), then every rounded quad (`all_rounded_quads`). Splitting
4167 // one paint into typed streams loses the order between them, and the
4168 // order is the picture: a TextBox draws its rounded background, carves
4169 // its well, THEN lays the selection highlight and caret on top — the
4170 // three-pass replay put the well under the highlight and the background
4171 // over both. A Dropdown's hovered row is drawn after its menu plate; a
4172 // host that replays plates after rects buries the highlight. The prim
4173 // walk keeps the widget's order, covers every widget instead of the four
4174 // that had a special case, and carries the relief prims' own per-corner
4175 // radii and depth (the re-derivations rounded those off).
4176 //
4177 // Mapping onto the tuple surface: Quad and RoundedRect are the two
4178 // native fills (the root's plain background keeps its solid-border
4179 // expansion and window-corner resolution); a zero-stroke Border is an
4180 // inset plate's FACE and is held until the Trough that follows it, so the
4181 // pair reaches the host as ONE `inset_plate` call (a relief host carves
4182 // it for real; the default degrades to the flat fill); Recess/Boss go to
4183 // `relief_carve`; a Bevel degrades to its fill — what a flat host can
4184 // draw of a raised plate. Ridges, circles, arcs, vectors and images have
4185 // no flat-surface counterpart and are skipped, as they always were.
4186 let solid_border = w.solid_border();
4187 let mut text_scratch = crate::scene::paint::PaintCtx::new();
4188 crate::scene::painter::paint_root_into(&*ctx, &*w, &mut text_scratch);
4189
4190 // A zero-stroke Border waiting for its Trough: (rect, radii, fill).
4191 let mut pending_face: Option<(crate::scene::layout::Rect, crate::scene::paint::Radii, [f32; 4])> = None;
4192 fn same_rect(a: crate::scene::layout::Rect, b: crate::scene::layout::Rect) -> bool {
4193 (a.x - b.x).abs() < 0.1 && (a.y - b.y).abs() < 0.1 && (a.width - b.width).abs() < 0.1 && (a.height - b.height).abs() < 0.1
4194 }
4195 fn emit_rounded(pc: &mut dyn RenderTarget, rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, color: [f32; 4]) {
4196 let (r1, r2, r3, r4) = radii;
4197 let radius = r1.max(r2).max(r3).max(r4);
4198 let mask = (r1 > 0.0, r2 > 0.0, r3 > 0.0, r4 > 0.0);
4199 pc.rect_with_radius_corners(color, rect.x, rect.y, rect.width, rect.height, radius, mask);
4200 }
4201
4202 for item in text_scratch.finish().items {
4203 use crate::scene::paint::Prim;
4204 let trough_for_face = match (&item.prim, &pending_face) {
4205 (Prim::Trough { rect, .. }, Some((face_rect, _, _))) => same_rect(*rect, *face_rect),
4206 _ => false,
4207 };
4208 if !trough_for_face {
4209 if let Some((frect, fradii, fill)) = pending_face.take() {
4210 emit_rounded(pc, frect, fradii, fill);
4211 }
4212 }
4213 match item.prim {
4214 Prim::Quad { rect, color: qc } => {
4215 let (qx, qy, qw, qh) = (rect.x, rect.y, rect.width, rect.height);
4216 let extra_corners = (
4217 corners.0 && qx <= wx + 1.5 && qy <= wy + 1.5,
4218 corners.1 && qx + qw >= wx + www - 1.5 && qy <= wy + 1.5,
4219 corners.2 && qx + qw >= wx + www - 1.5 && qy + qh >= wy + whh - 1.5,
4220 corners.3 && qx <= wx + 1.5 && qy + qh >= wy + whh - 1.5,
4221 );
4222
4223 let (resolved_r, resolved_corners) = if r <= 0.1 || corners == (false, false, false, false) || extra_corners == (false, false, false, false) {
4224 (0.0, (false, false, false, false))
4225 } else {
4226 (r, extra_corners)
4227 };
4228
4229 // The widget's own background quad with a solid border: full-size
4230 // border quad, then the inset background over it.
4231 let mut border_drawn = false;
4232 let is_bg_quad = (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - www).abs() < 0.1 && (qh - whh).abs() < 0.1;
4233 if is_bg_quad {
4234 if let Some((border_color, thickness)) = solid_border {
4235 if thickness > 0.0 {
4236 pc.rect_with_radius_corners(border_color, qx, qy, qw, qh, resolved_r, resolved_corners);
4237 pc.rect_with_radius_corners(
4238 qc,
4239 qx + thickness,
4240 qy + thickness,
4241 (qw - 2.0 * thickness).max(0.0),
4242 (qh - 2.0 * thickness).max(0.0),
4243 (resolved_r - thickness).max(0.0),
4244 resolved_corners,
4245 );
4246 border_drawn = true;
4247 }
4248 }
4249 }
4250
4251 if !border_drawn {
4252 pc.rect_with_radius_corners(qc, qx, qy, qw, qh, resolved_r, resolved_corners);
4253 }
4254 }
4255 Prim::RoundedRect { rect, radius, corners: qcorners, color: qc } => {
4256 pc.rect_with_radius_corners(qc, rect.x, rect.y, rect.width, rect.height, radius, qcorners);
4257 }
4258 Prim::Border { rect, radii, fill, border, thickness } => {
4259 if thickness > 0.0 && border[3].abs() > 0.001 {
4260 emit_rounded(pc, rect, radii, border);
4261 if fill[3].abs() > 0.001 {
4262 let inner = crate::scene::layout::Rect {
4263 x: rect.x + thickness,
4264 y: rect.y + thickness,
4265 width: (rect.width - 2.0 * thickness).max(0.0),
4266 height: (rect.height - 2.0 * thickness).max(0.0),
4267 };
4268 let (r1, r2, r3, r4) = radii;
4269 let shrink = |v: f32| if v > 0.0 { (v - thickness).max(0.0) } else { 0.0 };
4270 emit_rounded(pc, inner, (shrink(r1), shrink(r2), shrink(r3), shrink(r4)), fill);
4271 }
4272 } else if fill[3].abs() > 0.001 {
4273 // abs(): a negative alpha is the frost sentinel, a real face.
4274 pending_face = Some((rect, radii, fill));
4275 }
4276 }
4277 Prim::Trough { rect, radii, depth, tint, .. } => {
4278 let face = pending_face.take().map(|(_, _, fill)| fill).unwrap_or([0.0; 4]);
4279 let (r1, r2, r3, r4) = radii;
4280 let r = r1.max(r2).max(r3).max(r4);
4281 match tint {
4282 Some(t) => pc.inset_plate_tinted(face, rect.x, rect.y, rect.width, rect.height, r, depth, t),
4283 None => pc.inset_plate(face, rect.x, rect.y, rect.width, rect.height, r, depth),
4284 }
4285 }
4286 Prim::Bevel { rect, radii, material, .. } => {
4287 let color = material.fill(crate::scene::material::PlateRole::Nested);
4288 if color[3].abs() > 0.001 {
4289 emit_rounded(pc, rect, radii, color);
4290 }
4291 }
4292 Prim::Recess { rect, radii, depth, edges, tint } => {
4293 pc.relief_carve(&ReliefCarve {
4294 kind: CarveKind::Recess { tint },
4295 x: rect.x,
4296 y: rect.y,
4297 w: rect.width,
4298 h: rect.height,
4299 radii,
4300 depth,
4301 edges,
4302 });
4303 }
4304 Prim::Boss { rect, radii, depth, edges, tint } => {
4305 pc.relief_carve(&ReliefCarve {
4306 kind: CarveKind::Boss { tint },
4307 x: rect.x,
4308 y: rect.y,
4309 w: rect.width,
4310 h: rect.height,
4311 radii,
4312 depth,
4313 edges,
4314 });
4315 }
4316 // Text via the paint walk: each widget's Text prims (content font + scroll-ancestor
4317 // clip) exactly as the live display-list render does; the prim already carries the
4318 // per-widget font + bounds.
4319 Prim::Text { text, x, y, font_size, color, font, bounds, .. } => {
4320 let color_f32 = [
4321 color[0] as f32 / 255.0,
4322 color[1] as f32 / 255.0,
4323 color[2] as f32 / 255.0,
4324 1.0,
4325 ];
4326 // Compose the walk's container clip with the prim's own bounds (the engine's dl-text
4327 // merge), so a clipping ancestor still bounds the text.
4328 let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
4329 let merged = match (clip, bounds) {
4330 (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
4331 (Some(a), None) => Some(a),
4332 (None, b) => b,
4333 };
4334 match font {
4335 Some(ref f) => pc.text_with_font_and_bounds(&text, x, y, font_size, color_f32, f, merged),
4336 None => pc.text_with_bounds(&text, x, y, font_size, color_f32, merged),
4337 }
4338 }
4339 _ => {}
4340 }
4341 }
4342 if let Some((frect, fradii, fill)) = pending_face.take() {
4343 emit_rounded(pc, frect, fradii, fill);
4344 }
4345 if w.popover_rect().is_some() {
4346 ctx.register_popover(w);
4347 }
4348 }
4349
4350 pub fn render_popovers(pc: &mut dyn RenderTarget, ctx: &UiContext) {
4351 for &pop_id in &ctx.active_popovers {
4352 if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
4353 unsafe {
4354 (*ptr).render_popover(pc);
4355 }
4356 }
4357 }
4358 // Registry sweep for open popovers the app never registered (popover
4359 // registration is optional and spotty) — the same fallback the coverage
4360 // check and the engine's outside-press close use.
4361 for (id, ptr) in ctx.tree.iter_registered() {
4362 if ctx.active_popovers.contains(&id) {
4363 continue;
4364 }
4365 unsafe {
4366 if let Some(w) = ptr.as_ref() {
4367 if w.visible() && w.popover_rect().is_some() {
4368 w.render_popover(pc);
4369 }
4370 }
4371 }
4372 }
4373 }
4374
4375 pub fn partition_concentric_corners(
4376 x: f32, y: f32, w: f32, h: f32,
4377 _r_std: f32,
4378 r_adjust: [f32; 4],
4379 color: [f32; 4],
4380 ) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
4381 let mut quads = Vec::new();
4382
4383 let r0 = r_adjust[0];
4384 let r1 = r_adjust[1];
4385 let r2 = r_adjust[2];
4386 let r3 = r_adjust[3];
4387
4388 let max_left = r0.max(r3);
4389 let max_right = r1.max(r2);
4390
4391 let mut push_valid_quad = |qx: f32, qy: f32, qw: f32, qh: f32, qr: f32, qcorners: (bool, bool, bool, bool)| {
4392 if qw > 0.001 && qh > 0.001 {
4393 quads.push((qx, qy, qw, qh, qr, color, qcorners));
4394 }
4395 };
4396
4397 // 1. Center vertical block
4398 push_valid_quad(x + max_left, y, w - max_left - max_right, h, 0.0, (false, false, false, false));
4399
4400 // 2. Left block
4401 push_valid_quad(x, y + r0, max_left, h - r0 - r3, 0.0, (false, false, false, false));
4402
4403 // 3. Top-left transition
4404 push_valid_quad(x + r0, y, max_left - r0, r0, 0.0, (false, false, false, false));
4405
4406 // 4. Bottom-left transition
4407 push_valid_quad(x + r3, y + h - r3, max_left - r3, r3, 0.0, (false, false, false, false));
4408
4409 // 5. Right block
4410 push_valid_quad(x + w - max_right, y + r1, max_right, h - r1 - r2, 0.0, (false, false, false, false));
4411
4412 // 6. Top-right transition
4413 push_valid_quad(x + w - max_right, y, max_right - r1, r1, 0.0, (false, false, false, false));
4414
4415 // 7. Bottom-right transition
4416 push_valid_quad(x + w - max_right, y + h - r2, max_right - r2, r2, 0.0, (false, false, false, false));
4417
4418 // 8. Corner 0 (top-left)
4419 push_valid_quad(x, y, r0, r0, r0, (true, false, false, false));
4420
4421 // 9. Corner 1 (top-right)
4422 push_valid_quad(x + w - r1, y, r1, r1, r1, (false, true, false, false));
4423
4424 // 10. Corner 2 (bottom-right)
4425 push_valid_quad(x + w - r2, y + h - r2, r2, r2, r2, (false, false, true, false));
4426
4427 // 11. Corner 3 (bottom-left)
4428 push_valid_quad(x, y + h - r3, r3, r3, r3, (false, false, false, true));
4429
4430 quads
4431 }
4432
4433 pub struct UiFrame;
4434
4435 impl UiFrame {
4436 pub fn start(scroll_offset: f32) -> Self {
4437 crate::widget::hover_animation::reset_frame_registration();
4438 crate::widget::hover_animation::set_scroll_offset(scroll_offset);
4439 Self
4440 }
4441
4442 pub fn finish(self, pc: &mut dyn RenderTarget) {
4443 crate::widget::hover_animation::post_render_check();
4444 if let Some((qx, qy, qw, qh, qc)) = crate::widget::hover_animation::get_quad() {
4445 pc.rect(qc, qx, qy, qw, qh);
4446 }
4447 // render_popovers(pc);
4448 }
4449 }
4450
4451 pub struct Column {
4452 ox: f32,
4453 oy: f32,
4454 cx: f32,
4455 pub y: f32,
4456 pub cw: f32,
4457 }
4458
4459 impl Column {
4460 pub fn new(ox: f32, oy: f32, cx: f32, cy: f32, cw: f32) -> Self {
4461 Self { ox, oy, cx, y: cy, cw }
4462 }
4463
4464 pub fn ax(&self, x_off: f32) -> f32 {
4465 self.ox + self.cx + x_off
4466 }
4467
4468 pub fn ay(&self) -> f32 {
4469 self.oy + self.y
4470 }
4471
4472 pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
4473 pc.rect(color, self.ax(x_off), self.ay(), w, h);
4474 self.y += h;
4475 }
4476
4477 pub fn advance(&mut self, dy: f32) {
4478 self.y += dy;
4479 }
4480
4481 pub fn spacing(&mut self, dy: f32) {
4482 self.y += dy;
4483 }
4484
4485 pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
4486 let x = self.ax(8.0);
4487 let y = self.ay();
4488 pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 16.0, 1.0);
4489 self.y += 8.0;
4490 }
4491
4492 pub fn header(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32) {
4493 let x = self.ax(x_off);
4494 let y = self.ay();
4495 pc.text(text, x, y, 14.0, [0.83, 0.83, 0.83, 1.0]);
4496 self.y += 22.0;
4497 }
4498
4499 pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
4500 let x = self.ax(x_off);
4501 let y = self.ay() + y_off;
4502 pc.text(text, x, y, font_size, color);
4503 }
4504
4505 pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, x_off: f32, ww: f32, mut wh: f32, ctx: &mut UiContext) {
4506 if let Some(pref) = w.preferred_height() {
4507 wh = pref;
4508 }
4509 let top_room = w.label_strip();
4510 let total_h = wh + top_room;
4511 let x = self.ax(x_off);
4512 let y = self.ay();
4513 w.set_row_rect(self.ox + self.cx + 8.0, self.cw - 16.0);
4514 let clamped_w = ww.min((self.cw - x_off).max(0.0));
4515 render_widget(pc, w, x, y, clamped_w, total_h, ctx);
4516 self.y += total_h;
4517 }
4518
4519 pub fn row<F: FnOnce(&mut Row)>(&mut self, pc: &mut dyn RenderTarget, h: f32, f: F) {
4520 let row_y = self.ay();
4521 let mut row = Row {
4522 pc: &mut *pc,
4523 base_x: self.ox + self.cx,
4524 y: row_y,
4525 cursor_x: 0.0,
4526 spacing: CONTROL_GAP,
4527 };
4528 f(&mut row);
4529 self.y = self.y + h;
4530 }
4531 }
4532
4533 pub struct Row<'a> {
4534 pc: &'a mut dyn RenderTarget,
4535 base_x: f32,
4536 y: f32,
4537 pub cursor_x: f32,
4538 pub spacing: f32,
4539 }
4540
4541 impl<'a> Row<'a> {
4542 pub fn set_spacing(&mut self, spacing: f32) {
4543 self.spacing = spacing;
4544 }
4545
4546 pub fn gap(&mut self, width: f32) {
4547 self.cursor_x += width;
4548 }
4549
4550 pub fn text(&mut self, text: &str, y_off: f32, font_size: f32, color: [f32; 4], width: f32) {
4551 self.pc
4552 .text(text, self.base_x + self.cursor_x, self.y + y_off, font_size, color);
4553 self.cursor_x += width + self.spacing;
4554 }
4555
4556 pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, mut wh: f32, ctx: &mut UiContext) {
4557 if let Some(pref) = w.preferred_height() {
4558 wh = pref;
4559 }
4560 render_widget(self.pc, w, self.base_x + self.cursor_x, self.y, ww, wh, ctx);
4561 self.cursor_x += ww + self.spacing;
4562 }
4563 }
4564
4565 fn estimate_label_width_helper(label: &str, font_size: f32, font_fam: &str) -> f32 {
4566 let fam_lower = font_fam.to_lowercase();
4567 let is_mono = fam_lower.contains("mono") || fam_lower.contains("courier") || fam_lower == "monospace";
4568 if is_mono {
4569 label.chars().count() as f32 * font_size * 0.60
4570 } else {
4571 let mut width = 0.0;
4572 for c in label.chars() {
4573 let factor = match c {
4574 'i' | 'l' | 'I' | ' ' | '.' | ',' | '!' | ';' | ':' | '\'' | '"' | '(' | ')' | '[' | ']' | '-' => 0.30,
4575 'f' | 'j' | 't' => 0.35,
4576 'r' | 's' | 'c' | 'z' => 0.50,
4577 'a' | 'b' | 'd' | 'e' | 'g' | 'h' | 'k' | 'n' | 'o' | 'p' | 'q' | 'u' | 'v' | 'x' | 'y' => 0.60,
4578 'm' | 'w' | 'M' | 'W' | '&' | '@' | 'O' | 'Q' | 'G' => 0.85,
4579 'A' | 'B' | 'C' | 'D' | 'H' | 'N' | 'U' | 'V' | 'X' | 'Y' => 0.75,
4580 'E' | 'F' | 'K' | 'L' | 'P' | 'R' | 'S' | 'T' | 'Z' | 'J' => 0.68,
4581 '0'..='9' => 0.60,
4582 _ => 0.60,
4583 };
4584 width += factor * font_size;
4585 }
4586 width
4587 }
4588 }
4589
4590 pub struct Section {
4591 pub left: f32,
4592 pub top: f32,
4593 pub content_y: f32,
4594 pub cw: f32,
4595 pub label_width: f32,
4596 pub is_child: bool,
4597 pub grid: Grid,
4598 pub last_col: usize,
4599 }
4600
4601 impl Section {
4602 pub const DEFAULT_MARGIN_X: f32 = 12.0;
4603 pub const DEFAULT_ROW_GAP: f32 = 8.0;
4604
4605 fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
4606 estimate_label_width_helper(label, font_size, font_fam)
4607 }
4608
4609 pub fn padding(&self) -> f32 {
4610 section_padding()
4611 }
4612
4613 pub fn new(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str) -> Self {
4614 Self::new_opt(pc, left, top, cw, label, false)
4615 }
4616
4617 pub fn new_opt(pc: &mut dyn RenderTarget, left: f32, top: f32, cw: f32, label: &str, is_child: bool) -> Self {
4618 let font_setting = if is_child {
4619 nested_section_label_font()
4620 } else {
4621 section_label_font()
4622 };
4623 let (font_fam, font_size_opt) = parse_font_string(&font_setting);
4624 let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
4625 let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
4626 let label_width = Self::estimate_label_width(label, font_size, &font_fam);
4627 let label_x = if is_child {
4628 let base_x = match nested_section_label_alignment() {
4629 0 => left + 12.0,
4630 1 => left + (cw - label_width) / 2.0,
4631 2 => left + cw - 12.0 - label_width,
4632 _ => left + 12.0,
4633 };
4634 base_x + nested_section_label_offset()
4635 } else {
4636 left + (cw - label_width) / 2.0
4637 };
4638 pc.text_with_font(label, label_x, top, font_size, font_color, &font_fam);
4639
4640 let pad = section_padding();
4641 let margin_x = 2.0 * pad + 12.0;
4642 let usable_w = (cw - 2.0 * margin_x).max(1.0);
4643 let min_col_width = 130.0;
4644 let gap = 8.0;
4645 let max_cols = if is_child {
4646 1
4647 } else {
4648 ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
4649 };
4650 let content_start_y = top + pad + 19.0;
4651 let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
4652
4653 Self { left, top, content_y: content_start_y, cw, label_width, is_child, grid, last_col: usize::MAX }
4654 }
4655
4656 /// Horizontal inset of content from this section's left edge — the same
4657 /// one `row_layout`, the column `Grid` and `widget` use, so everything in
4658 /// a section lines up. See `SectionContext::content_margin`.
4659 pub fn content_margin(&self) -> f32 {
4660 2.0 * self.padding() + 12.0
4661 }
4662
4663 pub fn content_left(&self) -> f32 {
4664 self.left + self.content_margin()
4665 }
4666
4667 pub fn content_width(&self) -> f32 {
4668 (self.cw - 2.0 * self.content_margin()).max(0.0)
4669 }
4670
4671 /// See `SectionContext::ax` — same mapping, same reason it is no longer
4672 /// stepped at `x_off == 12.0`.
4673 pub fn ax(&self, x_off: f32) -> f32 {
4674 self.left + 2.0 * self.padding() + x_off
4675 }
4676
4677 pub fn ay(&self) -> f32 { self.content_y }
4678
4679 pub fn spacing(&mut self, dy: f32) {
4680 if self.grid.col_heights.len() >= 2 {
4681 if self.last_col == usize::MAX {
4682 for h in &mut self.grid.col_heights {
4683 *h += dy;
4684 }
4685 } else if self.last_col < self.grid.col_heights.len() {
4686 self.grid.col_heights[self.last_col] += dy;
4687 }
4688 self.content_y = self.grid.max_height();
4689 } else {
4690 self.content_y += dy;
4691 for h in &mut self.grid.col_heights {
4692 *h += dy;
4693 }
4694 }
4695 }
4696
4697 pub fn text(&mut self, pc: &mut dyn RenderTarget, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
4698 let x = self.ax(x_off);
4699 let y = self.ay() + y_off;
4700 // Bounded to the content box, like `SectionContext::text` — text is
4701 // the only thing a section draws that is not already sized to fit it.
4702 let right = self.content_left() + self.content_width();
4703 let bounds = if right > x {
4704 Some([x, y - font_size, right, y + 2.0 * font_size])
4705 } else {
4706 None
4707 };
4708 pc.text_with_bounds(text, x, y, font_size, color, bounds);
4709 }
4710
4711 pub fn widget<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
4712 if let Some(pref) = w.preferred_height() {
4713 wh = pref;
4714 }
4715 let pad = self.padding();
4716 let top_room = w.label_strip();
4717 let total_h = wh + top_room;
4718
4719 let name = w.type_name();
4720 let span_full = name == "Trackpad"
4721 || name == "Canvas"
4722 || name == "UsageBar"
4723 || name == "ProgressBar"
4724 || name == "ButtonStrip"
4725 || name == "Spreadsheet"
4726 || name == "Graph";
4727
4728 if span_full {
4729 let margin_x = 2.0 * pad + 12.0;
4730 let x = self.left + margin_x;
4731 let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
4732 let max_h = self.grid.max_height().max(self.content_y);
4733 let y = max_h;
4734
4735 w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
4736 render_widget(pc, w, x, y, clamped_w, total_h, ctx);
4737
4738 let new_bottom = y + total_h;
4739 self.content_y = new_bottom;
4740 for h in &mut self.grid.col_heights {
4741 *h = new_bottom;
4742 }
4743 } else {
4744 let max_h = self.grid.max_height();
4745 if self.content_y > max_h {
4746 for h in &mut self.grid.col_heights {
4747 *h = self.content_y;
4748 }
4749 }
4750
4751 let col = self.grid.next_column();
4752 self.last_col = col;
4753 let x = self.grid.col_lefts[col];
4754 let y = self.grid.col_heights[col];
4755
4756 w.set_row_rect(x, self.grid.col_width);
4757 render_widget(pc, w, x, y, self.grid.col_width, total_h, ctx);
4758 self.grid.col_heights[col] += total_h;
4759 self.content_y = self.grid.max_height();
4760 }
4761 }
4762
4763 pub fn widget_full<T: WidgetHost + 'static>(&mut self, pc: &mut dyn RenderTarget, w: &mut T, wh: f32, ctx: &mut UiContext) {
4764 let x_off = 12.0;
4765 let ww = self.cw - 2.0 * (self.padding() + x_off);
4766 self.widget(pc, w, x_off, ww, wh, ctx);
4767 }
4768
4769 pub fn separator(&mut self, pc: &mut dyn RenderTarget) {
4770 let pad = self.padding();
4771 let x = self.ax(pad);
4772 let max_h = self.grid.max_height().max(self.content_y);
4773 let y = max_h;
4774 pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
4775 self.content_y = max_h + 8.0;
4776 for h in &mut self.grid.col_heights {
4777 *h = self.content_y;
4778 }
4779 }
4780
4781 pub fn rect(&mut self, pc: &mut dyn RenderTarget, color: [f32; 4], x_off: f32, w: f32, h: f32) {
4782 let max_h = self.grid.max_height().max(self.content_y);
4783 pc.rect(color, self.ax(x_off), max_h, w, h);
4784 self.content_y = max_h + h;
4785 for col_h in &mut self.grid.col_heights {
4786 *col_h = self.content_y;
4787 }
4788 }
4789
4790 pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
4791 let margin_x = self.content_margin();
4792 let usable_w = self.content_width();
4793 if count == 0 {
4794 return Vec::new();
4795 }
4796 let total_gap = gap * (count - 1) as f32;
4797 let col_w = (usable_w - total_gap).max(0.0) / count as f32;
4798
4799 let mut cols = Vec::with_capacity(count);
4800 for i in 0..count {
4801 let x = self.left + margin_x + i as f32 * (col_w + gap);
4802 cols.push((x, col_w));
4803 }
4804 cols
4805 }
4806
4807 pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
4808 where
4809 F: FnMut(usize, f32, f32),
4810 {
4811 let max_h = self.grid.max_height().max(self.content_y);
4812 for col_h in &mut self.grid.col_heights {
4813 *col_h = max_h;
4814 }
4815 self.content_y = max_h;
4816
4817 let cols = self.row_layout(count, gap);
4818 for (i, &(x, w)) in cols.iter().enumerate() {
4819 f(i, x, w);
4820 }
4821 self.content_y += h;
4822
4823 for col_h in &mut self.grid.col_heights {
4824 *col_h = self.content_y;
4825 }
4826 }
4827
4828 pub fn finish(&mut self, pc: &mut dyn RenderTarget) -> f32 {
4829 self.finish_focused(pc, false)
4830 }
4831
4832 pub fn finish_focused(&mut self, pc: &mut dyn RenderTarget, focused: bool) -> f32 {
4833 let border: [f32; 4] = if self.is_child {
4834 if focused {
4835 [0.22, 0.38, 0.24, 1.0]
4836 } else {
4837 [0.18, 0.18, 0.25, 1.0]
4838 }
4839 } else {
4840 if focused {
4841 [0.30, 0.50, 0.32, 1.0]
4842 } else {
4843 [0.25, 0.25, 0.35, 1.0]
4844 }
4845 };
4846 let pad = self.padding();
4847 let x = self.left + pad;
4848 let y = self.top + 7.0;
4849 let w = self.cw - 2.0 * pad;
4850 let h = self.content_y - y;
4851
4852 let left_edge = x;
4853 let right_edge = x + w;
4854 if self.label_width > 0.0 {
4855 let label_x = if self.is_child {
4856 let base_x = match nested_section_label_alignment() {
4857 0 => self.left + 12.0,
4858 1 => self.left + (self.cw - self.label_width) / 2.0,
4859 2 => self.left + self.cw - 12.0 - self.label_width,
4860 _ => self.left + 12.0,
4861 };
4862 base_x + nested_section_label_offset()
4863 } else {
4864 self.left + (self.cw - self.label_width) / 2.0
4865 };
4866 let gap_margin = 6.0;
4867 let gap_start = label_x - gap_margin;
4868 let gap_end = label_x + self.label_width + gap_margin;
4869 if gap_start > left_edge {
4870 pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
4871 }
4872 if right_edge > gap_end {
4873 pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
4874 }
4875 } else {
4876 pc.rect(border, left_edge, y, w, 1.0);
4877 }
4878
4879 let extra_bottom = pad + 12.0;
4880 pc.rect(border, x, y + h + extra_bottom, w, 1.0);
4881 pc.rect(border, x, y, 1.0, h + extra_bottom);
4882 pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
4883 self.content_y + extra_bottom + 8.0
4884 }
4885
4886 pub fn vstack<'a>(&'a mut self, pc: &'a mut dyn RenderTarget, spacing: f32) -> SectionVStack<'a> {
4887 SectionVStack {
4888 section: self,
4889 pc,
4890 spacing,
4891 }
4892 }
4893 }
4894
4895 pub struct SectionVStack<'a> {
4896 section: &'a mut Section,
4897 pc: &'a mut dyn RenderTarget,
4898 spacing: f32,
4899 }
4900
4901 impl<'a> SectionVStack<'a> {
4902 pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, ww: f32, wh: f32, ctx: &mut UiContext) {
4903 self.section.widget(self.pc, w, Section::DEFAULT_MARGIN_X, ww, wh, ctx);
4904 self.section.spacing(self.spacing);
4905 }
4906
4907 pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, f: F)
4908 where
4909 F: FnMut(usize, f32, f32),
4910 {
4911 self.section.row(count, gap, h, f);
4912 self.section.spacing(self.spacing);
4913 }
4914 }
4915
4916
4917 pub struct SplitterLayout {
4918 pub splitter1_x: f32,
4919 pub splitter2_x: f32,
4920 pub splitter_width: f32,
4921 pub min_column_width: f32,
4922 }
4923
4924 impl SplitterLayout {
4925 pub fn new(width: f32, splitter_width: f32, min_column_width: f32) -> Self {
4926 let s1 = (width - 2.0 * splitter_width) / 3.0;
4927 let s2 = s1 + splitter_width + (width - 2.0 * splitter_width) / 3.0;
4928 Self {
4929 splitter1_x: s1,
4930 splitter2_x: s2,
4931 splitter_width,
4932 min_column_width,
4933 }
4934 }
4935
4936 pub fn clamp(&mut self, total_width: f32, detached_circular_network: bool) {
4937 if detached_circular_network {
4938 let min_s2 = self.min_column_width;
4939 let max_s2 = (total_width - self.min_column_width).max(min_s2);
4940 self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
4941 } else {
4942 let min_s1 = self.min_column_width;
4943 let max_s1 = (self.splitter2_x - self.splitter_width - self.min_column_width).max(min_s1);
4944 self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
4945 let min_s2 = self.splitter1_x + self.splitter_width + self.min_column_width;
4946 let max_s2 = (total_width - self.min_column_width).max(min_s2);
4947 self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
4948 }
4949 }
4950
4951 pub fn scale(&mut self, factor: f32) {
4952 self.splitter1_x *= factor;
4953 self.splitter2_x *= factor;
4954 }
4955
4956 pub fn left_col(&self) -> (f32, f32) { // (x, width)
4957 (0.0, self.splitter1_x)
4958 }
4959
4960 pub fn center_col(&self) -> (f32, f32) { // (x, width)
4961 let x = self.splitter1_x + self.splitter_width;
4962 (x, self.splitter2_x - x)
4963 }
4964
4965 pub fn right_col(&self, total_width: f32) -> (f32, f32) { // (x, width)
4966 let x = self.splitter2_x + self.splitter_width;
4967 (x, (total_width - x).max(0.0))
4968 }
4969 }
4970
4971 #[derive(Debug, Clone)]
4972 pub struct Grid {
4973 pub left: f32,
4974 pub top: f32,
4975 pub width: f32,
4976 pub col_width: f32,
4977 pub gap: f32,
4978 pub col_heights: Vec<f32>,
4979 pub col_lefts: Vec<f32>,
4980 }
4981
4982 impl Grid {
4983 pub fn new(left: f32, top: f32, width: f32, min_col_width: f32, gap: f32, count: usize) -> Self {
4984 let total_gap = gap * (count - 1) as f32;
4985 let col_width = if count > 0 {
4986 (width - total_gap).max(0.0) / count as f32
4987 } else {
4988 min_col_width
4989 };
4990 let left_offset = 0.0;
4991
4992 let mut col_lefts = Vec::with_capacity(count);
4993 let col_heights = vec![top; count];
4994 for i in 0..count {
4995 col_lefts.push(left + left_offset + i as f32 * (col_width + gap));
4996 }
4997
4998 Self {
4999 left,
5000 top,
5001 width,
5002 col_width,
5003 gap,
5004 col_heights,
5005 col_lefts,
5006 }
5007 }
5008
5009 pub fn next_column(&self) -> usize {
5010 let mut min_idx = 0;
5011 let mut min_h = self.col_heights[0];
5012 for i in 1..self.col_heights.len() {
5013 if self.col_heights[i] < min_h {
5014 min_h = self.col_heights[i];
5015 min_idx = i;
5016 }
5017 }
5018 min_idx
5019 }
5020
5021 pub fn max_height(&self) -> f32 {
5022 let mut max_h = self.col_heights[0];
5023 for i in 1..self.col_heights.len() {
5024 if self.col_heights[i] > max_h {
5025 max_h = self.col_heights[i];
5026 }
5027 }
5028 max_h
5029 }
5030 }
5031
5032 pub struct CircularPaneLayout {
5033 pub x: f32,
5034 pub y: f32,
5035 pub r: f32,
5036 }
5037
5038 impl CircularPaneLayout {
5039 pub fn new(x: f32, y: f32, r: f32) -> Self {
5040 Self { x, y, r }
5041 }
5042
5043 pub fn hit_test_content(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
5044 let dx = cx - self.x;
5045 let dy = cy - self.y;
5046 let dist_sq = dx * dx + dy * dy;
5047 dist_sq <= self.r * self.r && cy >= self.y - self.r + 45.0 + menubar_h + breadcrumb_h
5048 }
5049
5050 pub fn hit_test_menubar(&self, cx: f32, cy: f32, menubar_h: f32) -> bool {
5051 let dx = cx - self.x;
5052 let dy = cy - self.y;
5053 let dist = (dx * dx + dy * dy).sqrt();
5054 dist >= self.r - menubar_h && dist <= self.r && cy < self.y
5055 }
5056
5057 pub fn hit_test_breadcrumb(&self, cx: f32, cy: f32, menubar_h: f32, breadcrumb_h: f32) -> bool {
5058 let dx = cx - self.x;
5059 let dy = cy - self.y;
5060 let dist_sq = dx * dx + dy * dy;
5061 dist_sq <= self.r * self.r && cy >= self.y - self.r + menubar_h && cy < self.y - self.r + 45.0 + menubar_h + breadcrumb_h
5062 }
5063
5064 pub fn hit_test_border(&self, cx: f32, cy: f32, border_thickness: f32) -> bool {
5065 let dx = cx - self.x;
5066 let dy = cy - self.y;
5067 let dist = (dx * dx + dy * dy).sqrt();
5068 dist >= self.r - border_thickness && dist <= self.r
5069 }
5070 }
5071
5072 #[derive(Debug, Clone)]
5073 pub struct Radial {
5074 pub center_x: f32,
5075 pub center_y: f32,
5076 pub aspect_ratio: f32,
5077 pub base_spacing: f32,
5078 }
5079
5080 impl Radial {
5081 pub fn new(center_x: f32, center_y: f32, aspect_ratio: f32, base_spacing: f32) -> Self {
5082 Self { center_x, center_y, aspect_ratio, base_spacing }
5083 }
5084
5085 pub fn widget_rect(&self, idx: usize, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
5086 if idx == 0 {
5087 (self.center_x - ww / 2.0, self.center_y - wh / 2.0, ww, wh)
5088 } else {
5089 let mut ring = 1;
5090 let mut ring_start = 1;
5091 loop {
5092 let ring_capacity = ring * 6;
5093 if idx < ring_start + ring_capacity {
5094 let pos_in_ring = idx - ring_start;
5095 let angle = (pos_in_ring as f32) * (2.0 * std::f32::consts::PI / ring_capacity as f32);
5096 let radius = (ring as f32) * self.base_spacing;
5097
5098 let x_offset = radius * angle.cos() * self.aspect_ratio;
5099 let y_offset = radius * angle.sin();
5100
5101 return (
5102 self.center_x + x_offset - ww / 2.0,
5103 self.center_y + y_offset - wh / 2.0,
5104 ww,
5105 wh,
5106 );
5107 }
5108 ring_start += ring_capacity;
5109 ring += 1;
5110 }
5111 }
5112 }
5113
5114 }
5115
5116
5117
5118 pub trait LayoutStrategy: std::fmt::Debug {
5119 fn init(&mut self, left: f32, top: f32, width: f32, height: f32);
5120 fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32);
5121 fn set_section_count(&mut self, _count: usize) {}
5122 fn get_column_width(&self) -> Option<f32> { None }
5123 fn get_gap(&self) -> f32 { 20.0 }
5124
5125 fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32;
5126 fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size;
5127 fn box_clone(&self) -> Box<dyn LayoutStrategy>;
5128 }
5129
5130 impl Clone for Box<dyn LayoutStrategy> {
5131 fn clone(&self) -> Self {
5132 self.box_clone()
5133 }
5134 }
5135
5136 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5137 pub enum FlexDirection {
5138 Row,
5139 Column,
5140 }
5141
5142 #[derive(Debug, Clone)]
5143 pub struct FlexLayout {
5144 left: f32,
5145 top: f32,
5146 width: f32,
5147 height: f32,
5148 direction: FlexDirection,
5149 spacing: f32,
5150 current_x: f32,
5151 current_y: f32,
5152 }
5153
5154 impl FlexLayout {
5155 pub fn new(direction: FlexDirection, spacing: f32) -> Self {
5156 Self {
5157 left: 0.0,
5158 top: 0.0,
5159 width: 0.0,
5160 height: 0.0,
5161 direction,
5162 spacing,
5163 current_x: 0.0,
5164 current_y: 0.0,
5165 }
5166 }
5167 }
5168
5169 impl LayoutStrategy for FlexLayout {
5170 fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
5171 self.left = left;
5172 self.top = top;
5173 self.width = width;
5174 self.height = height;
5175 self.current_x = left;
5176 self.current_y = top;
5177 }
5178
5179 fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
5180 match self.direction {
5181 FlexDirection::Row => {
5182 let rx = self.current_x;
5183 let ry = self.current_y;
5184 self.current_x += ww + self.spacing;
5185 (rx, ry, ww, wh)
5186 }
5187 FlexDirection::Column => {
5188 let rx = self.current_x;
5189 let ry = self.current_y;
5190 self.current_y += wh + self.spacing;
5191 (rx, ry, ww, wh)
5192 }
5193 }
5194 }
5195
5196 fn get_gap(&self) -> f32 {
5197 self.spacing
5198 }
5199
5200 fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
5201 let (cur_x, cur_y) = (x, y);
5202 // Blocks: the label row (`label_lead`) above every child's content, the
5203 // gap between blocks, so a row's controls are level and a carve-out tab
5204 // sits a full gap from its neighbour.
5205 let lead = crate::widget::container::container_layout::label_lead(children);
5206 match self.direction {
5207 FlexDirection::Row => {
5208 let mut cur_x = cur_x;
5209 for &child_ptr in children {
5210 unsafe {
5211 let child = &mut *child_ptr;
5212 let child_w = child.rect().2;
5213 let child_h = crate::widget::container::container_layout::content_height(child);
5214 let use_h = if child_h > 0.0 { child_h } else { h };
5215 child.layout(
5216 crate::widget::Point { x: cur_x, y: cur_y + lead },
5217 crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
5218 ctx,
5219 );
5220 cur_x += child_w + self.spacing;
5221 }
5222 }
5223 (cur_x - x).max(0.0)
5224 }
5225 FlexDirection::Column => {
5226 let mut cur_y = cur_y;
5227 for &child_ptr in children {
5228 unsafe {
5229 let child = &mut *child_ptr;
5230 let child_h = crate::widget::container::container_layout::content_height(child);
5231 let use_h = if child_h > 0.0 { child_h } else { 44.0 };
5232 child.layout(
5233 crate::widget::Point { x, y: cur_y + lead },
5234 crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
5235 ctx,
5236 );
5237 cur_y += lead + use_h + self.spacing;
5238 }
5239 }
5240 (cur_y - y).max(0.0)
5241 }
5242 }
5243 }
5244
5245 fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
5246 match self.direction {
5247 FlexDirection::Row => {
5248 let mut total_w = 0.0f32;
5249 let mut max_h = 0.0f32;
5250 for (i, &child_ptr) in children.iter().enumerate() {
5251 unsafe {
5252 let size = (*child_ptr).measure(constraints, ctx);
5253 total_w += size.width;
5254 max_h = max_h.max(size.height);
5255 if i > 0 {
5256 total_w += self.spacing;
5257 }
5258 }
5259 }
5260 crate::widget::Size {
5261 width: total_w.clamp(constraints.min_width, constraints.max_width),
5262 height: max_h.clamp(constraints.min_height, constraints.max_height),
5263 }
5264 }
5265 FlexDirection::Column => {
5266 let mut total_h = 0.0f32;
5267 let mut max_w = 0.0f32;
5268 for (i, &child_ptr) in children.iter().enumerate() {
5269 unsafe {
5270 let size = (*child_ptr).measure(constraints, ctx);
5271 total_h += size.height;
5272 max_w = max_w.max(size.width);
5273 if i > 0 {
5274 total_h += self.spacing;
5275 }
5276 }
5277 }
5278 crate::widget::Size {
5279 width: max_w.clamp(constraints.min_width, constraints.max_width),
5280 height: total_h.clamp(constraints.min_height, constraints.max_height),
5281 }
5282 }
5283 }
5284 }
5285
5286 fn box_clone(&self) -> Box<dyn LayoutStrategy> {
5287 Box::new(self.clone())
5288 }
5289 }
5290
5291 #[derive(Debug, Clone)]
5292 pub struct ColumnLayout {
5293 left: f32,
5294 top: f32,
5295 width: f32,
5296 current_y: f32,
5297 gap: f32,
5298 }
5299
5300 impl ColumnLayout {
5301 pub fn new(gap: f32) -> Self {
5302 Self {
5303 left: 0.0,
5304 top: 0.0,
5305 width: 0.0,
5306 current_y: 0.0,
5307 gap,
5308 }
5309 }
5310 }
5311
5312 impl LayoutStrategy for ColumnLayout {
5313 fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
5314 self.left = left;
5315 self.top = top;
5316 self.width = width;
5317 self.current_y = top;
5318 }
5319
5320 fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
5321 let x = self.left;
5322 let y = self.current_y;
5323 self.current_y += wh + self.gap;
5324 (x, y, ww, wh)
5325 }
5326
5327 fn get_column_width(&self) -> Option<f32> {
5328 Some(self.width)
5329 }
5330
5331 fn get_gap(&self) -> f32 {
5332 self.gap
5333 }
5334
5335 fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
5336 let mut cur_y = y;
5337 for &child_ptr in children {
5338 unsafe {
5339 let child = &mut *child_ptr;
5340 let child_h = child.preferred_height().unwrap_or(child.rect().3);
5341 let use_h = if child_h > 0.0 { child_h } else { 44.0 };
5342 child.set_rect(x, cur_y, w, use_h);
5343 cur_y += use_h + self.gap;
5344 }
5345 }
5346 (cur_y - y).max(0.0)
5347 }
5348
5349 fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
5350 let mut total_h = 0.0f32;
5351 let mut max_w = 0.0f32;
5352 for (i, &child_ptr) in children.iter().enumerate() {
5353 unsafe {
5354 let size = (*child_ptr).measure(constraints, ctx);
5355 total_h += size.height;
5356 max_w = max_w.max(size.width);
5357 if i > 0 {
5358 total_h += self.gap;
5359 }
5360 }
5361 }
5362 crate::widget::Size {
5363 width: max_w.clamp(constraints.min_width, constraints.max_width),
5364 height: total_h.clamp(constraints.min_height, constraints.max_height),
5365 }
5366 }
5367
5368 fn box_clone(&self) -> Box<dyn LayoutStrategy> {
5369 Box::new(self.clone())
5370 }
5371 }
5372
5373 #[derive(Debug, Clone)]
5374 pub struct AdaptiveGrid {
5375 grid: Option<Grid>,
5376 #[allow(dead_code)]
5377 min_col_width: f32,
5378 #[allow(dead_code)]
5379 gap: f32,
5380 num_sections: Option<usize>,
5381 }
5382
5383 impl AdaptiveGrid {
5384 pub fn new(min_col_width: f32, gap: f32) -> Self {
5385 Self {
5386 grid: None,
5387 min_col_width,
5388 gap,
5389 num_sections: None,
5390 }
5391 }
5392 }
5393
5394 impl LayoutStrategy for AdaptiveGrid {
5395 fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
5396 let min_col_width = crate::layout::grid_min_col_width();
5397 let gap = crate::layout::grid_gap();
5398 let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
5399 let count = if let Some(n) = self.num_sections {
5400 n.min(max_cols).max(1)
5401 } else {
5402 max_cols
5403 };
5404 self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
5405 }
5406
5407 fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
5408 if let Some(ref mut grid) = self.grid {
5409 let num_cols = grid.col_heights.len();
5410 let num_cols_spanned = (((ww + grid.gap) / (grid.col_width + grid.gap)).round() as usize)
5411 .min(num_cols)
5412 .max(1);
5413
5414 if num_cols_spanned >= num_cols {
5415 let y = grid.max_height();
5416 let x = grid.left;
5417 let allocated_w = grid.width;
5418 for col_h in &mut grid.col_heights {
5419 *col_h = y + wh + grid.gap;
5420 }
5421 (x, y, allocated_w, wh)
5422 } else if num_cols_spanned == 1 {
5423 let col = grid.next_column();
5424 let x = grid.col_lefts[col];
5425 let y = grid.col_heights[col];
5426 grid.col_heights[col] += wh + grid.gap;
5427 (x, y, grid.col_width, wh)
5428 } else {
5429 let n = num_cols_spanned;
5430 let mut best_start_col = 0;
5431 let mut min_max_h = f32::MAX;
5432 for c in 0..=(num_cols - n) {
5433 let mut max_h = 0.0f32;
5434 for i in 0..n {
5435 if grid.col_heights[c + i] > max_h {
5436 max_h = grid.col_heights[c + i];
5437 }
5438 }
5439 if max_h < min_max_h {
5440 min_max_h = max_h;
5441 best_start_col = c;
5442 }
5443 }
5444 let x = grid.col_lefts[best_start_col];
5445 let y = min_max_h;
5446 let allocated_w = n as f32 * grid.col_width + (n - 1) as f32 * grid.gap;
5447 for i in 0..n {
5448 grid.col_heights[best_start_col + i] = y + wh + grid.gap;
5449 }
5450 (x, y, allocated_w, wh)
5451 }
5452 } else {
5453 (0.0, 0.0, 0.0, wh)
5454 }
5455 }
5456
5457 fn set_section_count(&mut self, count: usize) {
5458 self.num_sections = Some(count);
5459 }
5460
5461 fn get_column_width(&self) -> Option<f32> {
5462 self.grid.as_ref().map(|g| g.col_width)
5463 }
5464
5465 fn get_gap(&self) -> f32 {
5466 crate::layout::grid_gap()
5467 }
5468
5469 fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
5470 let usable_w = w.max(1.0);
5471 let min_col_width = crate::layout::grid_min_col_width();
5472 let gap = crate::layout::grid_gap();
5473 let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
5474 let count = if let Some(n) = self.num_sections {
5475 n.min(cols).max(1)
5476 } else {
5477 cols
5478 };
5479
5480 let total_gap = gap * (count - 1) as f32;
5481 let available_w = (w - total_gap).max(1.0);
5482 let col_w = available_w / count as f32;
5483
5484 let mut col_heights = vec![y; count];
5485
5486 for &child_ptr in children {
5487 unsafe {
5488 let child = &mut *child_ptr;
5489 let ch = child.preferred_height().unwrap_or(child.rect().3);
5490 let use_h = if ch > 0.0 { ch } else { 44.0 };
5491
5492 let mut min_col = 0;
5493 let mut min_h = col_heights[0];
5494 for i in 1..count {
5495 if col_heights[i] < min_h {
5496 min_h = col_heights[i];
5497 min_col = i;
5498 }
5499 }
5500
5501 let cx = x + min_col as f32 * (col_w + gap);
5502 let cy = col_heights[min_col];
5503 child.set_rect(cx, cy, col_w, use_h);
5504 col_heights[min_col] += use_h + gap;
5505 }
5506 }
5507
5508 let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
5509 (max_h - y).max(0.0)
5510 }
5511
5512 fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
5513 let usable_w = constraints.max_width.max(1.0);
5514 let min_col_width = crate::layout::grid_min_col_width();
5515 let gap = crate::layout::grid_gap();
5516 let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
5517 let count = if let Some(n) = self.num_sections {
5518 n.min(cols).max(1)
5519 } else {
5520 cols
5521 };
5522
5523 let mut col_heights = vec![0.0f32; count];
5524 let total_gap = gap * (count - 1) as f32;
5525 let available_w = (constraints.max_width - total_gap).max(1.0);
5526 let col_w = available_w / count as f32;
5527
5528 let child_constraints = crate::widget::LayoutConstraints::new(col_w, col_w, constraints.min_height, constraints.max_height);
5529
5530 for &child_ptr in children {
5531 unsafe {
5532 let size = (*child_ptr).measure(child_constraints, ctx);
5533 let mut min_col = 0;
5534 let mut min_h = col_heights[0];
5535 for i in 1..count {
5536 if col_heights[i] < min_h {
5537 min_h = col_heights[i];
5538 min_col = i;
5539 }
5540 }
5541 col_heights[min_col] += size.height + gap;
5542 }
5543 }
5544
5545 let max_h = col_heights.iter().cloned().fold(0.0f32, |a, b| a.max(b));
5546 crate::widget::Size {
5547 width: constraints.max_width,
5548 height: max_h.clamp(constraints.min_height, constraints.max_height),
5549 }
5550 }
5551
5552 fn box_clone(&self) -> Box<dyn LayoutStrategy> {
5553 Box::new(self.clone())
5554 }
5555 }
5556
5557 #[derive(Debug, Clone)]
5558 pub struct RadialLayout {
5559 radial: Option<Radial>,
5560 aspect_ratio: f32,
5561 base_spacing: f32,
5562 idx: usize,
5563 }
5564
5565 impl RadialLayout {
5566 pub fn new(aspect_ratio: f32, base_spacing: f32) -> Self {
5567 Self {
5568 radial: None,
5569 aspect_ratio,
5570 base_spacing,
5571 idx: 0,
5572 }
5573 }
5574 }
5575
5576 impl LayoutStrategy for RadialLayout {
5577 fn init(&mut self, left: f32, top: f32, width: f32, height: f32) {
5578 let cx = left + width / 2.0;
5579 let cy = top + height / 2.0;
5580 let aspect = if self.aspect_ratio > 0.0 {
5581 self.aspect_ratio
5582 } else {
5583 let screen_aspect = (width / height.max(1.0)).max(0.1);
5584 1.0 + (screen_aspect - 1.0) * 0.4
5585 };
5586 self.radial = Some(Radial::new(cx, cy, aspect, self.base_spacing));
5587 self.idx = 0;
5588 }
5589
5590 fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
5591 if let Some(ref radial) = self.radial {
5592 let rect = radial.widget_rect(self.idx, ww, wh);
5593 self.idx += 1;
5594 rect
5595 } else {
5596 (0.0, 0.0, ww, wh)
5597 }
5598 }
5599
5600 fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
5601 let cx = x + w / 2.0;
5602 let cy = y + h / 2.0;
5603 let aspect = if self.aspect_ratio > 0.0 {
5604 self.aspect_ratio
5605 } else {
5606 let screen_aspect = (w / h.max(1.0)).max(0.1);
5607 1.0 + (screen_aspect - 1.0) * 0.4
5608 };
5609 let radial = Radial::new(cx, cy, aspect, self.base_spacing);
5610 for (idx, &child_ptr) in children.iter().enumerate() {
5611 unsafe {
5612 let child = &mut *child_ptr;
5613 let cw = child.rect().2;
5614 let ch = child.preferred_height().unwrap_or(child.rect().3);
5615 let use_h = if ch > 0.0 { ch } else { 44.0 };
5616 let (rx, ry, rw, rh) = radial.widget_rect(idx, cw, use_h);
5617 child.set_rect(rx, ry, rw, rh);
5618 }
5619 }
5620 h
5621 }
5622
5623 fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
5624 let cx = constraints.max_width / 2.0;
5625 let cy = constraints.max_height / 2.0;
5626 let aspect = if self.aspect_ratio > 0.0 {
5627 self.aspect_ratio
5628 } else {
5629 let screen_aspect = (constraints.max_width / constraints.max_height.max(1.0)).max(0.1);
5630 1.0 + (screen_aspect - 1.0) * 0.4
5631 };
5632 let radial = Radial::new(cx, cy, aspect, self.base_spacing);
5633 let mut max_w = 0.0f32;
5634 let mut max_h = 0.0f32;
5635 for (idx, &child_ptr) in children.iter().enumerate() {
5636 unsafe {
5637 let size = (*child_ptr).measure(constraints, ctx);
5638 let (rx, ry, rw, rh) = radial.widget_rect(idx, size.width, size.height);
5639 max_w = max_w.max(rx + rw);
5640 max_h = max_h.max(ry + rh);
5641 }
5642 }
5643 crate::widget::Size {
5644 width: max_w.clamp(constraints.min_width, constraints.max_width),
5645 height: max_h.clamp(constraints.min_height, constraints.max_height),
5646 }
5647 }
5648
5649 fn box_clone(&self) -> Box<dyn LayoutStrategy> {
5650 Box::new(self.clone())
5651 }
5652 }
5653
5654 /// Vertical slack for a section's content clip. The clip exists to stop
5655 /// content escaping its section SIDEWAYS, which is the axis a section's width
5656 /// actually fixes; a section's height is only known once its content has been
5657 /// placed, so bounding that axis too would risk cutting content off rather
5658 /// than keeping it in. Deliberately far larger than any section.
5659 const SECTION_CLIP_SLACK: f32 = 100_000.0;
5660
5661 pub struct PageLayoutBuilder<'a, P> {
5662 pub strategy: &'a mut dyn LayoutStrategy,
5663 pub cx: f32,
5664 pub cy: f32,
5665 pub cw: f32,
5666 pub ch: f32,
5667 pub section_width: f32,
5668 pub idx: usize,
5669 _phantom: std::marker::PhantomData<P>,
5670 }
5671
5672 impl<'a, P: RenderTarget + Default> PageLayoutBuilder<'a, P> {
5673 pub fn new(
5674 strategy: &'a mut dyn LayoutStrategy,
5675 cx: f32,
5676 cy: f32,
5677 cw: f32,
5678 ch: f32,
5679 section_width: f32,
5680 ) -> Self {
5681 strategy.init(cx, cy, cw, ch);
5682 Self {
5683 strategy,
5684 cx,
5685 cy,
5686 cw,
5687 ch,
5688 section_width,
5689 idx: 0,
5690 _phantom: std::marker::PhantomData,
5691 }
5692 }
5693
5694 pub fn with_section_count(self, count: usize) -> Self {
5695 self.strategy.set_section_count(count);
5696 self.strategy.init(self.cx, self.cy, self.cw, self.ch);
5697 self
5698 }
5699
5700 pub fn add_section<F>(&mut self, final_pc: &mut P, label: &str, focused: bool, mut render_fn: F)
5701 where
5702 F: FnMut(&mut SectionContext<'_, P>),
5703 {
5704 let width = self.strategy.get_column_width().unwrap_or(self.section_width);
5705 let mut dummy = P::default();
5706 let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
5707 render_fn(&mut dummy_ctx);
5708 let wh = dummy_ctx.finish();
5709 let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
5710 let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
5711 let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
5712 real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
5713 render_fn(&mut real_ctx);
5714 real_ctx.pc.pop_clip_rect();
5715 real_ctx.finish();
5716 self.idx += 1;
5717 }
5718
5719 pub fn add_section_with_width<F>(&mut self, final_pc: &mut P, width: f32, label: &str, focused: bool, mut render_fn: F)
5720 where
5721 F: FnMut(&mut SectionContext<'_, P>),
5722 {
5723 let mut dummy = P::default();
5724 let mut dummy_ctx = SectionContext::new(&mut dummy, 0.0, 0.0, width, label, focused, false);
5725 render_fn(&mut dummy_ctx);
5726 let wh = dummy_ctx.finish();
5727 let (rx, ry, rw, _) = self.strategy.allocate(width, wh);
5728 let mut real_ctx = SectionContext::new(final_pc, rx, ry, rw, label, focused, false);
5729 let (clip_x, clip_w) = (real_ctx.content_left(), real_ctx.content_width());
5730 real_ctx.pc.push_clip_rect(clip_x, ry - SECTION_CLIP_SLACK, clip_w, 2.0 * SECTION_CLIP_SLACK);
5731 render_fn(&mut real_ctx);
5732 real_ctx.pc.pop_clip_rect();
5733 real_ctx.finish();
5734 self.idx += 1;
5735 }
5736
5737 pub fn add_section_spanned<F>(&mut self, final_pc: &mut P, label: &str, span: usize, focused: bool, render_fn: F)
5738 where
5739 F: FnMut(&mut SectionContext<'_, P>),
5740 {
5741 let col_width = self.strategy.get_column_width().unwrap_or(self.section_width);
5742 let gap = self.strategy.get_gap();
5743 let width = span as f32 * col_width + (span - 1) as f32 * gap;
5744 self.add_section_with_width(final_pc, width, label, focused, render_fn);
5745 }
5746 }
5747
5748 pub struct SectionContext<'a, P> {
5749 pub pc: &'a mut P,
5750 pub left: f32,
5751 pub top: f32,
5752 pub content_y: f32,
5753 pub cw: f32,
5754 pub label_width: f32,
5755 pub label_x: f32,
5756 /// Whether the host renders sections as sunken wells (`section_relief_style`).
5757 pub relief_style: bool,
5758 /// The title tab box (x, y, w, h) when the host's relief styling laid the
5759 /// label out left-aligned — `finish` offers it with the section carve.
5760 /// None under relief styling means a label-less section: the well carves
5761 /// tabless, flush with the allocation top.
5762 pub relief_tab: Option<(f32, f32, f32, f32)>,
5763 pub focused: bool,
5764 pub is_child: bool,
5765 pub grid: Grid,
5766 pub last_col: usize,
5767 pub content_start_y: f32,
5768 pub row_gap: f32,
5769 }
5770
5771 impl<'a, P: RenderTarget> SectionContext<'a, P> {
5772 pub const DEFAULT_MARGIN_X: f32 = 12.0;
5773 pub const DEFAULT_ROW_GAP: f32 = 8.0;
5774
5775 fn estimate_label_width(label: &str, font_size: f32, font_fam: &str) -> f32 {
5776 estimate_label_width_helper(label, font_size, font_fam)
5777 }
5778
5779 pub fn padding(&self) -> f32 {
5780 section_padding()
5781 }
5782
5783 pub fn new(pc: &'a mut P, left: f32, top: f32, cw: f32, label: &str, focused: bool, is_child: bool) -> Self {
5784 let font_setting = if is_child {
5785 nested_section_label_font()
5786 } else {
5787 section_label_font()
5788 };
5789 let (font_fam, font_size_opt) = parse_font_string(&font_setting);
5790 let font_size = font_size_opt.unwrap_or(if is_child { 12.0 } else { 14.0 });
5791 let font_color = if is_child { [0.53, 0.53, 0.60, 1.0] } else { [0.83, 0.83, 0.83, 1.0] };
5792 let label_width = Self::estimate_label_width(label, font_size, &font_fam);
5793 let relief_style = pc.section_relief_style();
5794 let label_x = if is_child {
5795 let base_x = match nested_section_label_alignment() {
5796 0 => left + 12.0,
5797 1 => left + (cw - label_width) / 2.0,
5798 2 => left + cw - 12.0 - label_width,
5799 _ => left + 12.0,
5800 };
5801 base_x + nested_section_label_offset()
5802 } else if relief_style {
5803 // Sunken style: the title sits in a tab flush with the well's left
5804 // edge (the designer look), not centered on the border.
5805 left + 12.0
5806 } else {
5807 left + (cw - label_width) / 2.0
5808 };
5809 // Under relief styling the well fills the whole allocated rect (so the
5810 // page's gaps and margins are the visual gaps) — the tab tops the
5811 // allocation and the label centers inside it.
5812 let label_y = if relief_style { top + 4.0 } else { top };
5813 pc.text_with_font(label, label_x, label_y, font_size, font_color, &font_fam);
5814
5815 // The tab wraps the label, flush on the well's top-left corner; clamped
5816 // so off-default child alignments can't push it outside the well.
5817 let relief_tab = if relief_style && label_width > 0.0 {
5818 let tab_x = (label_x - 12.0).max(left);
5819 Some((tab_x, top, label_width + 24.0, font_size + 10.0))
5820 } else {
5821 None
5822 };
5823
5824 let pad = section_padding();
5825 let margin_x = 2.0 * pad + 12.0;
5826 let usable_w = (cw - 2.0 * margin_x).max(1.0);
5827 let min_col_width = 130.0;
5828 let gap = 8.0;
5829 let max_cols = if is_child {
5830 1
5831 } else {
5832 ((usable_w + gap) / (min_col_width + gap)).floor().max(1.0).min(2.0) as usize
5833 };
5834 let content_start_y = top + pad + 19.0;
5835 let grid = Grid::new(left + margin_x, content_start_y, usable_w, min_col_width, gap, max_cols);
5836
5837 Self {
5838 pc,
5839 left,
5840 top,
5841 content_y: content_start_y,
5842 cw,
5843 label_width,
5844 label_x,
5845 relief_style,
5846 relief_tab,
5847 focused,
5848 is_child,
5849 grid,
5850 last_col: usize::MAX,
5851 content_start_y,
5852 row_gap: Self::DEFAULT_ROW_GAP,
5853 }
5854 }
5855
5856 pub fn with_row_gap(mut self, gap: f32) -> Self {
5857 self.row_gap = gap;
5858 self
5859 }
5860
5861 /// The section's content-box top edge: the body well's top (the tab's
5862 /// bottom) under relief styling, the outline's border line otherwise. Lets
5863 /// a page place content at an exact inset from the well's walls.
5864 pub fn well_top(&self) -> f32 {
5865 if self.relief_style {
5866 self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top)
5867 } else {
5868 self.top + 7.0
5869 }
5870 }
5871
5872 pub fn set_row_gap(&mut self, gap: f32) {
5873 self.row_gap = gap;
5874 }
5875
5876 /// Horizontal inset of section CONTENT from the section's left edge.
5877 ///
5878 /// One number, used by every content placer in here — `row_layout`, the
5879 /// column `Grid`, `widget`, `VStack` and `ax` (so `text`) — because they
5880 /// share a section and have to line up inside it. The section's border is
5881 /// drawn at `left + padding()` (see `finish`), so content clears the
5882 /// border by `padding() + 12`.
5883 pub fn content_margin(&self) -> f32 {
5884 2.0 * self.padding() + 12.0
5885 }
5886
5887 /// Left edge of the content box: where a row, a widget or a `text(_, 12.0,
5888 /// ..)` starts.
5889 pub fn content_left(&self) -> f32 {
5890 self.left + self.content_margin()
5891 }
5892
5893 /// Width of the content box — the section's width less the inset on both
5894 /// sides. Nothing a section draws should extend past `content_left() +
5895 /// content_width()`.
5896 pub fn content_width(&self) -> f32 {
5897 (self.cw - 2.0 * self.content_margin()).max(0.0)
5898 }
5899
5900 /// `x_off` px into the content box's coordinate space, where 12.0 is the
5901 /// content's own left edge — the offset 46 of the ~55 call sites in the
5902 /// tree already pass, and the one that lines text up with the buttons and
5903 /// widgets beside it.
5904 ///
5905 /// This used to add `padding()` only when `x_off >= 12.0`, which made the
5906 /// mapping DISCONTINUOUS: asking for 11 instead of 12 moved the text 5px
5907 /// LEFT rather than 1px, and silently dropped it out of alignment with
5908 /// every row in the same section. `cce-mail` and two others sit on the
5909 /// wrong side of that cliff today.
5910 pub fn ax(&self, x_off: f32) -> f32 {
5911 self.left + 2.0 * self.padding() + x_off
5912 }
5913
5914 pub fn ay(&self) -> f32 {
5915 self.content_y
5916 }
5917
5918 pub fn spacing(&mut self, dy: f32) {
5919 if self.grid.col_heights.len() >= 2 {
5920 if self.last_col == usize::MAX {
5921 for h in &mut self.grid.col_heights {
5922 *h += dy;
5923 }
5924 } else if self.last_col < self.grid.col_heights.len() {
5925 self.grid.col_heights[self.last_col] += dy;
5926 }
5927 self.content_y = self.grid.max_height();
5928 } else {
5929 self.content_y += dy;
5930 for h in &mut self.grid.col_heights {
5931 *h += dy;
5932 }
5933 }
5934 }
5935
5936 pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
5937 let mut y = self.content_y + y_off;
5938 if y > self.content_start_y {
5939 y += self.row_gap;
5940 }
5941 let x = self.ax(x_off);
5942 // Bound it to the content box. A section's text was drawn unbounded,
5943 // so a string wider than its section simply kept going — over the
5944 // border, over whatever sat to the right, and off the window (the
5945 // settings app's GPU names did all three). Rows and widgets have
5946 // always been sized to the section; text was the one thing that could
5947 // leave it. The vertical band is generous on purpose: it is the
5948 // horizontal overrun that has to be cut, and a tight band would
5949 // shave descenders.
5950 let right = self.content_left() + self.content_width();
5951 let bounds = if right > x {
5952 Some([x, y - font_size, right, y + 2.0 * font_size])
5953 } else {
5954 None
5955 };
5956 self.pc.text_with_bounds(text, x, y, font_size, color, bounds);
5957 let new_bottom = y + font_size + 4.0;
5958 self.content_y = new_bottom;
5959 for h in &mut self.grid.col_heights {
5960 *h = new_bottom;
5961 }
5962 }
5963
5964 pub fn widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
5965 if let Some(pref) = w.preferred_height() {
5966 wh = pref;
5967 }
5968 let pad = self.padding();
5969 let top_room = w.label_strip();
5970 let total_h = wh + top_room;
5971
5972 let name = w.type_name();
5973 let span_full = name == "Trackpad"
5974 || name == "Canvas"
5975 || name == "UsageBar"
5976 || name == "ProgressBar"
5977 || name == "ButtonStrip"
5978 || name == "Spreadsheet"
5979 || name == "Graph";
5980
5981 if span_full {
5982 let margin_x = 2.0 * pad + 12.0;
5983 let x = self.left + margin_x;
5984 let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
5985 let mut max_h = self.grid.max_height().max(self.content_y);
5986 if max_h > self.content_start_y {
5987 max_h += self.row_gap;
5988 }
5989 let y = max_h;
5990
5991 w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
5992 render_widget(self.pc, w, x, y, clamped_w, total_h, ctx);
5993
5994 let new_bottom = y + total_h;
5995 self.content_y = new_bottom;
5996 for h in &mut self.grid.col_heights {
5997 *h = new_bottom;
5998 }
5999 } else {
6000 let max_h = self.grid.max_height();
6001 if self.content_y > max_h {
6002 for h in &mut self.grid.col_heights {
6003 *h = self.content_y;
6004 }
6005 }
6006
6007 let col = self.grid.next_column();
6008 self.last_col = col;
6009 let x = self.grid.col_lefts[col];
6010 let mut y = self.grid.col_heights[col];
6011 if y > self.content_start_y {
6012 y += self.row_gap;
6013 }
6014
6015 let aligned_x = x;
6016 let aligned_w = self.grid.col_width;
6017
6018 w.set_row_rect(aligned_x, aligned_w);
6019 render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
6020 self.grid.col_heights[col] = y + total_h;
6021 self.content_y = self.grid.max_height();
6022 }
6023 }
6024
6025 pub fn widget_full<T: WidgetHost + 'static>(&mut self, w: &mut T, wh: f32, ctx: &mut UiContext) {
6026 let x_off = 12.0;
6027 let ww = self.cw - 2.0 * (self.padding() + x_off); // cw - 40.0
6028 self.widget(w, x_off, ww, wh, ctx);
6029 }
6030
6031 pub fn separator(&mut self) {
6032 let pad = self.padding();
6033 let x = self.ax(pad);
6034 let max_h = self.grid.max_height().max(self.content_y);
6035 let y = max_h;
6036 self.pc.rect([0.18, 0.18, 0.27, 1.0], x, y, self.cw - 2.0 * pad, 1.0);
6037 self.content_y = max_h + 8.0;
6038 for h in &mut self.grid.col_heights {
6039 *h = self.content_y;
6040 }
6041 }
6042
6043 pub fn rect(&mut self, color: [f32; 4], x_off: f32, w: f32, h: f32) {
6044 let max_h = self.grid.max_height().max(self.content_y);
6045 self.pc.rect(color, self.ax(x_off), max_h, w, h);
6046 self.content_y = max_h + h;
6047 for col_h in &mut self.grid.col_heights {
6048 *col_h = self.content_y;
6049 }
6050 }
6051
6052 pub fn row_layout(&self, count: usize, gap: f32) -> Vec<(f32, f32)> {
6053 let margin_x = self.content_margin();
6054 let usable_w = self.content_width();
6055 if count == 0 {
6056 return Vec::new();
6057 }
6058 let total_gap = gap * (count - 1) as f32;
6059 let col_w = (usable_w - total_gap).max(0.0) / count as f32;
6060
6061 let mut cols = Vec::with_capacity(count);
6062 for i in 0..count {
6063 let x = self.left + margin_x + i as f32 * (col_w + gap);
6064 cols.push((x, col_w));
6065 }
6066 cols
6067 }
6068
6069 /// A row whose columns are sized to what goes IN them: each gets the width
6070 /// it asked for in `needs`, and whatever is left over is shared equally.
6071 ///
6072 /// `row_layout` splits a row evenly and knows nothing about content, so it
6073 /// hands "Reboot" and "Hibernate" the same width — one floats in slack
6074 /// while the other is cut off, which is what a row of mismatched labels
6075 /// looks like. Sharing the SLACK equally rather than sizing proportionally
6076 /// is deliberate: proportional widths would make a two-character label a
6077 /// sliver, where what is wanted is "everyone fits, then everyone gets the
6078 /// same bonus".
6079 ///
6080 /// When the needs do not fit, every column is scaled by the same factor, so
6081 /// the row still cannot overflow its section and the shortfall is shared
6082 /// rather than landing entirely on the last column.
6083 pub fn row_layout_for(&self, needs: &[f32], gap: f32) -> Vec<(f32, f32)> {
6084 let count = needs.len();
6085 if count == 0 {
6086 return Vec::new();
6087 }
6088 let total_gap = gap * (count - 1) as f32;
6089 let room = (self.content_width() - total_gap).max(0.0);
6090 let total_need: f32 = needs.iter().map(|n| n.max(0.0)).sum();
6091
6092 let widths: Vec<f32> = if total_need <= room {
6093 let extra = (room - total_need) / count as f32;
6094 needs.iter().map(|n| n.max(0.0) + extra).collect()
6095 } else if total_need > 0.0 {
6096 let scale = room / total_need;
6097 needs.iter().map(|n| n.max(0.0) * scale).collect()
6098 } else {
6099 vec![room / count as f32; count]
6100 };
6101
6102 let mut cols = Vec::with_capacity(count);
6103 let mut x = self.content_left();
6104 for w in widths {
6105 cols.push((x, w));
6106 x += w + gap;
6107 }
6108 cols
6109 }
6110
6111
6112 pub fn row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
6113 where
6114 F: FnMut(usize, f32, f32),
6115 {
6116 let max_h = self.grid.max_height().max(self.content_y);
6117 for col_h in &mut self.grid.col_heights {
6118 *col_h = max_h;
6119 }
6120 self.content_y = max_h;
6121
6122 let cols = self.row_layout(count, gap);
6123 for (i, &(x, w)) in cols.iter().enumerate() {
6124 f(i, x, w);
6125 }
6126 self.content_y += h;
6127
6128 for col_h in &mut self.grid.col_heights {
6129 *col_h = self.content_y;
6130 }
6131 }
6132
6133 pub fn vstack(&mut self, spacing: f32) -> VStack<'_, 'a, P> {
6134 VStack {
6135 context: self,
6136 spacing,
6137 }
6138 }
6139
6140 pub fn add_section<F>(&mut self, label: &str, focused: bool, mut render_fn: F)
6141 where
6142 F: FnMut(&mut SectionContext<'_, P>),
6143 {
6144 let pad = self.padding();
6145 let (left, top, cw, is_side_by_side) = if self.grid.col_heights.len() >= 2 {
6146 let col = self.grid.next_column();
6147 self.last_col = col;
6148 let x = self.grid.col_lefts[col];
6149 let mut y = self.grid.col_heights[col];
6150 if y > self.content_start_y {
6151 y += self.row_gap;
6152 }
6153 (x, y, self.grid.col_width, true)
6154 } else {
6155 let left = self.ax(0.0) + pad;
6156 let mut max_h = self.grid.max_height().max(self.content_y);
6157 if max_h > self.content_start_y {
6158 max_h += self.row_gap;
6159 }
6160 let top = max_h;
6161 let cw = self.cw - 2.0 * pad;
6162 (left, top, cw, false)
6163 };
6164
6165 let mut sub_ctx = SectionContext::new(self.pc, left, top, cw, label, focused, true);
6166 render_fn(&mut sub_ctx);
6167 let new_bottom = sub_ctx.finish();
6168
6169 if is_side_by_side {
6170 let col = self.last_col;
6171 self.grid.col_heights[col] = new_bottom;
6172 self.content_y = self.grid.max_height();
6173 } else {
6174 self.content_y = new_bottom;
6175 for h in &mut self.grid.col_heights {
6176 *h = new_bottom;
6177 }
6178 }
6179 }
6180
6181 pub fn finish(self) -> f32 {
6182 let border: [f32; 4] = if self.is_child {
6183 if self.focused {
6184 [0.22, 0.38, 0.24, 1.0]
6185 } else {
6186 [0.18, 0.18, 0.25, 1.0]
6187 }
6188 } else {
6189 if self.focused {
6190 [0.30, 0.50, 0.32, 1.0] // Focused green
6191 } else {
6192 [0.25, 0.25, 0.35, 1.0] // Default gray
6193 }
6194 };
6195 let pad = self.padding();
6196 let x = self.left + pad;
6197 let y = self.top + 7.0;
6198 let w = self.cw - 2.0 * pad;
6199 let h = self.content_y - y;
6200 let extra_bottom = pad + 12.0;
6201 let bottom = y + h + extra_bottom;
6202
6203 if self.relief_style {
6204 // Sunken style: the well spans the full allocated rect — body top
6205 // edge at the tab's bottom (the tab is flush ON the body, the
6206 // designer union shape; label-less sections carve tabless from the
6207 // allocation top), walls on the allocation's edges, and no
6208 // trailing slack so the layout gap IS the visual gap.
6209 let body_y = self.relief_tab.map(|t| t.1 + t.3).unwrap_or(self.top);
6210 let frame = SectionFrame {
6211 x: self.left,
6212 y: body_y,
6213 w: self.cw,
6214 h: bottom - body_y,
6215 tab: self.relief_tab,
6216 focused: self.focused,
6217 is_child: self.is_child,
6218 };
6219 if self.pc.section_relief(&frame) {
6220 return self.content_y + extra_bottom;
6221 }
6222 }
6223
6224 let left_edge = x;
6225 let right_edge = x + w;
6226 if self.label_width > 0.0 {
6227 let label_x = self.label_x;
6228 let gap_margin = 6.0;
6229 let gap_start = label_x - gap_margin;
6230 let gap_end = label_x + self.label_width + gap_margin;
6231 if gap_start > left_edge {
6232 self.pc.rect(border, left_edge, y, gap_start - left_edge, 1.0);
6233 }
6234 if right_edge > gap_end {
6235 self.pc.rect(border, gap_end, y, right_edge - gap_end, 1.0);
6236 }
6237 } else {
6238 self.pc.rect(border, left_edge, y, w, 1.0);
6239 }
6240
6241 self.pc.rect(border, x, y + h + extra_bottom, w, 1.0);
6242 self.pc.rect(border, x, y, 1.0, h + extra_bottom);
6243 self.pc.rect(border, x + w - 1.0, y, 1.0, h + extra_bottom);
6244 self.content_y + extra_bottom + 8.0
6245 }
6246 }
6247
6248
6249 pub struct VStack<'b, 'a, P> {
6250 pub context: &'b mut SectionContext<'a, P>,
6251 pub spacing: f32,
6252 }
6253
6254 impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
6255 pub fn add_widget<T: WidgetHost + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
6256 let pad = self.context.padding();
6257 let margin_x = 2.0 * pad + 12.0;
6258 let x = self.context.left + margin_x;
6259 let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
6260
6261 let mut max_h = self.context.grid.max_height().max(self.context.content_y);
6262 if max_h > self.context.content_start_y {
6263 max_h += self.context.row_gap;
6264 }
6265 let y = max_h;
6266
6267 let pref_h = w.preferred_height().unwrap_or(wh);
6268 let top_room = w.label_strip();
6269 let total_h = pref_h + top_room;
6270
6271 w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
6272 render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
6273
6274 let new_bottom = y + total_h;
6275 self.context.content_y = new_bottom;
6276 for h in &mut self.context.grid.col_heights {
6277 *h = new_bottom;
6278 }
6279 self.context.spacing(self.spacing);
6280 }
6281
6282 /// [`add_row`](Self::add_row) with per-column widths from `needs` — see
6283 /// [`SectionContext::row_layout_for`]. For a row of buttons, `needs` is
6284 /// each label's measured width plus the plate's own inset.
6285 pub fn add_row_for<F>(&mut self, needs: &[f32], gap: f32, h: f32, mut f: F)
6286 where
6287 F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
6288 {
6289 let max_h = self.context.grid.max_height().max(self.context.content_y);
6290 for col_h in &mut self.context.grid.col_heights {
6291 *col_h = max_h;
6292 }
6293 self.context.content_y = max_h;
6294
6295 let cols = self.context.row_layout_for(needs, gap);
6296 for (i, &(x, w)) in cols.iter().enumerate() {
6297 self.context.content_y = max_h;
6298 f(self.context, i, x, w);
6299 }
6300
6301 let new_bottom = max_h + h;
6302 self.context.content_y = new_bottom;
6303 for col_h in &mut self.context.grid.col_heights {
6304 *col_h = new_bottom;
6305 }
6306 self.context.spacing(self.spacing);
6307 }
6308
6309 pub fn add_row<F>(&mut self, count: usize, gap: f32, h: f32, mut f: F)
6310 where
6311 F: FnMut(&mut SectionContext<'a, P>, usize, f32, f32),
6312 {
6313 let max_h = self.context.grid.max_height().max(self.context.content_y);
6314 for col_h in &mut self.context.grid.col_heights {
6315 *col_h = max_h;
6316 }
6317 self.context.content_y = max_h;
6318
6319 let cols = self.context.row_layout(count, gap);
6320 for (i, &(x, w)) in cols.iter().enumerate() {
6321 self.context.content_y = max_h;
6322 f(self.context, i, x, w);
6323 }
6324
6325 let new_bottom = max_h + h;
6326 self.context.content_y = new_bottom;
6327 for col_h in &mut self.context.grid.col_heights {
6328 *col_h = new_bottom;
6329 }
6330 self.context.spacing(self.spacing);
6331 }
6332 }
6333
6334 pub fn get_system_monospace_font() -> &'static str {
6335 static MONOSPACE_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
6336 MONOSPACE_FONT.get_or_init(|| {
6337 if let Ok(output) = std::process::Command::new("fc-match")
6338 .args(["-f", "%{family}", "monospace"])
6339 .output()
6340 {
6341 let name = String::from_utf8_lossy(&output.stdout);
6342 let parsed = name.split(',').next().unwrap_or("monospace").trim();
6343 if !parsed.is_empty() {
6344 return parsed.to_string();
6345 }
6346 }
6347 "monospace".to_string()
6348 })
6349 }
6350
6351 pub fn get_system_sans_serif_font() -> &'static str {
6352 static SANS_SERIF_FONT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
6353 SANS_SERIF_FONT.get_or_init(|| {
6354 if let Ok(output) = std::process::Command::new("fc-match")
6355 .args(["-f", "%{family}", "sans-serif"])
6356 .output()
6357 {
6358 let name = String::from_utf8_lossy(&output.stdout);
6359 let parsed = name.split(',').next().unwrap_or("sans-serif").trim();
6360 if !parsed.is_empty() {
6361 return parsed.to_string();
6362 }
6363 }
6364 "sans-serif".to_string()
6365 })
6366 }
6367
6368 /// The DE's font families, from the shared config's `fonts { }` block:
6369 /// `(sans_serif, serif, monospace, terminal)`.
6370 ///
6371 /// These used to live in `~/.config/fontconfig/fonts.conf`, read back out of
6372 /// fontconfig's XML by alias. Three of the seven aliases it carried
6373 /// (`window-borders`, `status-interface`, `fuzzel`) were cce inventions
6374 /// squatting in fontconfig's family namespace, and by the end none of them was
6375 /// read by anything: window borders lost their text when titlebars went away,
6376 /// the status bar moved to `module { font }` / `/style/status/font` in KDL and
6377 /// only ever consulted the alias as a last-resort fallback, and fuzzel was
6378 /// replaced by cce-cloud. The settings app's Fonts page — which edited that
6379 /// file, rewriting it wholesale and preserving only its `<dir>` lines — went
6380 /// with them.
6381 ///
6382 /// fonts.conf is still fontconfig's file and still governs GTK/Electron apps;
6383 /// cce simply no longer reads or writes it. Per-app overrides work here because
6384 /// this reads the merged config, the same way the status bar's font does.
6385 pub fn read_preferred_fonts() -> (String, String, String, String) {
6386 let get = |key: &str, fallback: &str| {
6387 crate::config::get_string(&format!("/fonts/{key}"))
6388 .filter(|s| !s.trim().is_empty())
6389 .unwrap_or_else(|| fallback.to_string())
6390 };
6391 (
6392 get("sans_serif", "Noto Sans"),
6393 get("serif", "Noto Serif"),
6394 get("monospace", "Noto Sans Mono"),
6395 get("terminal", "Noto Sans Mono"),
6396 )
6397 }
6398
6399 impl crate::widget::ContainerLayout for FlexLayout {
6400 fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
6401 Box::new(self.clone())
6402 }
6403 }
6404
6405 impl crate::widget::ContainerLayout for ColumnLayout {
6406 fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
6407 Box::new(self.clone())
6408 }
6409 }
6410
6411 impl crate::widget::ContainerLayout for AdaptiveGrid {
6412 fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
6413 Box::new(self.clone())
6414 }
6415 }
6416
6417 impl crate::widget::ContainerLayout for RadialLayout {
6418 fn box_clone_container(&self) -> Box<dyn crate::widget::ContainerLayout> {
6419 Box::new(self.clone())
6420 }
6421 }
6422
6423 #[cfg(test)]
6424 mod tests {
6425 #[test]
6426 fn unit_slots_resolve_through_the_metric_at_read_time() {
6427 use crate::units::{Len, Metric, MetricSource};
6428 let mut reg = super::StyleRegistry::new();
6429 reg.set_len("probe_width", Len::mm(2.0));
6430 // `get_float` resolves against the PROCESS metric at read time — the
6431 // same number `Len::to_px` gives — so it tracks whatever the metric
6432 // is now, not what it was at load. (The metric itself is left alone:
6433 // it is process-global and the suite runs in parallel.)
6434 let live = reg.get_float("probe_width").unwrap();
6435 assert!((live - Len::mm(2.0).to_px()).abs() < 1e-4, "{live}");
6436 // Two metrics give two answers for the one stored length.
6437 let assumed = Metric::assumed(1.0);
6438 let panel = Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap();
6439 let (a, b) = (Len::mm(2.0).resolve(&assumed), Len::mm(2.0).resolve(&panel));
6440 assert!((a - 2.0 * 96.0 / 25.4).abs() < 1e-3, "{a}");
6441 assert!((b - 2.0 * panel.px_per_mm).abs() < 1e-3, "{b}");
6442 assert_ne!(a, b);
6443 assert_eq!(reg.get_len("probe_width"), Some(Len::mm(2.0)));
6444 // A plain number written later wins, and reads back as px.
6445 reg.set_float("probe_width", 7.0);
6446 assert_eq!(reg.get_float("probe_width"), Some(7.0));
6447 assert_eq!(reg.get_len("probe_width"), Some(Len::px(7.0)));
6448 }
6449
6450 use super::*;
6451
6452 struct MockRenderTarget {
6453 rects: Vec<([f32; 4], f32, f32, f32, f32)>,
6454 }
6455
6456 impl RenderTarget for MockRenderTarget {
6457 fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
6458 self.rects.push((color, x, y, w, h));
6459 }
6460 fn text(&mut self, _content: &str, _x: f32, _y: f32, _size: f32, _color: [f32; 4]) {}
6461 }
6462
6463 #[derive(Default)]
6464 struct ProbeTarget {
6465 rects: Vec<([f32; 4], f32, f32, f32, f32)>,
6466 bounded: Vec<(String, f32, f32, Option<[f32; 4]>)>,
6467 }
6468
6469 impl RenderTarget for ProbeTarget {
6470 fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
6471 self.rects.push((color, x, y, w, h));
6472 }
6473 fn text(&mut self, content: &str, x: f32, y: f32, _size: f32, _color: [f32; 4]) {
6474 self.bounded.push((content.to_string(), x, y, None));
6475 }
6476 fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, _size: f32, _color: [f32; 4], bounds: Option<[f32; 4]>) {
6477 self.bounded.push((content.to_string(), x, y, bounds));
6478 }
6479 }
6480
6481 /// Everything a section places horizontally — text, button rows, widgets,
6482 /// the column grid — must share ONE inset, or content does not line up
6483 /// with the content beside it. Text used to sit `padding()` to the left of
6484 /// every row in the same section.
6485 #[test]
6486 fn section_text_and_rows_share_one_inset() {
6487 let mut pc = ProbeTarget::default();
6488 let (left, cw) = (100.0f32, 320.0f32);
6489 let mut sec: SectionContext<'_, ProbeTarget> =
6490 SectionContext::new(&mut pc, left, 50.0, cw, "Probe", false, false);
6491
6492 let cols = sec.row_layout(2, 8.0);
6493 assert_eq!(sec.ax(12.0), cols[0].0, "text at the conventional 12.0 must start where a row starts");
6494 assert_eq!(sec.ax(12.0), sec.content_left());
6495
6496 // Symmetric: the right-hand inset from the section border matches the
6497 // left-hand one.
6498 let pad = sec.padding();
6499 let (border_l, border_r) = (left + pad, left + cw - pad);
6500 let row_right = cols[1].0 + cols[1].1;
6501 assert_eq!(cols[0].0 - border_l, border_r - row_right);
6502 }
6503
6504 /// Even division gives a long label and a short one the same box, so one
6505 /// is clipped while the other floats in slack — the row of Suspend /
6506 /// Hibernate / Reboot / Power Off that prompted this. `row_layout_for`
6507 /// gives each column what it needs and shares the leftover equally.
6508 #[test]
6509 fn a_row_sized_for_content_fits_every_column() {
6510 let mut pc = ProbeTarget::default();
6511 let sec: SectionContext<'_, ProbeTarget> =
6512 SectionContext::new(&mut pc, 100.0, 50.0, 400.0, "Probe", false, false);
6513 let needs = [80.0f32, 30.0, 50.0, 40.0];
6514 let cols = sec.row_layout_for(&needs, 8.0);
6515
6516 for (i, &(_, w)) in cols.iter().enumerate() {
6517 assert!(w >= needs[i], "column {i} got {w}, less than the {} it needs", needs[i]);
6518 }
6519 // The slack is shared equally, so every column overshoots by the same
6520 // amount — not proportionally, which would starve the short ones.
6521 let slack: Vec<f32> = cols.iter().zip(needs.iter()).map(|(&(_, w), n)| w - n).collect();
6522 for s in &slack {
6523 assert!((s - slack[0]).abs() < 1.0e-3, "slack shared unevenly: {slack:?}");
6524 }
6525 // And the row still ends inside the section.
6526 let (lx, lw) = *cols.last().unwrap();
6527 assert!(lx + lw <= sec.content_left() + sec.content_width() + 1.0e-3);
6528 }
6529
6530 /// When the labels genuinely do not fit, everyone shrinks by the same
6531 /// factor rather than the last column absorbing the whole shortfall.
6532 #[test]
6533 fn an_overfull_row_shrinks_every_column_together() {
6534 let mut pc = ProbeTarget::default();
6535 let sec: SectionContext<'_, ProbeTarget> =
6536 SectionContext::new(&mut pc, 100.0, 50.0, 200.0, "Probe", false, false);
6537 let needs = [300.0f32, 150.0];
6538 let cols = sec.row_layout_for(&needs, 8.0);
6539 let ratio0 = cols[0].1 / needs[0];
6540 let ratio1 = cols[1].1 / needs[1];
6541 assert!((ratio0 - ratio1).abs() < 1.0e-3, "shrink was not shared: {ratio0} vs {ratio1}");
6542 let (lx, lw) = cols[1];
6543 assert!(lx + lw <= sec.content_left() + sec.content_width() + 1.0e-3, "overfull row escaped the section");
6544 }
6545
6546 /// `ax` used to add `padding()` only for `x_off >= 12.0`, so asking for one
6547 /// pixel less moved content a whole `padding()` the other way. Callers do
6548 /// pass 11.0 and 13.0 in this tree, and the step put them in different
6549 /// coordinate spaces from each other.
6550 #[test]
6551 fn section_ax_is_continuous() {
6552 let mut pc = ProbeTarget::default();
6553 let mut sec: SectionContext<'_, ProbeTarget> =
6554 SectionContext::new(&mut pc, 100.0, 50.0, 320.0, "Probe", false, false);
6555 for off in [0.0f32, 1.0, 11.0, 11.999, 12.0, 13.0, 24.0] {
6556 assert!(
6557 (sec.ax(off) - sec.ax(0.0) - off).abs() < 1.0e-3,
6558 "ax must be a plain translation; it stepped at {off}"
6559 );
6560 }
6561 }
6562
6563 /// A string wider than its section used to be drawn unbounded, running over
6564 /// the border and out of the window. Every section text now carries bounds
6565 /// no wider than the content box.
6566 #[test]
6567 fn section_text_is_bounded_to_the_content_box() {
6568 let mut pc = ProbeTarget::default();
6569 let (left, cw) = (100.0f32, 320.0f32);
6570 {
6571 let mut sec: SectionContext<'_, ProbeTarget> =
6572 SectionContext::new(&mut pc, left, 50.0, cw, "Probe", false, false);
6573 let right = sec.content_left() + sec.content_width();
6574 sec.text(
6575 "NVIDIA Corporation AD104M [GeForce RTX 4080 Max-Q / Mobile] and then some",
6576 12.0, 0.0, 12.0, [1.0; 4],
6577 );
6578 assert!(right < left + cw, "content box must sit inside the section");
6579 }
6580 // Pick our string out by content: the section's own label is drawn
6581 // through this target too, and it is not content.
6582 let (_, _, _, bounds) = pc
6583 .bounded
6584 .iter()
6585 .find(|(t, ..)| t.starts_with("NVIDIA"))
6586 .cloned()
6587 .expect("the section text must reach the render target");
6588 let b = bounds.expect("section text must be bounded");
6589 // Recompute the expectation from the same inputs the section used.
6590 let mut pc2 = ProbeTarget::default();
6591 let probe: SectionContext<'_, ProbeTarget> =
6592 SectionContext::new(&mut pc2, left, 50.0, cw, "Probe", false, false);
6593 assert_eq!(b[2], probe.content_left() + probe.content_width());
6594 assert!(b[2] <= left + cw - probe.padding(), "bound must not exceed the section border");
6595 }
6596
6597 struct MockWidget {
6598 base: crate::widget::Widget,
6599 x: f32,
6600 y: f32,
6601 w: f32,
6602 h: f32,
6603 }
6604
6605 impl WidgetHost for MockWidget {
6606 crate::impl_widget_base!(MockWidget);
6607 fn rect(&self) -> (f32, f32, f32, f32) {
6608 (self.x, self.y, self.w, self.h)
6609 }
6610 fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
6611 self.x = x;
6612 self.y = y;
6613 self.w = w;
6614 self.h = h;
6615 }
6616 fn color(&self) -> [f32; 4] {
6617 [0.0, 0.0, 0.0, 0.0]
6618 }
6619 }
6620
6621 struct MockWidgetWithLabel {
6622 base: crate::widget::Widget,
6623 }
6624
6625 impl WidgetHost for MockWidgetWithLabel {
6626 crate::impl_widget_base!(MockWidgetWithLabel);
6627 fn rect(&self) -> (f32, f32, f32, f32) {
6628 let offset = self.label_strip();
6629 (self.base.x, self.base.y - offset, self.base.w, self.base.h + offset)
6630 }
6631 fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
6632 let offset = self.label_strip();
6633 self.base.x = x;
6634 self.base.y = y + offset;
6635 self.base.w = w;
6636 self.base.h = (h - offset).max(0.0);
6637 }
6638 fn color(&self) -> [f32; 4] {
6639 [0.0, 0.0, 0.0, 0.0]
6640 }
6641 }
6642
6643 /// The vstack flow is checked against the LIVE style — the label margin, the
6644 /// detached-label font and the section padding are process-global and the suite
6645 /// runs in parallel, so pinning them here would be a window every other test
6646 /// could see (a label measured in one font by its own call and in another by the
6647 /// group hull's is exactly the flake this cost us). Every expectation below is
6648 /// derived from the getters instead, so the flow holds under any config.
6649 #[test]
6650 fn test_vstack_flow() {
6651 // `Section` seats its first column one `margin_x` in from its left edge.
6652 let first_col_x = 10.0 + 2.0 * section_padding() + Section::DEFAULT_MARGIN_X;
6653 let mut mock_pc = MockRenderTarget { rects: Vec::new() };
6654 let mut sec = Section::new(&mut mock_pc, 10.0, 20.0, 200.0, "Test Section");
6655
6656 let start_y = sec.ay();
6657 let mut stack = sec.vstack(&mut mock_pc, 10.0);
6658
6659 let mut dummy = crate::context::UiContext::new();
6660 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6661 stack.add_widget(&mut w1, 50.0, 30.0, &mut dummy);
6662
6663 // Standard margin should be applied
6664 assert_eq!(w1.x, first_col_x);
6665 assert_eq!(w1.y, start_y);
6666
6667 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6668 stack.add_widget(&mut w2, 60.0, 40.0, &mut dummy);
6669
6670 // Second widget should start after first widget height + vstack spacing
6671 assert_eq!(w2.y, start_y + 30.0 + 10.0);
6672
6673 let mut base = crate::widget::Widget::new();
6674 base.label = Some("Test Label".to_string());
6675 let mut w3 = MockWidgetWithLabel { base };
6676 stack.add_widget(&mut w3, 70.0, 50.0, &mut dummy);
6677
6678 let offset = w3.base.label_offset();
6679
6680 // Third widget has label, so its y should be shifted by offset
6681 assert!(offset > 0.0, "a labelled widget has a strip");
6682 assert_eq!(w3.base.y, start_y + 30.0 + 10.0 + 40.0 + 10.0 + offset);
6683 }
6684
6685 #[test]
6686 fn test_grid_layout() {
6687 // Test single column layout (width = 200, min_col_width = 300)
6688 let grid1 = Grid::new(10.0, 20.0, 200.0, 300.0, 10.0, 1);
6689 assert_eq!(grid1.col_heights.len(), 1);
6690 assert_eq!(grid1.col_lefts[0], 10.0);
6691 assert_eq!(grid1.col_width, 200.0);
6692
6693 // Test multi column layout (width = 700, min_col_width = 300, gap = 20)
6694 // count = floor((700 + 20) / (300 + 20)) = floor(720 / 320) = 2.
6695 // total_gap = 20 * 1 = 20.
6696 // col_width = (700 - 20) / 2 = 340.
6697 let mut grid2 = Grid::new(5.0, 15.0, 700.0, 300.0, 20.0, 2);
6698 assert_eq!(grid2.col_heights.len(), 2);
6699 assert_eq!(grid2.col_lefts[0], 5.0);
6700 assert_eq!(grid2.col_lefts[1], 365.0);
6701 assert_eq!(grid2.col_width, 340.0);
6702
6703 assert_eq!(grid2.next_column(), 0);
6704 grid2.col_heights[0] += 50.0; // Column 0 height becomes 65.0
6705 assert_eq!(grid2.next_column(), 1);
6706 grid2.col_heights[1] += 30.0; // Column 1 height becomes 45.0
6707 assert_eq!(grid2.next_column(), 1);
6708 grid2.col_heights[1] += 30.0; // Column 1 height becomes 75.0
6709 assert_eq!(grid2.next_column(), 0);
6710
6711 assert_eq!(grid2.max_height(), 75.0);
6712 }
6713
6714 #[test]
6715 fn test_subsection() {
6716 let mut pc = PopoverCollector::new();
6717 let mut subsec = Section::new_opt(&mut pc, 10.0, 20.0, 300.0, "Test Subsec", true);
6718 assert_eq!(subsec.left, 10.0);
6719 assert_eq!(subsec.top, 20.0);
6720 assert_eq!(subsec.cw, 300.0);
6721 assert!(subsec.is_child);
6722
6723 let mut dummy = crate::context::UiContext::new();
6724 let mut w = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6725 subsec.widget(&mut pc, &mut w, 12.0, 100.0, 40.0, &mut dummy);
6726
6727 let bottom = subsec.finish(&mut pc);
6728 assert!(bottom > 20.0);
6729 }
6730
6731 #[test]
6732 fn test_radial_layout() {
6733 let radial = Radial::new(100.0, 100.0, 1.5, 50.0);
6734
6735 // Check first widget is centered at (100.0, 100.0)
6736 let rect0 = radial.widget_rect(0, 40.0, 30.0);
6737 assert_eq!(rect0, (100.0 - 20.0, 100.0 - 15.0, 40.0, 30.0));
6738
6739 // Check Ring 1 (idx = 1) vs Ring 2 (idx = 7)
6740 let rect1 = radial.widget_rect(1, 40.0, 30.0);
6741 let rect7 = radial.widget_rect(7, 40.0, 30.0);
6742
6743 let c1_x = rect1.0 + rect1.2 / 2.0;
6744 let c1_y = rect1.1 + rect1.3 / 2.0;
6745 let c7_x = rect7.0 + rect7.2 / 2.0;
6746 let c7_y = rect7.1 + rect7.3 / 2.0;
6747
6748 let d1 = ((c1_x - 100.0).powi(2) + (c1_y - 100.0).powi(2)).sqrt();
6749 let d7 = ((c7_x - 100.0).powi(2) + (c7_y - 100.0).powi(2)).sqrt();
6750
6751 // Ring 2 should be further out than Ring 1
6752 assert!(d7 > d1);
6753 assert!(d1 > 0.0);
6754 }
6755
6756 // The `Once`-initialised style getters below are checked against the
6757 // statics their config pass fills, under whatever the live config says.
6758 // None of them WRITES: these are process globals and the suite runs in
6759 // parallel, so a set/restore window is visible to every other test — and
6760 // a "restore" that writes a hardcoded literal (as these did) clobbers a
6761 // non-default config permanently. What a getter here can actually get
6762 // wrong is which static it reads, and that is what these pin.
6763
6764 #[test]
6765 fn test_nested_section_label_alignment() {
6766 let align = nested_section_label_alignment();
6767 assert_eq!(align, *super::NESTED_SECTION_LABEL_ALIGNMENT.read().unwrap());
6768
6769 let offset = nested_section_label_offset();
6770 assert_eq!(offset, *super::NESTED_SECTION_LABEL_OFFSET.read().unwrap());
6771 assert!(offset.is_finite(), "label offset {offset}");
6772 }
6773
6774 #[test]
6775 fn test_dropdown_height() {
6776 let h = dropdown_height();
6777 assert_eq!(h, *super::DROPDOWN_HEIGHT.read().unwrap());
6778 assert!(h.is_finite() && h > 0.0, "dropdown height {h}");
6779 }
6780
6781 #[test]
6782 fn test_column_gap() {
6783 // A legacy key: set (by config or setter) it is honoured; unset it
6784 // lands on the ladder — the root plate's gap.
6785 let gap = column_gap();
6786 match (registry_float("column_gap"), *super::COLUMN_GAP.read().unwrap()) {
6787 (Some(v), _) | (None, Some(v)) => assert_eq!(gap, v),
6788 (None, None) => assert_eq!(gap, root_plate_gap()),
6789 }
6790 assert!(gap.is_finite() && gap >= 0.0, "column gap {gap}");
6791 }
6792
6793 #[test]
6794 fn spacing_ladder_falls_back_rung_by_rung() {
6795 // Every rung getter is finite and non-negative, and the inset is the
6796 // roll plus the padding, whatever the config says.
6797 for v in [root_plate_padding(), root_plate_gap(), root_plate_inset(), plate_padding(), plate_gap(), control_gap()] {
6798 assert!(v.is_finite() && v >= 0.0, "{v}");
6799 }
6800 assert_eq!(root_plate_inset(), bevel_width() + root_plate_padding());
6801 if registry_float("plate_gap").is_none() {
6802 assert_eq!(plate_gap(), root_plate_gap());
6803 }
6804 if registry_float("control_gap").is_none() {
6805 assert_eq!(control_gap(), CONTROL_GAP);
6806 }
6807 }
6808
6809 #[test]
6810 fn test_print_fonts() {
6811 let db = crate::widget::get_font_db();
6812 for face in db.faces() {
6813 println!("FAMILY: {:?}", face.families);
6814 }
6815 }
6816
6817 #[test]
6818 fn test_section_context_grid() {
6819 let mut mock_pc = MockRenderTarget { rects: Vec::new() };
6820 let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Section", false, false);
6821 assert_eq!(ctx.left, 10.0);
6822 assert_eq!(ctx.top, 20.0);
6823 assert_eq!(ctx.cw, 500.0);
6824
6825 let mut ui_ctx = crate::context::UiContext::new();
6826 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6827 ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx);
6828
6829 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6830 ctx.widget(&mut w2, 12.0, 100.0, 30.0, &mut ui_ctx);
6831
6832 // Since cw=500, we should have multiple columns!
6833 // The first widget goes into column 0, second into column 1.
6834 assert_ne!(w1.x, w2.x);
6835
6836 let bottom = ctx.finish();
6837 assert!(bottom > 20.0);
6838 }
6839
6840 #[test]
6841 fn test_child_section_single_column_controls() {
6842 let mut mock_pc = MockRenderTarget { rects: Vec::new() };
6843 let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Child Section", false, true);
6844 assert_eq!(ctx.grid.col_heights.len(), 1);
6845
6846 let mut ui_ctx = crate::context::UiContext::new();
6847 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6848 ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx);
6849
6850 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6851 ctx.widget(&mut w2, 12.0, 100.0, 30.0, &mut ui_ctx);
6852
6853 // Since it's a child section, we should have a single column only, so w1.x == w2.x.
6854 assert_eq!(w1.x, w2.x);
6855 }
6856
6857 #[test]
6858 fn test_parent_section_side_by_side_child_sections() {
6859 let mut mock_pc = MockRenderTarget { rects: Vec::new() };
6860 let mut parent_ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Parent Section", false, false);
6861 assert_eq!(parent_ctx.grid.col_heights.len(), 2);
6862
6863 let mut sub_left_1 = 0.0;
6864 let mut sub_left_2 = 0.0;
6865
6866 parent_ctx.add_section("Child Section 1", false, |subsec1| {
6867 sub_left_1 = subsec1.left;
6868 });
6869
6870 parent_ctx.add_section("Child Section 2", false, |subsec2| {
6871 sub_left_2 = subsec2.left;
6872 });
6873
6874 // The two child sections should be rendered side-by-side in different columns, so sub_left_1 != sub_left_2.
6875 assert_ne!(sub_left_1, sub_left_2);
6876 }
6877
6878 #[test]
6879 fn test_section_context_spacing_preserves_columns() {
6880 let mut mock_pc = MockRenderTarget { rects: Vec::new() };
6881 let mut ctx = SectionContext::new(&mut mock_pc, 10.0, 20.0, 500.0, "Test Section", false, false);
6882 assert_eq!(ctx.grid.col_heights.len(), 2);
6883
6884 let mut ui_ctx = crate::context::UiContext::new();
6885 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 0.0, h: 0.0 };
6886 ctx.widget(&mut w1, 12.0, 100.0, 40.0, &mut ui_ctx); // placed in col 0
6887
6888 let height_col_0_before = ctx.grid.col_heights[0];
6889 let height_col_1_before = ctx.grid.col_heights[1];
6890 assert_ne!(height_col_0_before, height_col_1_before);
6891
6892 ctx.spacing(12.0);
6893
6894 let height_col_0_after = ctx.grid.col_heights[0];
6895 let height_col_1_after = ctx.grid.col_heights[1];
6896 assert_eq!(height_col_0_after, height_col_0_before + 12.0);
6897 assert_eq!(height_col_1_after, height_col_1_before);
6898 assert_ne!(height_col_0_after, height_col_1_after);
6899 }
6900
6901 #[test]
6902 fn test_spinbox_button_padding_config() {
6903 let padding = spinbox_button_padding();
6904 println!("Parsed spinbox button padding: {}", padding);
6905 assert!(padding >= 0.0);
6906 }
6907
6908 /// The control rung's key flattens to `control_corner_radius`, beside a
6909 /// widget's own override — the config shape `control corner_radius=8 { button corner_radius=10 }`.
6910 #[test]
6911 fn control_rung_radius_flattens_beside_widget_overrides() {
6912 let val: serde_json::Value = serde_json::json!({
6913 "style": { "control": { "corner_radius": 8, "button": { "corner_radius": 10 } } }
6914 });
6915 let mut flat = String::new();
6916 flatten_json_to_flat_props(&val, "", &mut flat);
6917 assert!(flat.lines().any(|l| l.starts_with("control_corner_radius")), "{flat}");
6918 assert!(flat.lines().any(|l| l.starts_with("button_corner_radius")), "{flat}");
6919 }
6920
6921 /// A registry-backed style pinned by one test is invisible to a test
6922 /// beside it.
6923 ///
6924 /// The graph-style test below used to pin ~20 of these and restore none
6925 /// (it now reads the live registry instead: see
6926 /// `graph_style_getters_resolve_their_own_registry_keys`). Before the
6927 /// per-thread overlay that reached every test running alongside —
6928 /// provably: `dual_geometry_views_stay_consistent` bakes
6929 /// quads with `graph_node_corner_radius`, then re-reads the getter to
6930 /// compare, and a write landing between the two made them disagree.
6931 #[test]
6932 fn a_pinned_style_is_private_to_its_thread() {
6933 let base = graph_node_corner_radius();
6934 set_graph_node_corner_radius(base + 17.0);
6935 assert_eq!(graph_node_corner_radius(), base + 17.0, "the pinning thread sees its own value");
6936
6937 let elsewhere = std::thread::spawn(graph_node_corner_radius).join().unwrap();
6938 assert_eq!(elsewhere, base, "a thread beside it must still see the shared base");
6939 }
6940
6941 /// Every graph style getter resolves the registry key it is named for,
6942 /// falling back to its own documented default — checked against the live
6943 /// registry as it stands. Nothing is written: the style registry and the
6944 /// colour statics are process-global and the suite runs in parallel, so a
6945 /// set/assert here would be visible to every other test (this one used to
6946 /// set all twenty-one and restore none).
6947 #[test]
6948 fn graph_style_getters_resolve_their_own_registry_keys() {
6949 fn stored(key: &str) -> Option<f32> {
6950 crate::layout::lazy_init_style_registry();
6951 let reg = crate::layout::get_style_registry().read().unwrap();
6952 reg.get_float(key)
6953 }
6954
6955 assert_eq!(graph_spacing_x(), stored("graph_spacing_x").unwrap_or(187.5));
6956 assert_eq!(graph_spacing_y(), stored("graph_spacing_y").unwrap_or(112.5));
6957 assert_eq!(graph_line_width(), stored("graph_line_width").unwrap_or(1.0));
6958 assert_eq!(graph_node_width(), stored("graph_node_width").unwrap_or(150.0));
6959 assert_eq!(graph_node_height(), stored("graph_node_height").unwrap_or(75.0));
6960 assert_eq!(graph_grid_snap(), stored("graph_grid_snap").unwrap_or(0.0) != 0.0);
6961 assert_eq!(graph_blur(), stored("graph_blur").unwrap_or(0.0));
6962 assert_eq!(graph_node_corner_radius(), stored("graph_node_corner_radius").unwrap_or(4.0));
6963 assert_eq!(graph_wire_size(), stored("graph_wire_size").unwrap_or(6.0));
6964 assert_eq!(
6965 graph_wire_activation_radius(),
6966 stored("graph_wire_activation_radius").unwrap_or(9.0)
6967 );
6968 assert_eq!(graph_connector_size(), stored("graph_connector_size").unwrap_or(8.0));
6969 assert_eq!(
6970 graph_connector_activation_radius(),
6971 stored("graph_connector_activation_radius").unwrap_or(12.0)
6972 );
6973
6974 // The graph colours live behind statics in `color`, out of this
6975 // module's reach; what is checkable without writing them is that each
6976 // resolves to a real, in-gamut colour rather than an unparsed or
6977 // uninitialised one.
6978 let rgb: [(&str, [f32; 3]); 2] = [
6979 ("graph_cell", crate::color::graph_cell_color()),
6980 ("graph_gap", crate::color::graph_gap_color()),
6981 ];
6982 for (name, c) in rgb {
6983 assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{name}: {c:?}");
6984 }
6985 let rgba: [(&str, [f32; 4]); 10] = [
6986 ("graph_node", crate::color::graph_node_color()),
6987 ("graph_node_selected", crate::color::graph_node_selected_color()),
6988 ("graph_node_drag", crate::color::graph_node_drag_color()),
6989 ("node", crate::color::node_color()),
6990 ("node_selected", crate::color::node_selected_color()),
6991 ("node_drag", crate::color::node_drag_color()),
6992 ("graph_wire", crate::color::graph_wire_color()),
6993 ("graph_wire_highlight", crate::color::graph_wire_highlight_color()),
6994 ("graph_connector", crate::color::graph_connector_color()),
6995 ("graph_connector_highlight", crate::color::graph_connector_highlight_color()),
6996 ];
6997 for (name, c) in rgba {
6998 assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{name}: {c:?}");
6999 }
7000 }
7001
7002 #[test]
7003 fn test_mosaic_layout() {
7004 use crate::widget::MosaicLayout;
7005 use crate::layout::LayoutStrategy;
7006
7007 let layout = MosaicLayout {
7008 gap: 10.0,
7009 padding_x: 5.0,
7010 padding_y: 5.0,
7011 };
7012
7013 let mut dummy = crate::context::UiContext::new();
7014 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 50.0 };
7015 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 80.0 };
7016 let mut w3 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 80.0, h: 40.0 };
7017
7018 let children = vec![
7019 &mut w1 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7020 &mut w2 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7021 &mut w3 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7022 ];
7023
7024 let _ = layout.layout(10.0, 20.0, 250.0, 500.0, &children, &mut dummy);
7025
7026 assert_eq!(w1.x, 15.0);
7027 assert_eq!(w1.y, 25.0);
7028 assert_eq!(w2.x, 15.0);
7029 assert_eq!(w2.y, 85.0);
7030 assert_eq!(w3.x, 125.0);
7031 assert_eq!(w3.y, 25.0);
7032 }
7033
7034 #[test]
7035 fn test_reverse_mosaic_layout() {
7036 use crate::widget::ReverseMosaicLayout;
7037 use crate::layout::LayoutStrategy;
7038
7039 let layout = ReverseMosaicLayout {
7040 gap: 10.0,
7041 padding_x: 5.0,
7042 padding_y: 5.0,
7043 };
7044
7045 let mut dummy = crate::context::UiContext::new();
7046 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 50.0 };
7047 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 80.0 };
7048 let mut w3 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 80.0, h: 40.0 };
7049
7050 let children = vec![
7051 &mut w1 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7052 &mut w2 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7053 &mut w3 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
7054 ];
7055
7056 let _ = layout.layout(10.0, 20.0, 250.0, 300.0, &children, &mut dummy);
7057
7058 assert_eq!(w1.x, 15.0);
7059 assert_eq!(w1.y, 25.0);
7060 assert!((w1.w - 126.315).abs() < 0.01);
7061 assert!((w1.h - 103.57).abs() < 0.01);
7062
7063 assert!((w3.x - 153.947).abs() < 0.01);
7064 assert_eq!(w3.y, 25.0);
7065 assert!((w3.w - 101.05).abs() < 0.01);
7066 assert!((w3.h - 82.857).abs() < 0.01);
7067 }
7068
7069 /// The LUT is checked on `ramp_profile_lut`, the pure half of
7070 /// `set_bevel_profile_keys` / `set_roll_profile_keys` — installing it
7071 /// would restyle every wall in the process, and the suite runs in
7072 /// parallel.
7073 #[test]
7074 fn bevel_profile_lut_integrates_to_the_curves_net_rise() {
7075 let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
7076 let net_rise = |slopes: [f32; crate::layout::BEVEL_PROFILE_SAMPLES]| -> f32 {
7077 slopes.iter().map(|s| s / n).sum()
7078 };
7079
7080 // The identity 0→1 curve: slopes sum/N to its net rise of 1 (the same
7081 // total step the analytic smoothstep carries).
7082 let slopes = super::ramp_profile_lut(&[(0.0, 0.0), (1.0, 1.0)], true).expect("a curve");
7083 let rise = net_rise(slopes);
7084 assert!((rise - 1.0).abs() < 0.001, "net rise {rise}");
7085
7086 // A rim curve that returns to its start height nets zero.
7087 let slopes =
7088 super::ramp_profile_lut(&[(0.0, 0.5), (0.2, 1.0), (0.8, 1.0), (1.0, 0.5)], false)
7089 .expect("a curve");
7090 let rise = net_rise(slopes);
7091 assert!(rise.abs() < 0.001, "net rise {rise}");
7092
7093 // Degenerate key lists are no curve at all — the installers take that
7094 // `None` as "clear back to the analytic profile".
7095 assert!(super::ramp_profile_lut(&[(0.0, 1.0)], false).is_none());
7096 assert!(super::ramp_profile_lut(&[], true).is_none());
7097 }
7098
7099 #[test]
7100 fn relief_profile_specs_parse_with_identity_sentinel() {
7101 // Absent and identity-smooth mean "analytic" — nothing to install.
7102 assert!(crate::layout::parse_relief_profile_spec(None).is_none());
7103 assert!(crate::layout::parse_relief_profile_spec(Some(
7104 crate::layout::RELIEF_PROFILE_IDENTITY_SPEC
7105 ))
7106 .is_none());
7107 // Garbage falls back to analytic instead of poisoning the walls.
7108 assert!(crate::layout::parse_relief_profile_spec(Some("not a spec")).is_none());
7109 // A real curve installs: keys and line type round-trip.
7110 let (keys, smooth) = crate::layout::parse_relief_profile_spec(Some(
7111 "linear;0.000:0.200,0.500:1.000,1.000:0.800",
7112 ))
7113 .expect("custom spec parses");
7114 assert!(!smooth);
7115 assert_eq!(keys.len(), 3);
7116 assert!((keys[1].0 - 0.5).abs() < 0.001 && (keys[1].1 - 1.0).abs() < 0.001);
7117 // Identity under a LINEAR line type is a real profile (a straight
7118 // chamfer), not the sentinel — only the smooth spelling is analytic.
7119 assert!(crate::layout::parse_relief_profile_spec(Some(
7120 "linear;0.000:0.000,1.000:1.000"
7121 ))
7122 .is_some());
7123 }
7124 }
7125