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

commit0c401d2c18d61bc9854f1eecb2242b29a04484e2
parentba2f27528b
authorLucas Galante <[email protected]>
date2026-07-08 23:43
feat(widget): migrate Dropdown (Phase 5p)

First popover widget through the 5o Paint::popover/draw_popover surface.
New adapter hook: Layout::intrinsic_measure_width + Element::measure
override on Adapted (preserves auto_width measuring). gates_presses=false
(outside-press close), dynamic z_order, FocusIn re-claims the global slot
(direct focus() callers), FocusOut closes without releasing it — all
bug-for-bug. Backplate-concentric corners walk a parent_changed-tracked
pointer (exact for graph's Dropdown→Plate→Backplate chain); the legacy
'parent' field stays direct-write-only, so the Ramp popover clamp stays
dormant in production exactly as legacy shipped. Dropped, flagged: the
row-rect hit expansion (consistent with all migrated controls).

In-crate sweep: ramp, parameters_bg, multi_control hold Adapted<Dropdown>.

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

 src/widget/container/parameters_bg.rs |    2 +-
 src/widget/input/dropdown.rs          | 1222 +++++++++++++++++----------------
 src/widget/input/multi_control.rs     |    2 +-
 src/widget/input/ramp.rs              |   12 +-
 src/widget/model.rs                   |   24 +
 5 files changed, 666 insertions(+), 596 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index c2977eb..4f09912 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -14,7 +14,7 @@ pub struct ParametersBg {
     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<Dropdown>>,
+    pub choices: Vec<Option<crate::widget::Adapted<Dropdown>>>,
     pub texts: Vec<Option<TextBox>>,
     pub checkboxes: Vec<Option<crate::widget::Adapted<Checkbox>>>,
     pub colors: Vec<Option<ColorSelector>>,
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 66fa8b3..59ea0e2 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -1,68 +1,84 @@
+//! Narrow-trait `Dropdown` (Phase 5p — first popover widget through `Paint::popover` /
+//! `draw_popover`, the 5o surface). Detached-label control on the Slider convention (no rect
+//! inflation; the label eats into the assigned rect), `Control::control_label`'s +4px inset via
+//! `Layout::detached_label_inset`, side-label inset computed from the synced label.
+//!
+//! Parity notes (all legacy-faithful, verified against the pre-migration impl):
+//! - `parent` stays a public, direct-write-only field: legacy `set_parent` never wrote it (the
+//!   Element default only touched the tree — Ramp's dummy-ctx `set_parent` calls were silently
+//!   discarded), so the Ramp popover clamp and the fade-blend parent color activate only for
+//!   callers that assign the field, exactly as before. The backplate-concentric corner walk
+//!   (which legacy ran over the ctx tree) instead starts from a separate pointer captured by
+//!   `Layout::parent_changed` and hops legacy field-based `parent(&dummy)` impls — exact for
+//!   the real consumer (cce-graph: Dropdown → Plate → Backplate, both field-based).
+//! - The row-rect hit expansion (`base.row_x/row_w`) is dropped, consistent with every other
+//!   migrated control: `Input::hit` tests the widget rect plus the open popover.
+//! - `Layout::intrinsic_measure_width` (new hook) preserves the `auto_width` measure behavior
+//!   (cce-system-settings sizes its page dropdown from `Element::measure`).
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
+use crate::widget::{
+    Control, Element, ElementState, Event, Key, MouseButton, NamedKey, UiContext,
+};
+
+/// Side-layout label inset — the legacy `Element::label_x_offset` default for non-exempt
+/// widgets (Dropdown was never in the exempt list).
+fn side_offset(label: &Option<String>) -> f32 {
+    if crate::layout::control_label_layout() == "side" && label.is_some() {
+        90.0
+    } else {
+        0.0
+    }
+}
 
 #[derive(Debug, Clone)]
 pub struct Dropdown {
-    base: Widget,
     pub options: Vec<String>,
     pub selected: usize,
     pub open: bool,
     pub(crate) hovered_item: Option<usize>,
     just_changed: bool,
+    /// Legacy-faithful parent pointer: written ONLY by direct assignment (the Ramp-clamp unit
+    /// test; no production writer). Read by the Ramp popover clamp and the fade-blend parent
+    /// color, like legacy. NOT the corner-walk pointer — see `tracked_parent`.
     pub parent: Option<*mut (dyn Element + 'static)>,
-    pub children: Vec<*mut (dyn Element + 'static)>,
+    /// Captured by [`Layout::parent_changed`] whenever a container `set_parent`s this widget —
+    /// the model-side stand-in for the ctx-tree head the legacy backplate corner walk started
+    /// from (`paint` has no ctx to reach the real tree).
+    tracked_parent: Option<*mut (dyn Element + 'static)>,
     pub font_family: String,
     pub custom_display_text: Option<String>,
     pub open_upward: Option<bool>,
     pub auto_width: bool,
+    /// Synced copy of the control label ([`Paint::sync_label`]) — drives the side/detached
+    /// offsets the base label geometry imposes on the widget's own geometry.
+    label: Option<String>,
+    /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (the adapter's hover
+    /// bookkeeping hit-tests through [`Input::hit`], which includes the open popover — matching
+    /// the legacy `on_cursor_moved` + popover-aware `hit_test` pair).
+    hovered: bool,
 }
 
 impl Dropdown {
-    pub fn new(options: Vec<String>, selected: usize) -> Self {
-        Self {
-            base: Widget::new(),
+    pub fn new(options: Vec<String>, selected: usize) -> Adapted<Dropdown> {
+        Adapted::new(Dropdown {
             options,
             selected,
             open: false,
             hovered_item: None,
             just_changed: false,
             parent: None,
-            children: Vec::new(),
+            tracked_parent: None,
             font_family: "sans-serif".to_string(),
             custom_display_text: None,
             open_upward: None,
             auto_width: false,
-        }
-    }
-
-    pub fn with_custom_display_text(mut self, text: &str) -> Self {
-        self.custom_display_text = Some(text.to_string());
-        self
-    }
-
-    pub fn with_font_family(mut self, font_family: &str) -> Self {
-        self.font_family = font_family.to_string();
-        self
-    }
-
-    pub fn with_label(mut self, label: &str) -> Self {
-        self.base.label = Some(label.to_string());
-        self
-    }
-
-    pub fn with_open_upward(mut self, open_upward: bool) -> Self {
-        self.open_upward = Some(open_upward);
-        self
-    }
-
-    pub fn with_config(mut self, file: &str, key: &str) -> Self {
-        self.base.config_file = Some(file.to_string());
-        self.base.config_key = Some(key.to_string());
-        self
-    }
-
-    pub fn set_label(&mut self, label: &str) {
-        self.base.label = Some(label.to_string());
+            label: None,
+            hovered: false,
+        })
     }
 
     pub fn take_change(&mut self) -> bool {
@@ -71,11 +87,6 @@ impl Dropdown {
         changed
     }
 
-    pub fn with_auto_width(mut self, auto_width: bool) -> Self {
-        self.auto_width = auto_width;
-        self
-    }
-
     pub fn content_width(&self) -> f32 {
         let font_setting = crate::layout::control_label_font_detached();
         let (font_family, font_size_opt) = crate::layout::parse_font_string(&font_setting);
@@ -90,40 +101,54 @@ impl Dropdown {
         max_w
     }
 
-    pub fn popover_width(&self) -> f32 {
-        self.base.w.max(self.content_width())
+    /// The detached-label strip height above the content rect — a replica of
+    /// `Widget::label_offset` over the synced label (zero in side layout or unlabeled).
+    fn label_top(&self) -> f32 {
+        if crate::layout::control_label_layout() == "side" {
+            return 0.0;
+        }
+        if self.label.is_some() {
+            let (_, font_size) = crate::layout::control_label_font_detached_parsed();
+            font_size + crate::layout::control_label_margin()
+        } else {
+            0.0
+        }
     }
 
-    pub fn get_popover_geom(&self) -> (f32, f32, f32, f32) {
-        let rw = self.popover_width();
+    fn popover_width(&self, content: Rect) -> f32 {
+        content.width.max(self.content_width())
+    }
+
+    /// Popover geometry against the laid-out content rect — the legacy `get_popover_geom`,
+    /// with the base-rect reads rewritten in content-rect terms (`base.y + base.h` ⇒
+    /// `content.y + content.height`, `base.y + label_offset` ⇒ `content.y`).
+    pub fn popover_geom(&self, content: Rect) -> (f32, f32, f32, f32) {
+        let rw = self.popover_width(content);
         let rh = self.options.len() as f32 * 24.0;
-        
-        let open_upward = self.open_upward.unwrap_or_else(|| self.base.y > 400.0);
-        let label_x = self.label_x_offset();
-        
-        let mut rx = self.base.x + label_x;
+
+        let base_y = content.y - self.label_top();
+        let open_upward = self.open_upward.unwrap_or(base_y > 400.0);
+        let label_x = side_offset(&self.label);
+
+        let mut rx = content.x + label_x;
         let mut ry = if open_upward {
-            let label_offset = self.base.label_offset();
-            self.base.y + label_offset - rh
+            content.y - rh
         } else {
-            self.base.y + self.base.h
+            content.y + content.height
         };
-        
+
         let mut is_ramp = false;
         if let Some(parent_ptr) = self.parent {
-            is_ramp = unsafe {
-                (*parent_ptr).as_any().is::<crate::widget::Ramp>()
-            };
+            is_ramp = unsafe { (*parent_ptr).as_any().is::<crate::widget::Ramp>() };
         }
-        
+
         if is_ramp {
             if let Some(parent_ptr) = self.parent {
                 let (px, py, pw_parent, ph_parent) = unsafe { (*parent_ptr).rect() };
                 if pw_parent > 0.0 && ph_parent > 0.0 {
-                    let label_offset = self.base.label_offset();
-                    let dy_down = self.base.y + self.base.h;
-                    let dy_up = self.base.y + label_offset - rh;
-                    
+                    let dy_down = content.y + content.height;
+                    let dy_up = content.y - rh;
+
                     if self.open_upward.is_none() {
                         if dy_down + rh > py + ph_parent && dy_up >= py {
                             ry = dy_up;
@@ -131,7 +156,7 @@ impl Dropdown {
                             ry = dy_down;
                         }
                     }
-                    
+
                     // Clamp X to parent borders
                     if rx < px {
                         rx = px;
@@ -139,7 +164,7 @@ impl Dropdown {
                     if rx + rw > px + pw_parent {
                         rx = px + pw_parent - rw;
                     }
-                    
+
                     // Clamp Y to parent borders
                     if ry < py {
                         ry = py;
@@ -150,373 +175,267 @@ impl Dropdown {
                 }
             }
         }
-        
+
         (rx, ry, rw, rh)
     }
 
-    pub fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
-        if !self.open { return; }
-        
-        let (rx, ry, rw, rh) = self.get_popover_geom();
-        
-        // 1. Soft layered drop shadows
-        pc.rect([0.02, 0.02, 0.05, 0.15], rx + 1.0, ry + 1.0, rw, rh);
-        pc.rect([0.02, 0.02, 0.05, 0.08], rx + 3.0, ry + 3.0, rw, rh);
-        pc.rect([0.02, 0.02, 0.05, 0.04], rx + 5.0, ry + 5.0, rw, rh);
-
-        let theme = colors::active_theme();
-
-        // 2. High-contrast premium outer border
-        pc.rect(theme.surface_border, rx, ry, rw, rh);
-        
-        // 3. Frosted glass background
-        pc.rect(theme.surface_bg, rx + 1.0, ry + 1.0, rw - 2.0, rh - 2.0); // bg
-        
-        if let Some(h_idx) = self.hovered_item {
-            let iy = ry + h_idx as f32 * 24.0;
-            // 4. Vibrantly colored translucent selection highlight
-            pc.rect(theme.primary_accent, rx + 2.0, iy + 2.0, rw - 4.0, 20.0);
+    fn border_color(&self) -> [f32; 4] {
+        if self.open {
+            [0.30, 0.50, 0.32, 1.0]
+        } else if self.hovered {
+            let bc = colors::dropdown_border_color();
+            [(bc[0] + 0.15).min(1.0), (bc[1] + 0.15).min(1.0), (bc[2] + 0.15).min(1.0), bc[3]]
+        } else {
+            colors::dropdown_border_color()
         }
-        
-        for (idx, opt) in self.options.iter().enumerate() {
-            let iy = crate::layout::align_text_y(ry + idx as f32 * 24.0, 24.0, 12.0, 0.0);
-            
-            if opt == "-" {
-                pc.rect(theme.surface_border, rx + 8.0, ry + idx as f32 * 24.0 + 11.5, rw - 16.0, 1.0);
-                continue;
-            }
+    }
 
-            let text_color = if self.hovered_item == Some(idx) {
-                [0xff, 0xff, 0xff]
-            } else if self.selected == idx {
-                [0x3a, 0x9a, 0xff]
-            } else {
-                [0xcc, 0xcc, 0xd4]
-            };
-            
-            let color_f32 = [
-                text_color[0] as f32 / 255.0,
-                text_color[1] as f32 / 255.0,
-                text_color[2] as f32 / 255.0,
-                1.0,
-            ];
-            
-            let bounds = Some([rx, ry, rx + rw, ry + rh]);
-            if let Some(ref font) = self.widget_font() {
-                pc.text_with_font_and_bounds(
-                    opt,
-                    rx + 8.0,
-                    iy,
-                    12.0,
-                    color_f32,
-                    font,
-                    bounds,
-                );
-            } else {
-                pc.text_with_bounds(
-                    opt,
-                    rx + 8.0,
-                    iy,
-                    12.0,
-                    color_f32,
-                    bounds,
-                );
+    /// The legacy backplate-ancestor lookup for concentric corners, walked from the tracked
+    /// parent with a dummy ctx (field-based legacy `parent` impls answer; tree-only ones end
+    /// the walk, so deep tree-linked chains lose the adjustment — flagged in the module docs).
+    fn backplate_ancestor(&self) -> Option<*mut (dyn Element + 'static)> {
+        let dummy = crate::context::UiContext::new();
+        let mut curr = self.tracked_parent;
+        while let Some(ptr) = curr {
+            if unsafe { (*ptr).is_backplate() } {
+                return Some(ptr);
             }
+            curr = unsafe { (*ptr).parent(&dummy) };
         }
+        None
     }
-}
-
-impl Default for Dropdown {
-    fn default() -> Self {
-        Self::new(Vec::new(), 0)
-    }
-}
-
-impl Element for Dropdown {
-    crate::impl_widget_base!(Dropdown);
 
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.base.x = x;
-        self.base.y = y;
-        self.base.w = w;
-        self.base.h = h;
-    }
+    /// Emit the border + background geometry — the legacy `all_rounded_quads` body (rounded,
+    /// with the backplate-concentric corner adjustment) or `extra_quads` (plain) depending on
+    /// the configured radius, byte-for-byte on the same content rect.
+    fn paint_background(&self, content: Rect, ctx: &mut PaintCtx) {
+        let label_x = side_offset(&self.label);
+        let x = content.x + label_x;
+        let w = content.width - label_x;
+        let y = content.y;
+        let visual_h = content.height;
 
-    fn get_value_string(&self) -> Option<String> {
-        self.options.get(self.selected).cloned()
-    }
+        let mut bg_color = colors::dropdown_background_color();
+        bg_color[3] = 1.0; // Force opaque background to prevent subpixel blending artifacts
+        let border_color = self.border_color();
+
+        let radius = crate::layout::dropdown_corner_radius();
+        if radius <= 0.0 {
+            ctx.quad(Rect { x, y, width: w, height: visual_h }, border_color);
+            ctx.quad(
+                Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: visual_h - 2.0 },
+                bg_color,
+            );
+            return;
+        }
 
-    fn set_value_string(&mut self, val: &str) -> bool {
-        let val_trimmed = val.trim();
-        for (idx, opt) in self.options.iter().enumerate() {
-            if opt.eq_ignore_ascii_case(val_trimmed) {
-                if self.selected != idx {
-                    self.selected = idx;
-                    self.just_changed = true;
-                    return true;
-                }
-                return false;
+        let inner_radius = (radius - 1.0).max(0.0);
+        let mut adjusted = false;
+        let mut outer_radii = [radius; 4];
+        let mut inner_radii = [inner_radius; 4];
+
+        if let Some(bp) = self.backplate_ancestor() {
+            let (px, py, pw, ph) = unsafe { (*bp).rect() };
+            let pr = unsafe { (*bp).corner_radius() };
+            let (pr1, pr2, pr3, pr4) = unsafe { (*bp).rounded_corners() };
+
+            let g_left = x - px;
+            let g_top = y - py;
+            let g_right = (px + pw) - (x + w);
+            let g_bottom = (py + ph) - (y + visual_h);
+
+            if pr1 && (g_left - g_top).abs() < 1.0 && g_left >= 0.0 {
+                outer_radii[0] = (pr - g_left).max(0.0);
+                inner_radii[0] = (outer_radii[0] - 1.0).max(0.0);
+                adjusted = true;
             }
-        }
-        if let Ok(idx) = val_trimmed.parse::<usize>() {
-            if idx < self.options.len() {
-                if self.selected != idx {
-                    self.selected = idx;
-                    self.just_changed = true;
-                    return true;
-                }
-                return false;
+            if pr2 && (g_right - g_top).abs() < 1.0 && g_right >= 0.0 {
+                outer_radii[1] = (pr - g_right).max(0.0);
+                inner_radii[1] = (outer_radii[1] - 1.0).max(0.0);
+                adjusted = true;
+            }
+            if pr3 && (g_right - g_bottom).abs() < 1.0 && g_right >= 0.0 {
+                outer_radii[2] = (pr - g_right).max(0.0);
+                inner_radii[2] = (outer_radii[2] - 1.0).max(0.0);
+                adjusted = true;
+            }
+            if pr4 && (g_left - g_bottom).abs() < 1.0 && g_left >= 0.0 {
+                outer_radii[3] = (pr - g_left).max(0.0);
+                inner_radii[3] = (outer_radii[3] - 1.0).max(0.0);
+                adjusted = true;
             }
         }
-        false
-    }
 
-    fn take_change(&mut self) -> bool {
-        self.take_change()
-    }
-
-    fn color(&self) -> [f32; 4] {
-        [0.0, 0.0, 0.0, 0.0]
+        if adjusted {
+            for (qx, qy, qw, qh, qr, qc, corners) in crate::layout::partition_concentric_corners(
+                x, y, w, visual_h, radius, outer_radii, border_color,
+            ) {
+                ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
+            }
+            for (qx, qy, qw, qh, qr, qc, corners) in crate::layout::partition_concentric_corners(
+                x + 1.0, y + 1.0, w - 2.0, visual_h - 2.0, inner_radius, inner_radii, bg_color,
+            ) {
+                ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
+            }
+        } else {
+            let corners = (true, true, true, true);
+            ctx.rounded_rect(Rect { x, y, width: w, height: visual_h }, radius, corners, border_color);
+            ctx.rounded_rect(
+                Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: visual_h - 2.0 },
+                inner_radius,
+                corners,
+                bg_color,
+            );
+        }
     }
 
-    fn measure(&self, constraints: LayoutConstraints, _ctx: &UiContext) -> Size {
-        let (_, _, w, h) = self.rect();
-        let pref_w = if self.auto_width {
-            self.content_width()
+    /// Emit the selected-text (per-character fade against the right edge) and the ▼ arrow —
+    /// the legacy `text_labels` body minus the control label (the adapter's base-label
+    /// machinery draws that, with the +4px `detached_label_inset`).
+    fn paint_text(&self, content: Rect, ctx: &mut PaintCtx) {
+        let selected_text = if let Some(ref custom_text) = self.custom_display_text {
+            custom_text.clone()
         } else {
-            w
+            self.options.get(self.selected).cloned().unwrap_or_default()
         };
-        let pref_h = self.preferred_height().unwrap_or(h);
-        
-        let width = pref_w.clamp(constraints.min_width, constraints.max_width);
-        let height = pref_h.clamp(constraints.min_height, constraints.max_height);
-        
-        Size { width, height }
-    }
-
-    fn preferred_height(&self) -> Option<f32> {
-        Some(crate::layout::dropdown_height())
-    }
-
-    /// Content size for the scene layout engine (Phase 2b): wide enough for the widest option
-    /// (via `content_width`, which already includes the arrow/padding inset), at the configured
-    /// dropdown height — so the control doesn't resize as the selection changes.
-    fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> {
-        Some(crate::scene::layout::Size::new(self.content_width(), crate::layout::dropdown_height()))
-    }
 
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) {
-        let r = crate::layout::dropdown_corner_radius();
-        if r > 0.0 {
-            (true, true, true, true)
-        } else {
-            (false, false, false, false)
+        let (font_family, font_size) = crate::layout::control_label_font_detached_parsed();
+        let label_x = side_offset(&self.label);
+        let x = content.x + label_x;
+        let w = content.width - label_x;
+        let start_x = x + 8.0;
+        let right_limit = x + w - 28.0; // 10px margin before the arrow
+        let fade_start_x = (right_limit - 24.0).max(start_x); // Fade out over the last 24px
+        let text_y = crate::layout::center_text_y(content.y, content.height, font_size);
+        let tc = colors::dropdown_text_color();
+        let default_color = [
+            (colors::linear_to_srgb(tc[0]) * 255.0).round() as u8,
+            (colors::linear_to_srgb(tc[1]) * 255.0).round() as u8,
+            (colors::linear_to_srgb(tc[2]) * 255.0).round() as u8,
+        ];
+        let bg_color = colors::dropdown_background_color();
+        let mut parent_color = colors::page_color();
+        if let Some(parent_ptr) = self.parent {
+            unsafe {
+                parent_color = (*parent_ptr).color();
+            }
         }
-    }
-
-    fn corner_radius(&self) -> f32 {
-        crate::layout::dropdown_corner_radius()
-    }
-
-    fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
-        let mut quads = Vec::new();
-        let (r1, r2, r3, r4) = self.rounded_corners();
-        if r1 || r2 || r3 || r4 {
-            let top = self.base.label_offset();
-            let visual_h = self.base.h - top;
-            let radius = self.corner_radius();
-            
-            let mut bg_color = colors::dropdown_background_color();
-            bg_color[3] = 1.0; // Force opaque background to prevent subpixel blending artifacts
-            let border_color = if self.open {
-                [0.30, 0.50, 0.32, 1.0]
-            } else if self.base.hovered {
-                let bc = colors::dropdown_border_color();
-                [(bc[0] + 0.15).min(1.0), (bc[1] + 0.15).min(1.0), (bc[2] + 0.15).min(1.0), bc[3]]
-            } else {
-                colors::dropdown_border_color()
-            };
+        let alpha = 1.0; // The dropdown background is drawn fully opaque
+        let bg_rgb = [
+            ((parent_color[0] * (1.0 - alpha) + bg_color[0] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
+            ((parent_color[1] * (1.0 - alpha) + bg_color[1] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
+            ((parent_color[2] * (1.0 - alpha) + bg_color[2] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
+        ];
 
-            let label_x = self.label_x_offset();
-            let x = self.base.x + label_x;
-            let w = self.base.w - label_x;
-
-            // Draw border and background (with potential parent-concentric corner adjustment)
-            let inner_radius = (radius - 1.0).max(0.0);
-            let mut adjusted = false;
-            let mut outer_radii = [radius; 4];
-            let mut inner_radii = [inner_radius; 4];
-
-            let mut curr = self.parent(ctx);
-            let mut backplate_ptr = None;
-            while let Some(ptr) = curr {
-                if unsafe { (*ptr).is_backplate() } {
-                    backplate_ptr = Some(ptr);
-                    break;
-                }
-                curr = unsafe { (*ptr).parent(ctx) };
-            }
+        let w_dummy = crate::widget::display::measure_text_width("M", &font_family, font_size);
+        let chars: Vec<char> = selected_text.chars().collect();
+        let n = chars.len();
 
-            if let Some(bp) = backplate_ptr {
-                let (px, py, pw, ph) = unsafe { (*bp).rect() };
-                let pr = unsafe { (*bp).corner_radius() };
-                let (pr1, pr2, pr3, pr4) = unsafe { (*bp).rounded_corners() };
+        let is_monospace = {
+            let w_i10 = crate::widget::display::measure_text_width("iiiiiiiiii", &font_family, font_size);
+            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
+            (w_i10 - w_m10).abs() < 5.0
+        };
 
-                let g_left = x - px;
-                let g_top = (self.base.y + top) - py;
-                let g_right = (px + pw) - (x + w);
-                let g_bottom = (py + ph) - ((self.base.y + top) + visual_h);
+        let cell_width = if is_monospace {
+            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
+            let w_m20 = crate::widget::display::measure_text_width("mmmmmmmmmmmmmmmmmmmm", &font_family, font_size);
+            ((w_m20 - w_m10) / 10.0).max(1.0)
+        } else {
+            0.0
+        };
 
-                if pr1 && (g_left - g_top).abs() < 1.0 && g_left >= 0.0 {
-                    outer_radii[0] = (pr - g_left).max(0.0);
-                    inner_radii[0] = (outer_radii[0] - 1.0).max(0.0);
-                    adjusted = true;
-                }
-                if pr2 && (g_right - g_top).abs() < 1.0 && g_right >= 0.0 {
-                    outer_radii[1] = (pr - g_right).max(0.0);
-                    inner_radii[1] = (outer_radii[1] - 1.0).max(0.0);
-                    adjusted = true;
-                }
-                if pr3 && (g_right - g_bottom).abs() < 1.0 && g_right >= 0.0 {
-                    outer_radii[2] = (pr - g_right).max(0.0);
-                    inner_radii[2] = (outer_radii[2] - 1.0).max(0.0);
-                    adjusted = true;
-                }
-                if pr4 && (g_left - g_bottom).abs() < 1.0 && g_left >= 0.0 {
-                    outer_radii[3] = (pr - g_left).max(0.0);
-                    inner_radii[3] = (outer_radii[3] - 1.0).max(0.0);
-                    adjusted = true;
-                }
+        let mut char_offsets = Vec::with_capacity(n);
+        if is_monospace {
+            for i in 0..n {
+                char_offsets.push(i as f32 * cell_width);
             }
-
-            if adjusted {
-                let border_quads = crate::layout::partition_concentric_corners(
-                    x, self.base.y + top, w, visual_h,
-                    radius, outer_radii, border_color
-                );
-                quads.extend(border_quads);
-
-                let bg_quads = crate::layout::partition_concentric_corners(
-                    x + 1.0, self.base.y + top + 1.0, w - 2.0, visual_h - 2.0,
-                    inner_radius, inner_radii, bg_color
-                );
-                quads.extend(bg_quads);
-            } else {
-                quads.push((x, self.base.y + top, w, visual_h, radius, border_color, (r1, r2, r3, r4)));
-                quads.push((x + 1.0, self.base.y + top + 1.0, w - 2.0, visual_h - 2.0, inner_radius, bg_color, (r1, r2, r3, r4)));
+        } else {
+            if n > 0 {
+                char_offsets.push(0.0f32);
+            }
+            let mut prefix = String::new();
+            for i in 1..n {
+                prefix.push(chars[i - 1]);
+                let measure_str = format!("{}M", prefix);
+                let w_prefix_dummy = crate::widget::display::measure_text_width(&measure_str, &font_family, font_size);
+                let offset = (w_prefix_dummy - w_dummy).max(0.0);
+                char_offsets.push(offset);
             }
         }
-        for &child_ptr in &self.children(ctx) {
-            let widget = unsafe { &*child_ptr };
-            quads.extend(widget.all_rounded_quads(ctx));
-        }
-        quads
-    }
 
-    fn widget_font(&self) -> Option<String> {
-        Some(crate::layout::control_label_font_detached())
-    }
-
-    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
-        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
-            return false;
-        }
-        let (x, y, w, h) = self.rect();
-        if w <= 0.0 || h <= 0.0 {
-            return false;
-        }
-        let hx = if self.base.row_w > 0.0 { self.base.row_x } else { x };
-        let hw = if self.base.row_w > 0.0 { self.base.row_w } else { w };
-        if self.open {
-            let (rx, ry, rw, rh) = self.get_popover_geom();
-            let hit_trigger = px >= hx && px <= hx + hw && py >= y && py <= y + h;
-            let hit_popover = px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
-            hit_trigger || hit_popover
+        let total_advance = if n > 0 {
+            if is_monospace {
+                n as f32 * cell_width
+            } else {
+                let measure_str = format!("{}M", selected_text);
+                (crate::widget::display::measure_text_width(&measure_str, &font_family, font_size) - w_dummy).max(0.0)
+            }
         } else {
-            px >= hx && px <= hx + hw && py >= y && py <= y + h
-        }
-    }
-
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was_hovered = self.base.hovered;
-        let was_hovered_item = self.hovered_item;
-        
-        self.base.hovered = self.hit_test(px, py, ctx);
-        self.hovered_item = None;
+            0.0
+        };
 
-        if self.open {
-            let (rx, ry, rw, rh) = self.get_popover_geom();
-            if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
-                let idx = ((py - ry) / 24.0) as usize;
-                if idx < self.options.len() {
-                    if self.options[idx] != "-" {
-                        self.hovered_item = Some(idx);
-                    }
+        // Draw and fade every character individually
+        let mut prev_char_end = 0.0;
+        for i in 0..n {
+            let mut offset = char_offsets[i];
+            if !is_monospace {
+                if i > 0 {
+                    offset = offset.max(prev_char_end + 1.0);
                 }
             }
-        }
-
-        self.base.hovered != was_hovered || self.hovered_item != was_hovered_item
-    }
+            let next_offset = if i < n - 1 { char_offsets[i + 1] } else { total_advance };
+            let c_w = if is_monospace { cell_width } else { next_offset - offset };
+            let cur_x = start_x + offset;
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button == MouseButton::Right && state == ElementState::Pressed {
-            if self.hit_test(px, py, ctx) {
-                ctx.handle_right_click(self.as_ptr_mut(), px, py);
-                return true;
+            if cur_x >= right_limit {
+                break;
             }
-        }
-        if button != MouseButton::Left || state != ElementState::Pressed { return false; }
 
-        let (x, y, w, h) = self.rect();
-        let (rx, ry, rw, rh) = self.get_popover_geom();
-
-        let inside_trigger = px >= x && px <= x + w && py >= y && py <= y + h;
-        let inside_popover = self.open && px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
-
-        if inside_popover {
-            let idx = ((py - ry) / 24.0) as usize;
-            if idx < self.options.len() {
-                if self.options[idx] == "-" {
-                    return true;
-                }
-                if self.selected != idx || self.custom_display_text.is_some() {
-                    self.selected = idx;
-                    self.just_changed = true;
+            let char_mid_x = cur_x + c_w / 2.0;
+            let mut skip_char = false;
+            let text_end_x = start_x + total_advance;
+            let color = if text_end_x > right_limit && char_mid_x > fade_start_x {
+                let factor = ((char_mid_x - fade_start_x) / (right_limit - fade_start_x)).clamp(0.0, 1.0);
+                if factor >= 0.9 {
+                    skip_char = true;
+                    default_color
+                } else {
+                    [
+                        (default_color[0] as f32 + (bg_rgb[0] as f32 - default_color[0] as f32) * factor).round() as u8,
+                        (default_color[1] as f32 + (bg_rgb[1] as f32 - default_color[1] as f32) * factor).round() as u8,
+                        (default_color[2] as f32 + (bg_rgb[2] as f32 - default_color[2] as f32) * factor).round() as u8,
+                    ]
                 }
-            }
-            self.open = false;
-            return true;
-        }
-
-        if inside_trigger {
-            self.open = !self.open;
-            if self.open {
-                self.focus();
             } else {
-                self.unfocus();
-            }
-            return true;
-        }
+                default_color
+            };
 
-        if self.open {
-            self.open = false;
-            return true;
+            if !skip_char {
+                ctx.text(chars[i].to_string(), cur_x, text_y, font_size, color);
+                let c_w_ink = if is_monospace {
+                    cell_width
+                } else {
+                    crate::widget::display::measure_text_width(&chars[i].to_string(), &font_family, font_size)
+                };
+                prev_char_end = offset + c_w_ink;
+            }
         }
 
-        false
-    }
-
-    fn focus(&mut self) {
-        focus::set_focused(self);
-    }
-
-    fn unfocus(&mut self) {
-        self.open = false;
+        ctx.text(
+            "▼",
+            x + w - 18.0,
+            crate::layout::center_text_y(content.y, content.height, 10.0),
+            10.0,
+            [0x83, 0x83, 0x8a],
+        );
     }
 
-    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
-        if event.state != ElementState::Pressed { return false; }
+    /// Port of the legacy `keyboard_input` body.
+    fn handle_key(&mut self, event: &crate::widget::KeyEvent) -> bool {
+        if event.state != ElementState::Pressed {
+            return false;
+        }
         if !self.open {
             if let Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) = event.logical_key {
                 self.open = true;
@@ -534,7 +453,7 @@ impl Element for Dropdown {
             }
             return false;
         }
-        
+
         match event.logical_key {
             Key::Named(NamedKey::ArrowDown) => {
                 let current = self.hovered_item.unwrap_or(self.selected);
@@ -576,237 +495,343 @@ impl Element for Dropdown {
                 self.open = false;
                 true
             }
-            _ => false
+            _ => false,
+        }
+    }
+}
+
+impl Adapted<Dropdown> {
+    pub fn with_custom_display_text(mut self, text: &str) -> Self {
+        self.custom_display_text = Some(text.to_string());
+        self
+    }
+
+    pub fn with_font_family(mut self, font_family: &str) -> Self {
+        self.font_family = font_family.to_string();
+        self
+    }
+
+    pub fn with_open_upward(mut self, open_upward: bool) -> Self {
+        self.open_upward = Some(open_upward);
+        self
+    }
+
+    pub fn with_auto_width(mut self, auto_width: bool) -> Self {
+        self.auto_width = auto_width;
+        self
+    }
+
+    /// Popover geometry from the widget's laid-out rect — the legacy inherent
+    /// `get_popover_geom` shape, for callers that hold the wrapper.
+    pub fn get_popover_geom(&self) -> (f32, f32, f32, f32) {
+        let (x, y, w, h) = Element::rect(self);
+        let top = self.inner().label_top();
+        self.inner().popover_geom(Rect { x, y: y + top, width: w, height: h - top })
+    }
+}
+
+impl Layout for Dropdown {
+    fn layout_ignore(&self) -> bool {
+        true
+    }
+
+    fn z_order(&self) -> i32 {
+        if self.open {
+            100
+        } else {
+            0
+        }
+    }
+
+    fn inflates_label_rect(&self) -> bool {
+        false
+    }
+
+    fn detached_label_inset(&self) -> f32 {
+        4.0
+    }
+
+    /// Content size for the scene layout engine (Phase 2b): wide enough for the widest option
+    /// (via `content_width`, which already includes the arrow/padding inset), at the configured
+    /// dropdown height — so the control doesn't resize as the selection changes.
+    fn intrinsic_size(&self) -> Option<Size> {
+        Some(Size::new(self.content_width(), crate::layout::dropdown_height()))
+    }
+
+    fn intrinsic_measure_width(&self) -> bool {
+        self.auto_width
+    }
+
+    fn parent_changed(&mut self, parent: Option<*mut (dyn Element + 'static)>) {
+        self.tracked_parent = parent;
+    }
+}
+
+impl Paint for Dropdown {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+        let r = crate::layout::dropdown_corner_radius();
+        if r > 0.0 {
+            Some((r, (true, true, true, true)))
+        } else {
+            Some((r, (false, false, false, false)))
+        }
+    }
+
+    fn widget_font(&self) -> Option<String> {
+        Some(crate::layout::control_label_font_detached())
+    }
+
+    fn sync_label(&mut self, label: &str) {
+        self.label = Some(label.to_string());
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        self.paint_background(rect, ctx);
+        self.paint_text(rect, ctx);
+    }
+
+    fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
+        if self.open {
+            Some(self.popover_geom(rect))
+        } else {
+            None
         }
     }
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (r1, r2, r3, r4) = self.rounded_corners();
-        if r1 || r2 || r3 || r4 {
-            return Vec::new();
+    fn draw_popover(&self, rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
+        if !self.open {
+            return;
         }
 
-        let mut quads = Vec::new();
-        let top = self.base.label_offset();
-        let visual_h = self.base.h - top;
+        let (rx, ry, rw, rh) = self.popover_geom(rect);
 
-        let mut bg_color = colors::dropdown_background_color();
-        bg_color[3] = 1.0; // Force opaque background to prevent subpixel blending artifacts
-        let border_color = if self.open {
-            [0.30, 0.50, 0.32, 1.0]
-        } else if self.base.hovered {
-            let bc = colors::dropdown_border_color();
-            [(bc[0] + 0.15).min(1.0), (bc[1] + 0.15).min(1.0), (bc[2] + 0.15).min(1.0), bc[3]]
-        } else {
-            colors::dropdown_border_color()
-        };
+        // 1. Soft layered drop shadows
+        pc.rect([0.02, 0.02, 0.05, 0.15], rx + 1.0, ry + 1.0, rw, rh);
+        pc.rect([0.02, 0.02, 0.05, 0.08], rx + 3.0, ry + 3.0, rw, rh);
+        pc.rect([0.02, 0.02, 0.05, 0.04], rx + 5.0, ry + 5.0, rw, rh);
 
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
+        let theme = colors::active_theme();
 
-        quads.push((x, self.base.y + top, w, visual_h, border_color));
-        quads.push((x + 1.0, self.base.y + top + 1.0, w - 2.0, visual_h - 2.0, bg_color));
+        // 2. High-contrast premium outer border
+        pc.rect(theme.surface_border, rx, ry, rw, rh);
 
-        quads
-    }
+        // 3. Frosted glass background
+        pc.rect(theme.surface_bg, rx + 1.0, ry + 1.0, rw - 2.0, rh - 2.0); // bg
 
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let mut labels = Vec::new();
-        let top = self.base.label_offset();
-        let _visual_h = self.base.h - top;
-        
-        if let Some(lbl) = self.control_label() {
-            labels.push(lbl);
+        if let Some(h_idx) = self.hovered_item {
+            let iy = ry + h_idx as f32 * 24.0;
+            // 4. Vibrantly colored translucent selection highlight
+            pc.rect(theme.primary_accent, rx + 2.0, iy + 2.0, rw - 4.0, 20.0);
         }
 
-        let selected_text = if let Some(ref custom_text) = self.custom_display_text {
-            custom_text.clone()
-        } else {
-            self.options.get(self.selected).cloned().unwrap_or_default()
-        };
+        for (idx, opt) in self.options.iter().enumerate() {
+            let iy = crate::layout::align_text_y(ry + idx as f32 * 24.0, 24.0, 12.0, 0.0);
 
-        let (font_family, font_size) = crate::layout::control_label_font_detached_parsed();
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-        let start_x = x + 8.0;
-        let right_limit = x + w - 28.0; // 10px margin before the arrow
-        let fade_start_x = (right_limit - 24.0).max(start_x); // Fade out over the last 24px
-        let text_y = crate::layout::center_text_y(self.base.y + top, self.base.h - top, font_size);
-        let tc = colors::dropdown_text_color();
-        let default_color = [
-            (colors::linear_to_srgb(tc[0]) * 255.0).round() as u8,
-            (colors::linear_to_srgb(tc[1]) * 255.0).round() as u8,
-            (colors::linear_to_srgb(tc[2]) * 255.0).round() as u8,
-        ];
-        let bg_color = colors::dropdown_background_color();
-        let mut parent_color = colors::page_color();
-        if let Some(parent_ptr) = self.parent {
-            unsafe {
-                parent_color = (*parent_ptr).color();
+            if opt == "-" {
+                pc.rect(theme.surface_border, rx + 8.0, ry + idx as f32 * 24.0 + 11.5, rw - 16.0, 1.0);
+                continue;
             }
-        }
-        let alpha = 1.0; // The dropdown background is drawn fully opaque
-        let bg_rgb = [
-            ((parent_color[0] * (1.0 - alpha) + bg_color[0] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
-            ((parent_color[1] * (1.0 - alpha) + bg_color[1] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
-            ((parent_color[2] * (1.0 - alpha) + bg_color[2] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
-        ];
 
-        let w_dummy = crate::widget::display::measure_text_width("M", &font_family, font_size);
-        let chars: Vec<char> = selected_text.chars().collect();
-        let n = chars.len();
+            let text_color = if self.hovered_item == Some(idx) {
+                [0xff, 0xff, 0xff]
+            } else if self.selected == idx {
+                [0x3a, 0x9a, 0xff]
+            } else {
+                [0xcc, 0xcc, 0xd4]
+            };
 
-        let is_monospace = {
-            let w_i10 = crate::widget::display::measure_text_width("iiiiiiiiii", &font_family, font_size);
-            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
-            (w_i10 - w_m10).abs() < 5.0
-        };
+            let color_f32 = [
+                text_color[0] as f32 / 255.0,
+                text_color[1] as f32 / 255.0,
+                text_color[2] as f32 / 255.0,
+                1.0,
+            ];
 
-        let cell_width = if is_monospace {
-            let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", &font_family, font_size);
-            let w_m20 = crate::widget::display::measure_text_width("mmmmmmmmmmmmmmmmmmmm", &font_family, font_size);
-            ((w_m20 - w_m10) / 10.0).max(1.0)
-        } else {
-            0.0
-        };
+            let bounds = Some([rx, ry, rx + rw, ry + rh]);
+            let font = crate::layout::control_label_font_detached();
+            pc.text_with_font_and_bounds(opt, rx + 8.0, iy, 12.0, color_f32, &font, bounds);
+        }
+    }
+}
 
-        let mut char_offsets = Vec::with_capacity(n);
-        if is_monospace {
-            for i in 0..n {
-                char_offsets.push(i as f32 * cell_width);
-            }
+impl Input for Dropdown {
+    /// The legacy geometric test: the widget rect (edges inclusive), extended to the open
+    /// popover. `rect` is the full base rect (label strip included), as legacy `hit_test` used.
+    fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+        if rect.width <= 0.0 || rect.height <= 0.0 {
+            return false;
+        }
+        let hit_trigger =
+            x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
+        if self.open {
+            let top = self.label_top();
+            let content = Rect { x: rect.x, y: rect.y + top, width: rect.width, height: rect.height - top };
+            let (rx, ry, rw, rh) = self.popover_geom(content);
+            let hit_popover = x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
+            hit_trigger || hit_popover
         } else {
-            if n > 0 {
-                char_offsets.push(0.0f32);
-            }
-            let mut prefix = String::new();
-            for i in 1..n {
-                prefix.push(chars[i - 1]);
-                let measure_str = format!("{}M", prefix);
-                let w_prefix_dummy = crate::widget::display::measure_text_width(&measure_str, &font_family, font_size);
-                let offset = (w_prefix_dummy - w_dummy).max(0.0);
-                char_offsets.push(offset);
-            }
+            hit_trigger
         }
+    }
 
-        let total_advance = if n > 0 {
-            if is_monospace {
-                n as f32 * cell_width
-            } else {
-                let measure_str = format!("{}M", selected_text);
-                (crate::widget::display::measure_text_width(&measure_str, &font_family, font_size) - w_dummy).max(0.0)
-            }
-        } else {
-            0.0
-        };
+    fn opens_context_menu(&self) -> bool {
+        true
+    }
 
-        // Draw and fade every character individually
-        let mut prev_char_end = 0.0;
-        for i in 0..n {
-            let mut offset = char_offsets[i];
-            if !is_monospace {
-                if i > 0 {
-                    offset = offset.max(prev_char_end + 1.0);
+    /// Ungated presses (legacy `mouse_input` saw every press): an open dropdown must close on
+    /// an outside click it would otherwise never learn about.
+    fn gates_presses(&self) -> bool {
+        false
+    }
+
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton {
+                button: MouseButton::Left,
+                state: ElementState::Pressed,
+                x: px,
+                y: py,
+                ..
+            } => {
+                let content = ectx.rect;
+                let top = self.label_top();
+                let (bx, by, bw, bh) = (content.x, content.y - top, content.width, content.height + top);
+                let (rx, ry, rw, rh) = self.popover_geom(content);
+
+                let inside_trigger = *px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh;
+                let inside_popover =
+                    self.open && *px >= rx && *px <= rx + rw && *py >= ry && *py <= ry + rh;
+
+                if inside_popover {
+                    let idx = ((py - ry) / 24.0) as usize;
+                    if idx < self.options.len() {
+                        if self.options[idx] == "-" {
+                            return true;
+                        }
+                        if self.selected != idx || self.custom_display_text.is_some() {
+                            self.selected = idx;
+                            self.just_changed = true;
+                        }
+                    }
+                    self.open = false;
+                    return true;
                 }
-            }
-            let next_offset = if i < n - 1 { char_offsets[i + 1] } else { total_advance };
-            let c_w = if is_monospace { cell_width } else { next_offset - offset };
-            let cur_x = start_x + offset;
 
-            if cur_x >= right_limit {
-                break;
-            }
+                if inside_trigger {
+                    self.open = !self.open;
+                    if self.open {
+                        // Legacy `focus()` claimed only the global slot.
+                        ectx.request_focus();
+                    }
+                    return true;
+                }
 
-            let char_mid_x = cur_x + c_w / 2.0;
-            let mut skip_char = false;
-            let text_end_x = start_x + total_advance;
-            let color = if text_end_x > right_limit && char_mid_x > fade_start_x {
-                let factor = ((char_mid_x - fade_start_x) / (right_limit - fade_start_x)).clamp(0.0, 1.0);
-                if factor >= 0.9 {
-                    skip_char = true;
-                    default_color
-                } else {
-                    [
-                        (default_color[0] as f32 + (bg_rgb[0] as f32 - default_color[0] as f32) * factor).round() as u8,
-                        (default_color[1] as f32 + (bg_rgb[1] as f32 - default_color[1] as f32) * factor).round() as u8,
-                        (default_color[2] as f32 + (bg_rgb[2] as f32 - default_color[2] as f32) * factor).round() as u8,
-                    ]
+                if self.open {
+                    self.open = false;
+                    return true;
                 }
-            } else {
-                default_color
-            };
 
-            if !skip_char {
-                labels.push(TextLabel {
-                    text: chars[i].to_string(),
-                    x: cur_x,
-                    y: text_y,
-                    font_size,
-                    color,
-                });
-                let c_w_ink = if is_monospace {
-                    cell_width
-                } else {
-                    crate::widget::display::measure_text_width(&chars[i].to_string(), &font_family, font_size)
-                };
-                prev_char_end = offset + c_w_ink;
+                false
+            }
+            Event::PointerMove { x: px, y: py, .. } => {
+                // The popover-item half of the legacy `on_cursor_moved`; the trigger-hover half
+                // is the adapter's bookkeeping (MouseEnter/MouseLeave below).
+                let was_hovered_item = self.hovered_item;
+                self.hovered_item = None;
+                if self.open {
+                    let (rx, ry, rw, rh) = self.popover_geom(ectx.rect);
+                    if *px >= rx && *px <= rx + rw && *py >= ry && *py <= ry + rh {
+                        let idx = ((py - ry) / 24.0) as usize;
+                        if idx < self.options.len() && self.options[idx] != "-" {
+                            self.hovered_item = Some(idx);
+                        }
+                    }
+                }
+                self.hovered_item != was_hovered_item
+            }
+            Event::MouseEnter => {
+                self.hovered = true;
+                true
+            }
+            Event::MouseLeave => {
+                self.hovered = false;
+                true
             }
+            Event::KeyInput(key_event) => self.handle_key(key_event),
+            Event::FocusIn => {
+                // Legacy `focus()` claimed the global focus slot on every direct call
+                // (test-interface focuses the ramp's preset dropdown this way).
+                ectx.request_focus();
+                false
+            }
+            Event::FocusOut => {
+                // Legacy `unfocus` closed the dropdown.
+                self.open = false;
+                false
+            }
+            _ => false,
         }
-
-        let label_x = self.label_x_offset();
-        let x = self.base.x + label_x;
-        let w = self.base.w - label_x;
-
-        labels.push(TextLabel {
-            text: "▼".to_string(),
-            x: x + w - 18.0,
-            y: crate::layout::center_text_y(self.base.y + top, self.base.h - top, 10.0),
-            font_size: 10.0,
-            color: [0x83, 0x83, 0x8a],
-        });
-
-        labels
     }
 
-    fn value(&self) -> i32 { self.selected as i32 }
-    fn take_click(&mut self) -> bool { self.take_change() }
-    fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
-        if self.open {
-            Some(self.get_popover_geom())
-        } else {
-            None
-        }
+    fn take_click(&mut self) -> bool {
+        self.take_change()
     }
-    fn render_popover(&self, pc: &mut dyn crate::layout::RenderTarget) {
-        Dropdown::render_popover(self, pc);
+
+    fn take_change(&mut self) -> bool {
+        self.take_change()
     }
 
-    fn z_index(&self) -> i32 {
-        if self.open { 100 } else { 0 }
+    fn value(&self) -> i32 {
+        self.selected as i32
     }
 
-    fn layout_ignore(&self) -> bool {
-        true
+    fn value_string(&self) -> Option<String> {
+        self.options.get(self.selected).cloned()
     }
-}
 
-impl Drop for Dropdown {
-    fn drop(&mut self) {
-        clear_widget_references(self);
+    fn set_value_string(&mut self, val: &str) -> bool {
+        let val_trimmed = val.trim();
+        for (idx, opt) in self.options.iter().enumerate() {
+            if opt.eq_ignore_ascii_case(val_trimmed) {
+                if self.selected != idx {
+                    self.selected = idx;
+                    self.just_changed = true;
+                    return true;
+                }
+                return false;
+            }
+        }
+        if let Ok(idx) = val_trimmed.parse::<usize>() {
+            if idx < self.options.len() {
+                if self.selected != idx {
+                    self.selected = idx;
+                    self.just_changed = true;
+                    return true;
+                }
+                return false;
+            }
+        }
+        false
     }
 }
 
-
 unsafe impl Send for Dropdown {}
 unsafe impl Sync for Dropdown {}
 
-impl Control for Dropdown {}
+impl Control for Adapted<Dropdown> {
+    fn set_label(&mut self, label: &str) {
+        Adapted::set_label(self, label);
+    }
+}
 
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::widget::LayoutConstraints;
 
     #[test]
     fn test_dropdown_widget_interaction() {
@@ -909,7 +934,7 @@ mod tests {
         let mut ramp = crate::widget::Ramp::new();
         // Set the rect of parent Ramp
         ramp.set_rect(20.0, 20.0, 410.0, 260.0);
-        
+
         let options = vec![
             "Option 1".to_string(),
             "Option 2".to_string(),
@@ -920,13 +945,13 @@ mod tests {
         ];
         let mut dd = Dropdown::new(options, 0).with_label("Preset");
         dd.set_rect(30.0, 125.0, 110.0, 20.0);
-        
-        // Link the dropdown parent pointer to the Ramp
+
+        // Link the dropdown parent pointer to the Ramp (the legacy direct-write path)
         dd.parent = Some(&mut ramp as *mut crate::widget::Ramp as *mut (dyn crate::widget::Element + 'static));
-        
+
         // Compute geometry
         let (rx, ry, rw, rh) = dd.get_popover_geom();
-        
+
         // Validate coordinates stay inside the parent Ramp bounds: x in [20, 430], y in [20, 280]
         assert!(rx >= 20.0, "rx {} should be >= 20.0", rx);
         assert!(rx + rw <= 430.0, "rx + rw {} should be <= 430.0", rx + rw);
@@ -941,16 +966,14 @@ mod tests {
         dd.set_rect(10.0, 10.0, 100.0, 24.0); // very narrow dropdown
 
         let labels = dd.text_labels();
-        // Option 0: control_label (none)
-        // Option 1: ▼ (arrow) at the end of labels
-        // Remaining labels are individual characters of selected_text
+        // Labels are individual characters of selected_text, then the ▼ arrow (prim order).
         assert!(labels.len() > 2);
-        
+
         // The last character label (excluding the arrow) should be faded (i.e. not the default color)
         let last_char_idx = labels.len() - 2;
         let first_char = &labels[0];
         let last_char = &labels[last_char_idx];
-        
+
         let tc = colors::dropdown_text_color();
         let expected_color = [
             (colors::linear_to_srgb(tc[0]) * 255.0).round() as u8,
@@ -970,7 +993,7 @@ mod tests {
 
         let size = dd.measure(LayoutConstraints::new(0.0, 500.0, 24.0, 24.0), &dummy);
         assert!(size.width > 50.0, "Measured auto-width {} should be greater than original width 50.0", size.width);
-        
+
         let dd_no_auto = Dropdown::new(vec!["Short".to_string(), "A much longer option name".to_string()], 0);
         let size_no_auto = dd_no_auto.measure(LayoutConstraints::new(0.0, 500.0, 24.0, 24.0), &dummy);
         assert_eq!(size_no_auto.width, 0.0);
@@ -982,15 +1005,38 @@ mod tests {
             vec!["Short".to_string(), "A much longer option name".to_string()],
             0,
         );
-        let size = wide.intrinsic_size().expect("dropdown reports intrinsic size");
+        let size = Layout::intrinsic_size(wide.inner()).expect("dropdown reports intrinsic size");
         assert!(size.width >= wide.content_width(), "width fits the widest option");
         assert_eq!(size.height, crate::layout::dropdown_height());
 
         let narrow = Dropdown::new(vec!["Hi".to_string()], 0);
         assert!(
-            size.width > narrow.intrinsic_size().unwrap().width,
+            size.width > Layout::intrinsic_size(narrow.inner()).unwrap().width,
             "more/longer options measure wider",
         );
     }
-}
 
+    /// Migration additions: popover routing through the adapter (`Element::popover_rect` /
+    /// `render_popover`), outside-press close, and Escape via routed key events.
+    #[test]
+    fn popover_reaches_hosts_through_the_adapter() {
+        let mut dummy = crate::context::UiContext::new();
+        let options = vec!["A".to_string(), "B".to_string()];
+        let mut dd = Dropdown::new(options, 0);
+        dd.set_rect(10.0, 10.0, 100.0, 24.0);
+
+        assert!(Element::popover_rect(&dd).is_none(), "closed dropdown registers no popover");
+
+        dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 20.0, &mut dummy);
+        assert!(dd.open);
+        let (rx, ry, rw, rh) = Element::popover_rect(&dd).expect("open dropdown registers its popover");
+        assert_eq!((rx, ry), (10.0, 34.0), "popover opens under the trigger");
+        assert!(rw >= 100.0 && rh == 48.0);
+
+        // An outside press closes it (ungated presses — `gates_presses` is false).
+        let closed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 500.0, 500.0, &mut dummy);
+        assert!(closed);
+        assert!(!dd.open);
+        assert!(!dd.take_change(), "outside close does not report a change");
+    }
+}
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index 1e93544..deb86cb 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -131,7 +131,7 @@ impl InstancedWidget {
 #[derive(Clone, Debug)]
 pub struct MultiControlRow {
     pub key_input: TextBox,
-    pub type_dropdown: Dropdown,
+    pub type_dropdown: Adapted<Dropdown>,
     pub value_widget: InstancedWidget,
     pub remove_button: Adapted<Button>,
     pub layout_y: f32,
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 667c7e8..826a0cc 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -434,8 +434,8 @@ pub struct Ramp {
     // Child controls for value editing & deletion
     pub val_slider: Adapted<Slider>,
     pub del_button: Adapted<Button>,
-    pub preset_dropdown: Dropdown,
-    pub line_type_dropdown: Dropdown,
+    pub preset_dropdown: Adapted<Dropdown>,
+    pub line_type_dropdown: Adapted<Dropdown>,
     
     pub parent: Option<*mut (dyn Element + 'static)>,
 }
@@ -629,8 +629,8 @@ impl Element for Ramp {
         let self_ptr = self as *const Self as *mut Self;
         unsafe {
             let mut list = vec![
-                &mut (*self_ptr).preset_dropdown as *mut Dropdown as *mut (dyn Element + 'static),
-                &mut (*self_ptr).line_type_dropdown as *mut Dropdown as *mut (dyn Element + 'static),
+                (*self_ptr).preset_dropdown.as_ptr_mut(),
+                (*self_ptr).line_type_dropdown.as_ptr_mut(),
             ];
             if self.selected_key_idx.is_some() {
                 list.push((*self_ptr).val_slider.as_ptr_mut());
@@ -660,8 +660,8 @@ impl Element for Ramp {
             let self_ptr = self as *mut Self;
             let mut children = unsafe {
                 let mut list = vec![
-                    &mut (*self_ptr).preset_dropdown as *mut Dropdown as *mut (dyn Element + 'static),
-                    &mut (*self_ptr).line_type_dropdown as *mut Dropdown as *mut (dyn Element + 'static),
+                    (*self_ptr).preset_dropdown.as_ptr_mut(),
+                    (*self_ptr).line_type_dropdown.as_ptr_mut(),
                 ];
                 if (*self_ptr).selected_key_idx.is_some() {
                     list.push((*self_ptr).val_slider.as_ptr_mut());
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 3d199a0..3fd809a 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -85,6 +85,14 @@ pub trait Layout {
         0.0
     }
 
+    /// Whether `Element::measure` should prefer [`intrinsic_size`](Layout::intrinsic_size)'s
+    /// width over the current rect width (Dropdown's `auto_width` measure override — hosts size
+    /// it from `measure`, e.g. cce-system-settings' page dropdown). Default: keep the legacy
+    /// `Element::measure` width (the current rect's).
+    fn intrinsic_measure_width(&self) -> bool {
+        false
+    }
+
     // --- Container concern (transitional). Legacy containers own `Vec<*mut dyn Element>`
     // children (child-arranging `set_rect` has no ctx to reach the tree) and every one
     // hand-copies the same subtree plumbing: geometry/text aggregation, tick/popover/text-item
@@ -933,6 +941,22 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         Layout::intrinsic_size(&self.inner).map(|s| s.height)
     }
 
+    /// The `Element::measure` default, except the width consults the intrinsic size when the
+    /// widget opts in ([`Layout::intrinsic_measure_width`] — Dropdown's `auto_width`).
+    fn measure(&self, constraints: crate::widget::LayoutConstraints, _ctx: &UiContext) -> crate::widget::Size {
+        let (_, _, w, h) = self.rect();
+        let pref_w = if Layout::intrinsic_measure_width(&self.inner) {
+            Layout::intrinsic_size(&self.inner).map_or(w, |s| s.width)
+        } else {
+            w
+        };
+        let pref_h = self.preferred_height().unwrap_or(h);
+        crate::widget::Size {
+            width: pref_w.clamp(constraints.min_width, constraints.max_width),
+            height: pref_h.clamp(constraints.min_height, constraints.max_height),
+        }
+    }
+
     /// Narrow widgets own every pixel they draw through [`Paint::paint`]; the legacy shared
     /// hover-highlight overlay is suppressed (matching what most control widgets' `None`
     /// overrides do today).