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 }