Skip to main content

frust_material/dropdown/
panel.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/dropdown_menus/components/` — `m3e_dropdown_menu_panel.dart`
3// (the panel body, the loading/error/empty states and the search field) and
4// `m3e_dropdown_menu_item.dart` (a row's box, its radius morph and its slots),
5// plus `m3e_dropdown_menu_actions.dart`'s `_onItemTap`/`_selectSingleItem`/
6// `_canSelectMultiItem` selection rules, retrieved 2026-08-20.
7// Upstream: <https://github.com/paadevelopments/material_3_expressive>
8// Porting decisions: the reference's `_wrapExpandAnimation` scale/opacity ramp
9// and its `Positioned.fill` outside-tap `Listener` are **not** ported — the
10// merged anchored host owns placement, the ramp and light dismiss (see
11// `super`'s header); rows are kept as **data** with cached shaped runs rather
12// than as a `ListView` of child widgets, so per-state ink stays expressible
13// (the shape `crate::menu::panel` established); and keyboard navigation is an
14// addition the reference has no counterpart for.
15
16//! The dropdown's option panel: the width-matched, scrollable list of options
17//! plus the search field and the loading/error/empty states that replace it.
18//!
19//! # Width comes from the trigger, not from the content
20//!
21//! The reference wraps its follower in `SizedBox(width: renderBox.size.width)`
22//! (`m3e_dropdown_menu_panel.dart:35`) — the panel is exactly as wide as the
23//! field, whatever the options measure. This port reads the same number from
24//! the [`OverlayAnchor`] the field captures into, in its own `layout`. A panel
25//! whose anchor has never been painted into (a degenerate `Rect::ZERO` cell)
26//! falls back to its natural content width, so a panel mounted before its
27//! field has painted is merely mis-sized for one frame rather than invisible.
28//!
29//! # What scrolls
30//!
31//! The search field is a **header**, outside the scrollable region — the
32//! reference's `Column` puts it above the `Flexible(ListView)`
33//! (`m3e_dropdown_menu_panel.dart:107`), so typing never scrolls out of reach.
34//! Only the option list scrolls, capped by
35//! [`DropdownPanelView::max_height`](super::DropdownView::max_height).
36//!
37//! # Selection is requested, never applied
38//!
39//! Every activation reports through `on_change` and the panel's own
40//! [`selected`](DropdownPanelView::selected) is left alone until the app feeds
41//! one back. The three rules are the reference's, kept exactly:
42//!
43//! * **single-select toggles** — `toggleOnly` flips the tapped item and clears
44//!   every other, so tapping the already-selected option **clears** the
45//!   selection rather than re-picking it, and the dropdown closes either way
46//!   (`_selectSingleItem`).
47//! * **multi-select toggles, capped on the way in only** — `_canSelectMultiItem`
48//!   lets a deselect through unconditionally and refuses a *new* selection once
49//!   `maxSelections` is reached.
50//! * **a disabled row reports nothing at all**, not even a close.
51//!
52//! # Keyboard
53//!
54//! Up/Down move the highlight over the selectable rows, Enter activates it, and
55//! Escape is deliberately left [`EventResult::Ignored`] so
56//! [`crate::overlay::anchored`] dismisses the whole overlay with it. The
57//! reference has no keyboard handling to port here; the gap this shares with
58//! every other overlay in the catalog is that the panel only receives key
59//! events once it holds focus, which it claims on a press inside itself and
60//! outside the search field (see [`super`]'s keyboard note).
61
62use frust::Theme;
63use frust::authoring::text::TextStyle;
64use frust::authoring::{
65    Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Color, CornerRadii,
66    CursorIcon, ErasedArgCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx, NamedKey,
67    PaintCtx, PaintScene, Point, PointerEvent, PointerPhase, Rect, Role, ScrollDelta, SemanticsCtx,
68    Size, TypedArgCallback, View, Widget, any, build_child, erase_callback_arg, rebuild_children,
69    route_event_single, teardown_child, visit_children,
70};
71use frust::input::WHEEL_LINE_PX;
72
73use super::{
74    DROPDOWN_CONTENT_PADDING, DROPDOWN_EMPTY_TEXT, DROPDOWN_ICON_GAP, DROPDOWN_ICON_SIZE,
75    DROPDOWN_ITEM_GAP, DROPDOWN_ITEM_H_PADDING, DROPDOWN_ITEM_HOVER_RADIUS,
76    DROPDOWN_ITEM_INNER_RADIUS, DROPDOWN_ITEM_OUTER_RADIUS, DROPDOWN_ITEM_PRESSED_RADIUS,
77    DROPDOWN_ITEM_V_PADDING, DROPDOWN_LOADING_PADDING, DROPDOWN_MESSAGE_PADDING,
78    DROPDOWN_PANEL_MAX_HEIGHT, DROPDOWN_SEARCH_HINT, DropdownColors, DropdownItem, Glyph, OnOpen,
79    OnQuery, Run, SHAPING_INK, container_radius, dropdown_filter, ordered_selection,
80};
81use crate::card_list::{card_position, card_radii};
82use crate::icons;
83use crate::interaction::InteractionState;
84use crate::overlay::{OverlayAnchor, OverlayElevation, shadow, with_alpha};
85use crate::press::presses;
86use crate::progress::{ProgressValue, circular_progress};
87use crate::text_field::text_field;
88
89/// Left/right and top/bottom margins around the search field
90/// (`M3EDropdownSearchStyle.margin`, `EdgeInsets.fromLTRB(12, 8, 12, 4)`).
91pub(super) const SEARCH_MARGIN_H: f64 = 12.0;
92/// Top margin of the search field (`margin`'s `top`).
93pub(super) const SEARCH_MARGIN_TOP: f64 = 8.0;
94/// Bottom margin of the search field (`margin`'s `bottom`).
95pub(super) const SEARCH_MARGIN_BOTTOM: f64 = 4.0;
96/// The search field's own decorated height — [`crate::text_field`]'s fixed
97/// decoration box, which is what a mounted field measures.
98pub(super) const SEARCH_FIELD_HEIGHT: f64 = 56.0;
99
100/// One rendered option row: its resolved state, its shaped content, and the
101/// vertical band it occupies in the list's own content space.
102struct Row {
103    /// The option's value — the identity a selection is expressed in.
104    value: String,
105    enabled: bool,
106    /// Resolved from the app-confirmed selection at build time.
107    selected: bool,
108    label: Run,
109    /// The trailing check, present only while selected.
110    check: Option<Glyph>,
111    /// Top of the row's band, in content space.
112    top: f64,
113    /// The band's full height (the item box plus [`DROPDOWN_ITEM_GAP`]).
114    height: f64,
115}
116
117/// What the panel shows where the option list would be.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum Body {
120    /// The option rows (`_buildPanelBody`'s `ListView.separated`).
121    Rows,
122    /// The centered busy indicator.
123    Loading,
124    /// A load failure's message.
125    Error,
126    /// The no-matches message.
127    Empty,
128}
129
130/// A declarative dropdown option panel. See [`dropdown_panel`].
131pub struct DropdownPanelView<State: 'static> {
132    items: Vec<DropdownItem>,
133    selected: Vec<String>,
134    multi: bool,
135    max_selections: usize,
136    query: String,
137    searchable: bool,
138    loading: bool,
139    error: Option<String>,
140    empty_text: String,
141    max_height: f64,
142    open: bool,
143    anchor: OverlayAnchor,
144    on_change: TypedArgCallback<State, Vec<String>>,
145    on_open: Option<OnOpen<State>>,
146    on_query: Option<OnQuery<State>>,
147}
148
149/// Build a dropdown option panel over `items` — the list surface, without any
150/// anchoring or dismissal of its own.
151///
152/// This is what [`dropdown`](super::dropdown) mounts inside
153/// [`crate::overlay::anchored`]; an app building an ordinary dropdown wants
154/// that instead. `on_change(state, values)` reports the selection an activation
155/// requests, normalized into item order.
156pub fn dropdown_panel<State: 'static, F: Fn(&mut State, Vec<String>) + 'static>(
157    items: Vec<DropdownItem>,
158    on_change: F,
159) -> DropdownPanelView<State> {
160    DropdownPanelView {
161        items,
162        selected: Vec::new(),
163        multi: false,
164        max_selections: 0,
165        query: String::new(),
166        searchable: false,
167        loading: false,
168        error: None,
169        empty_text: DROPDOWN_EMPTY_TEXT.to_string(),
170        max_height: DROPDOWN_PANEL_MAX_HEIGHT,
171        open: true,
172        anchor: OverlayAnchor::new(),
173        on_change: std::rc::Rc::new(on_change),
174        on_open: None,
175        on_query: None,
176    }
177}
178
179impl<State: 'static> DropdownPanelView<State> {
180    /// Point the panel at the trigger's captured rect — what width-matches it
181    /// to the field (see the [module docs](self)).
182    pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
183        self.anchor = anchor.clone();
184        self
185    }
186
187    /// Hand the panel the app-confirmed selection (option values).
188    pub fn selected(mut self, selected: Vec<String>) -> Self {
189        self.selected = selected;
190        self
191    }
192
193    /// Allow more than one option at a time.
194    pub fn multi(mut self, multi: bool) -> Self {
195        self.multi = multi;
196        self
197    }
198
199    /// Cap a multi-select at `max` options; `0` is unlimited.
200    pub fn max_selections(mut self, max: usize) -> Self {
201        self.max_selections = max;
202        self
203    }
204
205    /// Hand the panel the app-confirmed filter query.
206    pub fn query(mut self, query: impl Into<String>) -> Self {
207        self.query = query.into();
208        self
209    }
210
211    /// Show the search field above the option list.
212    pub fn searchable(mut self, searchable: bool) -> Self {
213        self.searchable = searchable;
214        self
215    }
216
217    /// Show the busy row instead of the option list.
218    pub fn loading(mut self, loading: bool) -> Self {
219        self.loading = loading;
220        self
221    }
222
223    /// Show a load failure instead of the option list.
224    pub fn error(mut self, error: Option<String>) -> Self {
225        self.error = error;
226        self
227    }
228
229    /// Replace the empty-state message.
230    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
231        self.empty_text = text.into();
232        self
233    }
234
235    /// Cap the panel's height, past which the option list scrolls.
236    pub fn max_height(mut self, max_height: f64) -> Self {
237        self.max_height = max_height;
238        self
239    }
240
241    /// Tell the panel whether its host is open. `false` clears the keyboard
242    /// highlight and the press machine on the spot; the default is `true`.
243    pub fn open(mut self, open: bool) -> Self {
244        self.open = open;
245        self
246    }
247
248    /// Set the open callback — fired with `false` after a single-select pick.
249    pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
250        self.on_open = Some(std::rc::Rc::new(on_open));
251        self
252    }
253
254    /// Set the query callback: each edit of the search field reports the text
255    /// it requests.
256    pub fn on_query<F: Fn(&mut State, String) + 'static>(mut self, on_query: F) -> Self {
257        self.on_query = Some(std::rc::Rc::new(on_query));
258        self
259    }
260
261    /// The rows this view's props render as — computed identically by `build`
262    /// and `rebuild`, so the row list can never drift from the props.
263    fn build_rows(&self) -> Vec<Row> {
264        dropdown_filter(&self.items, &self.query)
265            .into_iter()
266            .map(|item| {
267                let selected = self.selected.iter().any(|v| v == item.value());
268                Row {
269                    value: item.value().to_string(),
270                    enabled: !item.is_disabled(),
271                    selected,
272                    label: Run::new(item.label()),
273                    // The reference draws the check only while selected
274                    // (`m3e_dropdown_menu_item.dart:157`).
275                    check: selected.then(|| Glyph::new(icons::CHECK_ROUNDED, DROPDOWN_ICON_SIZE)),
276                    top: 0.0,
277                    height: 0.0,
278                }
279            })
280            .collect()
281    }
282
283    /// Which body the current props render (`_buildPanelBody`'s own order:
284    /// loading, then error, then empty, then the rows).
285    fn body(&self, rows: &[Row]) -> Body {
286        if self.loading {
287            Body::Loading
288        } else if self.error.is_some() {
289            Body::Error
290        } else if rows.is_empty() {
291            Body::Empty
292        } else {
293            Body::Rows
294        }
295    }
296
297    /// The search field's view, in a zero-or-one list so a `searchable` flip
298    /// reconciles as an ordinary child-list length change.
299    fn search_views(&self) -> Vec<AnyView<State>> {
300        if !self.searchable {
301            return Vec::new();
302        }
303        let on_query = self.on_query.clone();
304        vec![any(text_field(
305            self.query.clone(),
306            move |state: &mut State, text| {
307                if let Some(on_query) = &on_query {
308                    on_query(state, text);
309                }
310            },
311        )
312        .label(DROPDOWN_SEARCH_HINT))]
313    }
314
315    /// The busy indicator's view, in a zero-or-one list on the same rule.
316    fn loading_views(&self) -> Vec<AnyView<State>> {
317        if !self.loading {
318            return Vec::new();
319        }
320        vec![any(circular_progress(ProgressValue::Indeterminate))]
321    }
322
323    /// The message a non-row body paints: the load failure when there is one,
324    /// the empty-state line otherwise.
325    fn message(&self) -> String {
326        self.error
327            .clone()
328            .unwrap_or_else(|| self.empty_text.clone())
329    }
330}
331
332/// The retained widget for a [`DropdownPanelView`].
333pub struct DropdownPanelWidget {
334    /// Every option, filtered or not — the order a reported selection is
335    /// normalized into.
336    items: Vec<DropdownItem>,
337    rows: Vec<Row>,
338    selected: Vec<String>,
339    multi: bool,
340    max_selections: usize,
341    body: Body,
342    /// The empty/error line, shaped lazily like every other run.
343    message: Run,
344    /// Whether [`Self::message`] is an error rather than the empty state.
345    message_is_error: bool,
346    /// The search field's pod, zero or one.
347    search: Vec<ChildPod>,
348    /// The busy indicator's pod, zero or one.
349    busy: Vec<ChildPod>,
350    max_height: f64,
351    open: bool,
352    anchor: OverlayAnchor,
353    on_change: ErasedArgCallback<Vec<String>>,
354    on_open: Option<ErasedArgCallback<bool>>,
355    /// The latched hovered row (the hover-flag half of the catalog's three-part
356    /// hover seam; `paint` self-corrects it from `PaintCtx::is_hovered`).
357    hovered: Option<usize>,
358    /// The keyboard highlight — independent of `hovered`, since a pointer and
359    /// the arrow keys can disagree.
360    highlighted: Option<usize>,
361    /// The row a live press is armed on.
362    pressed: Option<usize>,
363    /// Whether that press is currently *inside* its own row.
364    press_inside: bool,
365    captured: bool,
366    scroll: f64,
367    /// Top of the scrollable list, in the panel's own space.
368    body_top: f64,
369    /// The scrollable list's own height.
370    viewport: f64,
371    content_height: f64,
372    width: f64,
373    height: f64,
374}
375
376impl DropdownPanelWidget {
377    /// The panel's resolved width, in logical px.
378    pub fn width(&self) -> f64 {
379        self.width
380    }
381
382    /// The panel's resolved height, in logical px.
383    pub fn height(&self) -> f64 {
384        self.height
385    }
386
387    /// The panel's scroll offset, in logical px.
388    pub fn scroll(&self) -> f64 {
389        self.scroll
390    }
391
392    /// The keyboard-highlighted row, if any.
393    pub fn highlighted(&self) -> Option<usize> {
394        self.highlighted
395    }
396
397    /// The `(top, height)` band row `row` occupies in the list's content space.
398    #[cfg(test)]
399    fn band(&self, row: usize) -> (f64, f64) {
400        let row = &self.rows[row];
401        (row.top, row.height)
402    }
403
404    /// Clear every transient interaction record — what a closing panel and a
405    /// row-list replacement both do.
406    fn reset_interaction(&mut self) {
407        self.hovered = None;
408        self.highlighted = None;
409        self.pressed = None;
410        self.press_inside = false;
411        self.captured = false;
412    }
413
414    /// Keep the scroll offset inside the scrollable range.
415    fn clamp_scroll(&mut self) {
416        let max = (self.content_height - self.viewport).max(0.0);
417        self.scroll = self.scroll.clamp(0.0, max);
418    }
419
420    /// The search field's placed box, in the panel's own space — `None` when
421    /// the panel is not [`searchable`](DropdownPanelView::searchable).
422    pub fn search_rect(&self) -> Option<Rect> {
423        let pod = self.search.first()?;
424        Some(Rect::from_origin_size(pod.origin(), pod.size()))
425    }
426
427    /// The row under panel-local `pos`, if any. Only the scrollable list is
428    /// hit-tested; the header band above it belongs to the search field.
429    fn row_at(&self, pos: Point) -> Option<usize> {
430        if self.body != Body::Rows
431            || pos.x < 0.0
432            || pos.x >= self.width
433            || pos.y < self.body_top
434            || pos.y >= self.body_top + self.viewport
435        {
436            return None;
437        }
438        let y = pos.y - self.body_top + self.scroll;
439        self.rows
440            .iter()
441            .position(|row| y >= row.top && y < row.top + row.height)
442    }
443
444    /// Whether row `i` can be activated at all.
445    fn activatable(&self, i: usize) -> bool {
446        self.rows.get(i).is_some_and(|row| row.enabled)
447    }
448
449    /// The selection row `i`'s activation requests, or `None` when the rules
450    /// refuse it (`_onItemTap`'s three branches).
451    fn requested_selection(&self, i: usize) -> Option<Vec<String>> {
452        let row = self.rows.get(i)?;
453        if !row.enabled {
454            return None;
455        }
456        let already = self.selected.iter().any(|v| v == &row.value);
457        if !self.multi {
458            // `toggleOnly`: the tapped item flips, every other clears — so
459            // tapping the selected option clears the selection outright.
460            return Some(if already {
461                Vec::new()
462            } else {
463                vec![row.value.clone()]
464            });
465        }
466        if already {
467            // A deselect is never capped (`_canSelectMultiItem`'s first
468            // branch).
469            let kept: Vec<String> = self
470                .selected
471                .iter()
472                .filter(|v| *v != &row.value)
473                .cloned()
474                .collect();
475            return Some(ordered_selection(&self.items, &kept));
476        }
477        if self.max_selections > 0 && self.selected.len() >= self.max_selections {
478            return None;
479        }
480        let mut next = self.selected.clone();
481        next.push(row.value.clone());
482        Some(ordered_selection(&self.items, &next))
483    }
484
485    /// Report row `i`'s activation.
486    fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
487        let Some(next) = self.requested_selection(i) else {
488            return;
489        };
490        (self.on_change)(ctx, next);
491        // The reference closes after a single-select pick and leaves a
492        // multi-select open (`_selectSingleItem` vs. `_onItemTap`'s tail).
493        if !self.multi
494            && let Some(on_open) = self.on_open.as_mut()
495        {
496            on_open(ctx, false);
497        }
498    }
499
500    /// Move the highlight `step` rows, skipping disabled ones and stopping at
501    /// the ends rather than wrapping.
502    fn move_highlight(&mut self, step: isize) -> bool {
503        if self.body != Body::Rows || self.rows.is_empty() {
504            return false;
505        }
506        let len = self.rows.len() as isize;
507        let mut index = match self.highlighted {
508            Some(current) => current as isize,
509            // A first Down enters at the top, a first Up at the bottom.
510            None => {
511                if step > 0 {
512                    -1
513                } else {
514                    len
515                }
516            }
517        };
518        loop {
519            index += step;
520            if index < 0 || index >= len {
521                return false;
522            }
523            if self.rows[index as usize].enabled {
524                self.highlighted = Some(index as usize);
525                self.reveal(index as usize);
526                return true;
527            }
528        }
529    }
530
531    /// Scroll row `i` into the list's viewport.
532    fn reveal(&mut self, i: usize) {
533        let Some(row) = self.rows.get(i) else {
534            return;
535        };
536        let (top, bottom) = (row.top, row.top + row.height);
537        if top < self.scroll {
538            self.scroll = top;
539        } else if bottom > self.scroll + self.viewport {
540            self.scroll = bottom - self.viewport;
541        }
542        self.clamp_scroll();
543    }
544
545    /// The `Widget::event` key arm. Escape is deliberately left for the host.
546    fn handle_key(&mut self, ctx: &mut EventCtx, key: &Key) -> EventResult {
547        match key {
548            Key::Named(NamedKey::ArrowDown) => {
549                if self.move_highlight(1) {
550                    ctx.request_redraw();
551                }
552                EventResult::Handled
553            }
554            Key::Named(NamedKey::ArrowUp) => {
555                if self.move_highlight(-1) {
556                    ctx.request_redraw();
557                }
558                EventResult::Handled
559            }
560            Key::Named(NamedKey::Enter) => {
561                let Some(i) = self.highlighted.filter(|i| self.activatable(*i)) else {
562                    return EventResult::Ignored;
563                };
564                self.activate(ctx, i);
565                ctx.request_redraw();
566                EventResult::Handled
567            }
568            _ => EventResult::Ignored,
569        }
570    }
571
572    /// The `Widget::event` pointer arm: hover latching, press arming, and
573    /// release-to-activate.
574    fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
575        match p.phase {
576            PointerPhase::Move if !self.captured => {
577                let row = self.row_at(p.position);
578                // Claimed from the uncaptured move arm on every qualifying
579                // pass, per the catalog's hover seam.
580                if row.is_some() {
581                    ctx.claim_hover();
582                }
583                let hovered = row.filter(|i| self.activatable(*i));
584                if hovered.is_some() {
585                    ctx.set_cursor(CursorIcon::Pointer);
586                }
587                if self.hovered != hovered {
588                    self.hovered = hovered;
589                    ctx.request_redraw();
590                }
591                EventResult::Ignored
592            }
593            PointerPhase::Move => {
594                // Captured: the pressed wash follows the pointer in and out of
595                // the armed row without ending the gesture.
596                let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
597                if self.press_inside != inside {
598                    self.press_inside = inside;
599                    ctx.request_redraw();
600                }
601                EventResult::Handled
602            }
603            PointerPhase::Down => {
604                if !presses(p) {
605                    return EventResult::Ignored;
606                }
607                // Claimed so the arrow keys have somewhere to route; a press
608                // that lands in the search field has already been routed there
609                // and never reaches this arm.
610                ctx.request_focus();
611                let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
612                    // The panel's own background: left for the host, which
613                    // swallows a press inside its content without dismissing.
614                    return EventResult::Ignored;
615                };
616                self.pressed = Some(row);
617                self.press_inside = true;
618                self.captured = true;
619                ctx.capture_pointer();
620                ctx.request_redraw();
621                EventResult::Handled
622            }
623            PointerPhase::Up => {
624                if !self.captured {
625                    return EventResult::Ignored;
626                }
627                let released = self.row_at(p.position);
628                let armed = self.pressed;
629                self.pressed = None;
630                self.press_inside = false;
631                self.captured = false;
632                // Fire on up-inside only, the catalog's press contract.
633                if let Some(row) = armed.filter(|row| released == Some(*row)) {
634                    self.activate(ctx, row);
635                }
636                ctx.request_redraw();
637                EventResult::Handled
638            }
639            // A `Cancel` arm touches no app state — only the flags the next
640            // move re-establishes.
641            PointerPhase::Cancel => {
642                if !self.captured {
643                    return EventResult::Ignored;
644                }
645                self.pressed = None;
646                self.press_inside = false;
647                self.captured = false;
648                ctx.request_redraw();
649                EventResult::Handled
650            }
651        }
652    }
653
654    /// The `Widget::event` scroll arm: the same `offset + dy` wheel convention
655    /// every scrollable in this catalog shares, clamped to the content range.
656    fn handle_scroll(
657        &mut self,
658        ctx: &mut EventCtx,
659        position: Point,
660        delta: &ScrollDelta,
661    ) -> EventResult {
662        if position.y < self.body_top || position.y >= self.body_top + self.viewport {
663            return EventResult::Ignored;
664        }
665        let dy = match delta {
666            ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
667            ScrollDelta::Pixels(_, y) => *y,
668        };
669        let before = self.scroll;
670        self.scroll += dy;
671        self.clamp_scroll();
672        if (self.scroll - before).abs() <= f64::EPSILON {
673            return EventResult::Ignored;
674        }
675        ctx.request_redraw();
676        EventResult::Handled
677    }
678}
679
680impl<State: 'static> View<State> for DropdownPanelView<State> {
681    type Element = DropdownPanelWidget;
682
683    fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownPanelWidget {
684        let rows = self.build_rows();
685        let body = self.body(&rows);
686        DropdownPanelWidget {
687            items: self.items.clone(),
688            rows,
689            selected: self.selected.clone(),
690            multi: self.multi,
691            max_selections: self.max_selections,
692            body,
693            message: Run::new(self.message()),
694            message_is_error: self.error.is_some(),
695            search: self
696                .search_views()
697                .iter()
698                .map(|view| build_child(view, ctx))
699                .collect(),
700            busy: self
701                .loading_views()
702                .iter()
703                .map(|view| build_child(view, ctx))
704                .collect(),
705            max_height: self.max_height,
706            open: self.open,
707            anchor: self.anchor.clone(),
708            on_change: erase_callback_arg(&self.on_change),
709            on_open: self.on_open.as_ref().map(erase_callback_arg),
710            hovered: None,
711            highlighted: None,
712            pressed: None,
713            press_inside: false,
714            captured: false,
715            scroll: 0.0,
716            body_top: 0.0,
717            viewport: 0.0,
718            content_height: 0.0,
719            width: 0.0,
720            height: 0.0,
721        }
722    }
723
724    fn rebuild(
725        &self,
726        prev: &Self,
727        element: &mut DropdownPanelWidget,
728        ctx: &mut BuildCtx<'_>,
729    ) -> ChangeFlags {
730        let mut flags = rebuild_children(
731            &prev.search_views(),
732            &self.search_views(),
733            &mut element.search,
734            ctx,
735            |view: &AnyView<State>| view,
736            |_| None,
737        );
738        flags |= rebuild_children(
739            &prev.loading_views(),
740            &self.loading_views(),
741            &mut element.busy,
742            ctx,
743            |view: &AnyView<State>| view,
744            |_| None,
745        );
746        // Rows carry shaped runs and parsed icon paths, so they are rebuilt
747        // only on a real content change — a rebuild that re-supplies the same
748        // props keeps every cached layout.
749        let rows_changed =
750            prev.items != self.items || prev.selected != self.selected || prev.query != self.query;
751        if rows_changed {
752            element.rows = self.build_rows();
753            element.reset_interaction();
754            element.scroll = 0.0;
755            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
756        }
757        let body = self.body(&element.rows);
758        if element.body != body {
759            element.body = body;
760            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
761        }
762        if prev.error != self.error || prev.empty_text != self.empty_text {
763            element.message = Run::new(self.message());
764            element.message_is_error = self.error.is_some();
765            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
766        }
767        if element.max_height != self.max_height {
768            element.max_height = self.max_height;
769            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
770        }
771        if element.open != self.open {
772            element.open = self.open;
773            if !self.open {
774                element.reset_interaction();
775                flags |= ChangeFlags::PAINT;
776            }
777        }
778        element.items = self.items.clone();
779        element.selected = self.selected.clone();
780        element.multi = self.multi;
781        element.max_selections = self.max_selections;
782        element.anchor = self.anchor.clone();
783        // Closures aren't comparable, so the adapters are reinstalled
784        // unconditionally — cheap, and what every interactive widget does.
785        element.on_change = erase_callback_arg(&self.on_change);
786        element.on_open = self.on_open.as_ref().map(erase_callback_arg);
787        flags
788    }
789
790    fn teardown(&self, element: &mut DropdownPanelWidget, ctx: &mut BuildCtx<'_>) {
791        for (view, pod) in self.search_views().iter().zip(element.search.iter_mut()) {
792            teardown_child(view, pod, ctx);
793        }
794        for (view, pod) in self.loading_views().iter().zip(element.busy.iter_mut()) {
795            teardown_child(view, pod, ctx);
796        }
797    }
798}
799
800/// Everything a paint pass resolves from the theme once, then hands down to the
801/// panel and each row — so a single pass reads the theme exactly once and no
802/// two rows can disagree about a role or a radius.
803struct Chrome {
804    colors: DropdownColors,
805    /// The level-2 elevation rung's `(blur, y_offset, color)`, if any.
806    shadow: Option<(f64, f64, Color)>,
807    container_radius: f64,
808}
809
810/// The type styles a panel's two text roles resolve to: `bodyLarge` for a row
811/// label (`m3e_dropdown_menu_theme.dart:117`), `bodyMedium` for the empty and
812/// error lines (`m3e_dropdown_menu_panel.dart:153`).
813struct Styles {
814    label: TextStyle,
815    message: TextStyle,
816}
817
818impl Styles {
819    /// Resolve both roles from `theme`, or from the M3 token literals when no
820    /// theme is threaded into the pass.
821    fn resolve(theme: Option<&Theme>) -> Self {
822        let (mut label, mut message) = match theme {
823            Some(theme) => (
824                theme.type_scale.body_large.clone(),
825                theme.type_scale.body_medium.clone(),
826            ),
827            None => (
828                TextStyle::new(16.0, SHAPING_INK),
829                TextStyle::new(14.0, SHAPING_INK),
830            ),
831        };
832        label.color = SHAPING_INK;
833        message.color = SHAPING_INK;
834        Styles { label, message }
835    }
836}
837
838impl Widget for DropdownPanelWidget {
839    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
840        let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
841        let cap = if bc.max().width.is_finite() {
842            bc.max().width
843        } else {
844            f64::INFINITY
845        };
846
847        // Width comes from the trigger, never from the content — the
848        // reference's `SizedBox(width: renderBox.size.width)`. A never-painted
849        // anchor falls back to the natural row width (see the module docs).
850        let anchor_width = self.anchor.rect().width();
851        self.width = if anchor_width > 0.0 {
852            anchor_width
853        } else {
854            let mut natural: f64 = 0.0;
855            for row in self.rows.iter_mut() {
856                let mut width = row.label.natural_width(ctx, &styles.label);
857                width += 2.0 * DROPDOWN_ITEM_H_PADDING;
858                if row.check.is_some() {
859                    width += DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
860                }
861                natural = natural.max(width);
862            }
863            natural + 2.0 * DROPDOWN_CONTENT_PADDING
864        };
865        if cap.is_finite() {
866            self.width = self.width.min(cap);
867        }
868
869        // The header: the search field, laid out across the panel's width
870        // inside its own margins.
871        let mut header = 0.0;
872        if let Some(pod) = self.search.first_mut() {
873            let width = (self.width - 2.0 * SEARCH_MARGIN_H).max(0.0);
874            pod.layout_child(
875                ctx,
876                &BoxConstraints::tight(Size::new(width, SEARCH_FIELD_HEIGHT)),
877            );
878            pod.set_origin(Point::new(SEARCH_MARGIN_H, SEARCH_MARGIN_TOP));
879            header = SEARCH_MARGIN_TOP + SEARCH_FIELD_HEIGHT + SEARCH_MARGIN_BOTTOM;
880        }
881        self.body_top = header + DROPDOWN_CONTENT_PADDING;
882
883        // The body.
884        let inner = (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0);
885        self.content_height = match self.body {
886            Body::Rows => {
887                let text_width = |row: &Row| {
888                    let mut width = inner - 2.0 * DROPDOWN_ITEM_H_PADDING;
889                    if row.check.is_some() {
890                        width -= DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
891                    }
892                    width.max(0.0)
893                };
894                let mut y = 0.0;
895                for i in 0..self.rows.len() {
896                    if i > 0 {
897                        y += DROPDOWN_ITEM_GAP;
898                    }
899                    let width = text_width(&self.rows[i]);
900                    let label = self.rows[i].label.shape(ctx, &styles.label, width).height;
901                    let mut height = label + 2.0 * DROPDOWN_ITEM_V_PADDING;
902                    if self.rows[i].check.is_some() {
903                        height = height.max(DROPDOWN_ICON_SIZE + 2.0 * DROPDOWN_ITEM_V_PADDING);
904                    }
905                    self.rows[i].top = y;
906                    self.rows[i].height = height;
907                    y += height;
908                }
909                y
910            }
911            Body::Loading => {
912                let diameter = self.busy.first_mut().map_or(0.0, |pod| {
913                    let size =
914                        pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, inner)));
915                    size.height
916                });
917                diameter + 2.0 * DROPDOWN_LOADING_PADDING
918            }
919            Body::Error | Body::Empty => {
920                let width = (inner - 2.0 * DROPDOWN_MESSAGE_PADDING).max(0.0);
921                let height = self.message.shape(ctx, &styles.message, width).height;
922                height + 2.0 * DROPDOWN_MESSAGE_PADDING
923            }
924        };
925
926        let natural = self.body_top + self.content_height + DROPDOWN_CONTENT_PADDING;
927        let mut cap_height = self.max_height;
928        if bc.max().height.is_finite() {
929            cap_height = cap_height.min(bc.max().height);
930        }
931        self.height = natural.min(cap_height);
932        self.viewport = (self.height - self.body_top - DROPDOWN_CONTENT_PADDING).max(0.0);
933        self.clamp_scroll();
934
935        // The busy indicator is centered in whatever the body has left — read
936        // after the viewport is resolved, which is why it is placed here rather
937        // than in the `Body::Loading` arm that laid it out.
938        let (width, body_top, viewport) = (self.width, self.body_top, self.viewport);
939        if let Some(pod) = self.busy.first_mut() {
940            let size = pod.size();
941            pod.set_origin(Point::new(
942                (width - size.width) / 2.0,
943                body_top + (viewport - size.height) / 2.0,
944            ));
945        }
946
947        bc.constrain(Size::new(self.width, self.height))
948    }
949
950    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
951        // Authoritative hover read: a pointer that left this panel sends it
952        // nothing, so the latched row is cleared from path membership.
953        if !ctx.is_hovered() {
954            self.hovered = None;
955        }
956        let theme = Theme::from_paint_ctx(ctx);
957        let chrome = Chrome {
958            colors: DropdownColors::resolve(theme),
959            // The M3 menu rung, which the overlay seam already names — the
960            // reference's `elevation: 3` / `M3EElevation.level2`.
961            shadow: shadow(theme, OverlayElevation::Level2),
962            container_radius: container_radius(theme),
963        };
964        let origin = ctx.origin();
965        let size = Size::new(self.width, self.height);
966
967        if let Some((blur, y_offset, color)) = chrome.shadow {
968            scene.draw_shadow(
969                Point::new(origin.x, origin.y + y_offset),
970                size,
971                chrome.container_radius,
972                blur,
973                color,
974            );
975        }
976        scene.fill_rounded_rect(
977            origin,
978            size,
979            chrome.container_radius,
980            chrome.colors.panel_container,
981        );
982
983        if let Some(pod) = self.search.first_mut() {
984            pod.paint_child(ctx, scene);
985        }
986
987        match self.body {
988            Body::Rows => {
989                // Only a list that actually scrolls clips.
990                let clipped = self.content_height > self.viewport + f64::EPSILON;
991                if clipped {
992                    scene.push_clip(
993                        Point::new(origin.x, origin.y + self.body_top),
994                        Size::new(self.width, self.viewport),
995                    );
996                }
997                for i in 0..self.rows.len() {
998                    self.paint_row(i, origin, &chrome, scene);
999                }
1000                if clipped {
1001                    scene.pop_clip();
1002                }
1003            }
1004            Body::Loading => {
1005                if let Some(pod) = self.busy.first_mut() {
1006                    pod.paint_child(ctx, scene);
1007                }
1008            }
1009            Body::Error | Body::Empty => {
1010                let ink = if self.message_is_error {
1011                    chrome.colors.error
1012                } else {
1013                    chrome.colors.empty
1014                };
1015                self.message.paint(
1016                    Point::new(
1017                        origin.x + DROPDOWN_CONTENT_PADDING + DROPDOWN_MESSAGE_PADDING,
1018                        origin.y + self.body_top + DROPDOWN_MESSAGE_PADDING,
1019                    ),
1020                    ink,
1021                    scene,
1022                );
1023            }
1024        }
1025    }
1026
1027    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1028        if event.is_broadcast() {
1029            for pod in self.search.iter_mut().chain(self.busy.iter_mut()) {
1030                pod.event_child(ctx, event);
1031            }
1032            return EventResult::Ignored;
1033        }
1034        if !self.open {
1035            return EventResult::Ignored;
1036        }
1037        if let InputEvent::Key(key) = event {
1038            // The navigation keys are the panel's; everything else (typing,
1039            // caret motion inside the search field) falls through to the
1040            // focused pod below.
1041            if self.handle_key(ctx, &key.key) == EventResult::Handled {
1042                return EventResult::Handled;
1043            }
1044            if let Some(pod) = self.search.first_mut() {
1045                return route_event_single(pod, ctx, event);
1046            }
1047            return EventResult::Ignored;
1048        }
1049        // The search field owns any pointer landing inside it.
1050        if let Some(pod) = self.search.first_mut()
1051            && (pod.is_active() || pod.contains(event.position()))
1052            && route_event_single(pod, ctx, event) == EventResult::Handled
1053        {
1054            return EventResult::Handled;
1055        }
1056        match event {
1057            InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
1058            InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
1059            _ => EventResult::Ignored,
1060        }
1061    }
1062
1063    fn semantics(&self, ctx: &mut SemanticsCtx) {
1064        let rows = &self.rows;
1065        let search = &self.search;
1066        ctx.push_container(
1067            Role::ListBox,
1068            |_node| {},
1069            |ctx| {
1070                if let Some(pod) = search.first() {
1071                    pod.semantics_child(ctx);
1072                }
1073                for row in rows.iter() {
1074                    // Per-row bounds would need a `ChildPod` per row to descend
1075                    // through; rows are painted internally, so every node below
1076                    // shares the whole panel's bounds — the same v1 limitation
1077                    // `crate::menu`'s own panel accepts.
1078                    ctx.push_node(Role::ListBoxOption, |node| {
1079                        node.set_label(row.label.content.as_str());
1080                        node.set_selected(row.selected);
1081                        if row.enabled {
1082                            node.add_action(Action::Click);
1083                        }
1084                    });
1085                }
1086            },
1087        );
1088    }
1089
1090    visit_children!(search, busy);
1091}
1092
1093impl DropdownPanelWidget {
1094    /// The corner radii row `i` takes, resolved through the card-list treatment
1095    /// the reference's own item style points at (`_buildEffectiveRadius` +
1096    /// `_calculateBaseRadius`).
1097    fn row_radii(&self, i: usize) -> CornerRadii {
1098        let row = &self.rows[i];
1099        let pressed = self.pressed == Some(i) && self.press_inside;
1100        let hovered = self.hovered == Some(i) || self.highlighted == Some(i);
1101        let current = if pressed {
1102            DROPDOWN_ITEM_PRESSED_RADIUS
1103        } else if hovered {
1104            DROPDOWN_ITEM_HOVER_RADIUS
1105        } else if row.selected {
1106            DROPDOWN_ITEM_OUTER_RADIUS
1107        } else {
1108            DROPDOWN_ITEM_INNER_RADIUS
1109        };
1110        if row.selected {
1111            // The reference's first branch short-circuits: a selected row loses
1112            // its cap corners entirely.
1113            return CornerRadii::uniform(current);
1114        }
1115        card_radii(
1116            card_position(i, self.rows.len()),
1117            DROPDOWN_ITEM_OUTER_RADIUS,
1118            current,
1119        )
1120    }
1121
1122    /// Paint row `i`: its card, its state layer, its label and its check.
1123    fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1124        let row = &self.rows[i];
1125        let colors = &chrome.colors;
1126        let box_origin = Point::new(
1127            origin.x + DROPDOWN_CONTENT_PADDING,
1128            origin.y + self.body_top + row.top - self.scroll,
1129        );
1130        let box_size = Size::new(
1131            (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0),
1132            row.height,
1133        );
1134        let radii = self.row_radii(i);
1135        scene.fill_rounded_rect_radii(
1136            box_origin,
1137            box_size,
1138            radii,
1139            colors.item_background(row.enabled, row.selected),
1140        );
1141
1142        let mut state = InteractionState::new();
1143        state.hovered = self.hovered == Some(i);
1144        state.focused = self.highlighted == Some(i);
1145        state.pressed = self.pressed == Some(i) && self.press_inside;
1146        let opacity = if row.enabled {
1147            state.resolve_opacity()
1148        } else {
1149            0.0
1150        };
1151        if opacity > 0.0 {
1152            scene.fill_rounded_rect_radii(
1153                box_origin,
1154                box_size,
1155                radii,
1156                with_alpha(colors.item_foreground(row.enabled, row.selected), opacity),
1157            );
1158        }
1159
1160        let ink = colors.item_foreground(row.enabled, row.selected);
1161        let middle = box_origin.y + box_size.height / 2.0;
1162        let label_size = row.label.size();
1163        row.label.paint(
1164            Point::new(
1165                box_origin.x + DROPDOWN_ITEM_H_PADDING,
1166                middle - label_size.height / 2.0,
1167            ),
1168            ink,
1169            scene,
1170        );
1171        if let Some(check) = &row.check {
1172            check.paint(
1173                Point::new(
1174                    box_origin.x + box_size.width - DROPDOWN_ITEM_H_PADDING - check.extent,
1175                    middle - check.extent / 2.0,
1176                ),
1177                ink,
1178                scene,
1179            );
1180        }
1181    }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187    use crate::dropdown::dropdown_item;
1188    use frust::authoring::scene::GlyphRun;
1189    use frust::authoring::{BezPath, Brush, KeyEvent, Modifiers, PointerButton, text::TextContext};
1190    use frust::{Brightness, FrameTime};
1191    use std::any::Any;
1192
1193    const AREA: Size = Size::new(600.0, 800.0);
1194    /// The width the harness's anchor advertises — what the panel must match.
1195    const ANCHOR_WIDTH: f64 = 240.0;
1196
1197    /// What the panel reported, in order.
1198    #[derive(Default)]
1199    struct AppState {
1200        selections: Vec<Vec<String>>,
1201        opens: Vec<bool>,
1202        queries: Vec<String>,
1203    }
1204
1205    #[derive(Default)]
1206    struct Recorder {
1207        rrects: Vec<(Point, Size, f64, Color)>,
1208        radii: Vec<CornerRadii>,
1209        paths: Vec<Point>,
1210        runs: usize,
1211        clips: usize,
1212    }
1213
1214    impl PaintScene for Recorder {
1215        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1216        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1217            self.rrects.push((origin, size, radius, color));
1218        }
1219        fn fill_rounded_rect_radii(
1220            &mut self,
1221            origin: Point,
1222            size: Size,
1223            radii: CornerRadii,
1224            color: Color,
1225        ) {
1226            self.radii.push(radii);
1227            self.rrects.push((origin, size, radii.largest(), color));
1228        }
1229        fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1230            self.paths.push(origin);
1231        }
1232        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1233        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1234            self.runs += 1;
1235        }
1236        fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {}
1237        fn push_clip(&mut self, _origin: Point, _size: Size) {
1238            self.clips += 1;
1239        }
1240        fn pop_clip(&mut self) {}
1241    }
1242
1243    fn fruit() -> Vec<DropdownItem> {
1244        vec![
1245            dropdown_item("Apple", "apple"),
1246            dropdown_item("Banana", "banana"),
1247            dropdown_item("Cherry", "cherry"),
1248        ]
1249    }
1250
1251    fn anchor() -> OverlayAnchor {
1252        let anchor = OverlayAnchor::new();
1253        anchor.set(Rect::new(20.0, 40.0, 20.0 + ANCHOR_WIDTH, 92.0));
1254        anchor
1255    }
1256
1257    fn view(items: Vec<DropdownItem>, anchor: &OverlayAnchor) -> DropdownPanelView<AppState> {
1258        dropdown_panel(items, |state: &mut AppState, values| {
1259            state.selections.push(values)
1260        })
1261        .anchor(anchor)
1262        .on_open(|state: &mut AppState, open| state.opens.push(open))
1263        .on_query(|state: &mut AppState, query| state.queries.push(query))
1264    }
1265
1266    fn build(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1267        let mut counter = 0u64;
1268        View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1269    }
1270
1271    fn layout(w: &mut DropdownPanelWidget, area: Size) -> Size {
1272        let mut tcx = TextContext::new();
1273        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1274        w.layout(&mut lctx, &BoxConstraints::loose(area))
1275    }
1276
1277    /// Build + lay out in one step: the state every event/paint test starts in.
1278    fn ready(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1279        let mut w = build(view);
1280        layout(&mut w, AREA);
1281        w
1282    }
1283
1284    fn rebuild(
1285        w: &mut DropdownPanelWidget,
1286        prev: &DropdownPanelView<AppState>,
1287        next: &DropdownPanelView<AppState>,
1288    ) {
1289        let mut counter = 0u64;
1290        View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1291    }
1292
1293    fn paint(w: &mut DropdownPanelWidget) -> Recorder {
1294        let theme = crate::baseline().with_brightness(Brightness::Light);
1295        let mut rec = Recorder::default();
1296        let size = Size::new(w.width, w.height);
1297        let mut ctx =
1298            PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1299        w.paint(&mut ctx, &mut rec);
1300        rec
1301    }
1302
1303    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1304        InputEvent::Pointer(PointerEvent {
1305            phase,
1306            position: Point::new(x, y),
1307            button: PointerButton::Primary,
1308        })
1309    }
1310
1311    fn key(named: NamedKey) -> InputEvent {
1312        InputEvent::Key(KeyEvent {
1313            key: Key::Named(named),
1314            modifiers: Modifiers::default(),
1315            repeat: false,
1316        })
1317    }
1318
1319    fn dispatch(
1320        w: &mut DropdownPanelWidget,
1321        state: &mut AppState,
1322        event: &InputEvent,
1323    ) -> EventResult {
1324        let any: &mut dyn Any = state;
1325        let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
1326        w.event(&mut ctx, event)
1327    }
1328
1329    /// The centre of row `row`, in panel-local coordinates.
1330    fn row_center(w: &DropdownPanelWidget, row: usize) -> Point {
1331        let (top, height) = w.band(row);
1332        Point::new(w.width / 2.0, w.body_top + top + height / 2.0 - w.scroll)
1333    }
1334
1335    fn click(w: &mut DropdownPanelWidget, state: &mut AppState, row: usize) {
1336        let c = row_center(w, row);
1337        dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
1338        dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
1339    }
1340
1341    // ---- structure --------------------------------------------------------
1342
1343    #[test]
1344    fn the_panel_matches_the_trigger_s_width() {
1345        let a = anchor();
1346        let w = ready(&view(fruit(), &a));
1347        assert_eq!(w.width(), ANCHOR_WIDTH);
1348    }
1349
1350    #[test]
1351    fn a_never_painted_anchor_falls_back_to_the_natural_content_width() {
1352        let bare = OverlayAnchor::new();
1353        let w = ready(&view(fruit(), &bare));
1354        assert!(
1355            w.width() > 2.0 * DROPDOWN_CONTENT_PADDING,
1356            "a degenerate anchor still sizes the panel from its rows"
1357        );
1358        assert!(w.width() <= AREA.width);
1359    }
1360
1361    #[test]
1362    fn every_row_gets_a_band_and_the_gap_sits_between_them() {
1363        let a = anchor();
1364        let w = ready(&view(fruit(), &a));
1365        assert_eq!(w.rows.len(), 3);
1366        for i in 1..w.rows.len() {
1367            let (prev_top, prev_height) = w.band(i - 1);
1368            let (top, _) = w.band(i);
1369            assert!((top - (prev_top + prev_height) - DROPDOWN_ITEM_GAP).abs() < 1e-6);
1370        }
1371    }
1372
1373    #[test]
1374    fn a_tall_list_caps_at_the_max_height_and_clips_while_scrolling() {
1375        let a = anchor();
1376        let many: Vec<DropdownItem> = (0..40)
1377            .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1378            .collect();
1379        let mut w = ready(&view(many, &a));
1380        assert_eq!(w.height(), DROPDOWN_PANEL_MAX_HEIGHT);
1381        assert!(w.content_height > w.viewport);
1382        let rec = paint(&mut w);
1383        assert_eq!(rec.clips, 1, "a scrolling list clips its own viewport");
1384
1385        let mut state = AppState::default();
1386        let inside = Point::new(w.width / 2.0, w.body_top + 10.0);
1387        dispatch(
1388            &mut w,
1389            &mut state,
1390            &InputEvent::Scroll {
1391                position: inside,
1392                delta: ScrollDelta::Pixels(0.0, 40.0),
1393            },
1394        );
1395        assert_eq!(w.scroll(), 40.0);
1396    }
1397
1398    #[test]
1399    fn a_short_list_never_clips() {
1400        let a = anchor();
1401        let mut w = ready(&view(fruit(), &a));
1402        assert!(w.height() < DROPDOWN_PANEL_MAX_HEIGHT);
1403        let rec = paint(&mut w);
1404        assert_eq!(rec.clips, 0);
1405    }
1406
1407    #[test]
1408    fn the_search_field_is_a_header_above_the_scrolled_list() {
1409        let a = anchor();
1410        let plain = ready(&view(fruit(), &a));
1411        let searchable = ready(&view(fruit(), &a).searchable(true));
1412        assert!(searchable.body_top > plain.body_top);
1413        let rect = searchable.search_rect().expect("a mounted search field");
1414        assert_eq!(rect.x0, SEARCH_MARGIN_H);
1415        assert_eq!(rect.y0, SEARCH_MARGIN_TOP);
1416        assert!(
1417            rect.y1 <= searchable.body_top,
1418            "the header never overlaps the list"
1419        );
1420    }
1421
1422    // ---- the filter -------------------------------------------------------
1423
1424    #[test]
1425    fn a_query_narrows_the_rows_the_panel_renders() {
1426        let a = anchor();
1427        let w = ready(&view(fruit(), &a).query("an"));
1428        assert_eq!(w.rows.len(), 1);
1429        assert_eq!(w.rows[0].value, "banana");
1430    }
1431
1432    #[test]
1433    fn a_query_matching_nothing_shows_the_empty_message() {
1434        let a = anchor();
1435        let mut w = ready(&view(fruit(), &a).query("zzz"));
1436        assert_eq!(w.body, Body::Empty);
1437        assert!(w.rows.is_empty());
1438        let rec = paint(&mut w);
1439        assert!(rec.runs > 0, "the empty line is painted");
1440        assert!(rec.radii.is_empty(), "no row cards at all");
1441    }
1442
1443    #[test]
1444    fn a_rebuilt_query_re_filters_the_rows_and_drops_the_highlight() {
1445        let a = anchor();
1446        let prev = view(fruit(), &a);
1447        let mut w = ready(&prev);
1448        let mut state = AppState::default();
1449        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1450        assert_eq!(w.highlighted(), Some(0));
1451
1452        let next = view(fruit(), &a).query("cher");
1453        rebuild(&mut w, &prev, &next);
1454        layout(&mut w, AREA);
1455        assert_eq!(w.rows.len(), 1);
1456        assert_eq!(w.rows[0].value, "cherry");
1457        assert_eq!(w.highlighted(), None);
1458    }
1459
1460    // ---- single select ----------------------------------------------------
1461
1462    #[test]
1463    fn a_single_select_pick_reports_one_value_and_closes() {
1464        let a = anchor();
1465        let mut w = ready(&view(fruit(), &a));
1466        let mut state = AppState::default();
1467        click(&mut w, &mut state, 1);
1468        assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1469        assert_eq!(state.opens, vec![false]);
1470    }
1471
1472    #[test]
1473    fn a_single_select_never_flips_its_own_state() {
1474        let a = anchor();
1475        let mut w = ready(&view(fruit(), &a));
1476        let mut state = AppState::default();
1477        click(&mut w, &mut state, 1);
1478        // Nothing fed back down, so nothing is selected yet.
1479        assert!(w.rows.iter().all(|row| !row.selected));
1480    }
1481
1482    #[test]
1483    fn re_picking_the_selected_option_clears_it_the_way_toggle_only_does() {
1484        let a = anchor();
1485        let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1486        let mut state = AppState::default();
1487        click(&mut w, &mut state, 1);
1488        assert_eq!(state.selections, vec![Vec::<String>::new()]);
1489        assert_eq!(state.opens, vec![false]);
1490    }
1491
1492    #[test]
1493    fn a_single_select_pick_replaces_whatever_was_selected() {
1494        let a = anchor();
1495        let mut w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1496        let mut state = AppState::default();
1497        click(&mut w, &mut state, 2);
1498        assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1499    }
1500
1501    // ---- multi select -----------------------------------------------------
1502
1503    #[test]
1504    fn a_multi_select_pick_adds_in_item_order_and_stays_open() {
1505        let a = anchor();
1506        let mut w = ready(
1507            &view(fruit(), &a)
1508                .multi(true)
1509                .selected(vec!["cherry".to_string()]),
1510        );
1511        let mut state = AppState::default();
1512        click(&mut w, &mut state, 0);
1513        assert_eq!(
1514            state.selections,
1515            vec![vec!["apple".to_string(), "cherry".to_string()]],
1516            "item order, not click order"
1517        );
1518        assert!(state.opens.is_empty(), "a multi-select pick never closes");
1519    }
1520
1521    #[test]
1522    fn a_multi_select_pick_on_a_selected_row_removes_it() {
1523        let a = anchor();
1524        let mut w = ready(
1525            &view(fruit(), &a)
1526                .multi(true)
1527                .selected(vec!["apple".to_string(), "cherry".to_string()]),
1528        );
1529        let mut state = AppState::default();
1530        click(&mut w, &mut state, 0);
1531        assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1532    }
1533
1534    #[test]
1535    fn max_selections_refuses_a_new_pick_but_never_a_deselect() {
1536        let a = anchor();
1537        let mut w = ready(
1538            &view(fruit(), &a)
1539                .multi(true)
1540                .max_selections(1)
1541                .selected(vec!["apple".to_string()]),
1542        );
1543        let mut state = AppState::default();
1544        click(&mut w, &mut state, 1);
1545        assert!(state.selections.is_empty(), "the cap refuses the addition");
1546        click(&mut w, &mut state, 0);
1547        assert_eq!(
1548            state.selections,
1549            vec![Vec::<String>::new()],
1550            "a deselect is never capped"
1551        );
1552    }
1553
1554    #[test]
1555    fn a_disabled_row_reports_nothing_at_all() {
1556        let a = anchor();
1557        let items = vec![
1558            dropdown_item("Apple", "apple"),
1559            dropdown_item("Banana", "banana").disabled(true),
1560        ];
1561        let mut w = ready(&view(items, &a));
1562        let mut state = AppState::default();
1563        click(&mut w, &mut state, 1);
1564        assert!(state.selections.is_empty());
1565        assert!(state.opens.is_empty(), "not even a close");
1566    }
1567
1568    #[test]
1569    fn a_release_outside_the_armed_row_reports_nothing() {
1570        let a = anchor();
1571        let mut w = ready(&view(fruit(), &a));
1572        let mut state = AppState::default();
1573        let down = row_center(&w, 0);
1574        let up = row_center(&w, 2);
1575        dispatch(
1576            &mut w,
1577            &mut state,
1578            &pointer(PointerPhase::Down, down.x, down.y),
1579        );
1580        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, up.x, up.y));
1581        assert!(state.selections.is_empty());
1582    }
1583
1584    #[test]
1585    fn only_a_primary_press_arms_a_row() {
1586        let a = anchor();
1587        let mut w = ready(&view(fruit(), &a));
1588        let mut state = AppState::default();
1589        let c = row_center(&w, 0);
1590        let secondary = InputEvent::Pointer(PointerEvent {
1591            phase: PointerPhase::Down,
1592            position: c,
1593            button: PointerButton::Secondary,
1594        });
1595        assert_eq!(
1596            dispatch(&mut w, &mut state, &secondary),
1597            EventResult::Ignored
1598        );
1599        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
1600        assert!(state.selections.is_empty());
1601    }
1602
1603    #[test]
1604    fn a_cancel_clears_the_press_without_reporting() {
1605        let a = anchor();
1606        let mut w = ready(&view(fruit(), &a));
1607        let mut state = AppState::default();
1608        let c = row_center(&w, 0);
1609        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1610        dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y));
1611        assert!(state.selections.is_empty());
1612        assert_eq!(w.pressed, None);
1613    }
1614
1615    #[test]
1616    fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
1617        let a = anchor();
1618        let mut w = ready(&view(fruit(), &a));
1619        let mut state = AppState::default();
1620        // The panel's bottom padding band, below the last row.
1621        let y = w.height() - 1.0;
1622        assert_eq!(
1623            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, y)),
1624            EventResult::Ignored
1625        );
1626    }
1627
1628    // ---- keyboard ---------------------------------------------------------
1629
1630    #[test]
1631    fn arrow_down_enters_at_the_top_and_arrow_up_at_the_bottom() {
1632        let a = anchor();
1633        let mut w = ready(&view(fruit(), &a));
1634        let mut state = AppState::default();
1635        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1636        assert_eq!(w.highlighted(), Some(0));
1637
1638        let mut fresh = ready(&view(fruit(), &a));
1639        dispatch(&mut fresh, &mut state, &key(NamedKey::ArrowUp));
1640        assert_eq!(fresh.highlighted(), Some(2));
1641    }
1642
1643    #[test]
1644    fn the_highlight_skips_disabled_rows_and_stops_at_the_ends() {
1645        let a = anchor();
1646        let items = vec![
1647            dropdown_item("Apple", "apple"),
1648            dropdown_item("Banana", "banana").disabled(true),
1649            dropdown_item("Cherry", "cherry"),
1650        ];
1651        let mut w = ready(&view(items, &a));
1652        let mut state = AppState::default();
1653        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1654        assert_eq!(w.highlighted(), Some(0));
1655        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1656        assert_eq!(w.highlighted(), Some(2), "the disabled row is skipped");
1657        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1658        assert_eq!(w.highlighted(), Some(2), "the end does not wrap");
1659    }
1660
1661    #[test]
1662    fn enter_activates_the_highlighted_row() {
1663        let a = anchor();
1664        let mut w = ready(&view(fruit(), &a));
1665        let mut state = AppState::default();
1666        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1667        dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1668        assert_eq!(
1669            dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1670            EventResult::Handled
1671        );
1672        assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1673    }
1674
1675    #[test]
1676    fn enter_with_no_highlight_reports_nothing() {
1677        let a = anchor();
1678        let mut w = ready(&view(fruit(), &a));
1679        let mut state = AppState::default();
1680        assert_eq!(
1681            dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1682            EventResult::Ignored
1683        );
1684        assert!(state.selections.is_empty());
1685    }
1686
1687    #[test]
1688    fn escape_is_left_for_the_host_to_dismiss_on() {
1689        let a = anchor();
1690        let mut w = ready(&view(fruit(), &a));
1691        let mut state = AppState::default();
1692        assert_eq!(
1693            dispatch(&mut w, &mut state, &key(NamedKey::Escape)),
1694            EventResult::Ignored
1695        );
1696    }
1697
1698    #[test]
1699    fn the_highlight_scrolls_its_row_into_view() {
1700        let a = anchor();
1701        let many: Vec<DropdownItem> = (0..40)
1702            .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1703            .collect();
1704        let mut w = ready(&view(many, &a));
1705        let mut state = AppState::default();
1706        for _ in 0..12 {
1707            dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1708        }
1709        assert_eq!(w.highlighted(), Some(11));
1710        assert!(w.scroll() > 0.0, "the highlight pulled the list down");
1711        let (top, height) = w.band(11);
1712        assert!(top >= w.scroll() && top + height <= w.scroll() + w.viewport);
1713    }
1714
1715    #[test]
1716    fn a_closed_panel_ignores_every_interaction() {
1717        let a = anchor();
1718        let mut w = ready(&view(fruit(), &a).open(false));
1719        let mut state = AppState::default();
1720        let c = row_center(&w, 0);
1721        assert_eq!(
1722            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
1723            EventResult::Ignored
1724        );
1725        assert_eq!(
1726            dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown)),
1727            EventResult::Ignored
1728        );
1729        assert!(state.selections.is_empty());
1730    }
1731
1732    // ---- async seam -------------------------------------------------------
1733
1734    #[test]
1735    fn loading_replaces_the_rows_with_the_busy_indicator() {
1736        let a = anchor();
1737        let mut w = ready(&view(fruit(), &a).loading(true));
1738        assert_eq!(w.body, Body::Loading);
1739        assert_eq!(w.busy.len(), 1, "the indicator pod is mounted");
1740        let rec = paint(&mut w);
1741        assert!(rec.radii.is_empty(), "no row cards while loading");
1742    }
1743
1744    #[test]
1745    fn a_loading_panel_reports_nothing_on_a_press() {
1746        let a = anchor();
1747        let mut w = ready(&view(fruit(), &a).loading(true));
1748        let mut state = AppState::default();
1749        let middle = Point::new(w.width() / 2.0, w.body_top + w.viewport / 2.0);
1750        dispatch(
1751            &mut w,
1752            &mut state,
1753            &pointer(PointerPhase::Down, middle.x, middle.y),
1754        );
1755        dispatch(
1756            &mut w,
1757            &mut state,
1758            &pointer(PointerPhase::Up, middle.x, middle.y),
1759        );
1760        assert!(state.selections.is_empty());
1761    }
1762
1763    #[test]
1764    fn loading_outranks_an_empty_list_and_an_error_outranks_both() {
1765        let a = anchor();
1766        let loading = ready(&view(Vec::new(), &a).loading(true));
1767        assert_eq!(loading.body, Body::Loading);
1768        let error = ready(&view(Vec::new(), &a).error(Some("boom".to_string())));
1769        assert_eq!(error.body, Body::Error);
1770        let empty = ready(&view(Vec::new(), &a));
1771        assert_eq!(empty.body, Body::Empty);
1772    }
1773
1774    #[test]
1775    fn a_load_that_settles_swaps_the_busy_row_for_the_options() {
1776        let a = anchor();
1777        let prev = view(Vec::new(), &a).loading(true);
1778        let mut w = ready(&prev);
1779        assert_eq!(w.busy.len(), 1);
1780
1781        let next = view(fruit(), &a).loading(false);
1782        rebuild(&mut w, &prev, &next);
1783        layout(&mut w, AREA);
1784        assert_eq!(w.body, Body::Rows);
1785        assert_eq!(w.busy.len(), 0, "the indicator pod is unmounted");
1786        assert_eq!(w.rows.len(), 3);
1787    }
1788
1789    #[test]
1790    fn an_error_paints_its_message_in_the_error_role() {
1791        let a = anchor();
1792        let theme = crate::baseline().with_brightness(Brightness::Light);
1793        let mut w = ready(&view(fruit(), &a).error(Some("network down".to_string())));
1794        assert!(w.message_is_error);
1795        let rec = paint(&mut w);
1796        assert!(rec.runs > 0);
1797        // The panel container is the one rounded rect an error body paints.
1798        assert_eq!(rec.rrects.len(), 1);
1799        assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
1800    }
1801
1802    // ---- paint ------------------------------------------------------------
1803
1804    #[test]
1805    fn a_selected_row_takes_the_secondary_container_fill_and_a_trailing_check() {
1806        let a = anchor();
1807        let theme = crate::baseline().with_brightness(Brightness::Light);
1808        let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1809        let rec = paint(&mut w);
1810        let selected_fill = theme.scheme().secondary_container;
1811        assert!(
1812            rec.rrects
1813                .iter()
1814                .any(|(_, _, _, color)| *color == selected_fill),
1815            "the selected row fills with `secondaryContainer`"
1816        );
1817        assert_eq!(rec.paths.len(), 1, "exactly one check glyph, on row 1");
1818    }
1819
1820    #[test]
1821    fn a_selected_row_loses_its_cap_corners_the_way_the_reference_does() {
1822        let a = anchor();
1823        // Row 0 is both `First` (capped) and selected — the reference's
1824        // short-circuit makes it uniform instead.
1825        let w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1826        assert_eq!(
1827            w.row_radii(0),
1828            CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1829        );
1830        assert_eq!(
1831            w.row_radii(1),
1832            card_radii(
1833                card_position(1, 3),
1834                DROPDOWN_ITEM_OUTER_RADIUS,
1835                DROPDOWN_ITEM_INNER_RADIUS
1836            )
1837        );
1838    }
1839
1840    #[test]
1841    fn the_card_list_treatment_caps_the_first_and_last_rows() {
1842        let a = anchor();
1843        let w = ready(&view(fruit(), &a));
1844        assert_eq!(
1845            w.row_radii(0),
1846            CornerRadii::new(
1847                DROPDOWN_ITEM_OUTER_RADIUS,
1848                DROPDOWN_ITEM_OUTER_RADIUS,
1849                DROPDOWN_ITEM_INNER_RADIUS,
1850                DROPDOWN_ITEM_INNER_RADIUS
1851            )
1852        );
1853        assert_eq!(
1854            w.row_radii(2),
1855            CornerRadii::new(
1856                DROPDOWN_ITEM_INNER_RADIUS,
1857                DROPDOWN_ITEM_INNER_RADIUS,
1858                DROPDOWN_ITEM_OUTER_RADIUS,
1859                DROPDOWN_ITEM_OUTER_RADIUS
1860            )
1861        );
1862        // A lone row is capped on every corner.
1863        let single = ready(&view(vec![dropdown_item("Only", "only")], &a));
1864        assert_eq!(
1865            single.row_radii(0),
1866            CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1867        );
1868    }
1869
1870    #[test]
1871    fn hover_and_press_morph_the_inner_radius() {
1872        let a = anchor();
1873        let mut w = ready(&view(fruit(), &a));
1874        let mut state = AppState::default();
1875        let c = row_center(&w, 1);
1876        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1877        assert_eq!(
1878            w.row_radii(1),
1879            CornerRadii::uniform(DROPDOWN_ITEM_HOVER_RADIUS)
1880        );
1881        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1882        assert_eq!(
1883            w.row_radii(1),
1884            CornerRadii::uniform(DROPDOWN_ITEM_PRESSED_RADIUS)
1885        );
1886    }
1887
1888    #[test]
1889    fn a_disabled_row_never_washes() {
1890        let a = anchor();
1891        let items = vec![
1892            dropdown_item("Apple", "apple"),
1893            dropdown_item("Banana", "banana").disabled(true),
1894        ];
1895        let mut w = ready(&view(items, &a));
1896        let mut state = AppState::default();
1897        let c = row_center(&w, 1);
1898        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1899        assert_eq!(w.hovered, None, "a disabled row is never latched");
1900    }
1901
1902    #[test]
1903    fn a_paint_with_no_hover_link_clears_the_latched_row() {
1904        let a = anchor();
1905        let mut w = ready(&view(fruit(), &a));
1906        let mut state = AppState::default();
1907        let c = row_center(&w, 0);
1908        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1909        assert_eq!(w.hovered, Some(0));
1910        // `PaintCtx::for_test` reports no hover path.
1911        paint(&mut w);
1912        assert_eq!(w.hovered, None);
1913    }
1914
1915    #[test]
1916    fn every_painted_rect_stays_inside_the_panel_s_own_box() {
1917        let a = anchor();
1918        let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1919        let width = w.width();
1920        let height = w.height();
1921        let rec = paint(&mut w);
1922        for (origin, size, _, _) in rec.rrects {
1923            assert!(origin.x >= 0.0 && origin.x + size.width <= width + 1e-6);
1924            assert!(origin.y >= 0.0 && origin.y + size.height <= height + 1e-6);
1925        }
1926    }
1927
1928    #[test]
1929    fn a_rebuild_that_re_supplies_the_same_props_keeps_its_shaped_runs() {
1930        let a = anchor();
1931        let prev = view(fruit(), &a);
1932        let mut w = ready(&prev);
1933        let before: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1934        let next = view(fruit(), &a);
1935        rebuild(&mut w, &prev, &next);
1936        let after: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1937        assert_eq!(before, after);
1938        assert!(after.iter().all(|size| size.width > 0.0));
1939    }
1940
1941    #[test]
1942    fn the_semantics_tree_is_a_listbox_of_selectable_options() {
1943        use frust_core::RenderRoot;
1944        let a = anchor();
1945        let mut root: RenderRoot<AppState, DropdownPanelView<AppState>> = RenderRoot::new();
1946        let mut state = AppState::default();
1947        let mut tcx = TextContext::new();
1948        let cell = a.clone();
1949        let mut logic =
1950            move |_s: &mut AppState| view(fruit(), &cell).selected(vec!["banana".to_string()]);
1951        root.rebuild(&mut logic, &mut state);
1952        root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1953        let tree = root.semantics();
1954        assert!(
1955            tree.nodes
1956                .iter()
1957                .any(|(_, node)| node.role() == Role::ListBox)
1958        );
1959        let options: Vec<_> = tree
1960            .nodes
1961            .iter()
1962            .filter(|(_, node)| node.role() == Role::ListBoxOption)
1963            .collect();
1964        assert_eq!(options.len(), 3);
1965        assert_eq!(
1966            options
1967                .iter()
1968                .filter(|(_, n)| n.is_selected() == Some(true))
1969                .count(),
1970            1
1971        );
1972    }
1973}