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

src/widget/input/dropdown.rs (75.2K)

   1 //! Narrow-trait `Dropdown` (Phase 5p — first popover widget through `Paint::popover` /
   2 //! `draw_popover`, the 5o surface). Detached-label control: the adapter draws the label in
   3 //! the strip above the trigger, at `Layout::detached_label_inset`; the trigger's plate is the
   4 //! control alone.
   5 //!
   6 //! Parity notes (all legacy-faithful, verified against the pre-migration impl):
   7 //! - `parent_snapshot` is the data form of the legacy public, direct-write-only `parent`
   8 //!   pointer: legacy `set_parent` never wrote it (the WidgetHost default only touched the tree —
   9 //!   Ramp's dummy-ctx `set_parent` calls were silently discarded), so the Ramp popover clamp
  10 //!   and the fade-blend parent color activate only for callers that assign the field, exactly
  11 //!   as before — no production writer exists. The root plate-concentric corner walk it once
  12 //!   anchored is gone outright (replaced by the app-owned `corner_frame`, Phase 6s).
  13 //! - The row-rect hit expansion (`base.row_x/row_w`) is dropped, consistent with every other
  14 //!   migrated control: `Input::hit` tests the widget rect plus the open popover.
  15 //! - `Layout::intrinsic_measure_width` (new hook) preserves the `auto_width` measure behavior
  16 //!   (cce-system-interface sizes its page dropdown from `WidgetHost::measure`).
  17 
  18 use crate::colors;
  19 use crate::scene::layout::{Rect, Size};
  20 use crate::scene::paint::PaintCtx;
  21 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
  22 use crate::widget::{
  23     WidgetHost, ElementState, Event, Key, MouseButton, NamedKey,
  24 };
  25 
  26 /// Read-data stand-in for the legacy direct-write `parent` pointer (6bd — no stored widget
  27 /// pointers): the popover clamp and bg fade-blend read the host's rect/kind/color ctx-less at
  28 /// paint time. Callers that want the Ramp clamp assign it directly, same activation model as
  29 /// the old field.
  30 #[derive(Debug, Clone, Copy, PartialEq)]
  31 pub struct ParentSnapshot {
  32     pub rect: (f32, f32, f32, f32),
  33     pub is_ramp: bool,
  34     pub color: [f32; 4],
  35 }
  36 
  37 /// Monospace detection — the paint pass lays characters out on a fixed cell in a monospace font
  38 /// and on measured per-character advances otherwise, so the sizing pass must branch the same way.
  39 fn is_monospace_font(font_family: &str, font_size: f32) -> bool {
  40     let w_i10 = crate::widget::display::measure_text_width("iiiiiiiiii", font_family, font_size);
  41     let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", font_family, font_size);
  42     (w_i10 - w_m10).abs() < 5.0
  43 }
  44 
  45 /// The fixed per-character cell of a monospace font, taken as the slope between a 10- and a
  46 /// 20-`m` run so any constant side bearing in the measurement cancels out.
  47 fn monospace_cell_width(font_family: &str, font_size: f32) -> f32 {
  48     let w_m10 = crate::widget::display::measure_text_width("mmmmmmmmmm", font_family, font_size);
  49     let w_m20 = crate::widget::display::measure_text_width("mmmmmmmmmmmmmmmmmmmm", font_family, font_size);
  50     ((w_m20 - w_m10) / 10.0).max(1.0)
  51 }
  52 
  53 /// The advance width `paint_text` will actually lay `text` out to.
  54 ///
  55 /// This is the single source of truth shared by the sizing pass (`content_width` /
  56 /// `display_width`) and the paint pass. It is deliberately NOT a plain `measure_text_width`: the
  57 /// paint pass advances on the monospace cell or on the M-dummy trick, and either can exceed the
  58 /// raw ink measure. Sizing a dropdown from the ink measure therefore left the trigger a few px too
  59 /// narrow and tripped its own right-edge fade — badly under a monospace UI font like the default
  60 /// Berkeley Mono. Keep this in step with `paint_text`.
  61 fn text_advance(text: &str, font_family: &str, font_size: f32) -> f32 {
  62     let n = text.chars().count();
  63     if n == 0 {
  64         return 0.0;
  65     }
  66     if is_monospace_font(font_family, font_size) {
  67         n as f32 * monospace_cell_width(font_family, font_size)
  68     } else {
  69         let w_dummy = crate::widget::display::measure_text_width("M", font_family, font_size);
  70         let measure_str = format!("{}M", text);
  71         (crate::widget::display::measure_text_width(&measure_str, font_family, font_size) - w_dummy).max(0.0)
  72     }
  73 }
  74 
  75 #[derive(Debug, Clone)]
  76 pub struct Dropdown {
  77     pub options: Vec<String>,
  78     pub selected: usize,
  79     pub open: bool,
  80     pub(crate) hovered_item: Option<usize>,
  81     just_changed: bool,
  82     /// Host read-data, written ONLY by direct assignment (the Ramp-clamp unit test; no
  83     /// production writer). Read by the Ramp popover clamp and the fade-blend parent color,
  84     /// like the legacy `parent` pointer it replaces.
  85     pub parent_snapshot: Option<ParentSnapshot>,
  86     /// Optional popover anchor override: the menu hangs off THIS rect instead
  87     /// of the trigger's — for triggers embedded in a larger control (the
  88     /// parameter pane's textpick picker button nested in its TextBox), where
  89     /// the menu should span the whole field, not the button sliver.
  90     pub popover_anchor: Option<Rect>,
  91     pub font_family: String,
  92     pub custom_display_text: Option<String>,
  93     pub open_upward: Option<bool>,
  94     pub auto_width: bool,
  95     /// Synced copy of the control label ([`Paint::sync_label`]) — drives the side/detached
  96     /// offsets the base label geometry imposes on the widget's own geometry.
  97     label: Option<String>,
  98     /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (the adapter's hover
  99     /// bookkeeping hit-tests through [`Input::hit`], which includes the open popover — matching
 100     /// the legacy `on_cursor_moved` + popover-aware `hit_test` pair).
 101     hovered: bool,
 102     /// App-owned concentric frame (Phase 6s): `(rect, radius, corners)` of the rounded plate
 103     /// the dropdown sits in. When set, the corner adjustment uses it INSTEAD of walking for a
 104     /// root plate container ancestor — the hook that keeps the adjustment after an app dissolves its
 105     /// root plate container (the walk finds nothing once the widget is parentless).
 106     corner_frame: Option<((f32, f32, f32, f32), f32, (bool, bool, bool, bool))>,
 107     /// Raised style: the closed control's background is an SDF-lit `Bevel`
 108     /// plate (fill + rolled lit edge) instead of a flat fill + border stroke.
 109     raised: Option<bool>,
 110     /// Flat stance ([`crate::widget::PlateStance::Flat`]): the trigger's face
 111     /// alone, no groove, silhouette equal to its rect. Overrides `raised`.
 112     /// The concentric corner adjustment below does not apply — a flat fill
 113     /// carries one radius, and the adjustment exists to nest relief outlines.
 114     flat: bool,
 115     /// Per-widget override for the trigger plate's FACE, bypassing
 116     /// [`crate::scene::Material::control_face`] on the configured fill. The default
 117     /// forces the face opaque; an app that wants its controls made of the
 118     /// same frosted material as its panes passes the blur-behind sentinel (a
 119     /// negative alpha) here, which that helper would strip.
 120     face: Option<crate::scene::Material>,
 121     /// Keyboard focus (FocusIn / FocusOut): lights the trigger plate's rim.
 122     focused: bool,
 123     /// The open menu REPLACES the trigger instead of growing out of it: no
 124     /// trigger band (display text + ▼) in the open surface, the rows alone,
 125     /// with the menu's edge anchored where the trigger's was (its bottom for
 126     /// an upward menu, its top for a downward one) so the rows occupy the
 127     /// trigger's slot. The trigger itself stops painting once the revealed
 128     /// menu covers it. The settings app's page switcher: the current page
 129     /// already reads blue in the list, so the band repeated it.
 130     menu_replaces_trigger: bool,
 131     /// Expand/contract animation (the status-interface module-menu feel).
 132     /// WALL-CLOCK, not dt-stepped: progress runs from `anim_from` at
 133     /// `anim_start` toward 1 (or 0 while `closing`) over [`Self::ANIM_S`], read
 134     /// at draw time — so an app that never ticks its UiContext can never strand
 135     /// the popover mid-size; ticks only drive redraws and settle a landed
 136     /// close. `closing` keeps `open` (and the shrinking popover) alive while
 137     /// everything interactive gates on `!closing`.
 138     anim_from: f32,
 139     anim_start: Option<std::time::Instant>,
 140     closing: bool,
 141     /// Per-frame SNAPSHOT of the wall-clock progress, refreshed in `tick`
 142     /// (before each render) and on every routed event. All geometry readers —
 143     /// popover_rect at registration, draw_popover, the engine's occlusion
 144     /// clamp — use this one value, so the animated rect is stable within a
 145     /// frame: the clamp's exact-match overlay-text exemption compares text
 146     /// bounds against popover_rect evaluated later in the same pass, and a
 147     /// live clock read there would never match.
 148     anim_snap: f32,
 149 }
 150 
 151 impl Dropdown {
 152     /// The style in force: the per-widget override (`with_raised`) when set, else
 153     /// the DE's `control_relief`, read live so a runtime switch
 154     /// (`layout::set_control_relief`) restyles every control at once.
 155     fn raised(&self) -> bool {
 156         self.raised.unwrap_or_else(crate::layout::control_relief)
 157     }
 158 
 159     pub fn new(options: Vec<String>, selected: usize) -> Adapted<Dropdown> {
 160         Adapted::new(Dropdown {
 161             options,
 162             selected,
 163             open: false,
 164             hovered_item: None,
 165             just_changed: false,
 166             parent_snapshot: None,
 167             popover_anchor: None,
 168             font_family: "sans-serif".to_string(),
 169             custom_display_text: None,
 170             open_upward: None,
 171             auto_width: false,
 172             label: None,
 173             hovered: false,
 174             corner_frame: None,
 175             raised: None,
 176             flat: false,
 177             face: None,
 178             focused: false,
 179             menu_replaces_trigger: false,
 180             anim_from: 0.0,
 181             anim_start: None,
 182             closing: false,
 183             anim_snap: 0.0,
 184         })
 185     }
 186 
 187     /// Set (or clear) the app-owned concentric frame — see the `corner_frame` field docs.
 188     pub fn set_corner_frame(&mut self, frame: Option<((f32, f32, f32, f32), f32, (bool, bool, bool, bool))>) {
 189         self.corner_frame = frame;
 190     }
 191 
 192     pub fn take_change(&mut self) -> bool {
 193         let changed = self.just_changed;
 194         self.just_changed = false;
 195         changed
 196     }
 197 
 198     /// Horizontal inset added to a measured label to get a dropdown width the label fits inside
 199     /// without tripping `paint_text`'s right-edge fade: an 8px left pad plus the 28px right
 200     /// reservation (`right_limit = w - 28`, room for the 10px gap and the ▼ arrow) = 36px, plus a
 201     /// 2px cushion for the small gap between the ink-`measure_text_width` used here and the M-dummy
 202     /// advance the paint pass measures with. Shared by `content_width` (widest option) and
 203     /// `display_width` (collapsed display text) so the two can't drift.
 204     const LABEL_INSET: f32 = 38.0;
 205 
 206     pub fn content_width(&self) -> f32 {
 207         let font_setting = crate::layout::control_label_font_detached();
 208         let (font_family, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 209         let font_size = font_size_opt.unwrap_or(12.0);
 210         let mut max_w = 0.0f32;
 211         for opt in &self.options {
 212             let opt_w = text_advance(opt, &font_family, font_size) + Self::LABEL_INSET;
 213             if opt_w > max_w {
 214                 max_w = opt_w;
 215             }
 216         }
 217         max_w
 218     }
 219 
 220     /// The text shown on the collapsed trigger — the fixed `custom_display_text` (menu-button
 221     /// mode) if set, otherwise the selected option. Mirrors the selection logic in `paint_text`.
 222     fn display_text(&self) -> String {
 223         if let Some(ref custom_text) = self.custom_display_text {
 224             custom_text.clone()
 225         } else {
 226             self.options.get(self.selected).cloned().unwrap_or_default()
 227         }
 228     }
 229 
 230     /// Trigger width sized to the collapsed display text rather than the widest option (via
 231     /// `content_width`). Used by menu-button dropdowns whose label is fixed, so "File"/"Edit" don't
 232     /// stretch to their longest menu entry. Shares `LABEL_INSET` so the label fits without fading.
 233     fn display_width(&self) -> f32 {
 234         let font_setting = crate::layout::control_label_font_detached();
 235         let (font_family, font_size_opt) = crate::layout::parse_font_string(&font_setting);
 236         let font_size = font_size_opt.unwrap_or(12.0);
 237         text_advance(&self.display_text(), &font_family, font_size) + Self::LABEL_INSET
 238     }
 239 
 240     /// The detached-label strip height above the content rect (zero unlabeled) —
 241     /// the adapter's `Widget::label_offset` over the synced label.
 242     fn label_top(&self) -> f32 {
 243         crate::widget::input::slider::detached_strip(&self.label)
 244     }
 245 
 246     /// Expansion/contraction duration — the status-interface module-menu pace.
 247     const ANIM_S: f32 = 0.14;
 248 
 249     /// LIVE animation progress in [0, 1], wall-clock from the last transition.
 250     /// 1 = fully open, 0 = fully contracted. Geometry never reads this
 251     /// directly — it reads the per-frame `anim_snap` (see the field docs).
 252     fn anim_progress_now(&self) -> f32 {
 253         let Some(start) = self.anim_start else {
 254             return if self.open && !self.closing { 1.0 } else { 0.0 };
 255         };
 256         // Animations off: a transition in flight has already landed.
 257         if !crate::motion::enabled() {
 258             return if self.closing { 0.0 } else { 1.0 };
 259         }
 260         let el = start.elapsed().as_secs_f32() / Self::ANIM_S;
 261         if self.closing {
 262             (self.anim_from - el).clamp(0.0, 1.0)
 263         } else {
 264             (self.anim_from + el).clamp(0.0, 1.0)
 265         }
 266     }
 267 
 268     fn begin_open(&mut self) {
 269         self.anim_from = self.anim_progress_now();
 270         self.anim_start = Some(std::time::Instant::now());
 271         self.open = true;
 272         self.closing = false;
 273         self.anim_snap = self.anim_progress_now();
 274     }
 275 
 276     fn begin_close(&mut self) {
 277         if !self.open || self.closing {
 278             return;
 279         }
 280         self.anim_from = self.anim_progress_now();
 281         self.anim_start = Some(std::time::Instant::now());
 282         self.closing = true;
 283         self.anim_snap = self.anim_progress_now();
 284     }
 285 
 286     /// Fold finished animations back into settled state and refresh the
 287     /// per-frame progress snapshot (draw paths are `&self`, so this runs from
 288     /// the mutation entry points: `tick` and `on_event`). Also heals an
 289     /// externally forced `open = false` (a direct field write skips the
 290     /// animation; reset so the next open still animates).
 291     fn settle_anim(&mut self) {
 292         if self.closing {
 293             if self.anim_progress_now() <= 0.0 {
 294                 self.closing = false;
 295                 self.open = false;
 296                 self.anim_start = None;
 297                 self.hovered_item = None;
 298             }
 299         } else if self.open {
 300             if self.anim_progress_now() >= 1.0 {
 301                 self.anim_start = None;
 302             }
 303         } else {
 304             self.anim_start = None;
 305         }
 306         self.anim_snap = self.anim_progress_now();
 307     }
 308 
 309     /// Land an in-flight open or close instantly (tests can't wait out the
 310     /// wall clock).
 311     #[cfg(test)]
 312     fn land_anim_for_test(&mut self) {
 313         self.anim_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
 314         self.settle_anim();
 315     }
 316 
 317     /// One option row's height — a fixed pitch, not the trigger's height.
 318     /// `popover_geom` sizes the box by it, `draw_popover` lays the labels out
 319     /// on it and `row_at` reads it back.
 320     const ROW_H: f32 = 24.0;
 321 
 322     fn popover_width(&self, content: Rect) -> f32 {
 323         content.width.max(self.content_width())
 324     }
 325 
 326     /// The relief wall the open plate eats out of its own OUTER edges. The
 327     /// menu's surface is carved exactly like the closed trigger's
 328     /// (`carve_inside` at `depth`, then a trough straddling the carved edge by
 329     /// ±depth/2), so the outermost `depth` of the box is valley rather than
 330     /// face. A row laid flush against that edge therefore has its bottom
 331     /// padding — and any descender sitting in it — painted over by the wall,
 332     /// which is what cut the last option of every menu in half. The flat path
 333     /// draws a 1px outline instead and costs a row only its outermost pixel.
 334     fn plate_inset(&self, trigger_h: f32) -> f32 {
 335         if self.raised() {
 336             crate::layout::bevel_width().min(trigger_h * 0.2)
 337         } else {
 338             1.0
 339         }
 340     }
 341 
 342     /// What [`Self::popover_geom`] reserves above and below the row strip:
 343     /// [`Self::plate_inset`] on whichever menu edges are OUTER edges of the
 344     /// open surface. A downward menu meets the trigger band along its top and
 345     /// owns only its bottom edge; an upward one is the other way round; a menu
 346     /// that replaces the trigger owns both.
 347     fn popover_pads(&self, content: Rect) -> (f32, f32) {
 348         let inset = self.plate_inset(content.height);
 349         let anchor = self.popover_anchor.unwrap_or(content);
 350         let base_y = anchor.y - self.label_top();
 351         let open_upward = self.open_upward.unwrap_or(base_y > 400.0);
 352         if self.menu_replaces_trigger {
 353             (inset, inset)
 354         } else if open_upward {
 355             (inset, 0.0)
 356         } else {
 357             (0.0, inset)
 358         }
 359     }
 360 
 361     /// Where the first option row sits: the menu box's top edge, past the
 362     /// relief wall when that edge is an outer one. Rows run from here at
 363     /// [`Self::ROW_H`] — the ONE origin the paint and the hit test share.
 364     fn rows_top(&self, content: Rect) -> f32 {
 365         let (_, ry, _, _) = self.popover_geom(content);
 366         ry + self.popover_pads(content).0
 367     }
 368 
 369     /// Which option lies at `py`, or None for the reserved wall at the menu's
 370     /// outer edge (and for anything past the last row).
 371     fn row_at(&self, content: Rect, py: f32) -> Option<usize> {
 372         let rel = py - self.rows_top(content);
 373         if rel < 0.0 {
 374             return None;
 375         }
 376         let idx = (rel / Self::ROW_H) as usize;
 377         (idx < self.options.len()).then_some(idx)
 378     }
 379 
 380     /// The ONE continuous surface drawn while open: the trigger band unioned
 381     /// with the revealed menu area — the status-interface treatment, where the
 382     /// module box literally grows into its menu instead of spawning a detached
 383     /// popover plate.
 384     fn unified_geom_drawn(&self, content: Rect) -> (f32, f32, f32, f32) {
 385         let (ax, ay, aw, ah) = self.popover_geom_drawn(content);
 386         if self.menu_replaces_trigger {
 387             // No band: the revealed menu IS the whole open surface.
 388             return (ax, ay, aw, ah);
 389         }
 390         let (tx, ty) = (content.x, content.y);
 391         let (tw, th) = (content.width, content.height);
 392         let x0 = tx.min(ax);
 393         let y0 = ty.min(ay);
 394         let x1 = (tx + tw).max(ax + aw);
 395         let y1 = (ty + th).max(ay + ah);
 396         (x0, y0, x1 - x0, y1 - y0)
 397     }
 398 
 399     /// The menu box revealed this frame: the full geometry with the height
 400     /// revealed — and the width grown out of the trigger — by the eased
 401     /// progress (cubic-out, the status-interface module-menu curve). Rows keep
 402     /// their final positions and slide into view under the traveling edge; an
 403     /// upward popover anchors its bottom edge to the trigger instead.
 404     fn popover_geom_drawn(&self, content: Rect) -> (f32, f32, f32, f32) {
 405         let (rx, ry, rw, rh) = self.popover_geom(content);
 406         let a = self.anim_snap;
 407         if a >= 1.0 {
 408             return (rx, ry, rw, rh);
 409         }
 410         let t = 1.0 - (1.0 - a) * (1.0 - a) * (1.0 - a);
 411         let base_y = content.y - self.label_top();
 412         let open_upward = self.open_upward.unwrap_or(base_y > 400.0);
 413         let w0 = content.width.min(rw);
 414         let aw = w0 + (rw - w0) * t;
 415         let ah = rh * t;
 416         let ay = if open_upward { ry + rh - ah } else { ry };
 417         (rx, ay, aw, ah)
 418     }
 419 
 420     /// Popover geometry against the laid-out content rect — the legacy `get_popover_geom`,
 421     /// with the base-rect reads rewritten in content-rect terms (`base.y + base.h` ⇒
 422     /// `content.y + content.height`, `base.y + label_offset` ⇒ `content.y`).
 423     ///
 424     /// The box is the row strip PLUS [`Self::popover_pads`] — it is a plate,
 425     /// and its outer edges are relief wall, not face. Rows start at
 426     /// [`Self::rows_top`], never at `ry`.
 427     pub fn popover_geom(&self, content: Rect) -> (f32, f32, f32, f32) {
 428         let (pad_top, pad_bottom) = self.popover_pads(content);
 429         let content = self.popover_anchor.unwrap_or(content);
 430         let rw = self.popover_width(content);
 431         let rh = self.options.len() as f32 * Self::ROW_H + pad_top + pad_bottom;
 432 
 433         let base_y = content.y - self.label_top();
 434         let open_upward = self.open_upward.unwrap_or(base_y > 400.0);
 435         let mut rx = content.x;
 436         let mut ry = if self.menu_replaces_trigger {
 437             // The menu takes the trigger's slot: flush with its bottom edge
 438             // (upward) or its top edge (downward) rather than stacked past it.
 439             if open_upward { content.y + content.height - rh } else { content.y }
 440         } else if open_upward {
 441             content.y - rh
 442         } else {
 443             content.y + content.height
 444         };
 445 
 446         let is_ramp = self.parent_snapshot.map_or(false, |s| s.is_ramp);
 447 
 448         if is_ramp {
 449             if let Some(snap) = self.parent_snapshot {
 450                 let (px, py, pw_parent, ph_parent) = snap.rect;
 451                 if pw_parent > 0.0 && ph_parent > 0.0 {
 452                     let dy_down = content.y + content.height;
 453                     let dy_up = content.y - rh;
 454 
 455                     if self.open_upward.is_none() {
 456                         if dy_down + rh > py + ph_parent && dy_up >= py {
 457                             ry = dy_up;
 458                         } else if dy_up < py && dy_down + rh <= py + ph_parent {
 459                             ry = dy_down;
 460                         }
 461                     }
 462 
 463                     // Clamp X to parent borders
 464                     if rx < px {
 465                         rx = px;
 466                     }
 467                     if rx + rw > px + pw_parent {
 468                         rx = px + pw_parent - rw;
 469                     }
 470 
 471                     // Clamp Y to parent borders
 472                     if ry < py {
 473                         ry = py;
 474                     }
 475                     if ry + rh > py + ph_parent {
 476                         ry = py + ph_parent - rh;
 477                     }
 478                 }
 479             }
 480         }
 481 
 482         (rx, ry, rw, rh)
 483     }
 484 
 485     fn border_color(&self) -> [f32; 4] {
 486         if self.open && !self.closing {
 487             [0.30, 0.50, 0.32, 1.0]
 488         } else if self.hovered {
 489             let bc = colors::dropdown_border_color();
 490             [(bc[0] + 0.15).min(1.0), (bc[1] + 0.15).min(1.0), (bc[2] + 0.15).min(1.0), bc[3]]
 491         } else {
 492             colors::dropdown_border_color()
 493         }
 494     }
 495 
 496     /// Emit the border + background geometry — the legacy `all_rounded_quads` body (rounded,
 497     /// with the root plate-concentric corner adjustment) or `extra_quads` (plain) depending on
 498     /// the configured radius, byte-for-byte on the same content rect.
 499     fn paint_background(&self, content: Rect, ctx: &mut PaintCtx) {
 500         let x = content.x;
 501         let w = content.width;
 502         let y = content.y;
 503         let visual_h = content.height;
 504 
 505         let raw_bg = colors::dropdown_background_color();
 506         let mut bg_color = raw_bg;
 507         bg_color[3] = 1.0; // Force opaque background to prevent subpixel blending artifacts
 508         let border_color = self.border_color();
 509 
 510         let radius = crate::layout::dropdown_corner_radius();
 511         // Raised style: one lit Bevel plate owns fill and edge (the concentric
 512         // corner_frame adjustment keeps the legacy path — it exists to nest
 513         // flat outlines, which a rolled edge replaces). A transparent
 514         // configured fill degrades to a Boss: edges only, plate as the face —
 515         // judged on the RAW alpha, before the opacity force above.
 516         if self.flat {
 517             let plate = crate::widget::ControlPlate::control(
 518                 Rect { x, y, width: w, height: visual_h },
 519                 radius,
 520                 crate::widget::PlateStance::Flat,
 521                 self.face.or_else(|| crate::scene::Material::control_face(raw_bg)),
 522             )
 523             .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint));
 524             ctx.control_plate(&plate);
 525             return;
 526         }
 527         if self.raised() {
 528             let depth = crate::layout::bevel_width().min(visual_h * 0.2);
 529             // Concentric corner_frame adjustment applies to the relief too: a
 530             // corner nested at equal gaps into the frame follows its curve.
 531             // The window corner is span-widened (corner_span_factor, diagonal
 532             // curvature = pr), so its parallel curve at inset g has diagonal
 533             // curvature pr - g — which as a NOMINAL widget-scale squircle
 534             // radius is factor * (pr - g). Exactly pr - g for circular
 535             // corners (factor 1).
 536             let mut r4 = [radius; 4];
 537             if let Some(((px, py, pw, ph), pr, (pr1, pr2, pr3, pr4))) = self.corner_frame {
 538                 let cf = crate::layout::corner_span_factor();
 539                 let g_left = x - px;
 540                 let g_top = y - py;
 541                 let g_right = (px + pw) - (x + w);
 542                 let g_bottom = (py + ph) - (y + visual_h);
 543                 if pr1 && (g_left - g_top).abs() < 1.0 && g_left >= 0.0 {
 544                     r4[0] = (pr - g_left).max(0.0) * cf;
 545                 }
 546                 if pr2 && (g_right - g_top).abs() < 1.0 && g_right >= 0.0 {
 547                     r4[1] = (pr - g_right).max(0.0) * cf;
 548                 }
 549                 if pr3 && (g_right - g_bottom).abs() < 1.0 && g_right >= 0.0 {
 550                     r4[2] = (pr - g_right).max(0.0) * cf;
 551                 }
 552                 if pr4 && (g_left - g_bottom).abs() < 1.0 && g_left >= 0.0 {
 553                     r4[3] = (pr - g_left).max(0.0) * cf;
 554                 }
 555             }
 556             // The trigger is a flush control plate (groove ring down, beveled
 557             // lip back up, face level with the surface; a transparent raw
 558             // fill = edges only), its footprint the trigger's rect and its
 559             // silhouette the frame-adjusted radii.
 560             let plate = crate::widget::ControlPlate::control(
 561                 Rect { x, y, width: w, height: visual_h },
 562                 radius,
 563                 crate::widget::PlateStance::Flush,
 564                 self.face.or_else(|| crate::scene::Material::control_face(raw_bg)),
 565             )
 566             .with_radii((r4[0], r4[1], r4[2], r4[3]))
 567             .with_depth(depth)
 568             .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint));
 569             ctx.control_plate(&plate);
 570             return;
 571         }
 572         if radius <= 0.0 {
 573             ctx.quad(Rect { x, y, width: w, height: visual_h }, border_color);
 574             ctx.quad(
 575                 Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: visual_h - 2.0 },
 576                 bg_color,
 577             );
 578             return;
 579         }
 580 
 581         let inner_radius = (radius - 1.0).max(0.0);
 582         let mut adjusted = false;
 583         let mut outer_radii = [radius; 4];
 584         let mut inner_radii = [inner_radius; 4];
 585 
 586         // Only an explicit corner_frame adjusts concentric corners now — the legacy fallback
 587         // walked ancestors for a root plate, which no longer exists.
 588         let frame = self.corner_frame;
 589         if let Some(((px, py, pw, ph), pr, (pr1, pr2, pr3, pr4))) = frame {
 590             let g_left = x - px;
 591             let g_top = y - py;
 592             let g_right = (px + pw) - (x + w);
 593             let g_bottom = (py + ph) - (y + visual_h);
 594 
 595             if pr1 && (g_left - g_top).abs() < 1.0 && g_left >= 0.0 {
 596                 outer_radii[0] = (pr - g_left).max(0.0);
 597                 inner_radii[0] = (outer_radii[0] - 1.0).max(0.0);
 598                 adjusted = true;
 599             }
 600             if pr2 && (g_right - g_top).abs() < 1.0 && g_right >= 0.0 {
 601                 outer_radii[1] = (pr - g_right).max(0.0);
 602                 inner_radii[1] = (outer_radii[1] - 1.0).max(0.0);
 603                 adjusted = true;
 604             }
 605             if pr3 && (g_right - g_bottom).abs() < 1.0 && g_right >= 0.0 {
 606                 outer_radii[2] = (pr - g_right).max(0.0);
 607                 inner_radii[2] = (outer_radii[2] - 1.0).max(0.0);
 608                 adjusted = true;
 609             }
 610             if pr4 && (g_left - g_bottom).abs() < 1.0 && g_left >= 0.0 {
 611                 outer_radii[3] = (pr - g_left).max(0.0);
 612                 inner_radii[3] = (outer_radii[3] - 1.0).max(0.0);
 613                 adjusted = true;
 614             }
 615         }
 616 
 617         if adjusted {
 618             for (qx, qy, qw, qh, qr, qc, corners) in crate::layout::partition_concentric_corners(
 619                 x, y, w, visual_h, radius, outer_radii, border_color,
 620             ) {
 621                 ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
 622             }
 623             for (qx, qy, qw, qh, qr, qc, corners) in crate::layout::partition_concentric_corners(
 624                 x + 1.0, y + 1.0, w - 2.0, visual_h - 2.0, inner_radius, inner_radii, bg_color,
 625             ) {
 626                 ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, qr, corners, qc);
 627             }
 628         } else {
 629             let corners = (true, true, true, true);
 630             ctx.rounded_rect(Rect { x, y, width: w, height: visual_h }, radius, corners, border_color);
 631             ctx.rounded_rect(
 632                 Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: visual_h - 2.0 },
 633                 inner_radius,
 634                 corners,
 635                 bg_color,
 636             );
 637         }
 638     }
 639 
 640     /// Emit the selected-text (per-character fade against the right edge) and the ▼ arrow —
 641     /// the legacy `text_labels` body minus the control label (the adapter's base-label
 642     /// machinery draws that, with the +4px `detached_label_inset`).
 643     fn paint_text(&self, content: Rect, ctx: &mut PaintCtx) {
 644         let selected_text = if let Some(ref custom_text) = self.custom_display_text {
 645             custom_text.clone()
 646         } else {
 647             self.options.get(self.selected).cloned().unwrap_or_default()
 648         };
 649 
 650         let (font_family, font_size) = crate::layout::control_label_font_detached_parsed();
 651         let x = content.x;
 652         let w = content.width;
 653         let start_x = x + 8.0;
 654         let right_limit = x + w - 28.0; // 10px margin before the arrow
 655         let fade_start_x = (right_limit - 24.0).max(start_x); // Fade out over the last 24px
 656         let text_y = crate::layout::center_text_y(content.y, content.height, font_size);
 657         let tc = colors::dropdown_text_color();
 658         let default_color = [
 659             (colors::linear_to_srgb(tc[0]) * 255.0).round() as u8,
 660             (colors::linear_to_srgb(tc[1]) * 255.0).round() as u8,
 661             (colors::linear_to_srgb(tc[2]) * 255.0).round() as u8,
 662         ];
 663         let bg_color = colors::dropdown_background_color();
 664         let mut parent_color = colors::page_color();
 665         if let Some(snap) = self.parent_snapshot {
 666             parent_color = snap.color;
 667         }
 668         let alpha = 1.0; // The dropdown background is drawn fully opaque
 669         let bg_rgb = [
 670             ((parent_color[0] * (1.0 - alpha) + bg_color[0] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
 671             ((parent_color[1] * (1.0 - alpha) + bg_color[1] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
 672             ((parent_color[2] * (1.0 - alpha) + bg_color[2] * alpha) * 255.0).round().clamp(0.0, 255.0) as u8,
 673         ];
 674 
 675         let w_dummy = crate::widget::display::measure_text_width("M", &font_family, font_size);
 676         let chars: Vec<char> = selected_text.chars().collect();
 677         let n = chars.len();
 678 
 679         let is_monospace = is_monospace_font(&font_family, font_size);
 680 
 681         let cell_width = if is_monospace {
 682             monospace_cell_width(&font_family, font_size)
 683         } else {
 684             0.0
 685         };
 686 
 687         let mut char_offsets = Vec::with_capacity(n);
 688         if is_monospace {
 689             for i in 0..n {
 690                 char_offsets.push(i as f32 * cell_width);
 691             }
 692         } else {
 693             if n > 0 {
 694                 char_offsets.push(0.0f32);
 695             }
 696             let mut prefix = String::new();
 697             for i in 1..n {
 698                 prefix.push(chars[i - 1]);
 699                 let measure_str = format!("{}M", prefix);
 700                 let w_prefix_dummy = crate::widget::display::measure_text_width(&measure_str, &font_family, font_size);
 701                 let offset = (w_prefix_dummy - w_dummy).max(0.0);
 702                 char_offsets.push(offset);
 703             }
 704         }
 705 
 706         let total_advance = text_advance(&selected_text, &font_family, font_size);
 707 
 708         // Draw and fade every character individually
 709         let mut prev_char_end = 0.0;
 710         for i in 0..n {
 711             let mut offset = char_offsets[i];
 712             if !is_monospace {
 713                 if i > 0 {
 714                     offset = offset.max(prev_char_end + 1.0);
 715                 }
 716             }
 717             let next_offset = if i < n - 1 { char_offsets[i + 1] } else { total_advance };
 718             let c_w = if is_monospace { cell_width } else { next_offset - offset };
 719             let cur_x = start_x + offset;
 720 
 721             if cur_x >= right_limit {
 722                 break;
 723             }
 724 
 725             let char_mid_x = cur_x + c_w / 2.0;
 726             let mut skip_char = false;
 727             let text_end_x = start_x + total_advance;
 728             let color = if text_end_x > right_limit && char_mid_x > fade_start_x {
 729                 let factor = ((char_mid_x - fade_start_x) / (right_limit - fade_start_x)).clamp(0.0, 1.0);
 730                 if factor >= 0.9 {
 731                     skip_char = true;
 732                     default_color
 733                 } else {
 734                     [
 735                         (default_color[0] as f32 + (bg_rgb[0] as f32 - default_color[0] as f32) * factor).round() as u8,
 736                         (default_color[1] as f32 + (bg_rgb[1] as f32 - default_color[1] as f32) * factor).round() as u8,
 737                         (default_color[2] as f32 + (bg_rgb[2] as f32 - default_color[2] as f32) * factor).round() as u8,
 738                     ]
 739                 }
 740             } else {
 741                 default_color
 742             };
 743 
 744             if !skip_char {
 745                 ctx.text(chars[i].to_string(), cur_x, text_y, font_size, color);
 746                 let c_w_ink = if is_monospace {
 747                     cell_width
 748                 } else {
 749                     crate::widget::display::measure_text_width(&chars[i].to_string(), &font_family, font_size)
 750                 };
 751                 prev_char_end = offset + c_w_ink;
 752             }
 753         }
 754 
 755         // Only the chevron is bounded here. The trigger TEXT above fades
 756         // character by character toward `right_limit` and drops anything past
 757         // 90% — an overflow treatment of its own, which a hard clip would
 758         // fight rather than help.
 759         ctx.text_with(
 760             "▼",
 761             x + w - 18.0,
 762             crate::layout::center_text_y(content.y, content.height, 10.0),
 763             10.0,
 764             [0x83, 0x83, 0x8a],
 765             None,
 766             Some([content.x, content.y, content.x + content.width, content.y + content.height]),
 767         );
 768     }
 769 
 770     /// Port of the legacy `keyboard_input` body.
 771     fn handle_key(&mut self, event: &crate::widget::KeyEvent) -> bool {
 772         if event.state != ElementState::Pressed {
 773             return false;
 774         }
 775         if !self.open || self.closing {
 776             if let Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) = event.logical_key {
 777                 self.begin_open();
 778                 let mut start_idx = self.selected;
 779                 if start_idx < self.options.len() && self.options[start_idx] == "-" {
 780                     for i in 0..self.options.len() {
 781                         if self.options[i] != "-" {
 782                             start_idx = i;
 783                             break;
 784                         }
 785                     }
 786                 }
 787                 self.hovered_item = Some(start_idx);
 788                 return true;
 789             }
 790             return false;
 791         }
 792 
 793         match event.logical_key {
 794             Key::Named(NamedKey::ArrowDown) => {
 795                 let current = self.hovered_item.unwrap_or(self.selected);
 796                 let mut next = (current + 1) % self.options.len();
 797                 for _ in 0..self.options.len() {
 798                     if self.options[next] != "-" {
 799                         self.hovered_item = Some(next);
 800                         break;
 801                     }
 802                     next = (next + 1) % self.options.len();
 803                 }
 804                 true
 805             }
 806             Key::Named(NamedKey::ArrowUp) => {
 807                 let current = self.hovered_item.unwrap_or(self.selected);
 808                 let mut prev = if current == 0 { self.options.len() - 1 } else { current - 1 };
 809                 for _ in 0..self.options.len() {
 810                     if self.options[prev] != "-" {
 811                         self.hovered_item = Some(prev);
 812                         break;
 813                     }
 814                     prev = if prev == 0 { self.options.len() - 1 } else { prev - 1 };
 815                 }
 816                 true
 817             }
 818             Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
 819                 if std::env::var_os("CCE_DD_DEBUG").is_some() {
 820                     eprintln!("[dd] key-select hovered={:?} selected={}", self.hovered_item, self.selected);
 821                 }
 822                 if let Some(idx) = self.hovered_item {
 823                     if idx < self.options.len() && self.options[idx] != "-" {
 824                         if self.selected != idx || self.custom_display_text.is_some() {
 825                             self.selected = idx;
 826                             self.just_changed = true;
 827                         }
 828                         self.begin_close();
 829                     }
 830                 }
 831                 true
 832             }
 833             Key::Named(NamedKey::Escape) => {
 834                 self.begin_close();
 835                 true
 836             }
 837             _ => false,
 838         }
 839     }
 840 }
 841 
 842 impl Adapted<Dropdown> {
 843     /// Raised style: see the `raised` field.
 844     pub fn with_raised(mut self, raised: bool) -> Self {
 845         self.raised = Some(raised);
 846         self
 847     }
 848 
 849     /// Override the trigger plate's face — see the `face` field.
 850     pub fn with_face(mut self, face: crate::scene::Material) -> Self {
 851         self.face = Some(face);
 852         self
 853     }
 854 
 855     /// Draw the trigger with no relief at all — see
 856     /// [`crate::widget::PlateStance::Flat`]. Overrides `with_raised`.
 857     pub fn with_flat(mut self, flat: bool) -> Self {
 858         self.flat = flat;
 859         self
 860     }
 861 
 862     pub fn with_custom_display_text(mut self, text: &str) -> Self {
 863         self.custom_display_text = Some(text.to_string());
 864         self
 865     }
 866 
 867     pub fn with_font_family(mut self, font_family: &str) -> Self {
 868         self.font_family = font_family.to_string();
 869         self
 870     }
 871 
 872     pub fn with_open_upward(mut self, open_upward: bool) -> Self {
 873         self.open_upward = Some(open_upward);
 874         self
 875     }
 876 
 877     pub fn with_auto_width(mut self, auto_width: bool) -> Self {
 878         self.auto_width = auto_width;
 879         self
 880     }
 881 
 882     /// The open menu replaces the trigger: see the `menu_replaces_trigger` field.
 883     pub fn with_menu_replaces_trigger(mut self, replaces: bool) -> Self {
 884         self.menu_replaces_trigger = replaces;
 885         self
 886     }
 887 
 888     /// Popover geometry from the widget's laid-out rect — the legacy inherent
 889     /// `get_popover_geom` shape, for callers that hold the wrapper.
 890     pub fn get_popover_geom(&self) -> (f32, f32, f32, f32) {
 891         let (x, y, w, h) = WidgetHost::rect(self);
 892         let top = self.inner().label_top();
 893         self.inner().popover_geom(Rect { x, y: y + top, width: w, height: h - top })
 894     }
 895 }
 896 
 897 impl Layout for Dropdown {
 898 
 899     fn z_order(&self) -> i32 {
 900         if self.open {
 901             100
 902         } else {
 903             0
 904         }
 905     }
 906 
 907     /// Content size for the scene layout engine (Phase 2b). A normal dropdown is wide enough for
 908     /// the widest option (via `content_width`, which already includes the arrow/padding inset), so
 909     /// the control doesn't resize as the selection changes. A menu-button dropdown (fixed
 910     /// `custom_display_text`, e.g. a "File" menu) instead sizes to its display text — its label is
 911     /// fixed regardless of options, so fitting the widest entry would just stretch the trigger. The
 912     /// open popover still expands to the widest option via `popover_width`'s `.max(content_width())`.
 913     fn intrinsic_size(&self) -> Option<Size> {
 914         let width = if self.custom_display_text.is_some() {
 915             self.display_width()
 916         } else {
 917             self.content_width()
 918         };
 919         Some(Size::new(width, crate::layout::dropdown_height()))
 920     }
 921 
 922     fn intrinsic_measure_width(&self) -> bool {
 923         self.auto_width
 924     }
 925 }
 926 
 927 impl Paint for Dropdown {
 928     fn color(&self) -> [f32; 4] {
 929         [0.0, 0.0, 0.0, 0.0]
 930     }
 931 
 932     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
 933         let r = crate::layout::dropdown_corner_radius();
 934         if r > 0.0 {
 935             Some((r, (true, true, true, true)))
 936         } else {
 937             Some((r, (false, false, false, false)))
 938         }
 939     }
 940 
 941     fn widget_font(&self) -> Option<String> {
 942         Some(crate::layout::control_label_font_detached())
 943     }
 944 
 945     fn sync_label(&mut self, label: &str) {
 946         self.label = Some(label.to_string());
 947     }
 948 
 949     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
 950         if self.menu_replaces_trigger && self.open {
 951             // The trigger yields to the menu the moment the revealed rows
 952             // cover its band (and comes back as the contraction uncovers it);
 953             // the frosted menu plate is translucent, so a trigger left
 954             // painting beneath would show through blurred.
 955             let (_, ay, _, ah) = self.popover_geom_drawn(rect);
 956             if ay <= rect.y + 0.5 && ay + ah >= rect.y + rect.height - 0.5 {
 957                 return;
 958             }
 959         }
 960         self.paint_background(rect, ctx);
 961         self.paint_text(rect, ctx);
 962     }
 963 
 964     fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
 965         if self.open {
 966             // The ANIMATED unified box (trigger band + revealed menu), not the
 967             // full menu geometry: hosts replay popover text with bounds derived
 968             // from this rect (and the dl-text occlusion clamp reads it), so
 969             // reporting the drawn surface keeps labels — the band's included —
 970             // clipped to the traveling edge everywhere without per-app changes.
 971             Some(self.unified_geom_drawn(rect))
 972         } else {
 973             None
 974         }
 975     }
 976 
 977     fn draw_popover(&self, rect: Rect, pc: &mut dyn crate::layout::RenderTarget) {
 978         if !self.open {
 979             return;
 980         }
 981 
 982         // ONE continuous surface in the status-interface manner: the trigger
 983         // band grows into the menu — no detached popover plate, no drop
 984         // shadows. The unified box spans the trigger and the revealed menu;
 985         // the trigger's display text is redrawn on top of its band. Rows sit
 986         // at their FINAL positions (from the full geometry) and slide into
 987         // view as the traveling edge reveals them, clipped to the menu area by
 988         // hand (RenderTarget carries no clip stack); text clips through its
 989         // bounds.
 990         let (rx, _ry, rw, _rh) = self.popover_geom(rect);
 991         // The row strip inside the box — past the relief wall on an outer edge.
 992         let rows_y = self.rows_top(rect);
 993         let (ax, ay, aw, ah) = self.popover_geom_drawn(rect);
 994         if aw <= 0.5 || ah <= 0.5 {
 995             // Nothing revealed yet — the plain trigger stands alone.
 996             return;
 997         }
 998         let clip = |x: f32, y: f32, w: f32, h: f32| -> Option<(f32, f32, f32, f32)> {
 999             let x0 = x.max(ax);
1000             let y0 = y.max(ay);
1001             let x1 = (x + w).min(ax + aw);
1002             let y1 = (y + h).min(ay + ah);
1003             if x1 > x0 && y1 > y0 { Some((x0, y0, x1 - x0, y1 - y0)) } else { None }
1004         };
1005 
1006         let theme = colors::active_theme();
1007         let (ux, uy, uw, uh) = self.unified_geom_drawn(rect);
1008 
1009         // The ACTUAL button surface, expanded: the raised trigger's flush
1010         // inset plate grown over the unified box (real relief prims on a
1011         // PaintCtx-backed target; collector hosts degrade to a rounded fill).
1012         // The trigger's configured fill is usually transparent — the window
1013         // plate IS its face — so the expansion substitutes the plate color,
1014         // FROSTED: the popover material (`Material::popover`), the same the
1015         // context menu wears (`ContextMenuState::paint`), so the menu shows
1016         // the content beneath it blurred and tinted rather than covering it.
1017         // Encoded here because the flat-path `RenderTarget` speaks colours.
1018         let radius = crate::layout::dropdown_corner_radius();
1019         let raw_bg = colors::dropdown_background_color();
1020         let face = {
1021             let base = if raw_bg[3] > 0.001 { raw_bg } else { crate::color::page_low_color() };
1022             crate::scene::Material::popover(base).fill(crate::scene::PlateRole::Nested)
1023         };
1024         if self.raised() {
1025             let depth = crate::layout::bevel_width().min(rect.height * 0.2);
1026             let (t, tr) = crate::layout::carve_inside(
1027                 crate::scene::layout::Rect { x: ux, y: uy, width: uw, height: uh },
1028                 (radius, radius, radius, radius),
1029                 depth,
1030             );
1031             pc.inset_plate(face, t.x, t.y, t.width, t.height, tr.0, depth);
1032         } else {
1033             pc.rect_with_radius(self.border_color(), ux, uy, uw, uh, radius);
1034             pc.rect_with_radius(face, ux + 1.0, uy + 1.0, uw - 2.0, uh - 2.0, (radius - 1.0).max(0.0));
1035         }
1036 
1037         // Trigger content redrawn over its band (the box covers the widget-pass
1038         // trigger paint) — display text left, ▼ right, the paint_text palette.
1039         // A menu that replaces the trigger has no band to redraw on.
1040         if !self.menu_replaces_trigger {
1041             let (tx, ty) = (rect.x, rect.y);
1042             let (tw, th) = (rect.width, rect.height);
1043             let band_bounds = Some([ux, uy, ux + uw, uy + uh]);
1044             let font = crate::layout::control_label_font_detached();
1045             let text_y = crate::layout::align_text_y(ty, th, 12.0, 0.0);
1046             pc.text_with_font_and_bounds(
1047                 &self.display_text(),
1048                 tx + 8.0,
1049                 text_y,
1050                 12.0,
1051                 [0.8, 0.8, 0.85, 1.0],
1052                 &font,
1053                 band_bounds,
1054             );
1055             pc.text_with_font_and_bounds(
1056                 "▼",
1057                 tx + tw - 18.0,
1058                 crate::layout::center_text_y(ty, th, 10.0),
1059                 10.0,
1060                 [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0],
1061                 &font,
1062                 band_bounds,
1063             );
1064         }
1065 
1066         if let Some(h_idx) = self.hovered_item {
1067             let iy = rows_y + h_idx as f32 * Self::ROW_H;
1068             // 4. Vibrantly colored translucent selection highlight
1069             if let Some((cx, cy, cw, ch)) = clip(rx + 2.0, iy + 2.0, rw - 4.0, 20.0) {
1070                 pc.rect(theme.primary_accent, cx, cy, cw, ch);
1071             }
1072         }
1073 
1074         for (idx, opt) in self.options.iter().enumerate() {
1075             let row_top = rows_y + idx as f32 * Self::ROW_H;
1076             let iy = crate::layout::align_text_y(row_top, Self::ROW_H, 12.0, 0.0);
1077 
1078             if opt == "-" {
1079                 if let Some((cx, cy, cw, ch)) = clip(rx + 8.0, row_top + 11.5, rw - 16.0, 1.0) {
1080                     pc.rect(theme.surface_border, cx, cy, cw, ch);
1081                 }
1082                 continue;
1083             }
1084 
1085             // Menu-button mode (custom_display_text) has no "current" option —
1086             // its rows are commands, so none reads as selected.
1087             let text_color = if self.hovered_item == Some(idx) {
1088                 [0xff, 0xff, 0xff]
1089             } else if self.selected == idx && self.custom_display_text.is_none() {
1090                 [0x3a, 0x9a, 0xff]
1091             } else {
1092                 [0xcc, 0xcc, 0xd4]
1093             };
1094 
1095             let color_f32 = [
1096                 text_color[0] as f32 / 255.0,
1097                 text_color[1] as f32 / 255.0,
1098                 text_color[2] as f32 / 255.0,
1099                 1.0,
1100             ];
1101 
1102             // Bounds = the unified popover rect EXACTLY (not the menu sub-box):
1103             // the dl-text occlusion clamp exempts only exact-match overlay
1104             // labels, and the unified box's traveling edge clips identically.
1105             let bounds = Some([ux, uy, ux + uw, uy + uh]);
1106             let font = crate::layout::control_label_font_detached();
1107             pc.text_with_font_and_bounds(opt, rx + 8.0, iy, 12.0, color_f32, &font, bounds);
1108         }
1109     }
1110 }
1111 
1112 impl Input for Dropdown {
1113     fn focus_role(&self) -> crate::widget::FocusRole {
1114         crate::widget::FocusRole::Plate
1115     }
1116     /// The legacy geometric test: the widget rect (edges inclusive), extended to the open
1117     /// popover. `rect` is the full base rect (label strip included), as legacy `hit_test` used.
1118     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
1119         if rect.width <= 0.0 || rect.height <= 0.0 {
1120             return false;
1121         }
1122         let hit_trigger =
1123             x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
1124         if self.open && !self.closing {
1125             let top = self.label_top();
1126             let content = Rect { x: rect.x, y: rect.y + top, width: rect.width, height: rect.height - top };
1127             let (rx, ry, rw, rh) = self.popover_geom(content);
1128             let hit_popover = x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
1129             hit_trigger || hit_popover
1130         } else {
1131             hit_trigger
1132         }
1133     }
1134 
1135     fn opens_context_menu(&self) -> bool {
1136         true
1137     }
1138 
1139     /// Ungated presses (legacy `mouse_input` saw every press): an open dropdown must close on
1140     /// an outside click it would otherwise never learn about.
1141     fn gates_presses(&self) -> bool {
1142         false
1143     }
1144 
1145     /// Wall-clock animation bookkeeping: report "changed" while a transition is
1146     /// in flight (drives redraws where the app's UiContext gets ticked) and
1147     /// settle a landed close.
1148     fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
1149         let animating = self.anim_start.is_some();
1150         self.settle_anim();
1151         animating
1152     }
1153 
1154     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1155         self.settle_anim();
1156         match event {
1157             Event::MouseButton {
1158                 button: MouseButton::Left,
1159                 state: ElementState::Released,
1160                 ..
1161             } => {
1162                 // While the menu is open (or shrinking closed), the paired
1163                 // release of any press this dropdown handled must not leak to
1164                 // widgets stacked beneath the popover: an unconsumed release
1165                 // falling through the host's dispatch landed on the button
1166                 // whose row the menu covers — in the designer's params pane,
1167                 // picking an "Open" entry fired the Save As button underneath
1168                 // and opened a save chooser on top of the load.
1169                 self.open || self.closing
1170             }
1171             Event::MouseButton {
1172                 button: MouseButton::Left,
1173                 state: ElementState::Pressed,
1174                 x: px,
1175                 y: py,
1176                 ..
1177             } => {
1178                 let content = ectx.rect;
1179                 let top = self.label_top();
1180                 let (bx, by, bw, bh) = (content.x, content.y - top, content.width, content.height + top);
1181                 let (rx, ry, rw, rh) = self.popover_geom(content);
1182 
1183                 let inside_trigger = *px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh;
1184                 let inside_popover = self.open
1185                     && !self.closing
1186                     && *px >= rx && *px <= rx + rw && *py >= ry && *py <= ry + rh;
1187 
1188                 if std::env::var_os("CCE_DD_DEBUG").is_some() {
1189                     eprintln!(
1190                         "[dd] press ({px},{py}) rect=({:.0},{:.0},{:.0},{:.0}) popover=({rx:.0},{ry:.0},{rw:.0},{rh:.0}) open={} in_trig={inside_trigger} in_pop={inside_popover}",
1191                         content.x, content.y, content.width, content.height, self.open
1192                     );
1193                 }
1194                 if inside_popover {
1195                     if let Some(idx) = self.row_at(content, *py) {
1196                         if self.options[idx] == "-" {
1197                             return true;
1198                         }
1199                         if self.selected != idx || self.custom_display_text.is_some() {
1200                             self.selected = idx;
1201                             self.just_changed = true;
1202                         }
1203                     }
1204                     self.begin_close();
1205                     return true;
1206                 }
1207 
1208                 if inside_trigger {
1209                     if self.open && !self.closing {
1210                         self.begin_close();
1211                     } else {
1212                         self.begin_open();
1213                         // Animation frames arrive through the ctx tick loop.
1214                         if let Some(ui) = ectx.ui.as_deref_mut() {
1215                             ui.register_tick_receiver(ectx.id);
1216                         }
1217                         // Legacy `focus()` claimed only the global slot.
1218                         ectx.request_focus();
1219                     }
1220                     return true;
1221                 }
1222 
1223                 if self.open && !self.closing {
1224                     self.begin_close();
1225                     return true;
1226                 }
1227 
1228                 false
1229             }
1230             Event::PointerMove { x: px, y: py, .. } => {
1231                 // The popover-item half of the legacy `on_cursor_moved`; the trigger-hover half
1232                 // is the adapter's bookkeeping (MouseEnter/MouseLeave below).
1233                 let was_hovered_item = self.hovered_item;
1234                 self.hovered_item = None;
1235                 if self.open && !self.closing {
1236                     let (rx, ry, rw, rh) = self.popover_geom(ectx.rect);
1237                     if *px >= rx && *px <= rx + rw && *py >= ry && *py <= ry + rh {
1238                         if let Some(idx) = self.row_at(ectx.rect, *py) {
1239                             if self.options[idx] != "-" {
1240                                 self.hovered_item = Some(idx);
1241                             }
1242                         }
1243                     }
1244                 }
1245                 self.hovered_item != was_hovered_item
1246             }
1247             Event::MouseEnter => {
1248                 self.hovered = true;
1249                 true
1250             }
1251             Event::MouseLeave => {
1252                 self.hovered = false;
1253                 true
1254             }
1255             Event::KeyInput(key_event) => {
1256                 // A key event carries no position, so hosts that broadcast one to every
1257                 // widget root (cce-system-interface's `dispatch_page_event`) rely on each
1258                 // widget declining what is not addressed to it — TextBox gates on
1259                 // `editing`, Toggle has no key arm at all. A closed dropdown must gate on
1260                 // focus for the same reason: without this it opened on any Enter/Space
1261                 // that reached it, so the first dropdown in the host's dispatch order
1262                 // swallowed the Return meant for whatever actually held focus. (Settings'
1263                 // Browser page: Enter in the Homepage field opened the Page Color Scheme
1264                 // menu instead of committing the field. Its Power page: Enter on the
1265                 // focused Power Profile opened the last of the nine, GPU Power Limit.)
1266                 // An open dropdown always holds focus — the trigger press and `FocusIn`
1267                 // both claim it, and `FocusOut` closes it — so the `open` arm is reachable
1268                 // either way; it is spelled out so arrows and Escape stay live regardless.
1269                 if !self.open && !crate::widget::focus::is_focused_id(ectx.id) {
1270                     return false;
1271                 }
1272                 let handled = self.handle_key(key_event);
1273                 if self.open {
1274                     if let Some(ui) = ectx.ui.as_deref_mut() {
1275                         ui.register_tick_receiver(ectx.id);
1276                     }
1277                 }
1278                 handled
1279             }
1280             Event::FocusIn => {
1281                 // Legacy `focus()` claimed the global focus slot on every direct call
1282                 // (test-interface focuses the ramp's preset dropdown this way).
1283                 self.focused = true;
1284                 ectx.request_focus();
1285                 false
1286             }
1287             Event::FocusOut => {
1288                 // Legacy `unfocus` closed the dropdown.
1289                 self.focused = false;
1290                 self.begin_close();
1291                 false
1292             }
1293             _ => false,
1294         }
1295     }
1296 
1297     fn take_click(&mut self) -> bool {
1298         self.take_change()
1299     }
1300 
1301     fn take_change(&mut self) -> bool {
1302         self.take_change()
1303     }
1304 
1305     fn value(&self) -> i32 {
1306         self.selected as i32
1307     }
1308 
1309     fn value_string(&self) -> Option<String> {
1310         self.options.get(self.selected).cloned()
1311     }
1312 
1313     fn set_value_string(&mut self, val: &str) -> bool {
1314         let val_trimmed = val.trim();
1315         for (idx, opt) in self.options.iter().enumerate() {
1316             if opt.eq_ignore_ascii_case(val_trimmed) {
1317                 if self.selected != idx {
1318                     self.selected = idx;
1319                     self.just_changed = true;
1320                     return true;
1321                 }
1322                 return false;
1323             }
1324         }
1325         if let Ok(idx) = val_trimmed.parse::<usize>() {
1326             if idx < self.options.len() {
1327                 if self.selected != idx {
1328                     self.selected = idx;
1329                     self.just_changed = true;
1330                     return true;
1331                 }
1332                 return false;
1333             }
1334         }
1335         false
1336     }
1337 }
1338 
1339 unsafe impl Send for Dropdown {}
1340 unsafe impl Sync for Dropdown {}
1341 
1342 
1343 #[cfg(test)]
1344 mod tests {
1345     use super::*;
1346     use crate::widget::LayoutConstraints;
1347 
1348     #[test]
1349     fn test_dropdown_widget_interaction() {
1350         let mut dummy = crate::context::UiContext::new();
1351         let options = vec!["Option A".to_string(), "Option B".to_string(), "Option C".to_string()];
1352         let mut dd = Dropdown::new(options, 0);
1353         dd.set_rect(10.0, 10.0, 100.0, 24.0);
1354 
1355         // 1. Initial State
1356         assert!(!dd.open);
1357         assert_eq!(dd.selected, 0);
1358 
1359         // 2. Click trigger area opens dropdown
1360         let input_changed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 20.0, &mut dummy);
1361         assert!(input_changed);
1362         assert!(dd.open);
1363 
1364         // 3. Hovering options inside popover
1365         // Popover starts at y = 10 + 24 = 34. Options are of height 24 each.
1366         // Hover option B at y = 34 + 24 + 12 = 70.0
1367         let move_changed = dd.on_cursor_moved(50.0, 70.0, &mut dummy);
1368         assert!(move_changed);
1369         assert_eq!(dd.hovered_item, Some(1));
1370 
1371         // 4. Click option B selects it and starts the animated close
1372         let select_changed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 70.0, &mut dummy);
1373         assert!(select_changed);
1374         assert!(dd.closing, "selection starts the animated contraction");
1375         dd.land_anim_for_test();
1376         assert!(!dd.open);
1377         assert_eq!(dd.selected, 1);
1378         assert!(dd.take_change());
1379     }
1380 
1381     #[test]
1382     fn test_dropdown_context_menu_with_config() {
1383         let mut dummy = crate::context::UiContext::new();
1384         let options = vec!["Option A".to_string(), "Option B".to_string()];
1385         let mut dd = Dropdown::new(options, 0).with_config("path/to/config.json", "some_key");
1386         dd.set_rect(10.0, 10.0, 100.0, 24.0);
1387 
1388         assert!(!crate::widget::context_menu::is_visible());
1389 
1390         // Right click dropdown
1391         let handled = dd.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
1392         assert!(handled);
1393 
1394         assert!(crate::widget::context_menu::is_visible());
1395         let menu_options = crate::widget::context_menu::options();
1396         assert!(menu_options.len() >= 3);
1397         assert_eq!(menu_options[1], "File: path/to/config.json");
1398         assert_eq!(menu_options[2], "Key: some_key");
1399 
1400         crate::widget::context_menu::hide();
1401         assert!(!crate::widget::context_menu::is_visible());
1402     }
1403 
1404     /// A closed dropdown takes Enter only when it is the focused widget.
1405     ///
1406     /// Hosts that broadcast a key event to every widget root (cce-system-interface's
1407     /// `dispatch_page_event`) depend on each widget declining what is not addressed to
1408     /// it — a key event carries no position to filter on. Without the focus gate the
1409     /// first dropdown in the host's dispatch order opened on *any* Enter that reached
1410     /// it, swallowing the Return meant for whatever actually held focus: settings'
1411     /// Browser page opened Page Color Scheme instead of committing the Homepage field,
1412     /// and its Power page opened the last of nine menus instead of the focused one.
1413     #[test]
1414     fn closed_dropdown_takes_enter_only_when_focused() {
1415         let mut dummy = crate::context::UiContext::new();
1416         let enter = crate::widget::KeyEvent {
1417             state: ElementState::Pressed,
1418             logical_key: Key::Named(NamedKey::Enter),
1419             text: None,
1420             repeat: false,
1421             ctrl: false,
1422             shift: false,
1423             alt: false,
1424         };
1425 
1426         let opts = vec!["Dark".to_string(), "Light".to_string()];
1427         let mut dd = Dropdown::new(opts.clone(), 0);
1428         dd.set_rect(10.0, 10.0, 100.0, 24.0);
1429 
1430         // Nothing focused: the Return belongs to someone else, so it is declined and
1431         // the menu stays shut.
1432         crate::widget::focus::clear_focus(None);
1433         assert!(!dd.keyboard_input(&enter, &mut dummy));
1434         assert!(!dd.open);
1435 
1436         // Another widget focused: same — this is the settings-page case, where the
1437         // focused TextBox sits later in the reversed dispatch order.
1438         let other = Dropdown::new(opts, 0);
1439         crate::widget::focus::set_focused_id(other.id(), None);
1440         assert!(!dd.keyboard_input(&enter, &mut dummy));
1441         assert!(!dd.open);
1442 
1443         // Focused: Enter opens it, and arms the hover on the selection as before.
1444         crate::widget::focus::set_focused_id(dd.id(), None);
1445         assert!(dd.keyboard_input(&enter, &mut dummy));
1446         assert!(dd.open);
1447         assert_eq!(dd.hovered_item, Some(0));
1448 
1449         // Once open the gate is out of the way, so Escape still closes it even if the
1450         // focus moved on (`FocusOut` closes it too, but the key path must not depend
1451         // on that having run).
1452         crate::widget::focus::clear_focus(None);
1453         let escape = crate::widget::KeyEvent {
1454             logical_key: Key::Named(NamedKey::Escape),
1455             ..enter.clone()
1456         };
1457         assert!(dd.keyboard_input(&escape, &mut dummy));
1458         assert!(dd.closing || !dd.open);
1459     }
1460 
1461     #[test]
1462     fn test_dropdown_separators() {
1463         let mut dummy = crate::context::UiContext::new();
1464         let options = vec![
1465             "Option A".to_string(),
1466             "-".to_string(),
1467             "Option B".to_string(),
1468         ];
1469         let mut dd = Dropdown::new(options, 0);
1470         dd.set_rect(10.0, 10.0, 100.0, 24.0);
1471 
1472         // Open dropdown
1473         dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 20.0, &mut dummy);
1474         assert!(dd.open);
1475 
1476         // Hover over separator at index 1 at y = 34 + 24 + 12 = 70.0
1477         dd.on_cursor_moved(50.0, 70.0, &mut dummy);
1478         assert_eq!(dd.hovered_item, None); // Separator should not be hovered
1479 
1480         // Click separator at index 1
1481         let clicked = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 70.0, &mut dummy);
1482         assert!(clicked);
1483         assert!(dd.open); // Dropdown should remain open
1484         assert_eq!(dd.selected, 0); // Selection should not change
1485 
1486         // Hover over Option B at index 2 at y = 34 + 48 + 12 = 94.0
1487         dd.on_cursor_moved(50.0, 94.0, &mut dummy);
1488         assert_eq!(dd.hovered_item, Some(2));
1489 
1490         // Keyboard arrow up from index 2 should skip separator (index 1) and go to index 0
1491         let key_up = crate::widget::KeyEvent {
1492             state: ElementState::Pressed,
1493             logical_key: Key::Named(NamedKey::ArrowUp),
1494             text: None,
1495             repeat: false,
1496             ctrl: false,
1497             shift: false,
1498             alt: false,
1499         };
1500         dd.keyboard_input(&key_up, &mut dummy);
1501         assert_eq!(dd.hovered_item, Some(0));
1502     }
1503 
1504     #[test]
1505     fn test_dropdown_ramp_parent_constraints() {
1506         let mut ramp = crate::widget::Ramp::new();
1507         // Set the rect of parent Ramp
1508         ramp.set_rect(20.0, 20.0, 410.0, 260.0);
1509 
1510         let options = vec![
1511             "Option 1".to_string(),
1512             "Option 2".to_string(),
1513             "Option 3".to_string(),
1514             "Option 4".to_string(),
1515             "Option 5".to_string(),
1516             "Option 6".to_string(),
1517         ];
1518         let mut dd = Dropdown::new(options, 0).with_label("Preset");
1519         dd.set_rect(30.0, 125.0, 110.0, 20.0);
1520 
1521         // Link the dropdown to the Ramp's read-data (the legacy direct-write path)
1522         dd.parent_snapshot = Some(ParentSnapshot {
1523             rect: crate::widget::WidgetHost::rect(&ramp),
1524             is_ramp: true,
1525             color: crate::widget::WidgetHost::color(&ramp),
1526         });
1527 
1528         // Compute geometry
1529         let (rx, ry, rw, rh) = dd.get_popover_geom();
1530 
1531         // Validate coordinates stay inside the parent Ramp bounds: x in [20, 430], y in [20, 280]
1532         assert!(rx >= 20.0, "rx {} should be >= 20.0", rx);
1533         assert!(rx + rw <= 430.0, "rx + rw {} should be <= 430.0", rx + rw);
1534         assert!(ry >= 20.0, "ry {} should be >= 20.0", ry);
1535         assert!(ry + rh <= 280.0, "ry + rh {} should be <= 280.0", ry + rh);
1536     }
1537 
1538     #[test]
1539     fn test_dropdown_label_fade_out() {
1540         let options = vec!["This is a very long option name that will exceed the dropdown width".to_string()];
1541         let mut dd = Dropdown::new(options, 0);
1542         dd.set_rect(10.0, 10.0, 100.0, 24.0); // very narrow dropdown
1543 
1544         let labels = dd.own_text_labels();
1545         // Labels are individual characters of selected_text, then the ▼ arrow (prim order).
1546         assert!(labels.len() > 2);
1547 
1548         // The last character label (excluding the arrow) should be faded (i.e. not the default color)
1549         let last_char_idx = labels.len() - 2;
1550         let first_char = &labels[0];
1551         let last_char = &labels[last_char_idx];
1552 
1553         let tc = colors::dropdown_text_color();
1554         let expected_color = [
1555             (colors::linear_to_srgb(tc[0]) * 255.0).round() as u8,
1556             (colors::linear_to_srgb(tc[1]) * 255.0).round() as u8,
1557             (colors::linear_to_srgb(tc[2]) * 255.0).round() as u8,
1558         ];
1559         assert_eq!(first_char.color, expected_color);
1560         assert_ne!(last_char.color, expected_color); // color has shifted towards background
1561     }
1562 
1563     #[test]
1564     fn test_dropdown_auto_width() {
1565         let dummy = crate::context::UiContext::new();
1566         let options = vec!["Short".to_string(), "A much longer option name".to_string()];
1567         let mut dd = Dropdown::new(options, 0).with_auto_width(true);
1568         dd.set_rect(10.0, 10.0, 50.0, 24.0);
1569 
1570         let size = dd.measure(LayoutConstraints::new(0.0, 500.0, 24.0, 24.0), &dummy);
1571         assert!(size.width > 50.0, "Measured auto-width {} should be greater than original width 50.0", size.width);
1572 
1573         let dd_no_auto = Dropdown::new(vec!["Short".to_string(), "A much longer option name".to_string()], 0);
1574         let size_no_auto = dd_no_auto.measure(LayoutConstraints::new(0.0, 500.0, 24.0, 24.0), &dummy);
1575         assert_eq!(size_no_auto.width, 0.0);
1576     }
1577 
1578     #[test]
1579     fn intrinsic_size_fits_widest_option() {
1580         let wide = Dropdown::new(
1581             vec!["Short".to_string(), "A much longer option name".to_string()],
1582             0,
1583         );
1584         let size = Layout::intrinsic_size(wide.inner()).expect("dropdown reports intrinsic size");
1585         assert!(size.width >= wide.content_width(), "width fits the widest option");
1586         assert_eq!(size.height, crate::layout::dropdown_height());
1587 
1588         let narrow = Dropdown::new(vec!["Hi".to_string()], 0);
1589         assert!(
1590             size.width > Layout::intrinsic_size(narrow.inner()).unwrap().width,
1591             "more/longer options measure wider",
1592         );
1593     }
1594 
1595     #[test]
1596     fn menu_button_trigger_fits_display_text_not_widest_option() {
1597         // A menu-button dropdown (fixed custom display text) sizes its trigger to that text, so a
1598         // long menu entry (e.g. a recent-file path) no longer stretches the "File" button.
1599         let menu = Dropdown::new(
1600             vec![
1601                 "New".to_string(),
1602                 "/home/user/some/very/long/recent/project/path".to_string(),
1603             ],
1604             0,
1605         )
1606         .with_custom_display_text("File");
1607 
1608         let trigger = Layout::intrinsic_size(menu.inner()).expect("dropdown reports intrinsic size");
1609         assert!(
1610             trigger.width < menu.inner().content_width(),
1611             "menu-button trigger ({}) fits its display text, not the widest option ({})",
1612             trigger.width,
1613             menu.inner().content_width(),
1614         );
1615 
1616         // The open popover still expands to the widest option.
1617         let content = Rect { x: 0.0, y: 0.0, width: trigger.width, height: trigger.height };
1618         assert!(
1619             menu.inner().popover_geom(content).2 >= menu.inner().content_width(),
1620             "popover still fits the widest option",
1621         );
1622 
1623         // The trigger leaves the paint pass's full budget (8px left pad + 28px right/arrow
1624         // reservation) for the label, so the display text renders without tripping the right-edge
1625         // fade. This is the paint condition `start_x + total_advance > right_limit` restated:
1626         // `content.x + 8 + advance > content.x + width - 28`, i.e. it must hold that
1627         // `width >= advance + 36`. Measured via `text_advance` — the same function the paint pass
1628         // lays out with — so the guarantee holds in a monospace UI font too.
1629         let (font_family, font_size) = crate::layout::control_label_font_detached_parsed();
1630         let advance = text_advance("File", &font_family, font_size);
1631         assert!(
1632             trigger.width >= advance + 36.0,
1633             "trigger width ({}) leaves room for the laid-out label (advance {} + 36px budget), \
1634              so it doesn't fade",
1635             trigger.width,
1636             advance,
1637         );
1638     }
1639 
1640     /// Migration additions: popover routing through the adapter (`WidgetHost::popover_rect` /
1641     /// `render_popover`), outside-press close, and Escape via routed key events.
1642     #[test]
1643     fn popover_reaches_hosts_through_the_adapter() {
1644         let mut dummy = crate::context::UiContext::new();
1645         let options = vec!["A".to_string(), "B".to_string()];
1646         let mut dd = Dropdown::new(options, 0);
1647         dd.set_rect(10.0, 10.0, 100.0, 24.0);
1648 
1649         assert!(WidgetHost::popover_rect(&dd).is_none(), "closed dropdown registers no popover");
1650 
1651         dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 20.0, &mut dummy);
1652         assert!(dd.open);
1653         // popover_rect reports the ANIMATED box — land the expansion first.
1654         dd.land_anim_for_test();
1655         let (rx, ry, rw, rh) = WidgetHost::popover_rect(&dd).expect("open dropdown registers its popover");
1656         // The box is the band, the rows, and the relief wall the plate needs
1657         // along its one outer edge (the bottom, for a downward menu) — without
1658         // that reserve the trough painted over the last row's descenders.
1659         let wall = dd.plate_inset(24.0);
1660         assert!(wall > 0.0, "the default relief styling carves the open plate");
1661         assert_eq!((rx, ry), (10.0, 10.0), "the unified surface starts at the trigger band");
1662         assert!(
1663             rw >= 100.0 && rh == 24.0 + 2.0 * 24.0 + wall,
1664             "trigger band (24) + menu (2 * 24) + the bottom wall as one box",
1665         );
1666         assert_eq!(
1667             dd.rows_top(Rect { x: 10.0, y: 10.0, width: 100.0, height: 24.0 }),
1668             34.0,
1669             "a downward menu's rows still start flush under the band",
1670         );
1671         let trigger = Rect { x: 10.0, y: 10.0, width: 100.0, height: 24.0 };
1672         assert_eq!(dd.row_at(trigger, 34.0 + 47.9), Some(1), "the last row is whole");
1673         assert_eq!(dd.row_at(trigger, 34.0 + 48.1), None, "the wall below it picks nothing");
1674 
1675         // An outside press closes it (ungated presses — `gates_presses` is false).
1676         let closed = dd.mouse_input(MouseButton::Left, ElementState::Pressed, 500.0, 500.0, &mut dummy);
1677         assert!(closed);
1678         assert!(dd.closing, "outside press starts the animated contraction");
1679         dd.land_anim_for_test();
1680         assert!(!dd.open);
1681         assert!(!dd.take_change(), "outside close does not report a change");
1682     }
1683 
1684     /// `with_menu_replaces_trigger`: the open surface is the rows alone, sat
1685     /// in the trigger's slot — no band, and the trigger stops painting once
1686     /// the menu covers it.
1687     #[test]
1688     fn menu_replaces_trigger_drops_the_band() {
1689         let mut dummy = crate::context::UiContext::new();
1690         let options = vec!["A".to_string(), "B".to_string(), "C".to_string()];
1691         let mut dd = Dropdown::new(options, 1)
1692             .with_open_upward(true)
1693             .with_menu_replaces_trigger(true);
1694         dd.set_rect(10.0, 300.0, 100.0, 24.0);
1695 
1696         dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 310.0, &mut dummy);
1697         assert!(dd.open);
1698         dd.land_anim_for_test();
1699         let (rx, ry, rw, rh) = WidgetHost::popover_rect(&dd).expect("open dropdown registers its popover");
1700         // No band, so BOTH edges are outer ones and both carry the wall.
1701         let wall = dd.plate_inset(24.0);
1702         assert!(
1703             (rh - (3.0 * 24.0 + 2.0 * wall)).abs() < 0.01,
1704             "three rows and nothing else — no trigger band: {rh}",
1705         );
1706         assert_eq!((rx, ry + rh), (10.0, 324.0), "the menu's bottom edge sits on the trigger's bottom edge");
1707         assert_eq!(
1708             dd.rows_top(Rect { x: 10.0, y: 300.0, width: 100.0, height: 24.0 }),
1709             ry + wall,
1710             "the rows start inside the wall",
1711         );
1712         assert!(rw >= 100.0);
1713         assert_eq!(dd.get_popover_geom(), (rx, ry, rw, rh), "the drawn box is the full menu once landed");
1714 
1715         // The trigger's slot is now the bottom row: a press there picks it,
1716         // rather than toggling the trigger closed.
1717         dd.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 312.0, &mut dummy);
1718         assert_eq!(dd.selected, 2);
1719         assert!(dd.take_change());
1720         assert!(dd.closing);
1721         dd.land_anim_for_test();
1722         assert!(!dd.open);
1723 
1724         // The default keeps the band.
1725         let mut plain = Dropdown::new(vec!["A".to_string(), "B".to_string()], 0).with_open_upward(true);
1726         plain.set_rect(10.0, 300.0, 100.0, 24.0);
1727         plain.mouse_input(MouseButton::Left, ElementState::Pressed, 50.0, 310.0, &mut dummy);
1728         plain.land_anim_for_test();
1729         let (_, ry, _, rh) = WidgetHost::popover_rect(&plain).unwrap();
1730         // Upward: the menu's TOP is the outer edge, so the wall goes there and
1731         // the rows still end flush on the band.
1732         let wall = plain.plate_inset(24.0);
1733         assert_eq!(
1734             (ry, rh),
1735             (252.0 - wall, 24.0 + 2.0 * 24.0 + wall),
1736             "band (24) + two rows (48) + the top wall, stacked above the trigger",
1737         );
1738         assert_eq!(
1739             plain.rows_top(Rect { x: 10.0, y: 300.0, width: 100.0, height: 24.0 }),
1740             252.0,
1741             "the rows end flush on the band",
1742         );
1743     }
1744 }