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