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

commit3f1268f6853482b4c9c6831a937d87133d03d4c3
parent29e85ca97e
authorLucas Galante <[email protected]>
date2026-07-09 09:02
feat(widget): migrate ParametersBg (Phase 5s)

The designer's parameter panel — the last real container — moves onto the
narrow traits behind Adapted<ParametersBg>. The model caches its laid-out
rect via Layout::rect_assigned (all row geometry derives from it), keeps
the value-owned per-row widget vecs + raw-pointer children on the 5n
container hooks, and ports the bespoke event surface into on_event arms
(every-press dispatch chain, scrollbar drag, popover-first ordering,
per-row value commit-back, the inline code editor) plus the Input drag
hooks. New adapter surface:

- Paint::serves_legacy_labels / legacy_labels_with_font_and_bounds(rect,
  ctx): the text sibling of the 5m dual-geometry hatch — per-label
  font+bounds (viewport clip everywhere, code-box clip + monospace in
  code rows), served as a full replacement, children included.
- EventCtx::widget_addr(): the wheel arm's is_coordinate_covered check
  keys on the adapter's address, unreachable from on_event before.

Reuses the 5m plain-quad hatch for the designer's raw extra_quads path;
the translucent PARAM_BG plate stays out of it (the host draws it from
color()/corner_style — the 5l double-blend trap). window_runner's
as_any downcast and the 9 pub sub-widget fields keep working unchanged.
Flagged: the legacy scrollbar-press self.focus() (dead base flag) is
dropped.

Verified: 175 tests; cce-designer builds with zero app changes; live A/B
across four driven states pixel-identical except the bottom status strip,
calibrated as launch-to-launch live-data noise (77 px same-build vs 81 px
cross-build, same bbox).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MjP3pGQEDLkJbV5WEmYBe

 src/widget/container/parameters_bg.rs | 2391 ++++++++++++++++++---------------
 src/widget/model.rs                   |   36 +
 2 files changed, 1318 insertions(+), 1109 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 414a913..0c67246 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1,22 +1,50 @@
+//! Narrow-trait `ParametersBg` (Phase 5s) — the designer's parameter panel: a scrollable column
+//! of param rows (sliders, spinboxes, dropdowns, text boxes, checkboxes, colors, float3s,
+//! buttons, section borders, and an inline emacs-flavored code editor), each row's widget owned
+//! by value in parallel `Vec<Option<..>>` fields (most already `Adapted<W>` from earlier
+//! phases), plus a raw-pointer `children` container list. The designer stores it as
+//! `Box<dyn Element>` and drives it through direct `dyn Element` calls; `window_runner`'s
+//! `get_child_widget_for_quad` downcasts to the concrete type through `as_any` (which the
+//! adapter forwards to the inner widget) and reads the pub sub-widget fields — both keep
+//! working unchanged.
+//!
+//! The model caches its laid-out rect via [`Layout::rect_assigned`] (all row geometry derives
+//! from it — the TextBox pattern), and serves BOTH legacy escape hatches: the plain-quad view
+//! ([`Paint::serves_legacy_plain_quads`] — the designer renders params through raw
+//! `extra_quads`, and the panel's own background is drawn by the host from
+//! [`Paint::color`]/[`Paint::corner_style`], NOT emitted here), and the per-label text view
+//! ([`Paint::serves_legacy_labels`], new with this migration — each label clips to the
+//! viewport but the code editor's clip to the code box, in monospace, which the one-font
+//! one-bounds prim bridge can't express).
+//!
+//! Flagged approximation: the legacy scrollbar-press called `self.focus()` (base flag only —
+//! nothing reads it: the highlight keys on the ctx focus slot, and the designer tracks its
+//! focused pane by index); the `on_event` arm drops it.
+
 use crate::colors;
-use crate::widget::*;
-use crate::widget::input::{Slider, Spinbox, Button, Dropdown, TextBox, Checkbox, ColorSelector};
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
 use crate::widget::display::{Float3, TextLabel};
+use crate::widget::input::{Button, Checkbox, ColorSelector, Dropdown, Slider, Spinbox, TextBox};
+use crate::widget::{
+    Adapted, Element, ElementState, Event, EventCtx, Input, Key, KeyEvent, Layout, MouseButton,
+    MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
+};
 
 pub struct ParametersBg {
-    pub base: Widget,
+    rect: Rect,
     display_params: Vec<(String, String, String)>,
     dragging_param: Option<usize>,
     pub focused_param: Option<usize>,
     pub code_editor: Option<TextEditorState>,
     mouse_pos: Option<(f32, f32)>,
-    pub sliders: Vec<Option<crate::widget::Adapted<Slider>>>,
+    pub sliders: Vec<Option<Adapted<Slider>>>,
     pub float3s: Vec<Option<Float3>>,
-    pub spinboxes: Vec<Option<crate::widget::Adapted<Spinbox>>>,
-    pub buttons: Vec<Option<crate::widget::Adapted<Button>>>,
-    pub choices: Vec<Option<crate::widget::Adapted<Dropdown>>>,
-    pub texts: Vec<Option<crate::widget::Adapted<TextBox>>>,
-    pub checkboxes: Vec<Option<crate::widget::Adapted<Checkbox>>>,
+    pub spinboxes: Vec<Option<Adapted<Spinbox>>>,
+    pub buttons: Vec<Option<Adapted<Button>>>,
+    pub choices: Vec<Option<Adapted<Dropdown>>>,
+    pub texts: Vec<Option<Adapted<TextBox>>>,
+    pub checkboxes: Vec<Option<Adapted<Checkbox>>>,
     pub colors: Vec<Option<ColorSelector>>,
     visible: bool,
     pub children: Vec<*mut (dyn Element + 'static)>,
@@ -28,9 +56,9 @@ pub struct ParametersBg {
 }
 
 impl ParametersBg {
-    pub fn new() -> Self {
-        Self {
-            base: Widget::new(),
+    pub fn new() -> Adapted<ParametersBg> {
+        Adapted::new(ParametersBg {
+            rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
             display_params: Vec::new(),
             dragging_param: None,
             focused_param: None,
@@ -51,7 +79,7 @@ impl ParametersBg {
             content_h: 0.0,
             scrollbar_dragging: false,
             drag_offset_y: 0.0,
-        }
+        })
     }
 
     pub fn get_total_content_height(&self) -> f32 {
@@ -92,7 +120,7 @@ impl ParametersBg {
 
     pub fn get_param_rects(&self) -> Vec<(f32, f32, f32, f32)> {
         let mut rects = Vec::new();
-        let mut cur_y = self.base.y + 30.0 - self.scroll_y;
+        let mut cur_y = self.rect.y + 30.0 - self.scroll_y;
         for (i, p) in self.display_params.iter().enumerate() {
             let h = if p.2 == "code" {
                 let val_text = if self.focused_param == Some(i) {
@@ -122,20 +150,20 @@ impl ParametersBg {
             } else {
                 20.0
             };
-            rects.push((self.base.x + 8.0, cur_y, self.base.w - 16.0, h));
+            rects.push((self.rect.x + 8.0, cur_y, self.rect.width - 16.0, h));
             cur_y += h + 8.0;
         }
         rects
     }
 
     pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
-        if self.content_h <= self.base.h {
+        if self.content_h <= self.rect.height {
             return false;
         }
         let sb_w = crate::layout::scrollbar_width();
-        let sb_x = self.base.x + self.base.w - sb_w - 4.0;
-        let sb_track_h = self.base.h - 8.0;
-        let sb_track_y = self.base.y + 4.0;
+        let sb_x = self.rect.x + self.rect.width - sb_w - 4.0;
+        let sb_track_h = self.rect.height - 8.0;
+        let sb_track_y = self.rect.y + 4.0;
 
         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0
             && py >= sb_track_y && py <= sb_track_y + sb_track_h
@@ -193,6 +221,15 @@ impl ParametersBg {
         }
     }
 
+    /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
+    /// scroll into it, re-lay the rows.
+    fn refresh_scroll_metrics(&mut self) {
+        self.content_h = self.get_total_content_height();
+        let max_scroll = (self.content_h - self.rect.height).max(0.0);
+        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
+        self.update_slider_rects();
+    }
+
     fn own_text_labels(&self) -> Vec<TextLabel> {
         let rects = self.get_param_rects();
         let mut labels = Vec::new();
@@ -209,7 +246,7 @@ impl ParametersBg {
             } else if ptype == "section" {
                 labels.push(TextLabel {
                     text: name.clone(),
-                    x: self.base.x + 12.0,
+                    x: self.rect.x + 12.0,
                     y: r.1 + 2.0,
                     font_size: 13.0,
                     color: [0xee, 0xee, 0xf0],
@@ -217,7 +254,7 @@ impl ParametersBg {
             } else if ptype == "code" {
                 labels.push(TextLabel {
                     text: format!("{}:", name),
-                    x: self.base.x + 12.0,
+                    x: self.rect.x + 12.0,
                     y: r.1,
                     font_size: 12.0,
                     color: [0xaa, 0xaa, 0xbb],
@@ -265,7 +302,7 @@ impl ParametersBg {
             } else {
                 labels.push(TextLabel {
                     text: format!("{}: {}", name, value),
-                    x: self.base.x + 8.0,
+                    x: self.rect.x + 8.0,
                     y: r.1,
                     font_size: 12.0,
                     color: [0xaa, 0xaa, 0xbb],
@@ -274,143 +311,37 @@ impl ParametersBg {
         }
         labels
     }
-}
-
-fn parse_slider_range(ptype: &str) -> (f32, f32) {
-    if ptype.starts_with("slider:") || ptype.starts_with("float3:") {
-        let parts: Vec<&str> = ptype.split(':').collect();
-        if parts.len() >= 3 {
-            if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
-                return (min, max);
-            }
-        }
-    }
-    (0.0, 2.0)
-}
-
-fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
-    crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
-}
-
-fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
-    if ptype.starts_with("spinbox:") {
-        let parts: Vec<&str> = ptype.split(':').collect();
-        if parts.len() >= 4 {
-            if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
-                return (min, max, step);
-            }
-        } else if parts.len() == 3 {
-            if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
-                return (min, max, 1);
-            }
-        }
-    }
-    (0, 10000, 1)
-}
 
-fn parse_float3_value(val_str: &str, min: f32, max: f32) -> [f32; 3] {
-    let mut out = [0.5, 0.5, 0.5];
-    let parts: Vec<&str> = val_str
-        .split(|c| c == ':' || c == ',' || c == ' ')
-        .filter(|s| !s.is_empty())
-        .collect();
-    for i in 0..3 {
-        if i < parts.len() {
-            if let Ok(v) = parts[i].parse::<f32>() {
-                let range = max - min;
-                if range != 0.0 {
-                    out[i] = ((v - min) / range).clamp(0.0, 1.0);
-                } else {
-                    out[i] = 0.0;
+    /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
+    /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
+    fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
+        for d_opt in &self.choices {
+            if let Some(d) = d_opt {
+                if let Some(r) = d.popover_rect() {
+                    return Some(r);
                 }
             }
         }
-    }
-    out
-}
-
-impl Element for ParametersBg {
-    crate::impl_widget_base!(ParametersBg);
-    fn is_scrollable(&self) -> bool { true }
-    fn blocks_backplate_drag(&self) -> bool { true }
-
-    fn widget_font(&self) -> Option<String> {
-        Some(crate::layout::control_label_font())
-    }
-
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) { (true, true, true, true) }
-
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        if let Some(b) = self.base_mut() {
-            b.x = x;
-            b.y = y;
-            b.w = w;
-            b.h = h;
-        }
-        self.content_h = self.get_total_content_height();
-        let max_scroll = (self.content_h - h).max(0.0);
-        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
-        self.update_slider_rects();
-
-        // Layout child widgets vertically
-        if !self.visible {
-            return;
-        }
-        let padding_x = 8.0;
-        let padding_y = 10.0;
-        let left_x = x + padding_x;
-        let available_w = (w - 2.0 * padding_x).max(1.0);
-        let mut current_y = y + padding_y;
-        let spacing = 8.0;
-
-        for &child_ptr in &self.children {
-            let child = unsafe { &mut *child_ptr };
-            let (_, _, _, ch) = child.rect();
-            let use_h = if ch > 0.0 { ch } else { 42.0 };
-            child.set_rect(left_x, current_y, available_w, use_h);
-            current_y += use_h + spacing;
-        }
-    }
-
-    fn color(&self) -> [f32; 4] {
-        if !self.visible {
-            return [0.0, 0.0, 0.0, 0.0];
-        }
-        colors::PARAM_BG
-    }
-
-    fn set_visible(&mut self, visible: bool) {
-        self.visible = visible;
-    }
-
-    fn visible(&self) -> bool {
-        self.visible
+        None
     }
 
-    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
-            return false;
+    /// The full legacy popover reach (choices, then children) — hit-testing extends to it.
+    fn own_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
+        if let Some(r) = self.choices_popover_rect() {
+            return Some(r);
         }
-        let (x, y, w, h) = self.rect();
-        let hit_base = px >= x && px <= x + w && py >= y && py <= y + h;
-        if hit_base {
-            return true;
-        }
-        if let Some((pop_x, pop_y, pop_w, pop_h)) = self.popover_rect() {
-            if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
-                return true;
+        for &widget_ptr in self.children.iter().rev() {
+            let widget = unsafe { &*widget_ptr };
+            if let Some(r) = widget.popover_rect() {
+                return Some(r);
             }
         }
-        false
+        None
     }
 
-    fn as_param_controller(&self) -> Option<&dyn ParamController> { Some(self) }
-    fn as_param_controller_mut(&mut self) -> Option<&mut dyn ParamController> { Some(self) }
-
-    fn unfocus(&mut self) {
+    /// The state half of the legacy `unfocus`: commit the focused row's in-flight value back
+    /// into `display_params`, drop the code editor, and unfocus the raw children.
+    fn commit_and_unfocus(&mut self) {
         if let Some(idx) = self.focused_param {
             if idx < self.display_params.len() {
                 let p = &mut self.display_params[idx];
@@ -470,1111 +401,1248 @@ impl Element for ParametersBg {
         }
     }
 
-    fn draggable(&self) -> bool {
-        self.scrollbar_dragging
-            || self.dragging_param.is_some() 
-            || self.display_params.iter().any(|p| p.2.starts_with("slider") || p.2.starts_with("float3"))
-    }
-
-    fn is_dragging(&self) -> bool {
-        self.scrollbar_dragging || self.dragging_param.is_some()
-    }
-
-    fn drag_begin(&mut self, px: f32, py: f32) {
-        if self.scrollbar_dragging {
-            return;
+    /// The legacy `extra_quads` body: section border boxes, every row's chrome (slider/spinbox
+    /// backgrounds read via `rect()`+`color()`, the code editor's box/border/cursor), the raw
+    /// children via [`collect_child_quads`], all clipped to the viewport — plus the unclipped
+    /// scrollbar. Served verbatim through [`Paint::legacy_plain_quads`].
+    fn plain_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        if !self.visible {
+            return Vec::new();
         }
+        let mut quads = Vec::new();
         let rects = self.get_param_rects();
-        for (i, p) in self.display_params.iter().enumerate() {
-            if p.2.starts_with("slider") {
-                let r = rects[i];
-                if let Some(s) = &mut self.sliders[i] {
-                    let top = crate::widget::label_offset(s);
-                    if py >= r.1 + top && py <= r.1 + r.3 {
-                        s.drag_begin(px, py);
-                        self.dragging_param = Some(i);
-                        break;
-                    }
-                }
-            } else if p.2.starts_with("float3") {
-                let r = rects[i];
-                if py >= r.1 && py <= r.1 + r.3 {
-                    if let Some(f) = &mut self.float3s[i] {
-                        let mut dummy = crate::context::UiContext::new();
-                        if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
-                            self.dragging_param = Some(i);
-                            break;
-                        }
-                    }
-                }
-            }
-        }
-    }
+        let view_min = self.rect.y + 4.0;
+        let view_max = self.rect.y + self.rect.height - 4.0;
 
-    fn drag_update(&mut self, px: f32, py: f32) -> bool {
-        if self.scrollbar_dragging {
-            let sb_track_h = self.base.h - 8.0;
-            let sb_track_y = self.base.y + 4.0;
-            let visible_ratio = self.base.h / self.content_h;
-            let thumb_h = if sb_track_h <= 20.0 {
-                sb_track_h
-            } else {
-                (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
-            };
-            let max_scroll = (self.content_h - self.base.h).max(0.0);
-            
-            let target_thumb_y = py - self.drag_offset_y;
-            let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
-                ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
+        let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
+            let (qx, qy, qw, qh, qc) = q;
+            let y1 = qy.max(view_min);
+            let y2 = (qy + qh).min(view_max);
+            if y1 < y2 {
+                Some((qx, y1, qw, y2 - y1, qc))
             } else {
-                0.0
-            };
-            
-            let old_scroll = self.scroll_y;
-            self.scroll_y = new_scroll_ratio * max_scroll;
-            if (self.scroll_y - old_scroll).abs() > 0.01 {
-                self.update_slider_rects();
-                return true;
+                None
             }
-            return false;
-        }
+        };
 
-        if let Some(i) = self.dragging_param {
-            if let Some(s) = &mut self.sliders[i] {
-                if s.drag_update(px, py) {
-                    let (min, max) = parse_slider_range(&self.display_params[i].2);
-                    let new_val = min + s.value * (max - min);
-                    let old_val = &self.display_params[i].1;
-                    let new_val_str = format!("{:.2}", new_val);
-                    if *old_val != new_val_str {
-                        self.display_params[i].1 = new_val_str;
-                        return true;
-                    }
+        let mut param_quads = Vec::new();
+
+        // Find sections and their ranges
+        let mut sections = Vec::new();
+        let mut current_section: Option<(usize, usize)> = None;
+        let mut in_section = false;
+        for (i, p) in self.display_params.iter().enumerate() {
+            if p.2 == "section" {
+                if let Some((start, end)) = current_section {
+                    sections.push((start, end));
                 }
-            } else if let Some(f) = &mut self.float3s[i] {
-                if f.drag_update(px, py) {
-                    let (min, max) = parse_slider_range(&self.display_params[i].2);
-                    let val0 = min + f.values[0] * (max - min);
-                    let val1 = min + f.values[1] * (max - min);
-                    let val2 = min + f.values[2] * (max - min);
-                    let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                    let old_val = &self.display_params[i].1;
-                    if *old_val != new_val_str {
-                        self.display_params[i].1 = new_val_str;
-                        return true;
+                current_section = None;
+                in_section = true;
+            } else {
+                if in_section {
+                    if let Some((_, ref mut end)) = current_section {
+                        *end = i;
+                    } else {
+                        current_section = Some((i, i));
                     }
                 }
             }
         }
-        false
-    }
-
-    fn drag_end(&mut self) {
-        if self.scrollbar_dragging {
-            self.scrollbar_dragging = false;
-            return;
-        }
-        if let Some(i) = self.dragging_param.take() {
-            if let Some(s) = &mut self.sliders[i] {
-                s.drag_end();
-            } else if let Some(f) = &mut self.float3s[i] {
-                f.drag_end();
-            }
+        if let Some((start, end)) = current_section {
+            sections.push((start, end));
         }
-    }
 
-    fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> {
-        self.parent
-    }
-
-    fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) {
-        self.parent = parent;
-        let id = self.base.id();
-        if let Some(p_ptr) = parent {
-            if let Some(p_base) = unsafe { (*p_ptr).base() } {
-                let p_id = p_base.id();
-                ctx.register_widget(p_id, p_ptr);
-                ctx.register_widget(id, self as *mut Self as *mut (dyn Element + 'static));
-                ctx.link_ids(p_id, id);
-            }
-        } else {
-            ctx.tree.set_parent(id, None);
-        }
-    }
+        // Draw section border boxes
+        for (start, end) in sections {
+            if start <= end && start < rects.len() && end < rects.len() {
+                let r_start = rects[start];
+                let r_end = rects[end];
+                let bx = self.rect.x + 4.0;
+                let bw = self.rect.width - 8.0;
+                let by = r_start.1 - 4.0;
+                let bh = (r_end.1 + r_end.3 + 4.0) - by;
 
-    fn children(&self, _ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
-        self.children.clone()
-    }
+                let border_color = [0.18, 0.18, 0.27, 1.0];
+                let border_t = 1.0;
 
-    fn add_child(&mut self, child: *mut (dyn Element + 'static), ctx: &mut UiContext) {
-        self.children.push(child);
-        let id = self.base.id();
-        if let Some(c_base) = unsafe { (*child).base() } {
-            let c_id = c_base.id();
-            let self_ptr = self.as_ptr();
-            ctx.register_widget(id, self_ptr);
-            ctx.register_widget(c_id, child);
-            ctx.link_ids(id, c_id);
-        }
+                // Top border
+                param_quads.push((bx, by, bw, border_t, border_color));
+                // Bottom border
+                param_quads.push((bx, by + bh - border_t, bw, border_t, border_color));
+                // Left border
+                param_quads.push((bx, by, border_t, bh, border_color));
+                // Right border
+                param_quads.push((bx + bw - border_t, by, border_t, bh, border_color));
+            }
+        }
+
+        for (i, p) in self.display_params.iter().enumerate() {
+            let r = rects[i];
+            if p.2.starts_with("slider") {
+                if let Some(s) = &self.sliders[i] {
+                    let (sx, sy, sw, sh) = s.rect();
+                    param_quads.push((sx, sy, sw, sh, s.color()));
+                    param_quads.extend(s.extra_quads());
+                }
+            } else if p.2 == "section" {
+                // Section header line is handled by the border box top border now
+            } else if p.2.starts_with("float3") {
+                if let Some(f) = &self.float3s[i] {
+                    param_quads.extend(f.extra_quads());
+                }
+            } else if p.2 == "code" {
+                param_quads.push((r.0, r.1 + 18.0, r.2, r.3 - 18.0, [0.08, 0.08, 0.10, 1.0]));
+                let border_color = if self.focused_param == Some(i) {
+                    [0.25, 0.45, 0.85, 1.0]
+                } else {
+                    [0.20, 0.20, 0.25, 1.0]
+                };
+                let (bx, by, bw, bh) = (r.0, r.1 + 18.0, r.2, r.3 - 18.0);
+                param_quads.push((bx, by, bw, 1.0, border_color));
+                param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
+                param_quads.push((bx, by, 1.0, bh, border_color));
+                param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
+                if self.focused_param == Some(i) {
+                    if let Some(ref editor) = self.code_editor {
+                        let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
+                        let cursor_x = r.0 + 12.0 + (cursor_c as f32 * 7.2);
+                        let cursor_y = r.1 + 22.0 + (cursor_l as f32 * 16.0) + (16.0 - 13.0) / 2.0;
+                        if cursor_y >= r.1 + 18.0 && cursor_y + 13.0 <= r.1 + r.3 {
+                            param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
+                        }
+                    }
+                }
+            } else if p.2 == "text" {
+                if let Some(tb) = &self.texts[i] {
+                    param_quads.extend(tb.extra_quads());
+                }
+            } else if p.2.starts_with("choice") {
+                if let Some(d) = &self.choices[i] {
+                    param_quads.extend(d.extra_quads());
+                }
+            } else if p.2 == "button" {
+                if let Some(b) = &self.buttons[i] {
+                    param_quads.extend(b.extra_quads());
+                }
+            } else if p.2.starts_with("spinbox") {
+                if let Some(sb) = &self.spinboxes[i] {
+                    { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
+                    param_quads.extend(sb.extra_quads());
+                }
+            } else if p.2 == "toggle" || p.2 == "checkbox" {
+                if let Some(cb) = &self.checkboxes[i] {
+                    param_quads.extend(cb.extra_quads());
+                }
+            } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
+                if let Some(c) = &self.colors[i] {
+                    param_quads.extend(c.extra_quads());
+                }
+            }
+        }
+
+        for &child_ptr in &self.children {
+            let child = unsafe { &*child_ptr };
+            if child.visible() {
+                param_quads.extend(collect_child_quads(child));
+            }
+        }
+
+        // Clip all parameter quads vertically
+        for q in param_quads {
+            if let Some(clipped) = clip_quad(q) {
+                quads.push(clipped);
+            }
+        }
+
+        // Draw Scrollbar (unclipped) if content_h > base.h
+        if self.content_h > self.rect.height {
+            let sb_w = crate::layout::scrollbar_width();
+            let sb_x = self.rect.x + self.rect.width - sb_w - 4.0;
+            let sb_track_h = self.rect.height - 8.0;
+            let sb_track_y = self.rect.y + 4.0;
+
+            // Track
+            quads.push((sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()));
+
+            // Thumb
+            let visible_ratio = self.rect.height / self.content_h;
+            let thumb_h = if sb_track_h <= 20.0 {
+                sb_track_h
+            } else {
+                (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
+            };
+            let max_scroll = self.content_h - self.rect.height;
+            let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
+            let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
+            quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
+        }
+
+        quads
+    }
+}
+
+impl Layout for ParametersBg {
+    fn has_container_children(&self) -> bool {
+        true
+    }
+
+    fn container_children(&self) -> Vec<*mut (dyn Element + 'static)> {
+        self.children.clone()
     }
 
-    fn clear_children(&mut self, ctx: &mut UiContext) {
+    fn child_added(&mut self, child: *mut (dyn Element + 'static)) {
+        self.children.push(child);
+    }
+
+    fn children_cleared(&mut self) {
         self.children.clear();
-        let id = self.base.id();
-        ctx.clear_children_ids(id);
     }
 
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        self.mouse_pos = Some((px, py));
-        let was = self.base.hovered;
-        let is_hit = self.hit_test(px, py, ctx);
-        self.base.hovered = is_hit;
-        let mut changed = was != is_hit;
+    fn parent_changed(&mut self, parent: Option<*mut (dyn Element + 'static)>) {
+        self.parent = parent;
+    }
+
+    fn tracked_parent(&self) -> Option<Option<*mut (dyn Element + 'static)>> {
+        Some(self.parent)
+    }
+
+    /// Ungated rect landing (the legacy `set_rect` head, before its visibility gate): cache the
+    /// rect all row geometry derives from, then re-derive content height/scroll/row rects.
+    fn rect_assigned(&mut self, rect: Rect) {
+        self.rect = rect;
+        self.refresh_scroll_metrics();
+    }
+
+    /// The legacy `set_rect` tail (visible-gated there and by the adapter here): stack the raw
+    /// container children vertically.
+    fn arrange_children(&mut self, rect: Rect, _host: *mut (dyn Element + 'static)) {
+        let padding_x = 8.0;
+        let padding_y = 10.0;
+        let left_x = rect.x + padding_x;
+        let available_w = (rect.width - 2.0 * padding_x).max(1.0);
+        let mut current_y = rect.y + padding_y;
+        let spacing = 8.0;
+
+        for &child_ptr in &self.children {
+            let child = unsafe { &mut *child_ptr };
+            let (_, _, _, ch) = child.rect();
+            let use_h = if ch > 0.0 { ch } else { 42.0 };
+            child.set_rect(left_x, current_y, available_w, use_h);
+            current_y += use_h + spacing;
+        }
+    }
+}
+
+impl Paint for ParametersBg {
+    /// Transparent while hidden, like legacy — the designer draws the panel's background plate
+    /// itself from `color()` + the corner style (`push_widget_vertices`), so this must NOT be
+    /// emitted as a quad anywhere (the translucent PARAM_BG would double-blend).
+    fn color(&self) -> [f32; 4] {
+        if !self.visible {
+            return [0.0, 0.0, 0.0, 0.0];
+        }
+        colors::PARAM_BG
+    }
+
+    /// Legacy `rounded_corners` all-true at the default 12.0 radius.
+    fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+        Some((12.0, (true, true, true, true)))
+    }
+
+    fn widget_font(&self) -> Option<String> {
+        Some(crate::layout::control_label_font())
+    }
+
+    /// Scene-path emission (the designer renders through the legacy hatches instead): the row
+    /// chrome plus the viewport-filtered labels. The background plate stays out — see
+    /// [`color`](Paint::color).
+    fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
+        for (qx, qy, qw, qh, qc) in self.plain_quads() {
+            ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
+        }
+        let view_min = self.rect.y + 4.0;
+        let view_max = self.rect.y + self.rect.height - 4.0;
+        for l in self.own_text_labels() {
+            if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
+                ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+            }
+        }
+    }
+
+    fn serves_legacy_plain_quads(&self) -> bool {
+        true
+    }
+
+    fn legacy_plain_quads(&self, _rect: Rect) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        self.plain_quads()
+    }
+
+    fn serves_legacy_labels(&self) -> bool {
+        true
+    }
+
+    /// The legacy `text_labels_with_font_and_bounds` body: every label clipped to the panel
+    /// viewport in the control-label font, except labels inside a code row — those clip to the
+    /// code box (or hide when it's scrolled out) and render monospace.
+    fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+        let view_min = self.rect.y + 4.0;
+        let view_max = self.rect.y + self.rect.height - 4.0;
+        let mut result = Vec::new();
+        let font = Paint::widget_font(self);
+        let rects = self.get_param_rects();
+        for l in self.own_text_labels() {
+            if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
+                continue;
+            }
+            let mut bounds = Some([self.rect.x + 4.0, view_min, self.rect.x + self.rect.width - 4.0, view_max]);
+            let mut label_font = font.clone();
+            for (i, p) in self.display_params.iter().enumerate() {
+                if p.2 == "code" {
+                    let r = rects[i];
+                    if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
+                        let code_min = (r.1 + 19.0).max(view_min);
+                        let code_max = (r.1 + r.3 - 1.0).min(view_max);
+                        if code_min < code_max {
+                            bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
+                        } else {
+                            bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
+                        }
+                        label_font = Some("monospace".to_string());
+                        break;
+                    }
+                }
+            }
+            result.push((l, label_font, bounds));
+        }
+        for &child_ptr in &self.children {
+            let widget = unsafe { &*child_ptr };
+            result.extend(widget.text_labels_with_font_and_bounds(ctx));
+        }
+        result
+    }
+
+    fn popover(&self, _rect: Rect) -> Option<(f32, f32, f32, f32)> {
+        self.choices_popover_rect()
+    }
+
+    /// The dropdown rows' popovers; the raw children's are the adapter's recursion.
+    fn draw_popover(&self, _rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
+        for d_opt in &self.choices {
+            if let Some(d) = d_opt {
+                d.render_popover(pc);
+            }
+        }
+    }
+}
+
+impl Input for ParametersBg {
+    fn scrollable(&self) -> bool {
+        true
+    }
+
+    /// Legacy `mouse_input` saw every press (and consumes every left press — the designer
+    /// relies on the panel swallowing clicks anywhere while it's the dispatch target).
+    fn gates_presses(&self) -> bool {
+        false
+    }
+
+    /// Legacy hit reach: the panel rect, or an open popover (a dropdown row's list extends
+    /// below the panel).
+    fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
+        if px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height {
+            return true;
+        }
+        if let Some((pop_x, pop_y, pop_w, pop_h)) = self.own_popover_rect() {
+            if px >= pop_x && px <= pop_x + pop_w && py >= pop_y && py <= pop_y + pop_h {
+                return true;
+            }
+        }
+        false
+    }
+
+    fn param_controller(&self) -> Option<&dyn ParamController> {
+        Some(self)
+    }
+    fn param_controller_mut(&mut self) -> Option<&mut dyn ParamController> {
+        Some(self)
+    }
+
+    // --- The host-driven drag surface (the designer routes pointer drags here directly). ---
+
+    fn draggable(&self, _rect: Rect) -> bool {
+        self.scrollbar_dragging
+            || self.dragging_param.is_some()
+            || self.display_params.iter().any(|p| p.2.starts_with("slider") || p.2.starts_with("float3"))
+    }
+
+    fn is_dragging(&self) -> bool {
+        self.scrollbar_dragging || self.dragging_param.is_some()
+    }
+
+    fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
+        if self.scrollbar_dragging {
+            return;
+        }
+        let rects = self.get_param_rects();
+        for (i, p) in self.display_params.iter().enumerate() {
+            if p.2.starts_with("slider") {
+                let r = rects[i];
+                if let Some(s) = &mut self.sliders[i] {
+                    let top = crate::widget::label_offset(s);
+                    if py >= r.1 + top && py <= r.1 + r.3 {
+                        s.drag_begin(px, py);
+                        self.dragging_param = Some(i);
+                        break;
+                    }
+                }
+            } else if p.2.starts_with("float3") {
+                let r = rects[i];
+                if py >= r.1 && py <= r.1 + r.3 {
+                    if let Some(f) = &mut self.float3s[i] {
+                        let mut dummy = crate::context::UiContext::new();
+                        if f.mouse_input(MouseButton::Left, ElementState::Pressed, px, py, &mut dummy) {
+                            self.dragging_param = Some(i);
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+    }
 
+    fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
         if self.scrollbar_dragging {
-            let sb_track_h = self.base.h - 8.0;
-            let sb_track_y = self.base.y + 4.0;
-            let visible_ratio = self.base.h / self.content_h;
+            let sb_track_h = self.rect.height - 8.0;
+            let sb_track_y = self.rect.y + 4.0;
+            let visible_ratio = self.rect.height / self.content_h;
             let thumb_h = if sb_track_h <= 20.0 {
                 sb_track_h
             } else {
                 (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
             };
-            let max_scroll = (self.content_h - self.base.h).max(0.0);
-            
+            let max_scroll = (self.content_h - self.rect.height).max(0.0);
+
             let target_thumb_y = py - self.drag_offset_y;
             let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
                 ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
             } else {
                 0.0
             };
-            
+
             let old_scroll = self.scroll_y;
             self.scroll_y = new_scroll_ratio * max_scroll;
             if (self.scroll_y - old_scroll).abs() > 0.01 {
                 self.update_slider_rects();
-                changed = true;
+                return true;
             }
+            return false;
         }
 
-        for sb_opt in &mut self.spinboxes {
-            if let Some(sb) = sb_opt {
-                if sb.on_cursor_moved(px, py, ctx) {
-                    changed = true;
+        if let Some(i) = self.dragging_param {
+            if let Some(s) = &mut self.sliders[i] {
+                if s.drag_update(px, py) {
+                    let (min, max) = parse_slider_range(&self.display_params[i].2);
+                    let new_val = min + s.value * (max - min);
+                    let old_val = &self.display_params[i].1;
+                    let new_val_str = format!("{:.2}", new_val);
+                    if *old_val != new_val_str {
+                        self.display_params[i].1 = new_val_str;
+                        return true;
+                    }
                 }
-            }
-        }
-        for f_opt in &mut self.float3s {
-            if let Some(f) = f_opt {
-                if f.on_cursor_moved(px, py, ctx) {
-                    changed = true;
+            } else if let Some(f) = &mut self.float3s[i] {
+                if f.drag_update(px, py) {
+                    let (min, max) = parse_slider_range(&self.display_params[i].2);
+                    let val0 = min + f.values[0] * (max - min);
+                    let val1 = min + f.values[1] * (max - min);
+                    let val2 = min + f.values[2] * (max - min);
+                    let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
+                    let old_val = &self.display_params[i].1;
+                    if *old_val != new_val_str {
+                        self.display_params[i].1 = new_val_str;
+                        return true;
+                    }
                 }
             }
         }
-        for b_opt in &mut self.buttons {
-            if let Some(b) = b_opt {
-                if b.on_cursor_moved(px, py, ctx) {
-                    changed = true;
-                }
-            }
+        false
+    }
+
+    fn drag_end(&mut self) {
+        if self.scrollbar_dragging {
+            self.scrollbar_dragging = false;
+            return;
         }
-        for d_opt in &mut self.choices {
-            if let Some(d) = d_opt {
-                if d.on_cursor_moved(px, py, ctx) {
-                    changed = true;
-                }
+        if let Some(i) = self.dragging_param.take() {
+            if let Some(s) = &mut self.sliders[i] {
+                s.drag_end();
+            } else if let Some(f) = &mut self.float3s[i] {
+                f.drag_end();
             }
         }
-        for tb_opt in &mut self.texts {
-            if let Some(tb) = tb_opt {
-                if tb.on_cursor_moved(px, py, ctx) {
-                    changed = true;
-                }
-            }
+    }
+
+    /// Legacy `tick` forwarded to the raw children (the adapter's recursion now) and the
+    /// checkbox rows. The checkbox tick never touches the ctx (a leaf `Adapted` tick is
+    /// ctx-free), so the in-file dummy-ctx convention (`drag_begin`, `collect_child_quads`)
+    /// applies.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        if !self.visible {
+            return false;
         }
+        let mut changed = false;
+        let mut dummy = crate::context::UiContext::new();
         for cb_opt in &mut self.checkboxes {
             if let Some(cb) = cb_opt {
-                if cb.on_cursor_moved(px, py, ctx) {
-                    changed = true;
-                }
-            }
-        }
-        for c_opt in &mut self.colors {
-            if let Some(c) = c_opt {
-                if c.on_cursor_moved(px, py, ctx) {
+                if cb.tick(dt, &mut dummy) {
                     changed = true;
                 }
             }
         }
-
-        for &widget_ptr in &self.children {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.is_dragging() {
-                if widget.drag_update(px, py) {
-                    changed = true;
-                }
-            } else if widget.cursor_moved(px, py, ctx) {
-                changed = true;
-            }
-        }
         changed
     }
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
+    fn visibility_changed(&mut self, visible: bool) {
+        self.visible = visible;
+    }
+
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        // Copied before `ectx.ui` is borrowed: the wheel arm's occlusion check keys on the
+        // adapter's address (the pointer hosts register/popover-track).
+        let self_addr = ectx.widget_addr();
+        match event {
+            // Hosts call `unfocus()` directly (the designer's pane switches): commit the
+            // focused row and unfocus the children. Needs no ctx, so the direct path's
+            // ui-less synthesis works too.
+            Event::FocusOut => {
+                self.commit_and_unfocus();
+                true
+            }
+            Event::PointerMove { x: px, y: py, .. } => {
+                let (px, py) = (*px, *py);
+                self.mouse_pos = Some((px, py));
+                let Some(ui) = ectx.ui.as_deref_mut() else {
+                    return false;
+                };
+                let mut changed = false;
 
-        if button == MouseButton::Left {
-            if state == ElementState::Pressed {
-                if self.hit_test_scrollbar(px, py) {
-                    self.focus();
-                    self.scrollbar_dragging = true;
-                    
-                    let sb_track_h = self.base.h - 8.0;
-                    let sb_track_y = self.base.y + 4.0;
-                    let visible_ratio = self.base.h / self.content_h;
+                if self.scrollbar_dragging {
+                    let sb_track_h = self.rect.height - 8.0;
+                    let sb_track_y = self.rect.y + 4.0;
+                    let visible_ratio = self.rect.height / self.content_h;
                     let thumb_h = if sb_track_h <= 20.0 {
                         sb_track_h
                     } else {
                         (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
                     };
-                    let max_scroll = (self.content_h - self.base.h).max(0.0);
-                    let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
-                    let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
-                    
-                    let click_offset = py - thumb_y;
-                    if click_offset >= 0.0 && click_offset <= thumb_h {
-                        self.drag_offset_y = click_offset;
+                    let max_scroll = (self.content_h - self.rect.height).max(0.0);
+
+                    let target_thumb_y = py - self.drag_offset_y;
+                    let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
+                        ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
                     } else {
-                        // Clicked outside the thumb: jump thumb center to py
-                        self.drag_offset_y = thumb_h / 2.0;
-                        let target_thumb_y = py - self.drag_offset_y;
-                        let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
-                            ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
-                        } else {
-                            0.0
-                        };
-                        self.scroll_y = new_scroll_ratio * max_scroll;
-                        self.update_slider_rects();
-                    }
-                    return true;
-                }
-            } else if state == ElementState::Released {
-                if self.scrollbar_dragging {
-                    self.scrollbar_dragging = false;
-                    return true;
-                }
-            }
-        }
+                        0.0
+                    };
 
-        // 1. Check open dropdown popovers first (since they are drawn on top)
-        for (i, d_opt) in self.choices.iter_mut().enumerate() {
-            if let Some(d) = d_opt {
-                if d.popover_rect().is_some() {
-                    if d.mouse_input(button, state, px, py, ctx) {
-                        if d.take_change() {
-                            if let Some(val) = d.get_value_string() {
-                                self.display_params[i].1 = val;
-                            }
-                        }
-                        return true;
+                    let old_scroll = self.scroll_y;
+                    self.scroll_y = new_scroll_ratio * max_scroll;
+                    if (self.scroll_y - old_scroll).abs() > 0.01 {
+                        self.update_slider_rects();
+                        changed = true;
                     }
                 }
-            }
-        }
 
-        for &widget_ptr in self.children.iter().rev() {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.popover_rect().is_some() {
-                if widget.mouse_input(button, state, px, py, ctx) {
-                    return true;
-                }
-            }
-        }
-
-        // 2. Propagate to our widgets
-        for (i, p) in self.display_params.iter_mut().enumerate() {
-            if p.2.starts_with("choice") {
-                if let Some(d) = &mut self.choices[i] {
-                    if d.mouse_input(button, state, px, py, ctx) {
-                        if d.take_change() {
-                            if let Some(val) = d.get_value_string() {
-                                p.1 = val;
-                            }
-                        }
-                        return true;
-                    }
-                }
-            } else if p.2 == "button" {
-                if let Some(b) = &mut self.buttons[i] {
-                    if b.mouse_input(button, state, px, py, ctx) {
-                        if b.take_click() {
-                            p.1 = "clicked".to_string();
-                        }
-                        return true;
-                    }
-                }
-            } else if p.2 == "text" {
-                if let Some(tb) = &mut self.texts[i] {
-                    if tb.mouse_input(button, state, px, py, ctx) {
-                        if tb.editing {
-                            self.focused_param = Some(i);
-                        } else {
-                            if self.focused_param == Some(i) {
-                                self.focused_param = None;
-                            }
-                        }
-                        if tb.take_change() {
-                            if let Some(val) = tb.get_value_string() {
-                                p.1 = val;
-                            }
-                        }
-                        return true;
-                    }
-                }
-            } else if p.2.starts_with("spinbox") {
-                if let Some(sb) = &mut self.spinboxes[i] {
-                    if sb.mouse_input(button, state, px, py, ctx) {
-                        p.1 = sb.value.to_string();
-                        if sb.editing {
-                            self.focused_param = Some(i);
-                        } else {
-                            if self.focused_param == Some(i) {
-                                self.focused_param = None;
-                            }
+                for sb_opt in &mut self.spinboxes {
+                    if let Some(sb) = sb_opt {
+                        if sb.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        return true;
                     }
                 }
-            } else if p.2 == "toggle" || p.2 == "checkbox" {
-                if let Some(cb) = &mut self.checkboxes[i] {
-                    if cb.mouse_input(button, state, px, py, ctx) {
-                        if cb.take_change() {
-                            if let Some(val) = cb.get_value_string() {
-                                p.1 = val;
-                            }
+                for f_opt in &mut self.float3s {
+                    if let Some(f) = f_opt {
+                        if f.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        return true;
                     }
                 }
-            } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
-                if let Some(c) = &mut self.colors[i] {
-                    if c.mouse_input(button, state, px, py, ctx) {
-                        if let Some(val) = c.get_value_string() {
-                            p.1 = val;
-                        }
-                        if c.editing {
-                            self.focused_param = Some(i);
-                        } else {
-                            if self.focused_param == Some(i) {
-                                self.focused_param = None;
-                            }
+                for b_opt in &mut self.buttons {
+                    if let Some(b) = b_opt {
+                        if b.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        return true;
                     }
                 }
-            }
-        }
-
-        for &widget_ptr in self.children.iter().rev() {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.mouse_input(button, state, px, py, ctx) {
-                return true;
-            }
-            if state == ElementState::Pressed && !widget.hit_test(px, py, ctx) {
-                widget.unfocus();
-            }
-        }
-
-        if button == MouseButton::Left && state == ElementState::Pressed {
-            let rects = self.get_param_rects();
-            let mut clicked_any_focusable = false;
-            for (i, p) in self.display_params.iter_mut().enumerate() {
-                if p.2 == "code" {
-                    let r = rects[i];
-                    if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + 18.0 && py <= r.1 + r.3 {
-                        self.focused_param = Some(i);
-                        let mut editor = TextEditorState::new(p.1.clone());
-                        let click_x = px - (r.0 + 12.0);
-                        let click_y = py - (r.1 + 22.0);
-                        let line = (click_y / 16.0).floor().max(0.0) as usize;
-                        let col = (click_x / 7.2 + 0.5).floor().max(0.0) as usize;
-                        editor.cursor_idx = map_2d_to_1d(&editor.buffer, line, col);
-                        self.code_editor = Some(editor);
-                        clicked_any_focusable = true;
-                        break;
-                    }
-                } else if p.2.starts_with("slider") {
-                    let r = rects[i];
-                    if py >= r.1 && py <= r.1 + r.3 {
-                        if let Some(s) = &mut self.sliders[i] {
-                            if s.mouse_input(button, state, px, py, ctx) {
-                                if s.editing {
-                                    self.focused_param = Some(i);
-                                    clicked_any_focusable = true;
-                                }
-                                break;
-                            }
-                        }
-                    }
-                } else if p.2.starts_with("float3") {
-                    let r = rects[i];
-                    if py >= r.1 && py <= r.1 + r.3 {
-                        if let Some(f) = &mut self.float3s[i] {
-                            if f.mouse_input(button, state, px, py, ctx) {
-                                if f.editing_idx.is_some() {
-                                    self.focused_param = Some(i);
-                                    clicked_any_focusable = true;
-                                }
-                                break;
-                            }
+                for d_opt in &mut self.choices {
+                    if let Some(d) = d_opt {
+                        if d.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
                     }
                 }
-            }
-            if !clicked_any_focusable {
-                self.unfocus();
-            }
-            return true;
-        }
-        false
-    }
-
-    fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        for &widget_ptr in &self.children {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.keyboard_input(event, ctx) {
-                return true;
-            }
-        }
-
-        if let Some(idx) = self.focused_param {
-            if event.state == ElementState::Pressed {
-                let p = &mut self.display_params[idx];
-                if p.2 == "code" {
-                    if let Some(mut editor) = self.code_editor.take() {
-                        let mut changed = false;
-                        let mut handled = true;
-                        let mut should_unfocus = false;
-                        match &event.logical_key {
-                            Key::Named(NamedKey::Backspace) => {
-                                changed = editor.delete_backwards();
-                            }
-                            Key::Named(NamedKey::Delete) => {
-                                changed = editor.delete_forwards();
-                            }
-                            Key::Named(NamedKey::Enter) => {
-                                editor.insert_text("\n");
-                                changed = true;
-                            }
-                            Key::Named(NamedKey::Escape) => {
-                                should_unfocus = true;
-                            }
-                            Key::Named(NamedKey::ArrowLeft) => {
-                                editor.move_cursor_left(false);
-                            }
-                            Key::Named(NamedKey::ArrowRight) => {
-                                editor.move_cursor_right(false);
-                            }
-                            Key::Named(NamedKey::ArrowUp) => {
-                                let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                                if line > 0 {
-                                    editor.cursor_idx = map_2d_to_1d(&editor.buffer, line - 1, col);
-                                }
-                            }
-                            Key::Named(NamedKey::ArrowDown) => {
-                                let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                                let total_lines = editor.buffer.split('\n').count();
-                                if line + 1 < total_lines {
-                                    editor.cursor_idx = map_2d_to_1d(&editor.buffer, line + 1, col);
-                                }
-                            }
-                            Key::Named(NamedKey::Home) => {
-                                editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
-                            }
-                            Key::Named(NamedKey::End) => {
-                                editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
-                            }
-                            Key::Character(s) => {
-                                if event.ctrl {
-                                    match s.to_lowercase().as_str() {
-                                        "f" => {
-                                            editor.move_cursor_right(false);
-                                        }
-                                        "b" => {
-                                            editor.move_cursor_left(false);
-                                        }
-                                        "p" => {
-                                            let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                                            if line > 0 {
-                                                editor.cursor_idx = map_2d_to_1d(&editor.buffer, line - 1, col);
-                                            }
-                                        }
-                                        "n" => {
-                                            let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                                            let total_lines = editor.buffer.split('\n').count();
-                                            if line + 1 < total_lines {
-                                                editor.cursor_idx = map_2d_to_1d(&editor.buffer, line + 1, col);
-                                            }
-                                        }
-                                        "a" => {
-                                            editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
-                                        }
-                                        "e" => {
-                                            editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
-                                        }
-                                        "d" => {
-                                            changed = editor.delete_forwards();
-                                        }
-                                        "h" => {
-                                            changed = editor.delete_backwards();
-                                        }
-                                        "k" => {
-                                            let current_idx = editor.cursor_idx;
-                                            let end_idx = get_line_end(&editor.buffer, current_idx);
-                                            let chars: Vec<char> = editor.buffer.chars().collect();
-                                            if chars.is_empty() {
-                                                // do nothing
-                                            } else if current_idx < chars.len() {
-                                                let delete_end = if chars[current_idx] == '\n' {
-                                                    current_idx + 1
-                                                } else {
-                                                    end_idx
-                                                };
-                                                let mut new_buf = String::new();
-                                                for i in 0..current_idx {
-                                                    new_buf.push(chars[i]);
-                                                }
-                                                for i in delete_end..chars.len() {
-                                                    new_buf.push(chars[i]);
-                                                }
-                                                editor.buffer = new_buf;
-                                                changed = true;
-                                            }
-                                        }
-                                        _ => {
-                                            handled = false;
-                                        }
-                                    }
-                                } else {
-                                    editor.insert_text(s);
-                                    changed = true;
-                                }
-                            }
-                            _ => {
-                                handled = false;
-                            }
+                for tb_opt in &mut self.texts {
+                    if let Some(tb) = tb_opt {
+                        if tb.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        if changed {
-                            p.1 = editor.buffer.clone();
+                    }
+                }
+                for cb_opt in &mut self.checkboxes {
+                    if let Some(cb) = cb_opt {
+                        if cb.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        if should_unfocus {
-                            p.1 = editor.buffer;
-                            self.focused_param = None;
-                            self.code_editor = None;
-                        } else {
-                            self.code_editor = Some(editor);
+                    }
+                }
+                for c_opt in &mut self.colors {
+                    if let Some(c) = c_opt {
+                        if c.on_cursor_moved(px, py, ui) {
+                            changed = true;
                         }
-                        if handled {
-                            return true;
+                    }
+                }
+
+                for &widget_ptr in &self.children {
+                    let widget = unsafe { &mut *widget_ptr };
+                    if widget.is_dragging() {
+                        if widget.drag_update(px, py) {
+                            changed = true;
                         }
+                    } else if widget.cursor_moved(px, py, ui) {
+                        changed = true;
                     }
-                } else if p.2 == "text" {
-                    if let Some(tb) = &mut self.texts[idx] {
-                        if tb.keyboard_input(event, ctx) {
-                            if !tb.editing {
-                                p.1 = tb.text.clone();
-                                self.focused_param = None;
+                }
+                changed
+            }
+            Event::MouseButton { button, state, x: px, y: py, .. } => {
+                if !self.visible {
+                    return false;
+                }
+                let (button, state, px, py) = (*button, *state, *px, *py);
+                let Some(ui) = ectx.ui.as_deref_mut() else {
+                    return false;
+                };
+
+                if button == MouseButton::Left {
+                    if state == ElementState::Pressed {
+                        if self.hit_test_scrollbar(px, py) {
+                            // Legacy called `self.focus()` here — base flag only, which
+                            // nothing reads (see module docs).
+                            self.scrollbar_dragging = true;
+
+                            let sb_track_h = self.rect.height - 8.0;
+                            let sb_track_y = self.rect.y + 4.0;
+                            let visible_ratio = self.rect.height / self.content_h;
+                            let thumb_h = if sb_track_h <= 20.0 {
+                                sb_track_h
+                            } else {
+                                (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
+                            };
+                            let max_scroll = (self.content_h - self.rect.height).max(0.0);
+                            let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
+                            let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
+
+                            let click_offset = py - thumb_y;
+                            if click_offset >= 0.0 && click_offset <= thumb_h {
+                                self.drag_offset_y = click_offset;
                             } else {
-                                p.1 = tb.edit_buffer.clone();
+                                // Clicked outside the thumb: jump thumb center to py
+                                self.drag_offset_y = thumb_h / 2.0;
+                                let target_thumb_y = py - self.drag_offset_y;
+                                let new_scroll_ratio = if sb_track_h - thumb_h > 0.0 {
+                                    ((target_thumb_y - sb_track_y) / (sb_track_h - thumb_h)).clamp(0.0, 1.0)
+                                } else {
+                                    0.0
+                                };
+                                self.scroll_y = new_scroll_ratio * max_scroll;
+                                self.update_slider_rects();
                             }
                             return true;
                         }
+                    } else if state == ElementState::Released {
+                        if self.scrollbar_dragging {
+                            self.scrollbar_dragging = false;
+                            return true;
+                        }
                     }
-                } else if p.2.starts_with("choice") {
-                    if let Some(d) = &mut self.choices[idx] {
-                        if d.keyboard_input(event, ctx) {
-                            if !d.open {
-                                if let Some(val) = d.get_value_string() {
-                                    p.1 = val;
+                }
+
+                // 1. Check open dropdown popovers first (since they are drawn on top)
+                for (i, d_opt) in self.choices.iter_mut().enumerate() {
+                    if let Some(d) = d_opt {
+                        if d.popover_rect().is_some() {
+                            if d.mouse_input(button, state, px, py, ui) {
+                                if d.take_change() {
+                                    if let Some(val) = d.get_value_string() {
+                                        self.display_params[i].1 = val;
+                                    }
                                 }
-                                self.focused_param = None;
+                                return true;
                             }
-                            return true;
                         }
                     }
-                } else if p.2.starts_with("spinbox") {
-                    if let Some(sb) = &mut self.spinboxes[idx] {
-                        if sb.keyboard_input(event, ctx) {
-                            if !sb.editing {
-                                p.1 = sb.value.to_string();
-                                self.focused_param = None;
-                            } else {
-                                p.1 = sb.edit_buffer.clone();
-                            }
+                }
+
+                for &widget_ptr in self.children.iter().rev() {
+                    let widget = unsafe { &mut *widget_ptr };
+                    if widget.popover_rect().is_some() {
+                        if widget.mouse_input(button, state, px, py, ui) {
                             return true;
                         }
                     }
-                } else if p.2.starts_with("slider") {
-                    if let Some(s) = &mut self.sliders[idx] {
-                        if s.keyboard_input(event, ctx) {
-                            let (min, max) = parse_slider_range(&p.2);
-                            let new_val = min + s.value * (max - min);
-                            p.1 = format!("{:.2}", new_val);
-                            if !s.editing {
-                                self.focused_param = None;
+                }
+
+                // 2. Propagate to our widgets
+                for (i, p) in self.display_params.iter_mut().enumerate() {
+                    if p.2.starts_with("choice") {
+                        if let Some(d) = &mut self.choices[i] {
+                            if d.mouse_input(button, state, px, py, ui) {
+                                if d.take_change() {
+                                    if let Some(val) = d.get_value_string() {
+                                        p.1 = val;
+                                    }
+                                }
+                                return true;
                             }
-                            return true;
                         }
-                    }
-                } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
-                    if let Some(c) = &mut self.colors[idx] {
-                        if c.keyboard_input(event, ctx) {
-                            if let Some(val) = c.get_value_string() {
-                                p.1 = val;
+                    } else if p.2 == "button" {
+                        if let Some(b) = &mut self.buttons[i] {
+                            if b.mouse_input(button, state, px, py, ui) {
+                                if b.take_click() {
+                                    p.1 = "clicked".to_string();
+                                }
+                                return true;
                             }
-                            if !c.editing {
-                                self.focused_param = None;
+                        }
+                    } else if p.2 == "text" {
+                        if let Some(tb) = &mut self.texts[i] {
+                            if tb.mouse_input(button, state, px, py, ui) {
+                                if tb.editing {
+                                    self.focused_param = Some(i);
+                                } else {
+                                    if self.focused_param == Some(i) {
+                                        self.focused_param = None;
+                                    }
+                                }
+                                if tb.take_change() {
+                                    if let Some(val) = tb.get_value_string() {
+                                        p.1 = val;
+                                    }
+                                }
+                                return true;
                             }
-                            return true;
                         }
-                    }
-                } else if p.2.starts_with("float3") {
-                    if let Some(f) = &mut self.float3s[idx] {
-                        if f.keyboard_input(event, ctx) {
-                            let (min, max) = parse_slider_range(&p.2);
-                            let val0 = min + f.values[0] * (max - min);
-                            let val1 = min + f.values[1] * (max - min);
-                            let val2 = min + f.values[2] * (max - min);
-                            p.1 = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                            if f.editing_idx.is_none() {
-                                self.focused_param = None;
+                    } else if p.2.starts_with("spinbox") {
+                        if let Some(sb) = &mut self.spinboxes[i] {
+                            if sb.mouse_input(button, state, px, py, ui) {
+                                p.1 = sb.value.to_string();
+                                if sb.editing {
+                                    self.focused_param = Some(i);
+                                } else {
+                                    if self.focused_param == Some(i) {
+                                        self.focused_param = None;
+                                    }
+                                }
+                                return true;
+                            }
+                        }
+                    } else if p.2 == "toggle" || p.2 == "checkbox" {
+                        if let Some(cb) = &mut self.checkboxes[i] {
+                            if cb.mouse_input(button, state, px, py, ui) {
+                                if cb.take_change() {
+                                    if let Some(val) = cb.get_value_string() {
+                                        p.1 = val;
+                                    }
+                                }
+                                return true;
+                            }
+                        }
+                    } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
+                        if let Some(c) = &mut self.colors[i] {
+                            if c.mouse_input(button, state, px, py, ui) {
+                                if let Some(val) = c.get_value_string() {
+                                    p.1 = val;
+                                }
+                                if c.editing {
+                                    self.focused_param = Some(i);
+                                } else {
+                                    if self.focused_param == Some(i) {
+                                        self.focused_param = None;
+                                    }
+                                }
+                                return true;
                             }
-                            return true;
                         }
                     }
                 }
-            }
-        }
-        false
-    }
-
-    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        for &widget_ptr in self.children.iter().rev() {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.mouse_wheel(delta, px, py, ctx) {
-                return true;
-            }
-        }
 
-        let mut changed = false;
-        let rects = self.get_param_rects();
-        for (i, p) in self.display_params.iter_mut().enumerate() {
-            if p.2.starts_with("slider") {
-                let r = rects[i];
-                let row_y = r.1;
-                if py >= row_y - 2.0 && py <= row_y + r.3 && px >= self.base.x && px <= self.base.x + self.base.w {
-                    if let Some(s) = &mut self.sliders[i] {
-                        let was_scroll = s.scroll_enabled;
-                        s.set_scroll(true);
-                        if s.mouse_wheel(delta, px, py, ctx) {
-                            let (min, max) = parse_slider_range(&p.2);
-                            let new_val = min + s.value * (max - min);
-                            let old_val = &p.1;
-                            let new_val_str = format!("{:.2}", new_val);
-                            if *old_val != new_val_str {
-                                p.1 = new_val_str;
-                                changed = true;
-                            }
-                        }
-                        s.set_scroll(was_scroll);
+                for &widget_ptr in self.children.iter().rev() {
+                    let widget = unsafe { &mut *widget_ptr };
+                    if widget.mouse_input(button, state, px, py, ui) {
+                        return true;
+                    }
+                    if state == ElementState::Pressed && !widget.hit_test(px, py, ui) {
+                        widget.unfocus();
                     }
                 }
-            } else if p.2.starts_with("float3") {
-                let r = rects[i];
-                let row_y = r.1;
-                if py >= row_y && py <= row_y + r.3 && px >= self.base.x && px <= self.base.x + self.base.w {
-                    if let Some(f) = &mut self.float3s[i] {
-                        let rects_inner = f.get_row_rects();
-                        for j in 0..3 {
-                            let r_inner = rects_inner[j];
-                            if py >= r_inner.1 && py <= r_inner.1 + r_inner.3 {
-                                let scroll_amount = match delta {
-                                    MouseScrollDelta::LineDelta(_x, y) => *y,
-                                    MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 120.0,
-                                };
-                                let step = 0.02;
-                                let new_val = (f.values[j] - scroll_amount * step).clamp(0.0, 1.0);
-                                if (new_val - f.values[j]).abs() > 0.0001 {
-                                    f.values[j] = new_val;
-                                    if f.editing_idx == Some(j) {
-                                        let scaled_val = f.mins[j] + f.values[j] * (f.maxs[j] - f.mins[j]);
-                                        f.edit_buffer = format!("{:.2}", scaled_val);
+
+                if button == MouseButton::Left && state == ElementState::Pressed {
+                    let rects = self.get_param_rects();
+                    let mut clicked_any_focusable = false;
+                    for (i, p) in self.display_params.iter_mut().enumerate() {
+                        if p.2 == "code" {
+                            let r = rects[i];
+                            if px >= r.0 && px <= r.0 + r.2 && py >= r.1 + 18.0 && py <= r.1 + r.3 {
+                                self.focused_param = Some(i);
+                                let mut editor = TextEditorState::new(p.1.clone());
+                                let click_x = px - (r.0 + 12.0);
+                                let click_y = py - (r.1 + 22.0);
+                                let line = (click_y / 16.0).floor().max(0.0) as usize;
+                                let col = (click_x / 7.2 + 0.5).floor().max(0.0) as usize;
+                                editor.cursor_idx = map_2d_to_1d(&editor.buffer, line, col);
+                                self.code_editor = Some(editor);
+                                clicked_any_focusable = true;
+                                break;
+                            }
+                        } else if p.2.starts_with("slider") {
+                            let r = rects[i];
+                            if py >= r.1 && py <= r.1 + r.3 {
+                                if let Some(s) = &mut self.sliders[i] {
+                                    if s.mouse_input(button, state, px, py, ui) {
+                                        if s.editing {
+                                            self.focused_param = Some(i);
+                                            clicked_any_focusable = true;
+                                        }
+                                        break;
                                     }
-                                    let (min, max) = parse_slider_range(&p.2);
-                                    let val0 = min + f.values[0] * (max - min);
-                                    let val1 = min + f.values[1] * (max - min);
-                                    let val2 = min + f.values[2] * (max - min);
-                                    let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
-                                    if p.1 != new_val_str {
-                                        p.1 = new_val_str;
-                                        changed = true;
+                                }
+                            }
+                        } else if p.2.starts_with("float3") {
+                            let r = rects[i];
+                            if py >= r.1 && py <= r.1 + r.3 {
+                                if let Some(f) = &mut self.float3s[i] {
+                                    if f.mouse_input(button, state, px, py, ui) {
+                                        if f.editing_idx.is_some() {
+                                            self.focused_param = Some(i);
+                                            clicked_any_focusable = true;
+                                        }
+                                        break;
                                     }
                                 }
                             }
                         }
                     }
-                }
-            } else if p.2.starts_with("spinbox") {
-                let r = rects[i];
-                let row_y = r.1;
-                if py >= row_y && py <= row_y + r.3 && px >= self.base.x && px <= self.base.x + self.base.w {
-                    if let Some(sb) = &mut self.spinboxes[i] {
-                        let scroll_amount = match delta {
-                            MouseScrollDelta::LineDelta(_x, y) => *y as i32,
-                            MouseScrollDelta::PixelDelta(pos) => {
-                                let dy = pos.y;
-                                if dy > 0.0 { 1 } else if dy < 0.0 { -1 } else { 0 }
-                            }
-                        };
-                        let new_val = (sb.value + scroll_amount * sb.step).clamp(sb.min, sb.max);
-                        if sb.value != new_val {
-                            sb.value = new_val;
-                            p.1 = new_val.to_string();
-                            changed = true;
-                        }
+                    if !clicked_any_focusable {
+                        self.commit_and_unfocus();
                     }
+                    return true;
                 }
+                false
             }
-        }
-
-        if !changed && self.hit_test(px, py, ctx) {
-            let scroll_speed = 24.0;
-            let dy = match delta {
-                MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
-                MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-            };
-            let old_scroll = self.scroll_y;
-            let max_scroll = (self.content_h - self.base.h).max(0.0);
-            self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
-            if (self.scroll_y - old_scroll).abs() > 0.01 {
-                self.update_slider_rects();
-                changed = true;
-            }
-        }
-
-        changed
-    }
-
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let mut quads = Vec::new();
-        let rects = self.get_param_rects();
-        let view_min = self.base.y + 4.0;
-        let view_max = self.base.y + self.base.h - 4.0;
-
-        let clip_quad = |q: (f32, f32, f32, f32, [f32; 4])| -> Option<(f32, f32, f32, f32, [f32; 4])> {
-            let (qx, qy, qw, qh, qc) = q;
-            let y1 = qy.max(view_min);
-            let y2 = (qy + qh).min(view_max);
-            if y1 < y2 {
-                Some((qx, y1, qw, y2 - y1, qc))
-            } else {
-                None
-            }
-        };
-
-        let mut param_quads = Vec::new();
-
-        // Find sections and their ranges
-        let mut sections = Vec::new();
-        let mut current_section: Option<(usize, usize)> = None;
-        let mut in_section = false;
-        for (i, p) in self.display_params.iter().enumerate() {
-            if p.2 == "section" {
-                if let Some((start, end)) = current_section {
-                    sections.push((start, end));
+            Event::KeyInput(event) => {
+                if !self.visible {
+                    return false;
                 }
-                current_section = None;
-                in_section = true;
-            } else {
-                if in_section {
-                    if let Some((_, ref mut end)) = current_section {
-                        *end = i;
-                    } else {
-                        current_section = Some((i, i));
+                let Some(ui) = ectx.ui.as_deref_mut() else {
+                    return false;
+                };
+                for &widget_ptr in &self.children {
+                    let widget = unsafe { &mut *widget_ptr };
+                    if widget.keyboard_input(event, ui) {
+                        return true;
                     }
                 }
-            }
-        }
-        if let Some((start, end)) = current_section {
-            sections.push((start, end));
-        }
-
-        // Draw section border boxes
-        for (start, end) in sections {
-            if start <= end && start < rects.len() && end < rects.len() {
-                let r_start = rects[start];
-                let r_end = rects[end];
-                let bx = self.base.x + 4.0;
-                let bw = self.base.w - 8.0;
-                let by = r_start.1 - 4.0;
-                let bh = (r_end.1 + r_end.3 + 4.0) - by;
-                
-                let border_color = [0.18, 0.18, 0.27, 1.0];
-                let border_t = 1.0;
-                
-                // Top border
-                param_quads.push((bx, by, bw, border_t, border_color));
-                // Bottom border
-                param_quads.push((bx, by + bh - border_t, bw, border_t, border_color));
-                // Left border
-                param_quads.push((bx, by, border_t, bh, border_color));
-                // Right border
-                param_quads.push((bx + bw - border_t, by, border_t, bh, border_color));
-            }
-        }
 
-        for (i, p) in self.display_params.iter().enumerate() {
-            let r = rects[i];
-            if p.2.starts_with("slider") {
-                if let Some(s) = &self.sliders[i] {
-                    let (sx, sy, sw, sh) = s.rect();
-                    param_quads.push((sx, sy, sw, sh, s.color()));
-                    param_quads.extend(s.extra_quads());
-                }
-            } else if p.2 == "section" {
-                // Section header line is handled by the border box top border now
-            } else if p.2.starts_with("float3") {
-                if let Some(f) = &self.float3s[i] {
-                    param_quads.extend(f.extra_quads());
-                }
-            } else if p.2 == "code" {
-                param_quads.push((r.0, r.1 + 18.0, r.2, r.3 - 18.0, [0.08, 0.08, 0.10, 1.0]));
-                let border_color = if self.focused_param == Some(i) {
-                    [0.25, 0.45, 0.85, 1.0]
-                } else {
-                    [0.20, 0.20, 0.25, 1.0]
-                };
-                let (bx, by, bw, bh) = (r.0, r.1 + 18.0, r.2, r.3 - 18.0);
-                param_quads.push((bx, by, bw, 1.0, border_color));
-                param_quads.push((bx, by + bh - 1.0, bw, 1.0, border_color));
-                param_quads.push((bx, by, 1.0, bh, border_color));
-                param_quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
-                if self.focused_param == Some(i) {
-                    if let Some(ref editor) = self.code_editor {
-                        let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                        let cursor_x = r.0 + 12.0 + (cursor_c as f32 * 7.2);
-                        let cursor_y = r.1 + 22.0 + (cursor_l as f32 * 16.0) + (16.0 - 13.0) / 2.0;
-                        if cursor_y >= r.1 + 18.0 && cursor_y + 13.0 <= r.1 + r.3 {
-                            param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
+                if let Some(idx) = self.focused_param {
+                    if event.state == ElementState::Pressed {
+                        let p = &mut self.display_params[idx];
+                        if p.2 == "code" {
+                            if let Some(mut editor) = self.code_editor.take() {
+                                let mut changed = false;
+                                let mut handled = true;
+                                let mut should_unfocus = false;
+                                match &event.logical_key {
+                                    Key::Named(NamedKey::Backspace) => {
+                                        changed = editor.delete_backwards();
+                                    }
+                                    Key::Named(NamedKey::Delete) => {
+                                        changed = editor.delete_forwards();
+                                    }
+                                    Key::Named(NamedKey::Enter) => {
+                                        editor.insert_text("\n");
+                                        changed = true;
+                                    }
+                                    Key::Named(NamedKey::Escape) => {
+                                        should_unfocus = true;
+                                    }
+                                    Key::Named(NamedKey::ArrowLeft) => {
+                                        editor.move_cursor_left(false);
+                                    }
+                                    Key::Named(NamedKey::ArrowRight) => {
+                                        editor.move_cursor_right(false);
+                                    }
+                                    Key::Named(NamedKey::ArrowUp) => {
+                                        let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
+                                        if line > 0 {
+                                            editor.cursor_idx = map_2d_to_1d(&editor.buffer, line - 1, col);
+                                        }
+                                    }
+                                    Key::Named(NamedKey::ArrowDown) => {
+                                        let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
+                                        let total_lines = editor.buffer.split('\n').count();
+                                        if line + 1 < total_lines {
+                                            editor.cursor_idx = map_2d_to_1d(&editor.buffer, line + 1, col);
+                                        }
+                                    }
+                                    Key::Named(NamedKey::Home) => {
+                                        editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
+                                    }
+                                    Key::Named(NamedKey::End) => {
+                                        editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
+                                    }
+                                    Key::Character(s) => {
+                                        if event.ctrl {
+                                            match s.to_lowercase().as_str() {
+                                                "f" => {
+                                                    editor.move_cursor_right(false);
+                                                }
+                                                "b" => {
+                                                    editor.move_cursor_left(false);
+                                                }
+                                                "p" => {
+                                                    let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
+                                                    if line > 0 {
+                                                        editor.cursor_idx = map_2d_to_1d(&editor.buffer, line - 1, col);
+                                                    }
+                                                }
+                                                "n" => {
+                                                    let (line, col) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
+                                                    let total_lines = editor.buffer.split('\n').count();
+                                                    if line + 1 < total_lines {
+                                                        editor.cursor_idx = map_2d_to_1d(&editor.buffer, line + 1, col);
+                                                    }
+                                                }
+                                                "a" => {
+                                                    editor.cursor_idx = get_line_start(&editor.buffer, editor.cursor_idx);
+                                                }
+                                                "e" => {
+                                                    editor.cursor_idx = get_line_end(&editor.buffer, editor.cursor_idx);
+                                                }
+                                                "d" => {
+                                                    changed = editor.delete_forwards();
+                                                }
+                                                "h" => {
+                                                    changed = editor.delete_backwards();
+                                                }
+                                                "k" => {
+                                                    let current_idx = editor.cursor_idx;
+                                                    let end_idx = get_line_end(&editor.buffer, current_idx);
+                                                    let chars: Vec<char> = editor.buffer.chars().collect();
+                                                    if chars.is_empty() {
+                                                        // do nothing
+                                                    } else if current_idx < chars.len() {
+                                                        let delete_end = if chars[current_idx] == '\n' {
+                                                            current_idx + 1
+                                                        } else {
+                                                            end_idx
+                                                        };
+                                                        let mut new_buf = String::new();
+                                                        for i in 0..current_idx {
+                                                            new_buf.push(chars[i]);
+                                                        }
+                                                        for i in delete_end..chars.len() {
+                                                            new_buf.push(chars[i]);
+                                                        }
+                                                        editor.buffer = new_buf;
+                                                        changed = true;
+                                                    }
+                                                }
+                                                _ => {
+                                                    handled = false;
+                                                }
+                                            }
+                                        } else {
+                                            editor.insert_text(s);
+                                            changed = true;
+                                        }
+                                    }
+                                    _ => {
+                                        handled = false;
+                                    }
+                                }
+                                if changed {
+                                    p.1 = editor.buffer.clone();
+                                }
+                                if should_unfocus {
+                                    p.1 = editor.buffer;
+                                    self.focused_param = None;
+                                    self.code_editor = None;
+                                } else {
+                                    self.code_editor = Some(editor);
+                                }
+                                if handled {
+                                    return true;
+                                }
+                            }
+                        } else if p.2 == "text" {
+                            if let Some(tb) = &mut self.texts[idx] {
+                                if tb.keyboard_input(event, ui) {
+                                    if !tb.editing {
+                                        p.1 = tb.text.clone();
+                                        self.focused_param = None;
+                                    } else {
+                                        p.1 = tb.edit_buffer.clone();
+                                    }
+                                    return true;
+                                }
+                            }
+                        } else if p.2.starts_with("choice") {
+                            if let Some(d) = &mut self.choices[idx] {
+                                if d.keyboard_input(event, ui) {
+                                    if !d.open {
+                                        if let Some(val) = d.get_value_string() {
+                                            p.1 = val;
+                                        }
+                                        self.focused_param = None;
+                                    }
+                                    return true;
+                                }
+                            }
+                        } else if p.2.starts_with("spinbox") {
+                            if let Some(sb) = &mut self.spinboxes[idx] {
+                                if sb.keyboard_input(event, ui) {
+                                    if !sb.editing {
+                                        p.1 = sb.value.to_string();
+                                        self.focused_param = None;
+                                    } else {
+                                        p.1 = sb.edit_buffer.clone();
+                                    }
+                                    return true;
+                                }
+                            }
+                        } else if p.2.starts_with("slider") {
+                            if let Some(s) = &mut self.sliders[idx] {
+                                if s.keyboard_input(event, ui) {
+                                    let (min, max) = parse_slider_range(&p.2);
+                                    let new_val = min + s.value * (max - min);
+                                    p.1 = format!("{:.2}", new_val);
+                                    if !s.editing {
+                                        self.focused_param = None;
+                                    }
+                                    return true;
+                                }
+                            }
+                        } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
+                            if let Some(c) = &mut self.colors[idx] {
+                                if c.keyboard_input(event, ui) {
+                                    if let Some(val) = c.get_value_string() {
+                                        p.1 = val;
+                                    }
+                                    if !c.editing {
+                                        self.focused_param = None;
+                                    }
+                                    return true;
+                                }
+                            }
+                        } else if p.2.starts_with("float3") {
+                            if let Some(f) = &mut self.float3s[idx] {
+                                if f.keyboard_input(event, ui) {
+                                    let (min, max) = parse_slider_range(&p.2);
+                                    let val0 = min + f.values[0] * (max - min);
+                                    let val1 = min + f.values[1] * (max - min);
+                                    let val2 = min + f.values[2] * (max - min);
+                                    p.1 = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
+                                    if f.editing_idx.is_none() {
+                                        self.focused_param = None;
+                                    }
+                                    return true;
+                                }
+                            }
                         }
                     }
                 }
-            } else if p.2 == "text" {
-                if let Some(tb) = &self.texts[i] {
-                    param_quads.extend(tb.extra_quads());
-                }
-            } else if p.2.starts_with("choice") {
-                if let Some(d) = &self.choices[i] {
-                    param_quads.extend(d.extra_quads());
-                }
-            } else if p.2 == "button" {
-                if let Some(b) = &self.buttons[i] {
-                    param_quads.extend(b.extra_quads());
-                }
-            } else if p.2.starts_with("spinbox") {
-                if let Some(sb) = &self.spinboxes[i] {
-                    { let (bx, by, bw, bh) = sb.rect(); param_quads.push((bx, by, bw, bh, sb.color())); }
-                    param_quads.extend(sb.extra_quads());
-                }
-            } else if p.2 == "toggle" || p.2 == "checkbox" {
-                if let Some(cb) = &self.checkboxes[i] {
-                    param_quads.extend(cb.extra_quads());
+                false
+            }
+            Event::MouseWheel { delta, x: px, y: py, .. } => {
+                if !self.visible {
+                    return false;
                 }
-            } else if p.2.starts_with("color") || p.2 == "rgb" || p.2 == "rgba" {
-                if let Some(c) = &self.colors[i] {
-                    param_quads.extend(c.extra_quads());
+                let (px, py) = (*px, *py);
+                let Some(ui) = ectx.ui.as_deref_mut() else {
+                    return false;
+                };
+                for &widget_ptr in self.children.iter().rev() {
+                    let widget = unsafe { &mut *widget_ptr };
+                    if widget.mouse_wheel(delta, px, py, ui) {
+                        return true;
+                    }
                 }
-            }
-        }
-
-        for &child_ptr in &self.children {
-            let child = unsafe { &*child_ptr };
-            if child.visible() {
-                param_quads.extend(collect_child_quads(child));
-            }
-        }
-
-        // Clip all parameter quads vertically
-        for q in param_quads {
-            if let Some(clipped) = clip_quad(q) {
-                quads.push(clipped);
-            }
-        }
-
-        // Draw Scrollbar (unclipped) if content_h > base.h
-        if self.content_h > self.base.h {
-            let sb_w = crate::layout::scrollbar_width();
-            let sb_x = self.base.x + self.base.w - sb_w - 4.0;
-            let sb_track_h = self.base.h - 8.0;
-            let sb_track_y = self.base.y + 4.0;
-
-            // Track
-            quads.push((sb_x, sb_track_y, sb_w, sb_track_h, crate::color::scrollbar_track_color()));
-
-            // Thumb
-            let visible_ratio = self.base.h / self.content_h;
-            let thumb_h = if sb_track_h <= 20.0 {
-                sb_track_h
-            } else {
-                (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
-            };
-            let max_scroll = self.content_h - self.base.h;
-            let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
-            let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
-            quads.push((sb_x, thumb_y, sb_w, thumb_h, crate::color::scrollbar_thumb_color()));
-        }
-
-        quads
-    }
-
-    fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let mut quads = self.extra_quads();
-        if let Some(hq) = self.highlight_quad(ctx) {
-            if hq.4 != colors::HIGHLIGHT_SECONDARY {
-                quads.push(hq);
-            }
-        }
-        quads
-    }
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let view_min = self.base.y + 4.0;
-        let view_max = self.base.y + self.base.h - 4.0;
-        let mut labels = Vec::new();
-        for l in self.own_text_labels() {
-            if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
-                labels.push(l);
-            }
-        }
-        for &child_ptr in &self.children {
-            let widget = unsafe { &*child_ptr };
-            labels.extend(widget.text_labels());
-        }
-        labels
-    }
+                let mut changed = false;
+                let rects = self.get_param_rects();
+                for (i, p) in self.display_params.iter_mut().enumerate() {
+                    if p.2.starts_with("slider") {
+                        let r = rects[i];
+                        let row_y = r.1;
+                        if py >= row_y - 2.0 && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                            if let Some(s) = &mut self.sliders[i] {
+                                let was_scroll = s.scroll_enabled;
+                                s.set_scroll(true);
+                                if s.mouse_wheel(delta, px, py, ui) {
+                                    let (min, max) = parse_slider_range(&p.2);
+                                    let new_val = min + s.value * (max - min);
+                                    let old_val = &p.1;
+                                    let new_val_str = format!("{:.2}", new_val);
+                                    if *old_val != new_val_str {
+                                        p.1 = new_val_str;
+                                        changed = true;
+                                    }
+                                }
+                                s.set_scroll(was_scroll);
+                            }
+                        }
+                    } else if p.2.starts_with("float3") {
+                        let r = rects[i];
+                        let row_y = r.1;
+                        if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                            if let Some(f) = &mut self.float3s[i] {
+                                let rects_inner = f.get_row_rects();
+                                for j in 0..3 {
+                                    let r_inner = rects_inner[j];
+                                    if py >= r_inner.1 && py <= r_inner.1 + r_inner.3 {
+                                        let scroll_amount = match delta {
+                                            MouseScrollDelta::LineDelta(_x, y) => *y,
+                                            MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 120.0,
+                                        };
+                                        let step = 0.02;
+                                        let new_val = (f.values[j] - scroll_amount * step).clamp(0.0, 1.0);
+                                        if (new_val - f.values[j]).abs() > 0.0001 {
+                                            f.values[j] = new_val;
+                                            if f.editing_idx == Some(j) {
+                                                let scaled_val = f.mins[j] + f.values[j] * (f.maxs[j] - f.mins[j]);
+                                                f.edit_buffer = format!("{:.2}", scaled_val);
+                                            }
+                                            let (min, max) = parse_slider_range(&p.2);
+                                            let val0 = min + f.values[0] * (max - min);
+                                            let val1 = min + f.values[1] * (max - min);
+                                            let val2 = min + f.values[2] * (max - min);
+                                            let new_val_str = format!("{:.2}:{:.2}:{:.2}", val0, val1, val2);
+                                            if p.1 != new_val_str {
+                                                p.1 = new_val_str;
+                                                changed = true;
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    } else if p.2.starts_with("spinbox") {
+                        let r = rects[i];
+                        let row_y = r.1;
+                        if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                            if let Some(sb) = &mut self.spinboxes[i] {
+                                let scroll_amount = match delta {
+                                    MouseScrollDelta::LineDelta(_x, y) => *y as i32,
+                                    MouseScrollDelta::PixelDelta(pos) => {
+                                        let dy = pos.y;
+                                        if dy > 0.0 { 1 } else if dy < 0.0 { -1 } else { 0 }
+                                    }
+                                };
+                                let new_val = (sb.value + scroll_amount * sb.step).clamp(sb.min, sb.max);
+                                if sb.value != new_val {
+                                    sb.value = new_val;
+                                    p.1 = new_val.to_string();
+                                    changed = true;
+                                }
+                            }
+                        }
+                    }
+                }
 
-    fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
-        if !self.visible {
-            return Vec::new();
-        }
-        let view_min = self.base.y + 4.0;
-        let view_max = self.base.y + self.base.h - 4.0;
-        let mut result = Vec::new();
-        let font = self.widget_font();
-        let rects = self.get_param_rects();
-        for l in self.own_text_labels() {
-            if l.y < view_min - 20.0 || l.y > view_max + 20.0 {
-                continue;
-            }
-            let mut bounds = Some([self.base.x + 4.0, view_min, self.base.x + self.base.w - 4.0, view_max]);
-            let mut label_font = font.clone();
-            for (i, p) in self.display_params.iter().enumerate() {
-                if p.2 == "code" {
-                    let r = rects[i];
-                    if l.y >= r.1 + 18.0 && l.y <= r.1 + r.3 {
-                        let code_min = (r.1 + 19.0).max(view_min);
-                        let code_max = (r.1 + r.3 - 1.0).min(view_max);
-                        if code_min < code_max {
-                            bounds = Some([r.0 + 1.0, code_min, r.0 + r.2 - 1.0, code_max]);
-                        } else {
-                            bounds = Some([0.0, 0.0, 0.0, 0.0]); // hidden
+                // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
+                // address, then rect-or-popover containment.
+                if !changed && !ui.is_coordinate_covered(self_addr, px, py) {
+                    let in_rect = px >= self.rect.x
+                        && px <= self.rect.x + self.rect.width
+                        && py >= self.rect.y
+                        && py <= self.rect.y + self.rect.height;
+                    let in_popover = self.own_popover_rect().map_or(false, |(rx, ry, rw, rh)| {
+                        px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
+                    });
+                    if in_rect || in_popover {
+                        let scroll_speed = 24.0;
+                        let dy = match delta {
+                            MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
+                            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+                        };
+                        let old_scroll = self.scroll_y;
+                        let max_scroll = (self.content_h - self.rect.height).max(0.0);
+                        self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
+                        if (self.scroll_y - old_scroll).abs() > 0.01 {
+                            self.update_slider_rects();
+                            changed = true;
                         }
-                        label_font = Some("monospace".to_string());
-                        break;
                     }
                 }
+
+                changed
             }
-            result.push((l, label_font, bounds));
-        }
-        for &child_ptr in &self.children {
-            let widget = unsafe { &*child_ptr };
-            result.extend(widget.text_labels_with_font_and_bounds(ctx));
+            _ => false,
         }
-        result
     }
+}
 
-    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
-        if !self.visible {
-            return None;
-        }
-        for d_opt in &self.choices {
-            if let Some(d) = d_opt {
-                if let Some(r) = d.popover_rect() {
-                    return Some(r);
-                }
-            }
-        }
-        for &widget_ptr in self.children.iter().rev() {
-            let widget = unsafe { &*widget_ptr };
-            if let Some(r) = widget.popover_rect() {
-                return Some(r);
+fn parse_slider_range(ptype: &str) -> (f32, f32) {
+    if ptype.starts_with("slider:") || ptype.starts_with("float3:") {
+        let parts: Vec<&str> = ptype.split(':').collect();
+        if parts.len() >= 3 {
+            if let (Ok(min), Ok(max)) = (parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                return (min, max);
             }
         }
-        None
     }
+    (0.0, 2.0)
+}
 
-    fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
-        if !self.visible {
-            return;
-        }
-        for d_opt in &self.choices {
-            if let Some(d) = d_opt {
-                d.render_popover(pc);
+fn parse_hex_to_rgb(s: &str) -> Option<[u8; 3]> {
+    crate::color::parse_hex_bytes(s).map(|[r, g, b, _]| [r, g, b])
+}
+
+fn parse_spinbox_range(ptype: &str) -> (i32, i32, i32) {
+    if ptype.starts_with("spinbox:") {
+        let parts: Vec<&str> = ptype.split(':').collect();
+        if parts.len() >= 4 {
+            if let (Ok(min), Ok(max), Ok(step)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>(), parts[3].parse::<i32>()) {
+                return (min, max, step);
+            }
+        } else if parts.len() == 3 {
+            if let (Ok(min), Ok(max)) = (parts[1].parse::<i32>(), parts[2].parse::<i32>()) {
+                return (min, max, 1);
             }
-        }
-        for &widget_ptr in self.children.iter().rev() {
-            let widget = unsafe { &*widget_ptr };
-            widget.render_popover(pc);
         }
     }
+    (0, 10000, 1)
+}
 
-    fn tick(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
-        if !self.visible {
-            return false;
-        }
-        let mut changed = false;
-        for &widget_ptr in &self.children {
-            let widget = unsafe { &mut *widget_ptr };
-            if widget.tick(dt, ctx) {
-                changed = true;
-            }
-        }
-        for cb_opt in &mut self.checkboxes {
-            if let Some(cb) = cb_opt {
-                if cb.tick(dt, ctx) {
-                    changed = true;
+fn parse_float3_value(val_str: &str, min: f32, max: f32) -> [f32; 3] {
+    let mut out = [0.5, 0.5, 0.5];
+    let parts: Vec<&str> = val_str
+        .split(|c| c == ':' || c == ',' || c == ' ')
+        .filter(|s| !s.is_empty())
+        .collect();
+    for i in 0..3 {
+        if i < parts.len() {
+            if let Ok(v) = parts[i].parse::<f32>() {
+                let range = max - min;
+                if range != 0.0 {
+                    out[i] = ((v - min) / range).clamp(0.0, 1.0);
+                } else {
+                    out[i] = 0.0;
                 }
             }
         }
-        changed
     }
+    out
 }
 
 fn collect_child_quads(widget: &dyn Element) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
@@ -1744,10 +1812,7 @@ impl ParamController for ParametersBg {
                 }
             }
         }
-        self.content_h = self.get_total_content_height();
-        let max_scroll = (self.content_h - self.base.h).max(0.0);
-        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
-        self.update_slider_rects();
+        self.refresh_scroll_metrics();
     }
 }
 
@@ -1810,4 +1875,112 @@ fn get_line_end(buffer: &str, cursor_idx: usize) -> usize {
     map_2d_to_1d(buffer, line, line_len)
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::context::UiContext;
+
+    fn panel_with(params: &[(&str, &str, &str)]) -> Adapted<ParametersBg> {
+        let mut p = ParametersBg::new();
+        let params: Vec<(String, String, String)> = params
+            .iter()
+            .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string()))
+            .collect();
+        {
+            let elem: &mut dyn Element = &mut p;
+            elem.as_param_controller_mut().unwrap().set_display_params(&params);
+        }
+        Element::set_rect(&mut p, 0.0, 0.0, 300.0, 400.0);
+        p
+    }
+
+    #[test]
+    fn param_controller_roundtrip_and_row_widgets() {
+        let p = panel_with(&[
+            ("Size", "1.00", "slider:0:2"),
+            ("Mode", "b", "choice:a,b,c"),
+            ("On", "true", "checkbox"),
+        ]);
+        let elem: &dyn Element = &p;
+        let pc = elem.as_param_controller().unwrap();
+        assert_eq!(pc.node_params().len(), 3);
+        assert!(p.sliders[0].is_some() && p.choices[1].is_some() && p.checkboxes[2].is_some());
+        // Rows were laid out from the cached rect.
+        let (sx, _, sw, _) = p.sliders[0].as_ref().unwrap().rect();
+        assert_eq!((sx, sw), (8.0, 284.0), "row rect derives from the assigned rect");
+    }
+
+    #[test]
+    fn checkbox_click_commits_value_and_unfocus_commits_editor() {
+        let mut ctx = UiContext::new();
+        let mut p = panel_with(&[("On", "false", "checkbox")]);
+        let (cx, cy, _, ch) = p.checkboxes[0].as_ref().unwrap().rect();
+        // Click the checkbox row (presses are ungated for this widget; the panel consumes
+        // every left press, so the return is true either way — assert the value flip).
+        p.mouse_input(MouseButton::Left, ElementState::Pressed, cx + 6.0, cy + ch / 2.0, &mut ctx);
+        p.mouse_input(MouseButton::Left, ElementState::Released, cx + 6.0, cy + ch / 2.0, &mut ctx);
+        let elem: &dyn Element = &p;
+        assert_eq!(elem.as_param_controller().unwrap().node_params()[0].1, "true");
+
+        // Code editor: focus it via a click, type, then unfocus commits the buffer.
+        let mut p = panel_with(&[("Src", "let x = 1;", "code")]);
+        let rects = p.get_param_rects();
+        let r = rects[0];
+        p.mouse_input(MouseButton::Left, ElementState::Pressed, r.0 + 20.0, r.1 + 30.0, &mut ctx);
+        assert_eq!(p.focused_param, Some(0), "code row focused");
+        assert!(p.code_editor.is_some());
+        p.code_editor.as_mut().unwrap().insert_text("y");
+        Element::unfocus(&mut p);
+        assert_eq!(p.focused_param, None);
+        assert!(p.code_editor.is_none());
+        let elem: &dyn Element = &p;
+        assert!(elem.as_param_controller().unwrap().node_params()[0].1.contains('y'), "editor buffer committed on unfocus");
+    }
+
+    #[test]
+    fn plain_view_serves_row_chrome_and_all_quads_stays_empty() {
+        let ctx = UiContext::new();
+        let p = panel_with(&[("Size", "1.00", "slider:0:2")]);
+        // The designer's plain path: extra_quads carries the row chrome (clipped), including
+        // the slider background it reads via rect()+color()...
+        let extra = Element::extra_quads(&p);
+        assert!(!extra.is_empty(), "row chrome served through extra_quads");
+        // ...but NOT the panel's own PARAM_BG plate (the host draws that from color()).
+        let (x, y, w, h) = Element::rect(&p);
+        assert!(
+            !extra.iter().any(|q| (q.0, q.1, q.2, q.3) == (x, y, w, h)),
+            "panel bg plate is the host's, not extra_quads'"
+        );
+        // The no-double-draw contract of the plain-quad hatch.
+        assert!(Element::all_quads(&p, &ctx).is_empty());
+        // Per-label hatch: labels carry the widget font and viewport bounds.
+        let labels = Element::text_labels_with_font_and_bounds(&p, &ctx);
+        assert!(!labels.is_empty());
+        assert!(labels.iter().all(|(_, font, bounds)| font.is_some() && bounds.is_some()));
+    }
 
+    #[test]
+    fn scroll_wheel_scrolls_when_content_overflows() {
+        let mut ctx = UiContext::new();
+        let rows: Vec<(String, String, String)> = (0..30)
+            .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
+            .collect();
+        let mut p = ParametersBg::new();
+        {
+            let elem: &mut dyn Element = &mut p;
+            elem.as_param_controller_mut().unwrap().set_display_params(&rows);
+        }
+        Element::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
+        assert!(p.content_h > 200.0);
+        assert!(Element::is_scrollable(&p));
+        // Wheel over the panel body but off every slider row's x-span is impossible (rows are
+        // full-width), so scroll via the region below the last visible row: use a y between
+        // rows (the 2px slack above a row) — simplest is the bottom padding strip.
+        let before = p.scroll_y;
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 150.0, 199.0, &mut ctx);
+        // Either a slider consumed it (value change) or the panel scrolled; both mark change.
+        // The panel-scroll path must work when no slider is under the pointer:
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
+        assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
+    }
+}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index b0953cf..8bcae4d 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -320,6 +320,26 @@ pub trait Paint {
     fn forwarded_highlight(&self, _ctx: &UiContext) -> Option<Option<(f32, f32, f32, f32, [f32; 4])>> {
         None
     }
+
+    /// Whether this widget serves
+    /// [`legacy_labels_with_font_and_bounds`](Paint::legacy_labels_with_font_and_bounds) —
+    /// the text sibling of the dual-geometry escape hatch. The adapter's standard text bridge
+    /// gives every own label ONE font ([`widget_font`](Paint::widget_font)) and ONE clip rect
+    /// ([`text_bounds`](Paint::text_bounds)); a widget whose legacy
+    /// `text_labels_with_font_and_bounds` override assigns them PER LABEL (ParametersBg clips
+    /// each label to its viewport but its code editor's to the code box, in monospace) serves
+    /// that view verbatim instead. Transitional — dies when `Prim::Text` carries font+bounds.
+    fn serves_legacy_labels(&self) -> bool {
+        false
+    }
+
+    /// The per-label font+bounds text view for legacy `text_labels_with_font_and_bounds`
+    /// readers. Served as a FULL replacement: the adapter adds no child aggregation on top, so
+    /// a container's implementation must include its children (as the legacy overrides did —
+    /// hence the ctx, which the child recursion needs).
+    fn legacy_labels_with_font_and_bounds(&self, _rect: Rect, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+        Vec::new()
+    }
 }
 
 /// What an event handler may reach beyond its own state — the RFC §3.5 `EventCtx`, grown as
@@ -365,6 +385,14 @@ impl EventCtx<'_> {
             ui.handle_right_click(ptr, px, py);
         }
     }
+
+    /// This widget's identity address for the legacy address-keyed walks
+    /// (`UiContext::is_coordinate_covered` excludes the querying widget by pointer — the
+    /// adapter's, which is also what hosts register/popover-track). Zero outside a routed
+    /// path; the coverage walk then simply excludes nothing.
+    pub fn widget_addr(&self) -> usize {
+        self.self_ptr.map(|p| p as *const () as usize).unwrap_or(0)
+    }
 }
 
 /// The input concern — hit-testing and event handling against the laid-out rect. Mirrors the
@@ -1190,6 +1218,14 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     }
 
     fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+        // Per-label font+bounds escape hatch (ParametersBg): the widget's view is served
+        // verbatim, children included — no aggregation on top.
+        if Paint::serves_legacy_labels(&self.inner) {
+            if !self.visible() {
+                return Vec::new();
+            }
+            return Paint::legacy_labels_with_font_and_bounds(&self.inner, self.content_rect(), ctx);
+        }
         let mut own = self.own_labels_with_font_and_bounds(ctx);
         if self.visible() {
             for child in self.visible_children() {