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