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

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

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