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

src/widget/container/parameters_bg.rs (196.8K)

   1 //! Narrow-trait `ParametersBg` (Phase 5s) — the designer's parameter panel: a scrollable column
   2 //! of param rows (sliders, spinboxes, dropdowns, text boxes, toggles, colors, float3s,
   3 //! buttons, section borders, and an inline emacs-flavored code editor), each row's widget owned
   4 //! by value in parallel `Vec<Option<..>>` fields (most already `Adapted<W>` from earlier
   5 //! phases), plus a raw-pointer `children` container list. The designer stores it as
   6 //! `Box<dyn WidgetHost>` and drives it through direct `dyn WidgetHost` calls; `window_runner`'s
   7 //! `get_child_widget_for_quad` downcasts to the concrete type through `as_any` (which the
   8 //! adapter forwards to the inner widget) and reads the pub sub-widget fields — both keep
   9 //! working unchanged.
  10 //!
  11 //! The model caches its laid-out rect via [`Layout::rect_assigned`] (all row geometry derives
  12 //! from it — the TextBox pattern), and serves BOTH legacy escape hatches: the plain-quad view
  13 //! ([`Paint::serves_legacy_plain_quads`] — the designer renders params through raw
  14 //! `extra_quads`, and the panel's own background is drawn by the host from
  15 //! [`Paint::color`]/[`Paint::corner_style`], NOT emitted here), and the per-label text view
  16 //! ([`Paint::serves_legacy_labels`], new with this migration — each label clips to the
  17 //! viewport but the code editor's clip to the code box, in monospace, which the one-font
  18 //! one-bounds prim bridge can't express).
  19 //!
  20 //! The code row is an editor, not a text box (2026-09-24): a line-number gutter, shift+arrow /
  21 //! shift+click selection with ctrl+c / ctrl+x / ctrl+v through the toolkit clipboard, tab and
  22 //! shift+tab indenting by four, Enter carrying the line's indentation (a level deeper after an
  23 //! opening bracket), ctrl+z / ctrl+shift+z on a `History` of editor snapshots that lives as long
  24 //! as the editor does, and the emacs chords it always had. Edits go to the BUFFER and reach the
  25 //! row's VALUE on ctrl+enter, Escape, or leaving the row — never per keystroke, because a host
  26 //! that evaluates a script on every value change would fail it on every half-typed line. The
  27 //! border says which state the row is in (amber pending, blue applied, grey at rest), and
  28 //! [`ParametersBg::set_code_error_line`] lets the host flag the line its last evaluation
  29 //! failed on.
  30 //!
  31 //! Flagged approximation: the legacy scrollbar-press called `self.focus()` (base flag only —
  32 //! nothing reads it: the highlight keys on the ctx focus slot, and the designer tracks its
  33 //! focused pane by index); the `on_event` arm drops it.
  34 
  35 use crate::colors;
  36 use crate::scene::layout::Rect;
  37 use crate::scene::paint::PaintCtx;
  38 use crate::widget::display::{Float3, TextLabel};
  39 use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
  40 use crate::widget::{
  41     Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
  42     MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
  43 };
  44 
  45 /// A text row, in either variant: plain (`"text"`), or with a completion
  46 /// picker (`"textpick:a,b,c"` — the Houdini-style attribute/group chooser: a
  47 /// TextBox plus a slim menu-button Dropdown at its right edge whose pick
  48 /// fills the box; the host supplies the candidates in the type string).
  49 fn is_text_row(t: &str) -> bool {
  50     t == "text" || t.starts_with("textpick")
  51 }
  52 
  53 /// Width of a textpick row's picker button, nested inside the right end of
  54 /// the TextBox's recessed well.
  55 const PICK_W: f32 = 24.0;
  56 
  57 pub struct ParametersBg {
  58     /// A row VALUE changed inside `tick` (a picker stream folded into its
  59     /// row) — for the host's sync, which must not run on every tick that
  60     /// merely animated (a scroll glide reports change every frame; syncing
  61     /// the whole parameter list on each was the choppy params scroll,
  62     /// 2026-09-20). Drained by [`Self::take_tick_value_change`].
  63     tick_value_changed: bool,
  64     /// The label layout the rows were BUILT under (`layout::param_labels_inline`
  65     /// at the last rebuild): inline, the pane draws each label in a column
  66     /// beside an unlabelled control; stacked, the control carries its own
  67     /// label above itself. Read once per rebuild so geometry and widgets agree.
  68     inline_labels: bool,
  69     rect: Rect,
  70     display_params: Vec<(String, String, String)>,
  71     dragging_param: Option<usize>,
  72     pub focused_param: Option<usize>,
  73     pub code_editor: Option<TextEditorState>,
  74     /// The code editor's undo history: one entry per edit, typing runs
  75     /// coalesced by group. Lives exactly as long as the editor does.
  76     code_history: crate::history::History<TextEditorState>,
  77     /// A line (0-based) the host has flagged as the site of an error in the
  78     /// code row's value — a script that failed to parse. Drawn as a band
  79     /// under that line while the value it was reported for still stands.
  80     code_error_line: Option<usize>,
  81     mouse_pos: Option<(f32, f32)>,
  82     pub sliders: Vec<Option<Adapted<Slider>>>,
  83     pub float3s: Vec<Option<Adapted<Float3>>>,
  84     pub spinboxes: Vec<Option<Adapted<Spinbox>>>,
  85     pub buttons: Vec<Option<Adapted<Button>>>,
  86     pub choices: Vec<Option<Adapted<Dropdown>>>,
  87     pub texts: Vec<Option<Adapted<TextBox>>>,
  88     pub toggles: Vec<Option<Adapted<Toggle>>>,
  89     pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
  90     /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
  91     /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
  92     /// views can't carry the curve/key geometry.
  93     pub ramps: Vec<Option<Adapted<Ramp>>>,
  94     /// Titles of the sections the user has collapsed by clicking their header. Keyed by
  95     /// title so it outlives the row rebuild `set_display_params` runs on every node change.
  96     collapsed: std::collections::HashSet<String>,
  97     visible: bool,
  98     pub scroll_y: f32,
  99     pub content_h: f32,
 100     scrollbar_dragging: bool,
 101     drag_offset_y: f32,
 102     /// The raise/sink hysteresis (wheel/drag raises, hover sustains, the hold decays in
 103     /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
 104     /// this widget so every app's plate-straddling scrollbar behaves the same way.
 105     activity: crate::widget::ScrollbarActivity,
 106     /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
 107     scroll_motion: crate::widget::ScrollMotion,
 108     /// One code-editor column's shaped advance (monospace @12, the family/size
 109     /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
 110     /// and click→column math read it; the hardcoded 7.2 px/col they used
 111     /// before drifted off the glyphs. 0.0 until the first shape.
 112     code_char_advance: f32,
 113 }
 114 
 115 /// The channel: the ONLY gap a control keeps from whatever its edge meets — the
 116 /// neighboring control, or its section's wall. Controls pack edge-to-edge; the
 117 /// reliefs on either side (control bevel, section wall) shade the channel into a
 118 /// narrow 3D groove.
 119 const CHANNEL: f32 = 3.0;
 120 
 121 /// The section carve's wall width: the DE relief scaled by the section depth
 122 /// multiplier (`style.container.section.depth`), capped against the row height
 123 /// (safety) and the control channel — the channel cap scales WITH the
 124 /// multiplier, so deepening sections is an explicit choice to let the roll
 125 /// cross the groove.
 126 fn section_carve_depth(h: f32) -> f32 {
 127     let sd = crate::layout::section_depth().max(0.0);
 128     (crate::layout::bevel_width() * sd).min(h * 0.2).min(CHANNEL * sd)
 129 }
 130 /// Vertical pitch between consecutive rows. Wider than the horizontal
 131 /// channel on purpose: each label+control pair gets its own breathing room,
 132 /// so rows read as separate entries rather than one packed stack. Was 8
 133 /// until 2026-09-06; a control's flush seam and the next row's label sat
 134 /// close enough to read as one block, so the pitch is now near two channels
 135 /// past the label's own line height.
 136 const ROW_GAP: f32 = 14.0;
 137 /// How far a section's title box overhangs its header row upward, and the box's height.
 138 const TITLE_BOX_INSET: f32 = 2.0;
 139 const TITLE_BOX_H: f32 = 28.0;
 140 /// How far a section's content box overhangs the first and last row it wraps —
 141 /// one channel, so the rows abut the section's top/bottom walls too.
 142 const CONTENT_BOX_PAD: f32 = CHANNEL;
 143 /// The section outline's color, stroke width, and its corner radii: convex (outer) corners, and the
 144 /// concave (inner) corners where the neck joins the title and content boxes.
 145 const SECTION_BORDER_COLOR: [f32; 4] = [0.18, 0.18, 0.27, 1.0];
 146 const SECTION_BORDER_T: f32 = 1.0;
 147 const SECTION_R: f32 = 13.0;
 148 /// The throat — the concave fillet where the tab's right side turns onto the content
 149 /// body's top edge (the tab sits flush on the body; there is no connector neck).
 150 const SECTION_THROAT_R: f32 = 5.0;
 151 /// The relief carve's concave inside-corner radius at the tab throat
 152 /// (`section_fillets`) — sized against SECTION_R so inside and outside
 153 /// corners read as one family.
 154 const SECTION_FILLET_R: f32 = 10.0;
 155 /// Narrowest a title box may be: both its corners plus the throat fillet. Titles run
 156 /// wider than this in practice; it only keeps the throat clear of the corners.
 157 const SECTION_TITLE_MIN_W: f32 = 2.0 * SECTION_R + SECTION_THROAT_R;
 158 
 159 /// The gap between one section's bottom box edge and the next section's title box —
 160 /// deliberately much wider than the channel the rows pack on, so sections read as
 161 /// separate blocks. Laid out from the previous block's *drawn* bottom edge (rather
 162 /// than the uniform row pitch, which the two boxes' overhangs eat into unequally) so the
 163 /// gap is exact.
 164 const SECTION_GAP: f32 = 16.0;
 165 
 166 /// Horizontal inset of a section's boxes (title tab and content body) from the pane
 167 /// plate's sides — deliberately the wider of the two horizontal gaps, so sections
 168 /// float clearly inside the plate.
 169 const SECTION_MARGIN: f32 = 16.0;
 170 /// Horizontal gap between a control row and its parent section's side walls —
 171 /// one channel; controls abut the section's edge.
 172 const CONTROL_INSET: f32 = CHANNEL;
 173 /// A row's inset from the plate: the section margin plus the controls' inset within
 174 /// the section, so bare rows above the first section align with wrapped ones.
 175 const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
 176 
 177 impl ParametersBg {
 178     /// The code box's line pitch, its text inset below the box top, and the
 179     /// gutter that carries line numbers: four columns of digits and a gap.
 180     const CODE_LINE_H: f32 = 16.0;
 181     const CODE_TOP: f32 = 22.0;
 182     const CODE_BOX_TOP: f32 = 18.0;
 183     const CODE_GUTTER_COLS: f32 = 4.0;
 184     const CODE_INDENT: &'static str = "    ";
 185 
 186     /// Where a code row's text starts: past the gutter.
 187     fn code_text_x(&self, r: (f32, f32, f32, f32)) -> f32 {
 188         r.0 + 12.0 + (Self::CODE_GUTTER_COLS + 1.0) * self.code_col_w()
 189     }
 190 
 191     /// Flag a line of the code row as an error site, or clear it. The host
 192     /// calls this when the value it evaluated failed with a line number —
 193     /// it is the one piece of feedback a script editor cannot do without.
 194     pub fn set_code_error_line(&mut self, line: Option<usize>) {
 195         self.code_error_line = line;
 196     }
 197 
 198     /// Whether a code row is being edited right now.
 199     pub fn code_editing(&self) -> bool {
 200         self.focused_param.is_some() && self.code_editor.is_some()
 201     }
 202 
 203     /// The clipboard, selection and history actions over the code editor:
 204     /// what the runner's undo / redo chords and the context menu's rows
 205     /// reach through [`Input::context_action`], and what the editor's own
 206     /// chords call. Returns whether the editor was open to act on.
 207     pub fn code_action(&mut self, action: crate::widget::ContextAction) -> bool {
 208         match self.code_editor.as_mut() {
 209             Some(editor) => {
 210                 apply_code_action(editor, &mut self.code_history, action);
 211                 true
 212             }
 213             None => false,
 214         }
 215     }
 216 
 217     /// Whether the focused code row holds edits not yet applied to its value.
 218     pub fn code_is_dirty(&self) -> bool {
 219         match (self.focused_param, &self.code_editor) {
 220             (Some(i), Some(editor)) => self.display_params.get(i).is_some_and(|p| p.1 != editor.buffer),
 221             _ => false,
 222         }
 223     }
 224 
 225     /// One code column's width — the shaped advance when recorded, else the
 226     /// legacy 7.2 estimate (only before the first `prepare_text`).
 227     fn code_col_w(&self) -> f32 {
 228         if self.code_char_advance > 0.0 {
 229             self.code_char_advance
 230         } else {
 231             7.2
 232         }
 233     }
 234 
 235     pub fn new() -> Adapted<ParametersBg> {
 236         Adapted::new(ParametersBg {
 237             inline_labels: crate::layout::param_labels_inline(),
 238             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 239             display_params: Vec::new(),
 240             dragging_param: None,
 241             focused_param: None,
 242             code_editor: None,
 243             code_history: crate::history::History::with_limit(200),
 244             code_error_line: None,
 245             mouse_pos: None,
 246             sliders: Vec::new(),
 247             float3s: Vec::new(),
 248             spinboxes: Vec::new(),
 249             buttons: Vec::new(),
 250             choices: Vec::new(),
 251             texts: Vec::new(),
 252             toggles: Vec::new(),
 253             colors: Vec::new(),
 254             ramps: Vec::new(),
 255             collapsed: std::collections::HashSet::new(),
 256             visible: true,
 257             scroll_y: 0.0,
 258             content_h: 0.0,
 259             code_char_advance: 0.0,
 260             scrollbar_dragging: false,
 261             drag_offset_y: 0.0,
 262             activity: crate::widget::ScrollbarActivity::new(),
 263             scroll_motion: crate::widget::ScrollMotion::new(),
 264             tick_value_changed: false,
 265         })
 266     }
 267 
 268     /// Row `i`'s laid-out height, ignoring collapse (the row-type table).
 269     /// Whether row `i` is laid out with its label beside the control. Only
 270     /// the rows whose control would otherwise carry a label strip: toggles
 271     /// and buttons ARE their label, a ramp keeps its label band, sections
 272     /// and code rows have layouts of their own.
 273     fn inline_row(&self, i: usize) -> bool {
 274         if !self.inline_labels {
 275             return false;
 276         }
 277         let t = self.display_params[i].2.as_str();
 278         t.starts_with("slider")
 279             || t.starts_with("float3")
 280             || t.starts_with("spinbox")
 281             || t.starts_with("choice")
 282             || is_text_row(t)
 283             || t.starts_with("color")
 284             || t == "rgb"
 285             || t == "rgba"
 286     }
 287 
 288     /// The label strip an inline row no longer spends: the control was
 289     /// built unlabelled, so the row loses exactly the strip's height.
 290     fn inline_strip(&self, i: usize) -> f32 {
 291         if self.inline_row(i) { crate::layout::control_label_strip() } else { 0.0 }
 292     }
 293 
 294     /// Gap between the label column and the control.
 295     const LABEL_GAP: f32 = 16.0;
 296 
 297     /// The inline label's font: the pane's own labels draw in the control
 298     /// label font (`Paint::widget_font`), so the column is measured in it —
 299     /// family AND size — or the widest label runs into its control.
 300     fn inline_label_font() -> (String, f32) {
 301         crate::layout::control_label_font_parsed()
 302     }
 303 
 304     /// Width of the label column under the inline layout: the widest visible
 305     /// inline label plus the gap, clamped to a floor (so a pane of one-word
 306     /// labels still reads as a column) and to under half the row (so a long
 307     /// label truncates rather than squeezing the control out). Zero when
 308     /// nothing is inline.
 309     fn label_col_w(&self) -> f32 {
 310         if !self.inline_labels {
 311             return 0.0;
 312         }
 313         let (family, size) = Self::inline_label_font();
 314         let hidden = self.hidden_rows();
 315         let widest = self
 316             .display_params
 317             .iter()
 318             .enumerate()
 319             .filter(|(i, _)| !hidden[*i] && self.inline_row(*i))
 320             .map(|(_, p)| crate::widget::display::measure_text_width(&p.0, &family, size))
 321             .fold(0.0f32, f32::max);
 322         if widest <= 0.0 {
 323             return 0.0;
 324         }
 325         let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
 326         (widest + Self::LABEL_GAP).clamp(72.0f32.min(row_w * 0.45), row_w * 0.45)
 327     }
 328 
 329     /// Row `i`'s CONTROL rect: the row rect less the label column when the
 330     /// row is inline, the row rect itself otherwise.
 331     fn control_rect(&self, r: (f32, f32, f32, f32), i: usize, lw: f32) -> (f32, f32, f32, f32) {
 332         if self.inline_row(i) { (r.0 + lw, r.1, (r.2 - lw).max(0.0), r.3) } else { r }
 333     }
 334 
 335     fn row_height(&self, i: usize) -> f32 {
 336         let p = &self.display_params[i];
 337         if p.2 == "code" {
 338             let val_text = if self.focused_param == Some(i) {
 339                 if let Some(ref editor) = self.code_editor {
 340                     &editor.buffer
 341                 } else {
 342                     &p.1
 343                 }
 344             } else {
 345                 &p.1
 346             };
 347             let line_count = val_text.split('\n').count();
 348             let content_h = Self::CODE_TOP + (line_count as f32 * Self::CODE_LINE_H) + 12.0;
 349             content_h.max(200.0)
 350         } else if p.2 == "section" {
 351             24.0
 352         } else if p.2 == "ramp" {
 353             // Label band + the ramp's graph and control strip. The control
 354             // strip and label band are fixed, so this whole increase grows the
 355             // curve plot (Ramp::graph_h = height − strip).
 356             260.0
 357         } else if p.2.starts_with("float3") {
 358             Float3::preferred_height(!self.inline_row(i))
 359         } else if p.2.starts_with("slider") {
 360             (38.0 - self.inline_strip(i)).max(22.0)
 361         } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
 362             (42.0 - self.inline_strip(i)).max(24.0)
 363         } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
 364             (40.0 - self.inline_strip(i)).max(24.0)
 365         } else if p.2 == "button" || p.2 == "toggle" || p.2 == "checkbox" {
 366             24.0
 367         } else {
 368             20.0
 369         }
 370     }
 371 
 372     /// Per-row "sits inside a collapsed section" flags: a section owns every row between its
 373     /// header and the next header. Headers themselves are never hidden — they stay clickable
 374     /// so a collapsed section can be reopened. Rows before the first header belong to no
 375     /// section and are always shown.
 376     fn hidden_rows(&self) -> Vec<bool> {
 377         let mut out = Vec::with_capacity(self.display_params.len());
 378         let mut hiding = false;
 379         for p in &self.display_params {
 380             if p.2 == "section" {
 381                 hiding = self.collapsed.contains(&p.0);
 382                 out.push(false);
 383             } else {
 384                 out.push(hiding);
 385             }
 386         }
 387         out
 388     }
 389 
 390     /// Whether the section titled `title` is collapsed.
 391     pub fn section_collapsed(&self, title: &str) -> bool {
 392         self.collapsed.contains(title)
 393     }
 394 
 395     /// Collapse/expand the section titled `title`, re-laying the rows. Keyed by title, not
 396     /// row index, so the state survives the `set_display_params` rebuilds a host runs
 397     /// whenever the inspected node changes.
 398     pub fn set_section_collapsed(&mut self, title: &str, collapsed: bool) {
 399         let changed = if collapsed {
 400             self.collapsed.insert(title.to_string())
 401         } else {
 402             self.collapsed.remove(title)
 403         };
 404         if changed {
 405             self.refresh_scroll_metrics();
 406         }
 407     }
 408 
 409     /// The box drawn around a section header's title — and, since the title is the collapse
 410     /// affordance, that box is also the header's click target.
 411     fn section_title_box(&self, hdr: usize, r_hdr: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
 412         // The label sits 8px in from the box's left edge; matching that 8px on the right
 413         // (box width = text + 16) centers the text in the box. Measure the run in the
 414         // label's real family AND size — parsed, not the raw "Family NN" spec string, which
 415         // resvg can't resolve (it would fall back to a narrow font and undersize the box).
 416         let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
 417         let (label_family, label_size) = crate::layout::control_label_font_parsed();
 418         let text_w =
 419             crate::widget::display::measure_text_width(&self.display_params[hdr].0, &label_family, label_size);
 420         let title_w = (text_w + 16.0).max(SECTION_TITLE_MIN_W).min(full_w);
 421         // The tab sits FLUSH on the content body: its bottom edge is the body's top
 422         // edge, in every style (the relief carve always drew it there; the outline now
 423         // fuses to it too). Collapsed, the box stays where the expanded tab sits (one
 424         // title-box height above where the body's top edge would be), so collapsing
 425         // doesn't jump the tab — and the click-toggle hit zone follows the ink. An
 426         // expanded-but-empty section keeps the legacy header-row placement.
 427         let y = if self.collapsed.contains(&self.display_params[hdr].0) {
 428             r_hdr.1 + r_hdr.3 + ROW_GAP - CONTENT_BOX_PAD - TITLE_BOX_H
 429         } else {
 430             match self.first_visible_row_top(hdr) {
 431                 Some(control_top) => control_top - CONTENT_BOX_PAD - TITLE_BOX_H,
 432                 None => r_hdr.1 - TITLE_BOX_INSET,
 433             }
 434         };
 435         (self.rect.x + SECTION_MARGIN, y, title_w, TITLE_BOX_H)
 436     }
 437 
 438     /// The top edge of the first visible row under header `hdr` — the row the section's
 439     /// content box (and so its tab) hangs from. `None` for an empty section (a header
 440     /// with no rows of its own before the next header).
 441     fn first_visible_row_top(&self, hdr: usize) -> Option<f32> {
 442         let hidden = self.hidden_rows();
 443         let rects = self.get_param_rects();
 444         self.display_params
 445             .iter()
 446             .enumerate()
 447             .skip(hdr + 1)
 448             .take_while(|(_, q)| q.2 != "section")
 449             .find(|(j, _)| !hidden[*j])
 450             .map(|(j, _)| rects[j].1)
 451     }
 452 
 453     /// Each section as `(header index, its content-row range)` — the rows between a header
 454     /// and the next one, `None` for a header with nothing under it.
 455     fn sections(&self) -> Vec<(usize, Option<(usize, usize)>)> {
 456         let mut out: Vec<(usize, Option<(usize, usize)>)> = Vec::new();
 457         for (i, p) in self.display_params.iter().enumerate() {
 458             if p.2 == "section" {
 459                 out.push((i, None));
 460             } else if let Some((_, content)) = out.last_mut() {
 461                 match content {
 462                     Some((_, end)) => *end = i,
 463                     None => *content = Some((i, i)),
 464                 }
 465             }
 466         }
 467         out
 468     }
 469 
 470     /// Each section's boxes: the title box, plus the box wrapping its rows (`None` when the
 471     /// section is collapsed or has no rows). The shared source for the outline's straight
 472     /// runs and its corner fillets, so the two halves can't disagree.
 473     /// The row floors (`layout::param_compression`): the pane material
 474     /// frosted at the configured compression, filled under every visible
 475     /// parameter row (headers excepted) before anything else in the pane
 476     /// paints, at the control corner radius — each parameter on its own
 477     /// tablet, the way the designer's node bodies get their own compression.
 478     /// Nothing when the key is unset. Relief only: the flat style has no
 479     /// floor language.
 480     fn paint_row_floors(&self, ctx: &mut PaintCtx) {
 481         let Some(k) = crate::layout::param_compression() else { return };
 482         if !crate::layout::control_relief() {
 483             return;
 484         }
 485         let mut mat = crate::scene::Material::pane();
 486         if let crate::scene::Frost::Frosted { compression, .. } = &mut mat.frost {
 487             *compression = k;
 488         }
 489         let r = crate::layout::control_corner_radius();
 490         let hidden = self.hidden_rows();
 491         for (i, (x, y, w, h)) in self.get_param_rects().into_iter().enumerate() {
 492             if hidden[i] || h <= 0.0 || self.display_params[i].2 == "section" {
 493                 continue;
 494             }
 495             ctx.fill_material(Rect { x, y, width: w, height: h }, (r, r, r, r), &mat);
 496         }
 497     }
 498 
 499     fn section_boxes(&self) -> Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)> {
 500         let rects = self.get_param_rects();
 501         let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
 502         let mut out = Vec::new();
 503         for (hdr, content) in self.sections() {
 504             if hdr >= rects.len() {
 505                 continue;
 506             }
 507             let title = self.section_title_box(hdr, rects[hdr]);
 508             let content_box = if self.collapsed.contains(&self.display_params[hdr].0) {
 509                 None
 510             } else {
 511                 content.and_then(|(start, end)| {
 512                     if start <= end && start < rects.len() && end < rects.len() {
 513                         let by = rects[start].1 - CONTENT_BOX_PAD;
 514                         let bh = (rects[end].1 + rects[end].3 + CONTENT_BOX_PAD) - by;
 515                         Some((title.0, by, full_w, bh))
 516                     } else {
 517                         None
 518                     }
 519                 })
 520             };
 521             out.push((title, content_box));
 522         }
 523         out
 524     }
 525 
 526     /// One section's outline: a SINGLE continuous border shaped like a folder tab — around
 527     /// the title box, whose bottom edge is open onto the content body it sits flush on
 528     /// (its right side turning onto the body's top edge through the concave throat fillet,
 529     /// its left side running straight down into the body's left edge) — as
 530     /// `(straight runs, corner fillets)`.
 531     ///
 532     /// Runs are `(x, y, w, h)`; fillets are `(cx, cy, radius, start, end)` for an arc stroked
 533     /// `SECTION_BORDER_T` inward of `radius` (the renderer's convention). Convex corners take
 534     /// the stroke inside the box, so their radius is the outer one; the concave throat corner
 535     /// has its centre out in the empty pocket, so its carries the `+ T` that puts the
 536     /// ink on the far side. Every run stops a radius short of its corner, and each fillet
 537     /// picks it up there — the path closes.
 538     fn section_outline(
 539         &self,
 540         title: (f32, f32, f32, f32),
 541         content: Option<(f32, f32, f32, f32)>,
 542     ) -> (Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, f32, f32, f32)>) {
 543         use std::f32::consts::{PI, TAU};
 544         const Q: f32 = std::f32::consts::FRAC_PI_2;
 545         let (t, r) = (SECTION_BORDER_T, SECTION_R);
 546         let mut quads: Vec<(f32, f32, f32, f32)> = Vec::new();
 547         let mut arcs: Vec<(f32, f32, f32, f32, f32)> = Vec::new();
 548         fn hrun(out: &mut Vec<(f32, f32, f32, f32)>, x0: f32, x1: f32, y: f32) {
 549             if x1 - x0 > 0.01 {
 550                 out.push((x0, y, x1 - x0, SECTION_BORDER_T));
 551             }
 552         }
 553         fn vrun(out: &mut Vec<(f32, f32, f32, f32)>, y0: f32, y1: f32, x: f32) {
 554             if y1 - y0 > 0.01 {
 555                 out.push((x, y0, SECTION_BORDER_T, y1 - y0));
 556             }
 557         }
 558 
 559         let (tx, ty, tw, th) = title;
 560         let ty_b = ty + th; // the title box's bottom edge
 561         arcs.push((tx + r, ty + r, r, PI, PI + Q)); // title top-left
 562         arcs.push((tx + tw - r, ty + r, r, PI + Q, TAU)); // title top-right
 563         hrun(&mut quads, tx + r, tx + tw - r, ty); // title top
 564 
 565         let Some((cx, cy_t, cw, ch)) = content else {
 566             // Collapsed or empty: the title box IS the section, so it closes on itself.
 567             arcs.push((tx + tw - r, ty_b - r, r, 0.0, Q)); // title bottom-right
 568             arcs.push((tx + r, ty_b - r, r, Q, PI)); // title bottom-left
 569             vrun(&mut quads, ty + r, ty_b - r, tx + tw - t); // title right
 570             vrun(&mut quads, ty + r, ty_b - r, tx); // title left
 571             hrun(&mut quads, tx + r, tx + tw - r, ty_b - t); // title bottom
 572             return (quads, arcs);
 573         };
 574 
 575         // The tab sits flush on the body (`ty_b == cy_t` — `section_title_box` places it
 576         // there): its bottom edge is open. The throat fillet shrinks if the tab runs
 577         // close to the body's right corner.
 578         let f = SECTION_THROAT_R.min((cx + cw - r - (tx + tw)).max(0.0));
 579         vrun(&mut quads, ty + r, cy_t - f, tx + tw - t); // tab right side, down to the throat
 580         arcs.push((tx + tw + f, cy_t - f, f + t, Q, PI)); // throat: tab side -> body top
 581         hrun(&mut quads, tx + tw + f, cx + cw - r, cy_t); // body top, right of the tab
 582 
 583         arcs.push((cx + cw - r, cy_t + r, r, PI + Q, TAU)); // body top-right
 584         arcs.push((cx + cw - r, cy_t + ch - r, r, 0.0, Q)); // body bottom-right
 585         arcs.push((cx + r, cy_t + ch - r, r, Q, PI)); // body bottom-left
 586         vrun(&mut quads, cy_t + r, cy_t + ch - r, cx + cw - t); // body right
 587         hrun(&mut quads, cx + r, cx + cw - r, cy_t + ch - t); // body bottom
 588         vrun(&mut quads, ty + r, cy_t + ch - r, tx); // tab + body left, one straight run
 589 
 590         (quads, arcs)
 591     }
 592 
 593     /// The section outlines' corner fillets, as the renderer's arc tuples
 594     /// `(cx, cy, radius, thickness, start, end, color)`.
 595     ///
 596     /// A concrete accessor rather than prims out of [`Paint::paint`] on purpose: the host
 597     /// draws this panel through the legacy plain-quad hatch, and `append_widget_plate` serves
 598     /// a widget's arcs UNCLIPPED — these have to land inside the pane's scroll viewport, so
 599     /// the host draws them itself under its own clip (the straight runs ride `plain_quads`,
 600     /// which clips them there by hand).
 601     pub fn arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
 602         if !self.visible || crate::layout::control_relief() {
 603             return Vec::new();
 604         }
 605         let color = SECTION_BORDER_COLOR;
 606         let mut out = Vec::new();
 607         for (title, content) in self.section_boxes() {
 608             let (_, arcs) = self.section_outline(title, content);
 609             out.extend(
 610                 arcs.into_iter()
 611                     .map(|(cx, cy, r, a0, a1)| (cx, cy, r, SECTION_BORDER_T, a0, a1, color)),
 612             );
 613         }
 614         out
 615     }
 616 
 617     /// Where the rows start, in absolute y — the origin `get_param_rects` lays out from.
 618     fn rows_origin(&self) -> f32 {
 619         self.rect.y - self.scroll_y
 620     }
 621 
 622     /// The scrollable height of the row column. Derived from [`Self::get_param_rects`] rather
 623     /// than re-walking the advance rules, so the two can't drift apart.
 624     pub fn get_total_content_height(&self) -> f32 {
 625         let hidden = self.hidden_rows();
 626         let rects = self.get_param_rects();
 627         let bottom = rects
 628             .iter()
 629             .enumerate()
 630             .filter(|(i, _)| !hidden[*i])
 631             .map(|(_, r)| r.1 + r.3)
 632             .fold(f32::NEG_INFINITY, f32::max);
 633         if bottom == f32::NEG_INFINITY {
 634             return 30.0 + 10.0; // no rows: the top offset plus the bottom padding
 635         }
 636         (bottom - self.rows_origin()) + ROW_GAP + 10.0
 637     }
 638 
 639     /// One rect per row, in index order — collapsed rows get a zero-height rect at the
 640     /// current cursor (and consume no vertical space), so every index-parallel consumer
 641     /// keeps working while the row draws and hit-tests as nothing.
 642     ///
 643     /// Rows advance on a uniform [`ROW_GAP`] pitch, except a section header, which is placed
 644     /// [`SECTION_GAP`] below the previous section's drawn bottom edge — see [`SECTION_GAP`].
 645     pub fn get_param_rects(&self) -> Vec<(f32, f32, f32, f32)> {
 646         let hidden = self.hidden_rows();
 647         let mut rects = Vec::new();
 648         let mut cur_y = self.rows_origin() + 30.0;
 649         // Bottom edge of the last row as DRAWN: a row inside a section is wrapped by the
 650         // content box, which overhangs it; a header with no visible rows under it (empty or
 651         // collapsed) ends at its own title box. `None` until the first section starts —
 652         // rows above it are bare, with no box to measure against.
 653         let mut prev_bottom: Option<f32> = None;
 654         for i in 0..self.display_params.len() {
 655             if hidden[i] {
 656                 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, 0.0));
 657                 continue;
 658             }
 659             let is_header = self.display_params[i].2 == "section";
 660             if is_header {
 661                 if let Some(bottom) = prev_bottom {
 662                     cur_y = bottom + SECTION_GAP + TITLE_BOX_INSET;
 663                 }
 664             }
 665             let h = self.row_height(i);
 666             rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, h));
 667             prev_bottom = Some(if is_header {
 668                 cur_y - TITLE_BOX_INSET + TITLE_BOX_H
 669             } else if prev_bottom.is_some() {
 670                 cur_y + h + CONTENT_BOX_PAD
 671             } else {
 672                 cur_y + h
 673             });
 674             cur_y += h + ROW_GAP;
 675         }
 676         rects
 677     }
 678 
 679     /// The pane's scrollbar width — the DE `scrollbar_width` widened: the bar
 680     /// rides over the section carves and reads too slim at the stock width.
 681     fn scrollbar_w(&self) -> f32 {
 682         crate::layout::scrollbar_width() * 1.6
 683     }
 684 
 685     /// The scrollbar's left x: the bar rides the pane's CENTRE line, as
 686     /// every sink-behind bar in the DE does (cce-mail's list and body, the
 687     /// designer's dialog list) — over the rows, reserving no lane, in front
 688     /// only while raised. It sat a sixth of the width in from the right
 689     /// edge before 2026-09-21, which beside a centred bar read as off.
 690     fn scrollbar_x(&self) -> f32 {
 691         self.rect.x + (self.rect.width - self.scrollbar_w()) * 0.5
 692     }
 693 
 694     pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
 695         if self.content_h <= self.rect.height {
 696             return false;
 697         }
 698         let sb_w = self.scrollbar_w();
 699         let sb_x = self.scrollbar_x();
 700         let sb_track_h = self.rect.height - 8.0;
 701         let sb_track_y = self.rect.y + 4.0;
 702 
 703         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
 704             && py >= sb_track_y && py <= sb_track_y + sb_track_h
 705     }
 706 
 707     /// Whether the pane holds enough content to need a scrollbar at all.
 708     pub fn scrollbar_visible(&self) -> bool {
 709         self.content_h > self.rect.height
 710     }
 711 
 712     /// Whether the scrollbar is currently raised in front of the pane plate (the latched
 713     /// state). While this is false the bar sits behind the plate and is non-interactive.
 714     pub fn scrollbar_active(&self) -> bool {
 715         self.activity.raised()
 716     }
 717 
 718     /// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
 719     fn recompute_scrollbar_raised(&mut self) -> bool {
 720         let visible = self.scrollbar_visible();
 721         self.activity.recompute(visible, self.scrollbar_dragging)
 722     }
 723 
 724     /// The scrollbar's track + thumb quads (empty when no scrollbar is needed). The host draws
 725     /// these either behind or in front of the pane plate per [`Self::scrollbar_active`]; they
 726     /// are deliberately kept out of [`Self::plain_quads`] so the host controls their depth.
 727     pub fn scrollbar_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
 728         if !self.scrollbar_visible() {
 729             return Vec::new();
 730         }
 731         let sb_w = self.scrollbar_w();
 732         let sb_x = self.scrollbar_x();
 733         let sb_track_h = self.rect.height - 8.0;
 734         let sb_track_y = self.rect.y + 4.0;
 735 
 736         let visible_ratio = self.rect.height / self.content_h;
 737         let thumb_h = if sb_track_h <= 20.0 {
 738             sb_track_h
 739         } else {
 740             (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
 741         };
 742         let max_scroll = self.content_h - self.rect.height;
 743         let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
 744         let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
 745 
 746         vec![
 747             (sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()),
 748             (sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()),
 749         ]
 750     }
 751 
 752     fn update_slider_rects(&mut self) {
 753         // Inline rows hand their control the row less the label column; the
 754         // row rects themselves (`get_param_rects`) stay the full row, which is
 755         // what the floors, the section boxes and hit routing measure against.
 756         let lw = self.label_col_w();
 757         let rects: Vec<(f32, f32, f32, f32)> = self
 758             .get_param_rects()
 759             .into_iter()
 760             .enumerate()
 761             .map(|(i, r)| self.control_rect(r, i, lw))
 762             .collect();
 763         let inline: Vec<bool> = (0..self.display_params.len()).map(|i| self.inline_row(i)).collect();
 764         for (i, s_opt) in self.sliders.iter_mut().enumerate() {
 765             if let Some(s) = s_opt {
 766                 let r = rects[i];
 767                 s.set_rect(r.0, r.1, r.2, r.3);
 768             }
 769         }
 770         for (i, f_opt) in self.float3s.iter_mut().enumerate() {
 771             if let Some(f) = f_opt {
 772                 let r = rects[i];
 773                 f.set_rect(r.0, r.1, r.2, r.3);
 774             }
 775         }
 776         for (i, sb_opt) in self.spinboxes.iter_mut().enumerate() {
 777             if let Some(sb) = sb_opt {
 778                 let r = rects[i];
 779                 sb.set_rect(r.0, r.1, r.2, r.3);
 780             }
 781         }
 782         for (i, b_opt) in self.buttons.iter_mut().enumerate() {
 783             if let Some(b) = b_opt {
 784                 let r = rects[i];
 785                 b.set_rect(r.0, r.1, r.2, r.3);
 786             }
 787         }
 788         for (i, d_opt) in self.choices.iter_mut().enumerate() {
 789             if let Some(d) = d_opt {
 790                 let r = rects[i];
 791                 if self.display_params[i].2.starts_with("textpick") {
 792                     // The picker button nests INSIDE the text box's recessed
 793                     // well (the box spans the full row): below the detached
 794                     // label band, its face reaching exactly to the base of
 795                     // the well's wall (inset = the carve depth) on the sides
 796                     // it adjoins — there the WELL'S OWN BEVEL is the seam's
 797                     // far side, and the trough ([`Self::troughs`]) carves
 798                     // only the interior left edge. A ring encapsulated
 799                     // within the bevel doubled the valley on the adjoining
 800                     // sides.
 801                     let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
 802                     let band_h = r.3 - label_top;
 803                     let inset = crate::layout::bevel_width().min(band_h * 0.2);
 804                     let by = r.1 + label_top + inset;
 805                     let bh = (band_h - 2.0 * inset).max(8.0);
 806                     d.set_rect(r.0 + r.2 - PICK_W - inset, by, PICK_W, bh);
 807                     // The menu hangs off the WHOLE field, not the button
 808                     // sliver: anchor the popover to the box's well band.
 809                     d.popover_anchor = Some(Rect {
 810                         x: r.0,
 811                         y: r.1 + label_top,
 812                         width: r.2,
 813                         height: r.3 - label_top,
 814                     });
 815                 } else {
 816                     d.set_rect(r.0, r.1, r.2, r.3);
 817                 }
 818             }
 819         }
 820         for (i, tb_opt) in self.texts.iter_mut().enumerate() {
 821             if let Some(tb) = tb_opt {
 822                 let r = rects[i];
 823                 tb.set_rect(r.0, r.1, r.2, r.3);
 824             }
 825         }
 826         for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
 827             if let Some(cb) = cb_opt {
 828                 let r = rects[i];
 829                 cb.set_rect(r.0, r.1, r.2, r.3);
 830             }
 831         }
 832         for (i, c_opt) in self.colors.iter_mut().enumerate() {
 833             if let Some(c) = c_opt {
 834                 let r = rects[i];
 835                 c.set_rect(r.0, r.1, r.2, r.3);
 836             }
 837         }
 838         for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
 839             if let Some(rp) = rp_opt {
 840                 let r = rects[i];
 841                 // Below the 18px label band own_text_labels draws (the ramp
 842                 // carries no label of its own).
 843                 rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
 844             }
 845         }
 846     }
 847 
 848     /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
 849     /// scroll into it, re-lay the rows.
 850     fn refresh_scroll_metrics(&mut self) {
 851         self.content_h = self.get_total_content_height();
 852         let max_scroll = (self.content_h - self.rect.height).max(0.0);
 853         self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
 854         self.update_slider_rects();
 855     }
 856 
 857     fn own_text_labels(&self) -> Vec<TextLabel> {
 858         let rects = self.get_param_rects();
 859         let hidden = self.hidden_rows();
 860         let mut labels = Vec::new();
 861         let lw = self.label_col_w();
 862         let (family, size) = Self::inline_label_font();
 863         for (i, (name, value, ptype)) in self.display_params.iter().enumerate() {
 864             if hidden[i] {
 865                 continue;
 866             }
 867             let r = rects[i];
 868             if self.inline_row(i) {
 869                 // The pane's own label, in the column beside an unlabelled
 870                 // control: vertically centred on the row, tail-truncated to
 871                 // the column.
 872                 let em = crate::widget::display::measure_text_width("M", &family, size).max(1.0);
 873                 let cols = ((lw - Self::LABEL_GAP) / em).floor().max(0.0) as usize;
 874                 labels.push(TextLabel {
 875                     text: crate::widget::display::truncate_tail(name, cols),
 876                     x: r.0,
 877                     y: r.1 + (r.3 - size) * 0.5,
 878                     font_size: size,
 879                     color: [0xaa, 0xaa, 0xbb],
 880                 });
 881             }
 882             if ptype.starts_with("slider") {
 883                 if let Some(s) = &self.sliders[i] {
 884                     labels.extend(s.own_text_labels());
 885                 }
 886             } else if ptype.starts_with("float3") {
 887                 if let Some(f) = &self.float3s[i] {
 888                     labels.extend(f.own_text_labels());
 889                 }
 890             } else if ptype == "section" {
 891                 // Centered within the tab itself — `section_title_box` is the one
 892                 // source for where the tab sits (flush on the body; collapsed and
 893                 // empty sections carry their own placements there).
 894                 let font_size = 13.0;
 895                 let (bx, by, _, _) = self.section_title_box(i, r);
 896                 labels.push(TextLabel {
 897                     text: name.clone(),
 898                     // 8px in from the title box's left edge — the inset
 899                     // `section_title_box`'s width math centers against.
 900                     x: bx + 8.0,
 901                     y: by + (TITLE_BOX_H - font_size) / 2.0,
 902                     font_size,
 903                     color: [0xee, 0xee, 0xf0],
 904                 });
 905             } else if ptype == "code" {
 906                 labels.push(TextLabel {
 907                     text: format!("{}:", name),
 908                     x: self.rect.x + 12.0,
 909                     y: r.1,
 910                     font_size: 12.0,
 911                     color: [0xaa, 0xaa, 0xbb],
 912                 });
 913                 let val_text = if self.focused_param == Some(i) {
 914                     if let Some(ref editor) = self.code_editor {
 915                         editor.buffer.clone()
 916                     } else {
 917                         value.clone()
 918                     }
 919                 } else {
 920                     value.clone()
 921                 };
 922                 // One label PER LINE, at the same pitch the cursor math uses
 923                 // (`plain_quads`' cursor_y) — a single multi-line label would depend on
 924                 // the consumer's buffer line-height matching that pitch, and never
 925                 // exactly did. A line number sits in the gutter before each.
 926                 let text_x = self.code_text_x(r);
 927                 for (line_i, line) in val_text.split('\n').enumerate() {
 928                     let y = r.1 + Self::CODE_TOP + line_i as f32 * Self::CODE_LINE_H;
 929                     let number = format!("{:>3}", line_i + 1);
 930                     labels.push(TextLabel {
 931                         text: number,
 932                         x: r.0 + 12.0,
 933                         y,
 934                         font_size: 12.0,
 935                         color: if self.code_error_line == Some(line_i) { [0xff, 0x80, 0x70] } else { [0x66, 0x66, 0x78] },
 936                     });
 937                     if line.is_empty() {
 938                         continue;
 939                     }
 940                     labels.push(TextLabel {
 941                         text: line.to_string(),
 942                         x: text_x,
 943                         y,
 944                         font_size: 12.0,
 945                         color: [0xee, 0xee, 0xf0],
 946                     });
 947                 }
 948                 if self.focused_param == Some(i) && self.code_is_dirty() {
 949                     labels.push(TextLabel {
 950                         text: "ctrl+enter applies".to_string(),
 951                         x: r.0 + r.2 - 12.0 - 18.0 * self.code_col_w(),
 952                         y: r.1 + r.3 - 15.0,
 953                         font_size: 12.0,
 954                         color: [0xd8, 0xa0, 0x50],
 955                     });
 956                 }
 957             } else if ptype.starts_with("spinbox") {
 958                 if let Some(sb) = &self.spinboxes[i] {
 959                     labels.extend(sb.own_text_labels());
 960                 }
 961             } else if is_text_row(ptype) {
 962                 if let Some(tb) = &self.texts[i] {
 963                     labels.extend(tb.own_text_labels());
 964                 }
 965                 if let Some(d) = &self.choices[i] {
 966                     labels.extend(d.own_text_labels());
 967                 }
 968             } else if ptype.starts_with("choice") {
 969                 if let Some(d) = &self.choices[i] {
 970                     labels.extend(d.own_text_labels());
 971                 }
 972             } else if ptype == "button" {
 973                 if let Some(b) = &self.buttons[i] {
 974                     labels.extend(b.own_text_labels());
 975                 }
 976             } else if ptype == "toggle" || ptype == "checkbox" {
 977                 if let Some(cb) = &self.toggles[i] {
 978                     labels.extend(cb.own_text_labels());
 979                 }
 980             } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
 981                 if let Some(c) = &self.colors[i] {
 982                     labels.extend(c.own_text_labels());
 983                 }
 984             } else if ptype == "ramp" {
 985                 // The name label only — the ramp's own control labels ride its
 986                 // scene-path paint (paint_scene_rows).
 987                 labels.push(TextLabel {
 988                     text: name.clone(),
 989                     x: r.0,
 990                     y: r.1,
 991                     font_size: 12.0,
 992                     color: [0xaa, 0xaa, 0xbb],
 993                 });
 994             } else {
 995                 labels.push(TextLabel {
 996                     text: format!("{}: {}", name, value),
 997                     x: self.rect.x + ROW_X_INSET,
 998                     y: r.1,
 999                     font_size: 12.0,
1000                     color: [0xaa, 0xaa, 0xbb],
1001                 });
1002             }
1003         }
1004         labels
1005     }
1006 
1007     /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
1008     /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
1009     /// The ramp rows' field dropdowns count too.
1010     fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1011         for d_opt in &self.choices {
1012             if let Some(d) = d_opt {
1013                 if let Some(r) = d.popover_rect() {
1014                     return Some(r);
1015                 }
1016             }
1017         }
1018         for rp_opt in &self.ramps {
1019             if let Some(rp) = rp_opt {
1020                 let ramp = rp.inner();
1021                 if let Some(r) = ramp
1022                     .preset_dropdown
1023                     .popover_rect()
1024                     .or_else(|| ramp.line_type_dropdown.popover_rect())
1025                 {
1026                     return Some(r);
1027                 }
1028             }
1029         }
1030         None
1031     }
1032 
1033     /// The full legacy popover reach (choices, then children) — hit-testing extends to it.
1034     fn own_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1035         if let Some(r) = self.choices_popover_rect() {
1036             return Some(r);
1037         }
1038         None
1039     }
1040 
1041     /// The state half of the legacy `unfocus`: commit the focused row's in-flight value back
1042     /// into `display_params`, drop the code editor, and unfocus the raw children.
1043     fn commit_and_unfocus(&mut self) {
1044         if let Some(idx) = self.focused_param {
1045             if idx < self.display_params.len() {
1046                 let p = &mut self.display_params[idx];
1047                 if p.2.starts_with("spinbox") {
1048                     if let Some(sb) = &mut self.spinboxes[idx] {
1049                         sb.unfocus();
1050                         p.1 = sb.value.to_string();
1051                     }
1052                 } else if p.2.starts_with("slider") {
1053                     if let Some(s) = &mut self.sliders[idx] {
1054                         s.unfocus();
1055                         let (min, max) = parse_slider_range(&p.2);
1056                         let new_val = min + s.value * (max - min);
1057                         p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
1058                     }
1059                 } else if p.2.starts_with("float3") {
1060                     if let Some(f) = &mut self.float3s[idx] {
1061                         f.unfocus();
1062                         p.1 = f.value_string();
1063                     }
1064                 } else if is_text_row(&p.2) {
1065                     if let Some(tb) = &mut self.texts[idx] {
1066                         tb.unfocus();
1067                         p.1 = tb.text.clone();
1068                     }
1069                     if let Some(d) = &mut self.choices[idx] {
1070                         d.unfocus();
1071                     }
1072                 } else if p.2.starts_with("choice") {
1073                     if let Some(d) = &mut self.choices[idx] {
1074                         d.unfocus();
1075                         if let Some(val) = d.get_value_string() {
1076                             p.1 = val;
1077                         }
1078                     }
1079                 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1080                     if let Some(c) = &mut self.colors[idx] {
1081                         c.unfocus();
1082                         if let Some(val) = c.get_value_string() {
1083                             p.1 = val;
1084                         }
1085                     }
1086                 } else if p.2 == "code" {
1087                     if let Some(ref editor) = self.code_editor {
1088                         p.1 = editor.buffer.clone();
1089                     }
1090                     self.code_editor = None;
1091                     self.code_history.clear();
1092                 }
1093             }
1094         }
1095         self.focused_param = None;
1096     }
1097 
1098     /// The legacy `extra_quads` body: section border boxes, every row's chrome (slider/spinbox
1099     /// backgrounds read via `rect()`+`color()`, the code editor's box/border/cursor), the raw
1100     /// children via [`collect_child_quads`], all clipped to the viewport — plus the unclipped
1101     /// scrollbar. Served verbatim through [`Paint::legacy_plain_quads`].
1102     fn plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1103         if !self.visible {
1104             return Vec::new();
1105         }
1106         let mut quads = Vec::new();
1107         let rects = self.get_param_rects();
1108         let view_min = self.rect.y + 4.0;
1109         let view_max = self.rect.y + self.rect.height - 4.0;
1110 
1111         let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
1112             let (qx, qy, qw, qh, qc) = q;
1113             let y1 = qy.max(view_min);
1114             let y2 = (qy + qh).min(view_max);
1115             if y1 < y2 {
1116                 Some((qx, y1, qw, y2 - y1, qc))
1117             } else {
1118                 None
1119             }
1120         };
1121 
1122         let mut param_quads = Vec::new();
1123 
1124         // Each section is drawn as ONE continuous outline — around the title, down the
1125         // neck, around the content rows — whose straight runs are these quads; its corner
1126         // fillets ride `arcs()`, which the host draws under the same clip. Under
1127         // `control_relief` the outline is replaced by the inset carves served
1128         // through `reliefs` (title tab + content body recessed).
1129         if !crate::layout::control_relief() {
1130             for (title, content) in self.section_boxes() {
1131                 let (runs, _) = self.section_outline(title, content);
1132                 param_quads.extend(runs.into_iter().map(|(x, y, w, h)| (x, y, w, h, SECTION_BORDER_COLOR)));
1133             }
1134         }
1135 
1136         let hidden = self.hidden_rows();
1137         for (i, p) in self.display_params.iter().enumerate() {
1138             if hidden[i] {
1139                 continue;
1140             }
1141             let r = rects[i];
1142             if p.2.starts_with("slider") {
1143                 if let Some(s) = &self.sliders[i] {
1144                     let (sx, sy, sw, sh) = s.rect();
1145                     param_quads.push((sx, sy, sw, sh, s.color()));
1146                     param_quads.extend(s.extra_quads());
1147                 }
1148             } else if p.2 == "section" {
1149                 // Section header line is handled by the border box top border now
1150             } else if p.2.starts_with("float3") {
1151                 if let Some(f) = &self.float3s[i] {
1152                     param_quads.extend(f.extra_quads());
1153                 }
1154             } else if p.2 == "code" {
1155                 let (bx, by, bw, bh) = (r.0, r.1 + Self::CODE_BOX_TOP, r.2, r.3 - Self::CODE_BOX_TOP);
1156                 param_quads.push((bx, by, bw, bh, [0.08, 0.08, 0.10, 1.0]));
1157                 let focused = self.focused_param == Some(i);
1158                 // Amber while edits are pending, blue while focused and
1159                 // applied, grey at rest: the border says whether what the
1160                 // node runs is what the box shows.
1161                 let border_color = if focused && self.code_is_dirty() {
1162                     [0.85, 0.60, 0.25, 1.0]
1163                 } else if focused {
1164                     [0.25, 0.45, 0.85, 1.0]
1165                 } else {
1166                     [0.20, 0.20, 0.25, 1.0]
1167                 };
1168                 let text_x = self.code_text_x(r);
1169                 let col_w = self.code_col_w();
1170                 let line_y = |l: usize| by + (Self::CODE_TOP - Self::CODE_BOX_TOP) + l as f32 * Self::CODE_LINE_H;
1171                 // The gutter's edge.
1172                 param_quads.push((text_x - col_w * 0.5, by + 1.0, 1.0, bh - 2.0, [0.16, 0.16, 0.20, 1.0]));
1173                 // The error band, under the flagged line.
1174                 if let Some(err_line) = self.code_error_line {
1175                     let y = line_y(err_line);
1176                     if y >= by && y + Self::CODE_LINE_H <= by + bh {
1177                         param_quads.push((bx + 1.0, y, bw - 2.0, Self::CODE_LINE_H, [0.45, 0.12, 0.10, 1.0]));
1178                     }
1179                 }
1180                 if focused {
1181                     if let Some(ref editor) = self.code_editor {
1182                         // Selection: one band per line it covers.
1183                         if let Some((start, end)) = editor.selected_range() {
1184                             let (sl, sc) = get_cursor_line_col(&editor.buffer, start);
1185                             let (el, ec) = get_cursor_line_col(&editor.buffer, end);
1186                             let line_len = |l: usize| editor.buffer.split('\n').nth(l).map_or(0, |s| s.chars().count());
1187                             for l in sl..=el {
1188                                 let c0 = if l == sl { sc } else { 0 };
1189                                 let c1 = if l == el { ec } else { line_len(l) + 1 };
1190                                 let y = line_y(l);
1191                                 if y >= by && y + Self::CODE_LINE_H <= by + bh && c1 > c0 {
1192                                     param_quads.push((
1193                                         text_x + c0 as f32 * col_w,
1194                                         y,
1195                                         (c1 - c0) as f32 * col_w,
1196                                         Self::CODE_LINE_H,
1197                                         [0.22, 0.32, 0.55, 1.0],
1198                                     ));
1199                                 }
1200                             }
1201                         }
1202                         let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
1203                         let cursor_x = text_x + (cursor_c as f32 * col_w);
1204                         let cursor_y = line_y(cursor_l) + (Self::CODE_LINE_H - 13.0) / 2.0;
1205                         if cursor_y >= by && cursor_y + 13.0 <= by + bh {
1206                             param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
1207                         }
1208                     }
1209                 }
1210                 param_quads.push((bx, by, bw, 1.0, border_color));
1211                 param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
1212                 param_quads.push((bx, by, 1.0, bh, border_color));
1213                 param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
1214             } else if is_text_row(&p.2) {
1215                 if let Some(tb) = &self.texts[i] {
1216                     param_quads.extend(tb.extra_quads());
1217                 }
1218                 if let Some(d) = &self.choices[i] {
1219                     param_quads.extend(d.extra_quads());
1220                 }
1221             } else if p.2.starts_with("choice") {
1222                 if let Some(d) = &self.choices[i] {
1223                     param_quads.extend(d.extra_quads());
1224                 }
1225             } else if p.2 == "button" {
1226                 if let Some(b) = &self.buttons[i] {
1227                     param_quads.extend(b.extra_quads());
1228                 }
1229             } else if p.2.starts_with("spinbox") {
1230                 if let Some(sb) = &self.spinboxes[i] {
1231                     { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
1232                     param_quads.extend(sb.extra_quads());
1233                 }
1234             } else if p.2 == "toggle" || p.2 == "checkbox" {
1235                 if let Some(cb) = &self.toggles[i] {
1236                     param_quads.extend(cb.extra_quads());
1237                 }
1238             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1239                 if let Some(c) = &self.colors[i] {
1240                     param_quads.extend(c.extra_quads());
1241                 }
1242             }
1243         }
1244 
1245         // Clip all parameter quads vertically
1246         for q in param_quads {
1247             if let Some(clipped) = clip_quad(q) {
1248                 quads.push(clipped);
1249             }
1250         }
1251 
1252         // The scrollbar is NOT emitted here: the host draws it via `scrollbar_quads`, above
1253         // or below the pane plate depending on `scrollbar_active`.
1254 
1255         quads
1256     }
1257 
1258     /// The rounded companion to [`Self::plain_quads`]: the row controls whose boxes are
1259     /// `Prim::RoundedRect` (textbox, dropdown, button, toggle, color selector). Those
1260     /// backgrounds never reach the plain view — `own_plain_quads` keeps `Prim::Quad` only —
1261     /// so a host that renders this panel through the legacy plain-quad hatch must read this
1262     /// getter too or the controls draw as bare text. Returned unclipped; the host clips to
1263     /// the pane's scroll viewport when it pushes vertices. Tuple layout matches
1264     /// `all_rounded_quads`: (x, y, w, h, radius, color, (tl, tr, br, bl)).
1265     pub fn rounded_quads(
1266         &self,
1267         ctx: &UiContext,
1268     ) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
1269         if !self.visible {
1270             return Vec::new();
1271         }
1272         let mut out = Vec::new();
1273         let hidden = self.hidden_rows();
1274         for (i, p) in self.display_params.iter().enumerate() {
1275             if hidden[i] {
1276                 continue;
1277             }
1278             if is_text_row(&p.2) {
1279                 if let Some(tb) = &self.texts[i] {
1280                     out.extend(tb.all_rounded_quads(ctx));
1281                 }
1282                 if let Some(d) = &self.choices[i] {
1283                     out.extend(d.all_rounded_quads(ctx));
1284                 }
1285             } else if p.2.starts_with("slider") {
1286                 // Track (square style only — the recessed style has no track
1287                 // background), value fill, and readout box; the thumb knob is a
1288                 // `Prim::Sphere` and rides `spheres()` instead.
1289                 if let Some(s) = &self.sliders[i] {
1290                     out.extend(s.all_rounded_quads(ctx));
1291                 }
1292             } else if p.2.starts_with("float3") {
1293                 // Three slider rows: the same set per row (readout boxes,
1294                 // square-style tracks and fills), through the group's own paint.
1295                 if let Some(f) = &self.float3s[i] {
1296                     out.extend(f.all_rounded_quads(ctx));
1297                 }
1298             } else if p.2.starts_with("choice") {
1299                 if let Some(d) = &self.choices[i] {
1300                     out.extend(d.all_rounded_quads(ctx));
1301                 }
1302             } else if p.2.starts_with("spinbox") {
1303                 // The spinbox's whole chrome (frame, display well, +/- button
1304                 // wells) is modern rounded-rect paint — its legacy color() is
1305                 // transparent and it has no extra_quads, so skipping it here
1306                 // renders the row as bare text.
1307                 if let Some(sb) = &self.spinboxes[i] {
1308                     out.extend(sb.all_rounded_quads(ctx));
1309                 }
1310             } else if p.2 == "button" {
1311                 if let Some(b) = &self.buttons[i] {
1312                     out.extend(b.all_rounded_quads(ctx));
1313                 }
1314             } else if p.2 == "toggle" || p.2 == "checkbox" {
1315                 if let Some(cb) = &self.toggles[i] {
1316                     out.extend(cb.all_rounded_quads(ctx));
1317                 }
1318             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1319                 if let Some(c) = &self.colors[i] {
1320                     out.extend(c.all_rounded_quads(ctx));
1321                 }
1322             }
1323         }
1324         out
1325     }
1326 
1327     /// The relief companion to [`Self::rounded_quads`]: the row controls'
1328     /// raised/recessed step prims (boss rims, recess wells), which the flat
1329     /// views cannot carry — a host rendering this panel through the legacy
1330     /// views must read this getter too or the `control_relief` styling is lost
1331     /// entirely (with the DE's transparent control backgrounds the controls all
1332     /// but vanish). Edges-only variants throughout: the flat views own the
1333     /// faces, exactly the widgets' own transparent-fill bevel→boss degradation.
1334     /// Returned unclipped; the host clips to the pane's scroll viewport and
1335     /// draws these AFTER the flat quads, so the walls' shading modulates the
1336     /// fills they cross (the order the widgets' own paints use). Tuple:
1337     /// (x, y, w, h, per-corner radii, depth, raised, walls) — raised maps to
1338     /// `PaintCtx::boss_edges`, flat to `recess_edges`; a toggle's well and
1339     /// the plate standing in it are why radii/walls are per-entry.
1340     #[allow(clippy::type_complexity)]
1341     pub fn reliefs(
1342         &self,
1343     ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, bool, (bool, bool, bool, bool))> {
1344         if !self.visible || !crate::layout::control_relief() {
1345             return Vec::new();
1346         }
1347         let mut out = Vec::new();
1348 
1349         // Sections as inset panels (the flat outline+fillet path is the
1350         // non-relief style): ONE union-shaped recess per section — a
1351         // title-text-width tab strip flush with the body's left edge, opening
1352         // into the full-width body below. Composed from three edge-suppressed
1353         // pieces (the tab with its bottom open, the body with its top open,
1354         // and the top-wall run right of the tab's throat) so no wall crosses
1355         // the union's interior and the whole section reads as a single well.
1356         // Collapsed sections keep the title-box carve.
1357         let all = (true, true, true, true);
1358         let r4 = |r: f32| (r, r, r, r);
1359         let r = SECTION_R;
1360         for (title, content) in self.section_boxes() {
1361             let (tx, ty, tw, th) = title;
1362             if let Some((cx, cy, cw, ch)) = content {
1363                 // Capped at the channel: the well's wall must roll off inside the
1364                 // groove between it and the controls packed one CHANNEL inside,
1365                 // not shade across their faces. `section_depth` scales wall and
1366                 // channel cap together — past 1.0 the roll crosses the groove
1367                 // by choice.
1368                 let depth = section_carve_depth(ch);
1369                 // Tab strip: the title box itself, sitting flush on the body's top
1370                 // edge (the header row above it stays plain plate), bottom open.
1371                 // A wall FADES OUT over the carve width approaching a suppressed
1372                 // edge (the tessellator's host fade — meant for carves flush with
1373                 // a real plate edge), so every piece extends past its interior
1374                 // seam by `depth`: its fade-out then crossfades with the
1375                 // neighbor's fade-in instead of both dying AT the seam (which
1376                 // notched the walls there; found the hard way).
1377                 let throat_r = tx + tw;
1378                 let rho = SECTION_FILLET_R;
1379                 // Right of the throat, ONE piece owns the whole right run —
1380                 // top wall, top-right arc, right wall, bottom-right arc — so
1381                 // both right corners are real turns. (The old top-run +
1382                 // full-body split put the top wall and the right wall in
1383                 // different pieces; each faded out at the seam and the
1384                 // top-right corner rendered square.) A left piece carries the
1385                 // left wall and the bottom-left arc; the two bottom runs
1386                 // crossfade under the seam at the right piece's left edge.
1387                 let body_lr = |x_run: f32, out: &mut Vec<_>| {
1388                     out.push((x_run, cy, cx + cw - x_run, ch, (0.0, r, r, 0.0), depth, false, (true, true, true, false)));
1389                     out.push((cx, cy, x_run + depth - cx, ch, (0.0, 0.0, 0.0, r), depth, false, (false, false, true, true)));
1390                 };
1391                 if cx + cw > throat_r + 2.0 * rho {
1392                     // Filleted throat ([`Self::section_fillets`]): the tab's
1393                     // right wall must END at the fillet's vertical tangent
1394                     // (crossfading out under the arc) or its straight run
1395                     // ghosts through the curve — so the tab piece stops there,
1396                     // and a left-only bridge carries the left wall across the
1397                     // fillet span down to the body's own fade-in.
1398                     out.push((tx, ty, tw, (cy - rho) - ty + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1399                     out.push((tx, cy - rho, tw, rho + depth, (0.0, 0.0, 0.0, 0.0), depth, false, (false, false, false, true)));
1400                     body_lr(throat_r + rho - depth, &mut out);
1401                 } else {
1402                     // Too narrow for the fillet: the plain square throat.
1403                     out.push((tx, ty, tw, th + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1404                     if cx + cw > throat_r + 0.5 {
1405                         body_lr(throat_r - depth, &mut out);
1406                     } else {
1407                         // The tab spans the body: no top wall at all.
1408                         out.push((cx, cy, cw, ch, (0.0, 0.0, r, r), depth, false, (false, true, true, true)));
1409                     }
1410                 }
1411             } else {
1412                 let depth = section_carve_depth(th);
1413                 out.push((tx, ty, tw, th, r4(SECTION_R), depth, false, all));
1414             }
1415         }
1416 
1417         let hidden = self.hidden_rows();
1418         for (i, p) in self.display_params.iter().enumerate() {
1419             if hidden[i] {
1420                 continue;
1421             }
1422             // (control, its configured corner radius, raised vs recessed)
1423             let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
1424                 // The textpick picker button is NOT in this list: it is a
1425                 // flush inset control (face level with the well floor, a
1426                 // valley seam around it — the dropdown trigger's trough
1427                 // language, not a boss), and troughs travel through
1428                 // [`Self::picker_troughs`]. A boss here read as a raised
1429                 // island, which no other inset control in the DE does.
1430                 self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
1431             } else if p.2.starts_with("choice") {
1432                 // The dropdown trigger is a FLUSH inset control: the widget's
1433                 // own raised paint is one `inset_plate` (face level with the
1434                 // plate, a valley seam around it), so its ring travels through
1435                 // [`Self::troughs`]. A boss here read as a raised island the
1436                 // widget itself never draws.
1437                 None
1438             } else if p.2 == "button" {
1439                 self.buttons[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::button_corner_radius(), true))
1440             } else if p.2 == "toggle" || p.2 == "checkbox" {
1441                 // A toggle paints no fill at all, so these carves — the
1442                 // widget's own (`Toggle::flat_carves`, exactly what its paint
1443                 // emits, in the order it emits them) — ARE the control: the
1444                 // track's well (a recess) and the plate gliding on its floor
1445                 // (a boss). Neither is flush, so nothing here rides
1446                 // [`Self::troughs`].
1447                 if let Some(t) = &self.toggles[i] {
1448                     let (x, y, w, h) = t.rect();
1449                     if w > 0.0 && h > 0.0 {
1450                         let ty = t.label_strip();
1451                         let rect = Rect { x, y: y + ty, width: w, height: h - ty };
1452                         for c in t.inner().flat_carves(rect) {
1453                             let raised = match c.kind {
1454                                 crate::layout::CarveKind::Boss { .. } => true,
1455                                 crate::layout::CarveKind::Recess { .. } => false,
1456                                 crate::layout::CarveKind::Trough => continue,
1457                             };
1458                             out.push((c.x, c.y, c.w, c.h, c.radii, c.depth, raised, c.edges));
1459                         }
1460                     }
1461                 }
1462                 None
1463             } else if p.2.starts_with("spinbox") {
1464                 // The well recess only — the -/+ run's trough and its seam
1465                 // travel through [`Self::troughs`] / [`Self::grooves`] (this
1466                 // tuple speaks boss/recess). The generic push below matches
1467                 // the widget's own `relief_parts` well exactly: same side-
1468                 // label inset, same content band, same depth cap.
1469                 self.spinboxes[i]
1470                     .as_ref()
1471                     .map(|w| (w as &dyn WidgetHost, crate::layout::spinbox_corner_radius(), false))
1472             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1473                 // The control's one well (`ColorSelector::field_relief`, the
1474                 // same geometry its paint carves); the swatch is a fill on its
1475                 // floor and the seam a groove, both on the widget's paint.
1476                 if let Some(c) = &self.colors[i] {
1477                     let (x, y, w, h) = c.rect();
1478                     let ty = c.label_strip();
1479                     if let Some((rx, ry, rw, rh, rr, rd)) =
1480                         c.inner().field_relief(Rect { x, y: y + ty, width: w, height: h - ty })
1481                     {
1482                         out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
1483                     }
1484                 }
1485                 None
1486             } else {
1487                 // Sliders (and Float3's three rows) are bands: their well is
1488                 // hand-shaded quads that follow the band's contour, which reach
1489                 // a flat host through the plain-quad view — no rect carve.
1490                 None
1491             };
1492             if let Some((w, radius, raised)) = ctl {
1493                 let (x, y, ww, h) = w.rect();
1494                 if ww <= 0.0 || h <= 0.0 {
1495                     continue;
1496                 }
1497                 // The top-label band stays outside the relief like every other host.
1498                 let ty = w.label_strip();
1499                 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1500                 out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
1501             }
1502         }
1503         out
1504     }
1505 
1506     /// The textpick picker buttons' trough rings — `(x, y, w, h, radii, depth)`
1507     /// for [`crate::scene::paint::PaintCtx::trough_edges`], drawn by the host
1508     /// AFTER [`Self::reliefs`]. These are the rows' FLUSH inset controls — the
1509     /// textpick picker button, the spinbox's -/+ run, the dropdown trigger
1510     /// (the widget's own `inset_plate`): faces
1511     /// level with the surface they sit in, so the control reads as part of
1512     /// the plate, marked off by its seam alone. On the sides a control adjoins its well, the WELL'S
1513     /// OWN WALL is the seam's far side (the face reaches the wall's base and
1514     /// that trough edge is suppressed — a lip of its own there doubles the
1515     /// valley); only edges facing open floor carve their own wall. They
1516     /// cannot ride in [`Self::reliefs`], whose tuple only speaks boss/recess.
1517     /// Radii are the well radius's parallel curve at each control's inset;
1518     /// depths match the well's carve, so seam and wall read as one family.
1519     #[allow(clippy::type_complexity)]
1520     pub fn troughs(
1521         &self,
1522     ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, (bool, bool, bool, bool))> {
1523         if !self.visible || !crate::layout::control_relief() {
1524             return Vec::new();
1525         }
1526         let mut out = Vec::new();
1527         let hidden = self.hidden_rows();
1528         for (i, p) in self.display_params.iter().enumerate() {
1529             if hidden[i] {
1530                 continue;
1531             }
1532             if p.2.starts_with("textpick") {
1533                 if let (Some(d), Some(tb)) = (&self.choices[i], &self.texts[i]) {
1534                     let (bx, by, bw, bh) = d.rect();
1535                     let (_, _, _, th) = tb.rect();
1536                     let ty = tb.label_strip();
1537                     if bw > 0.0 && bh > 0.0 {
1538                         let depth = crate::layout::bevel_width().min((th - ty) * 0.2);
1539                         let r = (crate::layout::textbox_corner_radius() - depth).max(2.0);
1540                         // Left edge only: top, right and bottom adjoin the well.
1541                         out.push((bx, by, bw, bh, (r, r, r, r), depth, (false, false, false, true)));
1542                     }
1543                 }
1544             } else if p.2.starts_with("spinbox") {
1545                 if let Some(sb) = &self.spinboxes[i] {
1546                     let (x, y, w, h) = sb.rect();
1547                     let ty = sb.label_strip();
1548                     let band = Rect { x, y: y + ty, width: w, height: h - ty };
1549                     if let Some((_, Some(((run, radii, rd, edges), _)))) =
1550                         sb.inner().relief_parts(band)
1551                     {
1552                         out.push((run.x, run.y, run.width, run.height, radii, rd, edges));
1553                     }
1554                 }
1555             } else if p.2.starts_with("choice") {
1556                 // The dropdown trigger: the widget's own raised paint is one
1557                 // `inset_plate` on its content band — the same ring here, on
1558                 // the same band (top-label band excluded),
1559                 // same radius, same depth cap. The face stays the plate: the
1560                 // pane carries no dropdown fill (a `Border` face never reaches
1561                 // the rounded-quad view), so the trigger is flush and bare.
1562                 if let Some(d) = &self.choices[i] {
1563                     let (x, y, w, h) = d.rect();
1564                     if w > 0.0 && h > 0.0 {
1565                         let ty = d.label_strip();
1566                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1567                         let r = crate::layout::dropdown_corner_radius();
1568                         out.push((x, y + ty, w, h - ty, (r, r, r, r), depth, (true, true, true, true)));
1569                     }
1570                 }
1571             }
1572             // A toggle contributes nothing here: its well and its glider plate
1573             // are a recess and a boss, and both ride [`Self::reliefs`].
1574         }
1575         out
1576     }
1577 
1578     /// The engraved seams companion — `(a, b, width, depth, host)` for
1579     /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::troughs`]
1580     /// (a groove engraves the surface the trough's control face provides).
1581     /// Today: the seam dividing a spinbox's -/+ run into its two buttons —
1582     /// the breadcrumb's segment-seam language at miniature scale.
1583     #[allow(clippy::type_complexity)]
1584     pub fn grooves(&self) -> Vec<((f32, f32), (f32, f32), f32, f32, Rect)> {
1585         if !self.visible || !crate::layout::control_relief() {
1586             return Vec::new();
1587         }
1588         let mut out = Vec::new();
1589         let hidden = self.hidden_rows();
1590         for (i, p) in self.display_params.iter().enumerate() {
1591             if hidden[i] || !p.2.starts_with("spinbox") {
1592                 continue;
1593             }
1594             if let Some(sb) = &self.spinboxes[i] {
1595                 let (x, y, w, h) = sb.rect();
1596                 let ty = sb.label_strip();
1597                 let band = Rect { x, y: y + ty, width: w, height: h - ty };
1598                 if let Some((_, Some(((_, _, rd, _), (sa, sb2, sw, host))))) =
1599                     sb.inner().relief_parts(band)
1600                 {
1601                     out.push((sa, sb2, sw, rd, host));
1602                 }
1603             }
1604         }
1605         out
1606     }
1607 
1608     /// The knobs a flat host draws after [`Self::reliefs`] — none: the sliders
1609     /// are bands (their swell is part of the band's own quads), so this is kept
1610     /// only for the hosts that still call it.
1611     pub fn spheres(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
1612         Vec::new()
1613     }
1614 
1615     /// The section carves' concave inside-corner fillets — `(cx, cy, radius,
1616     /// depth, start angle)` for [`crate::scene::paint::PaintCtx::concave_fillet`]
1617     /// (recessed), drawn by the host AFTER [`Self::reliefs`]. The box reliefs
1618     /// can only round convex corners; this rounds the throat where a tab's
1619     /// right wall turns onto its body's top edge.
1620     pub fn section_fillets(&self) -> Vec<(f32, f32, f32, f32, f32)> {
1621         if !self.visible || !crate::layout::control_relief() {
1622             return Vec::new();
1623         }
1624         let mut out = Vec::new();
1625         for (title, content) in self.section_boxes() {
1626             let (tx, _ty, tw, _th) = title;
1627             if let Some((cx, cy, cw, ch)) = content {
1628                 let depth = section_carve_depth(ch);
1629                 let throat_r = tx + tw;
1630                 if cx + cw > throat_r + 2.0 * SECTION_FILLET_R {
1631                     out.push((
1632                         throat_r + SECTION_FILLET_R,
1633                         cy - SECTION_FILLET_R,
1634                         SECTION_FILLET_R,
1635                         depth,
1636                         std::f32::consts::FRAC_PI_2,
1637                     ));
1638                 }
1639             }
1640         }
1641         out
1642     }
1643 
1644     /// The scene-path companion to the legacy views: rows whose widgets paint
1645     /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
1646     /// circles, and field controls). A host rendering this panel through the
1647     /// legacy hatches calls this with its own `PaintCtx` inside the pane's
1648     /// scroll clip, after the flat chrome — or the rows draw as bare labels.
1649     /// Whether a row value changed inside `tick` since the last call
1650     /// (see `tick_value_changed`); clears the flag.
1651     pub fn take_tick_value_change(&mut self) -> bool {
1652         std::mem::take(&mut self.tick_value_changed)
1653     }
1654 
1655     pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
1656         if !self.visible {
1657             return;
1658         }
1659         let hidden = self.hidden_rows();
1660         let dummy = UiContext::new();
1661         for (i, p) in self.display_params.iter().enumerate() {
1662             if hidden[i] || p.2 != "ramp" {
1663                 continue;
1664             }
1665             if let Some(rp) = &self.ramps[i] {
1666                 rp.paint_self(&dummy, pc);
1667             }
1668         }
1669     }
1670 
1671 }
1672 
1673 impl Layout for ParametersBg {
1674     /// Ungated rect landing (the legacy `set_rect` head, before its visibility gate): cache the
1675     /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
1676     fn rect_assigned(&mut self, rect: Rect) {
1677         self.rect = rect;
1678         self.refresh_scroll_metrics();
1679     }
1680 
1681 }
1682 
1683 impl Paint for ParametersBg {
1684     /// Shape the hosted controls (their carets read per-glyph advances nothing
1685     /// else records for children of a container) and one code column's advance
1686     /// from the same monospace@12 path the code rows draw through.
1687     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
1688         for tb in self.texts.iter_mut().flatten() {
1689             tb.prepare_text(fs);
1690         }
1691         for sb in self.spinboxes.iter_mut().flatten() {
1692             sb.prepare_text(fs);
1693         }
1694         for c in self.colors.iter_mut().flatten() {
1695             c.prepare_text(fs);
1696         }
1697         let clusters = crate::backend::window_runner::shaped_cluster_offsets(
1698             fs,
1699             "MMMMMMMM",
1700             12.0,
1701             Some("monospace"),
1702         );
1703         if let Some(&(_, total)) = clusters.last() {
1704             if total > 0.0 {
1705                 self.code_char_advance = total / 8.0;
1706             }
1707         }
1708     }
1709 
1710     /// The panel IS its own background plate (the host draws it from `color()` + the corner
1711     /// style via `push_widget_vertices`) — there is no separate plate widget behind it, so
1712     /// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
1713     /// with the alpha negated as the scenefx blur marker when plate blur is on. Transparent
1714     /// while hidden. It must not ALSO be emitted as a quad anywhere or it would double-blend.
1715     fn color(&self) -> [f32; 4] {
1716         if !self.visible {
1717             return [0.0, 0.0, 0.0, 0.0];
1718         }
1719         colors::param_plate_fill()
1720     }
1721 
1722     /// The shared plate corner radius (rounded on all four corners when non-zero).
1723     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1724         let r = crate::layout::plate_corner_radius();
1725         let on = r > 0.0;
1726         Some((r, (on, on, on, on)))
1727     }
1728 
1729     /// The shared plate border (folded in from the retired backing plate widget).
1730     fn solid_border(&self) -> Option<([f32; 4], f32)> {
1731         colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
1732     }
1733 
1734     fn widget_font(&self) -> Option<String> {
1735         Some(crate::layout::control_label_font())
1736     }
1737 
1738     /// The COMPLETE row chrome — everything the legacy hatches carry, in the hosts' canonical
1739     /// draw order: the controls' rounded wells, the flat-style section outline fillets, the
1740     /// relief steps (after the fills so the walls shade what they cross), the section carves'
1741     /// concave throat fillets, the slider-thumb spheres, the scene-path rows, then the flat
1742     /// plain-quad chrome. What stays OUT, deliberately: the background plate (see
1743     /// [`color`](Paint::color)), the scrollbar (hosts place its depth — the designer straddles
1744     /// it around the pane plate), and text (`paint_self`'s own-labels bridge carries the
1745     /// per-row fonts and code-box bounds). Hosts clip this to their pane viewport — a rect
1746     /// clip pushed here would not survive `paint_self`'s replay.
1747     fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
1748         if !self.visible {
1749             return;
1750         }
1751         self.paint_row_floors(ctx);
1752         for (qx, qy, qw, qh, qr, qc, corners) in self.rounded_quads(ui) {
1753             ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
1754         }
1755         for (acx, acy, ar, at, a0, a1, ac) in self.arcs() {
1756             ctx.arc(acx, acy, ar, at, a0, a1, ac);
1757         }
1758         for (rx, ry, rw, rh, radii, rd, raised, edges) in self.reliefs() {
1759             if raised {
1760                 ctx.boss_edges(Rect { x: rx, y: ry, width: rw, height: rh }, radii, rd, edges);
1761             } else {
1762                 ctx.recess_edges(Rect { x: rx, y: ry, width: rw, height: rh }, radii, rd, edges);
1763             }
1764         }
1765         for (tx2, ty2, tw2, th2, radii, td, tedges) in self.troughs() {
1766             ctx.trough_edges(Rect { x: tx2, y: ty2, width: tw2, height: th2 }, radii, td, tedges);
1767         }
1768         for (ga, gb, gw, gd, ghost) in self.grooves() {
1769             ctx.groove(ga, gb, gw, gd, ghost);
1770         }
1771         for (fcx, fcy, fr, fd, fs) in self.section_fillets() {
1772             ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
1773         }
1774         for (scx, scy, sr, sc) in self.spheres() {
1775             ctx.sphere(scx, scy, sr, &crate::scene::material::Material::from_fill(sc));
1776         }
1777         self.paint_scene_rows(ctx);
1778         for (qx, qy, qw, qh, qc) in self.plain_quads() {
1779             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1780         }
1781     }
1782 
1783     /// Ui-less emission (the [`paint_ui`](Paint::paint_ui) override above is what `paint_self`
1784     /// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
1785     /// plate stays out — see [`color`](Paint::color).
1786     fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
1787         for (qx, qy, qw, qh, qc) in self.plain_quads() {
1788             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1789         }
1790         self.paint_scene_rows(ctx);
1791         // Scene-path hosts get the scrollbar on top (the designer instead straddles it around
1792         // the pane plate through `scrollbar_quads`).
1793         for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
1794             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1795         }
1796         let view_min = self.rect.y + 4.0;
1797         let view_max = self.rect.y + self.rect.height - 4.0;
1798         // The y test above culls the scrolled-away rows; it is the pane's
1799         // WIDTH that nothing enforced, so a long parameter name ran out of the
1800         // pane sideways.
1801         let pane = Some([
1802             self.rect.x,
1803             self.rect.y,
1804             self.rect.x + self.rect.width,
1805             self.rect.y + self.rect.height,
1806         ]);
1807         for l in self.own_text_labels() {
1808             if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
1809                 ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, pane);
1810             }
1811         }
1812     }
1813 
1814     fn serves_legacy_plain_quads(&self) -> bool {
1815         true
1816     }
1817 
1818     fn legacy_plain_quads(&self, _rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1819         self.plain_quads()
1820     }
1821 
1822     fn serves_legacy_labels(&self) -> bool {
1823         true
1824     }
1825 
1826     /// The legacy `text_labels_with_font_and_bounds` body: every label clipped to the panel
1827     /// viewport in the control-label font, except labels inside a code row — those clip to the
1828     /// code box (or hide when it's scrolled out) and render monospace.
1829     fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
1830         let view_min = self.rect.y + 4.0;
1831         let view_max = self.rect.y + self.rect.height - 4.0;
1832         let mut result = Vec::new();
1833         let font = Paint::widget_font(self);
1834         let rects = self.get_param_rects();
1835         for l in self.own_text_labels() {
1836             if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
1837                 continue;
1838             }
1839             let mut bounds = Some([self.rect.x + 4.0, view_min, self.rect.x + self.rect.width - 4.0, view_max]);
1840             let mut label_font = font.clone();
1841             for (i, p) in self.display_params.iter().enumerate() {
1842                 if p.2 == "code" {
1843                     let r = rects[i];
1844                     if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
1845                         let code_min = (r.1 + 19.0).max(view_min);
1846                         let code_max = (r.1 + r.3 - 1.0).min(view_max);
1847                         if code_min < code_max {
1848                             bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
1849                         } else {
1850                             bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
1851                         }
1852                         label_font = Some("monospace".to_string());
1853                         break;
1854                     }
1855                 }
1856             }
1857             result.push((l, label_font, bounds));
1858         }
1859         result
1860     }
1861 
1862     fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
1863         self.choices_popover_rect()
1864     }
1865 
1866     /// The dropdown rows' popovers; the raw children's are the adapter's recursion.
1867     fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
1868         for d_opt in &self.choices {
1869             if let Some(d) = d_opt {
1870                 d.render_popover(pc);
1871             }
1872         }
1873         for rp_opt in &self.ramps {
1874             if let Some(rp) = rp_opt {
1875                 rp.inner().preset_dropdown.render_popover(pc);
1876                 rp.inner().line_type_dropdown.render_popover(pc);
1877             }
1878         }
1879     }
1880 }
1881 
1882 impl Input for ParametersBg {
1883     /// While a code row is being edited, the clipboard, selection and
1884     /// history actions are the editor's — the runner routes the undo / redo
1885     /// chords here before the app's own history gets them.
1886     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
1887         if self.code_editing() {
1888             return self.code_action(action);
1889         }
1890         false
1891     }
1892 
1893     fn scrollable(&self) -> bool {
1894         true
1895     }
1896 
1897     /// Legacy `mouse_input` saw every press (and consumes every left press — the designer
1898     /// relies on the panel swallowing clicks anywhere while it's the dispatch target).
1899     fn gates_presses(&self) -> bool {
1900         false
1901     }
1902 
1903     /// Legacy hit reach: the panel rect, or an open popover (a dropdown row's list extends
1904     /// below the panel).
1905     fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
1906         if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
1907             return true;
1908         }
1909         if let Some((pop_x, pop_y, pop_w, pop_h)) = self.own_popover_rect() {
1910             if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
1911                 return true;
1912             }
1913         }
1914         false
1915     }
1916 
1917 
1918     // --- The host-driven drag surface (the designer routes pointer drags here directly). ---
1919 
1920     fn draggable(&self, _rect: Rect) -> bool {
1921         self.scrollbar_dragging
1922             || self.dragging_param.is_some()
1923             || self.display_params.iter().any(|p| p.2.starts_with("slider") || p.2.starts_with("float3"))
1924     }
1925 
1926     fn is_dragging(&self) -> bool {
1927         self.scrollbar_dragging || self.dragging_param.is_some()
1928     }
1929 
1930     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
1931         if self.scrollbar_dragging {
1932             return;
1933         }
1934         let rects = self.get_param_rects();
1935         for (i, p) in self.display_params.iter().enumerate() {
1936             if p.2.starts_with("slider") {
1937                 let r = rects[i];
1938                 if let Some(s) = &mut self.sliders[i] {
1939                     let top = s.label_strip();
1940                     if py >= r.1 + top && py <= r.1 + r.3 {
1941                         s.drag_begin(px, py);
1942                         self.dragging_param = Some(i);
1943                         break;
1944                     }
1945                 }
1946             } else if p.2.starts_with("float3") {
1947                 let r = rects[i];
1948                 if py >= r.1 && py <= r.1 + r.3 {
1949                     if let Some(f) = &mut self.float3s[i] {
1950                         let mut dummy = crate::context::UiContext::new();
1951                         if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
1952                             self.dragging_param = Some(i);
1953                             break;
1954                         }
1955                     }
1956                 }
1957             }
1958         }
1959     }
1960 
1961     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
1962         if self.scrollbar_dragging {
1963             let sb_track_h = self.rect.height - 8.0;
1964             let sb_track_y = self.rect.y + 4.0;
1965             let visible_ratio = self.rect.height / self.content_h;
1966             let thumb_h = if sb_track_h <= 20.0 {
1967                 sb_track_h
1968             } else {
1969                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
1970             };
1971             let max_scroll = (self.content_h - self.rect.height).max(0.0);
1972 
1973             let target_thumb_y = py - self.drag_offset_y;
1974             let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
1975                 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
1976             } else {
1977                 0.0
1978             };
1979 
1980             let old_scroll = self.scroll_y;
1981             self.scroll_y = new_scroll_ratio * max_scroll;
1982             if (self.scroll_y - old_scroll).abs() > 0.01 {
1983                 self.update_slider_rects();
1984                 return true;
1985             }
1986             return false;
1987         }
1988 
1989         if let Some(i) = self.dragging_param {
1990             if let Some(s) = &mut self.sliders[i] {
1991                 if s.drag_update(px, py) {
1992                     let (min, max) = parse_slider_range(&self.display_params[i].2);
1993                     let new_val = min + s.value * (max - min);
1994                     let old_val = &self.display_params[i].1;
1995                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
1996                     if *old_val != new_val_str {
1997                         self.display_params[i].1 = new_val_str;
1998                         return true;
1999                     }
2000                 }
2001             } else if let Some(f) = &mut self.float3s[i] {
2002                 if f.drag_update(px, py) {
2003                     let new_val_str = f.value_string();
2004                     if self.display_params[i].1 != new_val_str {
2005                         self.display_params[i].1 = new_val_str;
2006                         return true;
2007                     }
2008                 }
2009             }
2010         }
2011         false
2012     }
2013 
2014     fn drag_end(&mut self) {
2015         if self.scrollbar_dragging {
2016             self.scrollbar_dragging = false;
2017             self.activity.bump();
2018             return;
2019         }
2020         if let Some(i) = self.dragging_param.take() {
2021             if let Some(s) = &mut self.sliders[i] {
2022                 s.drag_end();
2023             } else if let Some(f) = &mut self.float3s[i] {
2024                 f.drag_end();
2025             }
2026         }
2027     }
2028 
2029     /// Legacy `tick` forwarded to the raw children (the adapter's recursion now) and the
2030     /// checkbox rows. The checkbox tick never touches the ctx (a leaf `Adapted` tick is
2031     /// ctx-free), so the in-file dummy-ctx convention (`drag_begin`, `collect_child_quads`)
2032     /// applies.
2033     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2034         if !self.visible {
2035             return false;
2036         }
2037         let mut changed = false;
2038         let mut dummy = crate::context::UiContext::new();
2039         // Choice rows tick their Dropdowns' open/close animation. This was the
2040         // ONLY path that can advance a pane dropdown's anim_snap (the widget's
2041         // tick-receiver registration points at an id pane internals never put
2042         // in the host tree), and without it every params-pane dropdown opened
2043         // at zero drawn extent: logically open, invisible, reporting a sliver
2044         // popover rect — and the next click toggled it closed again.
2045         for c_opt in &mut self.choices {
2046             if let Some(d) = c_opt {
2047                 if d.tick(dt, &mut dummy) {
2048                     changed = true;
2049                 }
2050             }
2051         }
2052         for cb_opt in &mut self.toggles {
2053             if let Some(cb) = cb_opt {
2054                 if cb.tick(dt, &mut dummy) {
2055                     changed = true;
2056                 }
2057             }
2058         }
2059         // Slider rows tick their wheel-glide inertia — fold a coasting value
2060         // back into the row string so hosts syncing off display_params apply
2061         // it, exactly like a live wheel event would.
2062         for i in 0..self.sliders.len() {
2063             if let Some(s) = &mut self.sliders[i] {
2064                 if s.tick(dt, &mut dummy) {
2065                     let (min, max) = parse_slider_range(&self.display_params[i].2);
2066                     let new_val = min + s.value * (max - min);
2067                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2068                     if self.display_params[i].1 != new_val_str {
2069                         self.display_params[i].1 = new_val_str;
2070                     }
2071                     changed = true;
2072                 }
2073             }
2074         }
2075         // Float3 rows are three slider rows: same glide, same fold-back.
2076         for i in 0..self.float3s.len() {
2077             if let Some(f) = &mut self.float3s[i] {
2078                 if f.tick(dt, &mut dummy) {
2079                     let new_val_str = f.value_string();
2080                     if self.display_params[i].1 != new_val_str {
2081                         self.display_params[i].1 = new_val_str;
2082                     }
2083                     changed = true;
2084                 }
2085             }
2086         }
2087         // Ramp rows tick their field widgets (preset application, slider→key
2088         // sync) and drain their change flag — fold the curve back into the row
2089         // value when it moved.
2090         for i in 0..self.ramps.len() {
2091             if let Some(rp) = &mut self.ramps[i] {
2092                 if rp.tick(dt, &mut dummy) {
2093                     self.display_params[i].1 = rp.inner().spec_string();
2094                     changed = true;
2095                 }
2096             }
2097         }
2098         // Color rows tick their picker-stream poll (`cce-color-editor --stream`
2099         // lines applying live) — fold a changed value back into the row so
2100         // hosts syncing off display_params see it while the picker is open.
2101         for i in 0..self.colors.len() {
2102             if let Some(c) = &mut self.colors[i] {
2103                 if c.tick(dt, &mut dummy) {
2104                     if let Some(val) = c.get_value_string() {
2105                         if self.display_params[i].1 != val {
2106                             self.display_params[i].1 = val;
2107                             self.tick_value_changed = true;
2108                         }
2109                     }
2110                     changed = true;
2111                 }
2112             }
2113         }
2114         // The pane's own wheel glide / trackpad coast: adopt any host write to
2115         // `scroll_y`, advance, and re-seat the rows when the offset moved.
2116         self.scroll_motion.reconcile(0.0, self.scroll_y);
2117         let pane_max = (self.content_h - self.rect.height).max(0.0);
2118         if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
2119             self.scroll_y = self.scroll_motion.y.pos();
2120             self.update_slider_rects();
2121             changed = true;
2122         }
2123         if self.scroll_motion.is_animating() {
2124             changed = true;
2125         }
2126         // Decay the "recently scrolled" window; keep frames coming until it expires so the
2127         // scrollbar's sink behind the plate actually renders.
2128         if self.activity.holding() {
2129             changed = true;
2130         }
2131         // Re-latch the raised state (e.g. sink once the scroll window lapses).
2132         let visible = self.scrollbar_visible();
2133         if self.activity.tick(dt, visible, self.scrollbar_dragging) {
2134             changed = true;
2135         }
2136         changed
2137     }
2138 
2139     fn visibility_changed(&mut self, visible: bool) {
2140         self.visible = visible;
2141     }
2142 
2143     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2144         // Copied before `ectx.ui` is borrowed: the wheel arm's occlusion check keys on the
2145         // adapter's address (the pointer hosts register/popover-track).
2146         let self_id = ectx.id;
2147         match event {
2148             // Hosts call `unfocus()` directly (the designer's pane switches): commit the
2149             // focused row and unfocus the children. Needs no ctx, so the direct path's
2150             // ui-less synthesis works too.
2151             Event::FocusOut => {
2152                 self.commit_and_unfocus();
2153                 true
2154             }
2155             Event::PointerMove { x: px, y: py, .. } => {
2156                 let (px, py) = (*px, *py);
2157                 self.mouse_pos = Some((px, py));
2158                 // Track scrollbar hover, then re-latch: hover only sustains an already-raised
2159                 // bar (a sunk one is behind the plate, so the pointer never reaches it), so
2160                 // the only visible change here is the raised state — redraw on that transition.
2161                 let hover = self.hit_test_scrollbar(px, py);
2162                 self.activity.set_hover(hover);
2163                 let raised_changed = self.recompute_scrollbar_raised();
2164                 let Some(ui) = ectx.ui.as_deref_mut() else {
2165                     return raised_changed;
2166                 };
2167                 let mut changed = raised_changed;
2168 
2169                 if self.scrollbar_dragging {
2170                     let sb_track_h = self.rect.height - 8.0;
2171                     let sb_track_y = self.rect.y + 4.0;
2172                     let visible_ratio = self.rect.height / self.content_h;
2173                     let thumb_h = if sb_track_h <= 20.0 {
2174                         sb_track_h
2175                     } else {
2176                         (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2177                     };
2178                     let max_scroll = (self.content_h - self.rect.height).max(0.0);
2179 
2180                     let target_thumb_y = py - self.drag_offset_y;
2181                     let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2182                         ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2183                     } else {
2184                         0.0
2185                     };
2186 
2187                     let old_scroll = self.scroll_y;
2188                     self.scroll_y = new_scroll_ratio * max_scroll;
2189                     if (self.scroll_y - old_scroll).abs() > 0.01 {
2190                         self.update_slider_rects();
2191                         changed = true;
2192                     }
2193                 }
2194 
2195                 for sb_opt in &mut self.spinboxes {
2196                     if let Some(sb) = sb_opt {
2197                         if sb.on_cursor_moved(px, py, ui) {
2198                             changed = true;
2199                         }
2200                     }
2201                 }
2202                 for f_opt in &mut self.float3s {
2203                     if let Some(f) = f_opt {
2204                         if f.on_cursor_moved(px, py, ui) {
2205                             changed = true;
2206                         }
2207                     }
2208                 }
2209                 for b_opt in &mut self.buttons {
2210                     if let Some(b) = b_opt {
2211                         if b.on_cursor_moved(px, py, ui) {
2212                             changed = true;
2213                         }
2214                     }
2215                 }
2216                 for d_opt in &mut self.choices {
2217                     if let Some(d) = d_opt {
2218                         if d.on_cursor_moved(px, py, ui) {
2219                             changed = true;
2220                         }
2221                     }
2222                 }
2223                 for tb_opt in &mut self.texts {
2224                     if let Some(tb) = tb_opt {
2225                         if tb.on_cursor_moved(px, py, ui) {
2226                             changed = true;
2227                         }
2228                     }
2229                 }
2230                 for cb_opt in &mut self.toggles {
2231                     if let Some(cb) = cb_opt {
2232                         if cb.on_cursor_moved(px, py, ui) {
2233                             changed = true;
2234                         }
2235                     }
2236                 }
2237                 for c_opt in &mut self.colors {
2238                     if let Some(c) = c_opt {
2239                         if c.on_cursor_moved(px, py, ui) {
2240                             changed = true;
2241                         }
2242                     }
2243                 }
2244                 // Ramp rows: a move can drag a key — re-serialize the curve
2245                 // into the row value so hosts polling `node_params` see it.
2246                 for i in 0..self.ramps.len() {
2247                     if let Some(rp) = &mut self.ramps[i] {
2248                         if rp.on_cursor_moved(px, py, ui) {
2249                             self.display_params[i].1 = rp.inner().spec_string();
2250                             changed = true;
2251                         }
2252                     }
2253                 }
2254 
2255                 changed
2256             }
2257             Event::MouseButton { button, state, x: px, y: py, .. } => {
2258                 if !self.visible {
2259                     return false;
2260                 }
2261                 let (button, state, px, py) = (*button, *state, *px, *py);
2262                 let Some(ui) = ectx.ui.as_deref_mut() else {
2263                     return false;
2264                 };
2265 
2266                 if button == MouseButton::Left {
2267                     if state == ElementState::Pressed {
2268                         // Only a raised bar can be grabbed — a sunk one is behind the plate,
2269                         // so the press falls through to the pane content underneath it.
2270                         if self.activity.raised() && self.hit_test_scrollbar(px, py) {
2271                             // Legacy called `self.focus()` here — base flag only, which
2272                             // nothing reads (see module docs).
2273                             self.scrollbar_dragging = true;
2274 
2275                             let sb_track_h = self.rect.height - 8.0;
2276                             let sb_track_y = self.rect.y + 4.0;
2277                             let visible_ratio = self.rect.height / self.content_h;
2278                             let thumb_h = if sb_track_h <= 20.0 {
2279                                 sb_track_h
2280                             } else {
2281                                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2282                             };
2283                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
2284                             let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
2285                             let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
2286 
2287                             let click_offset = py - thumb_y;
2288                             if click_offset >= 0.0 && click_offset <= thumb_h {
2289                                 self.drag_offset_y = click_offset;
2290                             } else {
2291                                 // Clicked outside the thumb: jump thumb center to py
2292                                 self.drag_offset_y = thumb_h / 2.0;
2293                                 let target_thumb_y = py - self.drag_offset_y;
2294                                 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2295                                     ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2296                                 } else {
2297                                     0.0
2298                                 };
2299                                 self.scroll_y = new_scroll_ratio * max_scroll;
2300                                 self.update_slider_rects();
2301                             }
2302                             return true;
2303                         }
2304                     } else if state == ElementState::Released {
2305                         if self.scrollbar_dragging {
2306                             self.scrollbar_dragging = false;
2307                             self.activity.bump();
2308                             return true;
2309                         }
2310                     }
2311                 }
2312 
2313                 // A press on a section's title box collapses/expands it. Checked before the
2314                 // rows so a header can never be shadowed by a control under it, and only on
2315                 // the press — the matching release lands on whatever the relayout moved
2316                 // under the pointer, which must not toggle it straight back.
2317                 if button == MouseButton::Left && state == ElementState::Pressed {
2318                     let rects = self.get_param_rects();
2319                     let hit = self.display_params.iter().enumerate().position(|(i, p)| {
2320                         if p.2 != "section" {
2321                             return false;
2322                         }
2323                         let (bx, by, bw, bh) = self.section_title_box(i, rects[i]);
2324                         px >= bx && px <= bx + bw && py >= by && py <= by + bh
2325                     });
2326                     if let Some(i) = hit {
2327                         let title = self.display_params[i].0.clone();
2328                         let collapsed = self.collapsed.contains(&title);
2329                         // Collapsing out from under a focused row would strand the editor.
2330                         self.commit_and_unfocus();
2331                         self.set_section_collapsed(&title, !collapsed);
2332                         return true;
2333                     }
2334                 }
2335 
2336                 let hidden = self.hidden_rows();
2337 
2338                 // 1. Check open dropdown popovers first (since they are drawn on top)
2339                 for (i, d_opt) in self.choices.iter_mut().enumerate() {
2340                     if hidden[i] {
2341                         continue;
2342                     }
2343                     if let Some(d) = d_opt {
2344                         if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2345                             eprintln!("[pdbg] press ({px:.0},{py:.0}) choice[{i}] open-priority: popover_rect={:?}", d.popover_rect());
2346                         }
2347                         if let Some((ox, oy, ow, oh)) = d.popover_rect() {
2348                             // Textpick pickers are press-driven end to end
2349                             // (selection fires on the option PRESS): a release
2350                             // over the open surface is swallowed, never
2351                             // dispatched — mid-animation it can read as an
2352                             // outside press and close the menu it just opened.
2353                             if state != ElementState::Pressed
2354                                 && self.display_params[i].2.starts_with("textpick")
2355                             {
2356                                 if px >= ox && px <= ox + ow && py >= oy && py <= oy + oh {
2357                                     return true;
2358                                 }
2359                                 continue;
2360                             }
2361                             let consumed = d.mouse_input(button, state, px, py, ui);
2362                             if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2363                                 eprintln!("[pdbg]   -> open dropdown consumed={consumed}");
2364                             }
2365                             if consumed {
2366                                 if d.take_change() {
2367                                     if let Some(val) = d.get_value_string() {
2368                                         // A textpick row's pick fills its
2369                                         // TextBox — the box IS the value.
2370                                         if self.display_params[i].2.starts_with("textpick") {
2371                                             if let Some(tb) = &mut self.texts[i] {
2372                                                 tb.text = val.clone();
2373                                                 tb.edit_buffer = val.clone();
2374                                             }
2375                                         }
2376                                         self.display_params[i].1 = val;
2377                                     }
2378                                 }
2379                                 return true;
2380                             }
2381                         }
2382                     }
2383                 }
2384                 // The ramp rows' field dropdowns can pop over neighboring rows too.
2385                 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
2386                     if hidden[i] {
2387                         continue;
2388                     }
2389                     if let Some(rp) = rp_opt {
2390                         let ramp = rp.inner();
2391                         if ramp.preset_dropdown.popover_rect().is_some()
2392                             || ramp.line_type_dropdown.popover_rect().is_some()
2393                         {
2394                             if rp.mouse_input(button, state, px, py, ui) {
2395                                 self.display_params[i].1 = rp.inner().spec_string();
2396                                 return true;
2397                             }
2398                         }
2399                     }
2400                 }
2401 
2402                 // 2. Propagate to our widgets
2403                 for (i, p) in self.display_params.iter_mut().enumerate() {
2404                     if hidden[i] {
2405                         continue;
2406                     }
2407                     if p.2.starts_with("choice") {
2408                         if let Some(d) = &mut self.choices[i] {
2409                             if d.mouse_input(button, state, px, py, ui) {
2410                                 // Claim the param focus while the dropdown is
2411                                 // open — the KeyInput arm above is gated on
2412                                 // `focused_param`, and without this the choice
2413                                 // row was the ONE row type that never set it,
2414                                 // so Escape/arrows/Enter could not reach an
2415                                 // open params dropdown (found via cce-designer).
2416                                 if d.open {
2417                                     self.focused_param = Some(i);
2418                                 } else if self.focused_param == Some(i) {
2419                                     self.focused_param = None;
2420                                 }
2421                                 if d.take_change() {
2422                                     if let Some(val) = d.get_value_string() {
2423                                         p.1 = val;
2424                                     }
2425                                 }
2426                                 return true;
2427                             }
2428                         }
2429                     } else if p.2 == "button" {
2430                         if let Some(b) = &mut self.buttons[i] {
2431                             if b.mouse_input(button, state, px, py, ui) {
2432                                 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2433                                     eprintln!("[pdbg] press ({px:.0},{py:.0}) BUTTON[{i}] '{}' consumed", p.0);
2434                                 }
2435                                 if b.take_click() {
2436                                     p.1 = "clicked".to_string();
2437                                 }
2438                                 return true;
2439                             }
2440                         }
2441                     } else if is_text_row(&p.2) {
2442                         if let Some(d) = &mut self.choices[i] {
2443                             // The picker acts on PRESSES only; the release
2444                             // over the button is swallowed. Releases used to
2445                             // reach the dropdown, and one arriving before the
2446                             // open animation's first frame (a fast or
2447                             // injected click) read as an outside press and
2448                             // closed the menu it had just opened.
2449                             let (bx, by, bw, bh) = d.rect();
2450                             let on_button =
2451                                 px >= bx && px <= bx + bw && py >= by && py <= by + bh;
2452                             if state == ElementState::Pressed {
2453                                 if d.mouse_input(button, state, px, py, ui) {
2454                                     if d.take_change() {
2455                                         if let Some(val) = d.get_value_string() {
2456                                             if let Some(tb) = &mut self.texts[i] {
2457                                                 tb.text = val.clone();
2458                                                 tb.edit_buffer = val.clone();
2459                                             }
2460                                             p.1 = val;
2461                                         }
2462                                     }
2463                                     return true;
2464                                 }
2465                             } else if on_button {
2466                                 return true;
2467                             }
2468                         }
2469                         if let Some(tb) = &mut self.texts[i] {
2470                             if tb.mouse_input(button, state, px, py, ui) {
2471                                 if tb.editing {
2472                                     self.focused_param = Some(i);
2473                                 } else {
2474                                     if self.focused_param == Some(i) {
2475                                         self.focused_param = None;
2476                                     }
2477                                 }
2478                                 if tb.take_change() {
2479                                     if let Some(val) = tb.get_value_string() {
2480                                         p.1 = val;
2481                                     }
2482                                 }
2483                                 return true;
2484                             }
2485                         }
2486                     } else if p.2.starts_with("spinbox") {
2487                         if let Some(sb) = &mut self.spinboxes[i] {
2488                             if sb.mouse_input(button, state, px, py, ui) {
2489                                 p.1 = sb.value.to_string();
2490                                 if sb.editing {
2491                                     self.focused_param = Some(i);
2492                                 } else {
2493                                     if self.focused_param == Some(i) {
2494                                         self.focused_param = None;
2495                                     }
2496                                 }
2497                                 return true;
2498                             }
2499                         }
2500                     } else if p.2 == "toggle" || p.2 == "checkbox" {
2501                         if let Some(cb) = &mut self.toggles[i] {
2502                             if cb.mouse_input(button, state, px, py, ui) {
2503                                 if cb.take_change() {
2504                                     if let Some(val) = cb.get_value_string() {
2505                                         p.1 = val;
2506                                     }
2507                                 }
2508                                 return true;
2509                             }
2510                         }
2511                     } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2512                         if let Some(c) = &mut self.colors[i] {
2513                             if c.mouse_input(button, state, px, py, ui) {
2514                                 if let Some(val) = c.get_value_string() {
2515                                     p.1 = val;
2516                                 }
2517                                 if c.editing {
2518                                     self.focused_param = Some(i);
2519                                 } else {
2520                                     if self.focused_param == Some(i) {
2521                                         self.focused_param = None;
2522                                     }
2523                                 }
2524                                 return true;
2525                             }
2526                         }
2527                     } else if p.2 == "ramp" {
2528                         if let Some(rp) = &mut self.ramps[i] {
2529                             if rp.mouse_input(button, state, px, py, ui) {
2530                                 p.1 = rp.inner().spec_string();
2531                                 return true;
2532                             }
2533                         }
2534                     }
2535                 }
2536 
2537                 if button == MouseButton::Left && state == ElementState::Pressed {
2538                     let rects = self.get_param_rects();
2539                     let mut clicked_any_focusable = false;
2540                     for (i, p) in self.display_params.iter_mut().enumerate() {
2541                         if hidden[i] {
2542                             continue;
2543                         }
2544                         if p.2 == "code" {
2545                             let r = rects[i];
2546                             if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + Self::CODE_BOX_TOP && py <= r.1 + r.3 {
2547                                 // Clicking inside the box already being edited moves the
2548                                 // caret and keeps the buffer (and its history); a click
2549                                 // into a different row's box starts a fresh editor.
2550                                 let mut editor = match (self.focused_param == Some(i), self.code_editor.take()) {
2551                                     (true, Some(e)) => e,
2552                                     (_, other) => {
2553                                         drop(other);
2554                                         self.code_history.clear();
2555                                         TextEditorState::new(p.1.clone())
2556                                     }
2557                                 };
2558                                 self.focused_param = Some(i);
2559                                 let click_x = px - self.code_text_x(r);
2560                                 let click_y = py - (r.1 + Self::CODE_TOP);
2561                                 let line = (click_y / Self::CODE_LINE_H).floor().max(0.0) as usize;
2562                                 let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
2563                                 let at = map_2d_to_1d(&editor.buffer, line, col);
2564                                 if ui.shift_pressed {
2565                                     if editor.select_anchor.is_none() {
2566                                         editor.select_anchor = Some(editor.cursor_idx);
2567                                     }
2568                                 } else {
2569                                     editor.clear_selection();
2570                                 }
2571                                 editor.cursor_idx = at;
2572                                 self.code_editor = Some(editor);
2573                                 clicked_any_focusable = true;
2574                                 break;
2575                             }
2576                         } else if p.2.starts_with("slider") {
2577                             let r = rects[i];
2578                             if py >= r.1 && py <= r.1 + r.3 {
2579                                 if let Some(s) = &mut self.sliders[i] {
2580                                     if s.mouse_input(button, state, px, py, ui) {
2581                                         if s.editing {
2582                                             self.focused_param = Some(i);
2583                                             clicked_any_focusable = true;
2584                                         }
2585                                         break;
2586                                     }
2587                                 }
2588                             }
2589                         } else if p.2.starts_with("float3") {
2590                             let r = rects[i];
2591                             if py >= r.1 && py <= r.1 + r.3 {
2592                                 if let Some(f) = &mut self.float3s[i] {
2593                                     if f.mouse_input(button, state, px, py, ui) {
2594                                         if f.editing_idx().is_some() {
2595                                             self.focused_param = Some(i);
2596                                             clicked_any_focusable = true;
2597                                         }
2598                                         break;
2599                                     }
2600                                 }
2601                             }
2602                         }
2603                     }
2604                     if !clicked_any_focusable {
2605                         self.commit_and_unfocus();
2606                     }
2607                     return true;
2608                 }
2609                 false
2610             }
2611             Event::KeyInput(event) => {
2612                 if !self.visible {
2613                     return false;
2614                 }
2615                 let Some(ui) = ectx.ui.as_deref_mut() else {
2616                     return false;
2617                 };
2618                 if let Some(idx) = self.focused_param {
2619                     if event.state == ElementState::Pressed {
2620                         let p = &mut self.display_params[idx];
2621                         if p.2 == "code" {
2622                             if let Some(mut editor) = self.code_editor.take() {
2623                                 // Edits go to the BUFFER, not the value: a script
2624                                 // re-evaluates the node on every value change, and
2625                                 // a half-typed line would fail on every keystroke.
2626                                 // ctrl+enter applies, and so does leaving the row
2627                                 // (Escape, a click elsewhere, `unfocus`).
2628                                 let before = editor.clone();
2629                                 let mut handled = true;
2630                                 let mut edited = false;
2631                                 let mut apply = false;
2632                                 let mut should_unfocus = false;
2633                                 let shift = event.shift;
2634                                 let mut move_vertical = |editor: &mut TextEditorState, delta: i32| {
2635                                     if shift && editor.select_anchor.is_none() {
2636                                         editor.select_anchor = Some(editor.cursor_idx);
2637                                     } else if !shift {
2638                                         editor.clear_selection();
2639                                     }
2640                                     let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
2641                                     let total = editor.buffer.split('\n').count() as i32;
2642                                     let target = (line as i32 + delta).clamp(0, total - 1) as usize;
2643                                     editor.cursor_idx = map_2d_to_1d(&editor.buffer, target, col);
2644                                 };
2645                                 match &event.logical_key {
2646                                     Key::Named(NamedKey::Backspace) => {
2647                                         edited = editor.delete_backwards();
2648                                     }
2649                                     Key::Named(NamedKey::Delete) => {
2650                                         edited = editor.delete_forwards();
2651                                     }
2652                                     Key::Named(NamedKey::Enter) if event.ctrl => {
2653                                         apply = true;
2654                                     }
2655                                     Key::Named(NamedKey::Enter) => {
2656                                         // Auto-indent: the line's own leading whitespace,
2657                                         // one level deeper after an opening brace.
2658                                         let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
2659                                         let line: String = editor.buffer.chars().skip(line_start).take(editor.cursor_idx - line_start).collect();
2660                                         let mut indent: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
2661                                         if line.trim_end().ends_with('{') || line.trim_end().ends_with('(') || line.trim_end().ends_with('[') {
2662                                             indent.push_str(Self::CODE_INDENT);
2663                                         }
2664                                         editor.insert_text(&format!("\n{indent}"));
2665                                         edited = true;
2666                                     }
2667                                     Key::Named(NamedKey::Tab) if event.shift => {
2668                                         // Dedent the current line by one level, or what it has.
2669                                         let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
2670                                         let leading = editor.buffer.chars().skip(line_start).take_while(|c| *c == ' ').count().min(Self::CODE_INDENT.len());
2671                                         if leading > 0 {
2672                                             let chars: Vec<char> = editor.buffer.chars().collect();
2673                                             editor.buffer = chars[..line_start].iter().chain(&chars[line_start + leading..]).collect();
2674                                             editor.cursor_idx = editor.cursor_idx.saturating_sub(leading).max(line_start);
2675                                             editor.clear_selection();
2676                                             edited = true;
2677                                         }
2678                                     }
2679                                     Key::Named(NamedKey::Tab) => {
2680                                         editor.insert_text(Self::CODE_INDENT);
2681                                         edited = true;
2682                                     }
2683                                     Key::Named(NamedKey::Escape) => {
2684                                         apply = true;
2685                                         should_unfocus = true;
2686                                     }
2687                                     Key::Named(NamedKey::ArrowLeft) => {
2688                                         editor.move_cursor_left(shift);
2689                                     }
2690                                     Key::Named(NamedKey::ArrowRight) => {
2691                                         editor.move_cursor_right(shift);
2692                                     }
2693                                     Key::Named(NamedKey::ArrowUp) => move_vertical(&mut editor, -1),
2694                                     Key::Named(NamedKey::ArrowDown) => move_vertical(&mut editor, 1),
2695                                     Key::Named(NamedKey::Home) => {
2696                                         if shift && editor.select_anchor.is_none() {
2697                                             editor.select_anchor = Some(editor.cursor_idx);
2698                                         } else if !shift {
2699                                             editor.clear_selection();
2700                                         }
2701                                         editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
2702                                     }
2703                                     Key::Named(NamedKey::End) => {
2704                                         if shift && editor.select_anchor.is_none() {
2705                                             editor.select_anchor = Some(editor.cursor_idx);
2706                                         } else if !shift {
2707                                             editor.clear_selection();
2708                                         }
2709                                         editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
2710                                     }
2711                                     Key::Character(s) => {
2712                                         if event.ctrl {
2713                                             match s.to_lowercase().as_str() {
2714                                                 "f" => {
2715                                                     editor.move_cursor_right(false);
2716                                                 }
2717                                                 "b" => {
2718                                                     editor.move_cursor_left(false);
2719                                                 }
2720                                                 "p" => move_vertical(&mut editor, -1),
2721                                                 "n" => move_vertical(&mut editor, 1),
2722                                                 "a" if event.shift => {
2723                                                     editor.select_all();
2724                                                 }
2725                                                 "a" => {
2726                                                     editor.clear_selection();
2727                                                     editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
2728                                                 }
2729                                                 "e" => {
2730                                                     editor.clear_selection();
2731                                                     editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
2732                                                 }
2733                                                 "d" => {
2734                                                     edited = editor.delete_forwards();
2735                                                 }
2736                                                 "h" => {
2737                                                     edited = editor.delete_backwards();
2738                                                 }
2739                                                 "k" => {
2740                                                     let current_idx = editor.cursor_idx;
2741                                                     let end_idx = get_line_end(&editor.buffer, current_idx);
2742                                                     let chars: Vec<char> = editor.buffer.chars().collect();
2743                                                     if current_idx < chars.len() {
2744                                                         let delete_end = if chars[current_idx] == '\n' { current_idx + 1 } else { end_idx };
2745                                                         editor.buffer = chars[..current_idx].iter().chain(&chars[delete_end..]).collect();
2746                                                         editor.clear_selection();
2747                                                         edited = true;
2748                                                     }
2749                                                 }
2750                                                 // The clipboard and history chords are the
2751                                                 // context actions, so the chord, the runner's
2752                                                 // routing and the menu row do one thing.
2753                                                 "c" | "x" | "v" | "z" => {
2754                                                     let action = match (s.to_lowercase().as_str(), event.shift) {
2755                                                         ("c", _) => crate::widget::ContextAction::Copy,
2756                                                         ("x", _) => crate::widget::ContextAction::Cut,
2757                                                         ("v", _) => crate::widget::ContextAction::Paste,
2758                                                         ("z", true) => crate::widget::ContextAction::Redo,
2759                                                         _ => crate::widget::ContextAction::Undo,
2760                                                     };
2761                                                     apply_code_action(&mut editor, &mut self.code_history, action);
2762                                                 }
2763                                                 _ => {
2764                                                     handled = false;
2765                                                 }
2766                                             }
2767                                         } else {
2768                                             editor.insert_text(s);
2769                                             edited = true;
2770                                         }
2771                                     }
2772                                     _ => {
2773                                         handled = false;
2774                                     }
2775                                 }
2776                                 if edited {
2777                                     // Typing runs coalesce into one undo step; anything
2778                                     // structural (a newline, a paste, a deletion of a
2779                                     // selection) starts a new one.
2780                                     let plain_char = matches!(&event.logical_key, Key::Character(c) if !event.ctrl && c.chars().count() == 1);
2781                                     if plain_char {
2782                                         self.code_history.record_grouped(before, 1);
2783                                     } else {
2784                                         self.code_history.record(before);
2785                                     }
2786                                 }
2787                                 if apply {
2788                                     p.1 = editor.buffer.clone();
2789                                 }
2790                                 if should_unfocus {
2791                                     self.focused_param = None;
2792                                     self.code_editor = None;
2793                                     self.code_history.clear();
2794                                 } else {
2795                                     self.code_editor = Some(editor);
2796                                 }
2797                                 if handled {
2798                                     return true;
2799                                 }
2800                             }
2801                         } else if is_text_row(&p.2) {
2802                             if let Some(d) = &mut self.choices[idx] {
2803                                 if d.open && d.keyboard_input(event, ui) {
2804                                     if d.take_change() {
2805                                         if let Some(val) = d.get_value_string() {
2806                                             if let Some(tb) = &mut self.texts[idx] {
2807                                                 tb.text = val.clone();
2808                                                 tb.edit_buffer = val.clone();
2809                                             }
2810                                             p.1 = val;
2811                                         }
2812                                     }
2813                                     return true;
2814                                 }
2815                             }
2816                             if let Some(tb) = &mut self.texts[idx] {
2817                                 if tb.keyboard_input(event, ui) {
2818                                     if !tb.editing {
2819                                         p.1 = tb.text.clone();
2820                                         self.focused_param = None;
2821                                     } else {
2822                                         p.1 = tb.edit_buffer.clone();
2823                                     }
2824                                     return true;
2825                                 }
2826                             }
2827                         } else if p.2.starts_with("choice") {
2828                             if let Some(d) = &mut self.choices[idx] {
2829                                 if d.keyboard_input(event, ui) {
2830                                     if !d.open {
2831                                         if let Some(val) = d.get_value_string() {
2832                                             p.1 = val;
2833                                         }
2834                                         self.focused_param = None;
2835                                     }
2836                                     return true;
2837                                 }
2838                             }
2839                         } else if p.2.starts_with("spinbox") {
2840                             if let Some(sb) = &mut self.spinboxes[idx] {
2841                                 if sb.keyboard_input(event, ui) {
2842                                     if !sb.editing {
2843                                         p.1 = sb.value.to_string();
2844                                         self.focused_param = None;
2845                                     } else {
2846                                         p.1 = sb.edit_buffer.clone();
2847                                     }
2848                                     return true;
2849                                 }
2850                             }
2851                         } else if p.2.starts_with("slider") {
2852                             if let Some(s) = &mut self.sliders[idx] {
2853                                 if s.keyboard_input(event, ui) {
2854                                     let (min, max) = parse_slider_range(&p.2);
2855                                     let new_val = min + s.value * (max - min);
2856                                     p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
2857                                     if !s.editing {
2858                                         self.focused_param = None;
2859                                     }
2860                                     return true;
2861                                 }
2862                             }
2863                         } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2864                             if let Some(c) = &mut self.colors[idx] {
2865                                 if c.keyboard_input(event, ui) {
2866                                     if let Some(val) = c.get_value_string() {
2867                                         p.1 = val;
2868                                     }
2869                                     if !c.editing {
2870                                         self.focused_param = None;
2871                                     }
2872                                     return true;
2873                                 }
2874                             }
2875                         } else if p.2.starts_with("float3") {
2876                             if let Some(f) = &mut self.float3s[idx] {
2877                                 if f.keyboard_input(event, ui) {
2878                                     p.1 = f.value_string();
2879                                     if f.editing_idx().is_none() {
2880                                         self.focused_param = None;
2881                                     }
2882                                     return true;
2883                                 }
2884                             }
2885                         }
2886                     }
2887                 }
2888                 false
2889             }
2890             Event::MouseWheel { delta, x: px, y: py, .. } => {
2891                 if !self.visible {
2892                     return false;
2893                 }
2894                 let (px, py) = (*px, *py);
2895                 let Some(ui) = ectx.ui.as_deref_mut() else {
2896                     return false;
2897                 };
2898                 let mut changed = false;
2899                 // A value row that took the wheel (slider/float3/spinbox),
2900                 // whether or not its 2-decimal string ticked over. Gating the
2901                 // pane's viewport-scroll fallback on the STRING (`changed`)
2902                 // let every sub-tick trackpad event scroll the pane instead —
2903                 // a slider-vs-pane tug-of-war that shifted the rows under the
2904                 // pointer mid-adjust.
2905                 let mut wheel_taken = false;
2906                 // Gesture ownership. A gesture the PANE acquired — its first
2907                 // event fell on no control and scrolled the rows — stays the
2908                 // pane's until the gesture ends (`scroll_gesture_new`), however
2909                 // the rows travel under the pointer meanwhile. Without this a
2910                 // list scroll ran until a slider's halo or a spinbox row slid
2911                 // under the pointer, which then took every remaining event of
2912                 // the same gesture and adjusted a value the user never aimed
2913                 // at (the Alt+D settings tab, 2026-09-20). A gesture that
2914                 // began ON a control is that control's, as before, and one
2915                 // nobody claimed (dead space, a control at its limit) is still
2916                 // open to spatial acquisition — the band slider's feel.
2917                 let pane_owns = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self_id);
2918                 // A trackpad gesture is the PANE's, from anywhere — a
2919                 // two-finger swipe is a scroll everywhere on the desktop, and
2920                 // a pane that is mostly controls (the Alt+D Settings list)
2921                 // was otherwise scrollable only from a label. A wheel notch
2922                 // still adjusts the control under the pointer. Unconditional
2923                 // since 2026-09-21: the first cut kept hover-adjust for finger
2924                 // gestures in a pane whose content fits, which made the main
2925                 // params pane feel different from the Settings list depending
2926                 // on how many parameters the node had. Finger-end frames (no
2927                 // delta) take the same path so the pane's coast starts.
2928                 let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
2929                     && matches!(
2930                         crate::widget::scroll_motion::current_scroll_phase(),
2931                         crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
2932                     );
2933                 let pane_takes = pane_owns || finger;
2934                 let rects = self.get_param_rects();
2935                 for (i, p) in self.display_params.iter_mut().enumerate() {
2936                     if pane_takes {
2937                         break;
2938                     }
2939                     if p.2.starts_with("slider") {
2940                         // The capture zone is the slider's own shape halo
2941                         // (`Slider::scroll_hit` — the band plus the traveling
2942                         // swell, inset), so scrolls off the shape fall through
2943                         // to the pane's viewport scroll below.
2944                         let in_zone = self.sliders[i].as_ref().map_or(false, |s| {
2945                             // The same gesture latch the slider's own wheel
2946                             // test applies: mid-gesture the slider that
2947                             // acquired the scroll keeps it (its halo travels
2948                             // away from the pointer as the value moves).
2949                             let latched = !ui.scroll_gesture_new
2950                                 && ui.scroll_initiate_widget_id == Some(s.base().id());
2951                             let (sx, sy, sw, sh) = s.rect();
2952                             let ty = s.label_strip();
2953                             latched
2954                                 || s.inner().scroll_hit(
2955                                     Rect { x: sx, y: sy + ty, width: sw, height: sh - ty },
2956                                     px,
2957                                     py,
2958                                 )
2959                         });
2960                         if in_zone {
2961                             if let Some(s) = &mut self.sliders[i] {
2962                                 let was_scroll = s.scroll_enabled;
2963                                 s.set_scroll(true);
2964                                 // Ungated: the in_zone halo above already gated
2965                                 // spatially, and the adapter's rect gate would
2966                                 // clip the halo's fringe outside the row rect.
2967                                 if s.mouse_wheel_ungated(delta, px, py, ui) {
2968                                     wheel_taken = true;
2969                                     let (min, max) = parse_slider_range(&p.2);
2970                                     let new_val = min + s.value * (max - min);
2971                                     let old_val = &p.1;
2972                                     let new_val_str = format!("{:.*}", slider_decimals(&p.2), new_val);
2973                                     if *old_val != new_val_str {
2974                                         p.1 = new_val_str;
2975                                         changed = true;
2976                                     }
2977                                 }
2978                                 s.set_scroll(was_scroll);
2979                             }
2980                         }
2981                     } else if p.2.starts_with("float3") {
2982                         let r = rects[i];
2983                         if py >= r.1 - 2.0 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
2984                             // The group's rows apply the slider-row contract
2985                             // themselves (band halo / gesture latch, else the
2986                             // row strip; ungated forward).
2987                             if let Some(f) = &mut self.float3s[i] {
2988                                 if f.wheel(delta, px, py, ui) {
2989                                     wheel_taken = true;
2990                                     let new_val_str = f.value_string();
2991                                     if p.1 != new_val_str {
2992                                         p.1 = new_val_str;
2993                                         changed = true;
2994                                     }
2995                                 }
2996                             }
2997                         }
2998                     } else if p.2.starts_with("spinbox") {
2999                         let r = rects[i];
3000                         let row_y = r.1;
3001                         if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
3002                             if let Some(sb) = &mut self.spinboxes[i] {
3003                                 wheel_taken = true;
3004                                 let scroll_amount = match delta {
3005                                     MouseScrollDelta::LineDelta(_x, y) => *y as i32,
3006                                     MouseScrollDelta::PixelDelta(pos) => {
3007                                         let dy = pos.y;
3008                                         if dy > 0.0 { 1 } else if dy < 0.0 { -1 } else { 0 }
3009                                     }
3010                                 };
3011                                 let new_val = (sb.value + scroll_amount * sb.step).clamp(sb.min, sb.max);
3012                                 if sb.value != new_val {
3013                                     sb.value = new_val;
3014                                     p.1 = new_val.to_string();
3015                                     changed = true;
3016                                 }
3017                             }
3018                         }
3019                     }
3020                 }
3021 
3022                 // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
3023                 // address, then rect-or-popover containment.
3024                 let mut swallowed = changed || wheel_taken;
3025                 if !ui.is_coordinate_covered(self_id, px, py) {
3026                     let in_rect = px >= self.rect.x
3027                         && px <= self.rect.x + self.rect.width
3028                         && py >= self.rect.y
3029                         && py <= self.rect.y + self.rect.height;
3030                     let in_popover = self.own_popover_rect().map_or(false, |(rx, ry, rw, rh)| {
3031                         px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
3032                     });
3033                     if in_rect || in_popover {
3034                         if !changed && !wheel_taken {
3035                             if crate::scroll_debug() {
3036                                 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
3037                             }
3038                             // The pane takes the gesture (see `pane_owns`).
3039                             ui.scroll_initiate_widget_id = Some(self_id);
3040                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
3041                             self.scroll_motion.reconcile(0.0, self.scroll_y);
3042                             let moved = self.scroll_motion.apply(
3043                                 delta,
3044                                 (crate::widget::LINE_PX, crate::widget::LINE_PX),
3045                                 crate::widget::Bounds::max(0.0),
3046                                 crate::widget::Bounds::max(max_scroll),
3047                             );
3048                             self.scroll_y = self.scroll_motion.y.pos();
3049                             if moved {
3050                                 self.update_slider_rects();
3051                                 self.activity.bump();
3052                                 self.recompute_scrollbar_raised();
3053                             }
3054                         }
3055                         // An opaque pane swallows EVERY wheel over it, whether
3056                         // anything moved or not: returning false would hand the
3057                         // event to whatever lies BEHIND the plate — the designer
3058                         // routes unhandled wheels to the 3D viewport, whose rect
3059                         // is the whole window in the floating layout, so a
3060                         // near-miss on a slider would orbit the camera through
3061                         // the pane.
3062                         swallowed = true;
3063                     }
3064                 }
3065 
3066                 swallowed
3067             }
3068             _ => false,
3069         }
3070     }
3071 }
3072 
3073 /// Display precision for a slider row from the type string's optional 4th
3074 /// segment (`slider:min:max:decimals`); 2 when absent — the pane-wide
3075 /// historical default.
3076 fn slider_decimals(ptype: &str) -> usize {
3077     ptype.split(':').nth(3).and_then(|s| s.parse().ok()).unwrap_or(2)
3078 }
3079 
3080 fn parse_slider_range(ptype: &str) -> (f32, f32) {
3081     if ptype.starts_with("slider:") || ptype.starts_with("float3:") {
3082         let parts: Vec<&str> = ptype.split(':').collect();
3083         if parts.len() >= 3 {
3084             if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
3085                 return (min, max);
3086             }
3087         }
3088     }
3089     (0.0, 2.0)
3090 }
3091 
3092 fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
3093     crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
3094 }
3095 
3096 fn parse_hex_to_rgba(s: &str) -> Option<[u8; 4]> {
3097     crate::color::parse_hex_bytes(s)
3098 }
3099 
3100 fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
3101     if ptype.starts_with("spinbox:") {
3102         let parts: Vec<&str> = ptype.split(':').collect();
3103         if parts.len() >= 4 {
3104             if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
3105                 return (min, max, step);
3106             }
3107         } else if parts.len() == 3 {
3108             if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
3109                 return (min, max, 1);
3110             }
3111         }
3112     }
3113     (0, 10000, 1)
3114 }
3115 
3116 fn parse_float3_value(val_str: &str, min: f32, max: f32) -> [f32; 3] {
3117     let mut out = [0.5, 0.5, 0.5];
3118     let parts: Vec<&str> = val_str
3119         .split(|c| c == ':' || c == ',' || c == ' ')
3120         .filter(|s| !s.is_empty())
3121         .collect();
3122     for i in 0..3 {
3123         if i < parts.len() {
3124             if let Ok(v) = parts[i].parse::<f32>() {
3125                 let range = max - min;
3126                 if range != 0.0 {
3127                     out[i] = ((v - min) / range).clamp(0.0, 1.0);
3128                 } else {
3129                     out[i] = 0.0;
3130                 }
3131             }
3132         }
3133     }
3134     out
3135 }
3136 
3137 impl ParamController for ParametersBg {
3138     fn node_params(&self) -> Vec<(String, String, String)> {
3139         self.display_params.clone()
3140     }
3141 
3142     fn set_display_params(&mut self, params: &[(String, String, String)]) {
3143         let mut layout_changed = self.display_params.len() != params.len();
3144         if !layout_changed {
3145             for (p_old, p_new) in self.display_params.iter().zip(params.iter()) {
3146                 if p_old.0 != p_new.0 || p_old.2 != p_new.2 {
3147                     layout_changed = true;
3148                     break;
3149                 }
3150             }
3151         }
3152 
3153         if layout_changed {
3154             self.scroll_y = 0.0;
3155             self.display_params = params.to_vec();
3156             self.focused_param = None;
3157             // Re-read at every rebuild, so a reloaded style takes effect the
3158             // next time the rows are built, and geometry and widgets agree.
3159             self.inline_labels = crate::layout::param_labels_inline();
3160             let inline = self.inline_labels;
3161             self.sliders = self.display_params.iter().map(|p| {
3162                 if p.2.starts_with("slider") {
3163                     let val = p.1.parse::<f32>().unwrap_or(0.0);
3164                     let (min, max) = parse_slider_range(&p.2);
3165                     let t = if max - min != 0.0 {
3166                         ((val - min) / (max - min)).clamp(0.0, 1.0)
3167                     } else {
3168                         0.0
3169                     };
3170                     let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2));
3171                     Some(if inline { s } else { s.with_label(&p.0) })
3172                 } else {
3173                     None
3174                 }
3175             }).collect();
3176             self.float3s = self.display_params.iter().map(|p| {
3177                 if p.2.starts_with("float3") {
3178                     let (min, max) = parse_slider_range(&p.2);
3179                     let vals = parse_float3_value(&p.1, min, max);
3180                     let f = Float3::new().with_values(vals).with_range(min, max);
3181                     Some(if inline { f } else { f.with_label(&p.0) })
3182                 } else {
3183                     None
3184                 }
3185             }).collect();
3186             self.spinboxes = self.display_params.iter().map(|p| {
3187                 if p.2.starts_with("spinbox") {
3188                     let (min, max, step) = parse_spinbox_range(&p.2);
3189                     let val = p.1.parse::<i32>().unwrap_or(min);
3190                     let sb = Spinbox::new(val, min, max, step);
3191                     Some(if inline { sb } else { sb.with_label(&p.0) })
3192                 } else {
3193                     None
3194                 }
3195             }).collect();
3196             self.buttons = self.display_params.iter().map(|p| {
3197                 if p.2 == "button" {
3198                     // Left-aligned, like the toggles below: the rows form one column, and
3199                     // centered labels made each row's text start at a different x.
3200                     Some(Button::new(0.0, 0.0, 0.0, 0.0).with_label(&p.0).with_left_align(true))
3201                 } else {
3202                     None
3203                 }
3204             }).collect();
3205             self.choices = self.display_params.iter().map(|p| {
3206                 if p.2.starts_with("choice:") {
3207                     let options_str = p.2.strip_prefix("choice:").unwrap_or("");
3208                     let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3209                     let selected = options.iter().position(|o| o == &p.1).unwrap_or(0);
3210                     let d = Dropdown::new(options, selected);
3211                     Some(if inline { d } else { d.with_label(&p.0) })
3212                 } else if p.2.starts_with("textpick:") {
3213                     // The text row's completion picker: a menu-button Dropdown
3214                     // (fixed glyph, re-fires on repeat picks) beside the box.
3215                     let options: Vec<String> = p.2.strip_prefix("textpick:").unwrap_or("")
3216                         .split(',')
3217                         .filter(|s| !s.is_empty())
3218                         .map(|s| s.to_string())
3219                         .collect();
3220                     if options.is_empty() {
3221                         None
3222                     } else {
3223                         // A raised face: the picker reads as a BUTTON sitting
3224                         // in the box's recess, not a bare glyph beside it.
3225                         Some(
3226                             Dropdown::new(options, 0)
3227                                 .with_custom_display_text("\u{25be}")
3228                                 .with_raised(true),
3229                         )
3230                     }
3231                 } else {
3232                     None
3233                 }
3234             }).collect();
3235             self.texts = self.display_params.iter().map(|p| {
3236                 if is_text_row(&p.2) {
3237                     let tb = TextBox::new(p.1.clone());
3238                     Some(if inline { tb } else { tb.with_label(&p.0) })
3239                 } else {
3240                     None
3241                 }
3242             }).collect();
3243             self.toggles = self.display_params.iter().map(|p| {
3244                 if p.2 == "toggle" || p.2 == "checkbox" {
3245                     let on = p.1.trim().to_lowercase() == "true";
3246                     let mut t = Toggle::new().with_label(&p.0).with_left_align(true);
3247                     t.set_toggled(on);
3248                     Some(t)
3249                 } else {
3250                     None
3251                 }
3252             }).collect();
3253             self.colors = self.display_params.iter().map(|p| {
3254                 if p.2 == "rgba" {
3255                     // Alpha-carrying param: the full picker, 8-digit hex.
3256                     let col = parse_hex_to_rgba(&p.1).unwrap_or([255, 255, 255, 255]);
3257                     let c = ColorSelector::new_rgba(col);
3258                     Some(if inline { c } else { c.with_label(&p.0) })
3259                 } else if p.2.starts_with("color") || p.2 == "rgb" {
3260                     let col = parse_hex_to_rgb(&p.1).unwrap_or([255, 255, 255]);
3261                     let c = ColorSelector::new(col);
3262                     Some(if inline { c } else { c.with_label(&p.0) })
3263                 } else {
3264                     None
3265                 }
3266             }).collect();
3267             self.ramps = self.display_params.iter().map(|p| {
3268                 if p.2 == "ramp" {
3269                     let mut rp = Ramp::new();
3270                     rp.inner_mut().set_spec(&p.1);
3271                     Some(rp)
3272                 } else {
3273                     None
3274                 }
3275             }).collect();
3276         } else {
3277             for (i, p_new) in params.iter().enumerate() {
3278                 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
3279                     self.display_params[i].1 = p_new.1.clone();
3280                     if let Some(ref mut s) = self.sliders[i] {
3281                         let (min, max) = parse_slider_range(&p_new.2);
3282                         // Idempotence guard: hosts push params straight back
3283                         // after every sync, and re-seeding from the 2-decimal
3284                         // string quantizes away the slider's sub-tick state —
3285                         // mid-scroll that snaps the value BACKWARD between
3286                         // wheel events/glide ticks (visible as jitter). Only
3287                         // re-seed when the incoming string says something the
3288                         // current value doesn't (a genuinely external change).
3289                         let cur_str = format!("{:.*}", slider_decimals(&p_new.2), min + s.value * (max - min));
3290                         if cur_str != p_new.1 {
3291                             let val = p_new.1.parse::<f32>().unwrap_or(0.0);
3292                             let t = if max - min != 0.0 {
3293                                 ((val - min) / (max - min)).clamp(0.0, 1.0)
3294                             } else {
3295                                 0.0
3296                             };
3297                             s.set_value(t);
3298                         }
3299                     } else if let Some(ref mut f) = self.float3s[i] {
3300                         let (min, max) = parse_slider_range(&p_new.2);
3301                         // Same round-trip guard as the slider row.
3302                         let cur_str = f.value_string();
3303                         if cur_str != p_new.1 {
3304                             let vals = parse_float3_value(&p_new.1, min, max);
3305                             f.set_values(vals);
3306                         }
3307                     } else if let Some(ref mut sb) = self.spinboxes[i] {
3308                         if !sb.editing {
3309                             let (min, _max, _step) = parse_spinbox_range(&p_new.2);
3310                             let val = p_new.1.parse::<i32>().unwrap_or(min);
3311                             sb.value = val;
3312                         }
3313                     } else if let Some(ref mut d) = self.choices[i] {
3314                         if !d.open {
3315                             if let Some(options_str) = p_new.2.strip_prefix("choice:") {
3316                                 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3317                                 if d.options != options {
3318                                     d.options = options.clone();
3319                                 }
3320                                 if let Some(idx) = options.iter().position(|o| o == &p_new.1) {
3321                                     d.selected = idx;
3322                                 }
3323                             }
3324                         }
3325                     } else if let Some(ref mut tb) = self.texts[i] {
3326                         if !tb.editing {
3327                             tb.set_value_string(&p_new.1);
3328                         }
3329                     } else if let Some(ref mut t) = self.toggles[i] {
3330                         let on = p_new.1.trim().to_lowercase() == "true";
3331                         t.set_toggled(on);
3332                     } else if let Some(ref mut c) = self.colors[i] {
3333                         if !c.editing {
3334                             c.set_value_string(&p_new.1);
3335                         }
3336                     } else if let Some(ref mut rp) = self.ramps[i] {
3337                         if !rp.inner().is_dragging_key {
3338                             rp.set_value_string(&p_new.1);
3339                         }
3340                     }
3341                 }
3342             }
3343         }
3344         self.refresh_scroll_metrics();
3345     }
3346 }
3347 
3348 /// One clipboard, selection or history action over a code editor and its
3349 /// history — the body [`ParametersBg::code_action`] and the editor's own
3350 /// chords share, free of `self` so a caller holding a row borrow can use it.
3351 fn apply_code_action(
3352     editor: &mut TextEditorState,
3353     history: &mut crate::history::History<TextEditorState>,
3354     action: crate::widget::ContextAction,
3355 ) {
3356     use crate::widget::ContextAction;
3357     let before = editor.clone();
3358     let mut edited = false;
3359     match action {
3360         ContextAction::Undo => {
3361             if let Some(prev) = history.undo(editor.clone()) {
3362                 *editor = prev;
3363             }
3364         }
3365         ContextAction::Redo => {
3366             if let Some(next) = history.redo(editor.clone()) {
3367                 *editor = next;
3368             }
3369         }
3370         ContextAction::SelectAll => editor.select_all(),
3371         ContextAction::Copy => {
3372             if let Some(text) = editor.selected_text() {
3373                 crate::widget::clipboard::copy_to_clipboard(&text);
3374             }
3375         }
3376         ContextAction::Cut => {
3377             if let Some(text) = editor.selected_text() {
3378                 crate::widget::clipboard::copy_to_clipboard(&text);
3379                 edited = editor.delete_backwards();
3380             }
3381         }
3382         ContextAction::Paste => {
3383             if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
3384                 editor.insert_text(&text);
3385                 edited = true;
3386             }
3387         }
3388         _ => {}
3389     }
3390     if edited {
3391         history.record(before);
3392     }
3393 }
3394 
3395 fn get_cursor_line_col(buffer: &str, cursor_idx: usize) -> (usize, usize) {
3396     let mut cur_line = 0;
3397     let mut cur_col = 0;
3398     let mut count = 0;
3399     for c in buffer.chars() {
3400         if count == cursor_idx {
3401             return (cur_line, cur_col);
3402         }
3403         if c == '\n' {
3404             cur_line += 1;
3405             cur_col = 0;
3406         } else {
3407             cur_col += 1;
3408         }
3409         count += 1;
3410     }
3411     (cur_line, cur_col)
3412 }
3413 
3414 fn map_2d_to_1d(buffer: &str, line: usize, col: usize) -> usize {
3415     let mut target_line = line;
3416     let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3417     if lines.is_empty() {
3418         return 0;
3419     }
3420     if target_line >= lines.len() {
3421         target_line = lines.len() - 1;
3422     }
3423     let mut target_col = col;
3424     if target_col > lines[target_line].len() {
3425         target_col = lines[target_line].len();
3426     }
3427     let mut index = 0;
3428     for i in 0..target_line {
3429         index += lines[i].len() + 1; // +1 for the '\n'
3430     }
3431     index += target_col;
3432     index
3433 }
3434 
3435 fn get_line_start(buffer: &str, cursor_idx: usize) -> usize {
3436     let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3437     map_2d_to_1d(buffer, line, 0)
3438 }
3439 
3440 fn get_line_end(buffer: &str, cursor_idx: usize) -> usize {
3441     let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3442     let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3443     if lines.is_empty() {
3444         return 0;
3445     }
3446     let line_len = if line < lines.len() {
3447         lines[line].len()
3448     } else {
3449         lines[lines.len() - 1].len()
3450     };
3451     map_2d_to_1d(buffer, line, line_len)
3452 }
3453 
3454 #[cfg(test)]
3455 mod tests {
3456     use super::*;
3457     use crate::context::UiContext;
3458 
3459     fn panel_with(params: &[(&str, &str, &str)]) -> Adapted<ParametersBg> {
3460         let mut p = ParametersBg::new();
3461         let params: Vec<(String, String, String)> = params
3462             .iter()
3463             .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3464             .collect();
3465         ParamController::set_display_params(&mut *p, &params);
3466         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
3467         p
3468     }
3469 
3470     /// The textpick text-row variant: a TextBox AND a menu-button Dropdown
3471     /// share the row — the picker takes a right-edge sliver, its options come
3472     /// from the type string, and a plain text row builds no picker.
3473     #[test]
3474     fn textpick_rows_carry_a_picker() {
3475         let p = panel_with(&[
3476             ("Attribute Name", "mass", "textpick:Norm,UV,Pos,Col"),
3477             ("Plain", "x", "text"),
3478             ("Empty", "y", "textpick:"),
3479         ]);
3480         assert!(p.texts[0].is_some(), "textpick keeps its TextBox");
3481         let d = p.choices[0].as_ref().expect("textpick builds the picker");
3482         assert_eq!(d.options, ["Norm", "UV", "Pos", "Col"]);
3483         assert!(d.custom_display_text.is_some(), "menu-button mode");
3484         assert!(p.choices[1].is_none(), "plain text has no picker");
3485         assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
3486 
3487         // Layout: the box spans the full row; the picker button nests
3488         // inside it (within the box's right end).
3489         let (tx, ty, tw, th) = p.texts[0].as_ref().unwrap().rect();
3490         let (dx, dy, dw, dh) = p.choices[0].as_ref().unwrap().rect();
3491         assert_eq!(dw, PICK_W);
3492         assert!(dx > tx && dx + dw < tx + tw, "button inside the box horizontally");
3493         assert!(dy > ty && dy + dh <= ty + th, "button inside the box vertically");
3494 
3495         // Both text variants lay out at the same row height.
3496         assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
3497 
3498         // The menu anchors off the WHOLE field: the popover spans at least
3499         // the box width and hangs below it, not off the button sliver.
3500         let anchor = p.choices[0].as_ref().unwrap().popover_anchor.expect("anchor set");
3501         assert_eq!(anchor.x, tx);
3502         assert_eq!(anchor.width, tw);
3503         let d = p.choices[0].as_ref().unwrap();
3504         let (px_, py_, pw, _ph) = d.popover_geom(crate::scene::layout::Rect {
3505             x: dx, y: dy, width: dw, height: dh,
3506         });
3507         assert_eq!(px_, tx, "menu left-aligns with the box");
3508         assert!(pw >= tw, "menu at least as wide as the box");
3509         assert!(py_ >= ty + th - 1.0, "menu hangs below the box");
3510     }
3511 
3512     #[test]
3513     fn param_controller_roundtrip_and_row_widgets() {
3514         let p = panel_with(&[
3515             ("Size", "1.00", "slider:0:2"),
3516             ("Mode", "b", "choice:a,b,c"),
3517             ("On", "true", "checkbox"),
3518         ]);
3519         assert_eq!(ParamController::node_params(&*p).len(), 3);
3520         assert!(p.sliders[0].is_some() && p.choices[1].is_some() && p.toggles[2].is_some());
3521         // Rows were laid out from the cached rect: the slider's control rect
3522         // is the row less the label column when the labels are inline (the
3523         // default), the whole row when they are stacked.
3524         let lw = p.inner().label_col_w();
3525         if p.inner().inline_labels {
3526             assert!(lw > 0.0, "inline labels reserve a column");
3527             let labels = p.inner().own_text_labels();
3528             let size = labels.iter().find(|l| l.text == "Size").expect("the pane draws the inline label");
3529             assert_eq!(size.x, ROW_X_INSET, "the label sits at the row's left edge");
3530         } else {
3531             assert_eq!(lw, 0.0);
3532         }
3533         let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
3534         assert_eq!(
3535             (sx, sw),
3536             (ROW_X_INSET + lw, 300.0 - 2.0 * ROW_X_INSET - lw),
3537             "control rect derives from the assigned rect and the label column"
3538         );
3539     }
3540 
3541     #[test]
3542     fn ramp_row_builds_from_spec_and_edits_serialize_back() {
3543         let mut ctx = UiContext::new();
3544         let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
3545         let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
3546         assert_eq!(rp.inner().keys.len(), 2);
3547         assert!(rp.inner().smooth());
3548 
3549         // A press inside the curve area adds a key, and the row value carries
3550         // the re-serialized spec (what hosts poll and persist).
3551         let (rx, ry, rw, _) = rp.rect();
3552         p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
3553         assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
3554         let val = &ParamController::node_params(&*p)[0].1;
3555         assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
3556         assert!(val.starts_with("smooth;"));
3557     }
3558 
3559     #[test]
3560     fn toggle_click_commits_value_and_unfocus_commits_editor() {
3561         let mut ctx = UiContext::new();
3562         let mut p = panel_with(&[("On", "false", "checkbox")]);
3563         let (cx, cy, _, ch) = p.toggles[0].as_ref().unwrap().rect();
3564         // Click the toggle row (presses are ungated for this widget; the panel consumes
3565         // every left press, so the return is true either way — assert the value flip).
3566         p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
3567         p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
3568         assert_eq!(ParamController::node_params(&*p)[0].1, "true");
3569 
3570         // Code editor: focus it via a click, type, then unfocus commits the buffer.
3571         let mut p = panel_with(&[("Src", "let x = 1;", "code")]);
3572         let rects = p.get_param_rects();
3573         let r = rects[0];
3574         p.mouse_input(MouseButton::Left, ElementState::Pressed, r.0 + 20.0, r.1 + 30.0, &mut ctx);
3575         assert_eq!(p.focused_param, Some(0), "code row focused");
3576         assert!(p.code_editor.is_some());
3577         p.code_editor.as_mut().unwrap().insert_text("y");
3578         WidgetHost::unfocus(&mut p);
3579         assert_eq!(p.focused_param, None);
3580         assert!(p.code_editor.is_none());
3581         assert!(ParamController::node_params(&*p)[0].1.contains('y'), "editor buffer committed on unfocus");
3582     }
3583 
3584     fn key(k: Key, ctrl: bool, shift: bool) -> Event {
3585         Event::KeyInput(crate::widget::KeyEvent { state: ElementState::Pressed, logical_key: k, text: None, repeat: false, ctrl, shift, alt: false })
3586     }
3587 
3588     fn chr(c: &str) -> Event {
3589         key(Key::Character(c.into()), false, false)
3590     }
3591 
3592     fn ctrl(c: &str) -> Event {
3593         key(Key::Character(c.into()), true, false)
3594     }
3595 
3596     fn named(k: NamedKey) -> Event {
3597         key(Key::Named(k), false, false)
3598     }
3599 
3600     fn shift_named(k: NamedKey) -> Event {
3601         key(Key::Named(k), false, true)
3602     }
3603 
3604     /// A code row focused by a click at its first character.
3605     fn code_panel(src: &str) -> (Adapted<ParametersBg>, UiContext) {
3606         let mut ctx = UiContext::new();
3607         let mut p = panel_with(&[("Src", src, "code")]);
3608         let r = p.get_param_rects()[0];
3609         let x = p.code_text_x(r);
3610         p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0, &mut ctx);
3611         assert_eq!(p.focused_param, Some(0));
3612         (p, ctx)
3613     }
3614 
3615     fn send(p: &mut Adapted<ParametersBg>, ctx: &mut UiContext, ev: Event) -> bool {
3616         WidgetHost::handle_event(p, &ev, ctx)
3617     }
3618 
3619     /// Typing edits the buffer and leaves the VALUE alone until ctrl+enter
3620     /// applies it — a script would otherwise re-run on every keystroke — and
3621     /// the row says so: dirty while they differ, clean once applied.
3622     #[test]
3623     fn code_edits_apply_on_ctrl_enter_not_per_keystroke() {
3624         let (mut p, mut ctx) = code_panel("let x = 1;");
3625         assert!(!p.code_is_dirty());
3626         send(&mut p, &mut ctx, chr("/"));
3627         send(&mut p, &mut ctx, chr("/"));
3628         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "//let x = 1;");
3629         assert_eq!(ParamController::node_params(&*p)[0].1, "let x = 1;", "the value waits");
3630         assert!(p.code_is_dirty());
3631         send(&mut p, &mut ctx, key(Key::Named(NamedKey::Enter), true, false));
3632         assert_eq!(ParamController::node_params(&*p)[0].1, "//let x = 1;", "ctrl+enter applies");
3633         assert!(!p.code_is_dirty());
3634         assert_eq!(p.focused_param, Some(0), "and keeps editing");
3635         // Escape applies too, and leaves the row.
3636         send(&mut p, &mut ctx, chr("!"));
3637         send(&mut p, &mut ctx, named(NamedKey::Escape));
3638         assert_eq!(ParamController::node_params(&*p)[0].1, "//!let x = 1;");
3639         assert_eq!(p.focused_param, None);
3640     }
3641 
3642     /// Tab indents, shift+tab dedents, and Enter carries the indentation —
3643     /// one level deeper after an opening brace.
3644     #[test]
3645     fn code_editor_indents_like_an_editor() {
3646         let (mut p, mut ctx) = code_panel("");
3647         send(&mut p, &mut ctx, chr("i"));
3648         send(&mut p, &mut ctx, chr("f"));
3649         send(&mut p, &mut ctx, chr(" "));
3650         send(&mut p, &mut ctx, chr("{"));
3651         send(&mut p, &mut ctx, named(NamedKey::Enter));
3652         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    ", "a brace opens a level");
3653         send(&mut p, &mut ctx, chr("x"));
3654         send(&mut p, &mut ctx, named(NamedKey::Enter));
3655         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n    ", "the level carries");
3656         send(&mut p, &mut ctx, shift_named(NamedKey::Tab));
3657         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n", "shift+tab dedents the line");
3658         send(&mut p, &mut ctx, chr("}"));
3659         send(&mut p, &mut ctx, named(NamedKey::Tab));
3660         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n}    ", "tab inserts a level");
3661         assert!(send(&mut p, &mut ctx, named(NamedKey::Tab)), "tab is the editor's: it does not fall through to the host");
3662     }
3663 
3664     /// shift+arrows select, typing replaces the selection, ctrl+z undoes a
3665     /// typing run as one step and ctrl+shift+z redoes it.
3666     #[test]
3667     fn code_editor_selects_and_undoes() {
3668         let (mut p, mut ctx) = code_panel("abc\ndef");
3669         send(&mut p, &mut ctx, named(NamedKey::End));
3670         send(&mut p, &mut ctx, shift_named(NamedKey::ArrowDown));
3671         let e = p.code_editor.as_ref().unwrap();
3672         assert_eq!(e.selected_text().as_deref(), Some("\ndef"), "shift+down from the end of line 1 selects to the same column of line 2");
3673         send(&mut p, &mut ctx, chr("Z"));
3674         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZ", "typing replaces the selection");
3675         send(&mut p, &mut ctx, chr("Y"));
3676         send(&mut p, &mut ctx, chr("X"));
3677         send(&mut p, &mut ctx, ctrl("z"));
3678         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc\ndef", "one typing run, one undo");
3679         send(&mut p, &mut ctx, key(Key::Character("z".into()), true, true));
3680         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZYX", "and one redo");
3681         send(&mut p, &mut ctx, key(Key::Character("a".into()), true, true));
3682         assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcZYX"), "ctrl+shift+a selects all");
3683         assert_eq!(ParamController::node_params(&*p)[0].1, "abc\ndef", "none of it applied yet");
3684     }
3685 
3686     /// The host flags an error line and the gutter number turns red for it;
3687     /// a click lands the caret past the gutter, on the column it named.
3688     #[test]
3689     fn code_row_flags_an_error_line_and_clicks_land_past_the_gutter() {
3690         let (mut p, mut ctx) = code_panel("one\ntwo\nthree");
3691         p.set_code_error_line(Some(1));
3692         let labels = p.own_text_labels();
3693         let two = labels.iter().find(|l| l.text == "  2").expect("a gutter number for line 2");
3694         assert_eq!(two.color, [0xff, 0x80, 0x70]);
3695         let one = labels.iter().find(|l| l.text == "  1").unwrap();
3696         assert_eq!(one.color, [0x66, 0x66, 0x78]);
3697         let r = p.get_param_rects()[0];
3698         let x = p.code_text_x(r) + 2.0 * p.code_col_w();
3699         p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0 * ParametersBg::CODE_LINE_H + 2.0, &mut ctx);
3700         let e = p.code_editor.as_ref().unwrap();
3701         assert_eq!(get_cursor_line_col(&e.buffer, e.cursor_idx), (2, 2), "line 3, column 2");
3702         p.set_code_error_line(None);
3703         assert!(p.own_text_labels().iter().all(|l| l.color != [0xff, 0x80, 0x70]));
3704     }
3705 
3706     /// The context actions reach the editor: undo through the runner's
3707     /// routing is the editor's own history, and select-all selects the
3708     /// buffer. Outside an edit the pane declines them.
3709     #[test]
3710     fn code_editor_answers_context_actions_while_editing() {
3711         use crate::widget::ContextAction;
3712         let (mut p, mut ctx) = code_panel("abc");
3713         send(&mut p, &mut ctx, named(NamedKey::End));
3714         send(&mut p, &mut ctx, chr("d"));
3715         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
3716         assert!(Input::context_action(&mut *p, ContextAction::Undo));
3717         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc", "undo through the context action");
3718         assert!(Input::context_action(&mut *p, ContextAction::Redo));
3719         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
3720         assert!(Input::context_action(&mut *p, ContextAction::SelectAll));
3721         assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcd"));
3722         WidgetHost::unfocus(&mut p);
3723         assert!(!Input::context_action(&mut *p, ContextAction::Undo), "nothing to act on once the editor is closed");
3724     }
3725 
3726     #[test]
3727     fn clicking_a_section_title_collapses_its_rows() {
3728         let mut ctx = UiContext::new();
3729         let mut p = panel_with(&[
3730             ("Transform", "", "section"),
3731             ("Size", "1.00", "slider:0:2"),
3732             ("Shading", "", "section"),
3733             ("On", "true", "checkbox"),
3734         ]);
3735         let expanded_h = p.get_total_content_height();
3736         let below_before = p.get_param_rects()[2].1;
3737 
3738         // Press the first section's title box.
3739         let r_hdr = p.get_param_rects()[0];
3740         let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
3741         p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
3742 
3743         assert!(p.section_collapsed("Transform"));
3744         assert_eq!(p.get_param_rects()[1].3, 0.0, "the collapsed section's row has no height");
3745         assert!(p.get_param_rects()[2].1 < below_before, "the next section moves up");
3746         assert!(p.get_total_content_height() < expanded_h);
3747         // The row's chrome and label are gone; the header's stay.
3748         assert!(!p.own_text_labels().iter().any(|l| l.text.contains("Size")));
3749         assert!(p.own_text_labels().iter().any(|l| l.text.contains("Transform")));
3750 
3751         // Clicking it again restores the section.
3752         let r_hdr = p.get_param_rects()[0];
3753         let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
3754         p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
3755         assert!(!p.section_collapsed("Transform"));
3756         assert_eq!(p.get_total_content_height(), expanded_h);
3757     }
3758 
3759     /// Both ends of a straight run, and of an arc, as points on the path.
3760     fn run_ends(r: &(f32, f32, f32, f32)) -> [(f32, f32); 2] {
3761         let (x, y, w, h) = *r;
3762         if w > h {
3763             [(x, y), (x + w, y)]
3764         } else {
3765             [(x, y), (x, y + h)]
3766         }
3767     }
3768     fn arc_ends(a: &(f32, f32, f32, f32, f32)) -> [(f32, f32); 2] {
3769         let (cx, cy, r, a0, a1) = *a;
3770         [
3771             (cx + r * a0.cos(), cy + r * a0.sin()),
3772             (cx + r * a1.cos(), cy + r * a1.sin()),
3773         ]
3774     }
3775 
3776     #[test]
3777     fn section_outline_is_one_continuous_path() {
3778         let p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
3779         let (title, content) = p.section_boxes().into_iter().next().expect("one section");
3780         let content = content.expect("the section has a content box");
3781         let (runs, arcs) = p.section_outline(title, content.into());
3782 
3783         // The tab sits flush on the body — its bottom edge is the body's top edge.
3784         assert_eq!(title.1 + title.3, content.1, "the tab fuses to the body");
3785         // Folder-tab shape: the tab's two top corners, the concave throat where its right
3786         // side turns onto the body's top edge, and the body's three remaining corners
3787         // (its top-LEFT is the tab's left side running straight through).
3788         assert_eq!(arcs.len(), 6);
3789         let (tab_right, body_top) = (title.0 + title.2, content.1);
3790         let throats = arcs
3791             .iter()
3792             .filter(|(cx, cy, ..)| *cx > tab_right - 0.01 && *cy < body_top)
3793             .count();
3794         assert_eq!(throats, 1, "the throat — centred out in the pocket right of the tab");
3795 
3796         // Every corner hands off to a straight run — no arc dangles. (Within a stroke width:
3797         // runs and arcs are anchored on opposite ink sides at the concave corner.)
3798         let ends: Vec<(f32, f32)> = runs.iter().flat_map(|r| run_ends(r)).collect();
3799         for arc in &arcs {
3800             for (ax, ay) in arc_ends(arc) {
3801                 let nearest = ends
3802                     .iter()
3803                     .map(|(x, y)| ((x - ax).powi(2) + (y - ay).powi(2)).sqrt())
3804                     .fold(f32::INFINITY, f32::min);
3805                 assert!(nearest <= SECTION_BORDER_T + 0.01, "corner at ({ax}, {ay}) dangles: {nearest}");
3806             }
3807         }
3808 
3809         // The tab's bottom edge is open (no run along it), and the body's top edge runs
3810         // only right of the throat.
3811         let tab_bottom = title.1 + title.3 - SECTION_BORDER_T;
3812         let bottom_runs = runs
3813             .iter()
3814             .filter(|(_, y, w, _)| (*y - tab_bottom).abs() < 0.01 && *w > SECTION_BORDER_T)
3815             .count();
3816         assert_eq!(bottom_runs, 0, "the tab opens onto the body");
3817         let top_runs: Vec<&(f32, f32, f32, f32)> = runs
3818             .iter()
3819             .filter(|(_, y, w, _)| (*y - body_top).abs() < 0.01 && *w > SECTION_BORDER_T)
3820             .collect();
3821         assert_eq!(top_runs.len(), 1, "the body's top edge starts past the tab");
3822         assert!(top_runs[0].0 >= tab_right, "…right of the throat");
3823 
3824         // The left edge is ONE straight run from the tab's top corner to the body's
3825         // bottom corner.
3826         let left_runs: Vec<&(f32, f32, f32, f32)> = runs
3827             .iter()
3828             .filter(|(x, _, _, h)| (*x - title.0).abs() < 0.01 && *h > 0.0)
3829             .collect();
3830         assert_eq!(left_runs.len(), 1, "tab + body share one left side");
3831         assert!((left_runs[0].1 - (title.1 + SECTION_R)).abs() < 0.01);
3832         assert!((left_runs[0].1 + left_runs[0].3 - (content.1 + content.3 - SECTION_R)).abs() < 0.01);
3833     }
3834 
3835     #[test]
3836     fn a_collapsed_section_outline_closes_on_itself() {
3837         let mut p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
3838         p.set_section_collapsed("Transform", true);
3839         let (title, content) = p.section_boxes().into_iter().next().expect("one section");
3840         assert!(content.is_none(), "nothing to wrap below a collapsed header");
3841         let (runs, arcs) = p.section_outline(title, None);
3842         assert_eq!(arcs.len(), 4, "a plain rounded rect");
3843         assert_eq!(runs.len(), 4);
3844     }
3845 
3846     #[test]
3847     fn rows_pack_on_the_channel_and_sections_separate_wider() {
3848         let p = panel_with(&[
3849             ("Transform", "", "section"),
3850             ("Size", "1.00", "slider:0:2"),
3851             ("Shading", "", "section"),
3852             ("On", "true", "checkbox"),
3853         ]);
3854         let rects = p.get_param_rects();
3855         let content_bottom = |i: usize| rects[i].1 + rects[i].3 + CONTENT_BOX_PAD;
3856         let title_top = |i: usize| rects[i].1 - TITLE_BOX_INSET;
3857 
3858         // Inside a section everything packs on the channel: each tab sits flush on its
3859         // content box, and the rows keep one channel from the box's walls.
3860         for (title, content) in p.section_boxes() {
3861             let content = content.expect("both sections have content");
3862             assert_eq!(title.1 + title.3, content.1, "tab flush on its body");
3863         }
3864         let (_, content0) = p.section_boxes()[0];
3865         let content0 = content0.unwrap();
3866         assert_eq!(rects[1].1 - content0.1, CHANNEL, "row -> its box's top wall");
3867         assert_eq!(rects[1].0 - content0.0, CHANNEL, "row -> its box's side wall");
3868 
3869         // Section to section stays far wider, so the blocks still read apart.
3870         assert_eq!(title_top(2) - content_bottom(1), SECTION_GAP, "section -> next section");
3871         assert!(SECTION_GAP > 2.0 * CHANNEL, "sections separate wider than any channel");
3872     }
3873 
3874     #[test]
3875     fn a_collapsed_section_keeps_the_same_gap_to_the_next_one() {
3876         // With no content box under it, the collapsed section's bottom edge is its own title
3877         // box — a fixed row-pitch bump would leave a double gap here.
3878         let mut p = panel_with(&[
3879             ("Transform", "", "section"),
3880             ("Size", "1.00", "slider:0:2"),
3881             ("Shading", "", "section"),
3882             ("On", "true", "checkbox"),
3883         ]);
3884         p.set_section_collapsed("Transform", true);
3885         let rects = p.get_param_rects();
3886         let collapsed_bottom = rects[0].1 - TITLE_BOX_INSET + TITLE_BOX_H;
3887         let next_title_top = rects[2].1 - TITLE_BOX_INSET;
3888         assert_eq!(next_title_top - collapsed_bottom, SECTION_GAP);
3889     }
3890 
3891     #[test]
3892     fn collapse_survives_a_param_rebuild_and_swallows_row_clicks() {
3893         let mut ctx = UiContext::new();
3894         let mut p = panel_with(&[("Shading", "", "section"), ("On", "false", "checkbox")]);
3895         p.set_section_collapsed("Shading", true);
3896 
3897         // A click where the toggle used to sit must not reach it.
3898         let (cx, cy, _, ch) = p.toggles[1].as_ref().unwrap().rect();
3899         p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
3900         p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
3901         assert_eq!(ParamController::node_params(&*p)[1].1, "false", "hidden row ignores clicks");
3902 
3903         // The host re-syncs the panel (node change): collapse is keyed by title, so it holds.
3904         let params: Vec<(String, String, String)> = [("Shading", "", "section"), ("On", "false", "checkbox"), ("Extra", "1", "int")]
3905             .iter()
3906             .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3907             .collect();
3908         ParamController::set_display_params(&mut *p, &params);
3909         assert!(p.section_collapsed("Shading"));
3910         assert_eq!(p.get_param_rects()[1].3, 0.0);
3911     }
3912 
3913     #[test]
3914     fn plain_view_serves_row_chrome_and_all_quads_stays_empty() {
3915         let ctx = UiContext::new();
3916         let p = panel_with(&[("Size", "1.00", "slider:0:2")]);
3917         // The designer's plain path: extra_quads carries the row chrome (clipped), including
3918         // the slider background it reads via rect()+color()...
3919         let extra = WidgetHost::extra_quads(&p);
3920         assert!(!extra.is_empty(), "row chrome served through extra_quads");
3921         // ...but NOT the panel's own PARAM_BG plate (the host draws that from color()).
3922         let (x, y, w, h) = WidgetHost::rect(&p);
3923         assert!(
3924             !extra.iter().any(|q| (q.0, q.1, q.2, q.3) == (x, y, w, h)),
3925             "panel bg plate is the host's, not extra_quads'"
3926         );
3927         // The no-double-draw contract of the plain-quad hatch.
3928         assert!(WidgetHost::all_quads(&p, &ctx).is_empty());
3929         // Per-label hatch: the walk's text prims carry the widget font and viewport bounds.
3930         let mut scratch = crate::scene::paint::PaintCtx::new();
3931         crate::scene::painter::append_widget_text(&ctx, &p, &mut scratch);
3932         let labels: Vec<_> = scratch
3933             .finish()
3934             .items
3935             .into_iter()
3936             .filter_map(|item| match item.prim {
3937                 crate::scene::paint::Prim::Text { font, bounds, .. } => Some((font, bounds)),
3938                 _ => None,
3939             })
3940             .collect();
3941         assert!(!labels.is_empty());
3942         assert!(labels.iter().all(|(font, bounds)| font.is_some() && bounds.is_some()));
3943     }
3944 
3945     #[test]
3946     fn scroll_wheel_scrolls_when_content_overflows() {
3947         let mut ctx = UiContext::new();
3948         let rows: Vec<(String, String, String)> = (0..30)
3949             .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
3950             .collect();
3951         let mut p = ParametersBg::new();
3952         ParamController::set_display_params(&mut *p, &rows);
3953         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
3954         assert!(p.content_h > 200.0);
3955         assert!(WidgetHost::is_scrollable(&p));
3956         // Wheel over the panel body but off every slider row's x-span is impossible (rows are
3957         // full-width), so scroll via the region below the last visible row: use a y between
3958         // rows (the 2px slack above a row) — simplest is the bottom padding strip.
3959         let before = p.scroll_y;
3960         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 150.0, 199.0, &mut ctx);
3961         // Either a slider consumed it (value change) or the panel scrolled; both mark change.
3962         // The panel-scroll path must work when no slider is under the pointer:
3963         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
3964         assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
3965     }
3966 
3967     /// A gesture the pane acquired stays the pane's: rows travelling under
3968     /// the pointer mid-gesture must not hand the wheel to the slider that
3969     /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
3970     /// over the same slider still adjusts it.
3971     #[test]
3972     fn pane_owned_scroll_gesture_is_not_captured_by_a_slider_sliding_under_the_pointer() {
3973         let rows: Vec<(String, String, String)> = (0..30)
3974             .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
3975             .collect();
3976         let fresh = || {
3977             let mut p = ParametersBg::new();
3978             ParamController::set_display_params(&mut *p, &rows);
3979             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
3980             p
3981         };
3982         // A point on a slider's band: row 1's rect, below the label strip.
3983         let band_point = |p: &Adapted<ParametersBg>| {
3984             let r = p.get_param_rects()[1];
3985             (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
3986         };
3987         let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
3988 
3989         // Control: a new gesture ON the band adjusts the slider (the point is a real hit).
3990         let mut ctx = UiContext::new();
3991         let mut p = fresh();
3992         let (bx, by) = band_point(&p);
3993         ctx.scroll_gesture_new = true;
3994         ctx.scroll_initiate_widget_id = None;
3995         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
3996         assert_ne!(values(&p)[1], "1.00", "a new gesture on the band adjusts the slider");
3997 
3998         // The case: the gesture starts on the pane (dead space), the pane owns it...
3999         let mut ctx = UiContext::new();
4000         let mut p = fresh();
4001         ctx.scroll_gesture_new = true;
4002         ctx.scroll_initiate_widget_id = None;
4003         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4004         // (The scroll itself is animated by scroll_motion over later ticks, so
4005         // ownership — set only on the pane-scroll path — is the witness.)
4006         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane owns the gesture");
4007         // ...and the same gesture continuing over a band adjusts nothing.
4008         let (bx, by) = band_point(&p);
4009         ctx.scroll_gesture_new = false;
4010         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4011         assert!(values(&p).iter().all(|v| v == "1.00"), "no slider took the pane's gesture: {:?}", values(&p));
4012         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
4013     }
4014 
4015     /// A trackpad gesture is the pane's from anywhere — even beginning on a
4016     /// slider band, and even in a pane whose content fits — while a wheel
4017     /// notch on the band still adjusts the slider.
4018     #[test]
4019     fn finger_gesture_goes_to_the_pane_even_from_a_control() {
4020         use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4021         let rows = |n: usize| -> Vec<(String, String, String)> {
4022             (0..n).map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string())).collect()
4023         };
4024         let panel = |n: usize, h: f32| {
4025             let mut p = ParametersBg::new();
4026             ParamController::set_display_params(&mut *p, &rows(n));
4027             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
4028             p
4029         };
4030         let band_point = |p: &Adapted<ParametersBg>| {
4031             let r = p.get_param_rects()[1];
4032             (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
4033         };
4034         let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4035 
4036         // Overflowing pane, finger gesture starting ON the band: the pane scrolls, the slider holds.
4037         let mut ctx = UiContext::new();
4038         let mut p = panel(30, 200.0);
4039         assert!(p.content_h > 200.0);
4040         let (bx, by) = band_point(&p);
4041         ctx.scroll_gesture_new = true;
4042         set_scroll_phase(ScrollPhase::Finger);
4043         p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
4044         assert!(values(&p).iter().all(|v| v == "1.00"), "finger over a band did not adjust: {:?}", values(&p));
4045         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
4046         assert!(p.scroll_y > 0.0, "the pane scrolled (finger tracks 1:1): {}", p.scroll_y);
4047 
4048         // Same pane, a wheel notch on the band: the slider adjusts.
4049         let mut ctx = UiContext::new();
4050         let mut p = panel(30, 200.0);
4051         let (bx, by) = band_point(&p);
4052         ctx.scroll_gesture_new = true;
4053         set_scroll_phase(ScrollPhase::Wheel);
4054         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4055         assert_ne!(values(&p)[1], "1.00", "a wheel notch on the band adjusts the slider");
4056 
4057         // A pane whose content fits: the finger gesture is still the pane's (nothing to
4058         // scroll, nothing adjusted) — the same rule whatever the node's parameter count.
4059         let mut ctx = UiContext::new();
4060         let mut p = panel(3, 600.0);
4061         assert!(p.content_h <= 600.0);
4062         let (bx, by) = band_point(&p);
4063         ctx.scroll_gesture_new = true;
4064         set_scroll_phase(ScrollPhase::Finger);
4065         p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
4066         assert!(values(&p).iter().all(|v| v == "1.00"), "a finger gesture never adjusts: {:?}", values(&p));
4067         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took it");
4068         set_scroll_phase(ScrollPhase::Wheel);
4069     }
4070 }