Skip to main content

frust_material/dropdown/
field.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/dropdown_menus/components/` — `m3e_dropdown_menu_field.dart`
3// (the field box, its trailing slot and its overlay alphas),
4// `m3e_dropdown_chips.dart` (`M3ESpringChip`'s scale ramp and body) and
5// `m3e_dropdown_menu_chip_build.dart` (the wrapped chip strip and the "+N"
6// overflow indicator), plus `m3e_dropdown_menu_actions.dart`'s `_toggle`,
7// retrieved 2026-08-20.
8// Upstream: <https://github.com/paadevelopments/material_3_expressive>
9// Porting decisions: the reference's `InkWell` splash and its per-state
10// `hoverRadius`/`pressedRadius`/`selectedBorderRadius` morph are **not**
11// ported — all three default to `null`, so a stock field never morphs, and the
12// catalog has no ink-splash primitive; chips are painted here rather than
13// mounted as `crate::input_chip` pods, and their slide/squish micro-animations
14// are dropped (see `super`'s chip divergence).
15
16//! The dropdown's trigger: a text-field-shaped tappable box carrying the hint,
17//! the selected label (single-select) or the selected chips (multi-select), and
18//! a trailing slot holding the rotating arrow, the clear affordance, or a busy
19//! indicator.
20//!
21//! # It is its own anchor
22//!
23//! The field writes `Rect::from_origin_size(ctx.origin(), ctx.size())` into the
24//! [`OverlayAnchor`] it was handed on every paint — the same one-line
25//! window-space capture [`crate::overlay::overlay_anchor`] performs, inlined so
26//! a dropdown's trigger needs no wrapper. The panel reads the same cell for
27//! both its placement and its width.
28//!
29//! # Not a text field
30//!
31//! The reference's trigger is a `Material` + `InkWell`, never an editable
32//! (`m3e_dropdown_menu_field.dart:251`): its "search" mode puts a `TextField`
33//! **inside the panel** instead (`m3e_dropdown_menu_panel.dart:203`). This port
34//! keeps that shape, so the field here shares
35//! [`mod@crate::text_field`]'s *silhouette* — a 56dp-ish container with a
36//! trailing slot — without wrapping an editable or the framework's IME session.
37//!
38//! # The arrow is the one ported spring
39//!
40//! `_arrowCtrl.animateTo(math.pi)` on open and `animateTo(0)` on close
41//! (`m3e_dropdown_menu_actions.dart:21`), on the reference's
42//! `M3EMotion.expressiveSpatialDefault` spring: the glyph rotates a half turn
43//! about its own center. Everything else the reference springs belongs to the
44//! panel, which the anchored host now ramps
45//! ([`super`]'s *The host owns the ramp*).
46//!
47//! # Chips grow the field
48//!
49//! In multi-select the field renders one chip per selected option, wrapped into
50//! as many rows as they need (`chipStyle.wrap`, the reference's default), and
51//! the field's own height follows — the growth `AnimatedSize` smooths upstream
52//! (`m3e_dropdown_menu_lifecycle.dart:174`) and this port takes as a plain
53//! relayout. Each chip springs in on arrival and springs **out** on removal:
54//! a chip dropped from the app's selection is kept in the retained strip, its
55//! scale driven to zero, and only then released, so a controlled removal still
56//! plays its exit.
57//!
58//! A chip's delete glyph owns its own hit region and reports the selection
59//! *without* that value; the rest of the chip is inert and falls through to the
60//! field's own press, which is what the reference's
61//! `GestureDetector`-inside-`MouseRegion` does.
62
63use frust::authoring::text::TextStyle;
64use frust::authoring::{
65    Action, Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon,
66    ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, Point,
67    PointerEvent, PointerPhase, Rect, Role, SemanticsCtx, Size, View, Widget, any, build_child,
68    erase_callback_arg, rebuild_children, route_event_single, teardown_child, visit_children,
69};
70use frust::{AnimationController, Theme};
71
72use super::{
73    DROPDOWN_CHIP_H_PADDING, DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_LABEL_GAP,
74    DROPDOWN_CHIP_RADIUS, DROPDOWN_CHIP_RUN_SPACING, DROPDOWN_CHIP_SPACING,
75    DROPDOWN_CHIP_V_PADDING, DROPDOWN_FIELD_H_PADDING, DROPDOWN_FIELD_HOVER_ALPHA,
76    DROPDOWN_FIELD_LOADING_SIZE, DROPDOWN_FIELD_LOADING_STROKE, DROPDOWN_FIELD_PRESSED_ALPHA,
77    DROPDOWN_FIELD_V_PADDING, DROPDOWN_FLING_VELOCITY, DROPDOWN_HINT, DROPDOWN_ICON_GAP,
78    DROPDOWN_ICON_SIZE, DROPDOWN_PROGRESS_EPSILON, DROPDOWN_SPRING, DropdownColors, DropdownItem,
79    Glyph, OnOpen, OnSelection, Run, SHAPING_INK, container_radius, ordered_selection,
80};
81use crate::icons;
82use crate::overlay::{OverlayAnchor, with_alpha};
83use crate::press::presses;
84use crate::progress::{ProgressValue, circular_progress};
85
86/// Width used when the incoming constraints are horizontally unbounded — the
87/// same fallback [`mod@crate::text_field`] applies for the same reason.
88const UNBOUNDED_WIDTH: f64 = 200.0;
89
90/// The half turn the arrow travels between closed and open
91/// (`_arrowCtrl.animateTo(math.pi)`), in radians.
92const ARROW_TURN: f64 = std::f64::consts::PI;
93
94/// What the field's trailing slot holds, resolved in the reference's own
95/// precedence order (`_buildFieldTrailing`).
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97enum Trailing {
98    /// The busy indicator, while `loading` is set.
99    Busy,
100    /// The clear-all affordance, when `show_clear` is on and something is
101    /// selected.
102    Clear,
103    /// The rotating chevron.
104    Arrow,
105}
106
107/// One selected option's chip: its label run, its spring, and the box the last
108/// layout placed it in.
109struct Chip {
110    /// The option's value — the identity a delete reports against.
111    value: String,
112    label: Run,
113    /// The scale ramp: `1.0` fully present, `0.0` gone.
114    scale: AnimationController,
115    /// The progress the last paint resolved.
116    progress: f64,
117    /// Whether this chip is on its way out and should be released once its
118    /// ramp settles.
119    removing: bool,
120    /// Whether the ramp has been released yet (the entrance is seeded on the
121    /// first paint, so a chip mounted with the field starts from zero).
122    started: bool,
123    /// The chip's placed box, in the field's own space.
124    rect: Rect,
125    /// The delete glyph's own hit region, inside [`Self::rect`].
126    delete: Rect,
127}
128
129impl Chip {
130    /// A chip for `value` labelled `label`, unshaped and unstarted.
131    fn new(value: &str, label: &str) -> Self {
132        Chip {
133            value: value.to_string(),
134            label: Run::new(label),
135            scale: AnimationController::new(std::time::Duration::ZERO),
136            progress: 0.0,
137            removing: false,
138            started: false,
139            rect: Rect::ZERO,
140            delete: Rect::ZERO,
141        }
142    }
143
144    /// The scale the chip is drawn at, clamped out of the spring's overshoot
145    /// for layout purposes but left free for paint.
146    fn layout_scale(&self) -> f64 {
147        self.progress.clamp(0.0, 1.0)
148    }
149}
150
151/// A declarative dropdown trigger. See [`dropdown_field`].
152pub struct DropdownFieldView<State: 'static> {
153    items: Vec<DropdownItem>,
154    selected: Vec<String>,
155    hint: String,
156    multi: bool,
157    open: bool,
158    enabled: bool,
159    loading: bool,
160    error: bool,
161    show_clear: bool,
162    max_chips: Option<usize>,
163    anchor: OverlayAnchor,
164    on_open: Option<OnOpen<State>>,
165    on_change: Option<OnSelection<State>>,
166}
167
168/// Build a dropdown trigger over `items`.
169///
170/// Hand it the same [`OverlayAnchor`] the [`dropdown`](super::dropdown) half
171/// uses ([`DropdownFieldView::anchor`]); without one the field still renders,
172/// it just anchors nothing.
173///
174/// Controlled throughout: a press reports the open state it *requests* through
175/// [`on_open`](DropdownFieldView::on_open), and a chip delete or a clear-all
176/// reports the selection it requests through
177/// [`on_change`](DropdownFieldView::on_change).
178pub fn dropdown_field<State: 'static>(items: Vec<DropdownItem>) -> DropdownFieldView<State> {
179    DropdownFieldView {
180        items,
181        selected: Vec::new(),
182        hint: DROPDOWN_HINT.to_string(),
183        multi: false,
184        open: false,
185        enabled: true,
186        loading: false,
187        error: false,
188        show_clear: false,
189        max_chips: None,
190        anchor: OverlayAnchor::new(),
191        on_open: None,
192        on_change: None,
193    }
194}
195
196impl<State: 'static> DropdownFieldView<State> {
197    /// Capture this field's window-space rect into `anchor` on every paint.
198    pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
199        self.anchor = anchor.clone();
200        self
201    }
202
203    /// Hand the field the app-confirmed selection (option values).
204    pub fn selected(mut self, selected: Vec<String>) -> Self {
205        self.selected = selected;
206        self
207    }
208
209    /// Replace the placeholder shown when nothing is selected (`hintText`).
210    pub fn hint(mut self, hint: impl Into<String>) -> Self {
211        self.hint = hint.into();
212        self
213    }
214
215    /// Render the selection as chips rather than as a single label — the
216    /// multi-select presentation (`showChipAnimation` over a multi-select
217    /// controller).
218    pub fn multi(mut self, multi: bool) -> Self {
219        self.multi = multi;
220        self
221    }
222
223    /// Tell the field whether its panel is open, which is what points the
224    /// arrow up.
225    pub fn open(mut self, open: bool) -> Self {
226        self.open = open;
227        self
228    }
229
230    /// Set whether the field accepts a press (default `true`). A disabled field
231    /// dims its content, refuses to open, and drops its chips' delete
232    /// affordances — the reference's `enabled` gate.
233    pub fn enabled(mut self, enabled: bool) -> Self {
234        self.enabled = enabled;
235        self
236    }
237
238    /// Swap the trailing slot for a busy indicator and refuse to open — the
239    /// field half of the async poll seam (see [`super`]).
240    pub fn loading(mut self, loading: bool) -> Self {
241        self.loading = loading;
242        self
243    }
244
245    /// Outline the container in the `error` role. The reference recolors its
246    /// border from a `FormField`'s `hasError`; with that wrapper descoped, this
247    /// is the app-driven equivalent (see [`super`]'s descope note).
248    pub fn error(mut self, error: bool) -> Self {
249        self.error = error;
250        self
251    }
252
253    /// Show a clear-all affordance in the trailing slot whenever something is
254    /// selected (`showClearIcon`, off by default). It replaces the arrow while
255    /// visible, exactly as upstream.
256    pub fn show_clear(mut self, show_clear: bool) -> Self {
257        self.show_clear = show_clear;
258        self
259    }
260
261    /// Cap the number of chips rendered, past which a `+N` indicator stands in
262    /// for the rest (`chipStyle.maxDisplayCount`; unlimited by default).
263    pub fn max_chips(mut self, max: usize) -> Self {
264        self.max_chips = Some(max);
265        self
266    }
267
268    /// Set the open callback: a press reports `!open`, the reference's
269    /// `_toggle`.
270    pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
271        self.on_open = Some(std::rc::Rc::new(on_open));
272        self
273    }
274
275    /// Set the selection callback: a chip delete reports the selection without
276    /// that value, and the clear affordance reports an empty one.
277    pub fn on_change<F: Fn(&mut State, Vec<String>) + 'static>(mut self, on_change: F) -> Self {
278        self.on_change = Some(std::rc::Rc::new(on_change));
279        self
280    }
281
282    /// The values whose chips are rendered, and how many are hidden behind the
283    /// `+N` indicator.
284    fn displayed(&self) -> (Vec<String>, usize) {
285        let ordered = ordered_selection(&self.items, &self.selected);
286        match self.max_chips {
287            Some(max) if ordered.len() > max => {
288                let hidden = ordered.len() - max;
289                (ordered.into_iter().take(max).collect(), hidden)
290            }
291            _ => (ordered, 0),
292        }
293    }
294
295    /// The label `value` names, or the value itself when no item claims it.
296    fn label_of(&self, value: &str) -> String {
297        self.items
298            .iter()
299            .find(|item| item.value() == value)
300            .map_or_else(|| value.to_string(), |item| item.label().to_string())
301    }
302
303    /// The busy indicator's view, in a zero-or-one list so a `loading` flip
304    /// reconciles as an ordinary child-list length change.
305    fn busy_views(&self) -> Vec<AnyView<State>> {
306        if !self.loading {
307            return Vec::new();
308        }
309        vec![any(circular_progress(ProgressValue::Indeterminate)
310            .diameter(DROPDOWN_FIELD_LOADING_SIZE)
311            .stroke_width(DROPDOWN_FIELD_LOADING_STROKE))]
312    }
313
314    /// The single-select label a field shows, or `None` when the chips or the
315    /// hint take the content slot instead (`_buildFieldContent`'s ladder).
316    fn single_label(&self, ordered: &[String]) -> Option<String> {
317        if self.multi || ordered.is_empty() {
318            return None;
319        }
320        Some(self.label_of(&ordered[0]))
321    }
322
323    /// The trailing slot's content, in the reference's precedence order.
324    fn trailing(&self) -> Trailing {
325        if self.loading {
326            Trailing::Busy
327        } else if self.show_clear && self.enabled && !self.selected.is_empty() {
328            Trailing::Clear
329        } else {
330            Trailing::Arrow
331        }
332    }
333}
334
335/// The retained widget for a [`DropdownFieldView`].
336pub struct DropdownFieldWidget {
337    hint: Run,
338    /// The single-select label, present only when exactly one value is
339    /// selected and `multi` is off.
340    value_label: Option<Run>,
341    chips: Vec<Chip>,
342    /// The `+N` overflow indicator's run and count, when chips are capped.
343    overflow: Option<(Run, usize)>,
344    arrow: Glyph,
345    clear: Glyph,
346    delete: Glyph,
347    trailing: Trailing,
348    /// The busy indicator's pod, zero or one.
349    busy: Vec<ChildPod>,
350    multi: bool,
351    open: bool,
352    enabled: bool,
353    error: bool,
354    loading: bool,
355    /// Every selected value, in item order — what a delete subtracts from.
356    selected: Vec<String>,
357    anchor: OverlayAnchor,
358    on_open: Option<ErasedArgCallback<bool>>,
359    on_change: Option<ErasedArgCallback<Vec<String>>>,
360    /// The arrow's rotation ramp.
361    arrow_anim: AnimationController,
362    arrow_progress: f64,
363    arrow_started: bool,
364    /// The latched hover flag (the catalog's three-part hover seam).
365    hovered: bool,
366    pressed: bool,
367    press_inside: bool,
368    captured: bool,
369    /// Which chip's delete glyph a live press is armed on, if any.
370    armed_delete: Option<usize>,
371    /// Whether a live press is armed on the trailing clear affordance.
372    armed_clear: bool,
373    width: f64,
374    height: f64,
375    /// The trailing slot's box, in the field's own space.
376    trailing_rect: Rect,
377    /// The `+N` indicator's box, in the field's own space.
378    overflow_rect: Rect,
379}
380
381impl DropdownFieldWidget {
382    /// The field's resolved width, in logical px.
383    pub fn width(&self) -> f64 {
384        self.width
385    }
386
387    /// The field's resolved height, in logical px — which grows with the chip
388    /// strip.
389    pub fn height(&self) -> f64 {
390        self.height
391    }
392
393    /// The arrow's rotation, in radians: `0` closed, π open.
394    pub fn arrow_radians(&self) -> f64 {
395        self.arrow_progress * ARROW_TURN
396    }
397
398    /// The number of chips currently retained, including any still playing
399    /// their exit ramp.
400    pub fn chip_count(&self) -> usize {
401        self.chips.len()
402    }
403
404    /// Reconcile the retained chip strip against `next`.
405    ///
406    /// Three moves, in order: a value that vanished is **marked removing** and
407    /// its ramp reversed; a value that came back mid-exit is **revived**; and
408    /// the survivors are re-ordered into `next`'s order — which is item order,
409    /// the only order the reference ever renders — with every still-fading chip
410    /// slotted back at the index it already held, so a removal plays out where
411    /// it stood rather than jumping to the end.
412    fn sync_chips(&mut self, next: &[String], labels: &[String]) -> bool {
413        let mut changed = false;
414        for chip in self.chips.iter_mut() {
415            let present = next.iter().any(|v| v == &chip.value);
416            if present && chip.removing {
417                chip.removing = false;
418                chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
419                changed = true;
420            } else if !present && !chip.removing {
421                chip.removing = true;
422                chip.scale.fling(-DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
423                changed = true;
424            }
425        }
426        let mut fading: Vec<(usize, Chip)> = Vec::new();
427        let mut present: Vec<Chip> = Vec::new();
428        for (i, chip) in std::mem::take(&mut self.chips).into_iter().enumerate() {
429            if chip.removing {
430                fading.push((i, chip));
431            } else {
432                present.push(chip);
433            }
434        }
435        let mut ordered: Vec<Chip> = Vec::with_capacity(next.len() + fading.len());
436        for (value, label) in next.iter().zip(labels.iter()) {
437            match present.iter().position(|chip| &chip.value == value) {
438                Some(i) => ordered.push(present.remove(i)),
439                None => {
440                    ordered.push(Chip::new(value, label));
441                    changed = true;
442                }
443            }
444        }
445        // Nothing should be left — the marking pass above turns every absent
446        // chip into a fading one — but keep any straggler rather than dropping
447        // retained state on the floor.
448        ordered.append(&mut present);
449        for (index, chip) in fading {
450            let at = index.min(ordered.len());
451            ordered.insert(at, chip);
452        }
453        self.chips = ordered;
454        changed
455    }
456
457    /// Advance every chip's ramp and the arrow's, releasing a settled exit.
458    /// Returns whether another frame is needed.
459    ///
460    /// A live chip ramp also requests a **relayout**, not just a repaint: a
461    /// chip's scale drives its own advance in the strip (see
462    /// [`Chip::layout_scale`]), so its box and its delete region are only
463    /// truthful for the frame the layout that produced them ran in. Gating
464    /// that request solely on `advance`'s own return would miss its
465    /// *landing* frame — `AnimationController::advance` reports `false` on
466    /// the very pass that snaps the value onto its target — so each chip's
467    /// progress is compared before/after advancing, the same check the
468    /// reduce-motion arm already runs for its snap.
469    fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> bool {
470        let mut animating = false;
471        let now = ctx.frame_time();
472
473        if !self.arrow_started {
474            self.arrow_started = true;
475            self.arrow_progress = if self.open { 1.0 } else { 0.0 };
476        } else if reduce {
477            if self.arrow_anim.is_animating() {
478                self.arrow_anim.stop();
479            }
480            self.arrow_progress = if self.open { 1.0 } else { 0.0 };
481        } else if self.arrow_anim.is_animating() {
482            if self.arrow_anim.advance(now) {
483                animating = true;
484            }
485            self.arrow_progress = self.arrow_anim.value_clamped();
486        }
487
488        let mut settled: Vec<usize> = Vec::new();
489        let mut chips_moved = false;
490        for (i, chip) in self.chips.iter_mut().enumerate() {
491            let progress_before = chip.progress;
492            if !chip.started {
493                chip.started = true;
494                if reduce {
495                    chip.progress = 1.0;
496                } else {
497                    chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
498                }
499            }
500            if reduce {
501                if chip.scale.is_animating() {
502                    chip.scale.stop();
503                }
504                chip.progress = if chip.removing { 0.0 } else { 1.0 };
505                // The reduce-motion snap is still a layout-relevant value
506                // change (`layout_scale` feeds the chip's box/delete-hit-box
507                // sizing) — most commonly a freshly-mounted chip going
508                // 0.0 → 1.0, since `layout` already ran this frame against
509                // the pre-snap value. Request the same relayout the animating
510                // arm below requests for an in-flight ramp.
511                if chip.progress != progress_before {
512                    chips_moved = true;
513                }
514            } else if chip.scale.is_animating() {
515                if chip.scale.advance(now) {
516                    animating = true;
517                }
518                chip.progress = chip.scale.value();
519                // Comparing before/after (not just `advance`'s own return)
520                // is what actually catches the ramp's *landing* frame:
521                // `AnimationController::advance` reports `false` on the
522                // very pass that snaps the value onto its target, so
523                // gating solely on it would leave the last chip box/
524                // delete-hit-box `layout` ever saw a hair short of rest —
525                // the same trap the reduce-motion arm above already
526                // guards against.
527                if chip.progress != progress_before {
528                    chips_moved = true;
529                }
530            }
531            if chip.removing && chip.progress <= DROPDOWN_PROGRESS_EPSILON {
532                settled.push(i);
533            }
534        }
535        for i in settled.into_iter().rev() {
536            self.chips.remove(i);
537            chips_moved = true;
538        }
539        if chips_moved {
540            ctx.request_layout();
541        }
542        animating
543    }
544
545    /// Whether the content slot holds the chip strip rather than a label or
546    /// the hint — true for a multi-select with anything to show, including a
547    /// lone `+N` indicator.
548    fn has_chip_strip(&self) -> bool {
549        self.multi && (!self.chips.is_empty() || self.overflow.is_some())
550    }
551
552    /// Report the selection a delete of `value` requests.
553    fn delete_chip(&mut self, ctx: &mut EventCtx, value: &str) {
554        let Some(on_change) = self.on_change.as_mut() else {
555            return;
556        };
557        let next: Vec<String> = self
558            .selected
559            .iter()
560            .filter(|v| v.as_str() != value)
561            .cloned()
562            .collect();
563        on_change(ctx, next);
564    }
565
566    /// The chip whose delete glyph covers `pos`, if any.
567    fn delete_at(&self, pos: Point) -> Option<usize> {
568        if !self.enabled {
569            return None;
570        }
571        self.chips
572            .iter()
573            .position(|chip| !chip.removing && chip.delete.contains(pos))
574    }
575
576    /// The `Widget::event` pointer arm.
577    fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
578        let inside =
579            |pos: Point| pos.x >= 0.0 && pos.x < self.width && pos.y >= 0.0 && pos.y < self.height;
580        match p.phase {
581            PointerPhase::Move if !self.captured => {
582                let over = inside(p.position);
583                if over {
584                    ctx.claim_hover();
585                    if self.enabled {
586                        ctx.set_cursor(CursorIcon::Pointer);
587                    }
588                }
589                if self.hovered != over {
590                    self.hovered = over;
591                    ctx.request_redraw();
592                }
593                EventResult::Ignored
594            }
595            PointerPhase::Move => {
596                let still = inside(p.position);
597                if self.press_inside != still {
598                    self.press_inside = still;
599                    ctx.request_redraw();
600                }
601                EventResult::Handled
602            }
603            PointerPhase::Down => {
604                if !presses(p) || !inside(p.position) {
605                    return EventResult::Ignored;
606                }
607                // A chip's delete glyph and the clear affordance each own their
608                // own region; everything else is the field's own press.
609                self.armed_delete = self.delete_at(p.position);
610                self.armed_clear = self.armed_delete.is_none()
611                    && self.trailing == Trailing::Clear
612                    && self.trailing_rect.contains(p.position);
613                self.pressed = true;
614                self.press_inside = true;
615                self.captured = true;
616                ctx.capture_pointer();
617                ctx.request_redraw();
618                EventResult::Handled
619            }
620            PointerPhase::Up => {
621                if !self.captured {
622                    return EventResult::Ignored;
623                }
624                let released_inside = inside(p.position);
625                let armed_delete = self.armed_delete;
626                let armed_clear = self.armed_clear;
627                self.pressed = false;
628                self.press_inside = false;
629                self.captured = false;
630                self.armed_delete = None;
631                self.armed_clear = false;
632                ctx.request_redraw();
633                if !released_inside {
634                    return EventResult::Handled;
635                }
636                if let Some(i) = armed_delete {
637                    if self.delete_at(p.position) == Some(i) {
638                        let value = self.chips[i].value.clone();
639                        self.delete_chip(ctx, &value);
640                    }
641                    return EventResult::Handled;
642                }
643                if armed_clear {
644                    if self.trailing_rect.contains(p.position)
645                        && let Some(on_change) = self.on_change.as_mut()
646                    {
647                        on_change(ctx, Vec::new());
648                    }
649                    return EventResult::Handled;
650                }
651                // `_toggle`: a disabled or loading field opens nothing.
652                if self.enabled
653                    && !self.loading
654                    && let Some(on_open) = self.on_open.as_mut()
655                {
656                    let open = self.open;
657                    on_open(ctx, !open);
658                }
659                EventResult::Handled
660            }
661            // A `Cancel` arm touches no app state — only the flags the next
662            // move re-establishes.
663            PointerPhase::Cancel => {
664                if !self.captured {
665                    return EventResult::Ignored;
666                }
667                self.pressed = false;
668                self.press_inside = false;
669                self.captured = false;
670                self.armed_delete = None;
671                self.armed_clear = false;
672                ctx.request_redraw();
673                EventResult::Handled
674            }
675        }
676    }
677}
678
679impl<State: 'static> View<State> for DropdownFieldView<State> {
680    type Element = DropdownFieldWidget;
681
682    fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownFieldWidget {
683        let (displayed, hidden) = self.displayed();
684        let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
685        let chips = if self.multi {
686            displayed
687                .iter()
688                .zip(labels.iter())
689                .map(|(value, label)| Chip::new(value, label))
690                .collect()
691        } else {
692            Vec::new()
693        };
694        let ordered = ordered_selection(&self.items, &self.selected);
695        DropdownFieldWidget {
696            hint: Run::new(self.hint.clone()),
697            value_label: self.single_label(&ordered).map(Run::new),
698            chips,
699            overflow: (self.multi && hidden > 0).then(|| (Run::new(format!("+{hidden}")), hidden)),
700            arrow: Glyph::new(icons::KEYBOARD_ARROW_DOWN, DROPDOWN_ICON_SIZE),
701            // The reference's clear affordance is `Icons.clear`, the same glyph
702            // this catalog generated under the `CLOSE` name.
703            clear: Glyph::new(icons::CLOSE, DROPDOWN_ICON_SIZE),
704            delete: Glyph::new(icons::CLOSE, DROPDOWN_CHIP_ICON_SIZE),
705            trailing: self.trailing(),
706            busy: self
707                .busy_views()
708                .iter()
709                .map(|view| build_child(view, ctx))
710                .collect(),
711            multi: self.multi,
712            open: self.open,
713            enabled: self.enabled,
714            error: self.error,
715            loading: self.loading,
716            selected: ordered,
717            anchor: self.anchor.clone(),
718            on_open: self.on_open.as_ref().map(erase_callback_arg),
719            on_change: self.on_change.as_ref().map(erase_callback_arg),
720            arrow_anim: AnimationController::new(std::time::Duration::ZERO),
721            arrow_progress: 0.0,
722            arrow_started: false,
723            hovered: false,
724            pressed: false,
725            press_inside: false,
726            captured: false,
727            armed_delete: None,
728            armed_clear: false,
729            width: 0.0,
730            height: 0.0,
731            trailing_rect: Rect::ZERO,
732            overflow_rect: Rect::ZERO,
733        }
734    }
735
736    fn rebuild(
737        &self,
738        prev: &Self,
739        element: &mut DropdownFieldWidget,
740        ctx: &mut BuildCtx<'_>,
741    ) -> ChangeFlags {
742        let mut flags = rebuild_children(
743            &prev.busy_views(),
744            &self.busy_views(),
745            &mut element.busy,
746            ctx,
747            |view: &AnyView<State>| view,
748            |_| None,
749        );
750        if prev.hint != self.hint {
751            element.hint = Run::new(self.hint.clone());
752            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
753        }
754        let ordered = ordered_selection(&self.items, &self.selected);
755        let (displayed, hidden) = self.displayed();
756        let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
757        if element.selected != ordered || prev.items != self.items || element.multi != self.multi {
758            element.selected = ordered.clone();
759            element.multi = self.multi;
760            element.value_label = self.single_label(&ordered).map(Run::new);
761            if self.multi {
762                if element.sync_chips(&displayed, &labels) {
763                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
764                }
765            } else if !element.chips.is_empty() {
766                element.chips.clear();
767                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
768            }
769            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
770        }
771        let overflow = (self.multi && hidden > 0).then_some(hidden);
772        if element.overflow.as_ref().map(|(_, n)| *n) != overflow {
773            element.overflow = overflow.map(|n| (Run::new(format!("+{n}")), n));
774            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
775        }
776        if element.open != self.open {
777            element.open = self.open;
778            element.arrow_anim.fling(
779                if self.open {
780                    DROPDOWN_FLING_VELOCITY
781                } else {
782                    -DROPDOWN_FLING_VELOCITY
783                },
784                DROPDOWN_SPRING,
785            );
786            flags |= ChangeFlags::PAINT;
787        }
788        let trailing = self.trailing();
789        if element.trailing != trailing {
790            element.trailing = trailing;
791            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
792        }
793        if element.enabled != self.enabled
794            || element.error != self.error
795            || element.loading != self.loading
796        {
797            element.enabled = self.enabled;
798            element.error = self.error;
799            element.loading = self.loading;
800            flags |= ChangeFlags::PAINT;
801        }
802        element.anchor = self.anchor.clone();
803        // Closures aren't comparable, so the adapters are reinstalled
804        // unconditionally — cheap, and what every interactive widget does.
805        element.on_open = self.on_open.as_ref().map(erase_callback_arg);
806        element.on_change = self.on_change.as_ref().map(erase_callback_arg);
807        flags
808    }
809
810    fn teardown(&self, element: &mut DropdownFieldWidget, ctx: &mut BuildCtx<'_>) {
811        for (view, pod) in self.busy_views().iter().zip(element.busy.iter_mut()) {
812            teardown_child(view, pod, ctx);
813        }
814    }
815}
816
817/// The type styles a field's two text roles resolve to: `bodyLarge` for the
818/// hint and the selected label (`m3e_dropdown_menu_theme.dart:101`),
819/// `labelMedium` for a chip (`chipLabelStyle`).
820struct Styles {
821    body: TextStyle,
822    chip: TextStyle,
823}
824
825impl Styles {
826    /// Resolve both roles from `theme`, or from the M3 token literals when no
827    /// theme is threaded into the pass.
828    fn resolve(theme: Option<&Theme>) -> Self {
829        let (mut body, mut chip) = match theme {
830            Some(theme) => (
831                theme.type_scale.body_large.clone(),
832                theme.type_scale.label_medium.clone(),
833            ),
834            None => {
835                let body = TextStyle::new(16.0, SHAPING_INK);
836                let mut chip = TextStyle::new(12.0, SHAPING_INK);
837                chip.letter_spacing = 0.5;
838                (body, chip)
839            }
840        };
841        body.color = SHAPING_INK;
842        chip.color = SHAPING_INK;
843        Styles { body, chip }
844    }
845}
846
847impl Widget for DropdownFieldWidget {
848    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
849        let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
850        self.width = if bc.max().width.is_finite() {
851            bc.max().width
852        } else {
853            UNBOUNDED_WIDTH
854        };
855
856        // The trailing slot always reserves a full icon box, whatever it holds,
857        // so the content column's width never depends on which slot is showing.
858        let slot = DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
859        let content_width = (self.width - 2.0 * DROPDOWN_FIELD_H_PADDING - slot).max(0.0);
860
861        let content_height = if self.has_chip_strip() {
862            self.layout_chips(ctx, &styles, content_width)
863        } else if let Some(label) = self.value_label.as_mut() {
864            label.shape(ctx, &styles.body, content_width).height
865        } else {
866            self.hint.shape(ctx, &styles.body, content_width).height
867        };
868
869        self.height = 2.0 * DROPDOWN_FIELD_V_PADDING + content_height.max(DROPDOWN_ICON_SIZE);
870        self.trailing_rect = Rect::from_origin_size(
871            Point::new(
872                self.width - DROPDOWN_FIELD_H_PADDING - DROPDOWN_ICON_SIZE,
873                (self.height - DROPDOWN_ICON_SIZE) / 2.0,
874            ),
875            Size::new(DROPDOWN_ICON_SIZE, DROPDOWN_ICON_SIZE),
876        );
877
878        if let Some(pod) = self.busy.first_mut() {
879            let extent = DROPDOWN_FIELD_LOADING_SIZE;
880            pod.layout_child(ctx, &BoxConstraints::tight(Size::new(extent, extent)));
881            pod.set_origin(Point::new(
882                self.width - DROPDOWN_FIELD_H_PADDING - extent,
883                (self.height - extent) / 2.0,
884            ));
885        }
886
887        bc.constrain(Size::new(self.width, self.height))
888    }
889
890    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
891        // Authoritative hover read (the catalog's three-part hover seam).
892        if !ctx.is_hovered() {
893            self.hovered = false;
894        }
895        let theme = Theme::from_paint_ctx(ctx);
896        let colors = DropdownColors::resolve(theme);
897        let radius = container_radius(theme);
898        let reduce = crate::overlay::reduce_motion(theme);
899        if self.advance(ctx, reduce) {
900            ctx.request_frame();
901        }
902
903        // `PaintCtx::origin` is absolute window space — the one read that can
904        // answer "where is this trigger on screen", which is what the anchored
905        // panel needs.
906        let origin = ctx.origin();
907        self.anchor.set(Rect::from_origin_size(origin, ctx.size()));
908
909        let size = Size::new(self.width, self.height);
910        scene.fill_rounded_rect(origin, size, radius, colors.field_container);
911        let ink = if self.error {
912            colors.error
913        } else {
914            colors.field_content
915        };
916        // The reference's own overlay alphas, resolved off the field's ink
917        // rather than through this catalog's shared state-layer table, which
918        // uses different numbers.
919        let overlay = if self.pressed && self.press_inside {
920            DROPDOWN_FIELD_PRESSED_ALPHA
921        } else if self.hovered {
922            DROPDOWN_FIELD_HOVER_ALPHA
923        } else {
924            0.0
925        };
926        if overlay > 0.0 && self.enabled {
927            scene.fill_rounded_rect(origin, size, radius, with_alpha(ink, overlay));
928        }
929
930        let content_x = origin.x + DROPDOWN_FIELD_H_PADDING;
931        if self.has_chip_strip() {
932            self.paint_chips(origin, &colors, scene);
933        } else if let Some(label) = &self.value_label {
934            let height = label.size().height;
935            label.paint(
936                Point::new(content_x, origin.y + (self.height - height) / 2.0),
937                ink,
938                scene,
939            );
940        } else {
941            let height = self.hint.size().height;
942            self.hint.paint(
943                Point::new(content_x, origin.y + (self.height - height) / 2.0),
944                colors.hint,
945                scene,
946            );
947        }
948
949        match self.trailing {
950            Trailing::Busy => {
951                if let Some(pod) = self.busy.first_mut() {
952                    pod.paint_child(ctx, scene);
953                }
954            }
955            Trailing::Clear => {
956                self.clear
957                    .paint(self.trailing_rect.origin() + origin.to_vec2(), ink, scene)
958            }
959            Trailing::Arrow => self.arrow.paint_rotated(
960                self.trailing_rect.origin() + origin.to_vec2(),
961                ink,
962                self.arrow_radians(),
963                scene,
964            ),
965        }
966    }
967
968    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
969        if event.is_broadcast() {
970            for pod in self.busy.iter_mut() {
971                pod.event_child(ctx, event);
972            }
973            return EventResult::Ignored;
974        }
975        // The busy indicator is decoration: it takes no input, but the routing
976        // helper still keeps its capture bookkeeping honest.
977        if let Some(pod) = self.busy.first_mut()
978            && pod.is_active()
979        {
980            return route_event_single(pod, ctx, event);
981        }
982        match event {
983            InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
984            _ => EventResult::Ignored,
985        }
986    }
987
988    fn semantics(&self, ctx: &mut SemanticsCtx) {
989        let label = if let Some(value) = &self.value_label {
990            value.content.clone()
991        } else {
992            self.hint.content.clone()
993        };
994        let open = self.open;
995        let enabled = self.enabled;
996        ctx.push_node(Role::ComboBox, |node| {
997            node.set_label(label.as_str());
998            node.set_expanded(open);
999            if enabled {
1000                node.add_action(Action::Click);
1001            }
1002        });
1003    }
1004
1005    visit_children!(busy);
1006}
1007
1008impl DropdownFieldWidget {
1009    /// Lay the chip strip out into as many rows as `content_width` needs,
1010    /// returning the strip's total height (`chipStyle.wrap`'s `Wrap`).
1011    fn layout_chips(&mut self, ctx: &mut LayoutCtx, styles: &Styles, content_width: f64) -> f64 {
1012        let chip_width = |label: Size, deletable: bool| {
1013            let mut width = 2.0 * DROPDOWN_CHIP_H_PADDING + label.width;
1014            if deletable {
1015                width += DROPDOWN_CHIP_LABEL_GAP + DROPDOWN_CHIP_ICON_SIZE;
1016            }
1017            width
1018        };
1019        let deletable = self.enabled;
1020        // A chip never ellipsizes: the reference's chip label is a plain
1021        // `Text`, and the strip wraps instead.
1022        let mut sizes: Vec<Size> = Vec::with_capacity(self.chips.len());
1023        for chip in self.chips.iter_mut() {
1024            let natural = chip.label.natural_width(ctx, &styles.chip);
1025            sizes.push(chip.label.shape(ctx, &styles.chip, natural));
1026        }
1027        let overflow_size = self
1028            .overflow
1029            .as_mut()
1030            .map(|(run, _)| {
1031                let natural = run.natural_width(ctx, &styles.chip);
1032                run.shape(ctx, &styles.chip, natural)
1033            })
1034            .unwrap_or(Size::ZERO);
1035
1036        let row_height = sizes
1037            .iter()
1038            .map(|size| size.height)
1039            .fold(overflow_size.height, f64::max)
1040            .max(if deletable {
1041                DROPDOWN_CHIP_ICON_SIZE
1042            } else {
1043                0.0
1044            })
1045            + 2.0 * DROPDOWN_CHIP_V_PADDING;
1046
1047        let mut x = 0.0;
1048        let mut y = 0.0;
1049        for (i, chip) in self.chips.iter_mut().enumerate() {
1050            let width = chip_width(sizes[i], deletable) * chip.layout_scale();
1051            if x > 0.0 && x + width > content_width {
1052                x = 0.0;
1053                y += row_height + DROPDOWN_CHIP_RUN_SPACING;
1054            }
1055            chip.rect = Rect::from_origin_size(
1056                Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
1057                Size::new(width, row_height),
1058            );
1059            chip.delete = if deletable && width > 0.0 {
1060                Rect::from_origin_size(
1061                    Point::new(
1062                        chip.rect.x1 - DROPDOWN_CHIP_H_PADDING - DROPDOWN_CHIP_ICON_SIZE,
1063                        chip.rect.y0 + (row_height - DROPDOWN_CHIP_ICON_SIZE) / 2.0,
1064                    ),
1065                    Size::new(DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_ICON_SIZE),
1066                )
1067            } else {
1068                Rect::ZERO
1069            };
1070            x += width + DROPDOWN_CHIP_SPACING;
1071        }
1072        if self.overflow.is_some() {
1073            let width = 2.0 * DROPDOWN_CHIP_H_PADDING + overflow_size.width;
1074            if x > 0.0 && x + width > content_width {
1075                x = 0.0;
1076                y += row_height + DROPDOWN_CHIP_RUN_SPACING;
1077            }
1078            self.overflow_rect = Rect::from_origin_size(
1079                Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
1080                Size::new(width, row_height),
1081            );
1082        } else {
1083            self.overflow_rect = Rect::ZERO;
1084        }
1085        y + row_height
1086    }
1087
1088    /// Paint the chip strip: each chip's pill, its label, and its delete glyph,
1089    /// scaled about its own leading edge by the spring ramp.
1090    fn paint_chips(&self, origin: Point, colors: &DropdownColors, scene: &mut dyn PaintScene) {
1091        for chip in self.chips.iter() {
1092            if chip.progress <= DROPDOWN_PROGRESS_EPSILON {
1093                continue;
1094            }
1095            let rect = chip.rect + origin.to_vec2();
1096            scene.fill_rounded_rect(
1097                rect.origin(),
1098                rect.size(),
1099                DROPDOWN_CHIP_RADIUS,
1100                colors.chip_container,
1101            );
1102            // `Transform(alignment: Alignment.centerLeft)` — the scale pivots on
1103            // the chip's own leading edge, so a strip collapses leftwards.
1104            let pivot = kurbo::Vec2::new(rect.x0, rect.y0 + rect.height() / 2.0);
1105            let scale = chip.progress.clamp(0.0, 1.0);
1106            let transform = Affine::translate(pivot)
1107                * Affine::scale_non_uniform(scale, 1.0)
1108                * Affine::translate(-pivot);
1109            let label_size = chip.label.size();
1110            chip.label.paint_transformed(
1111                Point::new(
1112                    rect.x0 + DROPDOWN_CHIP_H_PADDING,
1113                    rect.y0 + (rect.height() - label_size.height) / 2.0,
1114                ),
1115                colors.chip_content,
1116                transform,
1117                scene,
1118            );
1119            if chip.delete.width() > 0.0 {
1120                self.delete.paint(
1121                    chip.delete.origin() + origin.to_vec2(),
1122                    colors.chip_content,
1123                    scene,
1124                );
1125            }
1126        }
1127        if let Some((run, _)) = &self.overflow {
1128            let size = run.size();
1129            let rect = self.overflow_rect + origin.to_vec2();
1130            scene.fill_rounded_rect(
1131                rect.origin(),
1132                rect.size(),
1133                DROPDOWN_CHIP_RADIUS,
1134                colors.chip_container,
1135            );
1136            run.paint(
1137                Point::new(
1138                    rect.x0 + DROPDOWN_CHIP_H_PADDING,
1139                    rect.y0 + (rect.height() - size.height) / 2.0,
1140                ),
1141                colors.chip_content,
1142                scene,
1143            );
1144        }
1145    }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151    use crate::dropdown::dropdown_item;
1152    use frust::authoring::scene::GlyphRun;
1153    use frust::authoring::{BezPath, Brush, Color, PointerButton, text::TextContext};
1154    use frust::{Brightness, FrameTime};
1155    use std::any::Any;
1156
1157    const AREA: Size = Size::new(320.0, 200.0);
1158
1159    #[derive(Default)]
1160    struct AppState {
1161        opens: Vec<bool>,
1162        selections: Vec<Vec<String>>,
1163    }
1164
1165    #[derive(Default)]
1166    struct Recorder {
1167        rrects: Vec<(Point, Size, f64, Color)>,
1168        paths: Vec<Point>,
1169        runs: usize,
1170    }
1171
1172    impl PaintScene for Recorder {
1173        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1174        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1175            self.rrects.push((origin, size, radius, color));
1176        }
1177        fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1178            self.paths.push(origin);
1179        }
1180        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1181        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1182            self.runs += 1;
1183        }
1184    }
1185
1186    impl Recorder {
1187        /// The alphas of every translucent rounded rect — the state-layer
1188        /// washes, filtered out of the opaque container fills.
1189        fn washes(&self) -> Vec<f32> {
1190            self.rrects
1191                .iter()
1192                .map(|(_, _, _, color)| color.components[3])
1193                .filter(|alpha| *alpha < 1.0)
1194                .collect()
1195        }
1196    }
1197
1198    fn fruit() -> Vec<DropdownItem> {
1199        vec![
1200            dropdown_item("Apple", "apple"),
1201            dropdown_item("Banana", "banana"),
1202            dropdown_item("Cherry", "cherry"),
1203        ]
1204    }
1205
1206    fn view() -> DropdownFieldView<AppState> {
1207        dropdown_field(fruit())
1208            .on_open(|state: &mut AppState, open| state.opens.push(open))
1209            .on_change(|state: &mut AppState, values| state.selections.push(values))
1210    }
1211
1212    fn build(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
1213        let mut counter = 0u64;
1214        View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1215    }
1216
1217    fn layout(w: &mut DropdownFieldWidget, area: Size) -> Size {
1218        let mut tcx = TextContext::new();
1219        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1220        w.layout(&mut lctx, &BoxConstraints::loose(area))
1221    }
1222
1223    fn ready(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
1224        let mut w = build(view);
1225        layout(&mut w, AREA);
1226        w
1227    }
1228
1229    fn rebuild(
1230        w: &mut DropdownFieldWidget,
1231        prev: &DropdownFieldView<AppState>,
1232        next: &DropdownFieldView<AppState>,
1233    ) {
1234        let mut counter = 0u64;
1235        View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1236    }
1237
1238    fn paint_at(w: &mut DropdownFieldWidget, now: FrameTime) -> Recorder {
1239        let theme = crate::baseline().with_brightness(Brightness::Light);
1240        let mut rec = Recorder::default();
1241        let size = Size::new(w.width, w.height);
1242        let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, now).with_theme(&theme as &dyn Any);
1243        w.paint(&mut ctx, &mut rec);
1244        rec
1245    }
1246
1247    fn paint(w: &mut DropdownFieldWidget) -> Recorder {
1248        paint_at(w, FrameTime::ZERO)
1249    }
1250
1251    /// Run the widget's own ramps forward far enough to settle.
1252    fn settle(w: &mut DropdownFieldWidget) {
1253        for step in 1..200 {
1254            paint_at(w, FrameTime::from_nanos(step * 16_000_000));
1255        }
1256    }
1257
1258    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1259        InputEvent::Pointer(PointerEvent {
1260            phase,
1261            position: Point::new(x, y),
1262            button: PointerButton::Primary,
1263        })
1264    }
1265
1266    fn dispatch(
1267        w: &mut DropdownFieldWidget,
1268        state: &mut AppState,
1269        event: &InputEvent,
1270    ) -> EventResult {
1271        let any: &mut dyn Any = state;
1272        let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
1273        w.event(&mut ctx, event)
1274    }
1275
1276    fn click(w: &mut DropdownFieldWidget, state: &mut AppState, at: Point) {
1277        dispatch(w, state, &pointer(PointerPhase::Down, at.x, at.y));
1278        dispatch(w, state, &pointer(PointerPhase::Up, at.x, at.y));
1279    }
1280
1281    // ---- the anchor -------------------------------------------------------
1282
1283    #[test]
1284    fn the_field_captures_its_own_rect_into_the_anchor_on_paint() {
1285        let anchor = OverlayAnchor::new();
1286        let mut w = ready(&view().anchor(&anchor));
1287        assert_eq!(anchor.rect(), Rect::ZERO, "nothing captured before paint");
1288        paint(&mut w);
1289        assert_eq!(
1290            anchor.rect(),
1291            Rect::from_origin_size(Point::ORIGIN, Size::new(w.width(), w.height()))
1292        );
1293    }
1294
1295    // ---- content ----------------------------------------------------------
1296
1297    #[test]
1298    fn an_empty_selection_shows_the_hint() {
1299        let mut w = ready(&view());
1300        assert!(w.value_label.is_none());
1301        let rec = paint(&mut w);
1302        assert!(rec.runs > 0);
1303    }
1304
1305    #[test]
1306    fn a_single_selection_shows_its_label_not_its_value() {
1307        let w = ready(&view().selected(vec!["banana".to_string()]));
1308        assert_eq!(
1309            w.value_label.as_ref().map(|run| run.content.as_str()),
1310            Some("Banana")
1311        );
1312    }
1313
1314    #[test]
1315    fn a_value_naming_no_item_is_dropped_rather_than_shown_raw() {
1316        let w = ready(&view().selected(vec!["durian".to_string()]));
1317        assert!(
1318            w.value_label.is_none(),
1319            "an unknown value normalizes away, leaving the hint"
1320        );
1321    }
1322
1323    // ---- the trailing slot ------------------------------------------------
1324
1325    #[test]
1326    fn the_trailing_slot_follows_the_reference_s_precedence() {
1327        assert_eq!(ready(&view()).trailing, Trailing::Arrow);
1328        assert_eq!(
1329            ready(&view().show_clear(true).selected(vec!["apple".to_string()])).trailing,
1330            Trailing::Clear
1331        );
1332        assert_eq!(
1333            ready(
1334                &view()
1335                    .show_clear(true)
1336                    .selected(vec!["apple".to_string()])
1337                    .loading(true)
1338            )
1339            .trailing,
1340            Trailing::Busy,
1341            "loading outranks the clear affordance"
1342        );
1343        assert_eq!(
1344            ready(&view().show_clear(true)).trailing,
1345            Trailing::Arrow,
1346            "nothing selected, nothing to clear"
1347        );
1348        assert_eq!(
1349            ready(
1350                &view()
1351                    .show_clear(true)
1352                    .enabled(false)
1353                    .selected(vec!["apple".to_string()])
1354            )
1355            .trailing,
1356            Trailing::Arrow,
1357            "a disabled field offers no clear affordance"
1358        );
1359    }
1360
1361    #[test]
1362    fn a_busy_field_mounts_the_indicator_pod() {
1363        let w = ready(&view().loading(true));
1364        assert_eq!(w.busy.len(), 1);
1365        let plain = ready(&view());
1366        assert_eq!(plain.busy.len(), 0);
1367    }
1368
1369    // ---- the arrow --------------------------------------------------------
1370
1371    #[test]
1372    fn the_arrow_rests_at_zero_closed_and_a_half_turn_open() {
1373        let mut closed = ready(&view());
1374        paint(&mut closed);
1375        assert_eq!(closed.arrow_radians(), 0.0);
1376
1377        let mut open = ready(&view().open(true));
1378        paint(&mut open);
1379        assert!((open.arrow_radians() - ARROW_TURN).abs() < 1e-9);
1380    }
1381
1382    #[test]
1383    fn opening_springs_the_arrow_round_and_closing_springs_it_back() {
1384        let prev = view();
1385        let mut w = ready(&prev);
1386        paint(&mut w);
1387        assert_eq!(w.arrow_radians(), 0.0);
1388
1389        let next = view().open(true);
1390        rebuild(&mut w, &prev, &next);
1391        settle(&mut w);
1392        assert!(
1393            (w.arrow_radians() - ARROW_TURN).abs() < 1e-3,
1394            "the arrow settled at a half turn, got {}",
1395            w.arrow_radians()
1396        );
1397
1398        rebuild(&mut w, &next, &prev);
1399        settle(&mut w);
1400        assert!(w.arrow_radians().abs() < 1e-3);
1401    }
1402
1403    // ---- pressing ---------------------------------------------------------
1404
1405    #[test]
1406    fn a_press_requests_the_opposite_open_state_and_never_flips_its_own() {
1407        let mut w = ready(&view());
1408        let mut state = AppState::default();
1409        let at = Point::new(40.0, w.height() / 2.0);
1410        click(&mut w, &mut state, at);
1411        assert_eq!(state.opens, vec![true]);
1412        assert!(!w.open, "controlled: the field waits to be told");
1413    }
1414
1415    #[test]
1416    fn an_open_field_requests_a_close() {
1417        let mut w = ready(&view().open(true));
1418        let mut state = AppState::default();
1419        let at = Point::new(40.0, w.height() / 2.0);
1420        click(&mut w, &mut state, at);
1421        assert_eq!(state.opens, vec![false]);
1422    }
1423
1424    #[test]
1425    fn a_disabled_or_loading_field_opens_nothing() {
1426        for v in [view().enabled(false), view().loading(true)] {
1427            let mut w = ready(&v);
1428            let mut state = AppState::default();
1429            let at = Point::new(40.0, w.height() / 2.0);
1430            click(&mut w, &mut state, at);
1431            assert!(state.opens.is_empty());
1432        }
1433    }
1434
1435    #[test]
1436    fn a_release_outside_the_field_reports_nothing() {
1437        let mut w = ready(&view());
1438        let mut state = AppState::default();
1439        let below = w.height() + 40.0;
1440        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 40.0, 10.0));
1441        dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 40.0, below));
1442        assert!(state.opens.is_empty());
1443    }
1444
1445    #[test]
1446    fn only_a_primary_press_arms_the_field() {
1447        let mut w = ready(&view());
1448        let mut state = AppState::default();
1449        let secondary = InputEvent::Pointer(PointerEvent {
1450            phase: PointerPhase::Down,
1451            position: Point::new(40.0, w.height() / 2.0),
1452            button: PointerButton::Secondary,
1453        });
1454        assert_eq!(
1455            dispatch(&mut w, &mut state, &secondary),
1456            EventResult::Ignored
1457        );
1458    }
1459
1460    #[test]
1461    fn a_cancel_clears_the_press_without_reporting() {
1462        let mut w = ready(&view());
1463        let mut state = AppState::default();
1464        let at = Point::new(40.0, w.height() / 2.0);
1465        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1466        dispatch(
1467            &mut w,
1468            &mut state,
1469            &pointer(PointerPhase::Cancel, at.x, at.y),
1470        );
1471        assert!(state.opens.is_empty());
1472        assert!(!w.pressed);
1473    }
1474
1475    #[test]
1476    fn the_clear_affordance_requests_an_empty_selection_instead_of_opening() {
1477        let mut w = ready(
1478            &view()
1479                .show_clear(true)
1480                .selected(vec!["apple".to_string(), "banana".to_string()]),
1481        );
1482        let mut state = AppState::default();
1483        let at = w.trailing_rect.center();
1484        click(&mut w, &mut state, at);
1485        assert_eq!(state.selections, vec![Vec::<String>::new()]);
1486        assert!(state.opens.is_empty(), "the clear press never toggles");
1487    }
1488
1489    // ---- chips ------------------------------------------------------------
1490
1491    #[test]
1492    fn a_multi_select_renders_one_chip_per_selected_option() {
1493        let w = ready(
1494            &view()
1495                .multi(true)
1496                .selected(vec!["apple".to_string(), "cherry".to_string()]),
1497        );
1498        assert_eq!(w.chip_count(), 2);
1499        assert_eq!(w.chips[0].label.content, "Apple");
1500        assert_eq!(w.chips[1].label.content, "Cherry");
1501    }
1502
1503    #[test]
1504    fn a_single_select_never_renders_a_chip() {
1505        let w = ready(&view().selected(vec!["apple".to_string()]));
1506        assert_eq!(w.chip_count(), 0);
1507    }
1508
1509    /// Options whose labels are long enough that three chips cannot share one
1510    /// row in an [`AREA`]-wide field.
1511    fn long_items() -> Vec<DropdownItem> {
1512        vec![
1513            dropdown_item("Passionfruit sorbet", "a"),
1514            dropdown_item("Blackcurrant cordial", "b"),
1515            dropdown_item("Clementine marmalade", "c"),
1516        ]
1517    }
1518
1519    #[test]
1520    fn chips_grow_the_field_past_the_hint_only_row() {
1521        let plain = ready(&view());
1522        let field = |selected: Vec<String>| {
1523            dropdown_field::<AppState>(long_items())
1524                .multi(true)
1525                .selected(selected)
1526        };
1527        let mut one = ready(&field(vec!["a".to_string()]));
1528        settle(&mut one);
1529        layout(&mut one, AREA);
1530        let mut many = ready(&field(vec![
1531            "a".to_string(),
1532            "b".to_string(),
1533            "c".to_string(),
1534        ]));
1535        settle(&mut many);
1536        layout(&mut many, AREA);
1537        assert!(one.height() >= plain.height() - 1e-6);
1538        assert!(
1539            many.height() > one.height(),
1540            "three long chips wrap past one row in a {}px field ({} vs {})",
1541            AREA.width,
1542            many.height(),
1543            one.height()
1544        );
1545    }
1546
1547    #[test]
1548    fn a_chip_springs_in_from_nothing() {
1549        let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
1550        paint(&mut w);
1551        assert!(w.chips[0].progress < 1.0, "the entrance starts from zero");
1552        settle(&mut w);
1553        assert!((w.chips[0].progress - 1.0).abs() < 1e-2);
1554    }
1555
1556    #[test]
1557    fn a_freshly_added_chip_lands_its_box_exactly_on_the_settle_frame() {
1558        // The landing-frame relayout guard: `AnimationController::advance`
1559        // reports `false` on the very pass that snaps a chip's scale onto
1560        // its target, so gating the relayout request solely on that return
1561        // would leave the last chip box `layout` ever saw a hair short of
1562        // rest. This drives a shell-honest frame loop — `layout` runs only
1563        // on a frame the *previous* paint actually asked for one — so a
1564        // silently-skipped landing frame would leave the chip's box short
1565        // of its natural width instead of landing on it exactly. `settle()`
1566        // (used by the sibling test above) can't catch this: it never
1567        // relayouts and never reads laid-out geometry, only the raw
1568        // `progress` value, which `paint` always updates regardless of what
1569        // it requests. See docs/REVIEW_FOCUS.md's layout-skip section.
1570        let prev = view().multi(true);
1571        let mut w = ready(&prev);
1572        let next = view().multi(true).selected(vec!["apple".to_string()]);
1573        rebuild(&mut w, &prev, &next);
1574        assert_eq!(w.chip_count(), 1);
1575
1576        let theme = crate::baseline().with_brightness(Brightness::Light);
1577        // Both signals matter here, unlike the single-value widgets this
1578        // pattern guards elsewhere: the ramp's zero-delta seeding frame
1579        // reports `needs_frame` (`animating`, from a truthy `advance`) but
1580        // not yet `needs_layout` (`chip.progress` hasn't visibly moved),
1581        // since `chips_moved` — not a bare truthy `advance` — is this
1582        // widget's only relayout trigger. A shell-honest loop must keep
1583        // pumping frames on `needs_frame` alone and only relayout on
1584        // `needs_layout`, exactly as a real shell would.
1585        let mut needs_layout = true;
1586        let mut needs_frame = true;
1587        let mut t = 0u64;
1588        let mut frames = 0u32;
1589        let mut last_width = w.chips[0].rect.width();
1590        loop {
1591            if !needs_frame {
1592                break;
1593            }
1594            if needs_layout {
1595                layout(&mut w, AREA);
1596                last_width = w.chips[0].rect.width();
1597            }
1598            let mut rec = Recorder::default();
1599            let size = Size::new(w.width, w.height);
1600            let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::from_nanos(t))
1601                .with_theme(&theme as &dyn Any);
1602            w.paint(&mut ctx, &mut rec);
1603            needs_layout = ctx.needs_layout();
1604            needs_frame = ctx.needs_frame();
1605            frames += 1;
1606            assert!(
1607                frames < 600,
1608                "the chip entrance should settle well inside 600 frames"
1609            );
1610            t += 16_000_000;
1611        }
1612        assert!(frames > 1, "the chip entrance spans more than one frame");
1613
1614        // Reference: brute-force settle well past the spring's resting
1615        // time, then one more relayout.
1616        settle(&mut w);
1617        layout(&mut w, AREA);
1618        let target_width = w.chips[0].rect.width();
1619
1620        assert_eq!(
1621            last_width, target_width,
1622            "the last layout a shell-honest driver runs lands exactly on the chip's settled box width"
1623        );
1624    }
1625
1626    #[test]
1627    fn reduce_motion_gives_a_freshly_added_chip_a_real_box_after_relayout() {
1628        // Regression guard: a chip added under reduce_motion snaps `progress`
1629        // straight to `1.0` in `paint`, but `layout_chips` sizes the chip's
1630        // rect and its delete hit box off `progress` — without a relayout
1631        // request the new chip keeps the zero-width box `Chip::new` seeds
1632        // (its label would still paint at full scale, and its delete hit
1633        // box would stay empty) until some unrelated change happened to
1634        // trigger one.
1635        let mut theme = crate::baseline().with_brightness(Brightness::Light);
1636        theme.motion.reduce_motion = true;
1637
1638        let prev = view().multi(true);
1639        let mut w = ready(&prev);
1640        let next = view().multi(true).selected(vec!["apple".to_string()]);
1641        rebuild(&mut w, &prev, &next);
1642        assert_eq!(w.chip_count(), 1);
1643        assert_eq!(w.chips[0].rect, Rect::ZERO, "not yet laid out");
1644
1645        let mut rec = Recorder::default();
1646        let size = Size::new(w.width, w.height);
1647        let mut ctx =
1648            PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1649        w.paint(&mut ctx, &mut rec);
1650        assert_eq!(w.chips[0].progress, 1.0, "snapped straight to visible");
1651        assert!(
1652            ctx.needs_layout(),
1653            "the snap changed a layout-relevant value, so it must request a relayout"
1654        );
1655
1656        // The relayout a real shell would run on seeing `needs_layout`.
1657        layout(&mut w, AREA);
1658        assert!(
1659            w.chips[0].rect.width() > 0.0,
1660            "the chip's box reflects the snapped-in progress after the requested relayout"
1661        );
1662        assert_ne!(
1663            w.chips[0].delete,
1664            Rect::ZERO,
1665            "the delete glyph gets a real hit box, not an empty one"
1666        );
1667    }
1668
1669    #[test]
1670    fn a_removed_chip_outlives_the_rebuild_that_dropped_it_and_then_settles_away() {
1671        let prev = view()
1672            .multi(true)
1673            .selected(vec!["apple".to_string(), "banana".to_string()]);
1674        let mut w = ready(&prev);
1675        settle(&mut w);
1676        assert_eq!(w.chip_count(), 2);
1677
1678        let next = view().multi(true).selected(vec!["banana".to_string()]);
1679        rebuild(&mut w, &prev, &next);
1680        assert_eq!(
1681            w.chip_count(),
1682            2,
1683            "the removed chip is retained for its exit ramp"
1684        );
1685        assert!(w.chips[0].removing);
1686        settle(&mut w);
1687        assert_eq!(w.chip_count(), 1);
1688        assert_eq!(w.chips[0].value, "banana");
1689    }
1690
1691    #[test]
1692    fn a_chip_re_added_mid_exit_is_revived_rather_than_duplicated() {
1693        let prev = view().multi(true).selected(vec!["apple".to_string()]);
1694        let mut w = ready(&prev);
1695        settle(&mut w);
1696        let gone = view().multi(true);
1697        rebuild(&mut w, &prev, &gone);
1698        assert!(w.chips[0].removing);
1699        rebuild(&mut w, &gone, &prev);
1700        assert_eq!(w.chip_count(), 1);
1701        assert!(!w.chips[0].removing);
1702    }
1703
1704    #[test]
1705    fn a_chip_s_delete_glyph_requests_the_selection_without_that_value() {
1706        let mut w = ready(
1707            &view()
1708                .multi(true)
1709                .selected(vec!["apple".to_string(), "banana".to_string()]),
1710        );
1711        settle(&mut w);
1712        layout(&mut w, AREA);
1713        let mut state = AppState::default();
1714        let at = w.chips[0].delete.center();
1715        click(&mut w, &mut state, at);
1716        assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1717        assert!(state.opens.is_empty(), "a delete never toggles the panel");
1718    }
1719
1720    #[test]
1721    fn a_press_on_the_chip_body_falls_through_to_the_field_s_own_toggle() {
1722        let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
1723        settle(&mut w);
1724        layout(&mut w, AREA);
1725        let mut state = AppState::default();
1726        let chip = w.chips[0].rect;
1727        let at = Point::new(chip.x0 + 2.0, chip.center().y);
1728        click(&mut w, &mut state, at);
1729        assert!(state.selections.is_empty());
1730        assert_eq!(state.opens, vec![true]);
1731    }
1732
1733    #[test]
1734    fn a_disabled_field_s_chips_offer_no_delete_region() {
1735        let mut w = ready(
1736            &view()
1737                .multi(true)
1738                .enabled(false)
1739                .selected(vec!["apple".to_string()]),
1740        );
1741        settle(&mut w);
1742        layout(&mut w, AREA);
1743        assert_eq!(w.chips[0].delete, Rect::ZERO);
1744    }
1745
1746    #[test]
1747    fn max_chips_hides_the_tail_behind_an_overflow_indicator() {
1748        let mut w = ready(&view().multi(true).max_chips(2).selected(vec![
1749            "apple".to_string(),
1750            "banana".to_string(),
1751            "cherry".to_string(),
1752        ]));
1753        assert_eq!(w.chip_count(), 2);
1754        assert_eq!(w.overflow.as_ref().map(|(_, n)| *n), Some(1));
1755        assert_eq!(
1756            w.overflow.as_ref().map(|(run, _)| run.content.as_str()),
1757            Some("+1")
1758        );
1759        settle(&mut w);
1760        layout(&mut w, AREA);
1761        let rec = paint(&mut w);
1762        // The container, two chips and the overflow pill.
1763        assert!(rec.rrects.len() >= 4);
1764    }
1765
1766    // ---- paint ------------------------------------------------------------
1767
1768    #[test]
1769    fn the_container_fills_with_surface_container_highest_at_the_panel_radius() {
1770        let theme = crate::baseline().with_brightness(Brightness::Light);
1771        let mut w = ready(&view());
1772        let rec = paint(&mut w);
1773        let (origin, size, radius, color) = rec.rrects[0];
1774        assert_eq!(origin, Point::ORIGIN);
1775        assert_eq!(size, Size::new(w.width(), w.height()));
1776        assert_eq!(radius, theme.shape.extra_large);
1777        assert_eq!(color, theme.scheme().surface_container_highest);
1778    }
1779
1780    #[test]
1781    fn a_press_washes_the_container_at_the_reference_s_pressed_alpha() {
1782        let mut w = ready(&view());
1783        let mut state = AppState::default();
1784        let at = Point::new(40.0, w.height() / 2.0);
1785        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1786        let pressed = paint(&mut w);
1787        assert_eq!(
1788            pressed.rrects[1].3.components[3],
1789            DROPDOWN_FIELD_PRESSED_ALPHA
1790        );
1791    }
1792
1793    /// The hover link is recorded by the root's event pass and read back
1794    /// through `PaintCtx::is_hovered`, so a real [`RenderRoot`] is the only
1795    /// harness that can exercise it at all — the same shape
1796    /// [`crate::list_item`]'s own hover tests take.
1797    #[test]
1798    fn hovering_the_field_washes_it_at_the_reference_s_hover_alpha() {
1799        use frust_core::RenderRoot;
1800        let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
1801        let mut state = AppState::default();
1802        let mut tcx = TextContext::new();
1803        let mut logic = move |_s: &mut AppState| view();
1804        root.rebuild(&mut logic, &mut state);
1805        root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1806
1807        let mut rest = Recorder::default();
1808        root.paint(&mut rest, FrameTime::ZERO);
1809        assert_eq!(rest.washes(), Vec::<f32>::new(), "no wash at rest");
1810
1811        root.event(&mut state, &pointer(PointerPhase::Move, 40.0, 20.0));
1812        let mut hovered = Recorder::default();
1813        root.paint(&mut hovered, FrameTime::ZERO);
1814        assert_eq!(hovered.washes(), vec![DROPDOWN_FIELD_HOVER_ALPHA]);
1815    }
1816
1817    #[test]
1818    fn a_disabled_field_never_washes() {
1819        let mut w = ready(&view().enabled(false));
1820        let mut state = AppState::default();
1821        let at = Point::new(40.0, w.height() / 2.0);
1822        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
1823        let rec = paint(&mut w);
1824        assert_eq!(rec.rrects.len(), 1, "the container and nothing else");
1825    }
1826
1827    #[test]
1828    fn an_error_field_inks_its_content_in_the_error_role() {
1829        let theme = crate::baseline().with_brightness(Brightness::Light);
1830        let mut w = ready(&view().error(true).selected(vec!["apple".to_string()]));
1831        let mut state = AppState::default();
1832        let at = Point::new(40.0, w.height() / 2.0);
1833        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1834        let rec = paint(&mut w);
1835        let wash = rec.rrects[1].3;
1836        let error = theme.scheme().error;
1837        assert_eq!(wash.components[..3], error.components[..3]);
1838    }
1839
1840    #[test]
1841    fn the_arrow_is_the_one_glyph_a_resting_field_paints() {
1842        let mut w = ready(&view());
1843        let rec = paint(&mut w);
1844        assert_eq!(rec.paths.len(), 1);
1845    }
1846
1847    #[test]
1848    fn a_paint_with_no_hover_link_clears_the_latched_flag() {
1849        let mut w = ready(&view());
1850        let mut state = AppState::default();
1851        let at = Point::new(40.0, w.height() / 2.0);
1852        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
1853        assert!(w.hovered);
1854        // `PaintCtx::for_test` reports no hover path.
1855        paint(&mut w);
1856        assert!(!w.hovered);
1857    }
1858
1859    #[test]
1860    fn the_semantics_node_is_a_combo_box_carrying_the_open_state() {
1861        use frust_core::RenderRoot;
1862        let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
1863        let mut state = AppState::default();
1864        let mut tcx = TextContext::new();
1865        let mut logic = move |_s: &mut AppState| view().open(true);
1866        root.rebuild(&mut logic, &mut state);
1867        root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1868        let tree = root.semantics();
1869        let node = tree
1870            .nodes
1871            .iter()
1872            .find(|(_, node)| node.role() == Role::ComboBox)
1873            .expect("a combo-box node");
1874        assert!(node.1.is_expanded().unwrap_or(false));
1875    }
1876}