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 (253.2K)

   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). The panel's own background is drawn by the host from
  13 //! [`Paint::color`]/[`Paint::corner_style`], NOT emitted here, and its text rides the
  14 //! per-label view ([`Paint::serves_legacy_labels`] — each label clips to the viewport but
  15 //! the code editor's clip to the code box, in monospace, which the one-font one-bounds prim
  16 //! bridge can't express).
  17 //!
  18 //! The code row is an editor, not a text box (2026-09-24): a line-number gutter, shift+arrow /
  19 //! shift+click selection with ctrl+c / ctrl+x / ctrl+v through the toolkit clipboard, tab and
  20 //! shift+tab indenting by four, Enter carrying the line's indentation (a level deeper after an
  21 //! opening bracket), ctrl+z / ctrl+shift+z on a `History` of editor snapshots that lives as long
  22 //! as the editor does, and the emacs chords it always had. Edits go to the BUFFER and reach the
  23 //! row's VALUE on ctrl+enter, Escape, or leaving the row — never per keystroke, because a host
  24 //! that evaluates a script on every value change would fail it on every half-typed line. The
  25 //! border says which state the row is in (amber pending, blue applied, grey at rest), and
  26 //! [`ParametersBg::set_code_error_line`] lets the host flag the line its last evaluation
  27 //! failed on.
  28 //!
  29 //! Flagged approximation: the legacy scrollbar-press called `self.focus()` (base flag only —
  30 //! nothing reads it: the highlight keys on the ctx focus slot, and the designer tracks its
  31 //! focused pane by index); the `on_event` arm drops it.
  32 
  33 use crate::colors;
  34 use crate::scene::layout::Rect;
  35 use crate::scene::paint::PaintCtx;
  36 use crate::widget::display::{Float3, TextLabel};
  37 use crate::scene::paint::Field;
  38 use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
  39 use crate::widget::{Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
  40     MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext, WidgetHostExt};
  41 
  42 /// A text row, in either variant: plain (`"text"`), or with a completion
  43 /// picker (`"textpick:a,b,c"` — the Houdini-style attribute/group chooser: a
  44 /// TextBox plus a slim menu-button Dropdown at its right edge whose pick
  45 /// fills the box; the host supplies the candidates in the type string).
  46 fn is_text_row(t: &str) -> bool {
  47     t == "text" || t.starts_with("textpick")
  48 }
  49 
  50 /// Width of a textpick row's picker button: the right end of the field,
  51 /// a flush control plate reaching the field's outer edge as a dropdown
  52 /// trigger's does, the text box's well ending at the seam beside it.
  53 const PICK_W: f32 = crate::widget::input::dropdown::ARROW_SLOT;
  54 
  55 pub struct ParametersBg {
  56     /// A row VALUE changed inside `tick` (a picker stream folded into its
  57     /// row) — for the host's sync, which must not run on every tick that
  58     /// merely animated (a scroll glide reports change every frame; syncing
  59     /// the whole parameter list on each was the choppy params scroll,
  60     /// 2026-09-20). Drained by [`Self::take_tick_value_change`].
  61     tick_value_changed: bool,
  62     /// The view every trackball in the pane is seen from
  63     /// ([`Self::set_trackball_view`]); kept here so rows built later start
  64     /// from it.
  65     trackball_view: [[f32; 3]; 3],
  66     /// The configured preference (`layout::param_labels_inline`, the
  67     /// `param_label_layout` style key), re-read at every rebuild.
  68     inline_pref: bool,
  69     /// The label layout the rows are laid out under NOW: inline, the pane
  70     /// draws each label in a column beside an unlabelled control; stacked,
  71     /// the control carries its own label above itself. The preference,
  72     /// unless the pane is too narrow for a column: [`Self::decide_inline`]
  73     /// re-decides on every rect assignment and [`Self::relabel_rows`]
  74     /// moves the labels when it flips, so geometry and widgets agree.
  75     inline_labels: bool,
  76     rect: Rect,
  77     display_params: Vec<(String, String, String)>,
  78     dragging_param: Option<usize>,
  79     pub focused_param: Option<usize>,
  80     pub code_editor: Option<TextEditorState>,
  81     /// The code editor's undo history: one entry per edit, typing runs
  82     /// coalesced by group. Lives exactly as long as the editor does.
  83     code_history: crate::history::History<TextEditorState>,
  84     /// A line (0-based) the host has flagged as the site of an error in the
  85     /// code row's value — a script that failed to parse. Drawn as a band
  86     /// under that line while the value it was reported for still stands.
  87     code_error_line: Option<usize>,
  88     mouse_pos: Option<(f32, f32)>,
  89     /// The parameter row under the pointer: its LABEL lifts
  90     /// (`own_text_labels`) and its own carves light (`paint_ui`, the tint
  91     /// channel) — the pane's ONE hover rule, over every control kind. The controls keep their own hovers (a spinbox's +/- buttons, a
  92     /// slider's band), but under the DE relief style most of them draw none
  93     /// at all — the spinbox's row border, the well frames and the dropdown
  94     /// border are flat-style chrome the relief branches never paint — so
  95     /// which rows answered the pointer depended on the control and the
  96     /// style (2026-09-28: a slider lit, the spinbox beside it did not). Set
  97     /// with the controls' hover in `hover_controls`, so a scroll moves it
  98     /// too.
  99     ///
 100     /// A label, deliberately, and not a wash over the row. A row wash was
 101     /// tried the same day and failed twice, both times through the relief:
 102     /// painted before the wells it closed the pane plate's carve-grouping
 103     /// window and every well in the pane flipped to the overlay shading on
 104     /// any hover; painted after them, its 5% white composited in LINEAR
 105     /// light, which lifts a well's dark shade line from 18 to ~67 while
 106     /// the pane face moves 46 to 76 — the hovered well's outline paled and
 107     /// read as the well lighting up. Text stands clear of the relief, and
 108     /// the tinted carve is the relief's own way of lighting ONE rim.
 109     hover_row: Option<usize>,
 110     pub sliders: Vec<Option<Adapted<Slider>>>,
 111     pub float3s: Vec<Option<Adapted<Float3>>>,
 112     pub spinboxes: Vec<Option<Adapted<Spinbox>>>,
 113     pub buttons: Vec<Option<Adapted<Button>>>,
 114     pub choices: Vec<Option<Adapted<Dropdown>>>,
 115     pub texts: Vec<Option<Adapted<TextBox>>>,
 116     pub toggles: Vec<Option<Adapted<Toggle>>>,
 117     pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
 118     /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
 119     /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
 120     /// views can't carry the curve/key geometry.
 121     pub ramps: Vec<Option<Adapted<Ramp>>>,
 122     /// Titles of the sections the user has collapsed by clicking their header. Keyed by
 123     /// title so it outlives the row rebuild `set_display_params` runs on every node change.
 124     collapsed: std::collections::HashSet<String>,
 125     visible: bool,
 126     pub scroll_y: f32,
 127     pub content_h: f32,
 128     scrollbar_dragging: bool,
 129     drag_offset_y: f32,
 130     /// The raise/sink hysteresis (wheel/drag raises, hover sustains, the hold decays in
 131     /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
 132     /// this widget so every app's plate-straddling scrollbar behaves the same way.
 133     activity: crate::widget::ScrollbarActivity,
 134     /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
 135     scroll_motion: crate::widget::ScrollMotion,
 136     /// One code-editor column's shaped advance (monospace @12, the family/size
 137     /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
 138     /// and click→column math read it; the hardcoded 7.2 px/col they used
 139     /// before drifted off the glyphs. 0.0 until the first shape.
 140     code_char_advance: f32,
 141 }
 142 
 143 /// The channel: the ONLY gap a control keeps from whatever its edge meets — the
 144 /// neighboring control, or its section's wall. Controls pack edge-to-edge; the
 145 /// reliefs on either side (control bevel, section wall) shade the channel into a
 146 /// narrow 3D groove.
 147 const CHANNEL: f32 = 3.0;
 148 
 149 /// The section carve's wall width: the DE relief scaled by the section depth
 150 /// multiplier (`style.container.section.depth`), capped against the row height
 151 /// (safety) and the control channel — the channel cap scales WITH the
 152 /// multiplier, so deepening sections is an explicit choice to let the roll
 153 /// cross the groove.
 154 fn section_carve_depth(h: f32) -> f32 {
 155     let sd = crate::layout::section_depth().max(0.0);
 156     (crate::layout::bevel_width() * sd).min(h * 0.2).min(CHANNEL * sd)
 157 }
 158 /// Vertical pitch between consecutive rows. Wider than the horizontal
 159 /// channel on purpose: each label+control pair gets its own breathing room,
 160 /// so rows read as separate entries rather than one packed stack. Was 8
 161 /// until 2026-09-06; a control's flush seam and the next row's label sat
 162 /// close enough to read as one block, so the pitch is now near two channels
 163 /// past the label's own line height.
 164 const ROW_GAP: f32 = 14.0;
 165 /// How far a section's title box overhangs its header row upward, and the box's height.
 166 const TITLE_BOX_INSET: f32 = 2.0;
 167 const TITLE_BOX_H: f32 = 28.0;
 168 /// How far a section's content box overhangs the first and last row it wraps —
 169 /// one channel, so the rows abut the section's top/bottom walls too.
 170 const CONTENT_BOX_PAD: f32 = CHANNEL;
 171 /// The section outline's color, stroke width, and its corner radii: convex (outer) corners, and the
 172 /// concave (inner) corners where the neck joins the title and content boxes.
 173 const SECTION_BORDER_COLOR: [f32; 4] = [0.18, 0.18, 0.27, 1.0];
 174 const SECTION_BORDER_T: f32 = 1.0;
 175 const SECTION_R: f32 = 13.0;
 176 /// The throat — the concave fillet where the tab's right side turns onto the content
 177 /// body's top edge (the tab sits flush on the body; there is no connector neck).
 178 const SECTION_THROAT_R: f32 = 5.0;
 179 /// The relief carve's concave inside-corner radius at the tab throat
 180 /// (`section_fillets`) — sized against SECTION_R so inside and outside
 181 /// corners read as one family.
 182 const SECTION_FILLET_R: f32 = 10.0;
 183 /// Narrowest a title box may be: both its corners plus the throat fillet. Titles run
 184 /// wider than this in practice; it only keeps the throat clear of the corners.
 185 const SECTION_TITLE_MIN_W: f32 = 2.0 * SECTION_R + SECTION_THROAT_R;
 186 
 187 /// The gap between one section's bottom box edge and the next section's title box —
 188 /// deliberately much wider than the channel the rows pack on, so sections read as
 189 /// separate blocks. Laid out from the previous block's *drawn* bottom edge (rather
 190 /// than the uniform row pitch, which the two boxes' overhangs eat into unequally) so the
 191 /// gap is exact.
 192 const SECTION_GAP: f32 = 16.0;
 193 
 194 /// Horizontal inset of a section's boxes (title tab and content body) from the pane
 195 /// plate's sides — deliberately the wider of the two horizontal gaps, so sections
 196 /// float clearly inside the plate.
 197 const SECTION_MARGIN: f32 = 16.0;
 198 /// Horizontal gap between a control row and its parent section's side walls —
 199 /// one channel; controls abut the section's edge.
 200 const CONTROL_INSET: f32 = CHANNEL;
 201 /// A row's inset from the plate: the section margin plus the controls' inset within
 202 /// the section, so bare rows above the first section align with wrapped ones.
 203 const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
 204 
 205 impl ParametersBg {
 206     /// The code box's line pitch, its text inset below the box top, and the
 207     /// gutter that carries line numbers: four columns of digits and a gap.
 208     const CODE_LINE_H: f32 = 16.0;
 209     const CODE_TOP: f32 = 22.0;
 210     const CODE_BOX_TOP: f32 = 18.0;
 211     const CODE_GUTTER_COLS: f32 = 4.0;
 212     const CODE_INDENT: &'static str = "    ";
 213 
 214     /// Where a code row's text starts: past the gutter.
 215     fn code_text_x(&self, r: (f32, f32, f32, f32)) -> f32 {
 216         r.0 + 12.0 + (Self::CODE_GUTTER_COLS + 1.0) * self.code_col_w()
 217     }
 218 
 219     /// Flag a line of the code row as an error site, or clear it. The host
 220     /// calls this when the value it evaluated failed with a line number —
 221     /// it is the one piece of feedback a script editor cannot do without.
 222     pub fn set_code_error_line(&mut self, line: Option<usize>) {
 223         self.code_error_line = line;
 224     }
 225 
 226     /// Whether a code row is being edited right now.
 227     pub fn code_editing(&self) -> bool {
 228         self.focused_param.is_some() && self.code_editor.is_some()
 229     }
 230 
 231     /// The row open for typing, if any: a code row, or a hosted text box,
 232     /// spinbox, slider readout, colour or vector field that is editing. (A
 233     /// choice row holds `focused_param` while its list is open; that is not
 234     /// typing.)
 235     fn typing_row(&self) -> Option<usize> {
 236         let i = self.focused_param?;
 237         if self.code_editor.is_some() {
 238             return Some(i);
 239         }
 240         fn at<T>(v: &[Option<T>], i: usize) -> Option<&T> {
 241             v.get(i).and_then(Option::as_ref)
 242         }
 243         let typing = at(&self.texts, i).is_some_and(|t| t.editing)
 244             || at(&self.spinboxes, i).is_some_and(|t| t.editing)
 245             || at(&self.sliders, i).is_some_and(|t| t.editing)
 246             || at(&self.colors, i).is_some_and(|t| t.editing)
 247             || at(&self.float3s, i).is_some_and(|t| t.editing_idx().is_some());
 248         typing.then_some(i)
 249     }
 250 
 251     /// Say a field is open for typing (`crate::text_input`): at its row, or
 252     /// the pane when the row is not laid out. The hosted fields are drawn
 253     /// from this pane's aggregates, never through their own paint, so their
 254     /// own claims never run — the pane makes it for them.
 255     fn claim_typing(&self, ctx: &PaintCtx) {
 256         if let Some(i) = self.typing_row() {
 257             let (x, y, w, h) = self
 258                 .get_param_rects()
 259                 .get(i)
 260                 .copied()
 261                 .filter(|r| r.3 > 0.0)
 262                 .unwrap_or((self.rect.x, self.rect.y, self.rect.width, self.rect.height));
 263             let (ox, oy) = ctx.offset();
 264             crate::text_input::claim(x + ox, y + oy, w, h);
 265         }
 266     }
 267 
 268     /// The clipboard, selection and history actions over the code editor:
 269     /// what the runner's undo / redo chords and the context menu's rows
 270     /// reach through [`Input::context_action`], and what the editor's own
 271     /// chords call. Returns whether the editor was open to act on.
 272     pub fn code_action(&mut self, action: crate::widget::ContextAction) -> bool {
 273         match self.code_editor.as_mut() {
 274             Some(editor) => {
 275                 apply_code_action(editor, &mut self.code_history, action);
 276                 true
 277             }
 278             None => false,
 279         }
 280     }
 281 
 282     /// Whether the focused code row holds edits not yet applied to its value.
 283     pub fn code_is_dirty(&self) -> bool {
 284         match (self.focused_param, &self.code_editor) {
 285             (Some(i), Some(editor)) => self.display_params.get(i).is_some_and(|p| p.1 != editor.buffer),
 286             _ => false,
 287         }
 288     }
 289 
 290     /// One code column's width — the shaped advance when recorded, else the
 291     /// legacy 7.2 estimate (only before the first `prepare_text`).
 292     fn code_col_w(&self) -> f32 {
 293         if self.code_char_advance > 0.0 {
 294             self.code_char_advance
 295         } else {
 296             7.2
 297         }
 298     }
 299 
 300     pub fn new() -> Adapted<ParametersBg> {
 301         Adapted::new(ParametersBg {
 302             trackball_view: crate::widget::display::float3::IDENTITY_VIEW,
 303             inline_pref: crate::layout::param_labels_inline(),
 304             inline_labels: crate::layout::param_labels_inline(),
 305             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 306             display_params: Vec::new(),
 307             dragging_param: None,
 308             focused_param: None,
 309             code_editor: None,
 310             code_history: crate::history::History::with_limit(200),
 311             code_error_line: None,
 312             mouse_pos: None,
 313             hover_row: None,
 314             sliders: Vec::new(),
 315             float3s: Vec::new(),
 316             spinboxes: Vec::new(),
 317             buttons: Vec::new(),
 318             choices: Vec::new(),
 319             texts: Vec::new(),
 320             toggles: Vec::new(),
 321             colors: Vec::new(),
 322             ramps: Vec::new(),
 323             collapsed: std::collections::HashSet::new(),
 324             visible: true,
 325             scroll_y: 0.0,
 326             content_h: 0.0,
 327             code_char_advance: 0.0,
 328             scrollbar_dragging: false,
 329             drag_offset_y: 0.0,
 330             activity: crate::widget::ScrollbarActivity::new(),
 331             scroll_motion: crate::widget::ScrollMotion::new(),
 332             tick_value_changed: false,
 333         })
 334     }
 335 
 336     /// Row `i`'s laid-out height, ignoring collapse (the row-type table).
 337     /// The shortest TRACK an inline row may leave its slider. Below it the
 338     /// pane stacks every label above its control instead. Measured on the
 339     /// track — what the slider is, to the eye and the hand — not on the
 340     /// control's rect, which also holds the readout and its gap (and a
 341     /// float3's axis column): until 2026-09-28 the rect was what was
 342     /// measured, so a slider's track shrank to 52 px, and a float3's to
 343     /// 36, before the labels moved. A control with no track (spinbox, text
 344     /// box, dropdown, colour) is measured whole.
 345     pub const MIN_INLINE_TRACK_W: f32 = 120.0;
 346 
 347     /// See every trackball in the pane from a host's camera
 348     /// (`Float3::set_view`): the view's right, up and toward-the-viewer
 349     /// axes, in the vectors' space. Returns whether anything changed, so a
 350     /// host knows to draw again. Kept for the rows built after it.
 351     pub fn set_trackball_view(&mut self, view: [[f32; 3]; 3]) -> bool {
 352         let mut probe = Float3::new();
 353         if !probe.set_view(view) {
 354             return false;
 355         }
 356         let view = probe.view();
 357         let moved = (0..3).any(|i| (0..3).any(|k| (view[i][k] - self.trackball_view[i][k]).abs() > 1e-5));
 358         if moved {
 359             self.trackball_view = view;
 360             for f in self.float3s.iter_mut().flatten() {
 361                 f.set_view(view);
 362             }
 363         }
 364         moved
 365     }
 366 
 367     /// Whether a float3 row's type asks for the trackball: a fourth segment,
 368     /// `float3:lo:hi:trackball`.
 369     fn has_trackball(t: &str) -> bool {
 370         t.starts_with("float3:") && t.split(':').nth(3) == Some("trackball")
 371     }
 372 
 373     /// What a row of type `t` spends of its control rect on everything but
 374     /// its track: a slider's readout and gap, plus a float3's axis column.
 375     /// Zero for the controls that have no track.
 376     fn control_chrome(t: &str) -> f32 {
 377         if t.starts_with("slider") {
 378             crate::widget::input::slider::Slider::readout_chrome()
 379         } else if is_vec_row(t) {
 380             let ball = if Self::has_trackball(t) { Float3::trackball_chrome() } else { 0.0 };
 381             ball + crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
 382         } else {
 383             0.0
 384         }
 385     }
 386 
 387     /// Whether a row of type `t` takes the inline layout at all. Only the
 388     /// rows whose control would otherwise carry a label strip: toggles and
 389     /// buttons ARE their label, a ramp keeps its label band, sections and
 390     /// code rows have layouts of their own.
 391     fn inline_kind(t: &str) -> bool {
 392         t.starts_with("slider")
 393             || is_vec_row(t)
 394             || t.starts_with("spinbox")
 395             || t.starts_with("choice")
 396             || is_text_row(t)
 397             || t.starts_with("color")
 398             || t == "rgb"
 399             || t == "rgba"
 400     }
 401 
 402     /// Whether row `i` is laid out with its label beside the control.
 403     fn inline_row(&self, i: usize) -> bool {
 404         self.inline_labels && Self::inline_kind(&self.display_params[i].2)
 405     }
 406 
 407     /// The layout the rows allow: the preference, unless a label column
 408     /// would leave any visible row's track shorter than
 409     /// [`Self::MIN_INLINE_TRACK_W`], in which case the labels stack. One
 410     /// decision for the pane — a mix of inline and stacked rows reads as
 411     /// ragged — taken by its SHORTEST track, so a pane of spinboxes keeps
 412     /// its column at a width where a pane with a float3 has let it go. An
 413     /// unlaid pane (no rect yet) follows the preference; the first rect
 414     /// assignment decides.
 415     fn decide_inline(&self) -> bool {
 416         if !self.inline_pref {
 417             return false;
 418         }
 419         if self.rect.width <= 0.0 {
 420             return true;
 421         }
 422         let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
 423         let control_w = row_w - self.label_col_w_if(true);
 424         let hidden = self.hidden_rows();
 425         self.display_params
 426             .iter()
 427             .enumerate()
 428             .filter(|(i, p)| !hidden[*i] && Self::inline_kind(&p.2))
 429             .all(|(_, p)| control_w - Self::control_chrome(&p.2) >= Self::MIN_INLINE_TRACK_W)
 430     }
 431 
 432     /// Re-decide the layout for the current rect and, when it flips, move
 433     /// every label between the column and the controls. The widgets were
 434     /// built with or without a label, and the two layouts have different
 435     /// row heights, so a flip is a relabel and a re-layout, not a flag.
 436     fn apply_label_layout(&mut self) {
 437         let want = self.decide_inline();
 438         if want != self.inline_labels {
 439             self.inline_labels = want;
 440             self.relabel_rows();
 441         }
 442     }
 443 
 444     /// Put each inline-kind row's label where the current layout wants it:
 445     /// on the control when stacked, off it (the pane draws the column) when
 446     /// inline. Toggles, buttons and ramps carry their label either way.
 447     fn relabel_rows(&mut self) {
 448         let inline = self.inline_labels;
 449         for i in 0..self.display_params.len() {
 450             let (name, ty) = (self.display_params[i].0.clone(), self.display_params[i].2.clone());
 451             if !Self::inline_kind(&ty) {
 452                 continue;
 453             }
 454             macro_rules! relabel {
 455                 ($w:expr) => {
 456                     if let Some(w) = $w {
 457                         if inline { w.clear_label() } else { w.set_label(&name) }
 458                     }
 459                 };
 460             }
 461             relabel!(&mut self.sliders[i]);
 462             relabel!(&mut self.float3s[i]);
 463             relabel!(&mut self.spinboxes[i]);
 464             relabel!(&mut self.texts[i]);
 465             relabel!(&mut self.colors[i]);
 466             // Only a choice row's dropdown carries the row label; a text
 467             // row's completion picker is a button inside the box.
 468             if ty.starts_with("choice:") {
 469                 relabel!(&mut self.choices[i]);
 470             }
 471         }
 472     }
 473 
 474     /// The label strip an inline row no longer spends: the control was
 475     /// built unlabelled, so the row loses exactly the strip's height.
 476     fn inline_strip(&self, i: usize) -> f32 {
 477         if self.inline_row(i) { crate::layout::control_label_strip() } else { 0.0 }
 478     }
 479 
 480     /// Gap between the label column and the control.
 481     const LABEL_GAP: f32 = 16.0;
 482 
 483     /// The inline label's font: the pane's own labels draw in the control
 484     /// label font (`Paint::widget_font`), so the column is measured in it —
 485     /// family AND size — or the widest label runs into its control.
 486     fn inline_label_font() -> (String, f32) {
 487         crate::layout::control_label_font_parsed()
 488     }
 489 
 490     /// Width of the label column under the inline layout: the widest visible
 491     /// inline label plus the gap, clamped to a floor (so a pane of one-word
 492     /// labels still reads as a column) and to under half the row (so a long
 493     /// label truncates rather than squeezing the control out). Zero when
 494     /// nothing is inline.
 495     fn label_col_w(&self) -> f32 {
 496         self.label_col_w_if(self.inline_labels)
 497     }
 498 
 499     /// [`Self::label_col_w`] under a given layout — what the column WOULD
 500     /// take, which is what [`Self::decide_inline`] asks before committing.
 501     fn label_col_w_if(&self, inline: bool) -> f32 {
 502         if !inline {
 503             return 0.0;
 504         }
 505         let (family, size) = Self::inline_label_font();
 506         let hidden = self.hidden_rows();
 507         let widest = self
 508             .display_params
 509             .iter()
 510             .enumerate()
 511             .filter(|(i, p)| !hidden[*i] && Self::inline_kind(&p.2))
 512             .map(|(_, p)| crate::widget::display::measure_text_width(&p.0, &family, size))
 513             .fold(0.0f32, f32::max);
 514         if widest <= 0.0 {
 515             return 0.0;
 516         }
 517         let row_w = (self.rect.width - 2.0 * ROW_X_INSET).max(0.0);
 518         (widest + Self::LABEL_GAP).clamp(72.0f32.min(row_w * 0.45), row_w * 0.45)
 519     }
 520 
 521     /// Row `i`'s CONTROL rect: the row rect less the label column when the
 522     /// row is inline, the row rect itself otherwise.
 523     fn control_rect(&self, r: (f32, f32, f32, f32), i: usize, lw: f32) -> (f32, f32, f32, f32) {
 524         if self.inline_row(i) { (r.0 + lw, r.1, (r.2 - lw).max(0.0), r.3) } else { r }
 525     }
 526 
 527     fn row_height(&self, i: usize) -> f32 {
 528         let p = &self.display_params[i];
 529         if p.2 == "code" {
 530             let val_text = if self.focused_param == Some(i) {
 531                 if let Some(ref editor) = self.code_editor {
 532                     &editor.buffer
 533                 } else {
 534                     &p.1
 535                 }
 536             } else {
 537                 &p.1
 538             };
 539             let line_count = val_text.split('\n').count();
 540             let content_h = Self::CODE_TOP + (line_count as f32 * Self::CODE_LINE_H) + 12.0;
 541             content_h.max(200.0)
 542         } else if p.2 == "section" {
 543             24.0
 544         } else if p.2 == SEPARATOR {
 545             // A rule, its own pixel: the row gaps either side are the air.
 546             1.0
 547         } else if p.2 == "ramp" {
 548             // Label band + the ramp's graph and control strip. The control
 549             // strip and label band are fixed, so this whole increase grows the
 550             // curve plot (Ramp::graph_h = height − strip).
 551             260.0
 552         } else if is_vec_row(&p.2) {
 553             Float3::preferred_height_for(!self.inline_row(i), vec_row_n(&p.2))
 554         } else if p.2.starts_with("slider") {
 555             (38.0 - self.inline_strip(i)).max(22.0)
 556         } else if is_text_row(&p.2) || p.2.starts_with("spinbox") || p.2.starts_with("choice") {
 557             (42.0 - self.inline_strip(i)).max(24.0)
 558         } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
 559             (40.0 - self.inline_strip(i)).max(24.0)
 560         } else if p.2 == "button" || p.2 == "toggle" || p.2 == "checkbox" {
 561             24.0
 562         } else {
 563             20.0
 564         }
 565     }
 566 
 567     /// Per-row "sits inside a collapsed section" flags: a section owns every row between its
 568     /// header and the next header. Headers themselves are never hidden — they stay clickable
 569     /// so a collapsed section can be reopened. Rows before the first header belong to no
 570     /// section and are always shown.
 571     fn hidden_rows(&self) -> Vec<bool> {
 572         let mut out = Vec::with_capacity(self.display_params.len());
 573         let mut hiding = false;
 574         for p in &self.display_params {
 575             if p.2 == "section" {
 576                 hiding = self.collapsed.contains(&p.0);
 577                 out.push(false);
 578             } else {
 579                 out.push(hiding);
 580             }
 581         }
 582         out
 583     }
 584 
 585     /// Whether the section titled `title` is collapsed.
 586     pub fn section_collapsed(&self, title: &str) -> bool {
 587         self.collapsed.contains(title)
 588     }
 589 
 590     /// Collapse/expand the section titled `title`, re-laying the rows. Keyed by title, not
 591     /// row index, so the state survives the `set_display_params` rebuilds a host runs
 592     /// whenever the inspected node changes.
 593     pub fn set_section_collapsed(&mut self, title: &str, collapsed: bool) {
 594         let changed = if collapsed {
 595             self.collapsed.insert(title.to_string())
 596         } else {
 597             self.collapsed.remove(title)
 598         };
 599         if changed {
 600             self.refresh_scroll_metrics();
 601         }
 602     }
 603 
 604     /// The box drawn around a section header's title — and, since the title is the collapse
 605     /// affordance, that box is also the header's click target.
 606     fn section_title_box(&self, hdr: usize, r_hdr: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
 607         // The label sits 8px in from the box's left edge; matching that 8px on the right
 608         // (box width = text + 16) centers the text in the box. Measure the run in the
 609         // label's real family AND size — parsed, not the raw "Family NN" spec string, which
 610         // resvg can't resolve (it would fall back to a narrow font and undersize the box).
 611         let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
 612         let (label_family, label_size) = crate::layout::control_label_font_parsed();
 613         let text_w =
 614             crate::widget::display::measure_text_width(&self.display_params[hdr].0, &label_family, label_size);
 615         let title_w = (text_w + 16.0).max(SECTION_TITLE_MIN_W).min(full_w);
 616         // The tab sits FLUSH on the content body: its bottom edge is the body's top
 617         // edge, in every style (the relief carve always drew it there; the outline now
 618         // fuses to it too). Collapsed, the box stays where the expanded tab sits (one
 619         // title-box height above where the body's top edge would be), so collapsing
 620         // doesn't jump the tab — and the click-toggle hit zone follows the ink. An
 621         // expanded-but-empty section keeps the legacy header-row placement.
 622         let y = if self.collapsed.contains(&self.display_params[hdr].0) {
 623             r_hdr.1 + r_hdr.3 + ROW_GAP - CONTENT_BOX_PAD - TITLE_BOX_H
 624         } else {
 625             match self.first_visible_row_top(hdr) {
 626                 Some(control_top) => control_top - CONTENT_BOX_PAD - TITLE_BOX_H,
 627                 None => r_hdr.1 - TITLE_BOX_INSET,
 628             }
 629         };
 630         (self.rect.x + SECTION_MARGIN, y, title_w, TITLE_BOX_H)
 631     }
 632 
 633     /// The top edge of the first visible row under header `hdr` — the row the section's
 634     /// content box (and so its tab) hangs from. `None` for an empty section (a header
 635     /// with no rows of its own before the next header).
 636     fn first_visible_row_top(&self, hdr: usize) -> Option<f32> {
 637         let hidden = self.hidden_rows();
 638         let rects = self.get_param_rects();
 639         self.display_params
 640             .iter()
 641             .enumerate()
 642             .skip(hdr + 1)
 643             .take_while(|(_, q)| q.2 != "section")
 644             .find(|(j, _)| !hidden[*j])
 645             .map(|(j, _)| rects[j].1)
 646     }
 647 
 648     /// Each section as `(header index, its content-row range)` — the rows between a header
 649     /// and the next one, `None` for a header with nothing under it.
 650     fn sections(&self) -> Vec<(usize, Option<(usize, usize)>)> {
 651         let mut out: Vec<(usize, Option<(usize, usize)>)> = Vec::new();
 652         for (i, p) in self.display_params.iter().enumerate() {
 653             if p.2 == "section" {
 654                 out.push((i, None));
 655             } else if let Some((_, content)) = out.last_mut() {
 656                 match content {
 657                     Some((_, end)) => *end = i,
 658                     None => *content = Some((i, i)),
 659                 }
 660             }
 661         }
 662         out
 663     }
 664 
 665     /// Each section's boxes: the title box, plus the box wrapping its rows (`None` when the
 666     /// section is collapsed or has no rows). The shared source for the outline's straight
 667     /// runs and its corner fillets, so the two halves can't disagree.
 668     /// The row floors (`layout::param_compression`): the pane material
 669     /// frosted at the configured compression, filled under every visible
 670     /// parameter row (headers excepted) before anything else in the pane
 671     /// paints, at the control corner radius — each parameter on its own
 672     /// tablet, the way the designer's node bodies get their own compression.
 673     /// Nothing when the key is unset. Relief only: the flat style has no
 674     /// floor language.
 675     fn paint_row_floors(&self, ctx: &mut PaintCtx) {
 676         let Some(k) = crate::layout::param_compression() else { return };
 677         if !crate::layout::control_relief() {
 678             return;
 679         }
 680         let mut mat = crate::scene::Material::pane();
 681         if let crate::scene::Frost::Frosted { compression, .. } = &mut mat.frost {
 682             *compression = k;
 683         }
 684         let r = crate::layout::control_corner_radius();
 685         let hidden = self.hidden_rows();
 686         for (i, (x, y, w, h)) in self.get_param_rects().into_iter().enumerate() {
 687             if hidden[i] || h <= 0.0 || self.display_params[i].2 == "section" || self.display_params[i].2 == SEPARATOR {
 688                 continue;
 689             }
 690             ctx.fill_material(Rect { x, y, width: w, height: h }, (r, r, r, r), &mat);
 691         }
 692     }
 693 
 694     fn section_boxes(&self) -> Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)> {
 695         let rects = self.get_param_rects();
 696         let full_w = self.rect.width - 2.0 * SECTION_MARGIN;
 697         let mut out = Vec::new();
 698         for (hdr, content) in self.sections() {
 699             if hdr >= rects.len() {
 700                 continue;
 701             }
 702             let title = self.section_title_box(hdr, rects[hdr]);
 703             let content_box = if self.collapsed.contains(&self.display_params[hdr].0) {
 704                 None
 705             } else {
 706                 content.and_then(|(start, end)| {
 707                     if start <= end && start < rects.len() && end < rects.len() {
 708                         let by = rects[start].1 - CONTENT_BOX_PAD;
 709                         let bh = (rects[end].1 + rects[end].3 + CONTENT_BOX_PAD) - by;
 710                         Some((title.0, by, full_w, bh))
 711                     } else {
 712                         None
 713                     }
 714                 })
 715             };
 716             out.push((title, content_box));
 717         }
 718         out
 719     }
 720 
 721     /// One section's outline: a SINGLE continuous border shaped like a folder tab — around
 722     /// the title box, whose bottom edge is open onto the content body it sits flush on
 723     /// (its right side turning onto the body's top edge through the concave throat fillet,
 724     /// its left side running straight down into the body's left edge) — as
 725     /// `(straight runs, corner fillets)`.
 726     ///
 727     /// Runs are `(x, y, w, h)`; fillets are `(cx, cy, radius, start, end)` for an arc stroked
 728     /// `SECTION_BORDER_T` inward of `radius` (the renderer's convention). Convex corners take
 729     /// the stroke inside the box, so their radius is the outer one; the concave throat corner
 730     /// has its centre out in the empty pocket, so its carries the `+ T` that puts the
 731     /// ink on the far side. Every run stops a radius short of its corner, and each fillet
 732     /// picks it up there — the path closes.
 733     fn section_outline(
 734         &self,
 735         title: (f32, f32, f32, f32),
 736         content: Option<(f32, f32, f32, f32)>,
 737     ) -> (Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, f32, f32, f32)>) {
 738         use std::f32::consts::{PI, TAU};
 739         const Q: f32 = std::f32::consts::FRAC_PI_2;
 740         let (t, r) = (SECTION_BORDER_T, SECTION_R);
 741         let mut quads: Vec<(f32, f32, f32, f32)> = Vec::new();
 742         let mut arcs: Vec<(f32, f32, f32, f32, f32)> = Vec::new();
 743         fn hrun(out: &mut Vec<(f32, f32, f32, f32)>, x0: f32, x1: f32, y: f32) {
 744             if x1 - x0 > 0.01 {
 745                 out.push((x0, y, x1 - x0, SECTION_BORDER_T));
 746             }
 747         }
 748         fn vrun(out: &mut Vec<(f32, f32, f32, f32)>, y0: f32, y1: f32, x: f32) {
 749             if y1 - y0 > 0.01 {
 750                 out.push((x, y0, SECTION_BORDER_T, y1 - y0));
 751             }
 752         }
 753 
 754         let (tx, ty, tw, th) = title;
 755         let ty_b = ty + th; // the title box's bottom edge
 756         arcs.push((tx + r, ty + r, r, PI, PI + Q)); // title top-left
 757         arcs.push((tx + tw - r, ty + r, r, PI + Q, TAU)); // title top-right
 758         hrun(&mut quads, tx + r, tx + tw - r, ty); // title top
 759 
 760         let Some((cx, cy_t, cw, ch)) = content else {
 761             // Collapsed or empty: the title box IS the section, so it closes on itself.
 762             arcs.push((tx + tw - r, ty_b - r, r, 0.0, Q)); // title bottom-right
 763             arcs.push((tx + r, ty_b - r, r, Q, PI)); // title bottom-left
 764             vrun(&mut quads, ty + r, ty_b - r, tx + tw - t); // title right
 765             vrun(&mut quads, ty + r, ty_b - r, tx); // title left
 766             hrun(&mut quads, tx + r, tx + tw - r, ty_b - t); // title bottom
 767             return (quads, arcs);
 768         };
 769 
 770         // The tab sits flush on the body (`ty_b == cy_t` — `section_title_box` places it
 771         // there): its bottom edge is open. The throat fillet shrinks if the tab runs
 772         // close to the body's right corner.
 773         let f = SECTION_THROAT_R.min((cx + cw - r - (tx + tw)).max(0.0));
 774         vrun(&mut quads, ty + r, cy_t - f, tx + tw - t); // tab right side, down to the throat
 775         arcs.push((tx + tw + f, cy_t - f, f + t, Q, PI)); // throat: tab side -> body top
 776         hrun(&mut quads, tx + tw + f, cx + cw - r, cy_t); // body top, right of the tab
 777 
 778         arcs.push((cx + cw - r, cy_t + r, r, PI + Q, TAU)); // body top-right
 779         arcs.push((cx + cw - r, cy_t + ch - r, r, 0.0, Q)); // body bottom-right
 780         arcs.push((cx + r, cy_t + ch - r, r, Q, PI)); // body bottom-left
 781         vrun(&mut quads, cy_t + r, cy_t + ch - r, cx + cw - t); // body right
 782         hrun(&mut quads, cx + r, cx + cw - r, cy_t + ch - t); // body bottom
 783         vrun(&mut quads, ty + r, cy_t + ch - r, tx); // tab + body left, one straight run
 784 
 785         (quads, arcs)
 786     }
 787 
 788     /// The section outlines' corner fillets, as the renderer's arc tuples
 789     /// `(cx, cy, radius, thickness, start, end, color)`.
 790     ///
 791     /// A concrete accessor rather than prims out of [`Paint::paint`] on purpose: the host
 792     /// draws this panel through the legacy plain-quad hatch, and `append_widget_plate` serves
 793     /// a widget's arcs UNCLIPPED — these have to land inside the pane's scroll viewport, so
 794     /// the host draws them itself under its own clip (the straight runs ride `plain_quads`,
 795     /// which clips them there by hand).
 796     pub fn arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
 797         if !self.visible || crate::layout::control_relief() {
 798             return Vec::new();
 799         }
 800         let color = SECTION_BORDER_COLOR;
 801         let mut out = Vec::new();
 802         for (title, content) in self.section_boxes() {
 803             let (_, arcs) = self.section_outline(title, content);
 804             out.extend(
 805                 arcs.into_iter()
 806                     .map(|(cx, cy, r, a0, a1)| (cx, cy, r, SECTION_BORDER_T, a0, a1, color)),
 807             );
 808         }
 809         out
 810     }
 811 
 812     /// Where the rows start, in absolute y — the origin `get_param_rects` lays out from.
 813     fn rows_origin(&self) -> f32 {
 814         self.rect.y - self.scroll_y
 815     }
 816 
 817     /// The scrollable height of the row column. Derived from [`Self::get_param_rects`] rather
 818     /// than re-walking the advance rules, so the two can't drift apart.
 819     pub fn get_total_content_height(&self) -> f32 {
 820         let hidden = self.hidden_rows();
 821         let rects = self.get_param_rects();
 822         let bottom = rects
 823             .iter()
 824             .enumerate()
 825             .filter(|(i, _)| !hidden[*i])
 826             .map(|(_, r)| r.1 + r.3)
 827             .fold(f32::NEG_INFINITY, f32::max);
 828         if bottom == f32::NEG_INFINITY {
 829             return 30.0 + 10.0; // no rows: the top offset plus the bottom padding
 830         }
 831         (bottom - self.rows_origin()) + ROW_GAP + 10.0
 832     }
 833 
 834     /// One rect per row, in index order — collapsed rows get a zero-height rect at the
 835     /// current cursor (and consume no vertical space), so every index-parallel consumer
 836     /// keeps working while the row draws and hit-tests as nothing.
 837     ///
 838     /// Rows advance on a uniform [`ROW_GAP`] pitch, except a section header, which is placed
 839     /// [`SECTION_GAP`] below the previous section's drawn bottom edge — see [`SECTION_GAP`].
 840     pub fn get_param_rects(&self) -> Vec<(f32, f32, f32, f32)> {
 841         let hidden = self.hidden_rows();
 842         let mut rects = Vec::new();
 843         let mut cur_y = self.rows_origin() + 30.0;
 844         // Bottom edge of the last row as DRAWN: a row inside a section is wrapped by the
 845         // content box, which overhangs it; a header with no visible rows under it (empty or
 846         // collapsed) ends at its own title box. `None` until the first section starts —
 847         // rows above it are bare, with no box to measure against.
 848         let mut prev_bottom: Option<f32> = None;
 849         for i in 0..self.display_params.len() {
 850             if hidden[i] {
 851                 rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, 0.0));
 852                 continue;
 853             }
 854             let is_header = self.display_params[i].2 == "section";
 855             if is_header {
 856                 if let Some(bottom) = prev_bottom {
 857                     cur_y = bottom + SECTION_GAP + TITLE_BOX_INSET;
 858                 }
 859             }
 860             let h = self.row_height(i);
 861             rects.push((self.rect.x + ROW_X_INSET, cur_y, self.rect.width - 2.0 * ROW_X_INSET, h));
 862             prev_bottom = Some(if is_header {
 863                 cur_y - TITLE_BOX_INSET + TITLE_BOX_H
 864             } else if prev_bottom.is_some() {
 865                 cur_y + h + CONTENT_BOX_PAD
 866             } else {
 867                 cur_y + h
 868             });
 869             cur_y += h + ROW_GAP;
 870         }
 871         rects
 872     }
 873 
 874     /// The pane's scrollbar width — the DE `scrollbar_width` widened: the bar
 875     /// rides over the section carves and reads too slim at the stock width.
 876     fn scrollbar_w(&self) -> f32 {
 877         crate::layout::scrollbar_width() * 1.6
 878     }
 879 
 880     /// The scrollbar's left x: the bar rides the pane's CENTRE line, as
 881     /// every sink-behind bar in the DE does (cce-mail's list and body, the
 882     /// designer's dialog list) — over the rows, reserving no lane, in front
 883     /// only while raised. It sat a sixth of the width in from the right
 884     /// edge before 2026-09-21, which beside a centred bar read as off.
 885     fn scrollbar_x(&self) -> f32 {
 886         self.rect.x + (self.rect.width - self.scrollbar_w()) * 0.5
 887     }
 888 
 889     pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
 890         if self.content_h <= self.rect.height {
 891             return false;
 892         }
 893         let sb_w = self.scrollbar_w();
 894         let sb_x = self.scrollbar_x();
 895         let sb_track_h = self.rect.height - 8.0;
 896         let sb_track_y = self.rect.y + 4.0;
 897 
 898         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
 899             && py >= sb_track_y && py <= sb_track_y + sb_track_h
 900     }
 901 
 902     /// Whether the pane holds enough content to need a scrollbar at all.
 903     pub fn scrollbar_visible(&self) -> bool {
 904         self.content_h > self.rect.height
 905     }
 906 
 907     /// Whether the scrollbar is currently raised in front of the pane plate (the latched
 908     /// state). While this is false the bar sits behind the plate and is non-interactive.
 909     pub fn scrollbar_active(&self) -> bool {
 910         self.activity.raised()
 911     }
 912 
 913     /// How far the bar's FORE copy has faded in, 0..=1: the host draws the
 914     /// idle copy before the plate every frame and the fore copy after the
 915     /// rows at this alpha, so the raise and the sink are a fade, not a flip.
 916     pub fn scrollbar_fade(&self) -> f32 {
 917         self.activity.fade()
 918     }
 919 
 920     /// Re-latch the shared hysteresis with this pane's inputs, returning whether it changed.
 921     fn recompute_scrollbar_raised(&mut self) -> bool {
 922         let visible = self.scrollbar_visible();
 923         self.activity.recompute(visible, self.scrollbar_dragging)
 924     }
 925 
 926     /// The scrollbar's track + thumb quads (empty when no scrollbar is needed). The host draws
 927     /// these either behind or in front of the pane plate per [`Self::scrollbar_active`]; they
 928     /// are deliberately kept out of [`Self::plain_quads`] so the host controls their depth.
 929     pub fn scrollbar_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
 930         if !self.scrollbar_visible() {
 931             return Vec::new();
 932         }
 933         let sb_w = self.scrollbar_w();
 934         let sb_x = self.scrollbar_x();
 935         let sb_track_h = self.rect.height - 8.0;
 936         let sb_track_y = self.rect.y + 4.0;
 937 
 938         let visible_ratio = self.rect.height / self.content_h;
 939         let thumb_h = if sb_track_h <= 20.0 {
 940             sb_track_h
 941         } else {
 942             (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
 943         };
 944         let max_scroll = self.content_h - self.rect.height;
 945         let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
 946         let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
 947 
 948         vec![
 949             (sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()),
 950             (sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()),
 951         ]
 952     }
 953 
 954     /// Hand a pointer position to every hosted control, so each re-reads
 955     /// its own hover (and a ramp row its key drag). Called for every pointer
 956     /// move, and again from the last known position (`mouse_pos`) whenever a
 957     /// SCROLL moves the rows under a still pointer — the wheel arm and the
 958     /// tick's coast — because a control's hover is recomputed only when it
 959     /// is told where the pointer is, and until 2026-09-28 nothing told it on
 960     /// a scroll: a control that scrolled under the pointer stayed dark and
 961     /// one that scrolled away stayed lit until the next motion. Sliders are
 962     /// in the roster since the same day, having had no hover before.
 963     fn hover_controls(&mut self, px: f32, py: f32, ui: &mut UiContext) -> bool {
 964         let mut changed = false;
 965         for s in self.sliders.iter_mut().flatten() {
 966             if s.on_cursor_moved(px, py, ui) {
 967                 changed = true;
 968             }
 969         }
 970         for sb in self.spinboxes.iter_mut().flatten() {
 971             if sb.on_cursor_moved(px, py, ui) {
 972                 changed = true;
 973             }
 974         }
 975         for f in self.float3s.iter_mut().flatten() {
 976             if f.on_cursor_moved(px, py, ui) {
 977                 changed = true;
 978             }
 979         }
 980         for b in self.buttons.iter_mut().flatten() {
 981             if b.on_cursor_moved(px, py, ui) {
 982                 changed = true;
 983             }
 984         }
 985         for d in self.choices.iter_mut().flatten() {
 986             if d.on_cursor_moved(px, py, ui) {
 987                 changed = true;
 988             }
 989         }
 990         for tb in self.texts.iter_mut().flatten() {
 991             if tb.on_cursor_moved(px, py, ui) {
 992                 changed = true;
 993             }
 994         }
 995         for cb in self.toggles.iter_mut().flatten() {
 996             if cb.on_cursor_moved(px, py, ui) {
 997                 changed = true;
 998             }
 999         }
1000         for c in self.colors.iter_mut().flatten() {
1001             if c.on_cursor_moved(px, py, ui) {
1002                 changed = true;
1003             }
1004         }
1005         // Ramp rows: a move can drag a key — re-serialize the curve
1006         // into the row value so hosts polling `node_params` see it.
1007         for i in 0..self.ramps.len() {
1008             if let Some(rp) = &mut self.ramps[i] {
1009                 if rp.on_cursor_moved(px, py, ui) {
1010                     self.display_params[i].1 = rp.inner().spec_string();
1011                     changed = true;
1012                 }
1013             }
1014         }
1015         let row = self.hoverable_row_at(px, py);
1016         if row != self.hover_row {
1017             self.hover_row = row;
1018             changed = true;
1019         }
1020         changed
1021     }
1022 
1023     /// The parameter row under `(px, py)`: a visible, non-header row whose
1024     /// rect holds the point, and only while the point is inside the pane's
1025     /// own viewport (a row scrolled out of it is not under anything).
1026     fn hoverable_row_at(&self, px: f32, py: f32) -> Option<usize> {
1027         if !self.visible
1028             || px < self.rect.x
1029             || px > self.rect.x + self.rect.width
1030             || py < self.rect.y
1031             || py > self.rect.y + self.rect.height
1032         {
1033             return None;
1034         }
1035         let hidden = self.hidden_rows();
1036         self.get_param_rects().into_iter().enumerate().find_map(|(i, (x, y, w, h))| {
1037             let hoverable = !hidden[i] && h > 0.0 && self.display_params[i].2 != "section" && self.display_params[i].2 != SEPARATOR;
1038             (hoverable && px >= x && px <= x + w && py >= y && py <= y + h).then_some(i)
1039         })
1040     }
1041 
1042     /// The rows moved under a still pointer: re-hover from where it was.
1043     fn rehover_after_scroll(&mut self, ui: &mut UiContext) -> bool {
1044         match self.mouse_pos {
1045             Some((px, py)) => self.hover_controls(px, py, ui),
1046             None => false,
1047         }
1048     }
1049 
1050     /// The pane's own row label, and the same label under the pointer: the
1051     /// dialog's row and selected-row label colours, so a hovered parameter
1052     /// reads as the dialog's hovered command does.
1053     const LABEL: [u8; 3] = [0xaa, 0xaa, 0xbb];
1054     const LABEL_HOVER: [u8; 3] = [0xf4, 0xf4, 0xfa];
1055     /// The hovered row's carve tint: the lifted label's own colour, so the
1056     /// lit rim and the label read as one cue. Through the tint channel the
1057     /// shader composites the rim's light in this colour at its focus gain
1058     /// and the shadow in a quarter of it — the carve lights, it is not
1059     /// washed over (a wash over a carve pales its dark shade line in linear
1060     /// blending; see `hover_row`).
1061     const HOVER_TINT: [f32; 3] = [0xf4 as f32 / 255.0, 0xf4 as f32 / 255.0, 0xfa as f32 / 255.0];
1062 
1063     fn update_slider_rects(&mut self) {
1064         // Inline rows hand their control the row less the label column; the
1065         // row rects themselves (`get_param_rects`) stay the full row, which is
1066         // what the floors, the section boxes and hit routing measure against.
1067         let lw = self.label_col_w();
1068         let rects: Vec<(f32, f32, f32, f32)> = self
1069             .get_param_rects()
1070             .into_iter()
1071             .enumerate()
1072             .map(|(i, r)| self.control_rect(r, i, lw))
1073             .collect();
1074         let inline: Vec<bool> = (0..self.display_params.len()).map(|i| self.inline_row(i)).collect();
1075         for (i, s_opt) in self.sliders.iter_mut().enumerate() {
1076             if let Some(s) = s_opt {
1077                 let r = rects[i];
1078                 s.set_rect(r.0, r.1, r.2, r.3);
1079             }
1080         }
1081         for (i, f_opt) in self.float3s.iter_mut().enumerate() {
1082             if let Some(f) = f_opt {
1083                 let r = rects[i];
1084                 f.set_rect(r.0, r.1, r.2, r.3);
1085             }
1086         }
1087         for (i, sb_opt) in self.spinboxes.iter_mut().enumerate() {
1088             if let Some(sb) = sb_opt {
1089                 let r = rects[i];
1090                 sb.set_rect(r.0, r.1, r.2, r.3);
1091             }
1092         }
1093         for (i, b_opt) in self.buttons.iter_mut().enumerate() {
1094             if let Some(b) = b_opt {
1095                 let r = rects[i];
1096                 b.set_rect(r.0, r.1, r.2, r.3);
1097             }
1098         }
1099         for (i, d_opt) in self.choices.iter_mut().enumerate() {
1100             if let Some(d) = d_opt {
1101                 let r = rects[i];
1102                 if self.display_params[i].2.starts_with("textpick") {
1103                     // The picker is the field's RIGHT END: a flush control
1104                     // plate over the whole band below the detached label,
1105                     // out to the field's outer edge on the top, right and
1106                     // bottom as a dropdown trigger's plate is, square where
1107                     // it meets the text box (whose well stops at the seam —
1108                     // `TextBox::joined_right`) and the box's radius on the
1109                     // outside, so the two read as one field. Until
1110                     // 2026-10-01 it was a button nested INSIDE the box's
1111                     // well, its face stopping at the base of the well's
1112                     // wall, so it never reached the edge a dropdown's does.
1113                     let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
1114                     let band_h = r.3 - label_top;
1115                     let rr = crate::layout::textbox_corner_radius();
1116                     // The picker is a trigger's arrow slot: PICK_W and a
1117                     // wall, the wall being the lip its face rises out of at
1118                     // the seam. Its arrow in its middle is then where every
1119                     // other trigger's arrow is, in its slot.
1120                     let pick_w = crate::widget::input::dropdown::arrow_slot(band_h);
1121                     d.set_rect(r.0 + r.2 - pick_w, r.1 + label_top, pick_w, band_h);
1122                     d.inner_mut().set_radii(Some((0.0, rr, rr, 0.0)));
1123                     d.inner_mut().center_arrow = true;
1124                     // The menu hangs off the WHOLE field, not the button
1125                     // sliver: anchor the popover to the box's well band.
1126                     d.popover_anchor = Some(Rect {
1127                         x: r.0,
1128                         y: r.1 + label_top,
1129                         width: r.2,
1130                         height: r.3 - label_top,
1131                     });
1132                 } else {
1133                     d.set_rect(r.0, r.1, r.2, r.3);
1134                 }
1135             }
1136         }
1137         for (i, tb_opt) in self.texts.iter_mut().enumerate() {
1138             if let Some(tb) = tb_opt {
1139                 let r = rects[i];
1140                 // A textpick row's box ends where its picker begins.
1141                 let joined = self.display_params[i].2.starts_with("textpick") && self.choices[i].is_some();
1142                 tb.inner_mut().joined_right = joined;
1143                 tb.set_rect(r.0, r.1, r.2, r.3);
1144                 if joined {
1145                     // The box ends where the picker begins, at the seam.
1146                     let depth = crate::layout::bevel_width().min((r.3 - tb.label_strip()) * 0.2);
1147                     tb.set_rect(r.0, r.1, (r.2 - PICK_W - depth).max(0.0), r.3);
1148                 }
1149             }
1150         }
1151         for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
1152             if let Some(cb) = cb_opt {
1153                 let r = rects[i];
1154                 cb.set_rect(r.0, r.1, r.2, r.3);
1155             }
1156         }
1157         for (i, c_opt) in self.colors.iter_mut().enumerate() {
1158             if let Some(c) = c_opt {
1159                 let r = rects[i];
1160                 c.set_rect(r.0, r.1, r.2, r.3);
1161             }
1162         }
1163         for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
1164             if let Some(rp) = rp_opt {
1165                 let r = rects[i];
1166                 // Below the 18px label band own_text_labels draws (the ramp
1167                 // carries no label of its own).
1168                 rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
1169             }
1170         }
1171     }
1172 
1173     /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
1174     /// scroll into it, re-lay the rows.
1175     fn refresh_scroll_metrics(&mut self) {
1176         // The label layout (inline column or stacked) is decided first, by
1177         // the tracks the rows would get: every geometry below depends on
1178         // it. Here rather than on rect assignment alone, because a section
1179         // collapsing or opening changes which rows are visible, and so
1180         // which is the shortest track and how wide the column is.
1181         self.apply_label_layout();
1182         self.content_h = self.get_total_content_height();
1183         let max_scroll = (self.content_h - self.rect.height).max(0.0);
1184         self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
1185         self.update_slider_rects();
1186     }
1187 
1188     fn own_text_labels(&self) -> Vec<TextLabel> {
1189         let rects = self.get_param_rects();
1190         let hidden = self.hidden_rows();
1191         let mut labels = Vec::new();
1192         let lw = self.label_col_w();
1193         let (family, size) = Self::inline_label_font();
1194         for (i, (name, value, ptype)) in self.display_params.iter().enumerate() {
1195             if hidden[i] {
1196                 continue;
1197             }
1198             let r = rects[i];
1199             if self.inline_row(i) {
1200                 // The pane's own label, in the column beside an unlabelled
1201                 // control: vertically centred on the row, tail-truncated to
1202                 // the column — by the measure the column is sized with, so
1203                 // a label it was sized to hold is never cut (it was cut to
1204                 // as many chars as the column holds M's).
1205                 labels.push(TextLabel {
1206                     text: fit_tail(name, lw - Self::LABEL_GAP, &family, size),
1207                     x: r.0,
1208                     y: r.1 + (r.3 - size) * 0.5,
1209                     font_size: size,
1210                     color: if self.hover_row == Some(i) { Self::LABEL_HOVER } else { Self::LABEL },
1211                 });
1212             }
1213             if ptype.starts_with("slider") {
1214                 if let Some(s) = &self.sliders[i] {
1215                     labels.extend(s.own_text_labels());
1216                 }
1217             } else if is_vec_row(ptype) {
1218                 if let Some(f) = &self.float3s[i] {
1219                     labels.extend(f.own_text_labels());
1220                 }
1221             } else if ptype == "section" {
1222                 // Centered within the tab itself — `section_title_box` is the one
1223                 // source for where the tab sits (flush on the body; collapsed and
1224                 // empty sections carry their own placements there).
1225                 let font_size = 13.0;
1226                 let (bx, by, _, _) = self.section_title_box(i, r);
1227                 labels.push(TextLabel {
1228                     text: name.clone(),
1229                     // 8px in from the title box's left edge — the inset
1230                     // `section_title_box`'s width math centers against.
1231                     x: bx + 8.0,
1232                     y: by + (TITLE_BOX_H - font_size) / 2.0,
1233                     font_size,
1234                     color: [0xee, 0xee, 0xf0],
1235                 });
1236             } else if ptype == "code" {
1237                 labels.push(TextLabel {
1238                     text: format!("{}:", name),
1239                     x: self.rect.x + 12.0,
1240                     y: r.1,
1241                     font_size: 12.0,
1242                     color: [0xaa, 0xaa, 0xbb],
1243                 });
1244                 let val_text = if self.focused_param == Some(i) {
1245                     if let Some(ref editor) = self.code_editor {
1246                         editor.buffer.clone()
1247                     } else {
1248                         value.clone()
1249                     }
1250                 } else {
1251                     value.clone()
1252                 };
1253                 // One label PER LINE, at the same pitch the cursor math uses
1254                 // (`plain_quads`' cursor_y) — a single multi-line label would depend on
1255                 // the consumer's buffer line-height matching that pitch, and never
1256                 // exactly did. A line number sits in the gutter before each.
1257                 let text_x = self.code_text_x(r);
1258                 for (line_i, line) in val_text.split('\n').enumerate() {
1259                     let y = r.1 + Self::CODE_TOP + line_i as f32 * Self::CODE_LINE_H;
1260                     let number = format!("{:>3}", line_i + 1);
1261                     labels.push(TextLabel {
1262                         text: number,
1263                         x: r.0 + 12.0,
1264                         y,
1265                         font_size: 12.0,
1266                         color: if self.code_error_line == Some(line_i) { [0xff, 0x80, 0x70] } else { [0x66, 0x66, 0x78] },
1267                     });
1268                     if line.is_empty() {
1269                         continue;
1270                     }
1271                     labels.push(TextLabel {
1272                         text: line.to_string(),
1273                         x: text_x,
1274                         y,
1275                         font_size: 12.0,
1276                         color: [0xee, 0xee, 0xf0],
1277                     });
1278                 }
1279                 if self.focused_param == Some(i) && self.code_is_dirty() {
1280                     labels.push(TextLabel {
1281                         text: "ctrl+enter applies".to_string(),
1282                         x: r.0 + r.2 - 12.0 - 18.0 * self.code_col_w(),
1283                         y: r.1 + r.3 - 15.0,
1284                         font_size: 12.0,
1285                         color: [0xd8, 0xa0, 0x50],
1286                     });
1287                 }
1288             } else if ptype.starts_with("spinbox") {
1289                 if let Some(sb) = &self.spinboxes[i] {
1290                     labels.extend(sb.own_text_labels());
1291                 }
1292             } else if is_text_row(ptype) {
1293                 if let Some(tb) = &self.texts[i] {
1294                     labels.extend(tb.own_text_labels());
1295                 }
1296                 if let Some(d) = &self.choices[i] {
1297                     labels.extend(d.own_text_labels());
1298                 }
1299             } else if ptype.starts_with("choice") {
1300                 if let Some(d) = &self.choices[i] {
1301                     labels.extend(d.own_text_labels());
1302                 }
1303             } else if ptype == "button" {
1304                 if let Some(b) = &self.buttons[i] {
1305                     labels.extend(b.own_text_labels());
1306                 }
1307             } else if ptype == "toggle" || ptype == "checkbox" {
1308                 if let Some(cb) = &self.toggles[i] {
1309                     labels.extend(cb.own_text_labels());
1310                 }
1311             } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
1312                 if let Some(c) = &self.colors[i] {
1313                     labels.extend(c.own_text_labels());
1314                 }
1315             } else if ptype == SEPARATOR {
1316                 // A rule says nothing.
1317             } else if ptype == "ramp" {
1318                 // The name label only — the ramp's own control labels ride its
1319                 // scene-path paint (paint_scene_rows).
1320                 labels.push(TextLabel {
1321                     text: name.clone(),
1322                     x: r.0,
1323                     y: r.1,
1324                     font_size: 12.0,
1325                     color: [0xaa, 0xaa, 0xbb],
1326                 });
1327             } else {
1328                 labels.push(TextLabel {
1329                     text: format!("{}: {}", name, value),
1330                     x: self.rect.x + ROW_X_INSET,
1331                     y: r.1,
1332                     font_size: 12.0,
1333                     color: [0xaa, 0xaa, 0xbb],
1334                 });
1335             }
1336         }
1337         labels
1338     }
1339 
1340     /// The hosted controls' GLYPHS — a dropdown's or a picker's arrow, a
1341     /// spinbox's −/+ — as `(image, rect, alpha)`, row by row as
1342     /// [`own_text_labels`](Self::own_text_labels) collects their text. The
1343     /// pane paints its controls' chrome itself and never runs their
1344     /// `Paint::paint` into the frame, so a symbol a control draws reaches
1345     /// the pane only through here. Ramps are not asked: they paint whole
1346     /// through `paint_scene_rows`.
1347     fn child_glyphs(&self) -> Vec<(u32, Rect, f32)> {
1348         let hidden = self.hidden_rows();
1349         let mut glyphs = Vec::new();
1350         for (i, (_, _, ptype)) in self.display_params.iter().enumerate() {
1351             if hidden[i] {
1352                 continue;
1353             }
1354             if ptype.starts_with("slider") {
1355                 if let Some(s) = &self.sliders[i] {
1356                     glyphs.extend(s.own_glyphs());
1357                 }
1358             } else if is_vec_row(ptype) {
1359                 if let Some(f) = &self.float3s[i] {
1360                     glyphs.extend(f.own_glyphs());
1361                 }
1362             } else if ptype.starts_with("spinbox") {
1363                 if let Some(sb) = &self.spinboxes[i] {
1364                     glyphs.extend(sb.own_glyphs());
1365                 }
1366             } else if is_text_row(ptype) {
1367                 if let Some(tb) = &self.texts[i] {
1368                     glyphs.extend(tb.own_glyphs());
1369                 }
1370                 if let Some(d) = &self.choices[i] {
1371                     glyphs.extend(d.own_glyphs());
1372                 }
1373             } else if ptype.starts_with("choice") {
1374                 if let Some(d) = &self.choices[i] {
1375                     glyphs.extend(d.own_glyphs());
1376                 }
1377             } else if ptype == "button" {
1378                 if let Some(b) = &self.buttons[i] {
1379                     glyphs.extend(b.own_glyphs());
1380                 }
1381             } else if ptype == "toggle" || ptype == "checkbox" {
1382                 if let Some(cb) = &self.toggles[i] {
1383                     glyphs.extend(cb.own_glyphs());
1384                 }
1385             } else if ptype.starts_with("color") || ptype == "rgb" || ptype == "rgba" {
1386                 if let Some(c) = &self.colors[i] {
1387                     glyphs.extend(c.own_glyphs());
1388                 }
1389             }
1390         }
1391         glyphs
1392     }
1393 
1394     /// Paint [`child_glyphs`](Self::child_glyphs), over the chrome.
1395     fn paint_child_glyphs(&self, ctx: &mut PaintCtx) {
1396         for (image, rect, alpha) in self.child_glyphs() {
1397             ctx.image(image, rect, alpha);
1398         }
1399     }
1400 
1401     /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
1402     /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
1403     /// The ramp rows' field dropdowns count too.
1404     fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1405         for d in self.choices.iter().flatten() {
1406             if let Some(r) = d.popover_rect() {
1407                 return Some(r);
1408             }
1409         }
1410         for rp in self.ramps.iter().flatten() {
1411             let ramp = rp.inner();
1412             if let Some(r) = ramp
1413                 .preset_dropdown
1414                 .popover_rect()
1415                 .or_else(|| ramp.line_type_dropdown.popover_rect())
1416             {
1417                 return Some(r);
1418             }
1419         }
1420         None
1421     }
1422 
1423     /// The full legacy popover reach (choices, then children) — hit-testing extends to it.
1424     fn own_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
1425         if let Some(r) = self.choices_popover_rect() {
1426             return Some(r);
1427         }
1428         None
1429     }
1430 
1431     /// The state half of the legacy `unfocus`: commit the focused row's in-flight value back
1432     /// into `display_params`, drop the code editor, and unfocus the raw children.
1433     fn commit_and_unfocus(&mut self) {
1434         if let Some(idx) = self.focused_param {
1435             if idx < self.display_params.len() {
1436                 let p = &mut self.display_params[idx];
1437                 if p.2.starts_with("spinbox") {
1438                     if let Some(sb) = &mut self.spinboxes[idx] {
1439                         sb.unfocus();
1440                         p.1 = sb.value.to_string();
1441                     }
1442                 } else if p.2.starts_with("slider") {
1443                     if let Some(s) = &mut self.sliders[idx] {
1444                         s.unfocus();
1445                         let new_val = s.get_scaled_value();
1446                         p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
1447                     }
1448                 } else if is_vec_row(&p.2) {
1449                     if let Some(f) = &mut self.float3s[idx] {
1450                         f.unfocus();
1451                         p.1 = f.value_string();
1452                     }
1453                 } else if is_text_row(&p.2) {
1454                     if let Some(tb) = &mut self.texts[idx] {
1455                         tb.unfocus();
1456                         p.1 = tb.text.clone();
1457                     }
1458                     if let Some(d) = &mut self.choices[idx] {
1459                         d.unfocus();
1460                     }
1461                 } else if p.2.starts_with("choice") {
1462                     if let Some(d) = &mut self.choices[idx] {
1463                         d.unfocus();
1464                         if let Some(val) = d.get_value_string() {
1465                             p.1 = val;
1466                         }
1467                     }
1468                 } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1469                     if let Some(c) = &mut self.colors[idx] {
1470                         c.unfocus();
1471                         if let Some(val) = c.get_value_string() {
1472                             p.1 = val;
1473                         }
1474                     }
1475                 } else if p.2 == "code" {
1476                     if let Some(ref editor) = self.code_editor {
1477                         p.1 = editor.buffer.clone();
1478                     }
1479                     self.code_editor = None;
1480                     self.code_history.clear();
1481                 }
1482             }
1483         }
1484         self.focused_param = None;
1485     }
1486 
1487     /// The pane's plain quads: section border boxes, every row's chrome (slider/spinbox
1488     /// backgrounds read via `rect()`+`color()`, the code editor's box/border/cursor), the
1489     /// controls' own plain quads, all clipped to the viewport — plus the unclipped scrollbar.
1490     fn plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1491         if !self.visible {
1492             return Vec::new();
1493         }
1494         let mut quads = Vec::new();
1495         let rects = self.get_param_rects();
1496         let view_min = self.rect.y + 4.0;
1497         let view_max = self.rect.y + self.rect.height - 4.0;
1498 
1499         let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
1500             let (qx, qy, qw, qh, qc) = q;
1501             let y1 = qy.max(view_min);
1502             let y2 = (qy + qh).min(view_max);
1503             if y1 < y2 {
1504                 Some((qx, y1, qw, y2 - y1, qc))
1505             } else {
1506                 None
1507             }
1508         };
1509 
1510         let mut param_quads = Vec::new();
1511 
1512         // Each section is drawn as ONE continuous outline — around the title, down the
1513         // neck, around the content rows — whose straight runs are these quads; its corner
1514         // fillets ride `arcs()`, which the host draws under the same clip. Under
1515         // `control_relief` the outline is replaced by the inset carves served
1516         // through `reliefs` (title tab + content body recessed).
1517         if !crate::layout::control_relief() {
1518             for (title, content) in self.section_boxes() {
1519                 let (runs, _) = self.section_outline(title, content);
1520                 param_quads.extend(runs.into_iter().map(|(x, y, w, h)| (x, y, w, h, SECTION_BORDER_COLOR)));
1521             }
1522         }
1523 
1524         let hidden = self.hidden_rows();
1525         for (i, p) in self.display_params.iter().enumerate() {
1526             if hidden[i] {
1527                 continue;
1528             }
1529             let r = rects[i];
1530             if p.2.starts_with("slider") {
1531                 if let Some(s) = &self.sliders[i] {
1532                     let (sx, sy, sw, sh) = s.rect();
1533                     param_quads.push((sx, sy, sw, sh, s.color()));
1534                     param_quads.extend(crate::widget::shown_quads(s));
1535                 }
1536             } else if p.2 == "section" {
1537                 // Section header line is handled by the border box top border now
1538             } else if p.2 == SEPARATOR {
1539                 // A hairline across the row, inset from the pane's edges.
1540                 let c = crate::colors::active_theme().surface_border;
1541                 param_quads.push((r.0 + 6.0, r.1, (r.2 - 12.0).max(0.0), 1.0, [c[0], c[1], c[2], c[3] * 0.8]));
1542             } else if is_vec_row(&p.2) {
1543                 if let Some(f) = &self.float3s[i] {
1544                     param_quads.extend(crate::widget::shown_quads(f));
1545                 }
1546             } else if p.2 == "code" {
1547                 let (bx, by, bw, bh) = (r.0, r.1 + Self::CODE_BOX_TOP, r.2, r.3 - Self::CODE_BOX_TOP);
1548                 param_quads.push((bx, by, bw, bh, [0.08, 0.08, 0.10, 1.0]));
1549                 let focused = self.focused_param == Some(i);
1550                 // Amber while edits are pending, blue while focused and
1551                 // applied, grey at rest: the border says whether what the
1552                 // node runs is what the box shows.
1553                 let border_color = if focused && self.code_is_dirty() {
1554                     [0.85, 0.60, 0.25, 1.0]
1555                 } else if focused {
1556                     [0.25, 0.45, 0.85, 1.0]
1557                 } else {
1558                     [0.20, 0.20, 0.25, 1.0]
1559                 };
1560                 let text_x = self.code_text_x(r);
1561                 let col_w = self.code_col_w();
1562                 let line_y = |l: usize| by + (Self::CODE_TOP - Self::CODE_BOX_TOP) + l as f32 * Self::CODE_LINE_H;
1563                 // The gutter's edge.
1564                 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]));
1565                 // The error band, under the flagged line.
1566                 if let Some(err_line) = self.code_error_line {
1567                     let y = line_y(err_line);
1568                     if y >= by && y + Self::CODE_LINE_H <= by + bh {
1569                         param_quads.push((bx + 1.0, y, bw - 2.0, Self::CODE_LINE_H, [0.45, 0.12, 0.10, 1.0]));
1570                     }
1571                 }
1572                 if focused {
1573                     if let Some(ref editor) = self.code_editor {
1574                         // Selection: one band per line it covers.
1575                         if let Some((start, end)) = editor.selected_range() {
1576                             let (sl, sc) = get_cursor_line_col(&editor.buffer, start);
1577                             let (el, ec) = get_cursor_line_col(&editor.buffer, end);
1578                             let line_len = |l: usize| editor.buffer.split('\n').nth(l).map_or(0, |s| s.chars().count());
1579                             for l in sl..=el {
1580                                 let c0 = if l == sl { sc } else { 0 };
1581                                 let c1 = if l == el { ec } else { line_len(l) + 1 };
1582                                 let y = line_y(l);
1583                                 if y >= by && y + Self::CODE_LINE_H <= by + bh && c1 > c0 {
1584                                     param_quads.push((
1585                                         text_x + c0 as f32 * col_w,
1586                                         y,
1587                                         (c1 - c0) as f32 * col_w,
1588                                         Self::CODE_LINE_H,
1589                                         [0.22, 0.32, 0.55, 1.0],
1590                                     ));
1591                                 }
1592                             }
1593                         }
1594                         let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
1595                         let cursor_x = text_x + (cursor_c as f32 * col_w);
1596                         let cursor_y = line_y(cursor_l) + (Self::CODE_LINE_H - 13.0) / 2.0;
1597                         if cursor_y >= by && cursor_y + 13.0 <= by + bh {
1598                             param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
1599                         }
1600                     }
1601                 }
1602                 param_quads.push((bx, by, bw, 1.0, border_color));
1603                 param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
1604                 param_quads.push((bx, by, 1.0, bh, border_color));
1605                 param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
1606             } else if is_text_row(&p.2) {
1607                 if let Some(tb) = &self.texts[i] {
1608                     param_quads.extend(crate::widget::shown_quads(tb));
1609                 }
1610                 if let Some(d) = &self.choices[i] {
1611                     param_quads.extend(crate::widget::shown_quads(d));
1612                 }
1613             } else if p.2.starts_with("choice") {
1614                 if let Some(d) = &self.choices[i] {
1615                     param_quads.extend(crate::widget::shown_quads(d));
1616                 }
1617             } else if p.2 == "button" {
1618                 if let Some(b) = &self.buttons[i] {
1619                     param_quads.extend(crate::widget::shown_quads(b));
1620                 }
1621             } else if p.2.starts_with("spinbox") {
1622                 if let Some(sb) = &self.spinboxes[i] {
1623                     { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
1624                     param_quads.extend(crate::widget::shown_quads(sb));
1625                 }
1626             } else if p.2 == "toggle" || p.2 == "checkbox" {
1627                 if let Some(cb) = &self.toggles[i] {
1628                     param_quads.extend(crate::widget::shown_quads(cb));
1629                 }
1630             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1631                 if let Some(c) = &self.colors[i] {
1632                     param_quads.extend(crate::widget::shown_quads(c));
1633                 }
1634             }
1635         }
1636 
1637         // Clip all parameter quads vertically
1638         for q in param_quads {
1639             if let Some(clipped) = clip_quad(q) {
1640                 quads.push(clipped);
1641             }
1642         }
1643 
1644         // The scrollbar is NOT emitted here: the host draws it via `scrollbar_quads`, above
1645         // or below the pane plate depending on `scrollbar_active`.
1646 
1647         quads
1648     }
1649 
1650     /// The rounded companion to [`Self::plain_quads`]: the row controls whose boxes are
1651     /// `Prim::RoundedRect` (textbox, dropdown, button, toggle, color selector), which the
1652     /// plain list does not carry. Returned unclipped; the pane's paint clips them to its
1653     /// scroll viewport. (x, y, w, h, radius, color, (tl, tr, br, bl)).
1654     pub fn rounded_quads(
1655         &self,
1656     ) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
1657         if !self.visible {
1658             return Vec::new();
1659         }
1660         let mut out = Vec::new();
1661         let hidden = self.hidden_rows();
1662         for (i, p) in self.display_params.iter().enumerate() {
1663             if hidden[i] {
1664                 continue;
1665             }
1666             if is_text_row(&p.2) {
1667                 if let Some(tb) = &self.texts[i] {
1668                     out.extend(crate::widget::shown_rounded_quads(tb));
1669                 }
1670                 if let Some(d) = &self.choices[i] {
1671                     out.extend(crate::widget::shown_rounded_quads(d));
1672                 }
1673             } else if p.2.starts_with("slider") {
1674                 // Track (square style only — the recessed style has no track
1675                 // background), value fill, and readout box; the thumb knob is a
1676                 // `Prim::Sphere` and rides `spheres()` instead.
1677                 if let Some(s) = &self.sliders[i] {
1678                     out.extend(crate::widget::shown_rounded_quads(s));
1679                 }
1680             } else if is_vec_row(&p.2) {
1681                 // Three slider rows: the same set per row (readout boxes,
1682                 // square-style tracks and fills), through the group's own paint.
1683                 if let Some(f) = &self.float3s[i] {
1684                     out.extend(crate::widget::shown_rounded_quads(f));
1685                 }
1686             } else if p.2.starts_with("choice") {
1687                 if let Some(d) = &self.choices[i] {
1688                     out.extend(crate::widget::shown_rounded_quads(d));
1689                 }
1690             } else if p.2.starts_with("spinbox") {
1691                 // The spinbox's whole chrome (frame, display well, +/- button
1692                 // wells) is modern rounded-rect paint — its legacy color() is
1693                 // transparent and it has no extra_quads, so skipping it here
1694                 // renders the row as bare text.
1695                 if let Some(sb) = &self.spinboxes[i] {
1696                     out.extend(crate::widget::shown_rounded_quads(sb));
1697                 }
1698             } else if p.2 == "button" {
1699                 if let Some(b) = &self.buttons[i] {
1700                     out.extend(crate::widget::shown_rounded_quads(b));
1701                 }
1702             } else if p.2 == "toggle" || p.2 == "checkbox" {
1703                 if let Some(cb) = &self.toggles[i] {
1704                     out.extend(crate::widget::shown_rounded_quads(cb));
1705                 }
1706             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1707                 if let Some(c) = &self.colors[i] {
1708                     out.extend(crate::widget::shown_rounded_quads(c));
1709                 }
1710             }
1711         }
1712         out
1713     }
1714 
1715     /// The relief companion to [`Self::rounded_quads`]: the row controls'
1716     /// raised/recessed step prims (boss rims, recess wells), which the flat
1717     /// views cannot carry — a host rendering this panel through the legacy
1718     /// views must read this getter too or the `control_relief` styling is lost
1719     /// entirely (with the DE's transparent control backgrounds the controls all
1720     /// but vanish). Edges-only variants throughout: the flat views own the
1721     /// faces, exactly the widgets' own transparent-fill bevel→boss degradation.
1722     /// Returned unclipped; the host clips to the pane's scroll viewport and
1723     /// draws these AFTER the flat quads, so the walls' shading modulates the
1724     /// fills they cross (the order the widgets' own paints use). Tuple:
1725     /// (x, y, w, h, per-corner radii, depth, raised, walls) — raised maps to
1726     /// `PaintCtx::boss_edges`, flat to `recess_edges`; a toggle's well and
1727     /// the plate standing in it are why radii/walls are per-entry.
1728     #[allow(clippy::type_complexity)]
1729     pub fn reliefs(
1730         &self,
1731     ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, bool, (bool, bool, bool, bool))> {
1732         if !self.visible || !crate::layout::control_relief() {
1733             return Vec::new();
1734         }
1735         let mut out = Vec::new();
1736 
1737         // Sections as inset panels (the flat outline+fillet path is the
1738         // non-relief style): ONE union-shaped recess per section — a
1739         // title-text-width tab strip flush with the body's left edge, opening
1740         // into the full-width body below. Composed from three edge-suppressed
1741         // pieces (the tab with its bottom open, the body with its top open,
1742         // and the top-wall run right of the tab's throat) so no wall crosses
1743         // the union's interior and the whole section reads as a single well.
1744         // Collapsed sections keep the title-box carve.
1745         let all = (true, true, true, true);
1746         let r4 = |r: f32| (r, r, r, r);
1747         let r = SECTION_R;
1748         for (title, content) in self.section_boxes() {
1749             let (tx, ty, tw, th) = title;
1750             if let Some((cx, cy, cw, ch)) = content {
1751                 // Capped at the channel: the well's wall must roll off inside the
1752                 // groove between it and the controls packed one CHANNEL inside,
1753                 // not shade across their faces. `section_depth` scales wall and
1754                 // channel cap together — past 1.0 the roll crosses the groove
1755                 // by choice.
1756                 let depth = section_carve_depth(ch);
1757                 // Tab strip: the title box itself, sitting flush on the body's top
1758                 // edge (the header row above it stays plain plate), bottom open.
1759                 // A wall FADES OUT over the carve width approaching a suppressed
1760                 // edge (the tessellator's host fade — meant for carves flush with
1761                 // a real plate edge), so every piece extends past its interior
1762                 // seam by `depth`: its fade-out then crossfades with the
1763                 // neighbor's fade-in instead of both dying AT the seam (which
1764                 // notched the walls there; found the hard way).
1765                 let throat_r = tx + tw;
1766                 let rho = SECTION_FILLET_R;
1767                 // Right of the throat, ONE piece owns the whole right run —
1768                 // top wall, top-right arc, right wall, bottom-right arc — so
1769                 // both right corners are real turns. (The old top-run +
1770                 // full-body split put the top wall and the right wall in
1771                 // different pieces; each faded out at the seam and the
1772                 // top-right corner rendered square.) A left piece carries the
1773                 // left wall and the bottom-left arc; the two bottom runs
1774                 // crossfade under the seam at the right piece's left edge.
1775                 let body_lr = |x_run: f32, out: &mut Vec<_>| {
1776                     out.push((x_run, cy, cx + cw - x_run, ch, (0.0, r, r, 0.0), depth, false, (true, true, true, false)));
1777                     out.push((cx, cy, x_run + depth - cx, ch, (0.0, 0.0, 0.0, r), depth, false, (false, false, true, true)));
1778                 };
1779                 if cx + cw > throat_r + 2.0 * rho {
1780                     // Filleted throat ([`Self::section_fillets`]): the tab's
1781                     // right wall must END at the fillet's vertical tangent
1782                     // (crossfading out under the arc) or its straight run
1783                     // ghosts through the curve — so the tab piece stops there,
1784                     // and a left-only bridge carries the left wall across the
1785                     // fillet span down to the body's own fade-in.
1786                     out.push((tx, ty, tw, (cy - rho) - ty + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1787                     out.push((tx, cy - rho, tw, rho + depth, (0.0, 0.0, 0.0, 0.0), depth, false, (false, false, false, true)));
1788                     body_lr(throat_r + rho - depth, &mut out);
1789                 } else {
1790                     // Too narrow for the fillet: the plain square throat.
1791                     out.push((tx, ty, tw, th + depth, (r, r, 0.0, 0.0), depth, false, (true, true, false, true)));
1792                     if cx + cw > throat_r + 0.5 {
1793                         body_lr(throat_r - depth, &mut out);
1794                     } else {
1795                         // The tab spans the body: no top wall at all.
1796                         out.push((cx, cy, cw, ch, (0.0, 0.0, r, r), depth, false, (false, true, true, true)));
1797                     }
1798                 }
1799             } else {
1800                 let depth = section_carve_depth(th);
1801                 out.push((tx, ty, tw, th, r4(SECTION_R), depth, false, all));
1802             }
1803         }
1804 
1805         let hidden = self.hidden_rows();
1806         for (i, p) in self.display_params.iter().enumerate() {
1807             if hidden[i] {
1808                 continue;
1809             }
1810             // (control, its configured corner radius, raised vs recessed)
1811             let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
1812                 // The textpick picker is NOT in this list: with it the row is
1813                 // a field ([`Self::fields`]) — a well ending in a flush run.
1814                 self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
1815             } else if p.2.starts_with("choice") {
1816                 // The dropdown trigger is a FLUSH control, a field that is all
1817                 // run ([`Self::fields`]). A boss here read as a raised island
1818                 // the widget itself never draws.
1819                 None
1820             } else if p.2 == "button" {
1821                 // A flush control with a field run's edge, as its own paint
1822                 // draws it ([`Self::fields`]) — it was a boss here, a raised
1823                 // island the button itself never drew.
1824                 None
1825             } else if p.2 == "toggle" || p.2 == "checkbox" {
1826                 // A toggle is a field ([`Self::fields`]): its well and the
1827                 // flush run gliding in it, one outline round both.
1828                 None
1829             } else if p.2.starts_with("spinbox") {
1830                 // A plain well only when there is no -/+ run; with one the
1831                 // control is a field ([`Self::fields`]) and its -/+ seam a
1832                 // groove ([`Self::grooves`]). Same side-label inset, same
1833                 // content band, same depth cap as its paint.
1834                 if let Some(sb) = &self.spinboxes[i] {
1835                     let (x, y, w, h) = sb.rect();
1836                     let ty = sb.label_strip();
1837                     let band = Rect { x, y: y + ty, width: w, height: h - ty };
1838                     if w > 0.0 && h > 0.0 {
1839                         let r = crate::layout::spinbox_corner_radius();
1840                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1841                         // With its -/+ run the control is a field
1842                         // ([`Self::fields`]); without, a plain well.
1843                         if let Some(rel) = sb.inner().relief_parts(band) {
1844                             if rel.run.is_none() {
1845                                 out.push((x, y + ty, w, h - ty, r4(r), depth, false, all));
1846                             }
1847                         }
1848                     }
1849                 }
1850                 None
1851             } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
1852                 // The control's one well (`ColorSelector::field_relief`, the
1853                 // same geometry its paint carves); the swatch is a fill on its
1854                 // floor and the seam a groove, both on the widget's paint.
1855                 if let Some(c) = &self.colors[i] {
1856                     let (x, y, w, h) = c.rect();
1857                     let ty = c.label_strip();
1858                     if let Some((rx, ry, rw, rh, rr, rd)) =
1859                         c.inner().field_relief(Rect { x, y: y + ty, width: w, height: h - ty })
1860                     {
1861                         out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
1862                     }
1863                 }
1864                 None
1865             } else {
1866                 // Sliders (and Float3's three rows) are bands: their well is
1867                 // hand-shaded quads that follow the band's contour, which reach
1868                 // a flat host through the plain-quad view — no rect carve.
1869                 None
1870             };
1871             if let Some((w, radius, raised)) = ctl {
1872                 let (x, y, ww, h) = w.rect();
1873                 if ww <= 0.0 || h <= 0.0 {
1874                     continue;
1875                 }
1876                 // The top-label band stays outside the relief like every other host.
1877                 let ty = w.label_strip();
1878                 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1879                 // A text box joined to its picker is half of a field
1880                 // ([`Self::fields`]), drawn there.
1881                 if is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right) {
1882                     continue;
1883                 }
1884                 out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
1885             }
1886         }
1887         out
1888     }
1889 
1890     /// The rows that are ONE field with a run ([`Field`]: a sunken well
1891     /// holding a flush run, one outline round both), drawn AFTER
1892     /// [`Self::reliefs`]. A textpick row is its text box's well ending in its
1893     /// picker, a spinbox its value's well ending in its -/+ run
1894     /// ([`Field::ending_in_run`]); a dropdown or button row, a field that is
1895     /// all run ([`Field::run`]); a toggle, its own sliding field
1896     /// (`Toggle::field`). So every flush control in the pane has the same
1897     /// edge. The plain wells are not here — they are [`Self::reliefs`], which
1898     /// group into the pane's plate. They replaced
1899     /// `troughs()` on 2026-10-01. On the pane's own rule for wells and
1900     /// troughs: the content band as the outline, the wall straddling it.
1901     #[allow(clippy::type_complexity)]
1902     pub fn fields(&self) -> Vec<Field> {
1903         if !self.visible || !crate::layout::control_relief() {
1904             return Vec::new();
1905         }
1906         let mut out = Vec::new();
1907         let hidden = self.hidden_rows();
1908         for (i, p) in self.display_params.iter().enumerate() {
1909             if hidden[i] {
1910                 continue;
1911             }
1912             if p.2.starts_with("textpick") {
1913                 if let (Some(tb), Some(d)) = (&self.texts[i], &self.choices[i]) {
1914                     if !tb.inner().joined_right {
1915                         continue;
1916                     }
1917                     let (x, y, w, h) = tb.rect();
1918                     let (dx, _, dw, _) = d.rect();
1919                     let ty = tb.label_strip();
1920                     if w > 0.0 && h - ty > 0.0 && dw > 0.0 {
1921                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1922                         let r = crate::layout::textbox_corner_radius();
1923                         let outline = Rect { x, y: y + ty, width: dx + dw - x, height: h - ty };
1924                         out.push(Field::ending_in_run(outline, (r, r, r, r), depth, dx));
1925                     }
1926                 }
1927             } else if p.2 == "button" {
1928                 // A button: a field that is all run, as its own flush plate
1929                 // (`Button::plate`, `PaintCtx::inset_plate`) on its
1930                 // band, at the button radius.
1931                 if let Some(b) = &self.buttons[i] {
1932                     let (x, y, w, h) = b.rect();
1933                     let ty = b.label_strip();
1934                     if w > 0.0 && h - ty > 0.0 {
1935                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1936                         let r = crate::layout::button_corner_radius();
1937                         out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
1938                     }
1939                 }
1940             } else if p.2.starts_with("choice") {
1941                 // The dropdown trigger: a field that is all run — the
1942                 // widget's own raised paint (`PaintCtx::inset_plate`)
1943                 // on the same band, radius and depth cap. Until 2026-10-01
1944                 // a trough, whose edge differed from the run's at the end of
1945                 // a text row's field.
1946                 if let Some(d) = &self.choices[i] {
1947                     let (x, y, w, h) = d.rect();
1948                     let ty = d.label_strip();
1949                     if w > 0.0 && h - ty > 0.0 {
1950                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
1951                         let r = crate::layout::dropdown_corner_radius();
1952                         out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
1953                     }
1954                 }
1955             } else if p.2.starts_with("spinbox") {
1956                 if let Some(sb) = &self.spinboxes[i] {
1957                     let (x, y, w, h) = sb.rect();
1958                     let ty = sb.label_strip();
1959                     let band = Rect { x, y: y + ty, width: w, height: h - ty };
1960                     if let Some(rel) = sb.inner().relief_parts(band) {
1961                         if let Some((split, _)) = rel.run {
1962                             let r = rel.radius;
1963                             out.push(Field::ending_in_run(band, (r, r, r, r), rel.depth, split));
1964                         }
1965                     }
1966                 }
1967             } else if p.2 == "toggle" || p.2 == "checkbox" {
1968                 // The toggle's own field (`Toggle::field`) —
1969                 // exactly what its paint draws, on its band. It paints no
1970                 // fill, so this IS the control.
1971                 if let Some(t) = &self.toggles[i] {
1972                     let (x, y, w, h) = t.rect();
1973                     let ty = t.label_strip();
1974                     if w > 0.0 && h - ty > 0.0 {
1975                         // The pane's hover is the tint here, as on every field.
1976                         let band = Rect { x, y: y + ty, width: w, height: h - ty };
1977                         out.push(t.inner().field(band).with_tint(None));
1978                     }
1979                 }
1980             }
1981         }
1982         out
1983     }
1984 
1985     /// The engraved seams companion — `(a, b, width, depth, host)` for
1986     /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::fields`]
1987     /// (a groove engraves the surface the trough's control face provides).
1988     /// Today: the seam dividing a spinbox's -/+ run into its two buttons —
1989     /// the breadcrumb's segment-seam language at miniature scale.
1990     #[allow(clippy::type_complexity)]
1991     pub fn grooves(&self) -> Vec<((f32, f32), (f32, f32), f32, f32, Rect)> {
1992         if !self.visible || !crate::layout::control_relief() {
1993             return Vec::new();
1994         }
1995         let mut out = Vec::new();
1996         let hidden = self.hidden_rows();
1997         for (i, p) in self.display_params.iter().enumerate() {
1998             if hidden[i] || !p.2.starts_with("spinbox") {
1999                 continue;
2000             }
2001             if let Some(sb) = &self.spinboxes[i] {
2002                 let (x, y, w, h) = sb.rect();
2003                 let ty = sb.label_strip();
2004                 let band = Rect { x, y: y + ty, width: w, height: h - ty };
2005                 if let Some(rel) = sb.inner().relief_parts(band) {
2006                     if let Some((_, (sa, sb2, sw, host))) = rel.run {
2007                         out.push((sa, sb2, sw, rel.depth, host));
2008                     }
2009                 }
2010             }
2011         }
2012         out
2013     }
2014 
2015     /// The knobs a flat host draws after [`Self::reliefs`] — none: the sliders
2016     /// are bands (their swell is part of the band's own quads), so this is kept
2017     /// only for the hosts that still call it.
2018     pub fn spheres(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
2019         Vec::new()
2020     }
2021 
2022     /// The section carves' concave inside-corner fillets — `(cx, cy, radius,
2023     /// depth, start angle)` for [`crate::scene::paint::PaintCtx::concave_fillet`]
2024     /// (recessed), drawn by the host AFTER [`Self::reliefs`]. The box reliefs
2025     /// can only round convex corners; this rounds the throat where a tab's
2026     /// right wall turns onto its body's top edge.
2027     pub fn section_fillets(&self) -> Vec<(f32, f32, f32, f32, f32)> {
2028         if !self.visible || !crate::layout::control_relief() {
2029             return Vec::new();
2030         }
2031         let mut out = Vec::new();
2032         for (title, content) in self.section_boxes() {
2033             let (tx, _ty, tw, _th) = title;
2034             if let Some((cx, cy, cw, ch)) = content {
2035                 let depth = section_carve_depth(ch);
2036                 let throat_r = tx + tw;
2037                 if cx + cw > throat_r + 2.0 * SECTION_FILLET_R {
2038                     out.push((
2039                         throat_r + SECTION_FILLET_R,
2040                         cy - SECTION_FILLET_R,
2041                         SECTION_FILLET_R,
2042                         depth,
2043                         std::f32::consts::FRAC_PI_2,
2044                     ));
2045                 }
2046             }
2047         }
2048         out
2049     }
2050 
2051     /// The scene-path companion to the legacy views: rows whose widgets paint
2052     /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
2053     /// circles, and field controls). A host rendering this panel through the
2054     /// legacy hatches calls this with its own `PaintCtx` inside the pane's
2055     /// scroll clip, after the flat chrome — or the rows draw as bare labels.
2056     /// Whether a row value changed inside `tick` since the last call
2057     /// (see `tick_value_changed`); clears the flag.
2058     pub fn take_tick_value_change(&mut self) -> bool {
2059         std::mem::take(&mut self.tick_value_changed)
2060     }
2061 
2062     pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
2063         if !self.visible {
2064             return;
2065         }
2066         let hidden = self.hidden_rows();
2067         let dummy = UiContext::new();
2068         for (i, p) in self.display_params.iter().enumerate() {
2069             if hidden[i] {
2070                 continue;
2071             }
2072             if p.2 == "ramp" {
2073                 if let Some(rp) = &self.ramps[i] {
2074                     rp.paint_self(&dummy, pc);
2075                 }
2076             } else if let Some(f) = self.float3s[i].as_ref().filter(|f| f.has_trackball()) {
2077                 // A float3's trackball: a sphere is not a prim the flat
2078                 // views carry, so it rides the scene path like the ramp.
2079                 f.paint_ball(pc);
2080             }
2081         }
2082     }
2083 
2084 }
2085 
2086 impl Layout for ParametersBg {
2087     /// Ungated rect landing (the legacy `set_rect` head, before its visibility gate): cache the
2088     /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
2089     fn rect_assigned(&mut self, rect: Rect) {
2090         self.rect = rect;
2091         self.refresh_scroll_metrics();
2092     }
2093 
2094 }
2095 
2096 impl Paint for ParametersBg {
2097     /// Shape the hosted controls (their carets read per-glyph advances nothing
2098     /// else records for children of a container) and one code column's advance
2099     /// from the same monospace@12 path the code rows draw through.
2100     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
2101         for tb in self.texts.iter_mut().flatten() {
2102             tb.prepare_text(fs);
2103         }
2104         for sb in self.spinboxes.iter_mut().flatten() {
2105             sb.prepare_text(fs);
2106         }
2107         for c in self.colors.iter_mut().flatten() {
2108             c.prepare_text(fs);
2109         }
2110         let clusters = crate::backend::text::shaped_cluster_offsets(
2111             fs,
2112             "MMMMMMMM",
2113             12.0,
2114             Some("monospace"),
2115         );
2116         if let Some(&(_, total)) = clusters.last() {
2117             if total > 0.0 {
2118                 self.code_char_advance = total / 8.0;
2119             }
2120         }
2121     }
2122 
2123     /// The panel IS its own background plate (the host draws it from `color()` + the corner
2124     /// style via `append_widget_plate`) — there is no separate plate widget behind it, so
2125     /// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
2126     /// with the alpha negated as the scenefx blur marker when plate blur is on. Transparent
2127     /// while hidden. It must not ALSO be emitted as a quad anywhere or it would double-blend.
2128     fn color(&self) -> [f32; 4] {
2129         if !self.visible {
2130             return [0.0, 0.0, 0.0, 0.0];
2131         }
2132         colors::param_plate_fill()
2133     }
2134 
2135     /// The shared plate corner radius (rounded on all four corners when non-zero).
2136     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
2137         let r = crate::layout::plate_corner_radius();
2138         let on = r > 0.0;
2139         Some((r, (on, on, on, on)))
2140     }
2141 
2142     /// The shared plate border (folded in from the retired backing plate widget).
2143     fn solid_border(&self) -> Option<([f32; 4], f32)> {
2144         colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
2145     }
2146 
2147     fn widget_font(&self) -> Option<String> {
2148         Some(crate::layout::control_label_font())
2149     }
2150 
2151     /// The COMPLETE row chrome — everything the legacy hatches carry, in the hosts' canonical
2152     /// draw order: the controls' rounded wells, the flat-style section outline fillets, the
2153     /// relief steps (after the fills so the walls shade what they cross), the section carves'
2154     /// concave throat fillets, the slider-thumb spheres, the scene-path rows, the flat
2155     /// plain-quad chrome, then the hosted controls' glyphs (arrows, −/+) over it. What stays OUT, deliberately: the background plate (see
2156     /// [`color`](Paint::color)), the scrollbar (hosts place its depth — the designer straddles
2157     /// it around the pane plate), and text (`paint_self`'s own-labels bridge carries the
2158     /// per-row fonts and code-box bounds). Hosts clip this to their pane viewport — a rect
2159     /// clip pushed here would not survive `paint_self`'s replay.
2160     fn paint_ui(&self, _ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
2161         if !self.visible {
2162             return;
2163         }
2164         self.claim_typing(ctx);
2165         self.paint_row_floors(ctx);
2166         for (qx, qy, qw, qh, qr, qc, corners) in self.rounded_quads() {
2167             ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
2168         }
2169         for (acx, acy, ar, at, a0, a1, ac) in self.arcs() {
2170             ctx.arc(acx, acy, ar, at, a0, a1, ac);
2171         }
2172         // The hovered row's carves are TINTED — the focus treatment's channel
2173         // with a neutral light instead of the accent: the rim's own light and
2174         // shadow, recoloured and gained, so the control under the pointer
2175         // reads lit while every other carve keeps the plate's grouped shading.
2176         // A tinted carve shades through the overlay path by design, and a
2177         // section's well spans many rows, so it never qualifies.
2178         let hover_rect = self.hover_row.and_then(|i| self.get_param_rects().get(i).copied()).filter(|r| r.3 > 0.0);
2179         let hovered = |x: f32, y: f32, w: f32, h: f32| {
2180             hover_rect.is_some_and(|(rx, ry, rw, rh)| {
2181                 x >= rx - 0.5 && y >= ry - 0.5 && x + w <= rx + rw + 0.5 && y + h <= ry + rh + 0.5
2182             })
2183         };
2184         for (rx, ry, rw, rh, radii, rd, raised, edges) in self.reliefs() {
2185             let rect = Rect { x: rx, y: ry, width: rw, height: rh };
2186             match (raised, hovered(rx, ry, rw, rh)) {
2187                 (true, true) => ctx.boss_edges_tinted(rect, radii, rd, edges, Self::HOVER_TINT),
2188                 (true, false) => ctx.boss_edges(rect, radii, rd, edges),
2189                 (false, true) => ctx.recess_edges_tinted(rect, radii, rd, edges, Self::HOVER_TINT),
2190                 (false, false) => ctx.recess_edges(rect, radii, rd, edges),
2191             }
2192         }
2193         for field in self.fields() {
2194             let r = field.rect;
2195             let tint = hovered(r.x, r.y, r.width, r.height).then_some(Self::HOVER_TINT);
2196             ctx.field(&field.with_tint(tint));
2197         }
2198         for (ga, gb, gw, gd, ghost) in self.grooves() {
2199             ctx.groove(ga, gb, gw, gd, ghost);
2200         }
2201         for (fcx, fcy, fr, fd, fs) in self.section_fillets() {
2202             ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
2203         }
2204         for (scx, scy, sr, sc) in self.spheres() {
2205             ctx.sphere(scx, scy, sr, &crate::scene::material::Material::from_fill(sc));
2206         }
2207         self.paint_scene_rows(ctx);
2208         for (qx, qy, qw, qh, qc) in self.plain_quads() {
2209             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2210         }
2211         self.paint_child_glyphs(ctx);
2212     }
2213 
2214     /// Ui-less emission (the [`paint_ui`](Paint::paint_ui) override above is what `paint_self`
2215     /// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
2216     /// plate stays out — see [`color`](Paint::color).
2217     fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
2218         self.claim_typing(ctx);
2219         for (qx, qy, qw, qh, qc) in self.plain_quads() {
2220             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2221         }
2222         self.paint_scene_rows(ctx);
2223         self.paint_child_glyphs(ctx);
2224         // Scene-path hosts get the scrollbar on top (the designer instead straddles it around
2225         // the pane plate through `scrollbar_quads`).
2226         for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
2227             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
2228         }
2229         let view_min = self.rect.y + 4.0;
2230         let view_max = self.rect.y + self.rect.height - 4.0;
2231         // The y test above culls the scrolled-away rows; it is the pane's
2232         // WIDTH that nothing enforced, so a long parameter name ran out of the
2233         // pane sideways.
2234         let pane = Some([
2235             self.rect.x,
2236             self.rect.y,
2237             self.rect.x + self.rect.width,
2238             self.rect.y + self.rect.height,
2239         ]);
2240         for l in self.own_text_labels() {
2241             if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
2242                 ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, pane);
2243             }
2244         }
2245     }
2246 
2247     fn serves_legacy_labels(&self) -> bool {
2248         true
2249     }
2250 
2251     /// The legacy `text_labels_with_font_and_bounds` body: every label clipped to the panel
2252     /// viewport in the control-label font, except labels inside a code row — those clip to the
2253     /// code box (or hide when it's scrolled out) and render monospace.
2254     fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
2255         let view_min = self.rect.y + 4.0;
2256         let view_max = self.rect.y + self.rect.height - 4.0;
2257         let mut result = Vec::new();
2258         let font = Paint::widget_font(self);
2259         let rects = self.get_param_rects();
2260         for l in self.own_text_labels() {
2261             if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
2262                 continue;
2263             }
2264             let mut bounds = Some([self.rect.x + 4.0, view_min, self.rect.x + self.rect.width - 4.0, view_max]);
2265             let mut label_font = font.clone();
2266             for (i, p) in self.display_params.iter().enumerate() {
2267                 if p.2 == "code" {
2268                     let r = rects[i];
2269                     if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
2270                         let code_min = (r.1 + 19.0).max(view_min);
2271                         let code_max = (r.1 + r.3 - 1.0).min(view_max);
2272                         if code_min < code_max {
2273                             bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
2274                         } else {
2275                             bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
2276                         }
2277                         label_font = Some("monospace".to_string());
2278                         break;
2279                     }
2280                 }
2281             }
2282             result.push((l, label_font, bounds));
2283         }
2284         result
2285     }
2286 
2287     fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
2288         self.choices_popover_rect()
2289     }
2290 
2291     /// The dropdown rows' popovers; the raw children's are the adapter's recursion.
2292     fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
2293         for d in self.choices.iter().flatten() {
2294             d.render_popover(pc);
2295         }
2296         for rp in self.ramps.iter().flatten() {
2297             rp.inner().preset_dropdown.render_popover(pc);
2298             rp.inner().line_type_dropdown.render_popover(pc);
2299         }
2300     }
2301 }
2302 
2303 impl Input for ParametersBg {
2304     /// While a code row is being edited, the clipboard, selection and
2305     /// history actions are the editor's — the runner routes the undo / redo
2306     /// chords here before the app's own history gets them.
2307     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
2308         if self.code_editing() {
2309             return self.code_action(action);
2310         }
2311         false
2312     }
2313 
2314     fn scrollable(&self) -> bool {
2315         true
2316     }
2317 
2318     /// Legacy `mouse_input` saw every press (and consumes every left press — the designer
2319     /// relies on the panel swallowing clicks anywhere while it's the dispatch target).
2320     fn gates_presses(&self) -> bool {
2321         false
2322     }
2323 
2324     /// Legacy hit reach: the panel rect, or an open popover (a dropdown row's list extends
2325     /// below the panel).
2326     fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
2327         if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
2328             return true;
2329         }
2330         if let Some((pop_x, pop_y, pop_w, pop_h)) = self.own_popover_rect() {
2331             if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
2332                 return true;
2333             }
2334         }
2335         false
2336     }
2337 
2338 
2339     // --- The host-driven drag surface (the designer routes pointer drags here directly). ---
2340 
2341     fn draggable(&self, _rect: Rect) -> bool {
2342         self.scrollbar_dragging
2343             || self.dragging_param.is_some()
2344             || self.display_params.iter().any(|p| p.2.starts_with("slider") || is_vec_row(&p.2))
2345     }
2346 
2347     fn is_dragging(&self) -> bool {
2348         self.scrollbar_dragging || self.dragging_param.is_some()
2349     }
2350 
2351     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
2352         if self.scrollbar_dragging {
2353             return;
2354         }
2355         let rects = self.get_param_rects();
2356         for (i, p) in self.display_params.iter().enumerate() {
2357             if p.2.starts_with("slider") {
2358                 let r = rects[i];
2359                 if let Some(s) = &mut self.sliders[i] {
2360                     let top = s.label_strip();
2361                     if py >= r.1 + top && py <= r.1 + r.3 {
2362                         s.drag_begin(px, py);
2363                         self.dragging_param = Some(i);
2364                         break;
2365                     }
2366                 }
2367             } else if is_vec_row(&p.2) {
2368                 let r = rects[i];
2369                 if py >= r.1 && py <= r.1 + r.3 {
2370                     if let Some(f) = &mut self.float3s[i] {
2371                         let mut dummy = crate::context::UiContext::new();
2372                         if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
2373                             self.dragging_param = Some(i);
2374                             break;
2375                         }
2376                     }
2377                 }
2378             }
2379         }
2380     }
2381 
2382     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
2383         if self.scrollbar_dragging {
2384             let sb_track_h = self.rect.height - 8.0;
2385             let sb_track_y = self.rect.y + 4.0;
2386             let visible_ratio = self.rect.height / self.content_h;
2387             let thumb_h = if sb_track_h <= 20.0 {
2388                 sb_track_h
2389             } else {
2390                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2391             };
2392             let max_scroll = (self.content_h - self.rect.height).max(0.0);
2393 
2394             let target_thumb_y = py - self.drag_offset_y;
2395             let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2396                 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2397             } else {
2398                 0.0
2399             };
2400 
2401             let old_scroll = self.scroll_y;
2402             self.scroll_y = new_scroll_ratio * max_scroll;
2403             if (self.scroll_y - old_scroll).abs() > 0.01 {
2404                 self.update_slider_rects();
2405                 return true;
2406             }
2407             return false;
2408         }
2409 
2410         if let Some(i) = self.dragging_param {
2411             if let Some(s) = &mut self.sliders[i] {
2412                 if s.drag_update(px, py) {
2413                     let new_val = s.get_scaled_value();
2414                     let old_val = &self.display_params[i].1;
2415                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2416                     if *old_val != new_val_str {
2417                         self.display_params[i].1 = new_val_str;
2418                         return true;
2419                     }
2420                 }
2421             } else if let Some(f) = &mut self.float3s[i] {
2422                 if f.drag_update(px, py) {
2423                     let new_val_str = f.value_string();
2424                     if self.display_params[i].1 != new_val_str {
2425                         self.display_params[i].1 = new_val_str;
2426                         return true;
2427                     }
2428                 }
2429             }
2430         }
2431         false
2432     }
2433 
2434     fn drag_end(&mut self) {
2435         if self.scrollbar_dragging {
2436             self.scrollbar_dragging = false;
2437             self.activity.bump();
2438             return;
2439         }
2440         if let Some(i) = self.dragging_param.take() {
2441             if let Some(s) = &mut self.sliders[i] {
2442                 s.drag_end();
2443             } else if let Some(f) = &mut self.float3s[i] {
2444                 f.drag_end();
2445             }
2446         }
2447     }
2448 
2449     /// Legacy `tick` forwarded to the raw children (the adapter's recursion now) and the
2450     /// checkbox rows. The checkbox tick never touches the ctx (a leaf `Adapted` tick is
2451     /// ctx-free), so the in-file dummy-ctx convention (`drag_begin`, `collect_child_quads`)
2452     /// applies.
2453     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2454         if !self.visible {
2455             return false;
2456         }
2457         let mut changed = false;
2458         let mut dummy = crate::context::UiContext::new();
2459         // Choice rows tick their Dropdowns' open/close animation. This was the
2460         // ONLY path that can advance a pane dropdown's anim_snap (the widget's
2461         // tick-receiver registration points at an id pane internals never put
2462         // in the host tree), and without it every params-pane dropdown opened
2463         // at zero drawn extent: logically open, invisible, reporting a sliver
2464         // popover rect — and the next click toggled it closed again.
2465         for d in self.choices.iter_mut().flatten() {
2466             if d.tick(dt, &mut dummy) {
2467                 changed = true;
2468             }
2469         }
2470         for cb in self.toggles.iter_mut().flatten() {
2471             if cb.tick(dt, &mut dummy) {
2472                 changed = true;
2473             }
2474         }
2475         // Slider rows tick their wheel-glide inertia — fold a coasting value
2476         // back into the row string so hosts syncing off display_params apply
2477         // it, exactly like a live wheel event would.
2478         for i in 0..self.sliders.len() {
2479             if let Some(s) = &mut self.sliders[i] {
2480                 if s.tick(dt, &mut dummy) {
2481                     let new_val = s.get_scaled_value();
2482                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
2483                     if self.display_params[i].1 != new_val_str {
2484                         self.display_params[i].1 = new_val_str;
2485                     }
2486                     changed = true;
2487                 }
2488             }
2489         }
2490         // Float3 rows are three slider rows: same glide, same fold-back.
2491         for i in 0..self.float3s.len() {
2492             if let Some(f) = &mut self.float3s[i] {
2493                 if f.tick(dt, &mut dummy) {
2494                     let new_val_str = f.value_string();
2495                     if self.display_params[i].1 != new_val_str {
2496                         self.display_params[i].1 = new_val_str;
2497                     }
2498                     changed = true;
2499                 }
2500             }
2501         }
2502         // Ramp rows tick their field widgets (preset application, slider→key
2503         // sync) and drain their change flag — fold the curve back into the row
2504         // value when it moved.
2505         for i in 0..self.ramps.len() {
2506             if let Some(rp) = &mut self.ramps[i] {
2507                 if rp.tick(dt, &mut dummy) {
2508                     self.display_params[i].1 = rp.inner().spec_string();
2509                     changed = true;
2510                 }
2511             }
2512         }
2513         // Color rows tick their picker-stream poll (`cce-color-editor --stream`
2514         // lines applying live) — fold a changed value back into the row so
2515         // hosts syncing off display_params see it while the picker is open.
2516         for i in 0..self.colors.len() {
2517             if let Some(c) = &mut self.colors[i] {
2518                 if c.tick(dt, &mut dummy) {
2519                     if let Some(val) = c.get_value_string() {
2520                         if self.display_params[i].1 != val {
2521                             self.display_params[i].1 = val;
2522                             self.tick_value_changed = true;
2523                         }
2524                     }
2525                     changed = true;
2526                 }
2527             }
2528         }
2529         // The pane's own wheel glide / trackpad coast: adopt any host write to
2530         // `scroll_y`, advance, and re-seat the rows when the offset moved.
2531         self.scroll_motion.reconcile(0.0, self.scroll_y);
2532         let pane_max = (self.content_h - self.rect.height).max(0.0);
2533         if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
2534             self.scroll_y = self.scroll_motion.y.pos();
2535             self.update_slider_rects();
2536             self.rehover_after_scroll(&mut dummy);
2537             changed = true;
2538         }
2539         if self.scroll_motion.is_animating() {
2540             changed = true;
2541         }
2542         // Decay the "recently scrolled" window; keep frames coming until it expires so the
2543         // scrollbar's sink behind the plate actually renders.
2544         if self.activity.holding() {
2545             changed = true;
2546         }
2547         // Re-latch the raised state (e.g. sink once the scroll window lapses).
2548         let visible = self.scrollbar_visible();
2549         if self.activity.tick(dt, visible, self.scrollbar_dragging) {
2550             changed = true;
2551         }
2552         changed
2553     }
2554 
2555     fn visibility_changed(&mut self, visible: bool) {
2556         self.visible = visible;
2557     }
2558 
2559     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2560         // Copied before `ectx.ui` is borrowed: the wheel arm's occlusion check keys on the
2561         // adapter's address (the pointer hosts register/popover-track).
2562         let self_id = ectx.id;
2563         match event {
2564             // Hosts call `unfocus()` directly (the designer's pane switches): commit the
2565             // focused row and unfocus the children. Needs no ctx, so the direct path's
2566             // ui-less synthesis works too.
2567             Event::FocusOut => {
2568                 self.commit_and_unfocus();
2569                 true
2570             }
2571             Event::PointerMove { x: px, y: py, .. } => {
2572                 let (px, py) = (*px, *py);
2573                 self.mouse_pos = Some((px, py));
2574                 // Track scrollbar hover, then re-latch: hover only sustains an already-raised
2575                 // bar (a sunk one is behind the plate, so the pointer never reaches it), so
2576                 // the only visible change here is the raised state — redraw on that transition.
2577                 let hover = self.hit_test_scrollbar(px, py);
2578                 self.activity.set_hover(hover);
2579                 let raised_changed = self.recompute_scrollbar_raised();
2580                 let Some(ui) = ectx.ui.as_deref_mut() else {
2581                     return raised_changed;
2582                 };
2583                 let mut changed = raised_changed;
2584 
2585                 if self.scrollbar_dragging {
2586                     let sb_track_h = self.rect.height - 8.0;
2587                     let sb_track_y = self.rect.y + 4.0;
2588                     let visible_ratio = self.rect.height / self.content_h;
2589                     let thumb_h = if sb_track_h <= 20.0 {
2590                         sb_track_h
2591                     } else {
2592                         (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2593                     };
2594                     let max_scroll = (self.content_h - self.rect.height).max(0.0);
2595 
2596                     let target_thumb_y = py - self.drag_offset_y;
2597                     let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2598                         ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2599                     } else {
2600                         0.0
2601                     };
2602 
2603                     let old_scroll = self.scroll_y;
2604                     self.scroll_y = new_scroll_ratio * max_scroll;
2605                     if (self.scroll_y - old_scroll).abs() > 0.01 {
2606                         self.update_slider_rects();
2607                         changed = true;
2608                     }
2609                 }
2610 
2611                 if self.hover_controls(px, py, ui) {
2612                     changed = true;
2613                 }
2614 
2615                 changed
2616             }
2617             Event::MouseButton { button, state, x: px, y: py, .. } => {
2618                 if !self.visible {
2619                     return false;
2620                 }
2621                 let (button, state, px, py) = (*button, *state, *px, *py);
2622                 let Some(ui) = ectx.ui.as_deref_mut() else {
2623                     return false;
2624                 };
2625 
2626                 if button == MouseButton::Left {
2627                     if state == ElementState::Pressed {
2628                         // Only a raised bar can be grabbed — a sunk one is behind the plate,
2629                         // so the press falls through to the pane content underneath it.
2630                         if self.activity.raised() && self.hit_test_scrollbar(px, py) {
2631                             // Legacy called `self.focus()` here — base flag only, which
2632                             // nothing reads (see module docs).
2633                             self.scrollbar_dragging = true;
2634 
2635                             let sb_track_h = self.rect.height - 8.0;
2636                             let sb_track_y = self.rect.y + 4.0;
2637                             let visible_ratio = self.rect.height / self.content_h;
2638                             let thumb_h = if sb_track_h <= 20.0 {
2639                                 sb_track_h
2640                             } else {
2641                                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
2642                             };
2643                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
2644                             let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
2645                             let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
2646 
2647                             let click_offset = py - thumb_y;
2648                             if click_offset >= 0.0 && click_offset <= thumb_h {
2649                                 self.drag_offset_y = click_offset;
2650                             } else {
2651                                 // Clicked outside the thumb: jump thumb center to py
2652                                 self.drag_offset_y = thumb_h / 2.0;
2653                                 let target_thumb_y = py - self.drag_offset_y;
2654                                 let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
2655                                     ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
2656                                 } else {
2657                                     0.0
2658                                 };
2659                                 self.scroll_y = new_scroll_ratio * max_scroll;
2660                                 self.update_slider_rects();
2661                             }
2662                             return true;
2663                         }
2664                     } else if state == ElementState::Released
2665                         && self.scrollbar_dragging {
2666                             self.scrollbar_dragging = false;
2667                             self.activity.bump();
2668                             return true;
2669                         }
2670                 }
2671 
2672                 // A press on a section's title box collapses/expands it. Checked before the
2673                 // rows so a header can never be shadowed by a control under it, and only on
2674                 // the press — the matching release lands on whatever the relayout moved
2675                 // under the pointer, which must not toggle it straight back.
2676                 if button == MouseButton::Left && state == ElementState::Pressed {
2677                     let rects = self.get_param_rects();
2678                     let hit = self.display_params.iter().enumerate().position(|(i, p)| {
2679                         if p.2 != "section" {
2680                             return false;
2681                         }
2682                         let (bx, by, bw, bh) = self.section_title_box(i, rects[i]);
2683                         px >= bx && px <= bx + bw && py >= by && py <= by + bh
2684                     });
2685                     if let Some(i) = hit {
2686                         let title = self.display_params[i].0.clone();
2687                         let collapsed = self.collapsed.contains(&title);
2688                         // Collapsing out from under a focused row would strand the editor.
2689                         self.commit_and_unfocus();
2690                         self.set_section_collapsed(&title, !collapsed);
2691                         return true;
2692                     }
2693                 }
2694 
2695                 let hidden = self.hidden_rows();
2696 
2697                 // 1. Check open dropdown popovers first (since they are drawn on top)
2698                 for (i, d_opt) in self.choices.iter_mut().enumerate() {
2699                     if hidden[i] {
2700                         continue;
2701                     }
2702                     if let Some(d) = d_opt {
2703                         if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2704                             eprintln!("[pdbg] press ({px:.0},{py:.0}) choice[{i}] open-priority: popover_rect={:?}", d.popover_rect());
2705                         }
2706                         if let Some((ox, oy, ow, oh)) = d.popover_rect() {
2707                             // Textpick pickers are press-driven end to end
2708                             // (selection fires on the option PRESS): a release
2709                             // over the open surface is swallowed, never
2710                             // dispatched — mid-animation it can read as an
2711                             // outside press and close the menu it just opened.
2712                             if state != ElementState::Pressed
2713                                 && self.display_params[i].2.starts_with("textpick")
2714                             {
2715                                 if px >= ox && px <= ox + ow && py >= oy && py <= oy + oh {
2716                                     return true;
2717                                 }
2718                                 continue;
2719                             }
2720                             let consumed = d.mouse_input(button, state, px, py, ui);
2721                             if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2722                                 eprintln!("[pdbg]   -> open dropdown consumed={consumed}");
2723                             }
2724                             if consumed {
2725                                 if d.take_change() {
2726                                     if let Some(val) = d.get_value_string() {
2727                                         // A textpick row's pick fills its
2728                                         // TextBox — the box IS the value.
2729                                         if self.display_params[i].2.starts_with("textpick") {
2730                                             if let Some(tb) = &mut self.texts[i] {
2731                                                 tb.text = val.clone();
2732                                                 tb.edit_buffer = val.clone();
2733                                             }
2734                                         }
2735                                         self.display_params[i].1 = val;
2736                                     }
2737                                 }
2738                                 return true;
2739                             }
2740                         }
2741                     }
2742                 }
2743                 // The ramp rows' field dropdowns can pop over neighboring rows too.
2744                 for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
2745                     if hidden[i] {
2746                         continue;
2747                     }
2748                     if let Some(rp) = rp_opt {
2749                         let ramp = rp.inner();
2750                         if (ramp.preset_dropdown.popover_rect().is_some()
2751                             || ramp.line_type_dropdown.popover_rect().is_some())
2752                             && rp.mouse_input(button, state, px, py, ui) {
2753                                 self.display_params[i].1 = rp.inner().spec_string();
2754                                 return true;
2755                             }
2756                     }
2757                 }
2758 
2759                 // 2. Propagate to our widgets
2760                 for (i, p) in self.display_params.iter_mut().enumerate() {
2761                     if hidden[i] {
2762                         continue;
2763                     }
2764                     if p.2.starts_with("choice") {
2765                         if let Some(d) = &mut self.choices[i] {
2766                             if d.mouse_input(button, state, px, py, ui) {
2767                                 // Claim the param focus while the dropdown is
2768                                 // open — the KeyInput arm above is gated on
2769                                 // `focused_param`, and without this the choice
2770                                 // row was the ONE row type that never set it,
2771                                 // so Escape/arrows/Enter could not reach an
2772                                 // open params dropdown (found via cce-designer).
2773                                 if d.open {
2774                                     self.focused_param = Some(i);
2775                                 } else if self.focused_param == Some(i) {
2776                                     self.focused_param = None;
2777                                 }
2778                                 if d.take_change() {
2779                                     if let Some(val) = d.get_value_string() {
2780                                         p.1 = val;
2781                                     }
2782                                 }
2783                                 return true;
2784                             }
2785                         }
2786                     } else if p.2 == "button" {
2787                         if let Some(b) = &mut self.buttons[i] {
2788                             if b.mouse_input(button, state, px, py, ui) {
2789                                 if std::env::var("CCE_PARAM_DEBUG").is_ok() {
2790                                     eprintln!("[pdbg] press ({px:.0},{py:.0}) BUTTON[{i}] '{}' consumed", p.0);
2791                                 }
2792                                 if b.take_click() {
2793                                     p.1 = "clicked".to_string();
2794                                 }
2795                                 return true;
2796                             }
2797                         }
2798                     } else if is_text_row(&p.2) {
2799                         if let Some(d) = &mut self.choices[i] {
2800                             // The picker acts on PRESSES only; the release
2801                             // over the button is swallowed. Releases used to
2802                             // reach the dropdown, and one arriving before the
2803                             // open animation's first frame (a fast or
2804                             // injected click) read as an outside press and
2805                             // closed the menu it had just opened.
2806                             let (bx, by, bw, bh) = d.rect();
2807                             let on_button =
2808                                 px >= bx && px <= bx + bw && py >= by && py <= by + bh;
2809                             if state == ElementState::Pressed {
2810                                 if d.mouse_input(button, state, px, py, ui) {
2811                                     if d.take_change() {
2812                                         if let Some(val) = d.get_value_string() {
2813                                             if let Some(tb) = &mut self.texts[i] {
2814                                                 tb.text = val.clone();
2815                                                 tb.edit_buffer = val.clone();
2816                                             }
2817                                             p.1 = val;
2818                                         }
2819                                     }
2820                                     return true;
2821                                 }
2822                             } else if on_button {
2823                                 return true;
2824                             }
2825                         }
2826                         if let Some(tb) = &mut self.texts[i] {
2827                             if tb.mouse_input(button, state, px, py, ui) {
2828                                 if tb.editing {
2829                                     self.focused_param = Some(i);
2830                                 } else {
2831                                     if self.focused_param == Some(i) {
2832                                         self.focused_param = None;
2833                                     }
2834                                 }
2835                                 if tb.take_change() {
2836                                     if let Some(val) = tb.get_value_string() {
2837                                         p.1 = val;
2838                                     }
2839                                 }
2840                                 return true;
2841                             }
2842                         }
2843                     } else if p.2.starts_with("spinbox") {
2844                         if let Some(sb) = &mut self.spinboxes[i] {
2845                             if sb.mouse_input(button, state, px, py, ui) {
2846                                 p.1 = sb.value.to_string();
2847                                 if sb.editing {
2848                                     self.focused_param = Some(i);
2849                                 } else {
2850                                     if self.focused_param == Some(i) {
2851                                         self.focused_param = None;
2852                                     }
2853                                 }
2854                                 return true;
2855                             }
2856                         }
2857                     } else if p.2 == "toggle" || p.2 == "checkbox" {
2858                         if let Some(cb) = &mut self.toggles[i] {
2859                             if cb.mouse_input(button, state, px, py, ui) {
2860                                 if cb.take_change() {
2861                                     if let Some(val) = cb.get_value_string() {
2862                                         p.1 = val;
2863                                     }
2864                                 }
2865                                 return true;
2866                             }
2867                         }
2868                     } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
2869                         if let Some(c) = &mut self.colors[i] {
2870                             if c.mouse_input(button, state, px, py, ui) {
2871                                 if let Some(val) = c.get_value_string() {
2872                                     p.1 = val;
2873                                 }
2874                                 if c.editing {
2875                                     self.focused_param = Some(i);
2876                                 } else {
2877                                     if self.focused_param == Some(i) {
2878                                         self.focused_param = None;
2879                                     }
2880                                 }
2881                                 return true;
2882                             }
2883                         }
2884                     } else if p.2 == "ramp" {
2885                         if let Some(rp) = &mut self.ramps[i] {
2886                             if rp.mouse_input(button, state, px, py, ui) {
2887                                 p.1 = rp.inner().spec_string();
2888                                 return true;
2889                             }
2890                         }
2891                     }
2892                 }
2893 
2894                 if button == MouseButton::Left && state == ElementState::Pressed {
2895                     let rects = self.get_param_rects();
2896                     let mut clicked_any_focusable = false;
2897                     for (i, p) in self.display_params.iter_mut().enumerate() {
2898                         if hidden[i] {
2899                             continue;
2900                         }
2901                         if p.2 == "code" {
2902                             let r = rects[i];
2903                             if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + Self::CODE_BOX_TOP && py <= r.1 + r.3 {
2904                                 // Clicking inside the box already being edited moves the
2905                                 // caret and keeps the buffer (and its history); a click
2906                                 // into a different row's box starts a fresh editor.
2907                                 let mut editor = match (self.focused_param == Some(i), self.code_editor.take()) {
2908                                     (true, Some(e)) => e,
2909                                     (_, other) => {
2910                                         drop(other);
2911                                         self.code_history.clear();
2912                                         TextEditorState::new(p.1.clone())
2913                                     }
2914                                 };
2915                                 self.focused_param = Some(i);
2916                                 let click_x = px - self.code_text_x(r);
2917                                 let click_y = py - (r.1 + Self::CODE_TOP);
2918                                 let line = (click_y / Self::CODE_LINE_H).floor().max(0.0) as usize;
2919                                 let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
2920                                 let at = map_2d_to_1d(&editor.buffer, line, col);
2921                                 if ui.shift_pressed {
2922                                     if editor.select_anchor.is_none() {
2923                                         editor.select_anchor = Some(editor.cursor_idx);
2924                                     }
2925                                 } else {
2926                                     editor.clear_selection();
2927                                 }
2928                                 editor.cursor_idx = at;
2929                                 self.code_editor = Some(editor);
2930                                 clicked_any_focusable = true;
2931                                 break;
2932                             }
2933                         } else if p.2.starts_with("slider") {
2934                             let r = rects[i];
2935                             if py >= r.1 && py <= r.1 + r.3 {
2936                                 if let Some(s) = &mut self.sliders[i] {
2937                                     if s.mouse_input(button, state, px, py, ui) {
2938                                         if s.editing {
2939                                             self.focused_param = Some(i);
2940                                             clicked_any_focusable = true;
2941                                         }
2942                                         break;
2943                                     }
2944                                 }
2945                             }
2946                         } else if is_vec_row(&p.2) {
2947                             let r = rects[i];
2948                             if py >= r.1 && py <= r.1 + r.3 {
2949                                 if let Some(f) = &mut self.float3s[i] {
2950                                     if f.mouse_input(button, state, px, py, ui) {
2951                                         if f.editing_idx().is_some() {
2952                                             self.focused_param = Some(i);
2953                                             clicked_any_focusable = true;
2954                                         }
2955                                         break;
2956                                     }
2957                                 }
2958                             }
2959                         }
2960                     }
2961                     if !clicked_any_focusable {
2962                         self.commit_and_unfocus();
2963                     }
2964                     return true;
2965                 }
2966                 false
2967             }
2968             Event::KeyInput(event) => {
2969                 if !self.visible {
2970                     return false;
2971                 }
2972                 let Some(ui) = ectx.ui.as_deref_mut() else {
2973                     return false;
2974                 };
2975                 if let Some(idx) = self.focused_param {
2976                     if event.state == ElementState::Pressed {
2977                         let p = &mut self.display_params[idx];
2978                         if p.2 == "code" {
2979                             if let Some(mut editor) = self.code_editor.take() {
2980                                 // Edits go to the BUFFER, not the value: a script
2981                                 // re-evaluates the node on every value change, and
2982                                 // a half-typed line would fail on every keystroke.
2983                                 // ctrl+enter applies, and so does leaving the row
2984                                 // (Escape, a click elsewhere, `unfocus`).
2985                                 let before = editor.clone();
2986                                 let mut handled = true;
2987                                 let mut edited = false;
2988                                 let mut apply = false;
2989                                 let mut should_unfocus = false;
2990                                 let shift = event.shift;
2991                                 let move_vertical = |editor: &mut TextEditorState, delta: i32| {
2992                                     if shift && editor.select_anchor.is_none() {
2993                                         editor.select_anchor = Some(editor.cursor_idx);
2994                                     } else if !shift {
2995                                         editor.clear_selection();
2996                                     }
2997                                     let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
2998                                     let total = editor.buffer.split('\n').count() as i32;
2999                                     let target = (line as i32 + delta).clamp(0, total - 1) as usize;
3000                                     editor.cursor_idx = map_2d_to_1d(&editor.buffer, target, col);
3001                                 };
3002                                 match &event.logical_key {
3003                                     Key::Named(NamedKey::Backspace) => {
3004                                         edited = editor.delete_backwards();
3005                                     }
3006                                     Key::Named(NamedKey::Delete) => {
3007                                         edited = editor.delete_forwards();
3008                                     }
3009                                     Key::Named(NamedKey::Enter) if event.ctrl => {
3010                                         apply = true;
3011                                     }
3012                                     Key::Named(NamedKey::Enter) => {
3013                                         // Auto-indent: the line's own leading whitespace,
3014                                         // one level deeper after an opening brace.
3015                                         let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
3016                                         let line: String = editor.buffer.chars().skip(line_start).take(editor.cursor_idx - line_start).collect();
3017                                         let mut indent: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
3018                                         if line.trim_end().ends_with('{') || line.trim_end().ends_with('(') || line.trim_end().ends_with('[') {
3019                                             indent.push_str(Self::CODE_INDENT);
3020                                         }
3021                                         editor.insert_text(&format!("\n{indent}"));
3022                                         edited = true;
3023                                     }
3024                                     Key::Named(NamedKey::Tab) if event.shift => {
3025                                         // Dedent the current line by one level, or what it has.
3026                                         let line_start = get_line_start(&editor.buffer, editor.cursor_idx);
3027                                         let leading = editor.buffer.chars().skip(line_start).take_while(|c| *c == ' ').count().min(Self::CODE_INDENT.len());
3028                                         if leading > 0 {
3029                                             let chars: Vec<char> = editor.buffer.chars().collect();
3030                                             editor.buffer = chars[..line_start].iter().chain(&chars[line_start + leading..]).collect();
3031                                             editor.cursor_idx = editor.cursor_idx.saturating_sub(leading).max(line_start);
3032                                             editor.clear_selection();
3033                                             edited = true;
3034                                         }
3035                                     }
3036                                     Key::Named(NamedKey::Tab) => {
3037                                         editor.insert_text(Self::CODE_INDENT);
3038                                         edited = true;
3039                                     }
3040                                     Key::Named(NamedKey::Escape) => {
3041                                         apply = true;
3042                                         should_unfocus = true;
3043                                     }
3044                                     Key::Named(NamedKey::ArrowLeft) => {
3045                                         editor.move_cursor_left(shift);
3046                                     }
3047                                     Key::Named(NamedKey::ArrowRight) => {
3048                                         editor.move_cursor_right(shift);
3049                                     }
3050                                     Key::Named(NamedKey::ArrowUp) => move_vertical(&mut editor, -1),
3051                                     Key::Named(NamedKey::ArrowDown) => move_vertical(&mut editor, 1),
3052                                     Key::Named(NamedKey::Home) => {
3053                                         if shift && editor.select_anchor.is_none() {
3054                                             editor.select_anchor = Some(editor.cursor_idx);
3055                                         } else if !shift {
3056                                             editor.clear_selection();
3057                                         }
3058                                         editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
3059                                     }
3060                                     Key::Named(NamedKey::End) => {
3061                                         if shift && editor.select_anchor.is_none() {
3062                                             editor.select_anchor = Some(editor.cursor_idx);
3063                                         } else if !shift {
3064                                             editor.clear_selection();
3065                                         }
3066                                         editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
3067                                     }
3068                                     Key::Character(s) => {
3069                                         if event.ctrl {
3070                                             match s.to_lowercase().as_str() {
3071                                                 "f" => {
3072                                                     editor.move_cursor_right(false);
3073                                                 }
3074                                                 "b" => {
3075                                                     editor.move_cursor_left(false);
3076                                                 }
3077                                                 "p" => move_vertical(&mut editor, -1),
3078                                                 "n" => move_vertical(&mut editor, 1),
3079                                                 "a" if event.shift => {
3080                                                     editor.select_all();
3081                                                 }
3082                                                 "a" => {
3083                                                     editor.clear_selection();
3084                                                     editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
3085                                                 }
3086                                                 "e" => {
3087                                                     editor.clear_selection();
3088                                                     editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
3089                                                 }
3090                                                 "d" => {
3091                                                     edited = editor.delete_forwards();
3092                                                 }
3093                                                 "h" => {
3094                                                     edited = editor.delete_backwards();
3095                                                 }
3096                                                 "k" => {
3097                                                     let current_idx = editor.cursor_idx;
3098                                                     let end_idx = get_line_end(&editor.buffer, current_idx);
3099                                                     let chars: Vec<char> = editor.buffer.chars().collect();
3100                                                     if current_idx < chars.len() {
3101                                                         let delete_end = if chars[current_idx] == '\n' { current_idx + 1 } else { end_idx };
3102                                                         editor.buffer = chars[..current_idx].iter().chain(&chars[delete_end..]).collect();
3103                                                         editor.clear_selection();
3104                                                         edited = true;
3105                                                     }
3106                                                 }
3107                                                 // The clipboard and history chords are the
3108                                                 // context actions, so the chord, the runner's
3109                                                 // routing and the menu row do one thing.
3110                                                 "c" | "x" | "v" | "z" => {
3111                                                     let action = match (s.to_lowercase().as_str(), event.shift) {
3112                                                         ("c", _) => crate::widget::ContextAction::Copy,
3113                                                         ("x", _) => crate::widget::ContextAction::Cut,
3114                                                         ("v", _) => crate::widget::ContextAction::Paste,
3115                                                         ("z", true) => crate::widget::ContextAction::Redo,
3116                                                         _ => crate::widget::ContextAction::Undo,
3117                                                     };
3118                                                     apply_code_action(&mut editor, &mut self.code_history, action);
3119                                                 }
3120                                                 _ => {
3121                                                     handled = false;
3122                                                 }
3123                                             }
3124                                         } else {
3125                                             editor.insert_text(s);
3126                                             edited = true;
3127                                         }
3128                                     }
3129                                     _ => {
3130                                         handled = false;
3131                                     }
3132                                 }
3133                                 if edited {
3134                                     // Typing runs coalesce into one undo step; anything
3135                                     // structural (a newline, a paste, a deletion of a
3136                                     // selection) starts a new one.
3137                                     let plain_char = matches!(&event.logical_key, Key::Character(c) if !event.ctrl && c.chars().count() == 1);
3138                                     if plain_char {
3139                                         self.code_history.record_grouped(before, 1);
3140                                     } else {
3141                                         self.code_history.record(before);
3142                                     }
3143                                 }
3144                                 if apply {
3145                                     p.1 = editor.buffer.clone();
3146                                 }
3147                                 if should_unfocus {
3148                                     self.focused_param = None;
3149                                     self.code_editor = None;
3150                                     self.code_history.clear();
3151                                 } else {
3152                                     self.code_editor = Some(editor);
3153                                 }
3154                                 if handled {
3155                                     return true;
3156                                 }
3157                             }
3158                         } else if is_text_row(&p.2) {
3159                             if let Some(d) = &mut self.choices[idx] {
3160                                 if d.open && d.keyboard_input(event, ui) {
3161                                     if d.take_change() {
3162                                         if let Some(val) = d.get_value_string() {
3163                                             if let Some(tb) = &mut self.texts[idx] {
3164                                                 tb.text = val.clone();
3165                                                 tb.edit_buffer = val.clone();
3166                                             }
3167                                             p.1 = val;
3168                                         }
3169                                     }
3170                                     return true;
3171                                 }
3172                             }
3173                             if let Some(tb) = &mut self.texts[idx] {
3174                                 if tb.keyboard_input(event, ui) {
3175                                     if !tb.editing {
3176                                         p.1 = tb.text.clone();
3177                                         self.focused_param = None;
3178                                     } else {
3179                                         p.1 = tb.edit_buffer.clone();
3180                                     }
3181                                     return true;
3182                                 }
3183                             }
3184                         } else if p.2.starts_with("choice") {
3185                             if let Some(d) = &mut self.choices[idx] {
3186                                 if d.keyboard_input(event, ui) {
3187                                     if !d.open {
3188                                         if let Some(val) = d.get_value_string() {
3189                                             p.1 = val;
3190                                         }
3191                                         self.focused_param = None;
3192                                     }
3193                                     return true;
3194                                 }
3195                             }
3196                         } else if p.2.starts_with("spinbox") {
3197                             if let Some(sb) = &mut self.spinboxes[idx] {
3198                                 if sb.keyboard_input(event, ui) {
3199                                     if !sb.editing {
3200                                         p.1 = sb.value.to_string();
3201                                         self.focused_param = None;
3202                                     } else {
3203                                         p.1 = sb.edit_buffer.clone();
3204                                     }
3205                                     return true;
3206                                 }
3207                             }
3208                         } else if p.2.starts_with("slider") {
3209                             if let Some(s) = &mut self.sliders[idx] {
3210                                 if s.keyboard_input(event, ui) {
3211                                     let new_val = s.get_scaled_value();
3212                                     p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
3213                                     if !s.editing {
3214                                         self.focused_param = None;
3215                                     }
3216                                     return true;
3217                                 }
3218                             }
3219                         } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
3220                             if let Some(c) = &mut self.colors[idx] {
3221                                 if c.keyboard_input(event, ui) {
3222                                     if let Some(val) = c.get_value_string() {
3223                                         p.1 = val;
3224                                     }
3225                                     if !c.editing {
3226                                         self.focused_param = None;
3227                                     }
3228                                     return true;
3229                                 }
3230                             }
3231                         } else if is_vec_row(&p.2) {
3232                             if let Some(f) = &mut self.float3s[idx] {
3233                                 if f.keyboard_input(event, ui) {
3234                                     p.1 = f.value_string();
3235                                     if f.editing_idx().is_none() {
3236                                         self.focused_param = None;
3237                                     }
3238                                     return true;
3239                                 }
3240                             }
3241                         }
3242                     }
3243                 }
3244                 false
3245             }
3246             Event::MouseWheel { delta, x: px, y: py, .. } => {
3247                 if !self.visible {
3248                     return false;
3249                 }
3250                 let (px, py) = (*px, *py);
3251                 let Some(ui) = ectx.ui.as_deref_mut() else {
3252                     return false;
3253                 };
3254                 let mut changed = false;
3255                 // A value row that took the wheel (slider/float3/spinbox),
3256                 // whether or not its 2-decimal string ticked over. Gating the
3257                 // pane's viewport-scroll fallback on the STRING (`changed`)
3258                 // let every sub-tick trackpad event scroll the pane instead —
3259                 // a slider-vs-pane tug-of-war that shifted the rows under the
3260                 // pointer mid-adjust.
3261                 let mut wheel_taken = false;
3262                 // Gesture ownership. A gesture the PANE acquired — its first
3263                 // event fell on no control and scrolled the rows — stays the
3264                 // pane's until the gesture ends (`scroll_gesture_new`), however
3265                 // the rows travel under the pointer meanwhile. Without this a
3266                 // list scroll ran until a slider's halo or a spinbox row slid
3267                 // under the pointer, which then took every remaining event of
3268                 // the same gesture and adjusted a value the user never aimed
3269                 // at (the Alt+D settings tab, 2026-09-20). A gesture that
3270                 // began ON a control is that control's, as before, and one
3271                 // nobody claimed (dead space, a control at its limit) is still
3272                 // open to spatial acquisition — the band slider's feel.
3273                 let pane_owns = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self_id);
3274                 // A trackpad gesture is the PANE's, from anywhere — a
3275                 // two-finger swipe is a scroll everywhere on the desktop, and
3276                 // a pane that is mostly controls (the Alt+D Settings list)
3277                 // was otherwise scrollable only from a label. A wheel notch
3278                 // still adjusts the control under the pointer. Unconditional
3279                 // since 2026-09-21: the first cut kept hover-adjust for finger
3280                 // gestures in a pane whose content fits, which made the main
3281                 // params pane feel different from the Settings list depending
3282                 // on how many parameters the node had. Finger-end frames (no
3283                 // delta) take the same path so the pane's coast starts.
3284                 let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
3285                     && matches!(
3286                         crate::widget::scroll_motion::current_scroll_phase(),
3287                         crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
3288                     );
3289                 let rects = self.get_param_rects();
3290                 // The exception to the finger rule: a VALUE control takes the
3291                 // gesture that BEGINS on it, trackpad included, and keeps it
3292                 // until the gesture ends — a spinbox by its row, a slider
3293                 // and each row of a float3 by the band's own halo
3294                 // (`Slider::scroll_hit`), the same zones a wheel notch
3295                 // acquires by. A two-finger scroll is aimed like a wheel:
3296                 // over a band it is the value being turned, anywhere else
3297                 // (the label column, the gaps, a toggle, a section header)
3298                 // the pane being scrolled. Spinboxes first (2026-09-28,
3299                 // earlier the same day), sliders and float3 rows after: the
3300                 // slider's wheel arm was written for trackpad streams all
3301                 // along, and the pane's rule kept every one of them from
3302                 // reaching it. A gesture the pane or another control
3303                 // acquired never lands on a control sliding under the
3304                 // pointer — that is the leak the latch exists to stop — so
3305                 // the LATCHED control is asked first, and only a gesture
3306                 // nobody owns is open to the control under the pointer.
3307                 let owner = {
3308                     let latched_to = |id| !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(id);
3309                     let in_row = |r: (f32, f32, f32, f32)| {
3310                         py >= r.1 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width
3311                     };
3312                     let n = self.display_params.len();
3313                     let latched = (0..n).find(|&i| {
3314                         self.spinboxes[i].as_ref().is_some_and(|sb| latched_to(sb.base().id()))
3315                             || self.sliders[i].as_ref().is_some_and(|sl| latched_to(sl.base().id()))
3316                             || self.float3s[i].as_ref().is_some_and(|f| f.wheel_latched(ui))
3317                     });
3318                     // Under the pointer: the NEAREST control whose zone
3319                     // holds it, by the distance to the band's (or the
3320                     // spinbox row's) centre line. A band's halo reaches
3321                     // past the band by more than the gap between rows, so
3322                     // two neighbours' zones hold any point between them,
3323                     // and the first in row order is the wrong answer for
3324                     // the lower half of that gap.
3325                     latched.or_else(|| {
3326                         if pane_owns {
3327                             return None;
3328                         }
3329                         (0..n)
3330                             .filter_map(|i| {
3331                                 if rects[i].3 <= 0.0 {
3332                                     return None;
3333                                 }
3334                                 if self.spinboxes[i].is_some() {
3335                                     let r = rects[i];
3336                                     return in_row(r).then(|| (i, (py - (r.1 + r.3 * 0.5)).abs()));
3337                                 }
3338                                 if let Some(sl) = self.sliders[i].as_ref() {
3339                                     let (sx, sy, sw, sh) = sl.rect();
3340                                     let ty = sl.label_strip();
3341                                     let band = Rect { x: sx, y: sy + ty, width: sw, height: sh - ty };
3342                                     return sl
3343                                         .inner()
3344                                         .scroll_hit(band, px, py)
3345                                         .then(|| (i, (py - (band.y + band.height * 0.5)).abs()));
3346                                 }
3347                                 // A float3's bands, and its trackball when it has one.
3348                                 self.float3s[i].as_ref().and_then(|f| f.wheel_zone(px, py)).map(|d| (i, d))
3349                             })
3350                             .min_by(|a, b| a.1.total_cmp(&b.1))
3351                             .map(|(i, _)| i)
3352                     })
3353                 };
3354                 let pane_takes = pane_owns || (finger && owner.is_none());
3355                 for (i, p) in self.display_params.iter_mut().enumerate() {
3356                     if pane_takes {
3357                         break;
3358                     }
3359                     // A gesture a control owns is nobody else's: the pointer
3360                     // may have drifted onto another band's halo since it began.
3361                     if owner.is_some() && owner != Some(i) {
3362                         continue;
3363                     }
3364                     if p.2.starts_with("slider") {
3365                         // The capture zone is the slider's own shape halo
3366                         // (`Slider::scroll_hit` — the band plus the traveling
3367                         // swell, inset), so scrolls off the shape fall through
3368                         // to the pane's viewport scroll below.
3369                         // `owner` made the zone test: this slider is latched
3370                         // (mid-gesture the slider that acquired the scroll
3371                         // keeps it — its halo travels away from the pointer
3372                         // as the value moves), or its band is the nearest
3373                         // whose halo holds the pointer.
3374                         let in_zone = owner == Some(i);
3375                         if in_zone {
3376                             if let Some(s) = &mut self.sliders[i] {
3377                                 let was_scroll = s.scroll_enabled;
3378                                 s.set_scroll(true);
3379                                 // Ungated: the in_zone halo above already gated
3380                                 // spatially, and the adapter's rect gate would
3381                                 // clip the halo's fringe outside the row rect.
3382                                 if s.mouse_wheel_ungated(delta, px, py, ui) {
3383                                     wheel_taken = true;
3384                                     let new_val = s.get_scaled_value();
3385                                     let old_val = &p.1;
3386                                     let new_val_str = format!("{:.*}", slider_decimals(&p.2), new_val);
3387                                     if *old_val != new_val_str {
3388                                         p.1 = new_val_str;
3389                                         changed = true;
3390                                     }
3391                                 }
3392                                 s.set_scroll(was_scroll);
3393                             }
3394                         }
3395                     } else if is_vec_row(&p.2) {
3396                         if owner == Some(i) {
3397                             // The group's rows apply the slider-row contract
3398                             // themselves (band halo / gesture latch, else the
3399                             // row strip; ungated forward).
3400                             if let Some(f) = &mut self.float3s[i] {
3401                                 if f.wheel(delta, px, py, ui) {
3402                                     wheel_taken = true;
3403                                     let new_val_str = f.value_string();
3404                                     if p.1 != new_val_str {
3405                                         p.1 = new_val_str;
3406                                         changed = true;
3407                                     }
3408                                 }
3409                             }
3410                         }
3411                     } else if p.2.starts_with("spinbox")
3412                         && owner == Some(i) {
3413                             if let Some(sb) = &mut self.spinboxes[i] {
3414                                 // The widget's own wheel arm: one step per
3415                                 // notch, fractions carried between events.
3416                                 // Ungated — `owner` already tested the
3417                                 // row, and a latched gesture may have
3418                                 // drifted off it.
3419                                 wheel_taken = true;
3420                                 ui.scroll_initiate_widget_id = Some(sb.base().id());
3421                                 sb.mouse_wheel_ungated(delta, px, py, ui);
3422                                 let new_val_str = sb.value.to_string();
3423                                 if p.1 != new_val_str {
3424                                     p.1 = new_val_str;
3425                                     changed = true;
3426                                 }
3427                             }
3428                         }
3429                 }
3430 
3431                 // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
3432                 // address, then rect-or-popover containment.
3433                 let mut swallowed = changed || wheel_taken;
3434                 if !ui.is_coordinate_covered(self_id, px, py) {
3435                     let in_rect = px >= self.rect.x
3436                         && px <= self.rect.x + self.rect.width
3437                         && py >= self.rect.y
3438                         && py <= self.rect.y + self.rect.height;
3439                     let in_popover = self.own_popover_rect().is_some_and(|(rx, ry, rw, rh)| {
3440                         px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
3441                     });
3442                     if in_rect || in_popover {
3443                         if !changed && !wheel_taken {
3444                             if crate::scroll_debug() {
3445                                 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
3446                             }
3447                             // The pane takes the gesture (see `pane_owns`).
3448                             ui.scroll_initiate_widget_id = Some(self_id);
3449                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
3450                             self.scroll_motion.reconcile(0.0, self.scroll_y);
3451                             let moved = self.scroll_motion.apply(
3452                                 delta,
3453                                 (crate::widget::LINE_PX, crate::widget::LINE_PX),
3454                                 crate::widget::Bounds::max(0.0),
3455                                 crate::widget::Bounds::max(max_scroll),
3456                             );
3457                             self.scroll_y = self.scroll_motion.y.pos();
3458                             if moved {
3459                                 self.update_slider_rects();
3460                                 self.activity.bump();
3461                                 self.recompute_scrollbar_raised();
3462                                 self.rehover_after_scroll(ui);
3463                             }
3464                         }
3465                         // An opaque pane swallows EVERY wheel over it, whether
3466                         // anything moved or not: returning false would hand the
3467                         // event to whatever lies BEHIND the plate — the designer
3468                         // routes unhandled wheels to the 3D viewport, whose rect
3469                         // is the whole window in the floating layout, so a
3470                         // near-miss on a slider would orbit the camera through
3471                         // the pane.
3472                         swallowed = true;
3473                     }
3474                 }
3475 
3476                 swallowed
3477             }
3478             _ => false,
3479         }
3480     }
3481 }
3482 
3483 /// Display precision for a slider row from the type string's optional 4th
3484 /// segment (`slider:min:max:decimals`); 2 when absent — the pane-wide
3485 /// historical default.
3486 fn slider_decimals(ptype: &str) -> usize {
3487     ptype.split(':').nth(3).and_then(|s| s.parse().ok()).unwrap_or(2)
3488 }
3489 
3490 /// Whether a row of type `t` is a vector of sliders: `float2`, `float3` or
3491 /// `float4`, each `floatN:lo:hi` — the one [`Float3`] group with that many
3492 /// rows ([`Float3::set_components`]).
3493 fn is_vec_row(t: &str) -> bool {
3494     t.starts_with("float2") || t.starts_with("float3") || t.starts_with("float4")
3495 }
3496 
3497 /// A SEPARATOR row: a hairline between two runs of rows, a pixel tall with
3498 /// the row gap either side, that nothing focuses, hovers or edits — what a
3499 /// host puts between groups of parameters that are about different things.
3500 /// Its key and value mean nothing; a host writing rows back skips it.
3501 pub const SEPARATOR: &str = "separator";
3502 
3503 /// Whether a slider or vector row's range is SOFT: a `soft` segment
3504 /// anywhere after the range (`slider:lo:hi:dec:soft`,
3505 /// `float3:lo:hi:trackball:soft`). A value typed past an end widens the
3506 /// row's range rather than being clamped to it (`Slider::set_soft`); the
3507 /// host is expected to choose the range around the value.
3508 fn is_soft_row(t: &str) -> bool {
3509     t.split(':').skip(3).any(|s| s == "soft")
3510 }
3511 
3512 /// How many rows a vector row has: the digit after `float`.
3513 fn vec_row_n(t: &str) -> usize {
3514     t.get(5..6).and_then(|d| d.parse().ok()).unwrap_or(3)
3515 }
3516 
3517 fn parse_slider_range(ptype: &str) -> (f32, f32) {
3518     if ptype.starts_with("slider:") || (is_vec_row(ptype) && ptype.contains(':')) {
3519         let parts: Vec<&str> = ptype.split(':').collect();
3520         if parts.len() >= 3 {
3521             if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
3522                 return (min, max);
3523             }
3524         }
3525     }
3526     (0.0, 2.0)
3527 }
3528 
3529 /// `text` whole if it measures within `avail` px, else its longest head
3530 /// with "..." that does.
3531 fn fit_tail(text: &str, avail: f32, family: &str, size: f32) -> String {
3532     use crate::widget::display::{measure_text_width, truncate_tail};
3533     let fits = |t: &str| measure_text_width(t, family, size) <= avail;
3534     if fits(text) {
3535         return text.to_string();
3536     }
3537     (0..text.chars().count())
3538         .rev()
3539         .map(|n| truncate_tail(text, n))
3540         .find(|t| fits(t))
3541         .unwrap_or_default()
3542 }
3543 
3544 fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
3545     crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
3546 }
3547 
3548 fn parse_hex_to_rgba(s: &str) -> Option<[u8; 4]> {
3549     crate::color::parse_hex_bytes(s)
3550 }
3551 
3552 fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
3553     if ptype.starts_with("spinbox:") {
3554         let parts: Vec<&str> = ptype.split(':').collect();
3555         if parts.len() >= 4 {
3556             if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
3557                 return (min, max, step);
3558             }
3559         } else if parts.len() == 3 {
3560             if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
3561                 return (min, max, 1);
3562             }
3563         }
3564     }
3565     (0, 10000, 1)
3566 }
3567 
3568 /// A vector row's text as `n` normalized values: each component over the
3569 /// row's range, mid-range where the text has none.
3570 fn parse_vec_value(val_str: &str, min: f32, max: f32, n: usize) -> Vec<f32> {
3571     let mut out = vec![0.5; n];
3572     let parts: Vec<&str> = val_str
3573         .split([':', ',', ' '])
3574         .filter(|s| !s.is_empty())
3575         .collect();
3576     for i in 0..n {
3577         if i < parts.len() {
3578             if let Ok(v) = parts[i].parse::<f32>() {
3579                 let range = max - min;
3580                 if range != 0.0 {
3581                     out[i] = ((v - min) / range).clamp(0.0, 1.0);
3582                 } else {
3583                     out[i] = 0.0;
3584                 }
3585             }
3586         }
3587     }
3588     out
3589 }
3590 
3591 impl ParamController for ParametersBg {
3592     fn node_params(&self) -> Vec<(String, String, String)> {
3593         self.display_params.clone()
3594     }
3595 
3596     fn set_display_params(&mut self, params: &[(String, String, String)]) {
3597         let mut layout_changed = self.display_params.len() != params.len();
3598         if !layout_changed {
3599             for (p_old, p_new) in self.display_params.iter().zip(params.iter()) {
3600                 if p_old.0 != p_new.0 || p_old.2 != p_new.2 {
3601                     layout_changed = true;
3602                     break;
3603                 }
3604             }
3605         }
3606 
3607         if layout_changed {
3608             self.scroll_y = 0.0;
3609             self.display_params = params.to_vec();
3610             self.focused_param = None;
3611             // Re-read at every rebuild, so a reloaded style takes effect the
3612             // next time the rows are built, and geometry and widgets agree;
3613             // then decided against the cached rect's width, as a resize
3614             // decides it (`apply_label_layout`).
3615             self.inline_pref = crate::layout::param_labels_inline();
3616             self.inline_labels = self.decide_inline();
3617             let inline = self.inline_labels;
3618             self.sliders = self.display_params.iter().map(|p| {
3619                 if p.2.starts_with("slider") {
3620                     let val = p.1.parse::<f32>().unwrap_or(0.0);
3621                     let (min, max) = parse_slider_range(&p.2);
3622                     let t = if max - min != 0.0 {
3623                         ((val - min) / (max - min)).clamp(0.0, 1.0)
3624                     } else {
3625                         0.0
3626                     };
3627                     let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2)).with_soft(is_soft_row(&p.2));
3628                     Some(if inline { s } else { s.with_label(&p.0) })
3629                 } else {
3630                     None
3631                 }
3632             }).collect();
3633             self.float3s = self.display_params.iter().map(|p| {
3634                 if is_vec_row(&p.2) {
3635                     let (min, max) = parse_slider_range(&p.2);
3636                     let n = vec_row_n(&p.2);
3637                     let mut f = Float3::new().with_components(n).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
3638                     f.set_soft(is_soft_row(&p.2));
3639                     f.set_values_n(&parse_vec_value(&p.1, min, max, n));
3640                     f.set_view(self.trackball_view);
3641                     Some(if inline { f } else { f.with_label(&p.0) })
3642                 } else {
3643                     None
3644                 }
3645             }).collect();
3646             self.spinboxes = self.display_params.iter().map(|p| {
3647                 if p.2.starts_with("spinbox") {
3648                     let (min, max, step) = parse_spinbox_range(&p.2);
3649                     let val = p.1.parse::<i32>().unwrap_or(min);
3650                     let sb = Spinbox::new(val, min, max, step);
3651                     Some(if inline { sb } else { sb.with_label(&p.0) })
3652                 } else {
3653                     None
3654                 }
3655             }).collect();
3656             self.buttons = self.display_params.iter().map(|p| {
3657                 if p.2 == "button" {
3658                     // Left-aligned, like the toggles below: the rows form one column, and
3659                     // centered labels made each row's text start at a different x.
3660                     Some(Button::new(0.0, 0.0, 0.0, 0.0).with_label(&p.0).with_left_align(true))
3661                 } else {
3662                     None
3663                 }
3664             }).collect();
3665             self.choices = self.display_params.iter().map(|p| {
3666                 if p.2.starts_with("choice:") {
3667                     let options_str = p.2.strip_prefix("choice:").unwrap_or("");
3668                     let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3669                     let selected = options.iter().position(|o| o == &p.1).unwrap_or(0);
3670                     let d = Dropdown::new(options, selected);
3671                     Some(if inline { d } else { d.with_label(&p.0) })
3672                 } else if p.2.starts_with("textpick:") {
3673                     // The text row's completion picker: a menu-button Dropdown
3674                     // (fixed glyph, re-fires on repeat picks) beside the box.
3675                     let options: Vec<String> = p.2.strip_prefix("textpick:").unwrap_or("")
3676                         .split(',')
3677                         .filter(|s| !s.is_empty())
3678                         .map(|s| s.to_string())
3679                         .collect();
3680                     if options.is_empty() {
3681                         None
3682                     } else {
3683                         // A raised face: the picker reads as a BUTTON sitting
3684                         // in the box's recess, not a bare glyph beside it.
3685                         Some(
3686                             Dropdown::new(options, 0)
3687                                 .with_custom_display_text("")
3688                                 .with_raised(true),
3689                         )
3690                     }
3691                 } else {
3692                     None
3693                 }
3694             }).collect();
3695             self.texts = self.display_params.iter().map(|p| {
3696                 if is_text_row(&p.2) {
3697                     let tb = TextBox::new(p.1.clone());
3698                     Some(if inline { tb } else { tb.with_label(&p.0) })
3699                 } else {
3700                     None
3701                 }
3702             }).collect();
3703             self.toggles = self.display_params.iter().map(|p| {
3704                 if p.2 == "toggle" || p.2 == "checkbox" {
3705                     let on = p.1.trim().to_lowercase() == "true";
3706                     let mut t = Toggle::new().with_label(&p.0).with_left_align(true);
3707                     t.set_toggled(on);
3708                     Some(t)
3709                 } else {
3710                     None
3711                 }
3712             }).collect();
3713             self.colors = self.display_params.iter().map(|p| {
3714                 if p.2 == "rgba" {
3715                     // Alpha-carrying param: the full picker, 8-digit hex.
3716                     let col = parse_hex_to_rgba(&p.1).unwrap_or([255, 255, 255, 255]);
3717                     let c = ColorSelector::new_rgba(col);
3718                     Some(if inline { c } else { c.with_label(&p.0) })
3719                 } else if p.2.starts_with("color") || p.2 == "rgb" {
3720                     let col = parse_hex_to_rgb(&p.1).unwrap_or([255, 255, 255]);
3721                     let c = ColorSelector::new(col);
3722                     Some(if inline { c } else { c.with_label(&p.0) })
3723                 } else {
3724                     None
3725                 }
3726             }).collect();
3727             self.ramps = self.display_params.iter().map(|p| {
3728                 if p.2 == "ramp" {
3729                     let mut rp = Ramp::new();
3730                     rp.inner_mut().set_spec(&p.1);
3731                     Some(rp)
3732                 } else {
3733                     None
3734                 }
3735             }).collect();
3736         } else {
3737             for (i, p_new) in params.iter().enumerate() {
3738                 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
3739                     self.display_params[i].1 = p_new.1.clone();
3740                     if let Some(ref mut s) = self.sliders[i] {
3741                         let (min, max) = parse_slider_range(&p_new.2);
3742                         // Idempotence guard: hosts push params straight back
3743                         // after every sync, and re-seeding from the 2-decimal
3744                         // string quantizes away the slider's sub-tick state —
3745                         // mid-scroll that snaps the value BACKWARD between
3746                         // wheel events/glide ticks (visible as jitter). Only
3747                         // re-seed when the incoming string says something the
3748                         // current value doesn't (a genuinely external change).
3749                         let cur_str = format!("{:.*}", slider_decimals(&p_new.2), min + s.value * (max - min));
3750                         if cur_str != p_new.1 {
3751                             let val = p_new.1.parse::<f32>().unwrap_or(0.0);
3752                             let t = if max - min != 0.0 {
3753                                 ((val - min) / (max - min)).clamp(0.0, 1.0)
3754                             } else {
3755                                 0.0
3756                             };
3757                             s.set_value(t);
3758                         }
3759                     } else if let Some(ref mut f) = self.float3s[i] {
3760                         let (min, max) = parse_slider_range(&p_new.2);
3761                         // Same round-trip guard as the slider row.
3762                         let cur_str = f.value_string();
3763                         if cur_str != p_new.1 {
3764                             let n = vec_row_n(&p_new.2);
3765                             if f.components() != n {
3766                                 f.set_components(n);
3767                             }
3768                             f.set_values_n(&parse_vec_value(&p_new.1, min, max, n));
3769                         }
3770                     } else if let Some(ref mut sb) = self.spinboxes[i] {
3771                         if !sb.editing {
3772                             let (min, _max, _step) = parse_spinbox_range(&p_new.2);
3773                             let val = p_new.1.parse::<i32>().unwrap_or(min);
3774                             sb.value = val;
3775                         }
3776                     } else if let Some(ref mut d) = self.choices[i] {
3777                         if !d.open {
3778                             if let Some(options_str) = p_new.2.strip_prefix("choice:") {
3779                                 let options: Vec<String> = options_str.split(',').map(|s| s.to_string()).collect();
3780                                 if d.options != options {
3781                                     d.options = options.clone();
3782                                 }
3783                                 if let Some(idx) = options.iter().position(|o| o == &p_new.1) {
3784                                     d.selected = idx;
3785                                 }
3786                             }
3787                         }
3788                     } else if let Some(ref mut tb) = self.texts[i] {
3789                         if !tb.editing {
3790                             tb.set_value_string(&p_new.1);
3791                         }
3792                     } else if let Some(ref mut t) = self.toggles[i] {
3793                         let on = p_new.1.trim().to_lowercase() == "true";
3794                         t.set_toggled(on);
3795                     } else if let Some(ref mut c) = self.colors[i] {
3796                         if !c.editing {
3797                             c.set_value_string(&p_new.1);
3798                         }
3799                     } else if let Some(ref mut rp) = self.ramps[i] {
3800                         if !rp.inner().is_dragging_key {
3801                             rp.set_value_string(&p_new.1);
3802                         }
3803                     }
3804                 }
3805             }
3806         }
3807         self.refresh_scroll_metrics();
3808     }
3809 }
3810 
3811 /// One clipboard, selection or history action over a code editor and its
3812 /// history — the body [`ParametersBg::code_action`] and the editor's own
3813 /// chords share, free of `self` so a caller holding a row borrow can use it.
3814 fn apply_code_action(
3815     editor: &mut TextEditorState,
3816     history: &mut crate::history::History<TextEditorState>,
3817     action: crate::widget::ContextAction,
3818 ) {
3819     use crate::widget::ContextAction;
3820     let before = editor.clone();
3821     let mut edited = false;
3822     match action {
3823         ContextAction::Undo => {
3824             if let Some(prev) = history.undo(editor.clone()) {
3825                 *editor = prev;
3826             }
3827         }
3828         ContextAction::Redo => {
3829             if let Some(next) = history.redo(editor.clone()) {
3830                 *editor = next;
3831             }
3832         }
3833         ContextAction::SelectAll => editor.select_all(),
3834         ContextAction::Copy => {
3835             if let Some(text) = editor.selected_text() {
3836                 crate::widget::clipboard::copy_to_clipboard(&text);
3837             }
3838         }
3839         ContextAction::Cut => {
3840             if let Some(text) = editor.selected_text() {
3841                 crate::widget::clipboard::copy_to_clipboard(&text);
3842                 edited = editor.delete_backwards();
3843             }
3844         }
3845         ContextAction::Paste => {
3846             if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
3847                 editor.insert_text(&text);
3848                 edited = true;
3849             }
3850         }
3851         _ => {}
3852     }
3853     if edited {
3854         history.record(before);
3855     }
3856 }
3857 
3858 fn get_cursor_line_col(buffer: &str, cursor_idx: usize) -> (usize, usize) {
3859     let mut cur_line = 0;
3860     let mut cur_col = 0;
3861     for (count, c) in buffer.chars().enumerate() {
3862         if count == cursor_idx {
3863             return (cur_line, cur_col);
3864         }
3865         if c == '\n' {
3866             cur_line += 1;
3867             cur_col = 0;
3868         } else {
3869             cur_col += 1;
3870         }
3871     }
3872     (cur_line, cur_col)
3873 }
3874 
3875 fn map_2d_to_1d(buffer: &str, line: usize, col: usize) -> usize {
3876     let mut target_line = line;
3877     let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3878     if lines.is_empty() {
3879         return 0;
3880     }
3881     if target_line >= lines.len() {
3882         target_line = lines.len() - 1;
3883     }
3884     let mut target_col = col;
3885     if target_col > lines[target_line].len() {
3886         target_col = lines[target_line].len();
3887     }
3888     let mut index = 0;
3889     for i in 0..target_line {
3890         index += lines[i].len() + 1; // +1 for the '\n'
3891     }
3892     index += target_col;
3893     index
3894 }
3895 
3896 fn get_line_start(buffer: &str, cursor_idx: usize) -> usize {
3897     let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3898     map_2d_to_1d(buffer, line, 0)
3899 }
3900 
3901 fn get_line_end(buffer: &str, cursor_idx: usize) -> usize {
3902     let (line, _) = get_cursor_line_col(buffer, cursor_idx);
3903     let lines: Vec<Vec<char>> = buffer.split('\n').map(|l| l.chars().collect()).collect();
3904     if lines.is_empty() {
3905         return 0;
3906     }
3907     let line_len = if line < lines.len() {
3908         lines[line].len()
3909     } else {
3910         lines[lines.len() - 1].len()
3911     };
3912     map_2d_to_1d(buffer, line, line_len)
3913 }
3914 
3915 #[cfg(test)]
3916 mod tests {
3917     use super::*;
3918     use crate::context::UiContext;
3919 
3920     fn panel_with(params: &[(&str, &str, &str)]) -> Adapted<ParametersBg> {
3921         let mut p = ParametersBg::new();
3922         let params: Vec<(String, String, String)> = params
3923             .iter()
3924             .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
3925             .collect();
3926         ParamController::set_display_params(&mut *p, &params);
3927         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
3928         p
3929     }
3930 
3931     /// The textpick text-row variant: a TextBox AND a menu-button Dropdown
3932     /// share the row — the picker takes a right-edge sliver, its options come
3933     /// from the type string, and a plain text row builds no picker.
3934     /// A textpick row's picker centres its arrow, and every other dropdown
3935     /// row's arrow stands in the same column: the picker is a trigger's
3936     /// arrow slot (`dropdown::arrow_slot`).
3937     #[test]
3938     fn a_pickers_arrow_lines_up_with_a_dropdowns() {
3939         let p = panel_with(&[
3940             ("Name", "mass", "textpick:Norm,UV,Pos,Col"),
3941             ("Type", "Float", "choice:Float,Float3,Int"),
3942         ]);
3943         let arrow = |i: usize| {
3944             let d = p.choices[i].as_ref().unwrap();
3945             let (x, y, w, h) = d.rect();
3946             d.inner().arrow_rect(crate::scene::layout::Rect { x, y, width: w, height: h }).x
3947         };
3948         assert!((arrow(0) - arrow(1)).abs() < 1e-3, "picker {} vs dropdown {}", arrow(0), arrow(1));
3949     }
3950 
3951     /// The pane draws its controls' glyphs: a dropdown's and a picker's
3952     /// arrow, a spinbox's minus and plus. The pane paints its rows' chrome
3953     /// itself and took only their TEXT, so when the symbols became glyphs
3954     /// (2026-10-05) every control in the pane lost them. Skipped where the
3955     /// icon set is not checked out (no glyph uploads, nothing to name).
3956     #[test]
3957     fn the_pane_draws_its_controls_glyphs() {
3958         if !std::path::Path::new(&crate::icons_dir()).join("chevron-down.svg").is_file() {
3959             eprintln!("skipped: no icon set");
3960             return;
3961         }
3962         let p = panel_with(&[
3963             ("Name", "mass", "textpick:Norm,UV,Pos,Col"),
3964             ("Type", "Float", "choice:Float,Float3,Int"),
3965             ("Count", "3", "spinbox:0:10:1"),
3966         ]);
3967         let ui = UiContext::new();
3968         let mut pc = PaintCtx::new();
3969         let r = Rect { x: 0.0, y: 0.0, width: 300.0, height: 400.0 };
3970         Paint::paint_ui(&*p, &ui, r, &mut pc);
3971         let names: Vec<String> = pc
3972             .finish()
3973             .items
3974             .into_iter()
3975             .filter_map(|item| match item.prim {
3976                 crate::scene::paint::Prim::Image { image, .. } => crate::icon_source(image).map(|(n, _, _)| n),
3977                 _ => None,
3978             })
3979             .collect();
3980         let count = |n: &str| names.iter().filter(|m| *m == n).count();
3981         assert_eq!(count("chevron-down"), 2, "the picker's and the dropdown's arrows: {names:?}");
3982         assert_eq!(count("minus"), 1, "the spinbox's minus: {names:?}");
3983         assert_eq!(count("plus"), 1, "the spinbox's plus: {names:?}");
3984     }
3985 
3986     #[test]
3987     fn textpick_rows_carry_a_picker() {
3988         let p = panel_with(&[
3989             ("Attribute Name", "mass", "textpick:Norm,UV,Pos,Col"),
3990             ("Plain", "x", "text"),
3991             ("Empty", "y", "textpick:"),
3992         ]);
3993         assert!(p.texts[0].is_some(), "textpick keeps its TextBox");
3994         let d = p.choices[0].as_ref().expect("textpick builds the picker");
3995         assert_eq!(d.options, ["Norm", "UV", "Pos", "Col"]);
3996         assert!(d.custom_display_text.is_some(), "menu-button mode");
3997         assert!(p.choices[1].is_none(), "plain text has no picker");
3998         assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
3999 
4000         // Layout: the picker is the field's right end — the box stops at
4001         // the seam, square there, and the picker reaches the field's outer
4002         // edge on the right, top and bottom, as a dropdown trigger does.
4003         let (tx, ty, tw, th) = p.texts[0].as_ref().unwrap().rect();
4004         let (dx, dy, dw, dh) = p.choices[0].as_ref().unwrap().rect();
4005         // The box ends where the picker begins, at the seam; the picker
4006         // is a wall wider than PICK_W, the wall being the lip its face
4007         // rises out of at the seam, so the face is PICK_W wide.
4008         let label = p.texts[0].as_ref().unwrap().label_strip();
4009         let depth = crate::layout::bevel_width().min((th - label) * 0.2);
4010         assert!((dw - (PICK_W + depth)).abs() < 1e-3, "the picker is PICK_W and a wall: {dw}");
4011         assert!((dx - (tx + tw)).abs() < 1e-3, "the picker begins where the box ends: {dx} vs {}", tx + tw);
4012         assert!(d.center_arrow, "the picker's arrow stands in its middle, its padding even");
4013         assert_eq!((dy, dy + dh), (ty + label, ty + th), "the picker spans the field's band, edge to edge");
4014         assert!(p.texts[0].as_ref().unwrap().inner().joined_right, "the box is square at the seam");
4015         assert!(!p.texts[1].as_ref().unwrap().inner().joined_right, "a plain text row is not");
4016         // ONE field: one outline round the box and the picker, its seam at
4017         // the picker — not a well and a trough each turning its own corner
4018         // there.
4019         let rr = crate::layout::textbox_corner_radius();
4020         let fields = p.inner().fields();
4021         assert_eq!(fields.len(), 1, "{fields:?}");
4022         let f = fields[0].rect;
4023         assert_eq!((f.x, f.y, f.x + f.width, f.y + f.height), (tx, dy, dx + dw, dy + dh), "the field spans box and picker");
4024         assert_eq!(fields[0].radii, (rr, rr, rr, rr), "its own radius at all four corners");
4025         assert_eq!(fields[0].run_span(), Some((dx, dx + dw)), "the run is the picker, to the field's end");
4026         assert!(p.inner().reliefs().iter().all(|r| !(r.0 == tx && r.2 == tw)), "nor the box a well of its own");
4027 
4028         // Both text variants lay out at the same row height.
4029         assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
4030 
4031         // The menu anchors off the WHOLE field: the popover spans at least
4032         // the box width and hangs below it, not off the button sliver.
4033         let anchor = p.choices[0].as_ref().unwrap().popover_anchor.expect("anchor set");
4034         assert_eq!(anchor.x, tx);
4035         assert!((anchor.width - (dx + dw - tx)).abs() < 1e-3, "the whole field");
4036         let d = p.choices[0].as_ref().unwrap();
4037         let (px_, py_, pw, _ph) = d.popover_geom(crate::scene::layout::Rect {
4038             x: dx, y: dy, width: dw, height: dh,
4039         });
4040         assert_eq!(px_, tx, "menu left-aligns with the box");
4041         assert!(pw >= tw, "menu at least as wide as the box");
4042         assert!(py_ >= ty + th - 1.0, "menu hangs below the box");
4043     }
4044 
4045     /// A dropdown trigger's edge is a field's run: its own paint and the
4046     /// pane's re-emission both draw a field that is all run, so its edge
4047     /// matches the picker at the end of a text row and the -/+ run of a
4048     /// spinbox. Never a trough, whose outer half differed.
4049     #[test]
4050     fn a_dropdown_trigger_wears_the_runs_edge() {
4051         use crate::scene::paint::{PaintCtx, Prim};
4052         if !crate::layout::control_relief() {
4053             return;
4054         }
4055         let p = panel_with(&[("Mode", "b", "choice:a,b,c")]);
4056         let d = p.choices[0].as_ref().expect("a dropdown");
4057         let (x, y, w, h) = d.rect();
4058         let ty = d.label_strip();
4059         let fields = p.inner().fields();
4060         assert_eq!(fields.len(), 1, "{fields:?}");
4061         assert_eq!(fields[0].rect, Rect { x, y: y + ty, width: w, height: h - ty }, "on the trigger's band");
4062         assert!(!fields[0].has_well(), "all run, no well");
4063         let mut pc = PaintCtx::new();
4064         Paint::paint(d.inner(), Rect { x, y: y + ty, width: w, height: h - ty }, &mut pc);
4065         let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
4066         assert!(prims.iter().any(|p| matches!(p, Prim::Field { rect, split, .. } if *split < rect.x - 100.0)), "{prims:?}");
4067         assert!(!prims.iter().any(|p| matches!(p, Prim::Trough { .. })), "no trough");
4068     }
4069 
4070     #[test]
4071     fn param_controller_roundtrip_and_row_widgets() {
4072         let p = panel_with(&[
4073             ("Size", "1.00", "slider:0:2"),
4074             ("Mode", "b", "choice:a,b,c"),
4075             ("On", "true", "checkbox"),
4076         ]);
4077         assert_eq!(ParamController::node_params(&*p).len(), 3);
4078         assert!(p.sliders[0].is_some() && p.choices[1].is_some() && p.toggles[2].is_some());
4079         // Rows were laid out from the cached rect: the slider's control rect
4080         // is the row less the label column when the labels are inline (the
4081         // default), the whole row when they are stacked.
4082         let lw = p.inner().label_col_w();
4083         if p.inner().inline_labels {
4084             assert!(lw > 0.0, "inline labels reserve a column");
4085             let labels = p.inner().own_text_labels();
4086             let size = labels.iter().find(|l| l.text == "Size").expect("the pane draws the inline label");
4087             assert_eq!(size.x, ROW_X_INSET, "the label sits at the row's left edge");
4088         } else {
4089             assert_eq!(lw, 0.0);
4090         }
4091         let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4092         assert_eq!(
4093             (sx, sw),
4094             (ROW_X_INSET + lw, 300.0 - 2.0 * ROW_X_INSET - lw),
4095             "control rect derives from the assigned rect and the label column"
4096         );
4097     }
4098 
4099     /// A pane too narrow for a label column stacks its labels above the
4100     /// controls, and takes them back into the column when it widens: the
4101     /// controls are relabelled, the column goes, the rows grow by the label
4102     /// strip. The threshold is the control's width, not the pane's.
4103     #[test]
4104     fn a_narrow_pane_stacks_its_labels_and_a_wide_one_puts_them_back() {
4105         let mut p = panel_with(&[("Size", "1.00", "slider:0:2"), ("Mode", "b", "choice:a,b,c"), ("On", "true", "checkbox")]);
4106         if !p.inner().inline_pref {
4107             return; // a stacked preference has nothing to re-flow
4108         }
4109         let strip = crate::layout::control_label_strip();
4110         // The COLUMN label sits exactly at the row's left edge; a control's
4111         // own detached label is inset from the control's edge, so the two
4112         // are told apart by x.
4113         let labels_in_column = |p: &Adapted<ParametersBg>| {
4114             p.inner().own_text_labels().iter().any(|l| l.text == "Size" && l.x == ROW_X_INSET)
4115         };
4116 
4117         // 300 wide: inline. The slider carries no label; the pane draws it.
4118         assert!(p.inner().inline_labels);
4119         let col = p.inner().label_col_w();
4120         assert!(col > 0.0);
4121         assert!(labels_in_column(&p));
4122         assert!(p.sliders[0].as_ref().unwrap().base().label.is_none());
4123         let (_, _, sw_inline, _) = p.sliders[0].as_ref().unwrap().rect();
4124         let h_inline = p.get_param_rects()[0].3;
4125         // The threshold is the slider's TRACK: its rect less the readout.
4126         let chrome = crate::widget::input::slider::Slider::readout_chrome();
4127         assert!(sw_inline - chrome >= ParametersBg::MIN_INLINE_TRACK_W, "the fixture starts with room: {sw_inline}");
4128 
4129         // Narrow it until the TRACK would fall under the minimum: stacked,
4130         // though the control's rect is still well over it.
4131         let narrow = 2.0 * ROW_X_INSET + col + chrome + ParametersBg::MIN_INLINE_TRACK_W - 1.0;
4132         WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow, 400.0);
4133         assert!(!p.inner().inline_labels, "under the minimum the labels stack");
4134         assert_eq!(p.inner().label_col_w(), 0.0, "no column");
4135         assert!(!labels_in_column(&p), "the pane draws no column label");
4136         assert_eq!(p.sliders[0].as_ref().unwrap().base().label.as_deref(), Some("Size"), "the slider carries its label");
4137         assert_eq!(p.choices[1].as_ref().unwrap().base().label.as_deref(), Some("Mode"));
4138         let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4139         assert_eq!((sx, sw), (ROW_X_INSET, narrow - 2.0 * ROW_X_INSET), "the control spans the row");
4140         // The row grows for the label band — by the strip, less the floor
4141         // the inline row's height table clamps at.
4142         let h_stacked = p.get_param_rects()[0].3;
4143         assert!(h_stacked > h_inline && h_stacked <= h_inline + strip, "inline {h_inline}, stacked {h_stacked}, strip {strip}");
4144         // The toggle carried its label all along.
4145         assert_eq!(p.toggles[2].as_ref().unwrap().base().label.as_deref(), Some("On"));
4146 
4147         // One pixel wider than the minimum: inline again, labels off the controls.
4148         WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow + 1.0, 400.0);
4149         assert!(p.inner().inline_labels, "at the minimum the column comes back");
4150         assert!(labels_in_column(&p));
4151         assert!(p.sliders[0].as_ref().unwrap().base().label.is_none(), "the label left the slider");
4152         assert!(p.choices[1].as_ref().unwrap().base().label.is_none());
4153         assert_eq!(p.get_param_rects()[0].3, h_inline);
4154         let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
4155         assert_eq!((sx, sw), (ROW_X_INSET + col, narrow + 1.0 - 2.0 * ROW_X_INSET - col));
4156 
4157         // A rebuild under a narrow rect builds stacked from the start.
4158         let rows: Vec<(String, String, String)> = vec![("Size".into(), "1.00".into(), "slider:0:2".into()), ("Width".into(), "2.00".into(), "slider:0:2".into())];
4159         WidgetHost::set_rect(&mut p, 0.0, 0.0, narrow, 400.0);
4160         ParamController::set_display_params(&mut *p, &rows);
4161         assert!(!p.inner().inline_labels);
4162         assert_eq!(p.sliders[1].as_ref().unwrap().base().label.as_deref(), Some("Width"));
4163 
4164         // The shortest track decides. At one width: a pane of controls with
4165         // no track keeps its column, a slider's readout costs it the
4166         // column, and a float3's axis letters cost it sooner still.
4167         let at = |rows: &[(&str, &str, &str)], width: f32| {
4168             let mut p = panel_with(rows);
4169             WidgetHost::set_rect(&mut p, 0.0, 0.0, width, 400.0);
4170             p.inner().inline_labels
4171         };
4172         let spin = [("Size", "3", "spinbox:0:10")];
4173         let slider = [("Size", "1.00", "slider:0:2")];
4174         let float3 = [("Size", "0:0:0", "float3:-1:1")];
4175         let base = 2.0 * ROW_X_INSET + col + ParametersBg::MIN_INLINE_TRACK_W;
4176         assert!(at(&spin, base), "a spinbox is measured whole");
4177         assert!(!at(&slider, base), "a slider at that width has a track {chrome} short");
4178         assert!(at(&slider, base + chrome));
4179         assert!(!at(&float3, base + chrome), "a float3 spends an axis column too");
4180         assert!(at(&float3, base + chrome + crate::widget::display::float3::AXIS_W));
4181 
4182         // Collapsing the section that holds the only slider re-decides: the
4183         // rows left visible have no track, and the column comes back.
4184         let mut p = panel_with(&[("Count", "3", "spinbox:0:10"), ("Shape", "", "section"), ("Size", "1.00", "slider:0:2")]);
4185         WidgetHost::set_rect(&mut p, 0.0, 0.0, base, 400.0);
4186         assert!(!p.inner().inline_labels, "the slider's track is the shortest");
4187         p.inner_mut().set_section_collapsed("Shape", true);
4188         assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
4189     }
4190 
4191     /// A `separator` row is a hairline a pixel tall between its neighbours,
4192     /// the row gap either side, drawn as a rule and never hovered.
4193     #[test]
4194     fn a_separator_row_is_a_rule_between_rows() {
4195         let p = panel_with(&[("Input", "a", "text"), ("", "", SEPARATOR), ("Size", "1.00", "slider:0:2")]);
4196         let mut p = p;
4197         WidgetHost::set_rect(&mut p, 0.0, 0.0, 400.0, 400.0);
4198         let rects = p.get_param_rects();
4199         assert_eq!(rects[1].3, 1.0, "a pixel tall");
4200         assert!((rects[1].1 - (rects[0].1 + rects[0].3 + ROW_GAP)).abs() < 1e-3, "the row gap above");
4201         assert!((rects[2].1 - (rects[1].1 + 1.0 + ROW_GAP)).abs() < 1e-3, "and below");
4202         let rule = p.plain_quads().into_iter().find(|q| q.3 == 1.0 && (q.1 - rects[1].1).abs() < 1e-3);
4203         assert!(rule.is_some_and(|q| q.2 > 300.0), "a rule across the row");
4204         assert_eq!(p.hoverable_row_at(rects[1].0 + 50.0, rects[1].1 + 0.5), None, "nothing to hover");
4205         let near = |l: &TextLabel| (l.y - rects[1].1).abs() < 6.0;
4206         assert!(!p.own_text_labels().iter().any(near), "and no label: a fallback `name: value` once drew ':'");
4207     }
4208 
4209     /// A `soft` slider or float row builds its sliders with a soft range,
4210     /// and the pane writes back the value the slider holds — past the
4211     /// row's declared range once a typed value has widened it — rather
4212     /// than re-reading the slider's fraction over the declared range.
4213     #[test]
4214     fn a_soft_row_writes_back_what_its_slider_holds() {
4215         let mut p = panel_with(&[("V", "1.00", "slider:-10:10:2:soft"), ("W", "1:2", "float2:-10:10:soft"), ("H", "1.00", "slider:-10:10")]);
4216         WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
4217         assert!(is_soft_row("slider:-10:10:2:soft") && is_soft_row("float3:-1:1:trackball:soft"));
4218         assert!(!is_soft_row("slider:-10:10") && !is_soft_row("float3:-1:1:trackball"));
4219         assert_eq!(slider_decimals("slider:-10:10:2:soft"), 2);
4220         {
4221             let s = p.sliders[0].as_mut().unwrap();
4222             s.set_range(-10.0, 500.0);
4223             s.set_scaled_value(500.0);
4224         }
4225         p.inner_mut().focused_param = Some(0);
4226         p.inner_mut().commit_and_unfocus();
4227         assert_eq!(p.display_params[0].1, "500.00");
4228         assert_eq!(p.display_params[2].1, "1.00", "the hard row is untouched");
4229     }
4230 
4231     /// `float2` and `float4` rows are the float3 group with two or four
4232     /// rows (X Y, X Y Z W): that many sliders over the row's range, laid out
4233     /// and sized for that many, read from and written back as that many
4234     /// components — and no trackball, which a direction of three numbers
4235     /// is the only thing to have.
4236     #[test]
4237     fn float2_and_float4_rows_are_the_group_with_two_or_four_sliders() {
4238         let mut p = panel_with(&[
4239             ("Two", "1.00:-2.00", "float2:-10:10"),
4240             ("Four", "1:2:3:4", "float4:-10:10:trackball"),
4241             ("Three", "0:0:0", "float3:-10:10"),
4242         ]);
4243         WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 600.0);
4244         let two = p.float3s[0].as_ref().expect("a float2 row is a group");
4245         let four = p.float3s[1].as_ref().expect("a float4 row is a group");
4246         assert_eq!((two.components(), four.components()), (2, 4));
4247         assert_eq!(two.get_row_rects().len(), 2);
4248         assert_eq!(four.get_row_rects().len(), 4);
4249         assert!(!four.has_trackball(), "four numbers have no ball");
4250         assert_eq!(two.value_string(), "1.00:-2.00");
4251         assert_eq!(four.value_string(), "1.00:2.00:3.00:4.00");
4252         assert!(
4253             Float3::preferred_height_for(false, 4) > Float3::preferred_height_for(false, 3)
4254                 && Float3::preferred_height_for(false, 3) > Float3::preferred_height_for(false, 2)
4255         );
4256         assert_eq!(Float3::preferred_height_for(false, 3), Float3::preferred_height(false));
4257 
4258         // A new value for the row reaches the group, and a group whose
4259         // row changed width takes the new width.
4260         let rows: Vec<(String, String, String)> = vec![
4261             ("Two".into(), "3:4".into(), "float2:-10:10".into()),
4262             ("Four".into(), "5:6:7".into(), "float3:-10:10".into()),
4263             ("Three".into(), "0:0:0".into(), "float3:-10:10".into()),
4264         ];
4265         ParamController::set_display_params(&mut *p, &rows);
4266         assert_eq!(p.float3s[0].as_ref().unwrap().value_string(), "3.00:4.00");
4267         let f = p.float3s[1].as_ref().unwrap();
4268         assert_eq!((f.components(), f.value_string()), (3, "5.00:6.00:7.00".to_string()));
4269     }
4270 
4271     /// A `float3:lo:hi:trackball` row builds its group with the ball, counts
4272     /// the ball as chrome when the label layout is decided, paints it
4273     /// through the scene path, and a press-and-drag on it through the
4274     /// pane's own drag protocol turns the row's value.
4275     #[test]
4276     fn a_trackball_row_turns_its_value_through_the_pane() {
4277         use crate::scene::paint::Prim;
4278         let mut p = panel_with(&[("Name", "x", "text"), ("Pull", "0.000:0.000:2.000", "float3:-10:10:trackball"), ("Plain", "0:0:0", "float3:-10:10")]);
4279         WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
4280         assert!(p.float3s[1].as_ref().unwrap().has_trackball());
4281         assert!(!p.float3s[2].as_ref().unwrap().has_trackball());
4282         assert_eq!(
4283             ParametersBg::control_chrome("float3:-10:10:trackball") - ParametersBg::control_chrome("float3:-10:10"),
4284             Float3::trackball_chrome(),
4285             "the ball is chrome: the tracks are what is left of it"
4286         );
4287 
4288         let mut pc = crate::scene::paint::PaintCtx::new();
4289         p.inner().paint_scene_rows(&mut pc);
4290         let spheres = pc.finish().items.into_iter().filter(|i| matches!(i.prim, Prim::Sphere { .. })).count();
4291         assert_eq!(spheres, 1, "one ball, for the one row that asks");
4292 
4293         let (cx, cy, r) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
4294         let rect = Rect { x: 0.0, y: 0.0, width: 500.0, height: 400.0 };
4295         Input::drag_begin(p.inner_mut(), cx, cy, rect);
4296         assert!(Input::is_dragging(p.inner()), "the pane is dragging the row");
4297         assert!(Input::drag_update(p.inner_mut(), cx + r * std::f32::consts::FRAC_PI_2, cy, rect));
4298         Input::drag_end(p.inner_mut());
4299         let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
4300         assert!((v[0] - 2.0).abs() < 2e-3 && v[1].abs() < 2e-3 && v[2].abs() < 2e-3, "a quarter turn right: {v:?}");
4301         assert_eq!(p.display_params[2].1, "0:0:0", "the plain row is untouched");
4302 
4303         // The pane's camera reaches every ball it has, and the ones built
4304         // after it; the same view again changes nothing.
4305         let camera = [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
4306         assert!(p.inner_mut().set_trackball_view(camera));
4307         assert_eq!(p.float3s[1].as_ref().unwrap().view(), camera);
4308         assert!(!p.inner_mut().set_trackball_view(camera), "unchanged");
4309         let rebuilt: Vec<(String, String, String)> = vec![("Other".into(), "0.000:0.000:1.000".into(), "float3:-10:10:trackball".into())];
4310         ParamController::set_display_params(&mut *p, &rebuilt);
4311         assert_eq!(p.float3s[0].as_ref().unwrap().view(), camera, "a rebuilt row starts from the pane's view");
4312 
4313         // A trackpad gesture beginning on the ball rolls it — the pane does
4314         // not scroll — and one beginning on the label column is the pane's.
4315         use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4316         let rows: Vec<(String, String, String)> = (0..12)
4317             .map(|i| (format!("P{i}"), "0.000:0.000:2.000".to_string(), "float3:-10:10:trackball".to_string()))
4318             .collect();
4319         let mut p = ParametersBg::new();
4320         ParamController::set_display_params(&mut *p, &rows);
4321         WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 300.0);
4322         assert!(p.content_h > 300.0, "the pane overflows, so it has somewhere to scroll");
4323         let (cx, cy, _) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
4324         let mut ctx = UiContext::new();
4325         ctx.scroll_gesture_new = true;
4326         set_scroll_phase(ScrollPhase::Finger);
4327         assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 60.0, y: 0.0 }), cx, cy, &mut ctx));
4328         let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
4329         assert!(v[0] > 0.4 && v[1].abs() < 2e-3, "rolled right by a notch: {v:?}");
4330         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.float3s[1].as_ref().unwrap().ball_id()));
4331         assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
4332         assert_eq!(p.display_params[0].1, "0.000:0.000:2.000", "the row above is untouched");
4333         set_scroll_phase(ScrollPhase::Wheel);
4334     }
4335 
4336     #[test]
4337     fn ramp_row_builds_from_spec_and_edits_serialize_back() {
4338         let mut ctx = UiContext::new();
4339         let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
4340         let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
4341         assert_eq!(rp.inner().keys.len(), 2);
4342         assert!(rp.inner().smooth());
4343 
4344         // A press inside the curve area adds a key, and the row value carries
4345         // the re-serialized spec (what hosts poll and persist).
4346         let (rx, ry, rw, _) = rp.rect();
4347         p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
4348         assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
4349         let val = &ParamController::node_params(&*p)[0].1;
4350         assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
4351         assert!(val.starts_with("smooth;"));
4352     }
4353 
4354     #[test]
4355     fn toggle_click_commits_value_and_unfocus_commits_editor() {
4356         let mut ctx = UiContext::new();
4357         let mut p = panel_with(&[("On", "false", "checkbox")]);
4358         let (cx, cy, _, ch) = p.toggles[0].as_ref().unwrap().rect();
4359         // Click the toggle row (presses are ungated for this widget; the panel consumes
4360         // every left press, so the return is true either way — assert the value flip).
4361         p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
4362         p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
4363         assert_eq!(ParamController::node_params(&*p)[0].1, "true");
4364 
4365         // Code editor: focus it via a click, type, then unfocus commits the buffer.
4366         let mut p = panel_with(&[("Src", "let x = 1;", "code")]);
4367         let rects = p.get_param_rects();
4368         let r = rects[0];
4369         p.mouse_input(MouseButton::Left, ElementState::Pressed, r.0 + 20.0, r.1 + 30.0, &mut ctx);
4370         assert_eq!(p.focused_param, Some(0), "code row focused");
4371         assert!(p.code_editor.is_some());
4372         p.code_editor.as_mut().unwrap().insert_text("y");
4373         WidgetHost::unfocus(&mut p);
4374         assert_eq!(p.focused_param, None);
4375         assert!(p.code_editor.is_none());
4376         assert!(ParamController::node_params(&*p)[0].1.contains('y'), "editor buffer committed on unfocus");
4377     }
4378 
4379     fn key(k: Key, ctrl: bool, shift: bool) -> Event {
4380         Event::KeyInput(crate::widget::KeyEvent { state: ElementState::Pressed, logical_key: k, text: None, repeat: false, ctrl, shift, alt: false })
4381     }
4382 
4383     fn chr(c: &str) -> Event {
4384         key(Key::Character(c.into()), false, false)
4385     }
4386 
4387     fn ctrl(c: &str) -> Event {
4388         key(Key::Character(c.into()), true, false)
4389     }
4390 
4391     fn named(k: NamedKey) -> Event {
4392         key(Key::Named(k), false, false)
4393     }
4394 
4395     fn shift_named(k: NamedKey) -> Event {
4396         key(Key::Named(k), false, true)
4397     }
4398 
4399     /// A code row focused by a click at its first character.
4400     fn code_panel(src: &str) -> (Adapted<ParametersBg>, UiContext) {
4401         let mut ctx = UiContext::new();
4402         let mut p = panel_with(&[("Src", src, "code")]);
4403         let r = p.get_param_rects()[0];
4404         let x = p.code_text_x(r);
4405         p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0, &mut ctx);
4406         assert_eq!(p.focused_param, Some(0));
4407         (p, ctx)
4408     }
4409 
4410     fn send(p: &mut Adapted<ParametersBg>, ctx: &mut UiContext, ev: Event) -> bool {
4411         WidgetHost::handle_event(p, &ev, ctx)
4412     }
4413 
4414     /// Typing edits the buffer and leaves the VALUE alone until ctrl+enter
4415     /// applies it — a script would otherwise re-run on every keystroke — and
4416     /// the row says so: dirty while they differ, clean once applied.
4417     #[test]
4418     fn code_edits_apply_on_ctrl_enter_not_per_keystroke() {
4419         let (mut p, mut ctx) = code_panel("let x = 1;");
4420         assert!(!p.code_is_dirty());
4421         send(&mut p, &mut ctx, chr("/"));
4422         send(&mut p, &mut ctx, chr("/"));
4423         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "//let x = 1;");
4424         assert_eq!(ParamController::node_params(&*p)[0].1, "let x = 1;", "the value waits");
4425         assert!(p.code_is_dirty());
4426         send(&mut p, &mut ctx, key(Key::Named(NamedKey::Enter), true, false));
4427         assert_eq!(ParamController::node_params(&*p)[0].1, "//let x = 1;", "ctrl+enter applies");
4428         assert!(!p.code_is_dirty());
4429         assert_eq!(p.focused_param, Some(0), "and keeps editing");
4430         // Escape applies too, and leaves the row.
4431         send(&mut p, &mut ctx, chr("!"));
4432         send(&mut p, &mut ctx, named(NamedKey::Escape));
4433         assert_eq!(ParamController::node_params(&*p)[0].1, "//!let x = 1;");
4434         assert_eq!(p.focused_param, None);
4435     }
4436 
4437     /// Tab indents, shift+tab dedents, and Enter carries the indentation —
4438     /// one level deeper after an opening brace.
4439     #[test]
4440     fn code_editor_indents_like_an_editor() {
4441         let (mut p, mut ctx) = code_panel("");
4442         send(&mut p, &mut ctx, chr("i"));
4443         send(&mut p, &mut ctx, chr("f"));
4444         send(&mut p, &mut ctx, chr(" "));
4445         send(&mut p, &mut ctx, chr("{"));
4446         send(&mut p, &mut ctx, named(NamedKey::Enter));
4447         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    ", "a brace opens a level");
4448         send(&mut p, &mut ctx, chr("x"));
4449         send(&mut p, &mut ctx, named(NamedKey::Enter));
4450         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n    ", "the level carries");
4451         send(&mut p, &mut ctx, shift_named(NamedKey::Tab));
4452         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n", "shift+tab dedents the line");
4453         send(&mut p, &mut ctx, chr("}"));
4454         send(&mut p, &mut ctx, named(NamedKey::Tab));
4455         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "if {\n    x\n}    ", "tab inserts a level");
4456         assert!(send(&mut p, &mut ctx, named(NamedKey::Tab)), "tab is the editor's: it does not fall through to the host");
4457     }
4458 
4459     /// shift+arrows select, typing replaces the selection, ctrl+z undoes a
4460     /// typing run as one step and ctrl+shift+z redoes it.
4461     #[test]
4462     fn code_editor_selects_and_undoes() {
4463         let (mut p, mut ctx) = code_panel("abc\ndef");
4464         send(&mut p, &mut ctx, named(NamedKey::End));
4465         send(&mut p, &mut ctx, shift_named(NamedKey::ArrowDown));
4466         let e = p.code_editor.as_ref().unwrap();
4467         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");
4468         send(&mut p, &mut ctx, chr("Z"));
4469         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZ", "typing replaces the selection");
4470         send(&mut p, &mut ctx, chr("Y"));
4471         send(&mut p, &mut ctx, chr("X"));
4472         send(&mut p, &mut ctx, ctrl("z"));
4473         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc\ndef", "one typing run, one undo");
4474         send(&mut p, &mut ctx, key(Key::Character("z".into()), true, true));
4475         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcZYX", "and one redo");
4476         send(&mut p, &mut ctx, key(Key::Character("a".into()), true, true));
4477         assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcZYX"), "ctrl+shift+a selects all");
4478         assert_eq!(ParamController::node_params(&*p)[0].1, "abc\ndef", "none of it applied yet");
4479     }
4480 
4481     /// The host flags an error line and the gutter number turns red for it;
4482     /// a click lands the caret past the gutter, on the column it named.
4483     #[test]
4484     fn code_row_flags_an_error_line_and_clicks_land_past_the_gutter() {
4485         let (mut p, mut ctx) = code_panel("one\ntwo\nthree");
4486         p.set_code_error_line(Some(1));
4487         let labels = p.own_text_labels();
4488         let two = labels.iter().find(|l| l.text == "  2").expect("a gutter number for line 2");
4489         assert_eq!(two.color, [0xff, 0x80, 0x70]);
4490         let one = labels.iter().find(|l| l.text == "  1").unwrap();
4491         assert_eq!(one.color, [0x66, 0x66, 0x78]);
4492         let r = p.get_param_rects()[0];
4493         let x = p.code_text_x(r) + 2.0 * p.code_col_w();
4494         p.mouse_input(MouseButton::Left, ElementState::Pressed, x, r.1 + ParametersBg::CODE_TOP + 2.0 * ParametersBg::CODE_LINE_H + 2.0, &mut ctx);
4495         let e = p.code_editor.as_ref().unwrap();
4496         assert_eq!(get_cursor_line_col(&e.buffer, e.cursor_idx), (2, 2), "line 3, column 2");
4497         p.set_code_error_line(None);
4498         assert!(p.own_text_labels().iter().all(|l| l.color != [0xff, 0x80, 0x70]));
4499     }
4500 
4501     /// The context actions reach the editor: undo through the runner's
4502     /// routing is the editor's own history, and select-all selects the
4503     /// buffer. Outside an edit the pane declines them.
4504     #[test]
4505     fn code_editor_answers_context_actions_while_editing() {
4506         use crate::widget::ContextAction;
4507         let (mut p, mut ctx) = code_panel("abc");
4508         send(&mut p, &mut ctx, named(NamedKey::End));
4509         send(&mut p, &mut ctx, chr("d"));
4510         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
4511         assert!(Input::context_action(&mut *p, ContextAction::Undo));
4512         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abc", "undo through the context action");
4513         assert!(Input::context_action(&mut *p, ContextAction::Redo));
4514         assert_eq!(p.code_editor.as_ref().unwrap().buffer, "abcd");
4515         assert!(Input::context_action(&mut *p, ContextAction::SelectAll));
4516         assert_eq!(p.code_editor.as_ref().unwrap().selected_text().as_deref(), Some("abcd"));
4517         WidgetHost::unfocus(&mut p);
4518         assert!(!Input::context_action(&mut *p, ContextAction::Undo), "nothing to act on once the editor is closed");
4519     }
4520 
4521     #[test]
4522     fn clicking_a_section_title_collapses_its_rows() {
4523         let mut ctx = UiContext::new();
4524         let mut p = panel_with(&[
4525             ("Transform", "", "section"),
4526             ("Size", "1.00", "slider:0:2"),
4527             ("Shading", "", "section"),
4528             ("On", "true", "checkbox"),
4529         ]);
4530         let expanded_h = p.get_total_content_height();
4531         let below_before = p.get_param_rects()[2].1;
4532 
4533         // Press the first section's title box.
4534         let r_hdr = p.get_param_rects()[0];
4535         let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
4536         p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
4537 
4538         assert!(p.section_collapsed("Transform"));
4539         assert_eq!(p.get_param_rects()[1].3, 0.0, "the collapsed section's row has no height");
4540         assert!(p.get_param_rects()[2].1 < below_before, "the next section moves up");
4541         assert!(p.get_total_content_height() < expanded_h);
4542         // The row's chrome and label are gone; the header's stay.
4543         assert!(!p.own_text_labels().iter().any(|l| l.text.contains("Size")));
4544         assert!(p.own_text_labels().iter().any(|l| l.text.contains("Transform")));
4545 
4546         // Clicking it again restores the section.
4547         let r_hdr = p.get_param_rects()[0];
4548         let (bx, by, _, bh) = p.section_title_box(0, r_hdr);
4549         p.mouse_input(MouseButton::Left, ElementState::Pressed, bx + 4.0, by + bh / 2.0, &mut ctx);
4550         assert!(!p.section_collapsed("Transform"));
4551         assert_eq!(p.get_total_content_height(), expanded_h);
4552     }
4553 
4554     /// Both ends of a straight run, and of an arc, as points on the path.
4555     fn run_ends(r: &(f32, f32, f32, f32)) -> [(f32, f32); 2] {
4556         let (x, y, w, h) = *r;
4557         if w > h {
4558             [(x, y), (x + w, y)]
4559         } else {
4560             [(x, y), (x, y + h)]
4561         }
4562     }
4563     fn arc_ends(a: &(f32, f32, f32, f32, f32)) -> [(f32, f32); 2] {
4564         let (cx, cy, r, a0, a1) = *a;
4565         [
4566             (cx + r * a0.cos(), cy + r * a0.sin()),
4567             (cx + r * a1.cos(), cy + r * a1.sin()),
4568         ]
4569     }
4570 
4571     #[test]
4572     fn section_outline_is_one_continuous_path() {
4573         let p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
4574         let (title, content) = p.section_boxes().into_iter().next().expect("one section");
4575         let content = content.expect("the section has a content box");
4576         let (runs, arcs) = p.section_outline(title, content.into());
4577 
4578         // The tab sits flush on the body — its bottom edge is the body's top edge.
4579         assert_eq!(title.1 + title.3, content.1, "the tab fuses to the body");
4580         // Folder-tab shape: the tab's two top corners, the concave throat where its right
4581         // side turns onto the body's top edge, and the body's three remaining corners
4582         // (its top-LEFT is the tab's left side running straight through).
4583         assert_eq!(arcs.len(), 6);
4584         let (tab_right, body_top) = (title.0 + title.2, content.1);
4585         let throats = arcs
4586             .iter()
4587             .filter(|(cx, cy, ..)| *cx > tab_right - 0.01 && *cy < body_top)
4588             .count();
4589         assert_eq!(throats, 1, "the throat — centred out in the pocket right of the tab");
4590 
4591         // Every corner hands off to a straight run — no arc dangles. (Within a stroke width:
4592         // runs and arcs are anchored on opposite ink sides at the concave corner.)
4593         let ends: Vec<(f32, f32)> = runs.iter().flat_map(run_ends).collect();
4594         for arc in &arcs {
4595             for (ax, ay) in arc_ends(arc) {
4596                 let nearest = ends
4597                     .iter()
4598                     .map(|(x, y)| ((x - ax).powi(2) + (y - ay).powi(2)).sqrt())
4599                     .fold(f32::INFINITY, f32::min);
4600                 assert!(nearest <= SECTION_BORDER_T + 0.01, "corner at ({ax}, {ay}) dangles: {nearest}");
4601             }
4602         }
4603 
4604         // The tab's bottom edge is open (no run along it), and the body's top edge runs
4605         // only right of the throat.
4606         let tab_bottom = title.1 + title.3 - SECTION_BORDER_T;
4607         let bottom_runs = runs
4608             .iter()
4609             .filter(|(_, y, w, _)| (*y - tab_bottom).abs() < 0.01 && *w > SECTION_BORDER_T)
4610             .count();
4611         assert_eq!(bottom_runs, 0, "the tab opens onto the body");
4612         let top_runs: Vec<&(f32, f32, f32, f32)> = runs
4613             .iter()
4614             .filter(|(_, y, w, _)| (*y - body_top).abs() < 0.01 && *w > SECTION_BORDER_T)
4615             .collect();
4616         assert_eq!(top_runs.len(), 1, "the body's top edge starts past the tab");
4617         assert!(top_runs[0].0 >= tab_right, "…right of the throat");
4618 
4619         // The left edge is ONE straight run from the tab's top corner to the body's
4620         // bottom corner.
4621         let left_runs: Vec<&(f32, f32, f32, f32)> = runs
4622             .iter()
4623             .filter(|(x, _, _, h)| (*x - title.0).abs() < 0.01 && *h > 0.0)
4624             .collect();
4625         assert_eq!(left_runs.len(), 1, "tab + body share one left side");
4626         assert!((left_runs[0].1 - (title.1 + SECTION_R)).abs() < 0.01);
4627         assert!((left_runs[0].1 + left_runs[0].3 - (content.1 + content.3 - SECTION_R)).abs() < 0.01);
4628     }
4629 
4630     #[test]
4631     fn a_collapsed_section_outline_closes_on_itself() {
4632         let mut p = panel_with(&[("Transform", "", "section"), ("Size", "1.00", "slider:0:2")]);
4633         p.set_section_collapsed("Transform", true);
4634         let (title, content) = p.section_boxes().into_iter().next().expect("one section");
4635         assert!(content.is_none(), "nothing to wrap below a collapsed header");
4636         let (runs, arcs) = p.section_outline(title, None);
4637         assert_eq!(arcs.len(), 4, "a plain rounded rect");
4638         assert_eq!(runs.len(), 4);
4639     }
4640 
4641     #[test]
4642     fn rows_pack_on_the_channel_and_sections_separate_wider() {
4643         let p = panel_with(&[
4644             ("Transform", "", "section"),
4645             ("Size", "1.00", "slider:0:2"),
4646             ("Shading", "", "section"),
4647             ("On", "true", "checkbox"),
4648         ]);
4649         let rects = p.get_param_rects();
4650         let content_bottom = |i: usize| rects[i].1 + rects[i].3 + CONTENT_BOX_PAD;
4651         let title_top = |i: usize| rects[i].1 - TITLE_BOX_INSET;
4652 
4653         // Inside a section everything packs on the channel: each tab sits flush on its
4654         // content box, and the rows keep one channel from the box's walls.
4655         for (title, content) in p.section_boxes() {
4656             let content = content.expect("both sections have content");
4657             assert_eq!(title.1 + title.3, content.1, "tab flush on its body");
4658         }
4659         let (_, content0) = p.section_boxes()[0];
4660         let content0 = content0.unwrap();
4661         assert_eq!(rects[1].1 - content0.1, CHANNEL, "row -> its box's top wall");
4662         assert_eq!(rects[1].0 - content0.0, CHANNEL, "row -> its box's side wall");
4663 
4664         // Section to section stays far wider, so the blocks still read apart.
4665         assert_eq!(title_top(2) - content_bottom(1), SECTION_GAP, "section -> next section");
4666         const { assert!(SECTION_GAP > 2.0 * CHANNEL, "sections separate wider than any channel") };
4667     }
4668 
4669     #[test]
4670     fn a_collapsed_section_keeps_the_same_gap_to_the_next_one() {
4671         // With no content box under it, the collapsed section's bottom edge is its own title
4672         // box — a fixed row-pitch bump would leave a double gap here.
4673         let mut p = panel_with(&[
4674             ("Transform", "", "section"),
4675             ("Size", "1.00", "slider:0:2"),
4676             ("Shading", "", "section"),
4677             ("On", "true", "checkbox"),
4678         ]);
4679         p.set_section_collapsed("Transform", true);
4680         let rects = p.get_param_rects();
4681         let collapsed_bottom = rects[0].1 - TITLE_BOX_INSET + TITLE_BOX_H;
4682         let next_title_top = rects[2].1 - TITLE_BOX_INSET;
4683         assert_eq!(next_title_top - collapsed_bottom, SECTION_GAP);
4684     }
4685 
4686     #[test]
4687     fn collapse_survives_a_param_rebuild_and_swallows_row_clicks() {
4688         let mut ctx = UiContext::new();
4689         let mut p = panel_with(&[("Shading", "", "section"), ("On", "false", "checkbox")]);
4690         p.set_section_collapsed("Shading", true);
4691 
4692         // A click where the toggle used to sit must not reach it.
4693         let (cx, cy, _, ch) = p.toggles[1].as_ref().unwrap().rect();
4694         p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
4695         p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
4696         assert_eq!(ParamController::node_params(&*p)[1].1, "false", "hidden row ignores clicks");
4697 
4698         // The host re-syncs the panel (node change): collapse is keyed by title, so it holds.
4699         let params: Vec<(String, String, String)> = [("Shading", "", "section"), ("On", "false", "checkbox"), ("Extra", "1", "int")]
4700             .iter()
4701             .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
4702             .collect();
4703         ParamController::set_display_params(&mut *p, &params);
4704         assert!(p.section_collapsed("Shading"));
4705         assert_eq!(p.get_param_rects()[1].3, 0.0);
4706     }
4707 
4708     #[test]
4709     fn the_row_chrome_is_the_panes_and_the_plate_the_hosts() {
4710         let ctx = UiContext::new();
4711         let p = panel_with(&[("Size", "1.00", "slider:0:2")]);
4712         // The pane's plain quads carry the row chrome (clipped), including the slider
4713         // background it reads via rect()+color()...
4714         let plain = p.plain_quads();
4715         assert!(!plain.is_empty(), "row chrome in the pane's plain quads");
4716         // ...but NOT the panel's own PARAM_BG plate (the host draws that from color()).
4717         let (x, y, w, h) = WidgetHost::rect(&p);
4718         assert!(
4719             !plain.iter().any(|q| (q.0, q.1, q.2, q.3) == (x, y, w, h)),
4720             "panel bg plate is the host's, not the pane's"
4721         );
4722         // Per-label hatch: the walk's text prims carry the widget font and viewport bounds.
4723         let mut scratch = crate::scene::paint::PaintCtx::new();
4724         crate::scene::painter::append_widget_text(&ctx, &p, &mut scratch);
4725         let labels: Vec<_> = scratch
4726             .finish()
4727             .items
4728             .into_iter()
4729             .filter_map(|item| match item.prim {
4730                 crate::scene::paint::Prim::Text { font, bounds, .. } => Some((font, bounds)),
4731                 _ => None,
4732             })
4733             .collect();
4734         assert!(!labels.is_empty());
4735         assert!(labels.iter().all(|(font, bounds)| font.is_some() && bounds.is_some()));
4736     }
4737 
4738     #[test]
4739     fn scroll_wheel_scrolls_when_content_overflows() {
4740         let mut ctx = UiContext::new();
4741         let rows: Vec<(String, String, String)> = (0..30)
4742             .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4743             .collect();
4744         let mut p = ParametersBg::new();
4745         ParamController::set_display_params(&mut *p, &rows);
4746         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4747         assert!(p.content_h > 200.0);
4748         assert!(crate::widget::WidgetHostExt::is_scrollable(&p));
4749         // Wheel over the panel body but off every slider row's x-span is impossible (rows are
4750         // full-width), so scroll via the region below the last visible row: use a y between
4751         // rows (the 2px slack above a row) — simplest is the bottom padding strip.
4752         let before = p.scroll_y;
4753         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 150.0, 199.0, &mut ctx);
4754         // Either a slider consumed it (value change) or the panel scrolled; both mark change.
4755         // The panel-scroll path must work when no slider is under the pointer:
4756         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4757         assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
4758     }
4759 
4760     /// A scroll moves the rows under a still pointer, and the hover follows
4761     /// the rows: the control that was under the pointer goes dark and the one
4762     /// now there lights, without a pointer motion. The pointer's last
4763     /// position is what the pane re-hovers from, so the wheel's own position
4764     /// (over the label column, where the pane takes it) need not be it.
4765     #[test]
4766     fn a_scroll_re_hovers_the_control_under_a_still_pointer() {
4767         let mut ctx = UiContext::new();
4768         let rows: Vec<(String, String, String)> = (0..30)
4769             .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4770             .collect();
4771         let mut p = ParametersBg::new();
4772         ParamController::set_display_params(&mut *p, &rows);
4773         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4774         let hovered = |p: &Adapted<ParametersBg>| -> Vec<usize> {
4775             p.sliders.iter().enumerate().filter(|(_, s)| s.as_ref().is_some_and(|s| s.inner().hovered())).map(|(i, _)| i).collect()
4776         };
4777         assert!(hovered(&p).is_empty());
4778 
4779         // Over the first row's control.
4780         let r0 = p.get_param_rects()[0];
4781         let (px, py) = (250.0, r0.1 + r0.3 * 0.5);
4782         p.on_cursor_moved(px, py, &mut ctx);
4783         assert_eq!(hovered(&p), vec![0], "the slider under the pointer hovers");
4784         assert_eq!(p.hover_row, Some(0), "and the pane lifts its label");
4785 
4786         // A wheel over the label column scrolls the pane: a notch moves the
4787         // TARGET and the rows glide there over the following ticks, so the
4788         // re-hover that matters is the tick's. The pointer's stored position
4789         // has not moved, but the rows under it have.
4790         ctx.scroll_gesture_new = true;
4791         let before = p.scroll_y;
4792         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, py, &mut ctx);
4793         for _ in 0..120 {
4794             WidgetHost::tick(&mut p, 1.0 / 60.0, &mut ctx);
4795         }
4796         assert!(p.scroll_y > before, "the pane scrolled: {} -> {}", before, p.scroll_y);
4797         let now = p
4798             .get_param_rects()
4799             .iter()
4800             .position(|r| py >= r.1 && py <= r.1 + r.3)
4801             .expect("a row under the pointer after the scroll");
4802         assert_ne!(now, 0, "a different row is under the pointer");
4803         assert_eq!(hovered(&p), vec![now], "the hover followed the rows, without a motion");
4804         assert_eq!(p.hover_row, Some(now), "the label lift followed too");
4805 
4806         // Off the pane, nothing is hovered.
4807         p.on_cursor_moved(-9999.0, -9999.0, &mut ctx);
4808         assert!(hovered(&p).is_empty());
4809         assert_eq!(p.hover_row, None);
4810     }
4811 
4812     /// An inline label is cut only when it does not fit its column, by the
4813     /// measure the column is sized with. It used to be cut to as many
4814     /// characters as the column holds M's: in a proportional face a label
4815     /// of narrow letters is far narrower than that many M's, so a label the
4816     /// column was sized to hold lost its tail. On a runner whose fallback
4817     /// face drew "M" a pixel wider, "Count" became "C..." in a column with
4818     /// 30 px to spare.
4819     #[test]
4820     fn an_inline_label_is_cut_only_when_it_does_not_fit() {
4821         use crate::widget::display::measure_text_width;
4822         let narrow = "iiiiiiiiiiii";
4823         let long = "A parameter name far too long for any label column of this pane";
4824         let mut p = panel_with(&[(narrow, "x", "text"), ("Count", "3", "spinbox:0:10"), (long, "y", "text")]);
4825         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4826         let (family, size) = ParametersBg::inline_label_font();
4827         let avail = p.label_col_w() - ParametersBg::LABEL_GAP;
4828         assert!(avail > 0.0, "the pane lays its labels out inline");
4829         let texts: Vec<String> = p.own_text_labels().into_iter().map(|l| l.text).collect();
4830         for name in [narrow, "Count"] {
4831             if measure_text_width(name, &family, size) <= avail {
4832                 assert!(texts.iter().any(|t| t == name), "{name:?} fits its {avail} px column and is drawn whole: {texts:?}");
4833             }
4834         }
4835         assert!(measure_text_width(long, &family, size) > avail, "the long label cannot fit");
4836         let cut = texts.iter().find(|t| t.starts_with("A p") && t.ends_with("...")).expect("the long label is cut");
4837         assert!(measure_text_width(cut, &family, size) <= avail, "and what is left fits: {cut:?}");
4838     }
4839 
4840     /// The hover cue is the row's LABEL and the row's OWN carves lit through
4841     /// the tint channel — and nothing else in the pane's paint changes with
4842     /// it. A hover adds no geometry: it can neither close the pane plate's
4843     /// carve-grouping window (a wash before the wells did, and every well
4844     /// flipped shading on any hover) nor composite over a well's shade lines
4845     /// (a wash after them did, and the hovered well's outline paled). Both
4846     /// shipped for part of 2026-09-28. The other rows' carves keep their
4847     /// untinted, grouped shading.
4848     #[test]
4849     fn a_hover_lifts_the_label_and_lights_only_its_own_carves() {
4850         use crate::scene::paint::Prim;
4851         let mut ctx = UiContext::new();
4852         let mut p = panel_with(&[("Name", "x", "text"), ("Count", "3", "spinbox:0:10"), ("Size", "0.5", "slider:0:1")]);
4853         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4854         let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 400.0 };
4855         let prims = |p: &Adapted<ParametersBg>, ctx: &UiContext| {
4856             let mut pc = crate::scene::paint::PaintCtx::new();
4857             Paint::paint_ui(&**p, ctx, rect, &mut pc);
4858             pc.finish().items.into_iter().map(|i| i.prim).collect::<Vec<_>>()
4859         };
4860         let label_of = |p: &Adapted<ParametersBg>, name: &str| {
4861             p.own_text_labels().into_iter().find(|l| l.text == name).map(|l| l.color).expect("a row label")
4862         };
4863         // A carve's rect and tint, whatever its kind.
4864         let carve = |prim: &Prim| match prim {
4865             Prim::Recess { rect, tint, .. }
4866             | Prim::Boss { rect, tint, .. }
4867             | Prim::Trough { rect, tint, .. }
4868             | Prim::Field { rect, tint, .. } => Some((*rect, *tint)),
4869             _ => None,
4870         };
4871         let at_rest = prims(&p, &ctx);
4872         assert!(at_rest.iter().all(|pr| carve(pr).is_none_or(|(_, t)| t.is_none())), "nothing is tinted at rest");
4873         assert_eq!(label_of(&p, "Count"), ParametersBg::LABEL);
4874 
4875         let (x, y, w, h) = p.get_param_rects()[1];
4876         p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4877         assert_eq!(p.hover_row, Some(1));
4878         assert_eq!(label_of(&p, "Count"), ParametersBg::LABEL_HOVER, "the hovered row's label lifts");
4879         assert_eq!(label_of(&p, "Name"), ParametersBg::LABEL, "and only that row's");
4880 
4881         let hovered = prims(&p, &ctx);
4882         assert_eq!(hovered.len(), at_rest.len(), "a hover adds no geometry");
4883         let mut lit = 0;
4884         for (a, b) in at_rest.iter().zip(&hovered) {
4885             if a == b {
4886                 continue;
4887             }
4888             let ((ra, ta), (rb, tb)) = (carve(a).expect("only carves change"), carve(b).expect("only carves change"));
4889             assert_eq!(ra, rb, "a carve keeps its place");
4890             assert_eq!((ta, tb), (None, Some(ParametersBg::HOVER_TINT)), "the change is the hover tint");
4891             assert!(rb.y >= y - 0.5 && rb.y + rb.height <= y + h + 0.5, "and only inside the hovered row: {rb:?}");
4892             lit += 1;
4893         }
4894         assert!(lit > 0, "the hovered row's carves light");
4895     }
4896 
4897     /// The label lift is the pane's rule, not the control's: a spinbox row,
4898     /// which draws no hover of its own under the relief style, lifts exactly
4899     /// as a slider row does, and a section header never does.
4900     #[test]
4901     fn every_control_kind_hovers_by_its_row() {
4902         let mut ctx = UiContext::new();
4903         let mut p = panel_with(&[
4904             ("Shape", "", "section"),
4905             ("Count", "3", "spinbox:0:10"),
4906             ("Mode", "A", "choice:A,B"),
4907             ("Size", "0.5", "slider:0:1"),
4908         ]);
4909         WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
4910         let rects = p.get_param_rects();
4911         for i in 1..4 {
4912             let (x, y, w, h) = rects[i];
4913             p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4914             assert_eq!(p.hover_row, Some(i), "row {i} under the pointer");
4915         }
4916         let (x, y, w, h) = rects[0];
4917         p.on_cursor_moved(x + w * 0.5, y + h * 0.5, &mut ctx);
4918         assert_eq!(p.hover_row, None, "a header is not a hoverable row");
4919     }
4920 
4921     /// A gesture the pane acquired stays the pane's: rows travelling under
4922     /// the pointer mid-gesture must not hand the wheel to the slider that
4923     /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
4924     /// over the same slider still adjusts it.
4925     #[test]
4926     fn pane_owned_scroll_gesture_is_not_captured_by_a_slider_sliding_under_the_pointer() {
4927         let rows: Vec<(String, String, String)> = (0..30)
4928             .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
4929             .collect();
4930         let fresh = || {
4931             let mut p = ParametersBg::new();
4932             ParamController::set_display_params(&mut *p, &rows);
4933             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
4934             p
4935         };
4936         // A point on a slider's band: row 1's rect, below the label strip.
4937         let band_point = |p: &Adapted<ParametersBg>| {
4938             let r = p.get_param_rects()[1];
4939             (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
4940         };
4941         let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4942 
4943         // Control: a new gesture ON the band adjusts the slider (the point is a real hit).
4944         let mut ctx = UiContext::new();
4945         let mut p = fresh();
4946         let (bx, by) = band_point(&p);
4947         ctx.scroll_gesture_new = true;
4948         ctx.scroll_initiate_widget_id = None;
4949         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4950         assert_ne!(values(&p)[1], "1.00", "a new gesture on the band adjusts the slider");
4951 
4952         // The case: the gesture starts on the pane (dead space), the pane owns it...
4953         let mut ctx = UiContext::new();
4954         let mut p = fresh();
4955         ctx.scroll_gesture_new = true;
4956         ctx.scroll_initiate_widget_id = None;
4957         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
4958         // (The scroll itself is animated by scroll_motion over later ticks, so
4959         // ownership — set only on the pane-scroll path — is the witness.)
4960         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane owns the gesture");
4961         // ...and the same gesture continuing over a band adjusts nothing.
4962         let (bx, by) = band_point(&p);
4963         ctx.scroll_gesture_new = false;
4964         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
4965         assert!(values(&p).iter().all(|v| v == "1.00"), "no slider took the pane's gesture: {:?}", values(&p));
4966         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
4967     }
4968 
4969     /// A trackpad gesture belongs to what it BEGINS on: over a slider's band
4970     /// it turns the slider — in a pane that overflows and in one that fits
4971     /// alike — and keeps turning it as the pointer drifts; beginning on the
4972     /// label column it scrolls the pane, and stays the pane's as bands pass
4973     /// under the pointer. A float3's rows are three such bands. (Until
4974     /// 2026-09-28 every finger gesture was the pane's, from anywhere, and a
4975     /// slider could be turned by a wheel notch but not by a trackpad.)
4976     #[test]
4977     fn a_finger_gesture_belongs_to_the_control_it_begins_on() {
4978         use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
4979         let rows = |n: usize| -> Vec<(String, String, String)> {
4980             (0..n).map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string())).collect()
4981         };
4982         let panel = |n: usize, h: f32| {
4983             let mut p = ParametersBg::new();
4984             ParamController::set_display_params(&mut *p, &rows(n));
4985             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
4986             p
4987         };
4988         // A point on row 1's band: the slider's own rect, below its label strip.
4989         let band_point = |p: &Adapted<ParametersBg>| {
4990             let s = p.sliders[1].as_ref().unwrap();
4991             let (sx, sy, sw, sh) = s.rect();
4992             let ty = s.label_strip();
4993             (sx + sw * 0.3, sy + ty + (sh - ty) * 0.5)
4994         };
4995         let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
4996         let finger = |dy: f64| MouseScrollDelta::PixelDelta(Position { x: 0.0, y: dy });
4997 
4998         for (n, h) in [(30, 200.0), (3, 600.0)] {
4999             // Beginning ON the band: the slider turns, and owns the gesture.
5000             let mut ctx = UiContext::new();
5001             let mut p = panel(n, h);
5002             let (bx, by) = band_point(&p);
5003             ctx.scroll_gesture_new = true;
5004             set_scroll_phase(ScrollPhase::Finger);
5005             assert!(p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx));
5006             let slider_id = p.sliders[1].as_ref().unwrap().base().id();
5007             assert_ne!(values(&p)[1], "1.00", "a finger gesture on the band turns the slider ({n} rows)");
5008             assert_eq!(ctx.scroll_initiate_widget_id, Some(slider_id), "the slider owns the gesture");
5009             assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
5010             // Still its gesture with the pointer drifted onto the next band.
5011             let turned = values(&p)[1].clone();
5012             let (_, sy2, _, sh2) = p.sliders[2].as_ref().unwrap().rect();
5013             ctx.scroll_gesture_new = false;
5014             assert!(p.mouse_wheel(&finger(-60.0), bx, sy2 + sh2 * 0.7, &mut ctx));
5015             assert_ne!(values(&p)[1], turned, "latched: the first slider keeps turning");
5016             assert_eq!(values(&p)[2], "1.00", "the band under the drifted pointer holds");
5017         }
5018 
5019         // Beginning on the label column: the pane scrolls and owns the
5020         // gesture, and a band passing under the pointer adjusts nothing.
5021         let mut ctx = UiContext::new();
5022         let mut p = panel(30, 200.0);
5023         let (_, by) = band_point(&p);
5024         ctx.scroll_gesture_new = true;
5025         set_scroll_phase(ScrollPhase::Finger);
5026         p.mouse_wheel(&finger(-30.0), ROW_X_INSET + 2.0, by, &mut ctx);
5027         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
5028         assert!(p.scroll_y > 0.0, "the pane scrolled (finger tracks 1:1): {}", p.scroll_y);
5029         let (bx, by) = band_point(&p);
5030         ctx.scroll_gesture_new = false;
5031         p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx);
5032         assert!(values(&p).iter().all(|v| v == "1.00"), "a pane-owned gesture turns nothing: {:?}", values(&p));
5033 
5034         // A float3's rows are three bands: a finger gesture on the Y row
5035         // turns Y alone.
5036         let mut ctx = UiContext::new();
5037         let mut p = panel_with(&[("Name", "x", "text"), ("Offset", "0.00:0.00:0.00", "float3:-1:1")]);
5038         let f = p.float3s[1].as_ref().unwrap();
5039         let (rx, ry, rw, rh) = f.get_row_rects()[1];
5040         let chrome = crate::widget::input::slider::Slider::readout_chrome();
5041         let (bx, by) = (rx + (rw - chrome) * 0.5, ry + rh * 0.5);
5042         ctx.scroll_gesture_new = true;
5043         set_scroll_phase(ScrollPhase::Finger);
5044         assert!(p.mouse_wheel(&finger(-120.0), bx, by, &mut ctx));
5045         let parts: Vec<f32> = p.display_params[1].1.split(':').map(|v| v.parse().unwrap()).collect();
5046         assert_eq!((parts[0], parts[2]), (0.0, 0.0), "X and Z hold: {parts:?}");
5047         assert_ne!(parts[1], 0.0, "Y turned: {parts:?}");
5048         assert_eq!(p.scroll_y, 0.0);
5049         set_scroll_phase(ScrollPhase::Wheel);
5050     }
5051 
5052     /// The same rule on a spinbox, whose zone is its row: a gesture that
5053     /// begins on a spinbox row steps the spinbox — a finger's fractional notches accumulating
5054     /// into whole steps — and stays the spinbox's, while a gesture the pane
5055     /// acquired still scrolls past it. A wheel notch on the row steps it too,
5056     /// however the scroll factor scales the notch.
5057     #[test]
5058     fn a_gesture_beginning_on_a_spinbox_row_steps_it_trackpad_included() {
5059         use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
5060         let rows = |n: usize| -> Vec<(String, String, String)> {
5061             (0..n)
5062                 .map(|i| if i == 1 { ("Count".to_string(), "10".to_string(), "spinbox:0:100:1".to_string()) } else { (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()) })
5063                 .collect()
5064         };
5065         let panel = |n: usize, h: f32| {
5066             let mut p = ParametersBg::new();
5067             ParamController::set_display_params(&mut *p, &rows(n));
5068             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
5069             p
5070         };
5071         let row_point = |p: &Adapted<ParametersBg>| {
5072             let r = p.get_param_rects()[1];
5073             (r.0 + r.2 * 0.3, r.1 + r.3 * 0.5)
5074         };
5075         let count = |p: &Adapted<ParametersBg>| p.display_params[1].1.clone();
5076 
5077         // A finger gesture beginning on the row: 60 px of travel is one step,
5078         // the spinbox owns the gesture, and the pane does not scroll.
5079         let mut ctx = UiContext::new();
5080         let mut p = panel(30, 200.0);
5081         let (x, y) = row_point(&p);
5082         ctx.scroll_gesture_new = true;
5083         set_scroll_phase(ScrollPhase::Finger);
5084         assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
5085         assert_eq!(count(&p), "10", "half a notch: no step yet");
5086         let sb_id = p.spinboxes[1].as_ref().unwrap().base().id();
5087         assert_eq!(ctx.scroll_initiate_widget_id, Some(sb_id), "the spinbox owns the gesture");
5088         ctx.scroll_gesture_new = false;
5089         assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
5090         assert_eq!(count(&p), "11", "the second half completes the notch");
5091         assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
5092         // Still its gesture when the pointer drifts onto the row below.
5093         let below = p.get_param_rects()[2];
5094         assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, below.1 + below.3 * 0.5, &mut ctx));
5095         assert_eq!(count(&p), "9", "two notches down, latched");
5096         assert_eq!(p.display_params[2].1, "1.00", "the slider under the drifted pointer holds");
5097 
5098         // A gesture the pane acquired scrolls past the row without stepping it.
5099         let mut ctx = UiContext::new();
5100         let mut p = panel(30, 200.0);
5101         ctx.scroll_gesture_new = true;
5102         set_scroll_phase(ScrollPhase::Finger);
5103         p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), 2.0, 2.0, &mut ctx);
5104         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
5105         let (x, y) = row_point(&p);
5106         ctx.scroll_gesture_new = false;
5107         p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, y, &mut ctx);
5108         assert_eq!(count(&p), "10", "a pane-owned gesture never steps a spinbox");
5109         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()));
5110 
5111         // A scaled wheel notch (scroll_factor 0.5) still steps, over two notches.
5112         let mut ctx = UiContext::new();
5113         let mut p = panel(30, 200.0);
5114         let (x, y) = row_point(&p);
5115         ctx.scroll_gesture_new = true;
5116         set_scroll_phase(ScrollPhase::Wheel);
5117         assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
5118         assert_eq!(count(&p), "10");
5119         ctx.scroll_gesture_new = false;
5120         assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
5121         assert_eq!(count(&p), "11", "a half-notch factor is not truncated to nothing");
5122     }
5123 }