git.lucas.co / cce-relief
relief editors: wall and edge profiles, finish, frost; ramp specs
git clone https://git.lucas.co/cce-relief.git

src/main.rs (104.2K)

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