git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/bin/cce-relief.rs (87.1K)

   1 //! `cce-relief` — the relief-material control interface. A SHAPE from the
   2 //! `scene::paint` relief family (recess, boss, ridge, trough, groove, fillet,
   3 //! plate edge roll, and the composed inset plate) is shown as a lit
   4 //! CROSS-SECTION of the actual edge, and the curve its walls are cut with is
   5 //! shaped by three semantic sliders (Shoulder / Base / Bias) instead of a
   6 //! free-form ramp.
   7 //!
   8 //! Named for the family, not for one member: `scene::paint`'s `Prim` doc
   9 //! reserves "bevel" for the `Bevel` prim and the shared edge treatment, and
  10 //! calls the family relief primitives — which is also what `control_relief`
  11 //! gates and what the `relief` config node is called. This was `cce-bevel`
  12 //! until the shape picker landed and made the mismatch untenable.
  13 //!
  14 //! Under the section runs the SHADING STRIP: the same shape put through
  15 //! `scene::relief_shade`, the shader's own arithmetic in Rust, composited one
  16 //! carve at a time in emission order. The section says what the shape is; the
  17 //! strip says what it will look like. A composed shape gets one pass per carve
  18 //! there, which is how a stack whose geometry looks reasonable can still read
  19 //! hot.
  20 //!
  21 //! The knobs under the section: the wall curve's Shoulder / Base / Bias,
  22 //! then **Light** (the `bevel_depth` slot — how hard the light falls across
  23 //! the wall; not a length), **Width** (the wall's run, logical px) and
  24 //! **Height** (the wall's drop, logical px; 0 = follow the width at the
  25 //! analytic ratio). Height is the fabrication axis: the section's depth
  26 //! numbers read in millimetres whenever the display metric is real
  27 //! (`cce_ui::units`), and Save writes `style.surface.relief.height` as a
  28 //! `(mm)` length then, px otherwise.
  29 //!
  30 //! Every edit applies live to this process (the
  31 //! popup's own plate, wells, and buttons ARE the preview) and logs the
  32 //! sampled spec to stdout; Save persists to `~/.config/cce/config.kdl`
  33 //! (`style.surface.relief`) so every cce app starts with the material —
  34 //! or, with `--config <path>`, to that file instead (a per-app override
  35 //! like cce-designer's), which also seeds the knobs/depth/width on open.
  36 //!
  37 //! `--key <dotted.key>` edits a single `(relief)` VALUE in place instead
  38 //! (`cce_ui::relief_spec::ReliefSpec` — width/depth/wall knobs/wall
  39 //! profile folded into one string), e.g.
  40 //! `cce-relief --key style.surface.desktop.line_relief` for the desktop
  41 //! grid's lines. Seeds come from that key, edits still preview live, and
  42 //! Save rewrites only that key — the DE-wide material is untouched. The
  43 //! edge section is not part of a `(relief)` value; a feature material has
  44 //! one wall curve.
  45 //!
  46 //! The curve family is the two-exponent rational ease
  47 //! `h(w) = w^a / (w^a + (1-w)^b)` over a bias pre-warp `w = v^g` — monotone,
  48 //! endpoint-exact, with the shoulder (a) and base fillet (b) shaped
  49 //! independently. Slider midpoints give a=b=2, g=1: the analytic smoothstep.
  50 //! Curves are sampled into ramp-spec keys, so the config format and the
  51 //! DE-wide loader are unchanged — free-form specs from cce-designer or a
  52 //! hand-edited config still load everywhere.
  53 
  54 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
  55 use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
  56 use cce_ui::scene::layout::Rect;
  57 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
  58 use cce_ui::scene::relief_shade::{self, CarveMode};
  59 use cce_ui::widget::{
  60     Adapted, Button, Dropdown, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta,
  61     Slider, WidgetHost, WidgetId,
  62 };
  63 use wayland_client::QueueHandle;
  64 
  65 const HEADER_FONT_SIZE: f32 = 13.0;
  66 const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
  67 
  68 /// Knob ranges: light (how hard the light falls across the wall — the
  69 /// `bevel_depth` slot, NOT a length), width (how far the wall runs, logical
  70 /// px) and height (how far it drops, logical px; 0 = follow the width at
  71 /// the analytic ratio, the pre-height look). Defaults per
  72 /// `layout::bevel_depth` / `bevel_width` / `bevel_height`.
  73 const DEPTH_RANGE: (f32, f32) = (0.0, 0.6);
  74 const WIDTH_RANGE: (f32, f32) = (2.0, 24.0);
  75 const HEIGHT_RANGE: (f32, f32) = (0.0, 24.0);
  76 /// The finish's three fixed terms and the frost recipe — the material
  77 /// sections (RFC material step 4). Specular strength, shininess exponent,
  78 /// curvature/AO strength; luminance compression, rim refraction, blur sigma
  79 /// in logical px.
  80 const SPEC_RANGE: (f32, f32) = (0.0, 1.5);
  81 const SHINE_RANGE: (f32, f32) = (1.0, 64.0);
  82 const CURV_RANGE: (f32, f32) = (0.0, 1.0);
  83 const COMP_RANGE: (f32, f32) = (0.0, 1.0);
  84 const REFR_RANGE: (f32, f32) = (0.0, 1.0);
  85 const RADIUS_RANGE: (f32, f32) = (0.0, 20.0);
  86 
  87 /// Sample count for the spec written to config — enough that the 32-slot
  88 /// renderer LUT sees the curve, few enough that the config line stays sane.
  89 const SPEC_SAMPLES: usize = 17;
  90 
  91 /// Cutaway metrics, shared by `draw_section` and the layout's shrink-wrap:
  92 /// inner margin, the axis gutters (depth numbers left of the slab's cut
  93 /// face, run numbers under its underside), the plateau/floor minimum band,
  94 /// and the slab's underside room.
  95 const CUT_MARGIN: f32 = 8.0;
  96 const GUTTER_L: f32 = 34.0;
  97 const GUTTER_B: f32 = 16.0;
  98 const MIN_BAND: f32 = 36.0;
  99 const UNDERSIDE: f32 = 18.0;
 100 /// Height of the shading strip under the section — the band that shows what
 101 /// the shape will actually LOOK like, as opposed to what it is.
 102 const SHADE_STRIP_H: f32 = 14.0;
 103 
 104 /// The height this window WANTS at a given width: every fixed row, plus the
 105 /// cutaway at its natural (shrink-wrapped) height.
 106 ///
 107 /// The window is CONTENT-SIZED. There is nothing here to drag to a different
 108 /// shape — the rows are fixed and the cutaway shrink-wraps its section — so a
 109 /// user-chosen height only ever adds dark space or squeezes the section's wall
 110 /// to a sliver, because the proportional axes are height-bound. Kept as one
 111 /// function so `settings()` asks for the same number the layout will use.
 112 ///
 113 /// (The compositor may still restore a saved size over this; see the Utility
 114 /// window-mode proposal. Until then the request is only a request.)
 115 fn content_height(width: f32) -> f32 {
 116     let pad = cce_ui::layout::root_plate_padding();
 117     let w = (width - 2.0 * pad).max(0.0);
 118     let gap = 14.0;
 119     let strip = {
 120         let (_, fsize) = cce_ui::layout::control_label_font_detached_parsed();
 121         fsize + cce_ui::layout::control_label_margin()
 122     };
 123     let knob_h = 22.0 + strip;
 124     let button_h = 26.0;
 125     let status_h = HEADER_FONT_SIZE + 4.0;
 126     let fixed = knob_h + gap
 127         + 9.0 * (knob_h + gap)
 128         + button_h + 8.0 + status_h + 2.0 * gap;
 129     let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
 130         + 2.0 * CUT_MARGIN
 131         + UNDERSIDE
 132         + GUTTER_B
 133         + 2.0 * SHADE_STRIP_H
 134         + 6.0
 135         + GUTTER_B;
 136     2.0 * pad + fixed + natural
 137 }
 138 
 139 #[derive(Debug, Clone)]
 140 enum BevelMsg {
 141     Exit,
 142 }
 143 
 144 /// Which of the two profile curves a shape's walls are drawn with — the thing
 145 /// the knobs actually edit. Several shapes share one curve (every box carve and
 146 /// both straddling shapes are the Wall curve), so the picker selects a SHAPE and
 147 /// the curve follows; the caption says which one you are editing.
 148 #[derive(Clone, Copy, PartialEq, Eq)]
 149 enum Curve {
 150     Wall,
 151     Edge,
 152 }
 153 
 154 /// Which wall of the rect the section is taken through.
 155 ///
 156 /// Not cosmetic. A carve's shading depends on the angle between the wall's
 157 /// outward normal and the DE light, so ONE profile reads four different ways
 158 /// around a control — the reason a seam's two rims never match (measured on
 159 /// cce-files' pane gap: 187 grey one side, 125 the other, same carve).
 160 #[derive(Clone, Copy, PartialEq, Eq)]
 161 enum Edge {
 162     Left,
 163     Right,
 164     Top,
 165     Bottom,
 166 }
 167 
 168 impl Edge {
 169     const ALL: [Edge; 4] = [Edge::Left, Edge::Right, Edge::Top, Edge::Bottom];
 170 
 171     fn label(self) -> &'static str {
 172         match self {
 173             Edge::Left => "Left wall",
 174             Edge::Right => "Right wall",
 175             Edge::Top => "Top wall",
 176             Edge::Bottom => "Bottom wall",
 177         }
 178     }
 179 
 180     /// The SDF gradient at the wall: the unit vector pointing OUT of the carve.
 181     fn facing(self) -> [f32; 2] {
 182         match self {
 183             Edge::Left => [-1.0, 0.0],
 184             Edge::Right => [1.0, 0.0],
 185             Edge::Top => [0.0, -1.0],
 186             Edge::Bottom => [0.0, 1.0],
 187         }
 188     }
 189 
 190     /// Does the wall run horizontally? Then its shading varies DOWN the band
 191     /// rather than along it, and both bands must be sampled that way to stay
 192     /// comparable to each other.
 193     fn horizontal_wall(self) -> bool {
 194         matches!(self, Edge::Top | Edge::Bottom)
 195     }
 196 }
 197 
 198 /// A relief shape, as a cross-section. This is the `Prim` family from
 199 /// `scene::paint` — the point of the picker is that the section shows the shape
 200 /// you will actually emit, not an idealised wall standing in for all of them.
 201 ///
 202 /// Two of these are COMPOSED rather than primitive, and they are the reason the
 203 /// picker exists: `InsetPlate` is what `PaintCtx::inset_plate` emits today (one
 204 /// `Trough`), and `InsetStacked` is what it emitted before cce-ui@`35f5183` (a
 205 /// `Recess` on an outset rect plus a `Boss` on the rect). Put them side by side
 206 /// and the old one's fault is visible as geometry: same dip depth, 1.5× the run,
 207 /// and a flat floor where the single evaluation comes to a point.
 208 #[derive(Clone, Copy, PartialEq, Eq)]
 209 enum Shape {
 210     Recess,
 211     Boss,
 212     EdgeRoll,
 213     Ridge,
 214     Trough,
 215     Groove,
 216     Fillet,
 217     InsetPlate,
 218     InsetStacked,
 219 }
 220 
 221 impl Shape {
 222     const ALL: [Shape; 9] = [
 223         Shape::Recess,
 224         Shape::Boss,
 225         Shape::EdgeRoll,
 226         Shape::Ridge,
 227         Shape::Trough,
 228         Shape::Groove,
 229         Shape::Fillet,
 230         Shape::InsetPlate,
 231         Shape::InsetStacked,
 232     ];
 233 
 234     fn label(self) -> &'static str {
 235         match self {
 236             Shape::Recess => "Recess — carve, interior one step down",
 237             Shape::Boss => "Boss — plateau, interior one step up",
 238             Shape::EdgeRoll => "Plate edge — perimeter roll to the silhouette",
 239             Shape::Ridge => "Ridge — raised crest on the boundary",
 240             Shape::Trough => "Trough — sunken valley on the boundary",
 241             Shape::Groove => "Groove — slab valley (width 0 = a V)",
 242             Shape::Fillet => "Fillet — concave inside corner",
 243             Shape::InsetPlate => "Inset plate — flush control seam",
 244             Shape::InsetStacked => "Inset plate, STACKED (pre-35f5183)",
 245         }
 246     }
 247 
 248     /// The curve whose knobs shape this section.
 249     fn curve(self) -> Curve {
 250         match self {
 251             Shape::EdgeRoll => Curve::Edge,
 252             _ => Curve::Wall,
 253         }
 254     }
 255 
 256     /// Does the section end in a floor (material continues), or in air (past a
 257     /// silhouette)? Only the plate's edge roll ends in air.
 258     fn has_floor(self) -> bool {
 259         self != Shape::EdgeRoll
 260     }
 261 
 262     /// The run the shape's walls occupy, in WALL WIDTHS. Everything but the
 263     /// stacked inset is one wall wide; the stack is 1.5 because its two walls
 264     /// only overlap by half.
 265     fn run(self) -> f32 {
 266         match self {
 267             Shape::InsetStacked => 1.5,
 268             Shape::Groove => 1.0 + GROOVE_FLOOR,
 269             _ => 1.0,
 270         }
 271     }
 272 
 273     /// Surface height at run position `t` (in wall widths, 0 at the first wall's
 274     /// start). Units: 1.0 = one full wall drop DOWN into the material, negative
 275     /// = up out of it, 0 = the surrounding surface. `None` is air.
 276     fn height(self, p: &ProfileKnobs, t: f32) -> Option<f32> {
 277         // The profile's height curve, clamped to its plateaus.
 278         let h = |u: f32| -> f32 {
 279             if u <= 0.0 {
 280                 0.0
 281             } else if u >= 1.0 {
 282                 1.0
 283             } else {
 284                 p.eval(u)
 285             }
 286         };
 287         // A bump straddling the run: up the first half, back down the second,
 288         // amplitude halved — the shader's ridge/trough construction, where one
 289         // profile evaluation on the folded coordinate yields both walls.
 290         let bump = |t: f32| -> f32 {
 291             let w = (2.0 * t).min(2.0 - 2.0 * t).clamp(0.0, 1.0);
 292             0.5 * h(w)
 293         };
 294         Some(match self {
 295             Shape::Recess => h(t),
 296             Shape::Boss => -h(t),
 297             Shape::EdgeRoll => {
 298                 if t > 1.0 {
 299                     return None;
 300                 }
 301                 h(t)
 302             }
 303             Shape::Ridge => -bump(t),
 304             Shape::Trough | Shape::InsetPlate => bump(t),
 305             Shape::Groove => {
 306                 // The trough with a flat floor of GROOVE_FLOOR spliced in at the
 307                 // bottom — `width` in `Prim::Groove`, which is 0 for a pure V.
 308                 if t < 0.5 {
 309                     bump(t)
 310                 } else if t < 0.5 + GROOVE_FLOOR {
 311                     0.5 * h(1.0)
 312                 } else {
 313                     bump(t - GROOVE_FLOOR)
 314                 }
 315             }
 316             // Quarter arc, concave: the inside corner a box SDF cannot express.
 317             Shape::Fillet => {
 318                 let u = t.clamp(0.0, 1.0);
 319                 1.0 - (1.0 - u * u).max(0.0).sqrt()
 320             }
 321             // The composed stack: a recess wall stepping DOWN over [0,1] and a
 322             // boss wall stepping UP over [0.5,1.5]. They overlap across the
 323             // middle half, which is where the old code shaded twice.
 324             Shape::InsetStacked => h(t) - h(t - 0.5),
 325         })
 326     }
 327 
 328     /// The carve(s) this shape emits, as (mode, run start, run end). The
 329     /// shading strip composites these IN ORDER, exactly as the renderer
 330     /// composites one prim's cover quad over another's — which is the whole
 331     /// reason a stacked shape can read hot while its geometry looks fine.
 332     ///
 333     /// The plate's edge roll returns nothing: it is the shader's plate branch
 334     /// (fill + roll + CSG features), not the free-carve branch this models, so
 335     /// the strip says so rather than inventing a number.
 336     fn walls(self) -> Vec<(CarveMode, f32, f32)> {
 337         match self {
 338             Shape::Recess => vec![(CarveMode::Recess, 0.0, 1.0)],
 339             Shape::Boss => vec![(CarveMode::Boss, 0.0, 1.0)],
 340             Shape::Ridge => vec![(CarveMode::Ridge, 0.0, 1.0)],
 341             Shape::Trough | Shape::InsetPlate => vec![(CarveMode::Trough, 0.0, 1.0)],
 342             // The groove rejoins the free-carve path as a recess on |distance to
 343             // the line| - halfwidth; across the section that is a trough spread
 344             // over the wider run.
 345             Shape::Groove => vec![(CarveMode::Trough, 0.0, 1.0 + GROOVE_FLOOR)],
 346             // The fillet rejoins the shared path as its flat equivalent.
 347             Shape::Fillet => vec![(CarveMode::Recess, 0.0, 1.0)],
 348             Shape::EdgeRoll => Vec::new(),
 349             // The pre-35f5183 pair, in emission order.
 350             Shape::InsetStacked => {
 351                 vec![(CarveMode::Recess, 0.0, 1.0), (CarveMode::Boss, 0.5, 1.5)]
 352             }
 353         }
 354     }
 355 
 356     /// For a composed shape, the run interval where TWO walls are live at once
 357     /// — the band that gets two lighting evaluations instead of one, and so the
 358     /// band that reads hot. `None` for a primitive shape, which has one wall
 359     /// everywhere by construction.
 360     ///
 361     /// Drawn as a band rather than as the two contributing curves on purpose:
 362     /// plotted raw, the second wall's own excursion runs a full drop the other
 363     /// way and leaves the section entirely, which forces the view to zoom out
 364     /// far enough to shrink the composite you actually came to look at.
 365     fn overlap(self) -> Option<(f32, f32)> {
 366         match self {
 367             Shape::InsetStacked => Some((0.5, 1.0)),
 368             _ => None,
 369         }
 370     }
 371 }
 372 
 373 /// Flat floor spliced into the `Groove` section, in wall widths — `Prim::Groove`'s
 374 /// `width`, shown non-zero so the knob's effect is visible (0 would render as a V
 375 /// identical to `Trough`, whose difference is the slab SDF, not the section).
 376 const GROOVE_FLOOR: f32 = 0.45;
 377 
 378 /// One profile section's shape state: the three knob sliders plus whether
 379 /// the profile has diverged from the analytic default.
 380 struct ProfileKnobs {
 381     shoulder: Adapted<Slider>,
 382     base: Adapted<Slider>,
 383     bias: Adapted<Slider>,
 384     /// False until a knob moves or config installed a real (non-identity)
 385     /// profile for this section: the DE renders its analytic profile and
 386     /// Save writes the identity sentinel.
 387     custom: bool,
 388     /// Last spec applied+logged.
 389     last_spec: String,
 390 }
 391 
 392 impl ProfileKnobs {
 393     /// `installed` is whether the live material actually carries a custom
 394     /// LUT for this profile (`layout::*_profile_slopes().is_some()` — the
 395     /// shader's own condition). It is NOT the same as "config carried saved
 396     /// knobs": Save writes the knob triples as a ride-along even for an
 397     /// untouched section (so the editor reopens where it was left), while
 398     /// writing the identity SPEC — which installs nothing. Seeding `custom`
 399     /// from the knobs' presence made the prediction follow the knob curve
 400     /// while the renderer ran analytic. The wall curve hid it (the knob
 401     /// midpoints ARE the analytic smoothstep); the roll exposed it (the knob
 402     /// family is nothing like the superellipse quadrant) — and a Save from
 403     /// that state would have installed a smoothstep roll DE-wide unasked.
 404     fn new(seed: Option<(f32, f32, f32)>, installed: bool) -> Self {
 405         let (s, b, c) = seed.unwrap_or((0.5, 0.5, 0.5));
 406         let knob = |v: f32, label: &str| {
 407             Slider::new()
 408                 .with_label(label)
 409                 .with_value(v.clamp(0.0, 1.0))
 410                 .with_scroll(true)
 411         };
 412         let mut this = Self {
 413             shoulder: knob(s, "Shoulder"),
 414             base: knob(b, "Base"),
 415             bias: knob(c, "Bias"),
 416             custom: installed,
 417             last_spec: String::new(),
 418         };
 419         this.last_spec = if this.custom { this.spec() } else { IDENTITY_SPEC.to_string() };
 420         this
 421     }
 422 
 423     fn values(&self) -> (f32, f32, f32) {
 424         (self.shoulder.inner().value(), self.base.inner().value(), self.bias.inner().value())
 425     }
 426 
 427     /// The section's height curve `h(v)` — the shared `(bevel)` curve family
 428     /// (`cce_ui::widget::bevel_ease`; the BevelPreview swatch draws the same).
 429     fn eval(&self, v: f32) -> f32 {
 430         let (s, b, c) = self.values();
 431         cce_ui::widget::bevel_ease(s, b, c, v)
 432     }
 433 
 434     /// The curve sampled as linear ramp-spec keys — what the renderer LUT
 435     /// and the config carry.
 436     fn keys(&self) -> Vec<(f32, f32)> {
 437         (0..SPEC_SAMPLES)
 438             .map(|i| {
 439                 let v = i as f32 / (SPEC_SAMPLES - 1) as f32;
 440                 (v, self.eval(v))
 441             })
 442             .collect()
 443     }
 444 
 445     fn spec(&self) -> String {
 446         cce_ui::widget::format_ramp_spec(&self.keys(), false)
 447     }
 448 
 449     fn take_change(&mut self) -> bool {
 450         // Bitwise-or on purpose: every slider's flag must drain.
 451         self.shoulder.take_change() | self.base.take_change() | self.bias.take_change()
 452     }
 453 }
 454 
 455 struct BevelPopup {
 456     /// Which SHAPE the section shows; the curve it edits follows from it.
 457     profile_dropdown: Adapted<Dropdown>,
 458     /// Which wall of the rect the section is through — see [`Edge`].
 459     edge_dropdown: Adapted<Dropdown>,
 460     /// The carve wall — what `carve_slope` renders on every
 461     /// recess/boss/ridge in the DE.
 462     wall: ProfileKnobs,
 463     /// The plate perimeter roll — `roll_slope`'s descent profile.
 464     edge: ProfileKnobs,
 465     depth_slider: Adapted<Slider>,
 466     width_slider: Adapted<Slider>,
 467     height_slider: Adapted<Slider>,
 468     /// The Finish column: what the surface does under light beyond its
 469     /// strength (`Light` above) — `scene::material::Finish`'s spec /
 470     /// shininess / curvature. Applied live to the DE finish, or to the pane
 471     /// rung's bound material when config binds one.
 472     spec_slider: Adapted<Slider>,
 473     shine_slider: Adapted<Slider>,
 474     curv_slider: Adapted<Slider>,
 475     /// The Frost column: the pane material's recipe — compression,
 476     /// refraction, blur radius (`scene::material::Frost`). Same live target.
 477     comp_slider: Adapted<Slider>,
 478     refr_slider: Adapted<Slider>,
 479     radius_slider: Adapted<Slider>,
 480     save_button: Adapted<Button>,
 481     /// The material the Save target binds its pane rung to, if any — read
 482     /// from the `--config` file (this process's own config is not the
 483     /// target's), else this process's binding. `material_frosted` says
 484     /// whether that material (or the target's legacy pane) is frosted, i.e.
 485     /// whether the Frost column has anything to write.
 486     material_target: Option<String>,
 487     material_frosted: bool,
 488     /// Cancel = discard-and-close: edits are live only in THIS process, so
 489     /// with nothing persisted, closing IS the discard (same as Escape).
 490     cancel_button: Adapted<Button>,
 491     /// Set by the cancel click in `drain_widget_changes` (no exit access
 492     /// there); `handle_mouse_input` turns it into `BevelMsg::Exit`.
 493     exit_requested: bool,
 494     /// The window plate's alpha — seeded from this app's own config
 495     /// (`~/.config/cce/cce-relief/config.kdl`, `window { opacity }`, falling
 496     /// back to the pre-rename `cce-bevel` path), falling
 497     /// back to the DE root plate opacity. Edited on the file directly, the
 498     /// DE way — no dedicated control.
 499     plate_opacity: f32,
 500     /// Status line under the buttons: what the last save/reset did.
 501     status: String,
 502     /// Save/seed target: `--config <path>` retargets the editor at a
 503     /// specific config file (a per-app override like cce-designer's), else
 504     /// the shared config.kdl. Knob/depth/width seeds prefer this file.
 505     config_path: std::path::PathBuf,
 506     /// `--key <dotted.key>`: edit a single `(relief)` VALUE in place —
 507     /// Save serializes width/depth/wall knobs/wall profile into that one
 508     /// key instead of the `style.surface.relief.*` material keys, and the
 509     /// seeds come from it. The edge section still previews but is not part
 510     /// of a `(relief)` value (a feature material has one wall curve).
 511     target_key: Option<String>,
 512     /// Short label for a retargeted config ("cce-designer"), shown in the
 513     /// title and status so it's obvious which material is being edited.
 514     target_label: Option<String>,
 515     ui_context: cce_ui::context::UiContext,
 516     width: u32,
 517     height: u32,
 518     scale_factor: f64,
 519     needs_rebuild: bool,
 520     registered: bool,
 521     status_pos: (f32, f32),
 522     cut_rect: Rect,
 523 }
 524 
 525 /// Parse a saved "shoulder,base,bias" knob triple — the `(bevel)` value
 526 /// format, shared with the BevelPreview swatch.
 527 use cce_ui::widget::parse_bevel_knobs as parse_knobs;
 528 
 529 /// Draw one profile as a lit cutaway: the material slab (plate color) inside
 530 /// a dark opening, its surface stroked with segment lighting from the DE's
 531 /// light azimuth. `has_floor` distinguishes the carve (wall meets a floor
 532 /// inside the material) from the roll (the surface drops to the silhouette
 533 /// and the material simply ends — air beyond the edge).
 534 fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Shape, edge: Edge) {
 535     let has_floor = shape.has_floor();
 536     let radius = 6.0f32;
 537     let radii = (radius, radius, radius, radius);
 538     pc.rounded_rect(rect, radius, (true, true, true, true), [0.08, 0.08, 0.10, 1.0]);
 539 
 540     // The section geometry: a flat band, the shape's walls over `run` wall
 541     // widths, then a closing band. PROPORTIONAL AXES: one unit of run is one
 542     // unit of drop in pixels, whatever the shape or the window — so a 45°
 543     // chamfer draws at 45°, and a shape whose run is 1.5 wall widths draws
 544     // half again as wide as one that runs 1. That comparison is the whole
 545     // point of putting the composed shapes in here, so the scale must not be
 546     // renormalised per shape.
 547     let m = CUT_MARGIN;
 548     let x_l = rect.x + m + GUTTER_L;
 549     let x_r = rect.x + rect.width - m;
 550     let avail_w = x_r - x_l;
 551     // The shading strip owns a reserved band along the bottom of the opening,
 552     // and the SECTION lays out in what is left. Reserving it up front is what
 553     // keeps the slab from expanding over it — the slab grows to the bottom of
 554     // whatever area it is given.
 555     let strip_band = 2.0 * SHADE_STRIP_H + 6.0;
 556     let sec_h = rect.height - strip_band;
 557     // stroke + slab-underside room, plus the bottom gutter
 558     let avail_h = sec_h - 2.0 * m - UNDERSIDE - GUTTER_B;
 559 
 560     let run = shape.run();
 561     // Drop over run: the material's pinned height against the DE roll width
 562     // (`layout::carve_depth_ratio`), the roll's own rise for the edge roll.
 563     // Everything below scales the shape's unit drop by it, so the section
 564     // shows the geometry the shader shades — a 0.5 mm drop over a 2 mm wall
 565     // draws at that slope, not at the analytic 0.6.
 566     let ratio = if matches!(shape, Shape::EdgeRoll) {
 567         cce_ui::layout::roll_height_ratio()
 568     } else {
 569         cce_ui::layout::carve_depth_ratio()
 570     };
 571     // Vertical extent of THIS shape, sampled — a ridge lives above the surface,
 572     // a recess below, a trough only half a drop down.
 573     let (mut h_lo, mut h_hi) = (0.0f32, 0.0f32);
 574     for i in 0..=64 {
 575         let t = run * i as f32 / 64.0;
 576         if let Some(hv) = shape.height(profile, t) {
 577             h_lo = h_lo.min(hv * ratio);
 578             h_hi = h_hi.max(hv * ratio);
 579         }
 580     }
 581     let h_span = (h_hi - h_lo).max(0.25);
 582 
 583     // One scale for both axes (see above), the binding constraint whichever it is.
 584     let unit = ((avail_w - 2.0 * MIN_BAND) / run)
 585         .min(avail_h / h_span)
 586         .max(16.0);
 587     let wall_w = run * unit;
 588     let leftover = (avail_w - wall_w).max(2.0 * MIN_BAND);
 589     let plateau_frac = if has_floor { 0.45 } else { 0.62 };
 590     let plateau_w = leftover * plateau_frac;
 591     // y of h = 0 (the surrounding surface), placed so the whole excursion fits.
 592     let drawn_h = h_span * unit;
 593     let y_zero = rect.y
 594         + ((sec_h - drawn_h - UNDERSIDE - GUTTER_B) / 2.0).max(m)
 595         - h_lo * unit;
 596     let y_top = y_zero + h_lo * unit;
 597     let y_bot = y_zero + h_hi * unit;
 598     let x0 = x_l + plateau_w;
 599     let x1 = x0 + wall_w;
 600 
 601     // The axes: faint gridlines over the shape's run × depth domain, drawn
 602     // before the slab so the material occludes them (grid in the void only),
 603     // with the numbers in the gutters. x is run in wall widths, y is depth in
 604     // drops — 0 at the surrounding surface, positive down into the material.
 605     let grid = [0.25f32, 0.25, 0.28, 0.6];
 606     let num_color = [0x84u8, 0x84, 0x92];
 607     // The depth numbers are REAL lengths: one wall width of drop is
 608     // `bevel_width` logical px, shown in millimetres when the display metric
 609     // is measured or configured, in px when it is only assumed.
 610     let metric = cce_ui::units::metric();
 611     let wall_px = cce_ui::layout::bevel_width();
 612     let (axis_unit, per_wall) = if metric.is_real() {
 613         ("mm", wall_px * metric.mm_per_px())
 614     } else {
 615         ("px", wall_px)
 616     };
 617     let mut gh = (h_lo / 0.25).round() * 0.25;
 618     while gh <= h_hi + 1e-3 {
 619         let gy = y_zero + gh * unit;
 620         pc.quad(Rect { x: x0, y: gy, width: wall_w, height: 1.0 }, grid);
 621         pc.text_with(
 622             format!("{:.2}", gh * per_wall),
 623             rect.x + m + 2.0,
 624             gy - 5.0,
 625             10.0,
 626             num_color,
 627             Some("monospace".to_string()),
 628             None,
 629         );
 630         gh += 0.25;
 631     }
 632     let mut gt = 0.0f32;
 633     while gt <= run + 1e-3 {
 634         let gx = x0 + gt * unit;
 635         pc.quad(Rect { x: gx, y: y_top, width: 1.0, height: (y_bot - y_top).max(1.0) }, grid);
 636         gt += 0.25;
 637     }
 638 
 639     // A Right or Bottom wall genuinely draws MIRRORED: a wall's outward normal
 640     // always points at its plateau, so "plateau on the left" IS the left/top
 641     // wall. Flipping the run keeps the drawing, the predicted band and the real
 642     // swatch all describing the same physical wall — without it the prediction
 643     // and the swatch disagree by a reflection, which reads as a shading bug.
 644     let flip = matches!(edge, Edge::Right | Edge::Bottom);
 645     let t_of = |x: f32| -> f32 {
 646         if flip { (x1 - x) / unit } else { (x - x0) / unit }
 647     };
 648 
 649     // Surface height at a section x.
 650     let surface_y = |x: f32| -> Option<f32> {
 651         let inside = if flip { x >= x0 } else { x <= x1 };
 652         let outside = if flip { x >= x1 } else { x <= x0 };
 653         if outside {
 654             Some(y_zero)
 655         } else if inside {
 656             shape
 657                 .height(profile, t_of(x))
 658                 .map(|hv| y_zero + hv * ratio * unit)
 659         } else if has_floor {
 660             shape.height(profile, run).map(|hv| y_zero + hv * ratio * unit)
 661         } else {
 662             None // past the silhouette: air
 663         }
 664     };
 665 
 666     // A composed shape's double-shaded band: where two walls are live at once.
 667     // Drawn under the slab so the material still occludes it, like the grid.
 668     if let Some((ot0, ot1)) = shape.overlap() {
 669         pc.quad(
 670             Rect {
 671                 x: x0 + ot0 * unit,
 672                 y: y_top,
 673                 width: (ot1 - ot0) * unit,
 674                 height: (y_bot - y_top).max(1.0),
 675             },
 676             [0.55, 0.30, 0.30, 0.30],
 677         );
 678     }
 679 
 680     // The slab: the plate material itself, filled from the surface down to
 681     // the cut's bottom edge. Columns share exact edges (opaque fill, but the
 682     // ramp-fill rule keeps seams clean under AA).
 683     let mut slab = cce_ui::color::page_low_color();
 684     slab = [slab[0] * 1.25 + 0.03, slab[1] * 1.25 + 0.03, slab[2] * 1.25 + 0.03, 1.0];
 685     // A fixed slab thickness under the lowest surface, so vertical centering
 686     // doesn't grow a bottomless block of material.
 687     let slab_bot = (y_bot + 16.0).min(rect.y + sec_h - 8.0);
 688     let step = 2.0f32;
 689     let mut x = x_l;
 690     while x < x_r {
 691         let xm = (x + step / 2.0).min(x_r);
 692         if let Some(sy) = surface_y(xm) {
 693             let w = step.min(x_r - x);
 694             pc.quad(Rect { x, y: sy, width: w, height: (slab_bot - sy).max(0.0) }, slab);
 695         }
 696         x += step;
 697     }
 698 
 699     // Run numbers under the slab's underside, in wall widths — so a 1.5-wide
 700     // shape reads "…1.25 1.50" and its extra run is a number, not just a
 701     // feeling.
 702     let mut rt = 0.0f32;
 703     while rt <= run + 1e-3 {
 704         pc.text_with(
 705             format!("{rt:.2}"),
 706             (if flip { x1 - rt * unit } else { x0 + rt * unit }) - 11.0,
 707             slab_bot + 3.0,
 708             10.0,
 709             num_color,
 710             Some("monospace".to_string()),
 711             None,
 712         );
 713         rt += 0.25;
 714     }
 715     // The depth axis's unit, in the left gutter on the run-number row —
 716     // the one spot no excursion of the section can reach.
 717     pc.text_with(
 718         axis_unit.to_string(),
 719         rect.x + m + 2.0,
 720         slab_bot + 3.0,
 721         10.0,
 722         num_color,
 723         Some("monospace".to_string()),
 724         None,
 725     );
 726 
 727     // THE SHADING STRIP: what the shader will actually put on screen along
 728     // this section, as opposed to the geometry drawn above it.
 729     //
 730     // Each of the shape's carves is evaluated with the real model and
 731     // composited in emission order onto the surface colour, so a shape that
 732     // emits two overlapping walls gets two passes here exactly as it would in
 733     // the frame. That is the difference the geometry cannot show: the stacked
 734     // inset's dip is only half again as deep as the trough's, but its strip is
 735     // visibly hotter, because the overlap region is lit twice.
 736     let strip_h = SHADE_STRIP_H;
 737     let strip_y = rect.y + rect.height - strip_band + 2.0;
 738     {
 739         let walls = shape.walls();
 740         let light = relief_shade::light_vector();
 741         let mat = cce_ui::scene::material::Finish::from_style();
 742         // Follow the knobs ONLY when a custom profile is actually installed.
 743         // Until one is, the shader runs its analytic branch, and predicting
 744         // from the knob curve instead quietly disagrees with it. The carve case
 745         // hides this — the knob midpoints ARE the analytic smoothstep — but the
 746         // roll's analytic form is a superellipse quadrant, nothing like the
 747         // knob family, and there the two differ by ~20 grey levels.
 748         let custom = profile.custom;
 749         let knob_slope = |v: f32| -> f32 {
 750             let d = 1.0 / 32.0;
 751             let (a, b) = ((v - d * 0.5).clamp(0.0, 1.0), (v + d * 0.5).clamp(0.0, 1.0));
 752             let taper = (v.min(1.0 - v) * 32.0 * 0.667).clamp(0.0, 1.0);
 753             if b <= a { 0.0 } else { (profile.eval(b) - profile.eval(a)) / (b - a) * taper }
 754         };
 755         let slope_at = |v: f32| -> f32 {
 756             if custom { knob_slope(v) } else { relief_shade::analytic_carve_slope(v) }
 757         };
 758         // The DE's own plate colour, NOT a swatch grey. The composite is
 759         // asymmetric — brightening screens toward white, darkening multiplies
 760         // toward black — so which lobe dominates depends on how light the
 761         // surface under it is, and it INVERTS between a dark plate and a light
 762         // one. Drawn over the wrong base the strip reverses the very thing you
 763         // came to judge: on this plate a wall's bright side out-measures its
 764         // dark side about 4:1, and over a pale swatch it reads the other way.
 765         let plate = cce_ui::color::page_low_color();
 766         let surface = [plate[0], plate[1], plate[2]];
 767         // A HORIZONTAL wall's shading varies down the band, not along it: the
 768         // run axis is y there, so the band becomes rows of one colour rather
 769         // than columns. Both bands do this, so they still compare to each
 770         // other — and at 14px the vertical version is roughly life size, since
 771         // a real wall is 4.8-9.3 logical px.
 772         let horiz = edge.horizontal_wall();
 773         let step = 1.0f32;
 774         let (scan_lo, scan_hi) = if horiz { (strip_y, strip_y + strip_h) } else { (x_l, x_r) };
 775         let mut x = scan_lo;
 776         while x < scan_hi {
 777             let t = if horiz {
 778                 // One wall across the band's height, centred like the swatch's.
 779                 // Bottom mirrors for the same reason Right does.
 780                 let raw = (x - (strip_y + strip_h * 0.5)) / strip_h + 0.5;
 781                 if flip { 1.0 - raw } else { raw }
 782             } else {
 783                 t_of(x)
 784             };
 785             // The plate's edge roll is the OTHER shader branch: it emits its
 786             // own material rather than an overlay, and it ends at a silhouette
 787             // rather than a floor, so past f = 1 there is nothing to draw.
 788             if matches!(shape, Shape::EdgeRoll) {
 789                 let roll_slope_at = |f: f32| -> f32 {
 790                     if !custom {
 791                         return relief_shade::analytic_roll_slope(f);
 792                     }
 793                     let d = 1.0 / 32.0;
 794                     let (a, b) = ((f - d * 0.5).clamp(0.0, 1.0), (f + d * 0.5).clamp(0.0, 1.0));
 795                     // Taper at the FACE end only; the silhouette keeps whatever
 796                     // slope the curve was drawn ending on (roll_slope's `win`).
 797                     let taper = (f * 32.0 * 0.667).clamp(0.0, 1.0);
 798                     if b <= a { 0.0 } else { (profile.eval(b) - profile.eval(a)) / (b - a) * taper }
 799                 };
 800                 // Past the silhouette (f > 1) there is nothing; INSIDE the
 801                 // face (f < 0) there is the plate's own colour, untouched —
 802                 // that is the whole point of expressing plate shading relative
 803                 // to the flat face. Drawing nothing there, as the first cut
 804                 // did, leaves the band empty over most of its length and looks
 805                 // like the model failing.
 806                 //
 807                 // The section draws this shape with the face on the plateau
 808                 // side and AIR past the wall — so the outward normal at the
 809                 // drawn silhouette points along +x, which is the direction fed
 810                 // to the model here.
 811                 let c = if t < 0.0 {
 812                     Some(surface)
 813                 } else {
 814                     relief_shade::plate_surface(surface, t, [1.0, 0.0], &roll_slope_at, light, &mat)
 815                 };
 816                 if let Some(c) = c {
 817                     let band = if horiz {
 818                         Rect { x: x_l, y: x, width: x_r - x_l, height: step.min(scan_hi - x) }
 819                     } else {
 820                         Rect { x, y: strip_y, width: step.min(scan_hi - x), height: strip_h }
 821                     };
 822                     pc.quad(band, [c[0], c[1], c[2], 1.0]);
 823                 }
 824                 x += step;
 825                 continue;
 826             }
 827             let mut c = surface;
 828             for (mode, w0, w1) in &walls {
 829                 let u = if horiz { t } else { (t - w0) / (w1 - w0) };
 830                 if !(-0.02..=1.02).contains(&u) {
 831                     continue;
 832                 }
 833                 // Facing: the SDF gradient at this wall, pointing out of the
 834                 // carve. THIS is what the edge selector changes, and it is the
 835                 // whole of the difference between the four walls.
 836                 let v = relief_shade::carve_shade(*mode, u, edge.facing(), &slope_at, light, &mat);
 837                 c = relief_shade::composite(c, v);
 838             }
 839             let band = if horiz {
 840                 Rect { x: x_l, y: x, width: x_r - x_l, height: step.min(scan_hi - x) }
 841             } else {
 842                 Rect { x, y: strip_y, width: step.min(scan_hi - x), height: strip_h }
 843             };
 844             pc.quad(band, [c[0], c[1], c[2], 1.0]);
 845             x += step;
 846         }
 847         // THE LIVE SWATCH, directly under the prediction and on the same
 848         // x-scale: the shape emitted as REAL prims, through the same PaintCtx
 849         // as everything else, so the renderer draws it with the actual shader.
 850         //
 851         // The two bands are the anti-drift device with teeth. A shared constant
 852         // and a parsing test say the numbers agree; these say the PICTURES do.
 853         // Any divergence between them is either a bug in relief_shade or a
 854         // change in the shader that relief_shade has not tracked, and it shows
 855         // up as a visible seam between the bands rather than as silence.
 856         //
 857         // Alignment: a carve's wall straddles its box edge by ±depth/2, so
 858         // emitting at depth = `unit` with the edge at the section's own wall
 859         // centre puts the real wall over the predicted one, column for column.
 860         let swatch_y = strip_y + strip_h + 2.0;
 861         let edge_x = x0 + unit * 0.5;
 862         // Tall and wide: only the LEFT wall is meant to land in the band, so
 863         // the box's other three edges are pushed well outside it. The clip is
 864         // what keeps the cover quad — which inflates past the box — off the
 865         // section above and the sliders below.
 866         pc.clip(
 867             Rect { x: x_l, y: swatch_y, width: x_r - x_l, height: strip_h },
 868             |pc| {
 869                 // OPAQUE: page_low_color carries the plate's own alpha, and
 870                 // over the near-black opening that lands ~4 grey levels below
 871                 // the predicted band, which then reads as a constant model
 872                 // error it is not. The two bands must differ ONLY by shading.
 873                 // A carve needs a surface to cut into; a PLATE brings its own
 874                 // fill and ends at a silhouette. Backing the plate with a full
 875                 // band of its own colour paints over the air past that
 876                 // silhouette, so the band reads as uniform material and the
 877                 // roll vanishes — the swatch has to be left empty for it.
 878                 if !matches!(shape, Shape::EdgeRoll) {
 879                     pc.quad(
 880                         Rect { x: x_l, y: swatch_y, width: x_r - x_l, height: strip_h },
 881                         [plate[0], plate[1], plate[2], 1.0],
 882                     );
 883                 }
 884                 // The box is placed so the SELECTED wall is the one crossing
 885                 // the band, and its other three edges are pushed far outside
 886                 // it. For a horizontal wall the box spans the band's width and
 887                 // its top/bottom edge sits at the band's mid-height, so the
 888                 // wall runs across the band at depth = strip_h.
 889                 let (tall, d_sw) = match edge {
 890                     Edge::Left => (
 891                         Rect { x: edge_x, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
 892                         unit,
 893                     ),
 894                     Edge::Right => (
 895                         Rect { x: edge_x - 4000.0, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
 896                         unit,
 897                     ),
 898                     Edge::Top => (
 899                         Rect { x: x_l - 400.0, y: swatch_y + strip_h * 0.5, width: (x_r - x_l) + 800.0, height: 4000.0 },
 900                         strip_h,
 901                     ),
 902                     Edge::Bottom => (
 903                         Rect { x: x_l - 400.0, y: swatch_y + strip_h * 0.5 - 4000.0, width: (x_r - x_l) + 800.0, height: 4000.0 },
 904                         strip_h,
 905                     ),
 906                 };
 907                 let unit = d_sw;
 908                 let sq = (0.0, 0.0, 0.0, 0.0);
 909                 match shape {
 910                     Shape::Recess | Shape::Fillet => pc.recess(tall, sq, unit),
 911                     Shape::Boss => pc.boss(tall, sq, unit),
 912                     Shape::Ridge => pc.ridge(tall, sq, unit),
 913                     Shape::Trough => pc.trough(tall, sq, unit),
 914                     Shape::InsetPlate => pc.inset_plate(tall, sq, None, unit),
 915                     Shape::Groove => {
 916                         let cx = x0 + unit * (0.5 + GROOVE_FLOOR * 0.5);
 917                         pc.groove(
 918                             (cx, swatch_y - 400.0),
 919                             (cx, swatch_y + strip_h + 400.0),
 920                             GROOVE_FLOOR * unit,
 921                             unit,
 922                             tall,
 923                         );
 924                     }
 925                     // The pair as inset_plate used to emit it, in order.
 926                     Shape::InsetStacked => {
 927                         let g = unit * 0.5;
 928                         let inner = Rect { x: edge_x + g, ..tall };
 929                         pc.recess(
 930                             Rect { x: inner.x - g, y: inner.y, width: inner.width + 2.0 * g, height: inner.height },
 931                             sq,
 932                             unit,
 933                         );
 934                         pc.boss(inner, sq, unit);
 935                     }
 936                     // A plate's roll runs INWARD from its silhouette, where a
 937                     // carve's wall straddles its edge — so this box is placed
 938                     // by its silhouette at the section's x1, not by a wall
 939                     // centre at edge_x. Half a roll of misalignment otherwise.
 940                     Shape::EdgeRoll => pc.plate(
 941                         Rect { x: x1 - 4000.0, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
 942                         sq,
 943                         &cce_ui::scene::Material::from_fill([plate[0], plate[1], plate[2], 1.0]),
 944                         unit,
 945                     ),
 946                 }
 947             },
 948         );
 949 
 950         if walls.is_empty() {
 951             pc.text_with(
 952                 "".to_string(),
 953                 x_l + 4.0,
 954                 strip_y + 1.0,
 955                 10.0,
 956                 num_color,
 957                 Some("monospace".to_string()),
 958                 None,
 959             );
 960         }
 961     }
 962 
 963     // The surface stroke, lit per segment: outward normal (material below)
 964     // against the DE light azimuth — the same light the real walls shade by.
 965     let az = cce_ui::layout::light_source_position();
 966     let (lx, ly) = (az.cos(), -az.sin());
 967     let base = [0.60f32, 0.65, 0.74];
 968     let n_seg = 56usize;
 969     let seg_end = if has_floor { x_r } else { x1 };
 970     let mut prev = (x_l, surface_y(x_l).unwrap_or(y_top));
 971     for i in 1..=n_seg {
 972         let x = x_l + (seg_end - x_l) * i as f32 / n_seg as f32;
 973         let Some(y) = surface_y(x) else { break };
 974         let (dx, dy) = (x - prev.0, y - prev.1);
 975         let len = (dx * dx + dy * dy).sqrt().max(1e-3);
 976         let (nx, ny) = (dy / len, -dx / len);
 977         let lit = (nx * lx + ny * ly) * 0.35;
 978         let c = [
 979             (base[0] + lit).clamp(0.0, 1.0),
 980             (base[1] + lit).clamp(0.0, 1.0),
 981             (base[2] + lit).clamp(0.0, 1.0),
 982             1.0,
 983         ];
 984         pc.vector(prev.0, prev.1, x, y, 2.5, c, Cap::Round);
 985         prev = (x, y);
 986     }
 987     // The roll's cut face: a dimmer vertical edge closing the slab at the
 988     // silhouette.
 989     if !has_floor {
 990         pc.vector(x1, y_bot, x1, slab_bot, 2.0, [0.36, 0.39, 0.46, 1.0], Cap::Round);
 991     }
 992 
 993     // The opening's rim, drawn last so its shading falls over the slab edges.
 994     let depth = cce_ui::layout::bevel_width().min(rect.height * 0.2);
 995     pc.recess(rect, radii, depth);
 996 }
 997 
 998 impl BevelPopup {
 999     /// The pinned drop as the length it should be written as: millimetres
1000     /// when the display metric is real, logical px when it is only assumed.
1001     fn height_len(&self, px: f32) -> cce_ui::units::Len {
1002         let m = cce_ui::units::metric();
1003         if m.is_real() {
1004             cce_ui::units::Len::mm(((px * m.mm_per_px()) * 1000.0).round() / 1000.0)
1005         } else {
1006             cce_ui::units::Len::px(px)
1007         }
1008     }
1009 
1010     /// The selected shape. The dropdown index is the ONLY source; read it
1011     /// through here so layout and paint cannot disagree about it.
1012     fn active_shape(&self) -> Shape {
1013         Shape::ALL
1014             .get(self.profile_dropdown.selected)
1015             .copied()
1016             .unwrap_or(Shape::Recess)
1017     }
1018 
1019     /// The wall the section is taken through.
1020     fn active_edge(&self) -> Edge {
1021         Edge::ALL.get(self.edge_dropdown.selected).copied().unwrap_or(Edge::Left)
1022     }
1023 
1024     /// The curve the knobs are editing for the selected shape.
1025     fn active_curve(&self) -> Curve {
1026         self.active_shape().curve()
1027     }
1028 
1029     fn root_ids(&self) -> [WidgetId; 19] {
1030         [
1031             self.profile_dropdown.id(),
1032             self.edge_dropdown.id(),
1033             self.wall.shoulder.id(),
1034             self.wall.base.id(),
1035             self.wall.bias.id(),
1036             self.edge.shoulder.id(),
1037             self.edge.base.id(),
1038             self.edge.bias.id(),
1039             self.depth_slider.id(),
1040             self.width_slider.id(),
1041             self.height_slider.id(),
1042             self.spec_slider.id(),
1043             self.shine_slider.id(),
1044             self.curv_slider.id(),
1045             self.comp_slider.id(),
1046             self.refr_slider.id(),
1047             self.radius_slider.id(),
1048             self.save_button.id(),
1049             self.cancel_button.id(),
1050         ]
1051     }
1052 
1053     fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 19] {
1054         [
1055             self.profile_dropdown.as_ptr_mut(),
1056             self.edge_dropdown.as_ptr_mut(),
1057             self.wall.shoulder.as_ptr_mut(),
1058             self.wall.base.as_ptr_mut(),
1059             self.wall.bias.as_ptr_mut(),
1060             self.edge.shoulder.as_ptr_mut(),
1061             self.edge.base.as_ptr_mut(),
1062             self.edge.bias.as_ptr_mut(),
1063             self.depth_slider.as_ptr_mut(),
1064             self.width_slider.as_ptr_mut(),
1065             self.height_slider.as_ptr_mut(),
1066             self.spec_slider.as_ptr_mut(),
1067             self.shine_slider.as_ptr_mut(),
1068             self.curv_slider.as_ptr_mut(),
1069             self.comp_slider.as_ptr_mut(),
1070             self.refr_slider.as_ptr_mut(),
1071             self.radius_slider.as_ptr_mut(),
1072             self.save_button.as_ptr_mut(),
1073             self.cancel_button.as_ptr_mut(),
1074         ]
1075     }
1076 
1077     /// The pane rung's bound material name, when config binds one — the
1078     /// target the material sliders edit and Save writes; `None` = the DE
1079     /// keys (`style.surface.relief.*` for the finish, `style.surface.plate.*`
1080     /// for the frost).
1081     fn bound_material() -> Option<String> {
1082         cce_ui::color::material_binding(cce_ui::scene::PlateRung::Pane)
1083     }
1084 
1085     /// Push the six material sliders into the live style: the bound
1086     /// material's node when there is one (so the panes made of it follow),
1087     /// else the DE keys every unbound rung reads.
1088     fn apply_material_live(&self) {
1089         use cce_ui::scene::{FrostDef, MaterialDef};
1090         let spec = self.spec_slider.inner().get_scaled_value();
1091         let shine = self.shine_slider.inner().get_scaled_value();
1092         let curv = self.curv_slider.inner().get_scaled_value();
1093         let comp = self.comp_slider.inner().get_scaled_value();
1094         let refr = self.refr_slider.inner().get_scaled_value();
1095         let radius = self.radius_slider.inner().get_scaled_value();
1096         match self.material_target.clone() {
1097             Some(name) => {
1098                 let mut def: MaterialDef = cce_ui::color::named_material(&name).unwrap_or_default();
1099                 def.spec = Some(spec);
1100                 def.shininess = Some(shine);
1101                 def.curvature = Some(curv);
1102                 // Only a frosted material has a recipe to edit; an opaque
1103                 // node stays opaque (the sliders read as "when frosted").
1104                 if def.frost.is_some() || self.material_frosted {
1105                     def.frost = Some(FrostDef { compression: Some(comp), refraction: Some(refr), radius: Some(radius) });
1106                 }
1107                 cce_ui::color::set_named_material(&name, Some(def));
1108             }
1109             None => {
1110                 cce_ui::color::set_finish_spec(spec);
1111                 cce_ui::color::set_finish_shininess(shine);
1112                 cce_ui::color::set_finish_curvature(curv);
1113                 cce_ui::color::set_plate_backdrop_compression(comp);
1114                 cce_ui::color::set_plate_refraction(refr);
1115                 cce_ui::color::set_plate_frost_radius(radius);
1116             }
1117         }
1118     }
1119 
1120     /// Persist the material sliders: into the bound material's node
1121     /// (`style.surface.material.<name>.finish` / `.frost`) when the pane rung
1122     /// is bound, else the DE keys. Never restructures a config that has no
1123     /// materials — the named form is opted into by writing the binding.
1124     fn save_material(&self, p: &str) -> bool {
1125         let f = |v: f32| format!("{v:.3}");
1126         let w = |key: &str, value: &str| cce_ui::config::write_config_value(p, key, value, "style");
1127         let spec = f(self.spec_slider.inner().get_scaled_value());
1128         let shine = f(self.shine_slider.inner().get_scaled_value());
1129         let curv = f(self.curv_slider.inner().get_scaled_value());
1130         let comp = f(self.comp_slider.inner().get_scaled_value());
1131         let refr = f(self.refr_slider.inner().get_scaled_value());
1132         let radius = f(self.radius_slider.inner().get_scaled_value());
1133         match self.material_target.clone() {
1134             Some(name) => {
1135                 let m = format!("style.surface.material.{name}");
1136                 let frosted = self.material_frosted;
1137                 w(&format!("{m}.finish.spec"), &spec)
1138                     & w(&format!("{m}.finish.shininess"), &shine)
1139                     & w(&format!("{m}.finish.curvature"), &curv)
1140                     & (!frosted
1141                         || (w(&format!("{m}.frost.backdrop_compression"), &comp)
1142                             & w(&format!("{m}.frost.refraction"), &refr)
1143                             & w(&format!("{m}.frost.radius"), &radius)))
1144             }
1145             None => {
1146                 w("style.surface.relief.spec", &spec)
1147                     & w("style.surface.relief.shininess", &shine)
1148                     & w("style.surface.relief.curvature", &curv)
1149                     & w("style.surface.plate.backdrop_compression", &comp)
1150                     & w("style.surface.plate.refraction", &refr)
1151                     & w("style.surface.plate.radius", &radius)
1152             }
1153         }
1154     }
1155 
1156     /// `take_*` plumbing after any routed dispatch — state-gated, so it does
1157     /// not matter which propagate call consumed the event.
1158     fn drain_widget_changes(&mut self) {
1159         if self.edge_dropdown.take_change() {
1160             self.needs_rebuild = true;
1161         }
1162         if self.profile_dropdown.take_change() {
1163             // Switch which profile the section shows — re-arrange parks the
1164             // other set's knobs off-screen.
1165             self.needs_rebuild = true;
1166         }
1167         if self.wall.take_change() {
1168             self.wall.custom = true;
1169             let keys = self.wall.keys();
1170             cce_ui::layout::set_bevel_profile_keys(&keys, false);
1171             let spec = self.wall.spec();
1172             println!("wall {spec}");
1173             self.wall.last_spec = spec;
1174             self.needs_rebuild = true;
1175         }
1176         if self.edge.take_change() {
1177             self.edge.custom = true;
1178             let keys = self.edge.keys();
1179             cce_ui::layout::set_roll_profile_keys(&keys, false);
1180             let spec = self.edge.spec();
1181             println!("edge {spec}");
1182             self.edge.last_spec = spec;
1183             self.needs_rebuild = true;
1184         }
1185         if self.depth_slider.take_change() {
1186             let v = self.depth_slider.inner().get_scaled_value();
1187             if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
1188                 reg.set_float("bevel_depth", v);
1189             }
1190             println!("depth {v:.3}");
1191             self.needs_rebuild = true;
1192         }
1193         if self.width_slider.take_change() {
1194             let v = self.width_slider.inner().get_scaled_value();
1195             if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
1196                 reg.set_float("bevel_width", v);
1197             }
1198             println!("width {v:.2}");
1199             self.needs_rebuild = true;
1200         }
1201         if self.height_slider.take_change() {
1202             let v = self.height_slider.inner().get_scaled_value();
1203             // 0 = follow the width (`layout::bevel_height` reads 0 as unset).
1204             if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
1205                 reg.set_float("bevel_height", v);
1206             }
1207             let m = cce_ui::units::metric();
1208             println!("height {v:.2}px = {:.3}mm ({})", v * m.mm_per_px(), m.source.as_str());
1209             self.needs_rebuild = true;
1210         }
1211         let material_moved = self.spec_slider.take_change()
1212             | self.shine_slider.take_change()
1213             | self.curv_slider.take_change()
1214             | self.comp_slider.take_change()
1215             | self.refr_slider.take_change()
1216             | self.radius_slider.take_change();
1217         if material_moved {
1218             self.apply_material_live();
1219             println!(
1220                 "material spec {:.3} shininess {:.1} curvature {:.3} | compression {:.3} refraction {:.3} radius {:.1}",
1221                 self.spec_slider.inner().get_scaled_value(),
1222                 self.shine_slider.inner().get_scaled_value(),
1223                 self.curv_slider.inner().get_scaled_value(),
1224                 self.comp_slider.inner().get_scaled_value(),
1225                 self.refr_slider.inner().get_scaled_value(),
1226                 self.radius_slider.inner().get_scaled_value(),
1227             );
1228             self.needs_rebuild = true;
1229         }
1230         if self.save_button.take_click() {
1231             self.save_to_config();
1232             self.needs_rebuild = true;
1233         }
1234         if self.cancel_button.take_click() {
1235             self.exit_requested = true;
1236         }
1237     }
1238 
1239     /// Persist the current material to the shared config
1240     /// (`style.surface.relief` — the same keys every app reads at startup).
1241     /// Untouched sections write the identity sentinel (= analytic); the knob
1242     /// triples ride along so this editor reopens where you left it.
1243     fn save_to_config(&mut self) {
1244         let p = self.config_path.to_string_lossy().into_owned();
1245         // `--key` mode: the whole material folds into ONE `(relief)` value
1246         // at that key — width, depth, the wall curve, and the knob triple
1247         // behind it (so reopening with --key seeds these sliders). The edge
1248         // section is not part of a feature material; an untouched analytic
1249         // wall writes no profile at all.
1250         if let Some(key) = self.target_key.clone() {
1251             let h = self.height_slider.inner().get_scaled_value();
1252             let spec = cce_ui::relief_spec::ReliefSpec {
1253                 width: self.width_slider.inner().get_scaled_value(),
1254                 height: (h > 0.0).then(|| self.height_len(h)),
1255                 light: Some(self.depth_slider.inner().get_scaled_value()),
1256                 knobs: Some(self.wall.values()),
1257                 profile: self.wall.custom.then(|| self.wall.last_spec.clone()),
1258             };
1259             let ok = cce_ui::config::write_config_value_typed(
1260                 &p,
1261                 &key,
1262                 &spec.serialize(),
1263                 "style",
1264                 Some("relief"),
1265             );
1266             self.status = if ok {
1267                 println!("saved {key} -> {p}");
1268                 format!("Saved — {key} holds this material.")
1269             } else {
1270                 "Save FAILED — see config.kdl permissions.".to_string()
1271             };
1272             return;
1273         }
1274         let depth = format!("{:.3}", self.depth_slider.inner().get_scaled_value());
1275         let width = format!("{:.2}", self.width_slider.inner().get_scaled_value());
1276         let knob_str = |k: &ProfileKnobs| {
1277             let (s, b, c) = k.values();
1278             format!("{s:.3},{b:.3},{c:.3}")
1279         };
1280         let w = &mut |key: &str, value: &str| {
1281             cce_ui::config::write_config_value(&p, key, value, "style")
1282         };
1283         // The knob keys carry the (bevel) type explicitly, so a config that
1284         // never had them gains the annotation (and its editors' previews).
1285         let wb = |key: &str, value: &str| {
1286             cce_ui::config::write_config_value_typed(&p, key, value, "style", Some("bevel"))
1287         };
1288         // The pinned drop is a LENGTH: written in millimetres when the
1289         // display metric is real (fabrication reads it straight), in logical
1290         // px when it is only assumed; 0 = follow the width.
1291         let h = self.height_slider.inner().get_scaled_value();
1292         let height_ok = if h > 0.0 {
1293             let len = self.height_len(h);
1294             cce_ui::config::write_config_value_typed(
1295                 &p,
1296                 "style.surface.relief.height",
1297                 &cce_ui::units::fmt_num(len.value),
1298                 "style",
1299                 Some(len.unit.suffix()),
1300             )
1301         } else {
1302             w("style.surface.relief.height", "0")
1303         };
1304         let material_ok = self.save_material(&p);
1305         let ok = height_ok
1306             & material_ok
1307             & w("style.surface.relief.depth", &depth)
1308             & w("style.surface.relief.width", &width)
1309             & w("style.surface.relief.profile", &self.wall.last_spec)
1310             & w("style.surface.relief.edge_profile", &self.edge.last_spec)
1311             & wb("style.surface.relief.profile_knobs", &knob_str(&self.wall))
1312             & wb("style.surface.relief.edge_knobs", &knob_str(&self.edge));
1313         self.status = if ok {
1314             println!("saved {p}");
1315             "Saved — apps pick the material up on start.".to_string()
1316         } else {
1317             "Save FAILED — see config.kdl permissions.".to_string()
1318         };
1319     }
1320 
1321 }
1322 
1323 impl Application for BevelPopup {
1324     type Message = BevelMsg;
1325 
1326     fn new(
1327         _qh: &QueueHandle<EngineState<Self>>,
1328         _sender: calloop::channel::Sender<Self::Message>,
1329     ) -> Self {
1330         cce_ui::scale::set_scale_factor(1.0);
1331         // Force the lazy config load BEFORE reading the registry: the knob
1332         // strings are read directly (no getter wraps them), so nothing else
1333         // has triggered it yet this early in startup.
1334         cce_ui::layout::lazy_init_style_registry();
1335 
1336         // `--config <path>`: retarget Save (and the seeds below) at a
1337         // specific config file instead of the shared config.kdl.
1338         let shared_path = cce_ui::config::get_config_path();
1339         let mut config_path = shared_path.clone();
1340         let args: Vec<String> = std::env::args().collect();
1341         let mut target_key: Option<String> = None;
1342         let mut i = 1;
1343         while i < args.len() {
1344             if args[i] == "--config" && i + 1 < args.len() {
1345                 config_path = std::path::PathBuf::from(&args[i + 1]);
1346                 i += 1;
1347             } else if args[i] == "--key" && i + 1 < args.len() {
1348                 target_key = Some(args[i + 1].clone());
1349                 i += 1;
1350             }
1351             i += 1;
1352         }
1353         let target_label = (config_path != shared_path).then(|| {
1354             // An app override (~/.config/cce/<app>/config.kdl) reads best as
1355             // the app name; anything else as the file name.
1356             let parent = config_path.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned());
1357             match parent {
1358                 Some(dir) if dir.starts_with("cce-") => dir,
1359                 _ => config_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| config_path.display().to_string()),
1360             }
1361         });
1362 
1363         // Seeds prefer the target file's own relief keys, falling back to
1364         // the DE-wide registry for anything it lacks.
1365         let target_json = target_label
1366             .is_some()
1367             .then(|| std::fs::read_to_string(&config_path).ok())
1368             .flatten()
1369             .map(|c| cce_ui::config::parse_kdl_to_json(&c));
1370         let target_relief = target_json.as_ref().and_then(|v| v.pointer("/style/surface/relief").cloned());
1371         let rel_str = |k: &str| {
1372             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_str().map(String::from))
1373         };
1374         let rel_f32 = |k: &str| {
1375             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_f64()).map(|f| f as f32)
1376         };
1377         // A length key: a bare number is logical px, a `(mm)`-annotated one
1378         // arrives as the string "0.3mm" and resolves through the metric.
1379         let rel_len = |k: &str| {
1380             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| {
1381                 v.as_f64()
1382                     .map(|f| f as f32)
1383                     .or_else(|| v.as_str().and_then(cce_ui::units::Len::parse).map(|l| l.to_px()))
1384             })
1385         };
1386         // `--key` seeds: the single `(relief)` value at that key wins over
1387         // both the target file's material keys and the registry. Installing
1388         // it live BEFORE the knob structs are built means the preview shows
1389         // the key's material from the first frame, and the wall's
1390         // `installed` flag reads the truth from the registry as usual.
1391         let key_spec = target_key.as_ref().and_then(|k| {
1392             std::fs::read_to_string(&config_path)
1393                 .ok()
1394                 .map(|c| cce_ui::config::parse_kdl_to_json(&c))
1395                 .and_then(|v| v.pointer(&format!("/{}", k.replace('.', "/"))).cloned())
1396                 .and_then(|v| v.as_str().map(String::from))
1397                 .and_then(|s| cce_ui::relief_spec::ReliefSpec::parse(&s))
1398         });
1399         if let Some(ks) = &key_spec {
1400             if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
1401                 reg.set_float("bevel_width", ks.width);
1402                 if let Some(d) = ks.light {
1403                     reg.set_float("bevel_depth", d);
1404                 }
1405                 if let Some(h) = ks.height {
1406                     reg.set_len("bevel_height", h);
1407                 }
1408             }
1409             cce_ui::layout::install_wall_profile_spec(ks.profile.as_deref());
1410         }
1411         let (wall_seed, edge_seed) = {
1412             let reg = cce_ui::layout::get_style_registry().read().unwrap();
1413             (
1414                 key_spec
1415                     .as_ref()
1416                     .and_then(|s| s.knobs)
1417                     .or_else(|| rel_str("profile_knobs").as_deref().and_then(parse_knobs))
1418                     .or_else(|| reg.get_string("bevel_profile_knobs").as_deref().and_then(parse_knobs)),
1419                 rel_str("edge_knobs")
1420                     .as_deref()
1421                     .and_then(parse_knobs)
1422                     .or_else(|| reg.get_string("roll_profile_knobs").as_deref().and_then(parse_knobs)),
1423             )
1424         };
1425 
1426         let depth = key_spec
1427             .as_ref()
1428             .and_then(|s| s.light)
1429             .or_else(|| rel_f32("light"))
1430             .or_else(|| rel_f32("depth"))
1431             .unwrap_or_else(cce_ui::layout::bevel_depth);
1432         let height = key_spec
1433             .as_ref()
1434             .and_then(|s| s.height)
1435             .map(|l| l.to_px())
1436             .or_else(|| rel_len("height"))
1437             .or_else(cce_ui::layout::bevel_height)
1438             .unwrap_or(0.0);
1439         let width = key_spec
1440             .as_ref()
1441             .map(|s| s.width)
1442             .or_else(|| rel_f32("width"))
1443             .unwrap_or_else(cce_ui::layout::bevel_width);
1444         // The seeds ARE the material this window previews: install them so
1445         // the section, the strip and the popup's own plate show the target
1446         // file's width / light / height from the first frame, not the
1447         // DE-wide registry's until a knob moves. (A `--key` spec was
1448         // installed above already; this repeats it harmlessly.)
1449         if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
1450             reg.set_float("bevel_depth", depth);
1451             reg.set_float("bevel_width", width);
1452             reg.set_float("bevel_height", height);
1453         }
1454         // A key target labels the window by the key, not the file.
1455         let target_label = match &target_key {
1456             Some(k) => {
1457                 let parts: Vec<&str> = k.split('.').collect();
1458                 Some(parts[parts.len().saturating_sub(2)..].join("."))
1459             }
1460             None => target_label,
1461         };
1462         let (dmin, dmax) = DEPTH_RANGE;
1463         let (wmin, wmax) = WIDTH_RANGE;
1464         let (hmin, hmax) = HEIGHT_RANGE;
1465         // The material sliders seed from the pane rung's effective material
1466         // — the bound node when config binds one, else the DE keys — so they
1467         // open on what the panes actually wear. With `--config`, that is the
1468         // TARGET file's material, read from the file: this process loads its
1469         // own config, and seeding from that would make a Save write this
1470         // app's values over the target's (the designer's 0.6 / 0.3 became 0
1471         // / 0 that way once).
1472         let pane = cce_ui::scene::Material::pane();
1473         let tj = target_json.as_ref();
1474         let tnum = |p: &str| tj.and_then(|v| v.pointer(p)).and_then(|v| v.as_f64()).map(|f| f as f32);
1475         let material_target: Option<String> = match tj {
1476             Some(v) => v
1477                 .pointer("/style/surface/plate/material")
1478                 .and_then(|b| b.as_str())
1479                 .filter(|s| !s.is_empty())
1480                 .map(String::from),
1481             None => Self::bound_material(),
1482         };
1483         let tdef = |k: &str| material_target.as_deref().map(|n| format!("/style/surface/material/{n}/{k}"));
1484         let material_frosted = match tj {
1485             Some(v) => tdef("frost").is_some_and(|p| v.pointer(&p).is_some())
1486                 || (material_target.is_none() && tnum("/style/surface/plate/blur").map_or(
1487                     v.pointer("/style/surface/plate/blur").and_then(|b| b.as_bool()).unwrap_or(false),
1488                     |f| f > 0.001,
1489                 )),
1490             None => material_target
1491                 .as_deref()
1492                 .map_or(pane.frost.is_frosted(), |n| cce_ui::color::named_material(n).is_some_and(|d| d.frost.is_some())),
1493         };
1494         let finish_seed = |k: &str, process: f32| -> f32 {
1495             tdef(&format!("finish/{k}"))
1496                 .and_then(|p| tnum(&p))
1497                 .or_else(|| tnum(&format!("/style/surface/relief/{k}")))
1498                 .unwrap_or(process)
1499         };
1500         let (pcomp, prefr, pradius) = match pane.frost {
1501             cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
1502             cce_ui::scene::Frost::Opaque => match cce_ui::scene::Frost::from_style() {
1503                 cce_ui::scene::Frost::Frosted { compression, refraction, radius } => (compression, refraction, radius),
1504                 cce_ui::scene::Frost::Opaque => (0.0, 0.0, cce_ui::scene::Frost::DEFAULT_RADIUS),
1505             },
1506         };
1507         let frost_seed = |k: &str, plate_key: &str, process: f32| -> f32 {
1508             tdef(&format!("frost/{k}"))
1509                 .and_then(|p| tnum(&p))
1510                 .or_else(|| tnum(&format!("/style/surface/plate/{plate_key}")))
1511                 .unwrap_or(process)
1512         };
1513         let comp0 = frost_seed("backdrop_compression", "backdrop_compression", pcomp);
1514         let refr0 = frost_seed("refraction", "refraction", prefr);
1515         let radius0 = frost_seed("radius", "radius", pradius);
1516         let spec0 = finish_seed("spec", pane.finish.spec);
1517         let shine0 = finish_seed("shininess", pane.finish.shininess);
1518         let curv0 = finish_seed("curvature", pane.finish.curvature);
1519         let norm = |v: f32, (lo, hi): (f32, f32)| ((v - lo) / (hi - lo)).clamp(0.0, 1.0);
1520         let material_slider = |label: &str, v: f32, range: (f32, f32), decimals: usize| {
1521             Slider::new()
1522                 .with_label(label)
1523                 .with_range(range.0, range.1)
1524                 .with_value(norm(v, range))
1525                 .with_readout(true)
1526                 .with_decimals(decimals)
1527                 .with_scroll(true)
1528         };
1529         // The persisted per-app plate opacity, falling back to the DE look.
1530         // New path first, then the pre-rename one, so an existing opacity
1531         // setting keeps working without a migration step.
1532         let plate_opacity = std::fs::read_to_string(cce_ui::config::get_app_config_path("cce-relief"))
1533             .or_else(|_| std::fs::read_to_string(cce_ui::config::get_app_config_path("cce-bevel")))
1534             .ok()
1535             .map(|c| cce_ui::config::parse_kdl_to_json(&c))
1536             .and_then(|v| {
1537                 v.get("window")
1538                     .and_then(|w| w.get("opacity"))
1539                     .and_then(|o| o.as_f64())
1540                     .map(|f| (f as f32).clamp(0.0, 1.0))
1541             })
1542             .unwrap_or_else(|| cce_ui::color::root_plate_opacity());
1543         Self {
1544             profile_dropdown: Dropdown::new(
1545                 Shape::ALL.iter().map(|s| s.label().to_string()).collect(),
1546                 0,
1547             )
1548             .with_label("Shape"),
1549             edge_dropdown: Dropdown::new(
1550                 Edge::ALL.iter().map(|e| e.label().to_string()).collect(),
1551                 0,
1552             )
1553             .with_label("Edge"),
1554             wall: ProfileKnobs::new(wall_seed, cce_ui::layout::bevel_profile_slopes().is_some()),
1555             edge: ProfileKnobs::new(edge_seed, cce_ui::layout::roll_profile_slopes().is_some()),
1556             depth_slider: Slider::new()
1557                 .with_label("Light")
1558                 .with_range(dmin, dmax)
1559                 .with_value(((depth - dmin) / (dmax - dmin)).clamp(0.0, 1.0))
1560                 .with_readout(true)
1561                 .with_decimals(2)
1562                 .with_scroll(true),
1563             width_slider: Slider::new()
1564                 .with_label("Width")
1565                 .with_range(wmin, wmax)
1566                 .with_value(((width - wmin) / (wmax - wmin)).clamp(0.0, 1.0))
1567                 .with_readout(true)
1568                 .with_decimals(1)
1569                 .with_scroll(true),
1570             height_slider: Slider::new()
1571                 .with_label("Height")
1572                 .with_range(hmin, hmax)
1573                 .with_value(((height - hmin) / (hmax - hmin)).clamp(0.0, 1.0))
1574                 .with_readout(true)
1575                 .with_decimals(1)
1576                 .with_scroll(true),
1577             spec_slider: material_slider("Specular", spec0, SPEC_RANGE, 2),
1578             shine_slider: material_slider("Shininess", shine0, SHINE_RANGE, 0),
1579             curv_slider: material_slider("Curvature", curv0, CURV_RANGE, 2),
1580             comp_slider: material_slider("Compression", comp0, COMP_RANGE, 2),
1581             refr_slider: material_slider("Refraction", refr0, REFR_RANGE, 2),
1582             radius_slider: material_slider("Blur radius", radius0, RADIUS_RANGE, 1),
1583             save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
1584             cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
1585             material_target,
1586             material_frosted,
1587             exit_requested: false,
1588             plate_opacity,
1589             status: match (&target_key, &target_label) {
1590                 (Some(_), Some(l)) => format!("Edits apply live; Save writes the {l} key."),
1591                 (None, Some(l)) => format!("Edits apply live; Save writes {l}'s config."),
1592                 _ => "Edits apply live; Save writes config.kdl.".to_string(),
1593             },
1594             config_path,
1595             target_key,
1596             target_label,
1597             ui_context: cce_ui::context::UiContext::new(),
1598             width: 520,
1599             height: 480,
1600             scale_factor: 1.0,
1601             needs_rebuild: true,
1602             registered: false,
1603             status_pos: (0.0, 0.0),
1604             cut_rect: Rect::ZERO,
1605         }
1606     }
1607 
1608     fn settings(&self) -> WindowSettings {
1609         WindowSettings {
1610             title: match &self.target_label {
1611                 Some(l) => format!("Relief — {l}"),
1612                 None => "Relief".to_string(),
1613             },
1614             app_id: "cce-relief".to_string(),
1615             width: 520,
1616             // Asked for, not guessed: the exact height the content occupies at
1617             // this width (see `content_height`).
1618             height: content_height(520.0).round() as u32,
1619             fullscreen: false,
1620             // The floor is the same content height — this window has no useful
1621             // smaller shape, and shrinking it only eats the cutaway.
1622             min_size: Some((440, content_height(440.0).round() as u32)),
1623         }
1624     }
1625 
1626     /// The motivating case for the mode: this window's shape IS
1627     /// `content_height`, so nothing — not a drag, not a remembered size —
1628     /// should ever dictate a different one.
1629     fn utility(&self) -> bool {
1630         true
1631     }
1632 
1633     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
1634         match msg {
1635             BevelMsg::Exit => *exit = true,
1636         }
1637     }
1638 
1639     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1640         if self.ui_context.tick(dt) {
1641             self.drain_widget_changes();
1642             *needs_rebuild = true;
1643             self.needs_rebuild = true;
1644         }
1645     }
1646 
1647     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
1648         if !self.registered {
1649             self.registered = true;
1650             let self_ptr = self as *mut Self;
1651             unsafe {
1652                 for w in (*self_ptr).roots() {
1653                     let id = (*w).base().id();
1654                     self.ui_context.register_widget(id, w);
1655                 }
1656             }
1657         }
1658 
1659         let size_changed = self.width != size.width as u32
1660             || self.height != size.height as u32
1661             || self.scale_factor != scale;
1662         if self.needs_rebuild || size_changed {
1663             self.width = size.width as u32;
1664             self.height = size.height as u32;
1665             self.scale_factor = scale;
1666             cce_ui::scale::set_scale_factor(scale as f32);
1667 
1668             // Manual column layout: the profile selector, ONE cutaway, the
1669             // selected profile's knobs, then the global rows. The unselected
1670             // profile's knobs park off-screen.
1671             let pad = cce_ui::layout::root_plate_padding();
1672             let x = pad;
1673             let w = (self.width as f32 - 2.0 * pad).max(0.0);
1674             let gap = 14.0;
1675             let strip = {
1676                 let (_, fsize) = cce_ui::layout::control_label_font_detached_parsed();
1677                 fsize + cce_ui::layout::control_label_margin()
1678             };
1679             let knob_h = 22.0 + strip;
1680             let button_h = 26.0;
1681             let status_h = HEADER_FONT_SIZE + 4.0;
1682             // Rows above/below the cutaway: the selector row, then SIX
1683             // stacked sliders, then buttons and status. Stacked rather than
1684             // gridded because a slider's label and readout want the full width
1685             // — three to a row truncated both, and the two-wide Depth/Width row
1686             // set a different rhythm again for no reason.
1687             let fixed = knob_h + gap                      // selector row
1688                 + 6.0 * (knob_h + gap)                    // Shoulder..Height
1689                 + button_h + 8.0 + status_h + 2.0 * gap;  // buttons + status
1690             // The cutaway absorbs spare height — but only up to its NATURAL
1691             // height for this width (the proportional square domain plus
1692             // gutters), so the opening shrink-wraps the section instead of
1693             // floating it in dark space.
1694             let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
1695                 + 2.0 * CUT_MARGIN
1696                 + UNDERSIDE
1697                 + GUTTER_B
1698                 + 2.0 * SHADE_STRIP_H
1699                 + 6.0
1700                 + GUTTER_B;
1701             let cut_h = (self.height as f32 - 2.0 * pad - fixed).min(natural).max(90.0);
1702 
1703             let knob_row = |k: &mut ProfileKnobs, x: f32, y: f32| {
1704                 k.shoulder.set_rect(x, y, w, knob_h);
1705                 k.base.set_rect(x, y + (knob_h + gap), w, knob_h);
1706                 k.bias.set_rect(x, y + 2.0 * (knob_h + gap), w, knob_h);
1707             };
1708             let park = |k: &mut ProfileKnobs| {
1709                 k.shoulder.set_rect(-1000.0, -1000.0, 0.0, 0.0);
1710                 k.base.set_rect(-1000.0, -1000.0, 0.0, 0.0);
1711                 k.bias.set_rect(-1000.0, -1000.0, 0.0, 0.0);
1712             };
1713 
1714             let mut y = pad;
1715             // Shape takes the row's left portion, Edge the rest — one row,
1716             // because the cutaway is what should get the spare height.
1717             let edge_w = (w * 0.32).max(120.0).min(w * 0.5);
1718             let shape_w = (w - edge_w - gap).max(140.0);
1719             self.profile_dropdown.set_rect(x, y, shape_w, knob_h);
1720             self.edge_dropdown.set_rect(x + shape_w + gap, y, edge_w, knob_h);
1721             y += knob_h + gap;
1722             self.cut_rect = Rect { x, y, width: w, height: cut_h };
1723             y += cut_h + gap;
1724             // Keyed off the SHAPE's curve, never the dropdown index — several
1725             // shapes share the Wall curve, and this has to agree with the paint
1726             // side's `wall_active` or the row is laid out for one set and drawn
1727             // from the other, which parks every knob off-screen and looks like
1728             // the sliders vanished.
1729             if self.active_curve() == Curve::Wall {
1730                 knob_row(&mut self.wall, x, y);
1731                 park(&mut self.edge);
1732             } else {
1733                 knob_row(&mut self.edge, x, y);
1734                 park(&mut self.wall);
1735             }
1736             y += 3.0 * (knob_h + gap);
1737             self.depth_slider.set_rect(x, y, w, knob_h);
1738             y += knob_h + gap;
1739             self.width_slider.set_rect(x, y, w, knob_h);
1740             y += knob_h + gap;
1741             self.height_slider.set_rect(x, y, w, knob_h);
1742             y += knob_h + gap;
1743             // The material columns: Finish left, Frost right, three rows.
1744             let half = ((w - gap) * 0.5).max(60.0);
1745             for (l, r) in [
1746                 (&mut self.spec_slider, &mut self.comp_slider),
1747                 (&mut self.shine_slider, &mut self.refr_slider),
1748                 (&mut self.curv_slider, &mut self.radius_slider),
1749             ] {
1750                 l.set_rect(x, y, half, knob_h);
1751                 r.set_rect(x + half + gap, y, half, knob_h);
1752                 y += knob_h + gap;
1753             }
1754             self.save_button.set_rect(x, y, 96.0, button_h);
1755             self.cancel_button.set_rect(x + 96.0 + 12.0, y, 96.0, button_h);
1756             y += button_h + 8.0;
1757             self.status_pos = (x, y);
1758 
1759             self.needs_rebuild = false;
1760             self.ui_context.rebuild_spatial_grid();
1761         }
1762 
1763         // BOTH selectors, not just the shape one. A dropdown whose popover is
1764         // neither registered nor rendered still OPENS on a press — it just
1765         // opens invisibly, so its items cannot be hit and the widget reads as
1766         // completely dead. That is what adding the Edge selector looked like
1767         // until this list grew: paint, layout, registration and routing were
1768         // all correct and the thing still did nothing.
1769         self.ui_context.clear_popovers();
1770         if self.profile_dropdown.popover_rect().is_some() {
1771             self.ui_context.register_popover(&mut self.profile_dropdown);
1772         }
1773         if self.edge_dropdown.popover_rect().is_some() {
1774             self.ui_context.register_popover(&mut self.edge_dropdown);
1775         }
1776 
1777         let mut pc = PaintCtx::new();
1778         let (w, h) = (self.width as f32, self.height as f32);
1779 
1780         // The window plate at full opacity on purpose: its rolled perimeter
1781         // previews the edge profile, and the wells/buttons preview the wall
1782         // profile — the popup is its own material sample.
1783         let mut plate = cce_ui::color::page_low_color();
1784         plate[3] = self.plate_opacity;
1785         let radius = cce_ui::colors::root_plate_corner_radius();
1786         let bevel = cce_ui::layout::bevel_width();
1787         pc.plate(
1788             Rect { x: 0.0, y: 0.0, width: w, height: h },
1789             (radius, radius, radius, radius),
1790             &cce_ui::scene::Material::from_fill(plate),
1791             bevel,
1792         );
1793 
1794         pc.text_with(
1795             self.status.clone(),
1796             self.status_pos.0,
1797             self.status_pos.1,
1798             HEADER_FONT_SIZE,
1799             HEADER_COLOR,
1800             Some("monospace".to_string()),
1801             None,
1802         );
1803 
1804         let shape = self.active_shape();
1805         let wall_active = shape.curve() == Curve::Wall;
1806         let active = if wall_active { &self.wall } else { &self.edge };
1807         draw_section(&mut pc, self.cut_rect, active, shape, self.active_edge());
1808 
1809         let knobs = if wall_active { &self.wall } else { &self.edge };
1810         for s in [
1811             &knobs.shoulder,
1812             &knobs.base,
1813             &knobs.bias,
1814             &self.depth_slider,
1815             &self.width_slider,
1816             &self.height_slider,
1817             &self.spec_slider,
1818             &self.shine_slider,
1819             &self.curv_slider,
1820             &self.comp_slider,
1821             &self.refr_slider,
1822             &self.radius_slider,
1823         ] {
1824             cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
1825         }
1826         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.edge_dropdown, &mut pc);
1827         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.profile_dropdown, &mut pc);
1828         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.save_button, &mut pc);
1829         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.cancel_button, &mut pc);
1830 
1831         // The selector's popover, drawn into the frame on top of everything
1832         // below it (its labels carry the popover rect as bounds).
1833         if self.profile_dropdown.popover_rect().is_some() {
1834             // PaintCtx is a RenderTarget: the popover draws its real prims (the
1835             // dropdown's expanded inset-plate surface) with its own bounds.
1836             self.profile_dropdown.render_popover(&mut pc);
1837         }
1838         if self.edge_dropdown.popover_rect().is_some() {
1839             self.edge_dropdown.render_popover(&mut pc);
1840         }
1841 
1842         // The shared context menu (slider Copy/Paste), last, on top.
1843         // The lit plate and the menu font, in one call — the flat quads and
1844         // a family-less label loop drew this menu square, opaque and in the
1845         // default sans, unlike every app's.
1846         cce_ui::widget::context_menu::paint_with_labels(&mut pc);
1847 
1848         Some(pc.finish())
1849     }
1850 
1851     fn display_list_text(&self) -> bool {
1852         true
1853     }
1854 
1855     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
1856         Some(&self.ui_context)
1857     }
1858 
1859     // Engine-driven animation frames for the dropdown expand/contract.
1860     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
1861         Some(&mut self.ui_context)
1862     }
1863 
1864     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
1865         self.ui_context.drag_allowed_at(px, py)
1866     }
1867 
1868     fn clear_color(&self) -> [f32; 4] {
1869         [0.0, 0.0, 0.0, 0.0]
1870     }
1871 
1872     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1873         if self.ui_context.cursor_moved_context_menu(pos.x, pos.y) {
1874             *needs_rebuild = true;
1875             self.needs_rebuild = true;
1876         }
1877         let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
1878         let mut changed = false;
1879         for root in self.root_ids() {
1880             if self.ui_context.propagate_event(&ev, root) {
1881                 changed = true;
1882             }
1883         }
1884         self.drain_widget_changes();
1885         if changed || self.needs_rebuild {
1886             *needs_rebuild = true;
1887             self.needs_rebuild = true;
1888         }
1889     }
1890 
1891     fn handle_mouse_input(
1892         &mut self,
1893         button: MouseButton,
1894         state: ElementState,
1895         pos: LogicalPosition,
1896         needs_rebuild: &mut bool,
1897     ) -> Option<Self::Message> {
1898         // The open context menu owns the press (item dispatch / dismiss).
1899         if self.ui_context.mouse_input_context_menu(button, state, pos.x, pos.y) {
1900             self.drain_widget_changes();
1901             *needs_rebuild = true;
1902             self.needs_rebuild = true;
1903             return None;
1904         }
1905         let ev = Event::MouseButton {
1906             button,
1907             state,
1908             x: pos.x,
1909             y: pos.y,
1910             local_x: pos.x,
1911             local_y: pos.y,
1912         };
1913         let mut changed = false;
1914         for root in self.root_ids() {
1915             if self.ui_context.propagate_event(&ev, root) {
1916                 changed = true;
1917             }
1918         }
1919         self.drain_widget_changes();
1920         if self.exit_requested {
1921             return Some(BevelMsg::Exit);
1922         }
1923         if changed || self.needs_rebuild {
1924             *needs_rebuild = true;
1925             self.needs_rebuild = true;
1926         }
1927         None
1928     }
1929 
1930     fn handle_mouse_wheel(
1931         &mut self,
1932         delta: &MouseScrollDelta,
1933         pos: LogicalPosition,
1934         needs_rebuild: &mut bool,
1935     ) {
1936         let ev = Event::MouseWheel {
1937             delta: delta.clone(),
1938             x: pos.x,
1939             y: pos.y,
1940             local_x: pos.x,
1941             local_y: pos.y,
1942         };
1943         let mut changed = false;
1944         for root in self.root_ids() {
1945             if self.ui_context.propagate_event(&ev, root) {
1946                 changed = true;
1947             }
1948         }
1949         self.drain_widget_changes();
1950         if changed || self.needs_rebuild {
1951             *needs_rebuild = true;
1952             self.needs_rebuild = true;
1953         }
1954     }
1955 
1956     fn handle_key_input(
1957         &mut self,
1958         event: &KeyEvent,
1959         needs_rebuild: &mut bool,
1960     ) -> Option<Self::Message> {
1961         use cce_ui::widget::{Key, NamedKey};
1962         if event.state == ElementState::Pressed {
1963             // Escape exits — unless the context menu is up (the toolkit-wide
1964             // Escape-dismiss should win the first press).
1965             if event.logical_key == Key::Named(NamedKey::Escape)
1966                 && !self.ui_context.is_context_menu_visible()
1967             {
1968                 return Some(BevelMsg::Exit);
1969             }
1970             if event.ctrl {
1971                 if let Key::Character(ref c) = event.logical_key {
1972                     if c == "q" {
1973                         return Some(BevelMsg::Exit);
1974                     }
1975                 }
1976             }
1977         }
1978         let ev = Event::KeyInput(event.clone());
1979         let mut handled = false;
1980         for root in self.root_ids() {
1981             if self.ui_context.propagate_event(&ev, root) {
1982                 handled = true;
1983                 break;
1984             }
1985         }
1986         self.drain_widget_changes();
1987         if handled || self.needs_rebuild {
1988             *needs_rebuild = true;
1989             self.needs_rebuild = true;
1990         }
1991         None
1992     }
1993 }
1994 
1995 fn main() {
1996     cce_ui::engine::run::<BevelPopup>();
1997 }