git.lucas.co / cce-ui
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