GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/layout/mod.rs (91.6K)
1
2 // This module's part of the one style snapshot (`crate::style`): every slot below,
3 // with its default. Each slot's `static` handle stands where its lock did.
4 crate::style::style_slots! {
5 MENUBAR_FONT_CACHED: Option<(String, (String, f32))> = None;
6 STATUSBAR_FONT_CACHED: Option<(String, (String, f32))> = None;
7 FONT_SELECTOR_FONT_CACHED: Option<(String, (String, f32))> = None;
8 BUTTON_STRIP_FONT_CACHED: Option<(String, (String, f32))> = None;
9 CONTROL_LABEL_FONT_CACHED: Option<(String, (String, f32))> = None;
10 CONTROL_LABEL_FONT_DETACHED_CACHED: Option<(String, (String, f32))> = None;
11 LIST_FONT_CACHED: Option<(String, (String, f32))> = None;
12 TREE_FONT_CACHED: Option<(String, (String, f32))> = None;
13 GRAPH_FONT_CACHED: Option<(String, (String, f32))> = None;
14 GRAPH_NODE_FONT_CACHED: Option<(String, (String, f32))> = None;
15 BEVEL_PROFILE: Option<[f32; BEVEL_PROFILE_SAMPLES]> = None;
16 ROLL_PROFILE: Option<[f32; BEVEL_PROFILE_SAMPLES]> = None;
17 }
18
19 mod registry;
20 pub use registry::*;
21 mod bridge;
22 pub use bridge::*;
23 mod section;
24 pub use section::*;
25 mod form;
26 pub use form::{lay_row, line_height as form_line_height, text_width as form_text_width, Cell, Form, Group as FormGroup};
27 /// The height every text-bearing control falls back to when its own
28 /// `style.control.<name>.height` is unset: button, toggle (and the checkbox
29 /// row), dropdown, textbox (and the keybind recorder), spinbox, font selector,
30 /// colour selector, button strip, breadcrumb. One number, so a form built from
31 /// defaults lines up; a per-control key is the deliberate exception.
32 pub const DEFAULT_CONTROL_HEIGHT: f32 = 24.0;
33
34 /// The one gap between controls — one control height — in BOTH axes: what every
35 /// layout strategy's `Default` puts between children's blocks (a detached label
36 /// and the control below it, see `WidgetHost::label_strip`) and around them, and
37 /// what the legacy row builders advance by. One number, one module, so the space
38 /// beside a control and the space below it read the same.
39 pub const CONTROL_GAP: f32 = DEFAULT_CONTROL_HEIGHT;
40
41 /// The one inset from a control's edge to its text: the field text of a TextBox,
42 /// Dropdown, Spinbox, FontSelector, KeybindRecorder or ColorSelector, a left-
43 /// justified Button or Toggle label, a Slider's readout. Fields in a column line
44 /// their text up because they all use this.
45 pub const CONTROL_TEXT_INSET: f32 = 8.0;
46
47 /// A carve that stays INSIDE its rect. A recess, boss or trough wall straddles the
48 /// rect edge it is given — half its depth outside — so a well carved at a widget's
49 /// rect edge painted past the widget's box, and the gap beside a well read up to
50 /// half a depth smaller than the gap beside a raised plate (whose roll is inside).
51 /// Every widget carves the rect this returns instead: inset by half the depth, the
52 /// radii reduced by the same so the OUTER silhouette keeps the configured radius.
53 /// The widget's footprint is then its rect, and the gap is the gap.
54 pub fn carve_inside(rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, depth: f32) -> (crate::scene::layout::Rect, crate::scene::paint::Radii) {
55 let g = depth * 0.5;
56 let r = |r: f32| if r > 0.0 { (r - g).max(0.0) } else { 0.0 };
57 (
58 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) },
59 (r(radii.0), r(radii.1), r(radii.2), r(radii.3)),
60 )
61 }
62
63 /// The one inset from a control's left edge to its detached label above it — the
64 /// x offset the adapter draws the label at, and the tab hugging that label in the
65 /// carve-out compositions (Slider, Dropdown, RangeSlider). Every labeled control
66 /// uses it, so a column of labels is one line.
67 pub const DETACHED_LABEL_INSET: f32 = 4.0;
68 /// The same for the track-shaped controls: slider, range slider, progress bar,
69 /// usage bar.
70 pub const DEFAULT_TRACK_HEIGHT: f32 = 16.0;
71
72 static MENUBAR_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.MENUBAR_FONT_CACHED, |s| &mut s.layout.MENUBAR_FONT_CACHED);
73 static STATUSBAR_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.STATUSBAR_FONT_CACHED, |s| &mut s.layout.STATUSBAR_FONT_CACHED);
74
75 static FONT_SELECTOR_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.FONT_SELECTOR_FONT_CACHED, |s| &mut s.layout.FONT_SELECTOR_FONT_CACHED);
76 static BUTTON_STRIP_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.BUTTON_STRIP_FONT_CACHED, |s| &mut s.layout.BUTTON_STRIP_FONT_CACHED);
77 static CONTROL_LABEL_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.CONTROL_LABEL_FONT_CACHED, |s| &mut s.layout.CONTROL_LABEL_FONT_CACHED);
78 static CONTROL_LABEL_FONT_DETACHED_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.CONTROL_LABEL_FONT_DETACHED_CACHED, |s| &mut s.layout.CONTROL_LABEL_FONT_DETACHED_CACHED);
79 static LIST_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.LIST_FONT_CACHED, |s| &mut s.layout.LIST_FONT_CACHED);
80 static TREE_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.TREE_FONT_CACHED, |s| &mut s.layout.TREE_FONT_CACHED);
81 static GRAPH_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.GRAPH_FONT_CACHED, |s| &mut s.layout.GRAPH_FONT_CACHED);
82 static GRAPH_NODE_FONT_CACHED: crate::style::StyleCell<Option<(String, (String, f32))>> = crate::style::StyleCell::new(|s| &s.layout.GRAPH_NODE_FONT_CACHED, |s| &mut s.layout.GRAPH_NODE_FONT_CACHED);
83
84 /// Standard line height multiplier for text layout in cce-ui.
85 pub const TEXT_LINE_HEIGHT_MULTIPLIER: f32 = 1.0;
86
87 /// Standard line height based on font size.
88 pub fn line_height(font_size: f32) -> f32 {
89 font_size * TEXT_LINE_HEIGHT_MULTIPLIER
90 }
91
92 /// Aligns a text label's top coordinate (`y`) so it is centered vertically
93 /// inside a container of height `container_h` starting at `y`.
94 pub fn center_text_y(y: f32, container_h: f32, font_size: f32) -> f32 {
95 y + (container_h - line_height(font_size)) / 2.0
96 }
97
98 /// Standardized vertical text alignment calculation based on Spinbox widget alignment.
99 pub fn align_text_y(y: f32, height: f32, font_size: f32, top_offset: f32) -> f32 {
100 y + top_offset + (height - top_offset - font_size) / 2.0
101 }
102
103 /// Load the style config as one change of style (`crate::style::batch`): the registry and
104 /// every slot are published together, and the hundreds of per-key writes cost one copy.
105 pub fn reload_config() {
106 crate::style::batch(reload_config_in_batch);
107 }
108
109 fn reload_config_in_batch() {
110 if let Some(content) = read_config() {
111 // Every flattened key goes into the registry, which is the one copy every getter
112 // reads (since 2026-10-08; until then about fifty keys were also scanned off the
113 // same lines into slots of their own, and read from there).
114 if let Ok(mut registry) = get_style_registry().write() {
115 for line in content.lines() {
116 let trimmed = line.trim();
117 let Some(eq_idx) = trimmed.find('=') else { continue };
118 let key = trimmed[..eq_idx].trim();
119 let val_str = trimmed[eq_idx + 1..].trim().trim_matches('"').trim();
120 if let Ok(f_val) = val_str.parse::<f32>() {
121 registry.load_float(key, f_val);
122 } else if let Some(len) = crate::units::Len::parse(val_str) {
123 // `(mm)2.0` arrived as the string `2mm`.
124 registry.load_len(key, len);
125 } else {
126 registry.load_string(key, val_str.to_string());
127 }
128 }
129 }
130 if let Ok(raw_kdl) = std::fs::read_to_string(crate::config::get_config_path()) {
131 crate::color::reload_colors(&raw_kdl);
132 }
133 // The configured relief profiles, applied last so every process (not
134 // just the editor that wrote them) starts with the styled walls.
135 apply_relief_profile_config();
136 }
137 }
138
139 /// The untouched editor curve — the "analytic" sentinel in the config'd
140 /// profile specs (cce-designer's Edge Profile convention). For the wall curve
141 /// identity-smooth IS the analytic smoothstep, so skipping it changes
142 /// nothing; for the roll it would be a straight chamfer, not the analytic
143 /// superellipse quadrant, so it must read as "no custom profile".
144 pub const RELIEF_PROFILE_IDENTITY_SPEC: &str = "smooth;0.000:0.000,1.000:1.000";
145
146 /// Parse-and-install the relief profiles config carries as ramp specs
147 /// (`style.surface.relief.wall.profile` / `edge.profile` → the style
148 /// registry's `bevel_profile_spec` / `roll_profile_spec`). Absent, identity,
149 /// or unparseable specs clear back to the analytic profiles.
150 fn apply_relief_profile_config() {
151 let (wall, edge) = {
152 let reg = get_style_registry().read().unwrap();
153 (reg.get_string("bevel_profile_spec"), reg.get_string("roll_profile_spec"))
154 };
155 match parse_relief_profile_spec(wall.as_deref()) {
156 Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
157 None => clear_bevel_profile(),
158 }
159 match parse_relief_profile_spec(edge.as_deref()) {
160 Some((keys, smooth)) => set_roll_profile_keys(&keys, smooth),
161 None => clear_roll_profile(),
162 }
163 }
164
165 /// A config'd profile spec → installable keys. `None` (falling back to the
166 /// analytic profile) for absent, identity-sentinel, or unparseable specs.
167 fn parse_relief_profile_spec(spec: Option<&str>) -> Option<(Vec<(f32, f32)>, bool)> {
168 spec.filter(|s| *s != RELIEF_PROFILE_IDENTITY_SPEC).and_then(crate::widget::parse_ramp_spec)
169 }
170
171 /// Install (or clear back to analytic) the WALL profile from a ramp spec —
172 /// the entry point for a `(relief)` config value's profile
173 /// ([`crate::relief_spec::ReliefSpec`]): an app whose feature carries its
174 /// own material installs it process-wide here. Same identity/unparseable
175 /// filtering as the config path above.
176 pub fn install_wall_profile_spec(spec: Option<&str>) {
177 match parse_relief_profile_spec(spec) {
178 Some((keys, smooth)) => set_bevel_profile_keys(&keys, smooth),
179 None => clear_bevel_profile(),
180 }
181 }
182
183 pub fn control_label_margin() -> f32 {
184 registry_float("control_label_margin").unwrap_or(6.0)
185 }
186
187 pub(crate) fn control_label_strip() -> f32 {
188 let (_, font_size) = control_label_font_detached_parsed();
189 font_size + control_label_margin()
190 }
191
192 pub fn label_margin() -> f32 {
193 control_label_margin()
194 }
195
196 pub fn set_control_label_margin(margin: f32) {
197 if let Ok(mut r) = get_style_registry().write() {
198 r.set_float("control_label_margin", margin);
199 }
200 }
201
202 pub fn set_label_margin(margin: f32) {
203 set_control_label_margin(margin);
204 }
205
206 pub fn nested_section_label_alignment() -> u8 {
207 registry_float("nested_section_label_alignment").map(|v| v as u8).unwrap_or(0)
208 }
209
210 pub fn set_nested_section_label_alignment(align: u8) {
211 if let Ok(mut r) = get_style_registry().write() {
212 r.set_float("nested_section_label_alignment", align as f32);
213 }
214 }
215
216 pub fn nested_section_label_offset() -> f32 {
217 registry_float("nested_section_label_offset").unwrap_or(0.0)
218 }
219
220 pub fn set_nested_section_label_offset(offset: f32) {
221 if let Ok(mut r) = get_style_registry().write() {
222 r.set_float("nested_section_label_offset", offset);
223 }
224 }
225
226 /// The pane rung's padding: from a pane plate's rim to its content, in
227 /// logical px (`style.surface.plate.padding`). The second rung of the
228 /// spacing ladder — [`root_plate_inset`] / [`root_plate_gap`] on the root
229 /// plate, this and [`plate_gap`] inside a pane plate, [`control_gap`]
230 /// between controls. Registry-backed (live-reloadable); the legacy flat
231 /// `plate_padding = N` line still loads as a fallback.
232 pub fn plate_padding() -> f32 {
233 registry_float("plate_padding").unwrap_or(20.0)
234 }
235
236 pub fn set_plate_padding(padding: f32) {
237 if let Ok(mut r) = get_style_registry().write() {
238 r.set_float("plate_padding", padding);
239 }
240 }
241
242 /// Legacy: the page-level margin (`style.surface.page.margin`). Unset, it
243 /// IS the pane rung's [`plate_padding`] — a page is a pane — so an app
244 /// still reading it lands on the ladder. Set, it is honoured as before.
245 pub fn page_margin() -> f32 {
246 registry_float("page_margin").unwrap_or_else(plate_padding)
247 }
248
249 pub fn set_page_margin(margin: f32) {
250 if let Ok(mut r) = get_style_registry().write() {
251 r.set_float("page_margin", margin);
252 }
253 }
254
255 pub fn grid_min_col_width() -> f32 {
256 registry_float("grid_min_col_width").unwrap_or(260.0)
257 }
258
259 pub fn set_grid_min_col_width(width: f32) {
260 if let Ok(mut r) = get_style_registry().write() {
261 r.set_float("grid_min_col_width", width);
262 }
263 }
264
265 pub fn grid_gap() -> f32 {
266 registry_float("grid_gap").unwrap_or(8.0)
267 }
268
269 pub fn set_grid_gap(gap: f32) {
270 if let Ok(mut r) = get_style_registry().write() {
271 r.set_float("grid_gap", gap);
272 }
273 }
274
275 /// Legacy: the inter-column gap (`style.layout.column.gap`). Unset, it is
276 /// the root plate's [`root_plate_gap`] — columns are siblings on the plate.
277 pub fn column_gap() -> f32 {
278 registry_float("column_gap").unwrap_or_else(root_plate_gap)
279 }
280
281 pub fn set_column_gap(gap: f32) {
282 if let Ok(mut r) = get_style_registry().write() {
283 r.set_float("column_gap", gap);
284 }
285 }
286
287 /// Legacy: a control panel's padding (`style.control.control_panel.padding`).
288 /// Unset, it is the pane rung's [`plate_padding`] — a control panel is a pane.
289 pub fn control_panel_padding() -> f32 {
290 registry_float("control_panel_padding").unwrap_or_else(plate_padding)
291 }
292
293 pub fn set_control_panel_padding(padding: f32) {
294 if let Ok(mut r) = get_style_registry().write() {
295 r.set_float("control_panel_padding", padding);
296 }
297 }
298
299 /// Legacy: a control panel's gap (`style.control.control_panel.gap`).
300 /// Unset, it is the pane rung's [`plate_gap`].
301 pub fn control_panel_gap() -> f32 {
302 registry_float("control_panel_gap").unwrap_or_else(plate_gap)
303 }
304
305 pub fn set_control_panel_gap(gap: f32) {
306 if let Ok(mut r) = get_style_registry().write() {
307 r.set_float("control_panel_gap", gap);
308 }
309 }
310
311 /// The section carves' depth multiplier (`style.container.section.depth`,
312 /// default 1.0): scales the params pane's section-well wall — width and step
313 /// together — relative to the DE-wide relief material, so sections can read
314 /// deeper or shallower than the controls around them. Values past 1.0 let the
315 /// roll widen across the channel groove between the well wall and its packed
316 /// controls; tune to taste.
317 pub fn section_depth() -> f32 {
318 lazy_init_style_registry();
319 get_style_registry().read().unwrap().get_float("section_depth").unwrap_or(1.0)
320 }
321
322 /// The parameter rows' backdrop compression
323 /// (`style.surface.param.backdrop_compression`, 0..1): when set, every
324 /// parameter row in a params pane is floored with the pane material frosted
325 /// at THIS compression before its controls paint, so each parameter sits on
326 /// a tablet that pulls the view toward the tint while the pane around it
327 /// stays at its own — the row-scale twin of the designer's node
328 /// compression. `None` (unset) draws no floor — the rows are bare, as they
329 /// always were.
330 pub fn param_compression() -> Option<f32> {
331 lazy_init_style_registry();
332 get_style_registry().read().unwrap().get_float("param_compression").map(|v| v.clamp(0.0, 1.0))
333 }
334
335 /// Whether a params pane lays each row's label BESIDE its control
336 /// (`style.surface.param.label_layout = "inline"`, the default) or lets the
337 /// control carry it in the strip above itself (`"stacked"`, the layout every
338 /// row had before 2026-09-21). Inline, the pane owns the labels: it measures
339 /// a label column off the widest label, hands each control the rest of the
340 /// row, and the controls are built unlabelled — an unlabelled control takes
341 /// its whole rect (`WidgetHost::label_strip` is zero), so the row is one
342 /// control tall. Toggles and buttons carry their label as their own face and
343 /// are inline either way.
344 pub fn param_labels_inline() -> bool {
345 lazy_init_style_registry();
346 get_style_registry()
347 .read()
348 .unwrap()
349 .get_string("param_label_layout")
350 .is_none_or(|v| v.trim() != "stacked")
351 }
352
353 pub fn section_padding() -> f32 {
354 registry_float("section_padding").unwrap_or(8.0)
355 }
356
357 pub fn set_section_padding(padding: f32) {
358 if let Ok(mut r) = get_style_registry().write() {
359 r.set_float("section_padding", padding);
360 }
361 }
362
363 pub fn spinbox_height() -> f32 {
364 registry_float("spinbox_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
365 }
366
367 pub fn set_spinbox_height(height: f32) {
368 if let Ok(mut r) = get_style_registry().write() {
369 r.set_float("spinbox_height", height);
370 }
371 }
372
373 pub fn toggle_height() -> f32 {
374 registry_float("toggle_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
375 }
376
377 pub fn set_toggle_height(height: f32) {
378 if let Ok(mut r) = get_style_registry().write() {
379 r.set_float("toggle_height", height);
380 }
381 }
382
383 pub fn light_source_position() -> f32 {
384 let val = get_style_registry().read().unwrap().get_float("light_source_position").unwrap_or(2.3561945);
385 if val > 2.0 * std::f32::consts::PI {
386 val.to_radians()
387 } else {
388 val
389 }
390 }
391
392 pub fn bevel_depth() -> f32 {
393 get_style_registry().read().unwrap().get_float("bevel_depth").unwrap_or(0.15)
394 }
395
396 /// Corner shape exponent for SDF-lit plates: 2.0 (the default) is a circular
397 /// arc; higher values are superellipse "squircle" corners with continuous
398 /// curvature — ~4.5 is the Apple-like look. Clamped to [2, 16]: below 2 the
399 /// Lp construction degenerates toward a chamfer, above 16 it is visually a
400 /// square corner and the pow() terms start flirting with f32 range.
401 pub fn corner_shape() -> f32 {
402 lazy_init_style_registry();
403 get_style_registry().read().unwrap().get_float("corner_shape").unwrap_or(2.0).clamp(2.0, 16.0)
404 }
405
406 /// The window silhouette's nominal corner radius: the SHARED config's
407 /// root plate corner_radius, never the per-app override. The compositor clips
408 /// every decorated window with this value (widened by
409 /// [`corner_span_factor`]), so any window-corner arc an app draws itself must
410 /// use it too — even when the app restyles its own plates through its
411 /// override file — or its corners detach from the silhouette (and from the
412 /// desktop grid's cells, which share the same knob).
413 pub fn window_corner_radius() -> f32 {
414 crate::config::get_i64_shared("/style/surface/plate/root/corner_radius", 12) as f32
415 }
416
417 /// The curvature-matched corner-span factor for window-scale squircle corners.
418 /// A raw superellipse of exponent n at a circle's nominal radius turns tighter
419 /// at the diagonal than that circle — its radius of curvature there is
420 /// √2·r / (2^(1/n)·(n − 1)). Scaling the corner span by this factor makes the
421 /// diagonal curvature equal the configured radius, so the corner reads as the
422 /// same size as a circular one (the same reason Apple's continuous corners run
423 /// ~1.5·r along the edge). Exactly 1 at n = 2. Applied to window-scale corners
424 /// only — `Prim::Plate` and the renderer's window-corner clip — never to
425 /// widget-scale radii, which must match the nominal-radius squircles around them.
426 /// The window silhouette's EFFECTIVE corner radius: the shared nominal value
427 /// widened by the corner-span factor — exactly the arc the compositor clips
428 /// every decorated window with, and the radius a root plate's corners must
429 /// wear (RFC Phase 7b; `PlateSpec::radii_for` uses it for window-flagged
430 /// corners). Apps drawing root-surface geometry through non-Plate prims read
431 /// this scalar directly.
432 pub fn window_silhouette_radius() -> f32 {
433 window_corner_radius() * corner_span_factor()
434 }
435
436 pub fn corner_span_factor() -> f32 {
437 corner_span_factor_for(corner_shape())
438 }
439
440 /// The span factor for an explicit corner exponent — what a plate carrying
441 /// its own `shape` (see `scene::paint::Prim::Plate`) scales its radii by.
442 /// Same clamp as [`corner_shape`], so an override cannot reach an exponent
443 /// the shader would not accept.
444 pub fn corner_span_factor_for(n: f32) -> f32 {
445 let n = n.clamp(2.0, 16.0);
446 if n > 2.001 {
447 (n - 1.0) * 2f32.powf(1.0 / n) / std::f32::consts::SQRT_2
448 } else {
449 1.0
450 }
451 }
452
453 /// Whether the relief primitives (see `scene::paint::Prim`) render through
454 /// shader2d's per-pixel SDF-lit branch (the default) or the legacy banded vertex
455 /// shading. `style.surface.relief.shader=(bool)false` (or `0`) flips back to
456 /// the old look for A/B comparison — the registry key keeps the bevel name
457 /// because it selects how the shared lit EDGE is computed, not which shapes
458 /// exist. Until 2026-09-28 the config spelling was `window_manager.bevel_shader`
459 /// (retired, reported), and only a NUMBER worked: a `(bool)` flattens to the
460 /// string "false", which the float read never saw.
461 pub fn bevel_shader() -> bool {
462 lazy_init_style_registry();
463 let reg = get_style_registry().read().unwrap();
464 shader_on(reg.get_float("bevel_shader"), reg.get_string("bevel_shader").as_deref())
465 }
466
467 /// The shader toggle's reading of its registry slot: a number is on unless
468 /// zero, a string is on unless it says `false` / `off` / `no`, and an unset
469 /// slot is on.
470 fn shader_on(float: Option<f32>, string: Option<&str>) -> bool {
471 match (float, string) {
472 (Some(v), _) => v != 0.0,
473 (None, Some(s)) => !matches!(s.trim().to_ascii_lowercase().as_str(), "false" | "off" | "no" | "0"),
474 (None, None) => true,
475 }
476 }
477
478 /// How wide a rolled edge is, in logical px — the distance over which a plate's perimeter
479 /// or a recess wall curves away from the flat surface. `bevel_depth` is the companion
480 /// knob: it sets how hard the light falls across that distance. Wide and shallow reads as
481 /// thick glass; narrow and deep reads as a stamped metal lip.
482 pub fn bevel_width() -> f32 {
483 lazy_init_style_registry();
484 get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(9.3)
485 }
486
487 /// A carve's geometric drop when the material pins one
488 /// (`style.surface.relief.wall.height`, a length — `(mm)0.3` resolves through
489 /// the display metric), in logical px. `None` = follow the wall width at the
490 /// analytic ratio ([`crate::scene::relief_shade::RECESS_DEPTH`]), the look
491 /// every config had before heights existed. A configured 0 reads as unset,
492 /// which is how an editor puts a material back on "follow".
493 pub fn bevel_height() -> Option<f32> {
494 lazy_init_style_registry();
495 get_style_registry()
496 .read()
497 .unwrap()
498 .get_float("bevel_height")
499 .filter(|h| h.is_finite() && *h > 0.0)
500 }
501
502 /// The plate roll's rise when pinned (`style.surface.relief.edge.height`, a
503 /// length), logical px. `None` = a quarter-round of radius `bevel_width`.
504 pub fn roll_height() -> Option<f32> {
505 lazy_init_style_registry();
506 get_style_registry()
507 .read()
508 .unwrap()
509 .get_float("roll_height")
510 .filter(|h| h.is_finite() && *h > 0.0)
511 }
512
513 /// The drop of a carve whose wall runs `wall` logical px: the pinned height
514 /// when there is one, else the analytic ratio of the wall — saturating at the
515 /// DE's roll width, so a wall wider than the plate's own perimeter roll
516 /// spreads the same step over a longer run (a softer transition) instead of
517 /// cutting proportionally deeper. The tessellator's CSG features and the
518 /// shader's free carves both derive from this rule.
519 pub fn carve_depth_px(wall: f32) -> f32 {
520 match bevel_height() {
521 Some(h) => h,
522 None => crate::scene::relief_shade::RECESS_DEPTH * wall.min(bevel_width()),
523 }
524 }
525
526 /// Drop over run for a wall of the DE roll width — what the shading twin
527 /// scales its slopes by.
528 pub fn carve_depth_ratio() -> f32 {
529 let w = bevel_width().max(0.001);
530 carve_depth_px(w) / w
531 }
532
533 /// Rise over run of the plate roll: 1 (the quarter-round) unless pinned.
534 pub fn roll_height_ratio() -> f32 {
535 roll_height().map_or(1.0, |h| h / bevel_width().max(0.001))
536 }
537
538 /// Sample count of the custom bevel profile LUT ([`set_bevel_profile_keys`]).
539 pub const BEVEL_PROFILE_SAMPLES: usize = 32;
540
541 /// The custom bevel/carve height profile, as the slope LUT the renderer uploads
542 /// to the 2D shader: slot `i` holds `h'` at `v = (i + 0.5) / N` of the wall's
543 /// height curve `h(v)` (`v` runs 0 at the surrounding plateau → 1 at the carve
544 /// floor / boss crest; `h` in units of the feature's depth, so a 0→1 curve is
545 /// the classic full-depth bevel and a curve ending back at its start height is
546 /// a pure decorative rim). `None` = the analytic smoothstep profile.
547 static BEVEL_PROFILE: crate::style::StyleCell<Option<[f32; BEVEL_PROFILE_SAMPLES]>> = crate::style::StyleCell::new(|s| &s.layout.BEVEL_PROFILE, |s| &mut s.layout.BEVEL_PROFILE);
548 /// Bumped on every profile change so renderers know to re-upload their LUT.
549 static BEVEL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
550
551 /// The custom EDGE (plate roll) profile — same slope-LUT encoding as
552 /// [`BEVEL_PROFILE`], but read by the shader's `roll_slope` for the perimeter
553 /// roll of widget-scale plates: `v` runs 0 at the face join → 1 at the
554 /// silhouette, and the curve is the roll's descent progress (0 = face height,
555 /// 1 = fully dropped), so the identity curve is a straight chamfer and `None`
556 /// is the analytic superellipse quadrant.
557 static ROLL_PROFILE: crate::style::StyleCell<Option<[f32; BEVEL_PROFILE_SAMPLES]>> = crate::style::StyleCell::new(|s| &s.layout.ROLL_PROFILE, |s| &mut s.layout.ROLL_PROFILE);
558 static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
559
560 pub use cce_core::ramp::sample_ramp_keys;
561
562 #[cfg(test)]
563 mod ramp_sampling_tests {
564 use super::sample_ramp_keys;
565
566 #[test]
567 fn two_key_smooth_is_exactly_smoothstep() {
568 let keys = [(0.0, 0.0), (1.0, 1.0)];
569 for i in 0..=20 {
570 let t = i as f32 / 20.0;
571 let ss = t * t * (3.0 - 2.0 * t);
572 assert!((sample_ramp_keys(&keys, true, t) - ss).abs() < 1e-6, "t={t}");
573 }
574 }
575
576 #[test]
577 fn passes_through_every_key_and_holds_the_ends() {
578 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)];
579 for &(p, v) in &keys {
580 assert!((sample_ramp_keys(&keys, true, p) - v).abs() < 1e-6, "key {p}");
581 }
582 assert_eq!(sample_ramp_keys(&keys, true, -1.0), 0.0);
583 assert_eq!(sample_ramp_keys(&keys, true, 2.0), 1.0);
584 }
585
586 #[test]
587 fn monotone_keys_give_a_monotone_curve_without_wobble() {
588 // The wall profile that came out as a chain of bumps under the old
589 // per-segment smoothstep.
590 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)];
591 let mut last = -1.0f32;
592 let mut slopes = Vec::new();
593 for i in 0..=400 {
594 let t = i as f32 / 400.0;
595 let v = sample_ramp_keys(&keys, true, t);
596 assert!(v >= last - 1e-6, "not monotone at t={t}: {v} < {last}");
597 slopes.push(v - last);
598 last = v;
599 }
600 // No wobble: the slope at an INTERIOR key is a real slope, not the
601 // zero the old blend pinned there (0.35: secants 1.25 and 0.4 either
602 // side — the harmonic mean is well above half the smaller one).
603 let dv = (sample_ramp_keys(&keys, true, 0.355) - sample_ramp_keys(&keys, true, 0.345)) / 0.01;
604 assert!(dv > 0.3, "slope at key 0.35 is {dv}");
605 // …and the slope never flips sign back and forth between keys: at
606 // most one local slope maximum per segment would be a stretch to
607 // assert, so pin the direct symptom — the curve stays inside the
608 // key hull (no overshoot beyond the neighbouring key values).
609 for i in 0..keys.len() - 1 {
610 let (a, b) = (keys[i], keys[i + 1]);
611 for j in 1..10 {
612 let t = a.0 + (b.0 - a.0) * j as f32 / 10.0;
613 let v = sample_ramp_keys(&keys, true, t);
614 assert!(v >= a.1.min(b.1) - 1e-6 && v <= a.1.max(b.1) + 1e-6, "overshoot at t={t}: {v}");
615 }
616 }
617 }
618
619 #[test]
620 fn a_peak_is_flat_at_the_peak_and_never_overshoots() {
621 // The desktop overview speed ramp: rises then falls.
622 let keys = [(0.0, 0.15), (0.4, 1.0), (1.0, 0.1)];
623 assert!((sample_ramp_keys(&keys, true, 0.4) - 1.0).abs() < 1e-6);
624 for i in 0..=100 {
625 let v = sample_ramp_keys(&keys, true, i as f32 / 100.0);
626 assert!((0.1 - 1e-6..=1.0 + 1e-6).contains(&v), "overshoot {v}");
627 }
628 let near = sample_ramp_keys(&keys, true, 0.39);
629 assert!(near > 0.99, "flat at the extremum: {near}");
630 }
631
632 #[test]
633 fn linear_is_untouched() {
634 let keys = [(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)];
635 assert!((sample_ramp_keys(&keys, false, 0.25) - 0.5).abs() < 1e-6);
636 assert!((sample_ramp_keys(&keys, false, 0.75) - 0.5).abs() < 1e-6);
637 }
638 }
639
640 /// Install a custom bevel/carve profile from ramp keys (`(pos, value)`, both
641 /// 0..1, sorted by pos). Sampled into the slope LUT the shader's `carve_slope`
642 /// reads in place of its analytic smoothstep — every recess/boss/ridge wall in
643 /// this process restyles on the next frame. Empty or single-key lists clear
644 /// back to the analytic profile ([`clear_bevel_profile`]).
645 pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
646 let Some(lut) = ramp_profile_lut(keys, smooth) else {
647 clear_bevel_profile();
648 return;
649 };
650 *BEVEL_PROFILE.write().unwrap() = Some(lut);
651 BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
652 }
653
654 /// What a key list installs: its slope LUT, or `None` for a degenerate list
655 /// (fewer than two keys is no curve at all) — the caller clears back to the
656 /// analytic profile. The pure half of `set_*_profile_keys`.
657 fn ramp_profile_lut(keys: &[(f32, f32)], smooth: bool) -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
658 if keys.len() < 2 {
659 return None;
660 }
661 Some(ramp_slope_lut(keys, smooth))
662 }
663
664 /// A ramp key list sampled into the shader's slope LUT — slot `i` holds the
665 /// curve's slope at `v = (i + 0.5) / N`.
666 fn ramp_slope_lut(keys: &[(f32, f32)], smooth: bool) -> [f32; BEVEL_PROFILE_SAMPLES] {
667 let n = BEVEL_PROFILE_SAMPLES;
668 let mut slopes = [0.0f32; BEVEL_PROFILE_SAMPLES];
669 for (i, slot) in slopes.iter_mut().enumerate() {
670 let h0 = sample_ramp_keys(keys, smooth, i as f32 / n as f32);
671 let h1 = sample_ramp_keys(keys, smooth, (i + 1) as f32 / n as f32);
672 *slot = (h1 - h0) * n as f32;
673 }
674 slopes
675 }
676
677 /// Install a custom EDGE profile for the plate perimeter roll from ramp keys —
678 /// the [`set_bevel_profile_keys`] twin for [`ROLL_PROFILE`]. The curve is the
679 /// roll's descent progress from the face join (0) to the silhouette (1); the
680 /// shader's `roll_slope` samples it in place of the analytic superellipse
681 /// quadrant. Empty or single-key lists clear back to the analytic roll.
682 pub fn set_roll_profile_keys(keys: &[(f32, f32)], smooth: bool) {
683 let Some(lut) = ramp_profile_lut(keys, smooth) else {
684 clear_roll_profile();
685 return;
686 };
687 *ROLL_PROFILE.write().unwrap() = Some(lut);
688 ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
689 }
690
691 /// Drop the custom edge profile — plate rolls return to the analytic quadrant.
692 pub fn clear_roll_profile() {
693 let mut guard = ROLL_PROFILE.write().unwrap();
694 if guard.is_some() {
695 *guard = None;
696 ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
697 }
698 }
699
700 /// The installed edge profile's slope LUT, if any — what the renderer uploads.
701 pub fn roll_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
702 *ROLL_PROFILE.read().unwrap()
703 }
704
705 /// Change counter for [`roll_profile_slopes`].
706 pub fn roll_profile_generation() -> u64 {
707 ROLL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
708 }
709
710 /// Drop the custom bevel profile — walls return to the analytic smoothstep.
711 pub fn clear_bevel_profile() {
712 let mut guard = BEVEL_PROFILE.write().unwrap();
713 if guard.is_some() {
714 *guard = None;
715 BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
716 }
717 }
718
719 /// The installed profile's slope LUT, if any — what the renderer uploads.
720 pub fn bevel_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
721 *BEVEL_PROFILE.read().unwrap()
722 }
723
724 /// Change counter for [`bevel_profile_slopes`] — a renderer re-uploads when it
725 /// differs from the generation it last wrote.
726 pub fn bevel_profile_generation() -> u64 {
727 BEVEL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
728 }
729
730 /// Padding between the window plate's edge and the objects sitting on it, in
731 /// logical px (`style.surface.plate.root.padding` in config.kdl). DE-wide so
732 /// every app's content sits the same distance off the plate rim.
733 pub fn root_plate_padding() -> f32 {
734 lazy_init_style_registry();
735 get_style_registry().read().unwrap().get_float("root_plate_padding").unwrap_or(16.0)
736 }
737
738 /// Gap between sibling objects on the window plate, in logical px
739 /// (`style.surface.plate.root.gap`) — pane splits, control rows. The companion to [`root_plate_padding`]:
740 /// rim distance vs object spacing.
741 pub fn root_plate_gap() -> f32 {
742 lazy_init_style_registry();
743 get_style_registry().read().unwrap().get_float("root_plate_gap").unwrap_or(12.0)
744 }
745
746 /// Where content starts on the standard root plate, measured from the
747 /// WINDOW edge: the plate's rolled rim ([`bevel_width`]) plus one
748 /// [`root_plate_padding`]. The padding is a run of flat plate face, the
749 /// same run [`root_plate_gap`] leaves between two siblings; but the face
750 /// only begins where the roll ends, so a bare padding at a window edge
751 /// leaves most of it on the roll — measured at 4px of visible flat against
752 /// 12 between panes (cce-mail, 2026-09-19). This is the one number an app
753 /// on the standard plate insets by at its four edges; between siblings it
754 /// uses the gap, and everything inside a pane plate uses
755 /// [`plate_padding`]. An app whose base is NOT the rolled root plate (a
756 /// transparent surface, a bare fill) has no roll to clear and insets by
757 /// [`root_plate_padding`] alone.
758 pub fn root_plate_inset() -> f32 {
759 bevel_width() + root_plate_padding()
760 }
761
762 /// One style-registry float, initialising the registry on first use — the
763 /// one read every rung getter goes through.
764 ///
765 /// The guard is released before this returns, which is why a getter whose
766 /// unset slot falls back to ANOTHER getter must read through here:
767 /// `get_style_registry().read().unwrap().get_float(k).unwrap_or_else(g)`
768 /// holds its guard to the end of the statement, so `g`'s read nests inside
769 /// it, and std's `RwLock` queues a reader behind a waiting writer — a
770 /// `reload_config` arriving between the two reads parks both, and every
771 /// reader in the process behind them. That hung cce-designer's suite
772 /// (2026-09-25); `tests/style_registry_reentrancy.rs` is the check.
773 fn registry_float(slot: &str) -> Option<f32> {
774 lazy_init_style_registry();
775 get_style_registry().read().unwrap().get_float(slot)
776 }
777
778 /// [`registry_float`] for a switch: a number is on when it is not zero (`set_*` writes
779 /// 1 or 0), a word when it is `true`.
780 fn registry_bool(key: &str) -> Option<bool> {
781 registry_float(key).map(|v| v != 0.0).or_else(|| registry_string(key).map(|v| v == "true"))
782 }
783
784 /// A configured font string parsed into (family, size; 12 when the string names none),
785 /// cached against the string it was parsed from — a reload, a setter or a test's
786 /// per-thread overlay that changes the string parses it again.
787 fn parsed_font(cache: &crate::style::StyleCell<Option<(String, (String, f32))>>, font: String) -> (String, f32) {
788 if let Ok(c) = cache.read() {
789 if let Some((src, val)) = &*c {
790 if *src == font {
791 return val.clone();
792 }
793 }
794 }
795 let (family, size) = parse_font_string(&font);
796 let val = (family, size.unwrap_or(12.0));
797 if let Ok(mut c) = cache.write() {
798 *c = Some((font, val.clone()));
799 }
800 val
801 }
802
803 /// [`registry_float`] for a string key (a font), with the same guard discipline.
804 fn registry_string(key: &str) -> Option<String> {
805 lazy_init_style_registry();
806 get_style_registry().read().unwrap().get_string(key)
807 }
808
809 /// Gap between siblings INSIDE a pane plate, in logical px
810 /// (`style.surface.plate.gap`) — the pane rung's twin of
811 /// [`root_plate_gap`]. Unset, it is the root gap: one number reads as one
812 /// rhythm across both rungs unless a config says otherwise.
813 pub fn plate_gap() -> f32 {
814 registry_float("plate_gap").unwrap_or_else(root_plate_gap)
815 }
816
817 /// Gap between controls, in logical px (`style.control.gap`) — the control
818 /// rung of the ladder: what the layout strategies put between a form's
819 /// controls (and between a detached label's block and the next), in both
820 /// axes. Unset, it is [`CONTROL_GAP`], one control height, the value every
821 /// strategy's `Default` carried as a literal.
822 pub fn control_gap() -> f32 {
823 registry_float("control_gap").unwrap_or(CONTROL_GAP)
824 }
825
826 /// Gap inside a list row, in logical px (`style.control.list_gap`) — the rung below the
827 /// controls: what a scrolling list's row puts between its cells (a row's buttons, its glyph
828 /// and its name) and between its content and the list's wall. A row is one control's height,
829 /// so the control gap (one control height) would part it into islands. Unset, it is
830 /// [`CONTROL_TEXT_INSET`], the inset text keeps from a control's wall.
831 pub fn list_gap() -> f32 {
832 registry_float("list_gap").unwrap_or(CONTROL_TEXT_INSET)
833 }
834
835 /// Roll-off width for the wall where a bar (menubar / status bar / the demo's
836 /// header band) steps down into the window plate. Wider than the plate's own
837 /// perimeter roll on purpose: the carve depth saturates at `bevel_width` in the
838 /// tessellator, so the extra width flattens the wall's slope — a soft, gradual
839 /// transition into the bar — instead of cutting a proportionally deeper groove.
840 pub fn bar_wall_width() -> f32 {
841 bevel_width() * 1.75
842 }
843
844 /// DE-wide default for the controls' relief styling (`window_manager.control_relief`
845 /// in config.kdl, default on): raised Button/Toggle/Dropdown plates, recessed
846 /// TextBox/Slider wells, recessed MenuBar/StatusBar bands. Widgets read this
847 /// LIVE, at paint and layout, so [`set_control_relief`] restyles every control
848 /// in the process at once; the per-widget `with_raised` / `with_recessed` /
849 /// `with_recess` builders pin one widget either way. `0` reverts the whole DE
850 /// to the flat look.
851 pub fn control_relief() -> bool {
852 lazy_init_style_registry();
853 get_style_registry().read().unwrap().get_float("control_relief").map(|v| v != 0.0).unwrap_or(true)
854 }
855
856 /// Switch the controls' relief styling at runtime — the gallery's Style
857 /// dropdown. Every widget without a per-widget override follows on its next
858 /// paint; the caller asks for a rebuild. Not persisted: a config reload puts
859 /// the configured value back.
860 pub fn set_control_relief(relief: bool) {
861 lazy_init_style_registry();
862 get_style_registry().write().unwrap().set_float("control_relief", if relief { 1.0 } else { 0.0 });
863 }
864
865 pub fn toggle_corner_radius() -> f32 {
866 lazy_init_style_registry();
867 registry_float("toggle_corner_radius").unwrap_or_else(control_corner_radius)
868 }
869
870 pub fn set_toggle_corner_radius(radius: f32) {
871 lazy_init_style_registry();
872 if let Ok(mut registry) = get_style_registry().write() {
873 registry.set_float("toggle_corner_radius", radius);
874 }
875 }
876
877 pub fn toggle_border_width() -> f32 {
878 registry_float("toggle_border_width").unwrap_or(1.0)
879 }
880
881 pub fn set_toggle_border_width(width: f32) {
882 if let Ok(mut r) = get_style_registry().write() {
883 r.set_float("toggle_border_width", width);
884 }
885 }
886
887 pub fn slider_corner_radius() -> f32 {
888 lazy_init_style_registry();
889 registry_float("slider_corner_radius").unwrap_or_else(control_corner_radius)
890 }
891
892 /// The slider band's knobs (`style.control.slider.*`): the flat band's thickness,
893 /// and the swell's half-span / peak height around the value position. The band —
894 /// a thin full-range band that swells at the value — is the one slider style.
895 pub fn slider_band_thickness() -> f32 {
896 lazy_init_style_registry();
897 get_style_registry().read().unwrap().get_float("slider_band_thickness").unwrap_or(2.0)
898 }
899
900 pub fn slider_bulge_width() -> f32 {
901 lazy_init_style_registry();
902 get_style_registry().read().unwrap().get_float("slider_bulge_width").unwrap_or(26.0)
903 }
904
905 pub fn slider_bulge_height() -> f32 {
906 lazy_init_style_registry();
907 get_style_registry().read().unwrap().get_float("slider_bulge_height").unwrap_or(14.0)
908 }
909
910 pub fn set_slider_corner_radius(radius: f32) {
911 lazy_init_style_registry();
912 if let Ok(mut registry) = get_style_registry().write() {
913 registry.set_float("slider_corner_radius", radius);
914 }
915 }
916
917 pub fn plate_corner_radius() -> f32 {
918 lazy_init_style_registry();
919 let r = get_style_registry().read().unwrap();
920 r.get_float("plate_corner_radius")
921 .or_else(|| r.get_float("root_plate_corner_radius"))
922 .unwrap_or(12.0)
923 }
924
925 pub fn set_plate_corner_radius(radius: f32) {
926 lazy_init_style_registry();
927 if let Ok(mut registry) = get_style_registry().write() {
928 registry.set_float("plate_corner_radius", radius);
929 }
930 }
931
932 pub fn plate_opacity() -> f32 {
933 registry_float("plate_opacity").unwrap_or(1.0)
934 }
935
936 pub fn set_plate_opacity(opacity: f32) {
937 if let Ok(mut r) = get_style_registry().write() {
938 r.set_float("plate_opacity", opacity);
939 }
940 }
941
942 pub fn page_opacity() -> f32 {
943 registry_float("page_opacity").unwrap_or(1.0)
944 }
945
946 pub fn set_page_opacity(opacity: f32) {
947 if let Ok(mut r) = get_style_registry().write() {
948 r.set_float("page_opacity", opacity);
949 }
950 }
951
952 pub fn layer_opacity() -> f32 {
953 registry_float("layer_opacity").unwrap_or(1.0)
954 }
955
956 pub fn set_layer_opacity(opacity: f32) {
957 if let Ok(mut r) = get_style_registry().write() {
958 r.set_float("layer_opacity", opacity);
959 }
960 }
961
962 pub fn color_selector_height() -> f32 {
963 registry_float("color_selector_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
964 }
965
966 pub fn set_color_selector_height(height: f32) {
967 if let Ok(mut r) = get_style_registry().write() {
968 r.set_float("color_selector_height", height);
969 }
970 }
971
972 pub fn font_selector_height() -> f32 {
973 registry_float("font_selector_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
974 }
975
976 pub fn set_font_selector_height(height: f32) {
977 if let Ok(mut r) = get_style_registry().write() {
978 r.set_float("font_selector_height", height);
979 }
980 }
981
982 pub fn color_selector_font() -> String {
983 registry_string("color_selector_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "monospace".to_string())
984 }
985
986 pub fn set_color_selector_font(font: &str) {
987 if let Ok(mut r) = get_style_registry().write() {
988 r.set_string("color_selector_font", font.to_string());
989 }
990 }
991
992 pub fn menubar_font() -> String {
993 registry_string("menubar_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
994 }
995
996 pub fn menubar_font_parsed() -> (String, f32) {
997 parsed_font(&MENUBAR_FONT_CACHED, menubar_font())
998 }
999
1000 pub fn set_menubar_font(font: &str) {
1001 if let Ok(mut r) = get_style_registry().write() {
1002 r.set_string("menubar_font", font.to_string());
1003 }
1004 }
1005
1006 pub fn statusbar_font() -> String {
1007 registry_string("statusbar_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1008 }
1009
1010 pub fn statusbar_font_parsed() -> (String, f32) {
1011 parsed_font(&STATUSBAR_FONT_CACHED, statusbar_font())
1012 }
1013
1014 pub fn set_statusbar_font(font: &str) {
1015 if let Ok(mut r) = get_style_registry().write() {
1016 r.set_string("statusbar_font", font.to_string());
1017 }
1018 }
1019
1020 pub fn font_selector_font() -> String {
1021 registry_string("font_selector_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1022 }
1023
1024 pub fn font_selector_font_parsed() -> (String, f32) {
1025 parsed_font(&FONT_SELECTOR_FONT_CACHED, font_selector_font())
1026 }
1027
1028 pub fn set_font_selector_font(font: &str) {
1029 if let Ok(mut r) = get_style_registry().write() {
1030 r.set_string("font_selector_font", font.to_string());
1031 }
1032 }
1033
1034 // Button Strip Font
1035 pub fn button_strip_font() -> String {
1036 registry_string("button_strip_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1037 }
1038
1039 pub fn button_strip_font_parsed() -> (String, f32) {
1040 parsed_font(&BUTTON_STRIP_FONT_CACHED, button_strip_font())
1041 }
1042
1043 pub fn set_button_strip_font(font: &str) {
1044 if let Ok(mut r) = get_style_registry().write() {
1045 r.set_string("button_strip_font", font.to_string());
1046 }
1047 }
1048
1049 // Control Label Font
1050 pub fn control_label_font() -> String {
1051 registry_string("control_label_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1052 }
1053
1054 pub fn control_label_font_parsed() -> (String, f32) {
1055 parsed_font(&CONTROL_LABEL_FONT_CACHED, control_label_font())
1056 }
1057
1058 pub fn set_control_label_font(font: &str) {
1059 if let Ok(mut r) = get_style_registry().write() {
1060 r.set_string("control_label_font", font.to_string());
1061 }
1062 }
1063
1064 // Control Label Font Detached
1065 pub fn control_label_font_detached() -> String {
1066 registry_string("control_label_font_detached").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1067 }
1068
1069 pub fn control_label_font_detached_parsed() -> (String, f32) {
1070 parsed_font(&CONTROL_LABEL_FONT_DETACHED_CACHED, control_label_font_detached())
1071 }
1072
1073 pub fn set_control_label_font_detached(font: &str) {
1074 if let Ok(mut r) = get_style_registry().write() {
1075 r.set_string("control_label_font_detached", font.to_string());
1076 }
1077 }
1078
1079 // List Font
1080 pub fn list_font() -> String {
1081 registry_string("list_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1082 }
1083
1084 pub fn list_font_parsed() -> (String, f32) {
1085 parsed_font(&LIST_FONT_CACHED, list_font())
1086 }
1087
1088 pub fn set_list_font(font: &str) {
1089 if let Ok(mut r) = get_style_registry().write() {
1090 r.set_string("list_font", font.to_string());
1091 }
1092 }
1093
1094 // Tree Font
1095 pub fn tree_font() -> String {
1096 registry_string("tree_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1097 }
1098
1099 pub fn tree_font_parsed() -> (String, f32) {
1100 parsed_font(&TREE_FONT_CACHED, tree_font())
1101 }
1102
1103 pub fn set_tree_font(font: &str) {
1104 if let Ok(mut r) = get_style_registry().write() {
1105 r.set_string("tree_font", font.to_string());
1106 }
1107 }
1108
1109 // Graph Font
1110 pub fn graph_font() -> String {
1111 registry_string("graph_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1112 }
1113
1114 pub fn graph_font_parsed() -> (String, f32) {
1115 parsed_font(&GRAPH_FONT_CACHED, graph_font())
1116 }
1117
1118 pub fn set_graph_font(font: &str) {
1119 if let Ok(mut r) = get_style_registry().write() {
1120 r.set_string("graph_font", font.to_string());
1121 }
1122 }
1123
1124 // Graph Node Font
1125 pub fn graph_node_font() -> String {
1126 registry_string("graph_node_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1127 }
1128
1129 pub fn graph_node_font_parsed() -> (String, f32) {
1130 parsed_font(&GRAPH_NODE_FONT_CACHED, graph_node_font())
1131 }
1132
1133 pub fn set_graph_node_font(font: &str) {
1134 if let Ok(mut r) = get_style_registry().write() {
1135 r.set_string("graph_node_font", font.to_string());
1136 }
1137 }
1138
1139 // List Justification
1140 pub fn list_justification() -> u8 {
1141 registry_float("list_justification").map(|v| v as u8).unwrap_or(0)
1142 }
1143
1144 pub fn set_list_justification(just: u8) {
1145 if let Ok(mut r) = get_style_registry().write() {
1146 r.set_float("list_justification", just as f32);
1147 }
1148 }
1149
1150 pub fn section_label_font() -> String {
1151 registry_string("section_label_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1152 }
1153
1154 pub fn set_section_label_font(font: &str) {
1155 if let Ok(mut r) = get_style_registry().write() {
1156 r.set_string("section_label_font", font.to_string());
1157 }
1158 }
1159
1160 pub fn nested_section_label_font() -> String {
1161 registry_string("nested_section_label_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1162 }
1163
1164 pub fn set_nested_section_label_font(font: &str) {
1165 if let Ok(mut r) = get_style_registry().write() {
1166 r.set_string("nested_section_label_font", font.to_string());
1167 }
1168 }
1169
1170 pub fn breadcrumb_font() -> String {
1171 registry_string("breadcrumb_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1172 }
1173
1174 pub fn set_breadcrumb_font(font: &str) {
1175 if let Ok(mut r) = get_style_registry().write() {
1176 r.set_string("breadcrumb_font", font.to_string());
1177 }
1178 }
1179
1180 pub fn button_font() -> String {
1181 registry_string("button_font").filter(|f| !f.is_empty()).unwrap_or_else(|| "Berkeley Mono".to_string())
1182 }
1183
1184 pub fn set_button_font(font: &str) {
1185 if let Ok(mut r) = get_style_registry().write() {
1186 r.set_string("button_font", font.to_string());
1187 }
1188 }
1189
1190 pub fn color_selector_preview_corner_radius() -> f32 {
1191 lazy_init_style_registry();
1192 // Unset: the swatch rounds like the text field beside it (the TextBox radius).
1193 registry_float("color_selector_preview_corner_radius").unwrap_or_else(textbox_corner_radius)
1194 }
1195
1196 pub fn set_color_selector_preview_corner_radius(radius: f32) {
1197 lazy_init_style_registry();
1198 if let Ok(mut registry) = get_style_registry().write() {
1199 registry.set_float("color_selector_preview_corner_radius", radius);
1200 }
1201 }
1202
1203 pub fn color_selector_corner_radius() -> f32 {
1204 lazy_init_style_registry();
1205 // Unset: the selector's frame rounds like the text field it stands in for.
1206 registry_float("color_selector_corner_radius").unwrap_or_else(textbox_corner_radius)
1207 }
1208
1209 pub fn set_color_selector_corner_radius(radius: f32) {
1210 lazy_init_style_registry();
1211 if let Ok(mut registry) = get_style_registry().write() {
1212 registry.set_float("color_selector_corner_radius", radius);
1213 }
1214 }
1215
1216 /// The control rung's corner radius (`style.control.corner_radius`): the
1217 /// default every control-scale radius getter falls back to when the widget's
1218 /// own `corner_radius` key is unset — buttons, dropdowns, font selectors,
1219 /// sliders, spinboxes, text boxes, toggles, and the list and tree wells. The
1220 /// per-widget keys are overrides on top of it. See "Plates, wells and seams"
1221 /// in `CLAUDE.md`; the pane and root rungs are `plate_corner_radius` and
1222 /// `crate::color::root_plate_corner_radius`.
1223 pub fn control_corner_radius() -> f32 {
1224 lazy_init_style_registry();
1225 get_style_registry().read().unwrap().get_float("control_corner_radius").unwrap_or(8.0)
1226 }
1227
1228 pub fn set_control_corner_radius(radius: f32) {
1229 lazy_init_style_registry();
1230 if let Ok(mut registry) = get_style_registry().write() {
1231 registry.set_float("control_corner_radius", radius);
1232 }
1233 }
1234
1235 pub fn button_corner_radius() -> f32 {
1236 lazy_init_style_registry();
1237 registry_float("button_corner_radius").unwrap_or_else(control_corner_radius)
1238 }
1239
1240 pub fn set_button_corner_radius(radius: f32) {
1241 lazy_init_style_registry();
1242 if let Ok(mut registry) = get_style_registry().write() {
1243 registry.set_float("button_corner_radius", radius);
1244 }
1245 }
1246
1247 pub fn spinbox_corner_radius() -> f32 {
1248 lazy_init_style_registry();
1249 registry_float("spinbox_corner_radius").unwrap_or_else(control_corner_radius)
1250 }
1251
1252 pub fn set_spinbox_corner_radius(radius: f32) {
1253 lazy_init_style_registry();
1254 if let Ok(mut registry) = get_style_registry().write() {
1255 registry.set_float("spinbox_corner_radius", radius);
1256 }
1257 }
1258
1259 pub fn spinbox_button_padding() -> f32 {
1260 registry_float("spinbox_button_padding").unwrap_or(0.0)
1261 }
1262
1263 pub fn scrollbar_width() -> f32 {
1264 registry_float("scrollbar_width").unwrap_or(4.0)
1265 }
1266
1267 /// The thickness of a CENTRED scrollbar — one that rides the centre line of
1268 /// what it scrolls, over the content and behind the host's plate until a
1269 /// scroll raises it (the params pane, the spreadsheet, a sink-behind
1270 /// `ScrollRegion`, the designer's dialog). [`scrollbar_width`] widened by
1271 /// 1.6: over rows rather than in a lane of its own, the stock width reads
1272 /// too slim.
1273 pub fn centred_scrollbar_width() -> f32 {
1274 scrollbar_width() * 1.6
1275 }
1276
1277 pub fn set_scrollbar_width(width: f32) {
1278 if let Ok(mut r) = get_style_registry().write() {
1279 r.set_float("scrollbar_width", width);
1280 }
1281 }
1282
1283 /// How far a page-level scrollbar stands off its window/plate right edge — the
1284 /// designer parameter-pane look (config `style.control.scrollbar.inset`).
1285 /// Framed inner lists keep their own tight 4px hug; this is for bars floating
1286 /// over a plate.
1287 pub fn scrollbar_inset() -> f32 {
1288 registry_float("scrollbar_inset").unwrap_or(16.0)
1289 }
1290
1291 pub fn tree_opacity() -> f32 {
1292 registry_float("tree_opacity").unwrap_or(1.0)
1293 }
1294
1295 pub fn set_tree_opacity(opacity: f32) {
1296 if let Ok(mut r) = get_style_registry().write() {
1297 r.set_float("tree_opacity", opacity);
1298 }
1299 }
1300
1301 pub fn tree_blur() -> f32 {
1302 registry_float("tree_blur").unwrap_or(0.0)
1303 }
1304
1305 pub fn set_tree_blur(blur: f32) {
1306 if let Ok(mut r) = get_style_registry().write() {
1307 r.set_float("tree_blur", blur);
1308 }
1309 }
1310
1311 pub fn set_spinbox_button_padding(padding: f32) {
1312 if let Ok(mut r) = get_style_registry().write() {
1313 r.set_float("spinbox_button_padding", padding);
1314 }
1315 }
1316
1317 pub fn textbox_corner_radius() -> f32 {
1318 lazy_init_style_registry();
1319 registry_float("textbox_corner_radius").unwrap_or_else(control_corner_radius)
1320 }
1321
1322 pub fn set_textbox_corner_radius(radius: f32) {
1323 lazy_init_style_registry();
1324 if let Ok(mut registry) = get_style_registry().write() {
1325 registry.set_float("textbox_corner_radius", radius);
1326 }
1327 }
1328
1329 pub fn textbox_line_wrap() -> bool {
1330 registry_bool("textbox_line_wrap").unwrap_or(true)
1331 }
1332
1333 pub fn set_textbox_line_wrap(wrap: bool) {
1334 if let Ok(mut r) = get_style_registry().write() {
1335 r.set_float("textbox_line_wrap", if wrap { 1.0 } else { 0.0 });
1336 }
1337 }
1338
1339 pub fn touchpad_natural_scroll() -> bool {
1340 registry_bool("touchpad_natural_scroll").unwrap_or(false)
1341 }
1342
1343 pub fn set_touchpad_natural_scroll(enabled: bool) {
1344 if let Ok(mut r) = get_style_registry().write() {
1345 r.set_float("touchpad_natural_scroll", if enabled { 1.0 } else { 0.0 });
1346 }
1347 }
1348
1349 pub fn textbox_multiline_border_width() -> f32 {
1350 registry_float("textbox_multiline_border_width").unwrap_or(1.0)
1351 }
1352
1353 pub fn set_textbox_multiline_border_width(width: f32) {
1354 if let Ok(mut r) = get_style_registry().write() {
1355 r.set_float("textbox_multiline_border_width", width);
1356 }
1357 }
1358
1359 pub fn list_corner_radius() -> f32 {
1360 lazy_init_style_registry();
1361 registry_float("list_corner_radius").unwrap_or_else(control_corner_radius)
1362 }
1363
1364 pub fn set_list_corner_radius(radius: f32) {
1365 lazy_init_style_registry();
1366 if let Ok(mut registry) = get_style_registry().write() {
1367 registry.set_float("list_corner_radius", radius);
1368 }
1369 }
1370
1371 pub fn tree_corner_radius() -> f32 {
1372 lazy_init_style_registry();
1373 registry_float("tree_corner_radius").unwrap_or_else(control_corner_radius)
1374 }
1375
1376 pub fn set_tree_corner_radius(radius: f32) {
1377 lazy_init_style_registry();
1378 if let Ok(mut registry) = get_style_registry().write() {
1379 registry.set_float("tree_corner_radius", radius);
1380 }
1381 }
1382
1383 /// The graph grid's pitch along x: the distance from the centre of one
1384 /// vertical grid line to the centre of the next. It is the grid's ONE size
1385 /// per axis — nodes are centred on the lattice intersections. The node body
1386 /// has a size of its own (`graph_node_width` / `graph_node_height`), so a
1387 /// denser grid does not shrink the nodes. The pitch replaced a cell size
1388 /// plus a gap (`spacing_*` was the cell, `gap_col_w` / `gap_row_h` the gap,
1389 /// and a step was the two added up); the defaults are what those two used
1390 /// to add up to, so a config that set neither draws the same lattice it did.
1391 pub fn graph_spacing_x() -> f32 {
1392 lazy_init_style_registry();
1393 get_style_registry().read().unwrap().get_float("graph_spacing_x").unwrap_or(187.5)
1394 }
1395
1396 pub fn set_graph_spacing_x(spacing: f32) {
1397 lazy_init_style_registry();
1398 if let Ok(mut registry) = get_style_registry().write() {
1399 registry.set_float("graph_spacing_x", spacing);
1400 }
1401 }
1402
1403 /// The graph grid's pitch along y — see [`graph_spacing_x`].
1404 pub fn graph_spacing_y() -> f32 {
1405 lazy_init_style_registry();
1406 get_style_registry().read().unwrap().get_float("graph_spacing_y").unwrap_or(112.5)
1407 }
1408
1409 pub fn set_graph_spacing_y(spacing: f32) {
1410 lazy_init_style_registry();
1411 if let Ok(mut registry) = get_style_registry().write() {
1412 registry.set_float("graph_spacing_y", spacing);
1413 }
1414 }
1415
1416 /// The drawn width of a graph grid line, in logical px. The pitch is
1417 /// measured centre to centre, so this changes how heavy the lattice looks
1418 /// and nothing about where anything sits. Not scaled by zoom — a lattice is
1419 /// a reference, not a thing in the scene.
1420 pub fn graph_line_width() -> f32 {
1421 lazy_init_style_registry();
1422 get_style_registry().read().unwrap().get_float("graph_line_width").unwrap_or(1.0)
1423 }
1424
1425 pub fn set_graph_line_width(width: f32) {
1426 lazy_init_style_registry();
1427 if let Ok(mut registry) = get_style_registry().write() {
1428 registry.set_float("graph_line_width", width);
1429 }
1430 }
1431
1432 /// The node body's width at 100% zoom (`style.surface.graph.node.width`),
1433 /// independent of the grid pitch: a node is a thing of its own size sitting
1434 /// on a crossing, and the grid is a reference under it. The default is the
1435 /// cell the old grid gave a node.
1436 pub fn graph_node_width() -> f32 {
1437 lazy_init_style_registry();
1438 get_style_registry().read().unwrap().get_float("graph_node_width").unwrap_or(150.0)
1439 }
1440
1441 pub fn set_graph_node_width(width: f32) {
1442 lazy_init_style_registry();
1443 if let Ok(mut registry) = get_style_registry().write() {
1444 registry.set_float("graph_node_width", width);
1445 }
1446 }
1447
1448 /// The node body's height at 100% zoom — see [`graph_node_width`].
1449 pub fn graph_node_height() -> f32 {
1450 lazy_init_style_registry();
1451 get_style_registry().read().unwrap().get_float("graph_node_height").unwrap_or(75.0)
1452 }
1453
1454 pub fn set_graph_node_height(height: f32) {
1455 lazy_init_style_registry();
1456 if let Ok(mut registry) = get_style_registry().write() {
1457 registry.set_float("graph_node_height", height);
1458 }
1459 }
1460
1461 pub fn graph_grid_snap() -> bool {
1462 lazy_init_style_registry();
1463 get_style_registry().read().unwrap().get_float("graph_grid_snap").unwrap_or(0.0) != 0.0
1464 }
1465
1466 pub fn set_graph_grid_snap(snap: bool) {
1467 lazy_init_style_registry();
1468 if let Ok(mut registry) = get_style_registry().write() {
1469 registry.set_float("graph_grid_snap", if snap { 1.0 } else { 0.0 });
1470 }
1471 }
1472
1473 pub fn graph_blur() -> f32 {
1474 lazy_init_style_registry();
1475 get_style_registry().read().unwrap().get_float("graph_blur").unwrap_or(0.0)
1476 }
1477
1478 pub fn set_graph_blur(blur: f32) {
1479 lazy_init_style_registry();
1480 if let Ok(mut registry) = get_style_registry().write() {
1481 registry.set_float("graph_blur", blur);
1482 }
1483 }
1484
1485 pub fn graph_node_corner_radius() -> f32 {
1486 lazy_init_style_registry();
1487 get_style_registry().read().unwrap().get_float("graph_node_corner_radius").unwrap_or(4.0)
1488 }
1489
1490 pub fn set_graph_node_corner_radius(radius: f32) {
1491 lazy_init_style_registry();
1492 if let Ok(mut registry) = get_style_registry().write() {
1493 registry.set_float("graph_node_corner_radius", radius);
1494 }
1495 }
1496
1497 pub fn graph_node_delete() -> String {
1498 lazy_init_style_registry();
1499 get_style_registry().read().unwrap().get_string("graph_node_delete").unwrap_or_else(|| "delete".to_string())
1500 }
1501
1502 pub fn set_graph_node_delete(key: String) {
1503 lazy_init_style_registry();
1504 if let Ok(mut registry) = get_style_registry().write() {
1505 registry.set_string("graph_node_delete", key);
1506 }
1507 }
1508
1509 /// `style.surface.graph.node.wire_style`: how a wire runs, by
1510 /// `WireStyle::name` — `orthogonal`, `rounded`, `bezier` or `straight`.
1511 /// `None` when the config does not say.
1512 pub fn graph_wire_style() -> Option<String> {
1513 lazy_init_style_registry();
1514 get_style_registry().read().unwrap().get_string("graph_wire_style")
1515 }
1516
1517 pub fn graph_wire_size() -> f32 {
1518 lazy_init_style_registry();
1519 get_style_registry().read().unwrap().get_float("graph_wire_size").unwrap_or(6.0)
1520 }
1521
1522 pub fn set_graph_wire_size(size: f32) {
1523 lazy_init_style_registry();
1524 if let Ok(mut registry) = get_style_registry().write() {
1525 registry.set_float("graph_wire_size", size);
1526 }
1527 }
1528
1529 pub fn graph_wire_activation_radius() -> f32 {
1530 lazy_init_style_registry();
1531 get_style_registry().read().unwrap().get_float("graph_wire_activation_radius").unwrap_or(9.0)
1532 }
1533
1534 pub fn set_graph_wire_activation_radius(radius: f32) {
1535 lazy_init_style_registry();
1536 if let Ok(mut registry) = get_style_registry().write() {
1537 registry.set_float("graph_wire_activation_radius", radius);
1538 }
1539 }
1540
1541 pub fn graph_connector_size() -> f32 {
1542 lazy_init_style_registry();
1543 get_style_registry().read().unwrap().get_float("graph_connector_size").unwrap_or(8.0)
1544 }
1545
1546 pub fn set_graph_connector_size(size: f32) {
1547 lazy_init_style_registry();
1548 if let Ok(mut registry) = get_style_registry().write() {
1549 registry.set_float("graph_connector_size", size);
1550 }
1551 }
1552
1553 pub fn graph_connector_activation_radius() -> f32 {
1554 lazy_init_style_registry();
1555 get_style_registry().read().unwrap().get_float("graph_connector_activation_radius").unwrap_or(12.0)
1556 }
1557
1558 pub fn set_graph_connector_activation_radius(radius: f32) {
1559 lazy_init_style_registry();
1560 if let Ok(mut registry) = get_style_registry().write() {
1561 registry.set_float("graph_connector_activation_radius", radius);
1562 }
1563 }
1564
1565 pub fn font_selector_corner_radius() -> f32 {
1566 lazy_init_style_registry();
1567 registry_float("font_selector_corner_radius").unwrap_or_else(control_corner_radius)
1568 }
1569
1570 pub fn set_font_selector_corner_radius(radius: f32) {
1571 lazy_init_style_registry();
1572 if let Ok(mut registry) = get_style_registry().write() {
1573 registry.set_float("font_selector_corner_radius", radius);
1574 }
1575 }
1576
1577 /// The context menu's corner radius (`style.surface.menu.corner_radius`),
1578 /// falling back to the control rung's. It used to take the pane radius
1579 /// (`plate_corner_radius`, 12 in the shipped config), which is a corner
1580 /// too wide for a surface whose labels sit 8px in from its edge: the first
1581 /// row's text ran off the plate through the arc. A menu is a popover, and
1582 /// its corner belongs to the control scale, like the dropdown's.
1583 pub fn menu_corner_radius() -> f32 {
1584 lazy_init_style_registry();
1585 registry_float("menu_corner_radius").unwrap_or_else(control_corner_radius)
1586 }
1587
1588 pub fn dropdown_corner_radius() -> f32 {
1589 lazy_init_style_registry();
1590 registry_float("dropdown_corner_radius").unwrap_or_else(control_corner_radius)
1591 }
1592
1593 pub fn set_dropdown_corner_radius(radius: f32) {
1594 lazy_init_style_registry();
1595 if let Ok(mut registry) = get_style_registry().write() {
1596 registry.set_float("dropdown_corner_radius", radius);
1597 }
1598 }
1599
1600 pub fn color_selector_preview_margin() -> f32 {
1601 registry_float("color_selector_preview_margin").unwrap_or(0.0)
1602 }
1603
1604 pub fn set_color_selector_preview_margin(margin: f32) {
1605 if let Ok(mut r) = get_style_registry().write() {
1606 r.set_float("color_selector_preview_margin", margin);
1607 }
1608 }
1609
1610 pub fn paginator_tab_padding_x() -> f32 {
1611 registry_float("paginator_tab_padding_x").unwrap_or(10.0)
1612 }
1613
1614 pub fn set_paginator_tab_padding_x(padding: f32) {
1615 if let Ok(mut r) = get_style_registry().write() {
1616 r.set_float("paginator_tab_padding_x", padding);
1617 }
1618 }
1619
1620 pub fn button_padding() -> f32 {
1621 registry_float("button_padding")
1622 .or_else(|| registry_float("paginator_tab_padding_y"))
1623 .unwrap_or(14.0)
1624 }
1625
1626 pub fn set_button_padding(padding: f32) {
1627 if let Ok(mut r) = get_style_registry().write() {
1628 r.set_float("button_padding", padding);
1629 }
1630 }
1631
1632 pub fn button_height() -> f32 {
1633 registry_float("button_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
1634 }
1635
1636 pub fn ramp_height() -> f32 {
1637 registry_float("ramp_height").unwrap_or(32.0)
1638 }
1639
1640 pub fn set_ramp_height(height: f32) {
1641 if let Ok(mut r) = get_style_registry().write() {
1642 r.set_float("ramp_height", height);
1643 }
1644 }
1645
1646 pub fn set_button_height(height: f32) {
1647 if let Ok(mut r) = get_style_registry().write() {
1648 r.set_float("button_height", height);
1649 }
1650 }
1651
1652 pub fn button_strip_spacing() -> f32 {
1653 registry_float("button_strip_spacing").unwrap_or(8.0)
1654 }
1655
1656 pub fn set_button_strip_spacing(spacing: f32) {
1657 if let Ok(mut r) = get_style_registry().write() {
1658 r.set_float("button_strip_spacing", spacing);
1659 }
1660 }
1661
1662 pub fn paginator_tab_padding_y() -> f32 {
1663 button_padding()
1664 }
1665
1666 pub fn set_paginator_tab_padding_y(padding: f32) {
1667 set_button_padding(padding);
1668 }
1669
1670 pub fn textbox_height() -> f32 {
1671 registry_float("textbox_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
1672 }
1673
1674 pub fn set_textbox_height(height: f32) {
1675 if let Ok(mut r) = get_style_registry().write() {
1676 r.set_float("textbox_height", height);
1677 }
1678 }
1679
1680 pub fn dropdown_height() -> f32 {
1681 registry_float("dropdown_height").unwrap_or(DEFAULT_CONTROL_HEIGHT)
1682 }
1683
1684 pub fn set_dropdown_height(height: f32) {
1685 if let Ok(mut r) = get_style_registry().write() {
1686 r.set_float("dropdown_height", height);
1687 }
1688 }
1689
1690 pub fn slider_height() -> f32 {
1691 registry_float("slider_height").unwrap_or(DEFAULT_TRACK_HEIGHT)
1692 }
1693
1694 pub fn set_slider_height(height: f32) {
1695 if let Ok(mut r) = get_style_registry().write() {
1696 r.set_float("slider_height", height);
1697 }
1698 }
1699
1700 pub fn progressbar_height() -> f32 {
1701 registry_float("progressbar_height").unwrap_or(DEFAULT_TRACK_HEIGHT)
1702 }
1703
1704 pub fn set_progressbar_height(height: f32) {
1705 if let Ok(mut r) = get_style_registry().write() {
1706 r.set_float("progressbar_height", height);
1707 }
1708 }
1709
1710 pub fn rangeslider_height() -> f32 {
1711 registry_float("rangeslider_height").unwrap_or(DEFAULT_TRACK_HEIGHT)
1712 }
1713
1714 pub fn set_rangeslider_height(height: f32) {
1715 if let Ok(mut r) = get_style_registry().write() {
1716 r.set_float("rangeslider_height", height);
1717 }
1718 }
1719
1720 /// The DE's font families, from the shared config's `fonts { }` block:
1721 /// `(sans_serif, serif, monospace, terminal)`.
1722 ///
1723 /// These used to live in `~/.config/fontconfig/fonts.conf`, read back out of
1724 /// fontconfig's XML by alias. Three of the seven aliases it carried
1725 /// (`window-borders`, `status-interface`, `fuzzel`) were cce inventions
1726 /// squatting in fontconfig's family namespace, and by the end none of them was
1727 /// read by anything: window borders lost their text when titlebars went away,
1728 /// the status bar moved to `module { font }` / `/style/status/font` in KDL and
1729 /// only ever consulted the alias as a last-resort fallback, and fuzzel was
1730 /// replaced by cce-cloud. The settings app's Fonts page — which edited that
1731 /// file, rewriting it wholesale and preserving only its `<dir>` lines — went
1732 /// with them.
1733 ///
1734 /// fonts.conf is still fontconfig's file and still governs GTK/Electron apps;
1735 /// cce simply no longer reads or writes it. Per-app overrides work here because
1736 /// this reads the merged config, the same way the status bar's font does.
1737 pub fn read_preferred_fonts() -> (String, String, String, String) {
1738 let get = |key: &str, fallback: &str| {
1739 crate::config::get_string(&format!("/fonts/{key}"))
1740 .filter(|s| !s.trim().is_empty())
1741 .unwrap_or_else(|| fallback.to_string())
1742 };
1743 (
1744 get("sans_serif", "Noto Sans"),
1745 get("serif", "Noto Serif"),
1746 get("monospace", "Noto Sans Mono"),
1747 get("terminal", "Noto Sans Mono"),
1748 )
1749 }
1750
1751 #[cfg(test)]
1752 mod tests {
1753 use crate::widget::WidgetHost;
1754
1755 #[test]
1756 fn unit_slots_resolve_through_the_metric_at_read_time() {
1757 use crate::units::{Len, Metric, MetricSource};
1758 let mut reg = super::StyleRegistry::new();
1759 reg.set_len("probe_width", Len::mm(2.0));
1760 // `get_float` resolves against the PROCESS metric at read time — the
1761 // same number `Len::to_px` gives — so it tracks whatever the metric
1762 // is now, not what it was at load. (The metric itself is left alone:
1763 // it is process-global and the suite runs in parallel.)
1764 let live = reg.get_float("probe_width").unwrap();
1765 assert!((live - Len::mm(2.0).to_px()).abs() < 1e-4, "{live}");
1766 // Two metrics give two answers for the one stored length.
1767 let assumed = Metric::assumed(1.0);
1768 let panel = Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap();
1769 let (a, b) = (Len::mm(2.0).resolve(&assumed), Len::mm(2.0).resolve(&panel));
1770 assert!((a - 2.0 * 96.0 / 25.4).abs() < 1e-3, "{a}");
1771 assert!((b - 2.0 * panel.px_per_mm).abs() < 1e-3, "{b}");
1772 assert_ne!(a, b);
1773 assert_eq!(reg.get_len("probe_width"), Some(Len::mm(2.0)));
1774 // A plain number written later wins, and reads back as px.
1775 reg.set_float("probe_width", 7.0);
1776 assert_eq!(reg.get_float("probe_width"), Some(7.0));
1777 assert_eq!(reg.get_len("probe_width"), Some(Len::px(7.0)));
1778 }
1779
1780 use super::*;
1781
1782 /// A graph's wires and ports reach a flat host: `render_widget` hands
1783 /// its strokes to `RenderTarget::line` (and arcs and discs to `arc` and
1784 /// `circle`), where it used to drop them, so cce-files' Graph page drew
1785 /// nodes and no wires.
1786 #[test]
1787 fn a_graphs_wires_reach_a_flat_host() {
1788 #[derive(Default)]
1789 struct Strokes {
1790 lines: usize,
1791 circles: usize,
1792 }
1793 impl RenderTarget for Strokes {
1794 fn rect(&mut self, _: [f32; 4], _: f32, _: f32, _: f32, _: f32) {}
1795 fn text(&mut self, _: &str, _: f32, _: f32, _: f32, _: [f32; 4]) {}
1796 fn line(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: [f32; 4], _: crate::scene::paint::Cap) {
1797 self.lines += 1;
1798 }
1799 fn arc(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32, _: [f32; 4]) {
1800 self.lines += 1;
1801 }
1802 fn circle(&mut self, _: f32, _: f32, _: f32, _: [f32; 4]) {
1803 self.circles += 1;
1804 }
1805 }
1806 use crate::widget::GraphController;
1807 let node = |name: &str, pos: (f32, f32), input: Option<&str>| crate::widget::GraphNode {
1808 id: name.to_string(),
1809 name: name.to_string(),
1810 position: pos,
1811 parameters: input.map(|i| vec![("input".to_string(), i.to_string(), "string".to_string())]).unwrap_or_default(),
1812 geom_visible: true,
1813 node_type: String::new(),
1814 inputs: 1,
1815 outputs: 1,
1816 };
1817 let mut graph = crate::widget::Graph::new();
1818 graph.set_grid_origin(100.0, 100.0);
1819 graph.set_nodes(&[node("a", (0.0, 0.0), None), node("b", (0.0, 1.0), Some("a"))]);
1820 let mut pc = Strokes::default();
1821 let mut ctx = crate::context::UiContext::new();
1822 render_widget(&mut pc, &mut graph, 0.0, 0.0, 600.0, 600.0, &mut ctx);
1823 assert!(pc.lines > 0, "the wire a -> b reached the host");
1824 assert!(pc.circles > 0, "the ports reached the host");
1825 }
1826
1827 /// A widget's glyph reaches a flat host: `render_widget` hands the
1828 /// dropdown's arrow to the host's `RenderTarget::icon` by name. It used
1829 /// to drop every image, so after the toolkit's symbols became glyphs a
1830 /// flat host showed a dropdown with no arrow. Skipped where the icon set
1831 /// is not checked out (no glyph uploads, so there is nothing to name).
1832 #[test]
1833 fn a_widget_glyph_reaches_a_flat_host() {
1834 if !std::path::Path::new(&crate::icons_dir()).join("chevron-down.svg").is_file() {
1835 eprintln!("skipped: no icon set");
1836 return;
1837 }
1838 #[derive(Default)]
1839 struct Icons(Vec<String>);
1840 impl RenderTarget for Icons {
1841 fn rect(&mut self, _: [f32; 4], _: f32, _: f32, _: f32, _: f32) {}
1842 fn text(&mut self, _: &str, _: f32, _: f32, _: f32, _: [f32; 4]) {}
1843 fn icon(&mut self, name: &str, _: crate::scene::layout::Rect, _: [f32; 4]) {
1844 self.0.push(name.to_string());
1845 }
1846 }
1847 let mut pc = Icons::default();
1848 let mut ctx = crate::context::UiContext::new();
1849 let mut dd = crate::widget::Dropdown::new(vec!["One".to_string(), "Two".to_string()], 0);
1850 render_widget(&mut pc, &mut dd, 10.0, 10.0, 160.0, 24.0, &mut ctx);
1851 assert!(pc.0.iter().any(|n| n == "chevron-down"), "the arrow reached the host: {:?}", pc.0);
1852 }
1853
1854
1855
1856
1857
1858
1859 struct MockWidget {
1860 base: crate::widget::Widget,
1861 x: f32,
1862 y: f32,
1863 w: f32,
1864 h: f32,
1865 }
1866
1867 impl WidgetHost for MockWidget {
1868 crate::impl_widget_base!(MockWidget);
1869 fn rect(&self) -> (f32, f32, f32, f32) {
1870 (self.x, self.y, self.w, self.h)
1871 }
1872 fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
1873 self.x = x;
1874 self.y = y;
1875 self.w = w;
1876 self.h = h;
1877 }
1878 }
1879
1880 #[test]
1881 fn test_grid_layout() {
1882 // Test single column layout (width = 200, min_col_width = 300)
1883 let grid1 = Grid::new(10.0, 20.0, 200.0, 300.0, 10.0, 1);
1884 assert_eq!(grid1.col_heights.len(), 1);
1885 assert_eq!(grid1.col_lefts[0], 10.0);
1886 assert_eq!(grid1.col_width, 200.0);
1887
1888 // Test multi column layout (width = 700, min_col_width = 300, gap = 20)
1889 // count = floor((700 + 20) / (300 + 20)) = floor(720 / 320) = 2.
1890 // total_gap = 20 * 1 = 20.
1891 // col_width = (700 - 20) / 2 = 340.
1892 let mut grid2 = Grid::new(5.0, 15.0, 700.0, 300.0, 20.0, 2);
1893 assert_eq!(grid2.col_heights.len(), 2);
1894 assert_eq!(grid2.col_lefts[0], 5.0);
1895 assert_eq!(grid2.col_lefts[1], 365.0);
1896 assert_eq!(grid2.col_width, 340.0);
1897
1898 assert_eq!(grid2.next_column(), 0);
1899 grid2.col_heights[0] += 50.0; // Column 0 height becomes 65.0
1900 assert_eq!(grid2.next_column(), 1);
1901 grid2.col_heights[1] += 30.0; // Column 1 height becomes 45.0
1902 assert_eq!(grid2.next_column(), 1);
1903 grid2.col_heights[1] += 30.0; // Column 1 height becomes 75.0
1904 assert_eq!(grid2.next_column(), 0);
1905
1906 assert_eq!(grid2.max_height(), 75.0);
1907 }
1908
1909
1910 /// These keys have ONE home, the style registry (since 2026-10-08): the setter writes
1911 /// it, the getter reads it, an unset key is its default, and a length in other units
1912 /// resolves through the metric — which the slot each used to have, filled by a scan
1913 /// of `key = number` lines, ignored. Writes go to this thread's test overlay, so no
1914 /// other test sees them.
1915 #[test]
1916 fn a_style_key_lives_in_the_registry_alone() {
1917 set_button_height(33.0);
1918 assert_eq!(button_height(), 33.0);
1919 set_nested_section_label_alignment(2);
1920 assert_eq!(nested_section_label_alignment(), 2);
1921 set_dropdown_height(31.0);
1922 assert_eq!(dropdown_height(), 31.0);
1923
1924 set_section_label_font("Circe Slab A 12");
1925 assert_eq!(section_label_font(), "Circe Slab A 12");
1926 set_section_label_font("");
1927 assert_eq!(section_label_font(), "Berkeley Mono", "an empty font falls back");
1928
1929 // `button_padding` reads `paginator_tab_padding_y` when it has none of its own.
1930 get_style_registry().write().unwrap().set_float("paginator_tab_padding_y", 5.0);
1931 assert_eq!(button_padding(), 5.0);
1932 set_button_padding(9.0);
1933 assert_eq!(button_padding(), 9.0, "its own wins");
1934
1935 let len = crate::units::Len::mm(5.0);
1936 get_style_registry().write().unwrap().set_len("textbox_height", len);
1937 assert_eq!(textbox_height(), len.to_px(), "a length in mm is honoured");
1938 }
1939
1940 /// Another program's config keeps its own name in the registry: the compositor's
1941 /// `layout { grid_gap 18 }` (its window-tiling gap) is `layout.grid_gap`, never the
1942 /// toolkit's `grid_gap`, which the settings app's section flow reads. A path the
1943 /// toolkit maps still lands on its key, and a flat top-level key stays flat.
1944 #[test]
1945 fn another_programs_keys_do_not_become_the_toolkits() {
1946 let val = crate::config::parse_kdl_to_json(
1947 "layout {\n grid_gap (i64)18\n gap (i64)48\n}\ntransparency {\n plate_opacity (f64)0.5\n}\nwindow_manager {\n control_relief (bool)true\n}\nstyle {\n control {\n button height=(i64)30\n }\n}\nplate_corner_radius (i64)14\n",
1948 );
1949 let mut flat = String::new();
1950 super::flatten_json_to_flat_props(&val, "", &mut flat);
1951 let keys: Vec<&str> = flat.lines().filter_map(|l| l.split('=').next()).map(str::trim).collect();
1952 for leaked in ["grid_gap", "gap", "plate_opacity"] {
1953 assert!(!keys.contains(&leaked), "{leaked} leaked into the toolkit's keys: {keys:?}");
1954 }
1955 for kept in ["layout.grid_gap", "layout.gap", "transparency.plate_opacity", "control_relief", "button_height", "plate_corner_radius"] {
1956 assert!(keys.contains(&kept), "{kept} missing: {keys:?}");
1957 }
1958 }
1959
1960 #[test]
1961 fn test_column_gap() {
1962 // A legacy key: set (by config or setter) it is honoured; unset it
1963 // lands on the ladder — the root plate's gap.
1964 let gap = column_gap();
1965 match registry_float("column_gap") {
1966 Some(v) => assert_eq!(gap, v),
1967 None => assert_eq!(gap, root_plate_gap()),
1968 }
1969 assert!(gap.is_finite() && gap >= 0.0, "column gap {gap}");
1970 }
1971
1972 #[test]
1973 fn spacing_ladder_falls_back_rung_by_rung() {
1974 // Every rung getter is finite and non-negative, and the inset is the
1975 // roll plus the padding, whatever the config says.
1976 for v in [root_plate_padding(), root_plate_gap(), root_plate_inset(), plate_padding(), plate_gap(), control_gap()] {
1977 assert!(v.is_finite() && v >= 0.0, "{v}");
1978 }
1979 assert_eq!(root_plate_inset(), bevel_width() + root_plate_padding());
1980 if registry_float("plate_gap").is_none() {
1981 assert_eq!(plate_gap(), root_plate_gap());
1982 }
1983 if registry_float("control_gap").is_none() {
1984 assert_eq!(control_gap(), CONTROL_GAP);
1985 }
1986 }
1987
1988 #[test]
1989 fn test_print_fonts() {
1990 let db = crate::widget::get_font_db();
1991 for face in db.faces() {
1992 println!("FAMILY: {:?}", face.families);
1993 }
1994 }
1995
1996
1997
1998
1999
2000 #[test]
2001 fn test_spinbox_button_padding_config() {
2002 let padding = spinbox_button_padding();
2003 println!("Parsed spinbox button padding: {}", padding);
2004 assert!(padding >= 0.0);
2005 }
2006
2007 /// The relief's two shapes are nodes — `relief { wall … ; edge … }` —
2008 /// and each of their keys flattens to the registry key the flat legacy
2009 /// spelling always did, so nothing downstream of the registry moved.
2010 #[test]
2011 fn relief_wall_and_edge_nodes_flatten_to_the_legacy_registry_keys() {
2012 let val: serde_json::Value = serde_json::json!({
2013 "style": { "surface": { "relief": {
2014 "width": 9.3,
2015 "wall": { "height": "0.3mm", "profile": "smooth;0:0,1:1" },
2016 "edge": { "height": 4.0, "profile": "smooth;0:0,1:0.9" }
2017 } } }
2018 });
2019 let mut flat = String::new();
2020 flatten_json_to_flat_props(&val, "", &mut flat);
2021 // A flat line is `key = value`, strings quoted — what reload_config parses.
2022 let value = |k: &str| {
2023 flat.lines()
2024 .find(|l| l.starts_with(&format!("{k} = ")))
2025 .map(|l| l[k.len() + 3..].trim_matches('"').to_string())
2026 };
2027 assert_eq!(value("bevel_height").as_deref(), Some("0.3mm"), "{flat}");
2028 assert_eq!(value("roll_height").as_deref(), Some("4"), "{flat}");
2029 assert_eq!(value("bevel_profile_spec").as_deref(), Some("smooth;0:0,1:1"), "{flat}");
2030 assert_eq!(value("roll_profile_spec").as_deref(), Some("smooth;0:0,1:0.9"), "{flat}");
2031 // The retired spellings — the flat geometry keys, `depth`, and the
2032 // knob keys that are editor state — reach NO registry key: a config
2033 // that says only these draws the defaults, and the load-time report
2034 // (`color::retired_surface_keys`) is what says why.
2035 let old: serde_json::Value = serde_json::json!({
2036 "style": { "surface": { "relief": {
2037 "depth": 0.3,
2038 "height": 2.0, "edge_height": 3.0, "profile": "a", "edge_profile": "b",
2039 "wall": { "knobs": "1,1,1" }, "edge_knobs": "2,2,2"
2040 } } },
2041 "window_manager": { "bevel_depth": 0.3, "bevel_width": 5.0, "bevel_shader": 0 }
2042 });
2043 let mut flat = String::new();
2044 flatten_json_to_flat_props(&old, "", &mut flat);
2045 for k in ["bevel_depth", "bevel_width", "bevel_shader", "bevel_height", "roll_height", "bevel_profile_spec", "roll_profile_spec", "profile_knobs"] {
2046 assert!(!flat.lines().any(|l| l.starts_with(&format!("{k} = "))), "{k} landed from a retired spelling: {flat}");
2047 }
2048 // And a file carrying BOTH spellings is what its current one says,
2049 // with no precedence pass in between — the retired one is not read.
2050 let both: serde_json::Value = serde_json::json!({
2051 "style": { "surface": { "relief": {
2052 "light": 0.15, "depth": 0.9,
2053 "height": 2.0, "wall": { "height": 5.0 },
2054 "edge_profile": "old", "edge": { "profile": "new" }
2055 } } }
2056 });
2057 let mut flat = String::new();
2058 flatten_json_to_flat_props(&both, "", &mut flat);
2059 assert_eq!(flat.lines().filter(|l| l.starts_with("bevel_height = ")).count(), 1, "{flat}");
2060 assert!(flat.contains("bevel_height = 5") && flat.contains("bevel_depth = 0.15"), "{flat}");
2061 assert!(flat.contains("roll_profile_spec = \"new\""), "{flat}");
2062 }
2063
2064 /// The relief's shader toggle is `style.surface.relief.shader`, and a
2065 /// `(bool)` works there: it flattens to the string "false", which the
2066 /// getter reads (a number was the only thing the old float read saw).
2067 #[test]
2068 fn the_shader_toggle_lives_with_the_relief_and_takes_a_bool() {
2069 let val: serde_json::Value = serde_json::json!({
2070 "style": { "surface": { "relief": { "shader": false } } }
2071 });
2072 let mut flat = String::new();
2073 flatten_json_to_flat_props(&val, "", &mut flat);
2074 assert!(flat.lines().any(|l| l == "bevel_shader = false"), "{flat}");
2075 assert!(shader_on(None, None), "unset: the SDF branch");
2076 assert!(shader_on(Some(1.0), None) && !shader_on(Some(0.0), Some("true")), "a number decides when present");
2077 assert!(!shader_on(None, Some("false")) && !shader_on(None, Some("off")) && shader_on(None, Some("true")));
2078 }
2079
2080 /// The control rung's key flattens to `control_corner_radius`, beside a
2081 /// widget's own override — the config shape `control corner_radius=8 { button corner_radius=10 }`.
2082 #[test]
2083 fn control_rung_radius_flattens_beside_widget_overrides() {
2084 let val: serde_json::Value = serde_json::json!({
2085 "style": { "control": { "corner_radius": 8, "button": { "corner_radius": 10 } } }
2086 });
2087 let mut flat = String::new();
2088 flatten_json_to_flat_props(&val, "", &mut flat);
2089 assert!(flat.lines().any(|l| l.starts_with("control_corner_radius")), "{flat}");
2090 assert!(flat.lines().any(|l| l.starts_with("button_corner_radius")), "{flat}");
2091 }
2092
2093 /// A registry-backed style pinned by one test is invisible to a test
2094 /// beside it.
2095 ///
2096 /// The graph-style test below used to pin ~20 of these and restore none
2097 /// (it now reads the live registry instead: see
2098 /// `graph_style_getters_resolve_their_own_registry_keys`). Before the
2099 /// per-thread overlay that reached every test running alongside —
2100 /// provably: `dual_geometry_views_stay_consistent` bakes
2101 /// quads with `graph_node_corner_radius`, then re-reads the getter to
2102 /// compare, and a write landing between the two made them disagree.
2103 #[test]
2104 fn a_pinned_style_is_private_to_its_thread() {
2105 let base = graph_node_corner_radius();
2106 set_graph_node_corner_radius(base + 17.0);
2107 assert_eq!(graph_node_corner_radius(), base + 17.0, "the pinning thread sees its own value");
2108
2109 let elsewhere = std::thread::spawn(graph_node_corner_radius).join().unwrap();
2110 assert_eq!(elsewhere, base, "a thread beside it must still see the shared base");
2111 }
2112
2113 /// Every graph style getter resolves the registry key it is named for,
2114 /// falling back to its own documented default — checked against the live
2115 /// registry as it stands. Nothing is written: the style registry and the
2116 /// colour statics are process-global and the suite runs in parallel, so a
2117 /// set/assert here would be visible to every other test (this one used to
2118 /// set all twenty-one and restore none).
2119 #[test]
2120 fn graph_style_getters_resolve_their_own_registry_keys() {
2121 fn stored(key: &str) -> Option<f32> {
2122 crate::layout::lazy_init_style_registry();
2123 let reg = crate::layout::get_style_registry().read().unwrap();
2124 reg.get_float(key)
2125 }
2126
2127 assert_eq!(graph_spacing_x(), stored("graph_spacing_x").unwrap_or(187.5));
2128 assert_eq!(graph_spacing_y(), stored("graph_spacing_y").unwrap_or(112.5));
2129 assert_eq!(graph_line_width(), stored("graph_line_width").unwrap_or(1.0));
2130 assert_eq!(graph_node_width(), stored("graph_node_width").unwrap_or(150.0));
2131 assert_eq!(graph_node_height(), stored("graph_node_height").unwrap_or(75.0));
2132 assert_eq!(graph_grid_snap(), stored("graph_grid_snap").unwrap_or(0.0) != 0.0);
2133 assert_eq!(graph_blur(), stored("graph_blur").unwrap_or(0.0));
2134 assert_eq!(graph_node_corner_radius(), stored("graph_node_corner_radius").unwrap_or(4.0));
2135 assert_eq!(graph_wire_size(), stored("graph_wire_size").unwrap_or(6.0));
2136 assert_eq!(
2137 graph_wire_activation_radius(),
2138 stored("graph_wire_activation_radius").unwrap_or(9.0)
2139 );
2140 assert_eq!(graph_connector_size(), stored("graph_connector_size").unwrap_or(8.0));
2141 assert_eq!(
2142 graph_connector_activation_radius(),
2143 stored("graph_connector_activation_radius").unwrap_or(12.0)
2144 );
2145
2146 // The graph colours live behind statics in `color`, out of this
2147 // module's reach; what is checkable without writing them is that each
2148 // resolves to a real, in-gamut colour rather than an unparsed or
2149 // uninitialised one.
2150 let rgb: [(&str, [f32; 3]); 1] = [("graph_grid", crate::color::graph_grid_color())];
2151 for (name, c) in rgb {
2152 assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{name}: {c:?}");
2153 }
2154 let rgba: [(&str, [f32; 4]); 10] = [
2155 ("graph_node", crate::color::graph_node_color()),
2156 ("graph_node_selected", crate::color::graph_node_selected_color()),
2157 ("graph_node_drag", crate::color::graph_node_drag_color()),
2158 ("node", crate::color::node_color()),
2159 ("node_selected", crate::color::node_selected_color()),
2160 ("node_drag", crate::color::node_drag_color()),
2161 ("graph_wire", crate::color::graph_wire_color()),
2162 ("graph_wire_highlight", crate::color::graph_wire_highlight_color()),
2163 ("graph_connector", crate::color::graph_connector_color()),
2164 ("graph_connector_highlight", crate::color::graph_connector_highlight_color()),
2165 ];
2166 for (name, c) in rgba {
2167 assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{name}: {c:?}");
2168 }
2169 }
2170
2171 #[test]
2172 fn test_mosaic_layout() {
2173 use crate::widget::MosaicLayout;
2174 use crate::widget::ContainerLayout;
2175
2176 let layout = MosaicLayout {
2177 gap: 10.0,
2178 padding_x: 5.0,
2179 padding_y: 5.0,
2180 };
2181
2182 let mut dummy = crate::context::UiContext::new();
2183 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 50.0 };
2184 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 80.0 };
2185 let mut w3 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 80.0, h: 40.0 };
2186
2187 let children = vec![
2188 &mut w1 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2189 &mut w2 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2190 &mut w3 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2191 ];
2192
2193 let _ = layout.layout(10.0, 20.0, 250.0, 500.0, &children, &mut dummy);
2194
2195 assert_eq!(w1.x, 15.0);
2196 assert_eq!(w1.y, 25.0);
2197 assert_eq!(w2.x, 15.0);
2198 assert_eq!(w2.y, 85.0);
2199 assert_eq!(w3.x, 125.0);
2200 assert_eq!(w3.y, 25.0);
2201 }
2202
2203 #[test]
2204 fn test_reverse_mosaic_layout() {
2205 use crate::widget::ReverseMosaicLayout;
2206 use crate::widget::ContainerLayout;
2207
2208 let layout = ReverseMosaicLayout {
2209 gap: 10.0,
2210 padding_x: 5.0,
2211 padding_y: 5.0,
2212 };
2213
2214 let mut dummy = crate::context::UiContext::new();
2215 let mut w1 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 50.0 };
2216 let mut w2 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 100.0, h: 80.0 };
2217 let mut w3 = MockWidget { base: crate::widget::Widget::new(), x: 0.0, y: 0.0, w: 80.0, h: 40.0 };
2218
2219 let children = vec![
2220 &mut w1 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2221 &mut w2 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2222 &mut w3 as *mut MockWidget as *mut (dyn WidgetHost + 'static),
2223 ];
2224
2225 let _ = layout.layout(10.0, 20.0, 250.0, 300.0, &children, &mut dummy);
2226
2227 assert_eq!(w1.x, 15.0);
2228 assert_eq!(w1.y, 25.0);
2229 assert!((w1.w - 126.315).abs() < 0.01);
2230 assert!((w1.h - 103.57).abs() < 0.01);
2231
2232 assert!((w3.x - 153.947).abs() < 0.01);
2233 assert_eq!(w3.y, 25.0);
2234 assert!((w3.w - 101.05).abs() < 0.01);
2235 assert!((w3.h - 82.857).abs() < 0.01);
2236 }
2237
2238 /// The LUT is checked on `ramp_profile_lut`, the pure half of
2239 /// `set_bevel_profile_keys` / `set_roll_profile_keys` — installing it
2240 /// would restyle every wall in the process, and the suite runs in
2241 /// parallel.
2242 #[test]
2243 fn bevel_profile_lut_integrates_to_the_curves_net_rise() {
2244 let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
2245 let net_rise = |slopes: [f32; crate::layout::BEVEL_PROFILE_SAMPLES]| -> f32 {
2246 slopes.iter().map(|s| s / n).sum()
2247 };
2248
2249 // The identity 0→1 curve: slopes sum/N to its net rise of 1 (the same
2250 // total step the analytic smoothstep carries).
2251 let slopes = super::ramp_profile_lut(&[(0.0, 0.0), (1.0, 1.0)], true).expect("a curve");
2252 let rise = net_rise(slopes);
2253 assert!((rise - 1.0).abs() < 0.001, "net rise {rise}");
2254
2255 // A rim curve that returns to its start height nets zero.
2256 let slopes =
2257 super::ramp_profile_lut(&[(0.0, 0.5), (0.2, 1.0), (0.8, 1.0), (1.0, 0.5)], false)
2258 .expect("a curve");
2259 let rise = net_rise(slopes);
2260 assert!(rise.abs() < 0.001, "net rise {rise}");
2261
2262 // Degenerate key lists are no curve at all — the installers take that
2263 // `None` as "clear back to the analytic profile".
2264 assert!(super::ramp_profile_lut(&[(0.0, 1.0)], false).is_none());
2265 assert!(super::ramp_profile_lut(&[], true).is_none());
2266 }
2267
2268 #[test]
2269 fn relief_profile_specs_parse_with_identity_sentinel() {
2270 // Absent and identity-smooth mean "analytic" — nothing to install.
2271 assert!(crate::layout::parse_relief_profile_spec(None).is_none());
2272 assert!(crate::layout::parse_relief_profile_spec(Some(
2273 crate::layout::RELIEF_PROFILE_IDENTITY_SPEC
2274 ))
2275 .is_none());
2276 // Garbage falls back to analytic instead of poisoning the walls.
2277 assert!(crate::layout::parse_relief_profile_spec(Some("not a spec")).is_none());
2278 // A real curve installs: keys and line type round-trip.
2279 let (keys, smooth) = crate::layout::parse_relief_profile_spec(Some(
2280 "linear;0.000:0.200,0.500:1.000,1.000:0.800",
2281 ))
2282 .expect("custom spec parses");
2283 assert!(!smooth);
2284 assert_eq!(keys.len(), 3);
2285 assert!((keys[1].0 - 0.5).abs() < 0.001 && (keys[1].1 - 1.0).abs() < 0.001);
2286 // Identity under a LINEAR line type is a real profile (a straight
2287 // chamfer), not the sentinel — only the smooth spelling is analytic.
2288 assert!(crate::layout::parse_relief_profile_spec(Some(
2289 "linear;0.000:0.000,1.000:1.000"
2290 ))
2291 .is_some());
2292 }
2293 }
2294