frust_material/dropdown/mod.rs
1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/dropdown_menus/` — `m3e_dropdown_menus.dart` (the widget's
3// prop set), `controllers/m3e_dropdown_controller.dart` (selection + filter
4// rules), `components/` (`m3e_dropdown_menu_field.dart`,
5// `m3e_dropdown_menu_panel.dart`, `m3e_dropdown_menu_item.dart`,
6// `m3e_dropdown_chips.dart`, `m3e_dropdown_menu_actions.dart`,
7// `m3e_dropdown_menu_lifecycle.dart`, `m3e_dropdown_menu_chip_build.dart`) and
8// `styles/` (`m3e_dropdown_menu_theme.dart` plus the five per-part styles),
9// retrieved 2026-08-20. That family is itself upstream's vendoring of
10// <https://github.com/Mudit200408/m3e_dropdown_menu>.
11// Upstream: <https://github.com/paadevelopments/material_3_expressive>
12// Porting decisions (each expanded in the module docs below): the reference's
13// `FormField` wrapper — `validator`, `autovalidateMode`, the inline error line
14// and Flutter's restoration plumbing — is **descoped**; the reference's own
15// `OverlayPortal` + expand/collapse spring is **not** ported (this catalog
16// already merged one anchored host, `crate::overlay::anchored`, which owns
17// placement, light dismiss, Escape and the enter/exit ramp); `M3EDropdownMenu
18// .future` becomes a poll seam (`loading` + app-fed items); and selection lives
19// with the app rather than on the item, per this catalog's controlled-component
20// rule.
21
22//! The Material 3 Expressive **dropdown menu**: a text-field-shaped trigger and
23//! the option panel it anchors — single-select, multi-select with chips inside
24//! the field, type-to-filter search, and async option loading.
25//!
26//! # Two views, because there is no portal
27//!
28//! A widget paints inside its parent's box, so the panel cannot live inside the
29//! field. The family is therefore the same two-piece shape [`mod@crate::menu`]
30//! takes:
31//!
32//! 1. [`dropdown_field`] — the trigger, mounted in the app's own layout. It
33//! captures its window-space rect into an [`OverlayAnchor`] on every paint
34//! (the same one-line capture [`crate::overlay::overlay_anchor`] performs,
35//! inlined so a field needs no wrapper).
36//! 2. [`dropdown`] — [`dropdown_panel`] inside [`crate::overlay::anchored`],
37//! mounted as the top child of a full-area [`frust::Stack`] (or a
38//! transparent navigator page), pointed at the same anchor.
39//!
40//! Both take the same `items`/`selected` props, and both are **controlled**:
41//! neither ever mutates a selection, an open flag or a query of its own. Every
42//! interaction reports the value it *requests* and waits for the app to feed it
43//! back down — the catalog-wide rule (`docs/CODE_STANDARDS.md`'s Interaction
44//! Semantics), and the reason [`DropdownItem`] carries no `selected` field even
45//! though the reference's `M3EDropdownItem` does.
46//!
47//! # The host owns the ramp
48//!
49//! The reference drives its own `_expandCtrl`/`_arrowCtrl` springs and inserts
50//! its own `OverlayPortal` with a hand-rolled outside-tap `Listener`
51//! (`m3e_dropdown_menu_panel.dart:5`, `m3e_dropdown_menu_actions.dart:5`).
52//! **None of the panel half is ported**: [`crate::overlay::anchored`] already
53//! places the panel, light-dismisses, handles Escape and composites the
54//! fade + anchor-pivoted scale. The one spring that *is* ported is the field's
55//! arrow rotation, which is the trigger's own chrome — see [`mod@field`].
56//!
57//! An app that wants the panel's **exit** ramp keeps [`dropdown`] mounted and
58//! toggles [`DropdownView::open`] rather than unmounting it (the kept-mounted
59//! pattern that host documents).
60//!
61//! # Descoped: form-field validation and restoration
62//!
63//! The reference wraps its whole tree in a Flutter `FormField`
64//! (`m3e_dropdown_menus.dart:355`) for `validator`/`autovalidateMode`, renders
65//! an error line under the field, and recolors the field's border through
66//! `formState.hasError`. **None of that ships here**: this workspace has no
67//! `Form`/`FormField` equivalent to register with, and Flutter's state
68//! restoration has no counterpart at all. An app that needs validation renders
69//! its own message beside the field and drives [`DropdownFieldView::error`] for
70//! the error-colored container.
71//!
72//! # Async options: a poll seam, not a `Future`
73//!
74//! `M3EDropdownMenu.future` owns the load: it calls the provider in `initState`,
75//! flips a private `_isLoading`, and writes the result into its own controller
76//! (`m3e_dropdown_menu_lifecycle.dart:128`). A controlled widget cannot own
77//! that, and this crate has no established async widget seam (neither
78//! [`mod@crate::text_field`] nor [`mod@crate::slider`] has one). The adaptation
79//! is a **poll seam**: the app loads options however it likes
80//! (`frust::use_task`, `frust::spawn`, a plugin call) and feeds
81//! [`DropdownView::loading`], [`DropdownView::error`] and the `items` list back
82//! down. While `loading` is set the panel shows the reference's centered
83//! indicator row and the field's trailing slot swaps its arrow for a small one,
84//! exactly as `_buildFieldTrailing`/`_buildPanelBody` do; the field also refuses
85//! to open, matching `_toggle`'s `if (!widget.enabled || _isLoading) return`.
86//!
87//! # Divergences from [`mod@crate::menu`]'s item rendering
88//!
89//! The row vocabulary is shared — rows are **data** with cached shaped runs and
90//! parsed icon paths, not child pods, so per-state ink stays expressible — but
91//! the reference's dropdown item is a different control from its menu item, so
92//! nothing is reused verbatim:
93//!
94//! | | `crate::menu` row | dropdown row |
95//! |---|---|---|
96//! | container | transparent, on one elevated surface | its own `surfaceContainerHigh` card |
97//! | corners | one `item_radius` (12) | the M3E card-list treatment: [`DROPDOWN_ITEM_OUTER_RADIUS`] caps, [`DROPDOWN_ITEM_INNER_RADIUS`] inside, morphing on hover/press |
98//! | selected fill | `tertiaryContainer` | `secondaryContainer` |
99//! | label role | `labelLarge` | `bodyLarge` |
100//! | selected check | **leading**, at 90% icon size | **trailing**, at full icon size |
101//! | extra slots | supporting text, shortcut, submenu chevron | none |
102//!
103//! The corner rule is literally the card list's: this module resolves it
104//! through [`crate::card_list::card_position`]/[`crate::card_list::card_radii`],
105//! which is what the reference's own comment points at
106//! (`m3e_dropdown_item_style.dart:39`, *"mirroring the M3E card list
107//! treatment"*). One reference quirk is kept: a **selected** row takes a uniform
108//! radius and loses its cap corners entirely (`_calculateBaseRadius`'s first
109//! branch short-circuits before the first/last cases).
110//!
111//! The one addition over the reference: a row paints this catalog's shared
112//! [`InteractionState`](crate::interaction::InteractionState) state layer. The
113//! reference delegates its overlay to `M3ECard`, whose tappable surface paints
114//! one for it; resolving it here keeps the row a plain painted band.
115//!
116//! # Divergences from [`mod@crate::chips`]
117//!
118//! The multi-select chips inside the field are **not** [`crate::input_chip`]s.
119//! The reference ships its own `M3ESpringChip` (`m3e_dropdown_chips.dart`) with
120//! its own metrics, and they differ from `M3EChipTheme`'s on every axis but the
121//! color pair:
122//!
123//! | | `crate::chips` input chip | dropdown chip |
124//! |---|---|---|
125//! | corner radius | 8dp (`shape.small`) | [`DROPDOWN_CHIP_RADIUS`], 20dp |
126//! | horizontal insets | 16dp leading / 12dp trailing | [`DROPDOWN_CHIP_H_PADDING`] both sides |
127//! | height | a fixed 32dp | its label's line box plus [`DROPDOWN_CHIP_V_PADDING`], so the strip breathes with the type scale |
128//! | label role | the nested text's themed role | `labelMedium` |
129//! | fill / ink | `secondaryContainer` / `onSecondaryContainer` | the same pair |
130//!
131//! They are painted by the field rather than mounted as `input_chip` pods for
132//! two reasons: a pod cannot be spring-scaled about its own leading edge from
133//! the field's paint pass, and a removed chip has to outlive the rebuild that
134//! dropped it to play its exit. The one place this port takes the chip
135//! catalog's number over the reference's is the delete glyph
136//! ([`DROPDOWN_CHIP_ICON_SIZE`]) — see that constant.
137//!
138//! The reference's chip choreography is ported **in part**: the spring
139//! entrance and exit are here; the sibling *slide* + *squish* micro-animations
140//! that follow an insertion or a removal (`_triggerInsertionSquish`,
141//! `_animateChipsAfterRemoval`) are not, and neither is the non-wrapping
142//! `Flow`-delegate row they exist to drive (`chipStyle.wrap` defaults to `true`,
143//! which is the mode ported here).
144//!
145//! One adaptation covers most of what those micro-animations buy. A Flutter
146//! `Transform` scales what is *painted* and leaves the layout box alone, which
147//! is exactly why the reference needs an explicit slide to close the gap a
148//! shrinking chip leaves behind. Here a chip's spring drives its **advance in
149//! the strip** as well as its paint, so its neighbours travel into the space it
150//! vacates for free — the same outcome, without the `Flow` delegate. The price
151//! is one relayout per animated frame, which the field asks for from its own
152//! paint pass.
153//!
154//! # Keyboard, and the focus gap
155//!
156//! Up/Down move the panel's highlight, Enter activates it, Escape closes
157//! through the host. This is an **addition**: the reference ships no keyboard
158//! handling for the dropdown at all. It is also gated on the framework's focus
159//! seam — the panel only sees key events once it holds focus, which it claims on
160//! a press inside itself, so a caller must complete one pointer interaction with
161//! the panel (or its search field) before the arrows do anything. There is no
162//! auto-focus-on-appear hook and no focus-traversal seam in the framework, the
163//! same gap [`crate::overlay::anchored`] records for Escape.
164
165pub mod field;
166pub mod panel;
167
168pub use field::{DropdownFieldView, DropdownFieldWidget, dropdown_field};
169pub use panel::{DropdownPanelView, DropdownPanelWidget, dropdown_panel};
170
171use std::rc::Rc;
172
173use frust::authoring::text::{TextContext, TextLayout, TextOverflow, TextStyle};
174use frust::authoring::{
175 Affine, BezPath, Brush, BuildCtx, ChangeFlags, LayoutCtx, PaintScene, Point, Size, View,
176};
177use frust::{Color, IconData, SpringDesc, Theme};
178
179use crate::interaction::DISABLED_CONTENT_OPACITY;
180use crate::overlay::{
181 AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP, OverlayAlign, OverlayAnchor,
182 OverlayPlacement, OverlaySide, anchored_overlay, with_alpha,
183};
184use crate::tokens::{MaterialDimensions, MaterialSpacing, MaterialSpring};
185
186// ---- Metrics --------------------------------------------------------------
187//
188// Every value below is the reference's own default. Where one lands exactly on
189// a Material spacing/shape token it resolves to that token rather than
190// repeating a literal, the same rule `crate::menu`'s metrics follow.
191//
192// Note which defaults actually render: `M3EDropdownMenuTheme` carries
193// `itemOuterRadius: 12` / `itemInnerRadius: 4`, but the item widget reads
194// `M3EDropdownItemStyle` instead (`m3e_dropdown_menu_item.dart:84`), whose own
195// constructor defaults are `outerRadius: null → 12` and `innerRadius: 6`. The
196// style's values are what a stock dropdown paints, so they are what is pinned
197// here.
198
199/// Left/right inset from the field's edge to its content
200/// (`M3EDropdownFieldStyle.padding`, `horizontal: 16`).
201pub const DROPDOWN_FIELD_H_PADDING: f64 = MaterialSpacing::LG;
202/// Top/bottom inset of the field's content (`padding`, `vertical: 14`) — the
203/// one field metric with no Material spacing token to resolve against.
204pub const DROPDOWN_FIELD_V_PADDING: f64 = 14.0;
205/// Gap between the field's content and its trailing slot
206/// (`m3e_dropdown_menu_field.dart:222`'s `SizedBox(width: 8)`).
207pub const DROPDOWN_ICON_GAP: f64 = MaterialSpacing::SM;
208/// A trailing/selected icon's side length. The reference reads
209/// `M3EThemeData.resolvedIconTheme.size`; 24dp is that theme's own default and
210/// the same value [`mod@crate::menu`] pins.
211pub const DROPDOWN_ICON_SIZE: f64 = 24.0;
212/// Diameter of the field's trailing busy indicator, in logical px
213/// (`m3e_dropdown_menu_field.dart:76`'s `SizedBox(width: 18, height: 18)`).
214pub const DROPDOWN_FIELD_LOADING_SIZE: f64 = 18.0;
215/// Stroke width of that indicator (`strokeWidth: 2`).
216pub const DROPDOWN_FIELD_LOADING_STROKE: f64 = 2.0;
217
218/// The panel's height cap, past which it scrolls
219/// (`M3EDropdownPanelStyle.maxHeight`).
220pub const DROPDOWN_PANEL_MAX_HEIGHT: f64 = 350.0;
221/// Gap between the field and the panel (`M3EDropdownPanelStyle.marginTop`, 4) —
222/// the same 4dp [`OVERLAY_ANCHOR_GAP`] the anchored host defaults to.
223pub const DROPDOWN_PANEL_GAP: f64 = OVERLAY_ANCHOR_GAP;
224/// Padding around the panel's item list (`contentPadding: EdgeInsets.all(8)`).
225pub const DROPDOWN_CONTENT_PADDING: f64 = MaterialSpacing::SM;
226/// Padding around the panel's empty/error message (`EdgeInsets.all(16)`).
227pub const DROPDOWN_MESSAGE_PADDING: f64 = MaterialSpacing::LG;
228/// Padding around the panel's busy indicator (`EdgeInsets.all(24)`).
229pub const DROPDOWN_LOADING_PADDING: f64 = MaterialSpacing::XL;
230
231/// Left/right inset inside one option row
232/// (`M3EDropdownItemStyle.itemPadding`, `horizontal: 16`).
233pub const DROPDOWN_ITEM_H_PADDING: f64 = MaterialSpacing::LG;
234/// Top/bottom inset inside one option row (`itemPadding`, `vertical: 12`).
235pub const DROPDOWN_ITEM_V_PADDING: f64 = MaterialSpacing::MD;
236/// Vertical gap between two option rows
237/// (`m3e_dropdown_menu_panel.dart:177`'s `itemGap ?? 3.0`) — the one row metric
238/// with no Material spacing token to resolve against.
239pub const DROPDOWN_ITEM_GAP: f64 = 3.0;
240/// A row's cap-corner radius (`M3EDropdownItemStyle.outerRadius ?? 12.0`).
241pub const DROPDOWN_ITEM_OUTER_RADIUS: f64 = MaterialDimensions::RADIUS_MEDIUM;
242/// A row's inner-corner radius at rest (`innerRadius: 6.0`).
243pub const DROPDOWN_ITEM_INNER_RADIUS: f64 = 6.0;
244/// A row's inner-corner radius while hovered (`hoverRadius: 8.0`).
245pub const DROPDOWN_ITEM_HOVER_RADIUS: f64 = MaterialDimensions::RADIUS_SMALL;
246/// A row's inner-corner radius while pressed (`pressedRadius: 4.0`).
247pub const DROPDOWN_ITEM_PRESSED_RADIUS: f64 = MaterialDimensions::RADIUS_EXTRA_SMALL;
248
249/// Left/right inset inside a selected-option chip
250/// (`M3EDropdownChipStyle.padding`, `horizontal: 8`).
251pub const DROPDOWN_CHIP_H_PADDING: f64 = MaterialSpacing::SM;
252/// Top/bottom inset inside a chip (`padding`, `vertical: 4`).
253pub const DROPDOWN_CHIP_V_PADDING: f64 = MaterialSpacing::XS;
254/// A chip's corner radius (`borderRadius: BorderRadius.circular(20)`) — a
255/// visual constant with no Material shape token at 20dp, and deliberately not
256/// [`crate::chips`]' own 8dp (see the module docs' chip divergence).
257pub const DROPDOWN_CHIP_RADIUS: f64 = 20.0;
258/// Horizontal gap between two chips (`spacing: 6`).
259pub const DROPDOWN_CHIP_SPACING: f64 = 6.0;
260/// Vertical gap between two chip rows (`runSpacing: 6`).
261pub const DROPDOWN_CHIP_RUN_SPACING: f64 = 6.0;
262/// Gap between a chip's label and its delete glyph
263/// (`m3e_dropdown_chips.dart:186`'s `SizedBox(width: 4)`).
264pub const DROPDOWN_CHIP_LABEL_GAP: f64 = MaterialSpacing::XS;
265/// A chip's delete-glyph side length. The reference reads the theme's icon size
266/// (24dp) here too; this port takes [`crate::chips`]' own 18dp delete-icon size
267/// instead, so the two chip families agree on the affordance — the module docs'
268/// one deliberate metric deviation.
269pub const DROPDOWN_CHIP_ICON_SIZE: f64 = 18.0;
270
271/// The field's placeholder when nothing is selected
272/// (`m3e_dropdown_menu_field.dart:167`'s `hintText ?? 'Select'`).
273pub const DROPDOWN_HINT: &str = "Select";
274/// The panel's message when the filter matches nothing
275/// (`M3EDropdownPanelStyle.noItemsFoundText`).
276pub const DROPDOWN_EMPTY_TEXT: &str = "No items found";
277/// The search field's placeholder (`M3EDropdownSearchStyle.hintText`).
278pub const DROPDOWN_SEARCH_HINT: &str = "Search…";
279
280/// The field's hover overlay alpha (`m3e_dropdown_menu_field.dart:272`).
281pub const DROPDOWN_FIELD_HOVER_ALPHA: f32 = 0.05;
282/// The field's pressed overlay alpha (`m3e_dropdown_menu_field.dart:269`).
283pub const DROPDOWN_FIELD_PRESSED_ALPHA: f32 = 0.10;
284/// The hint's ink opacity over `onSurface`
285/// (`m3e_dropdown_menu_theme.dart:103`).
286pub const DROPDOWN_HINT_OPACITY: f32 = 0.6;
287/// The empty-state message's ink opacity over `onSurface`
288/// (`m3e_dropdown_menu_panel.dart:164`).
289pub const DROPDOWN_EMPTY_OPACITY: f32 = 0.5;
290/// A disabled row's container opacity over `onSurface`
291/// (`m3e_dropdown_menu_item.dart:110`).
292pub const DROPDOWN_DISABLED_CONTAINER_OPACITY: f32 = 0.04;
293
294/// The spring both the field's arrow and a chip's scale ride
295/// ([`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`], the reference's
296/// `M3EMotion.expressiveSpatialDefault` default for `openMotion`/`closeMotion`).
297pub(crate) const DROPDOWN_SPRING: SpringDesc = SpringDesc {
298 mass: 1.0,
299 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
300 damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
301};
302
303/// The velocity a spring ramp is released with. A spring settles on the target
304/// its velocity's *sign* picks, so the magnitude only shapes the launch; this
305/// is [`mod@crate::toggle_button`]'s own `FLING_VELOCITY`, kept in step so
306/// every spring-morphing control in the catalog launches alike.
307pub(crate) const DROPDOWN_FLING_VELOCITY: f64 = 4.0;
308
309/// Progress difference below which a spring ramp counts as settled.
310pub(crate) const DROPDOWN_PROGRESS_EPSILON: f64 = 1e-3;
311
312/// The ink every run is *shaped* with; never painted — a run is re-brushed with
313/// its resolved per-state color at paint time, and holding the shaping color
314/// constant keeps the shape cache from missing on a recolor (the contract
315/// [`mod@crate::button`]'s own runs document).
316pub(crate) const SHAPING_INK: Color = Color::BLACK;
317
318// ---- Colors (`m3e_dropdown_menu_theme.dart:59-118`) ------------------------
319
320/// Unthemed-fallback `surfaceContainerHighest`.
321const FALLBACK_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
322/// Unthemed-fallback `surfaceContainerHigh`.
323const FALLBACK_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
324/// Unthemed-fallback `onSurface`.
325const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
326/// Unthemed-fallback `secondaryContainer`.
327const FALLBACK_SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
328/// Unthemed-fallback `onSecondaryContainer`.
329const FALLBACK_ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
330/// Unthemed-fallback `error`.
331const FALLBACK_ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
332
333/// One dropdown's resolved color roles (`M3EDropdownMenuTheme`'s ten color
334/// methods, plus the two the field and the empty state resolve inline).
335#[derive(Clone, Copy, Debug, PartialEq)]
336pub struct DropdownColors {
337 /// The trigger field's fill (`fieldBackgroundColor`).
338 pub field_container: Color,
339 /// The trigger field's ink (`fieldForegroundColor`), and the ink its
340 /// hover/press overlay is drawn from.
341 pub field_content: Color,
342 /// The panel's fill (`panelBackgroundColor`).
343 pub panel_container: Color,
344 /// A selected-option chip's fill (`chipBackgroundColor`).
345 pub chip_container: Color,
346 /// A chip's label and delete-glyph ink (`chipForegroundColor`).
347 pub chip_content: Color,
348 /// An idle option row's fill (`itemBackgroundColor`).
349 pub item_container: Color,
350 /// A selected option row's fill (`itemSelectedBackgroundColor`).
351 pub item_selected_container: Color,
352 /// An idle option row's ink (`itemForegroundColor`).
353 pub item_content: Color,
354 /// A selected option row's ink (`itemSelectedForegroundColor`).
355 pub item_selected_content: Color,
356 /// The placeholder's ink (`hintTextStyle`).
357 pub hint: Color,
358 /// The empty-state message's ink (`m3e_dropdown_menu_panel.dart:164`).
359 pub empty: Color,
360 /// A load failure's ink (`m3e_dropdown_menu_panel.dart:153`'s
361 /// `scheme.error`).
362 pub error: Color,
363}
364
365impl DropdownColors {
366 /// Resolve every role from `theme`, or from the M3 baseline light values
367 /// when no theme is threaded into the pass.
368 pub fn resolve(theme: Option<&Theme>) -> Self {
369 let (
370 container_highest,
371 container_high,
372 on_surface,
373 secondary_container,
374 on_secondary_container,
375 error,
376 ) = match theme {
377 Some(theme) => {
378 let s = theme.scheme();
379 (
380 s.surface_container_highest,
381 s.surface_container_high,
382 s.on_surface,
383 s.secondary_container,
384 s.on_secondary_container,
385 s.error,
386 )
387 }
388 None => (
389 FALLBACK_CONTAINER_HIGHEST,
390 FALLBACK_CONTAINER_HIGH,
391 FALLBACK_ON_SURFACE,
392 FALLBACK_SECONDARY_CONTAINER,
393 FALLBACK_ON_SECONDARY_CONTAINER,
394 FALLBACK_ERROR,
395 ),
396 };
397 DropdownColors {
398 field_container: container_highest,
399 field_content: on_surface,
400 panel_container: container_highest,
401 chip_container: secondary_container,
402 chip_content: on_secondary_container,
403 item_container: container_high,
404 item_selected_container: secondary_container,
405 item_content: on_surface,
406 item_selected_content: on_secondary_container,
407 hint: with_alpha(on_surface, DROPDOWN_HINT_OPACITY),
408 empty: with_alpha(on_surface, DROPDOWN_EMPTY_OPACITY),
409 error,
410 }
411 }
412
413 /// An option row's fill: disabled first, then selected
414 /// (`_resolveBackgroundColor`).
415 pub fn item_background(&self, enabled: bool, selected: bool) -> Color {
416 if !enabled {
417 return with_alpha(self.item_content, DROPDOWN_DISABLED_CONTAINER_OPACITY);
418 }
419 if selected {
420 self.item_selected_container
421 } else {
422 self.item_container
423 }
424 }
425
426 /// An option row's ink, on the same ladder (`_resolveTextColor`).
427 pub fn item_foreground(&self, enabled: bool, selected: bool) -> Color {
428 if !enabled {
429 return with_alpha(self.item_content, DISABLED_CONTENT_OPACITY);
430 }
431 if selected {
432 self.item_selected_content
433 } else {
434 self.item_content
435 }
436 }
437}
438
439/// The panel's corner radius, resolved from the theme's shape scale.
440///
441/// The reference's `containerRadius: 28` lands exactly on the `extraLarge`
442/// shape token, so the token is what a themed pass reads — the
443/// **explicit > theme > fallback** ladder the workspace's theming conventions
444/// require, and the same accessor [`crate::overlay::radius`] already is.
445pub(crate) fn container_radius(theme: Option<&Theme>) -> f64 {
446 crate::overlay::radius(theme)
447}
448
449// ---- The item model -------------------------------------------------------
450
451/// One selectable option (`M3EDropdownItem`).
452///
453/// The reference's item carries its own `selected` flag and the controller
454/// mutates it through `copyWith`. **This port does not**: selection is the
455/// app's, handed down through [`DropdownView::selected`]/
456/// [`DropdownFieldView::selected`] and requested back through `on_change`, per
457/// this catalog's controlled-component rule. `disabled` stays on the item,
458/// since it is a property of the option rather than of the selection.
459///
460/// `value` is the identity a selection is expressed in and must be unique
461/// across an item list; `label` is what the field and the panel display and
462/// what the filter matches against.
463#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct DropdownItem {
465 label: String,
466 value: String,
467 disabled: bool,
468}
469
470/// Build an option labelled `label` carrying `value`.
471pub fn dropdown_item(label: impl Into<String>, value: impl Into<String>) -> DropdownItem {
472 DropdownItem {
473 label: label.into(),
474 value: value.into(),
475 disabled: false,
476 }
477}
478
479impl DropdownItem {
480 /// Refuse selection of this option (`M3EDropdownItem.disabled`). A disabled
481 /// option still renders, dimmed, and still matches the filter.
482 pub fn disabled(mut self, disabled: bool) -> Self {
483 self.disabled = disabled;
484 self
485 }
486
487 /// The displayed text.
488 pub fn label(&self) -> &str {
489 &self.label
490 }
491
492 /// The identity a selection is expressed in.
493 pub fn value(&self) -> &str {
494 &self.value
495 }
496
497 /// Whether this option refuses selection.
498 pub fn is_disabled(&self) -> bool {
499 self.disabled
500 }
501}
502
503/// Whether `item`'s label matches `query` under the reference's filter rule.
504///
505/// `M3EDropdownController._reapplySearchFilter` lowercases the query once and
506/// tests `i.label.toLowerCase().contains(q)` — a **case-insensitive substring
507/// match against the label only**, never the value, with an empty query
508/// matching everything. Rust's `str::to_lowercase` is the Unicode-aware simple
509/// lowercase mapping, the closest equivalent of Dart's `String.toLowerCase`.
510pub fn dropdown_matches(item: &DropdownItem, query: &str) -> bool {
511 if query.is_empty() {
512 return true;
513 }
514 item.label.to_lowercase().contains(&query.to_lowercase())
515}
516
517/// The subset of `items` [`dropdown_matches`] admits, in the original order.
518pub fn dropdown_filter(items: &[DropdownItem], query: &str) -> Vec<DropdownItem> {
519 items
520 .iter()
521 .filter(|item| dropdown_matches(item, query))
522 .cloned()
523 .collect()
524}
525
526/// The selection `values` requests, ordered the way `items` are.
527///
528/// The reference's `selectedItems` is always `_items.where((i) => i.selected)`
529/// — item order, never click order (`m3e_dropdown_controller.dart:83`) — so
530/// every reported selection is normalized through this rather than appended to.
531/// Values naming no item are dropped, which is what makes a stale selection
532/// handed down by an app converge instead of accumulating.
533pub(crate) fn ordered_selection(items: &[DropdownItem], values: &[String]) -> Vec<String> {
534 items
535 .iter()
536 .filter(|item| values.iter().any(|v| v == &item.value))
537 .map(|item| item.value.clone())
538 .collect()
539}
540
541// ---- Shared paint helpers -------------------------------------------------
542
543/// A shaped, cached text run, re-brushed at paint. The same lazily-shaped,
544/// measured-against-`(style, max_width)` cache [`mod@crate::menu`]'s rows carry.
545pub(crate) struct Run {
546 pub(crate) content: String,
547 layout: Option<TextLayout>,
548 /// The `(style, max_width)` the cached `layout` was shaped for.
549 shaped_for: Option<(TextStyle, Option<f64>)>,
550 /// The `(style, width)` of the last natural (unconstrained) measurement.
551 natural: Option<(TextStyle, f64)>,
552}
553
554impl Run {
555 /// A run holding `content`, unshaped until the first measurement.
556 pub(crate) fn new(content: impl Into<String>) -> Self {
557 Run {
558 content: content.into(),
559 layout: None,
560 shaped_for: None,
561 natural: None,
562 }
563 }
564
565 /// The run's width with nothing constraining it.
566 pub(crate) fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
567 if let Some((cached, width)) = &self.natural
568 && cached == style
569 {
570 return *width;
571 }
572 let text_ctx = ctx.text_context::<TextContext>();
573 let width = text_ctx.layout(&self.content, style, None).size().width;
574 self.natural = Some((style.clone(), width));
575 width
576 }
577
578 /// Shape (or reuse) the run in `style`, fitted to `max_width`, and return
579 /// its measured size.
580 ///
581 /// A fitted run is a **single ellipsized line**: the reference's field text
582 /// is `overflow: TextOverflow.ellipsis` (`m3e_dropdown_menu_field.dart:163`)
583 /// and its rows are single-line `Text`s inside an `Expanded`.
584 pub(crate) fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
585 let key = (style.clone(), Some(max_width));
586 if let Some(cached) = &self.layout
587 && self.shaped_for.as_ref() == Some(&key)
588 {
589 return cached.size();
590 }
591 let text_ctx = ctx.text_context::<TextContext>();
592 let laid = text_ctx.layout_bounded(
593 &self.content,
594 style,
595 Some(max_width.max(0.0) as f32),
596 Some(1),
597 TextOverflow::Ellipsis,
598 );
599 let size = laid.size();
600 self.layout = Some(laid);
601 self.shaped_for = Some(key);
602 size
603 }
604
605 /// The shaped size, or zero for a run no layout pass has reached yet.
606 pub(crate) fn size(&self) -> Size {
607 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
608 }
609
610 /// Paint the run at `origin` in `color`, overriding the [`SHAPING_INK`] it
611 /// was shaped with. A never-shaped run paints nothing.
612 pub(crate) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
613 self.paint_transformed(origin, color, Affine::IDENTITY, scene);
614 }
615
616 /// Paint the run at `origin` in `color`, with `transform` applied about
617 /// `origin` first — how a chip's spring scale reaches its label.
618 pub(crate) fn paint_transformed(
619 &self,
620 origin: Point,
621 color: Color,
622 transform: Affine,
623 scene: &mut dyn PaintScene,
624 ) {
625 let Some(layout) = &self.layout else {
626 return;
627 };
628 for mut run in layout.to_scene_runs(origin) {
629 run.brush = Brush::Solid(color);
630 if transform != Affine::IDENTITY {
631 run.transform = transform * run.transform;
632 }
633 scene.draw_glyph_run(run);
634 }
635 }
636}
637
638/// An icon slot, parsed once at build into its design-box path — the same
639/// record [`mod@crate::menu`]'s rows carry, plus a rotation the field's arrow
640/// needs.
641pub(crate) struct Glyph {
642 path: BezPath,
643 design: f64,
644 /// The side length to paint at.
645 pub(crate) extent: f64,
646}
647
648impl Glyph {
649 /// Parse `icon`'s path once, to be painted at `extent`.
650 pub(crate) fn new(icon: frust::IconSource, extent: f64) -> Self {
651 let (path, design) = IconData::from(icon).resolve();
652 Glyph {
653 path,
654 design,
655 extent,
656 }
657 }
658
659 /// The scale factor from the glyph's design box to [`Self::extent`].
660 fn scale(&self) -> f64 {
661 if self.design > 0.0 {
662 self.extent / self.design
663 } else {
664 1.0
665 }
666 }
667
668 /// Paint the glyph with its top-left at `origin`, in `color`.
669 pub(crate) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
670 let scaled = Affine::scale(self.scale()) * self.path.clone();
671 scene.fill_path(origin, &scaled, &Brush::Solid(color));
672 }
673
674 /// Paint the glyph rotated `radians` about its own center — the field
675 /// arrow's `Transform.rotate(angle: _arrowCtrl.value)`
676 /// (`m3e_dropdown_menu_field.dart:93`).
677 pub(crate) fn paint_rotated(
678 &self,
679 origin: Point,
680 color: Color,
681 radians: f64,
682 scene: &mut dyn PaintScene,
683 ) {
684 let half = self.extent / 2.0;
685 let pivot = kurbo::Vec2::new(half, half);
686 let transform = Affine::translate(pivot)
687 * Affine::rotate(radians)
688 * Affine::translate(-pivot)
689 * Affine::scale(self.scale());
690 scene.fill_path(
691 origin,
692 &(transform * self.path.clone()),
693 &Brush::Solid(color),
694 );
695 }
696}
697
698// ---- The composite dropdown ----------------------------------------------
699
700/// A view-held, typed selection callback.
701pub(crate) type OnSelection<State> = Rc<dyn Fn(&mut State, Vec<String>)>;
702/// A view-held, typed open-request callback.
703pub(crate) type OnOpen<State> = Rc<dyn Fn(&mut State, bool)>;
704/// A view-held, typed query-request callback.
705pub(crate) type OnQuery<State> = Rc<dyn Fn(&mut State, String)>;
706
707/// A declarative dropdown: a [`dropdown_panel`] inside
708/// [`crate::overlay::anchored`]. See [`dropdown`].
709///
710/// The host is composed fresh in each pass from the props below rather than
711/// retained: [`AnchoredOverlayView`] type-erases its content at construction,
712/// so there is no way to reach back into the panel and re-set one of its own
713/// props once it is inside — the same shape [`crate::menu::MenuView`] takes.
714pub struct DropdownView<State: 'static> {
715 items: Vec<DropdownItem>,
716 selected: Vec<String>,
717 multi: bool,
718 open: bool,
719 query: String,
720 searchable: bool,
721 loading: bool,
722 error: Option<String>,
723 empty_text: String,
724 max_selections: usize,
725 max_height: f64,
726 placement: OverlayPlacement,
727 anchor: OverlayAnchor,
728 on_change: OnSelection<State>,
729 on_open: Option<OnOpen<State>>,
730 on_query: Option<OnQuery<State>>,
731}
732
733/// Build the panel half of a dropdown over `items`, to be mounted as the top
734/// child of a full-area [`frust::Stack`] (or a transparent navigator page) and
735/// pointed at the same [`OverlayAnchor`] the [`dropdown_field`] captures into.
736///
737/// `on_change(state, values)` reports the selection an activation *requests*,
738/// already ordered the way `items` are (item order, never click order); the panel never
739/// mutates its own [`DropdownView::selected`].
740///
741/// The default placement is the reference's `expandDirection: auto` below the
742/// field, leading edges flush, at [`DROPDOWN_PANEL_GAP`] — with the anchored
743/// host's own collision flip standing in for `auto`'s space check.
744pub fn dropdown<State: 'static, F: Fn(&mut State, Vec<String>) + 'static>(
745 items: Vec<DropdownItem>,
746 on_change: F,
747) -> DropdownView<State> {
748 DropdownView {
749 items,
750 selected: Vec::new(),
751 multi: false,
752 open: true,
753 query: String::new(),
754 searchable: false,
755 loading: false,
756 error: None,
757 empty_text: DROPDOWN_EMPTY_TEXT.to_string(),
758 max_selections: 0,
759 max_height: DROPDOWN_PANEL_MAX_HEIGHT,
760 placement: OverlayPlacement::default()
761 .align(OverlayAlign::Start)
762 .offset(DROPDOWN_PANEL_GAP),
763 anchor: OverlayAnchor::new(),
764 on_change: Rc::new(on_change),
765 on_open: None,
766 on_query: None,
767 }
768}
769
770impl<State: 'static> DropdownView<State> {
771 /// Anchor the panel to the rect `anchor` carries — the same cell the
772 /// [`dropdown_field`] writes, which is also what width-matches the panel to
773 /// the field.
774 pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
775 self.anchor = anchor.clone();
776 self
777 }
778
779 /// Hand the panel the app-confirmed selection (option values).
780 pub fn selected(mut self, selected: Vec<String>) -> Self {
781 self.selected = selected;
782 self
783 }
784
785 /// Allow more than one option at a time (`singleSelect: false`). The
786 /// default here is **single**-select, inverting the reference's own default
787 /// so the simpler mode is the one a caller gets for free.
788 pub fn multi(mut self, multi: bool) -> Self {
789 self.multi = multi;
790 self
791 }
792
793 /// Cap a multi-select at `max` options (`maxSelections`); `0` is unlimited.
794 /// Deselecting is never capped.
795 pub fn max_selections(mut self, max: usize) -> Self {
796 self.max_selections = max;
797 self
798 }
799
800 /// Hand a **kept-mounted** panel the app's open flag, so closing it plays
801 /// the host's exit ramp instead of vanishing.
802 pub fn open(mut self, open: bool) -> Self {
803 self.open = open;
804 self
805 }
806
807 /// Show the search field above the option list (`searchEnabled`).
808 pub fn searchable(mut self, searchable: bool) -> Self {
809 self.searchable = searchable;
810 self
811 }
812
813 /// Hand the panel the app-confirmed filter query. Only rendered when
814 /// [`searchable`](Self::searchable) is set; the filter itself applies
815 /// regardless, so an app may drive it from its own field.
816 pub fn query(mut self, query: impl Into<String>) -> Self {
817 self.query = query.into();
818 self
819 }
820
821 /// Set the query callback: each edit of the search field reports the text
822 /// it *requests* (`onSearchChanged`).
823 pub fn on_query<F: Fn(&mut State, String) + 'static>(mut self, on_query: F) -> Self {
824 self.on_query = Some(Rc::new(on_query));
825 self
826 }
827
828 /// Show the reference's centered busy row instead of the option list — the
829 /// panel half of the async poll seam (see the [module docs](self)).
830 pub fn loading(mut self, loading: bool) -> Self {
831 self.loading = loading;
832 self
833 }
834
835 /// Show a load failure instead of the option list
836 /// (`m3e_dropdown_menu_panel.dart:148`'s `_errorMessage`).
837 pub fn error(mut self, error: Option<String>) -> Self {
838 self.error = error;
839 self
840 }
841
842 /// Replace the empty-state message (`noItemsFoundText`).
843 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
844 self.empty_text = text.into();
845 self
846 }
847
848 /// Cap the panel's height, past which it scrolls (`maxHeight`).
849 pub fn max_height(mut self, max_height: f64) -> Self {
850 self.max_height = max_height;
851 self
852 }
853
854 /// Set the side the panel opens on (`bottom` by default). The reference's
855 /// `expandDirection` `up`/`down` map onto this; its `auto` is the anchored
856 /// host's own collision flip, which is on by default.
857 pub fn side(mut self, side: OverlaySide) -> Self {
858 self.placement.side = side;
859 self
860 }
861
862 /// Set the cross-axis alignment (`start` by default, the edge the
863 /// reference's `CompositedTransformFollower` pins).
864 pub fn align(mut self, align: OverlayAlign) -> Self {
865 self.placement.align = align;
866 self
867 }
868
869 /// Set the gap between field and panel (`marginTop`, 4dp by default).
870 pub fn offset(mut self, offset: f64) -> Self {
871 self.placement.offset = offset;
872 self
873 }
874
875 /// Set the open callback. Fired with `false` by a light dismiss, by Escape
876 /// once the host holds focus, and by a single-select activation — the
877 /// reference's `_selectSingleItem` closes the dropdown after picking.
878 pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
879 self.on_open = Some(Rc::new(on_open));
880 self
881 }
882
883 /// Compose the host and its panel from the current props.
884 fn host(&self) -> AnchoredOverlayView<State> {
885 let on_change = self.on_change.clone();
886 let mut panel = dropdown_panel(self.items.clone(), move |state: &mut State, values| {
887 on_change(state, values)
888 })
889 .anchor(&self.anchor)
890 .selected(self.selected.clone())
891 .multi(self.multi)
892 .max_selections(self.max_selections)
893 .query(self.query.clone())
894 .searchable(self.searchable)
895 .loading(self.loading)
896 .error(self.error.clone())
897 .empty_text(self.empty_text.clone())
898 .max_height(self.max_height)
899 .open(self.open);
900 if let Some(on_open) = self.on_open.clone() {
901 panel = panel.on_open(move |state: &mut State, open| on_open(state, open));
902 }
903 if let Some(on_query) = self.on_query.clone() {
904 panel = panel.on_query(move |state: &mut State, query| on_query(state, query));
905 }
906 let mut host = anchored_overlay(panel)
907 .anchor(&self.anchor)
908 .placement(self.placement)
909 .open(self.open);
910 if let Some(on_open) = self.on_open.clone() {
911 host = host.on_dismiss(move |state: &mut State| on_open(state, false));
912 }
913 host
914 }
915}
916
917impl<State: 'static> View<State> for DropdownView<State> {
918 type Element = AnchoredOverlayWidget;
919
920 fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
921 View::build(&self.host(), ctx)
922 }
923
924 fn rebuild(
925 &self,
926 prev: &Self,
927 element: &mut AnchoredOverlayWidget,
928 ctx: &mut BuildCtx<'_>,
929 ) -> ChangeFlags {
930 View::rebuild(&self.host(), &prev.host(), element, ctx)
931 }
932
933 fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
934 View::teardown(&self.host(), element, ctx);
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use frust::Brightness;
942
943 fn fruit() -> Vec<DropdownItem> {
944 vec![
945 dropdown_item("Apple", "apple"),
946 dropdown_item("Banana", "banana"),
947 dropdown_item("Blackcurrant", "blackcurrant").disabled(true),
948 ]
949 }
950
951 #[test]
952 fn the_metrics_are_the_reference_s_own_defaults() {
953 assert_eq!(DROPDOWN_FIELD_H_PADDING, 16.0);
954 assert_eq!(DROPDOWN_FIELD_V_PADDING, 14.0);
955 assert_eq!(DROPDOWN_ICON_GAP, 8.0);
956 assert_eq!(DROPDOWN_ICON_SIZE, 24.0);
957 assert_eq!(DROPDOWN_FIELD_LOADING_SIZE, 18.0);
958 assert_eq!(DROPDOWN_FIELD_LOADING_STROKE, 2.0);
959 assert_eq!(DROPDOWN_PANEL_MAX_HEIGHT, 350.0);
960 assert_eq!(DROPDOWN_PANEL_GAP, 4.0);
961 assert_eq!(DROPDOWN_CONTENT_PADDING, 8.0);
962 assert_eq!(DROPDOWN_MESSAGE_PADDING, 16.0);
963 assert_eq!(DROPDOWN_LOADING_PADDING, 24.0);
964 assert_eq!(DROPDOWN_ITEM_H_PADDING, 16.0);
965 assert_eq!(DROPDOWN_ITEM_V_PADDING, 12.0);
966 assert_eq!(DROPDOWN_ITEM_GAP, 3.0);
967 // The *style*'s defaults, not the theme's unread 12/4 pair.
968 assert_eq!(DROPDOWN_ITEM_OUTER_RADIUS, 12.0);
969 assert_eq!(DROPDOWN_ITEM_INNER_RADIUS, 6.0);
970 assert_eq!(DROPDOWN_ITEM_HOVER_RADIUS, 8.0);
971 assert_eq!(DROPDOWN_ITEM_PRESSED_RADIUS, 4.0);
972 assert_eq!(DROPDOWN_CHIP_H_PADDING, 8.0);
973 assert_eq!(DROPDOWN_CHIP_V_PADDING, 4.0);
974 assert_eq!(DROPDOWN_CHIP_RADIUS, 20.0);
975 assert_eq!(DROPDOWN_CHIP_SPACING, 6.0);
976 assert_eq!(DROPDOWN_CHIP_RUN_SPACING, 6.0);
977 assert_eq!(DROPDOWN_CHIP_LABEL_GAP, 4.0);
978 assert_eq!(DROPDOWN_HINT, "Select");
979 assert_eq!(DROPDOWN_EMPTY_TEXT, "No items found");
980 }
981
982 #[test]
983 fn the_container_radius_is_the_theme_s_extra_large_shape_token() {
984 let theme = crate::baseline();
985 assert_eq!(container_radius(Some(&theme)), theme.shape.extra_large);
986 // The reference's own `containerRadius: 28` default.
987 assert_eq!(container_radius(None), 28.0);
988 }
989
990 #[test]
991 fn every_color_folds_onto_its_material_role() {
992 let theme = crate::baseline().with_brightness(Brightness::Light);
993 let s = theme.scheme();
994 let c = DropdownColors::resolve(Some(&theme));
995 assert_eq!(c.field_container, s.surface_container_highest);
996 assert_eq!(c.field_content, s.on_surface);
997 assert_eq!(c.panel_container, s.surface_container_highest);
998 assert_eq!(c.chip_container, s.secondary_container);
999 assert_eq!(c.chip_content, s.on_secondary_container);
1000 assert_eq!(c.item_container, s.surface_container_high);
1001 assert_eq!(c.item_selected_container, s.secondary_container);
1002 assert_eq!(c.item_content, s.on_surface);
1003 assert_eq!(c.item_selected_content, s.on_secondary_container);
1004 assert_eq!(c.hint, with_alpha(s.on_surface, DROPDOWN_HINT_OPACITY));
1005 assert_eq!(c.empty, with_alpha(s.on_surface, DROPDOWN_EMPTY_OPACITY));
1006 assert_eq!(c.error, s.error);
1007 }
1008
1009 #[test]
1010 fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
1011 let light = crate::baseline().with_brightness(Brightness::Light);
1012 assert_eq!(
1013 DropdownColors::resolve(None),
1014 DropdownColors::resolve(Some(&light))
1015 );
1016 }
1017
1018 #[test]
1019 fn the_colors_follow_a_live_brightness_flip() {
1020 let light = crate::baseline().with_brightness(Brightness::Light);
1021 let dark = crate::baseline().with_brightness(Brightness::Dark);
1022 assert_ne!(
1023 DropdownColors::resolve(Some(&light)).panel_container,
1024 DropdownColors::resolve(Some(&dark)).panel_container
1025 );
1026 }
1027
1028 #[test]
1029 fn a_non_material_theme_still_resolves_every_role() {
1030 // An app is free to thread a bare theme; nothing here may panic.
1031 let bare = Theme::neutral();
1032 let _ = DropdownColors::resolve(Some(&bare));
1033 let _ = container_radius(Some(&bare));
1034 }
1035
1036 #[test]
1037 fn the_item_ladder_is_disabled_then_selected() {
1038 let c = DropdownColors::resolve(None);
1039 assert_eq!(
1040 c.item_background(false, true),
1041 with_alpha(c.item_content, DROPDOWN_DISABLED_CONTAINER_OPACITY),
1042 "disabled outranks selected"
1043 );
1044 assert_eq!(
1045 c.item_foreground(false, true),
1046 with_alpha(c.item_content, DISABLED_CONTENT_OPACITY)
1047 );
1048 assert_eq!(c.item_background(true, true), c.item_selected_container);
1049 assert_eq!(c.item_background(true, false), c.item_container);
1050 assert_eq!(c.item_foreground(true, true), c.item_selected_content);
1051 assert_eq!(c.item_foreground(true, false), c.item_content);
1052 }
1053
1054 // ---- the filter -------------------------------------------------------
1055
1056 #[test]
1057 fn an_empty_query_admits_every_item() {
1058 let items = fruit();
1059 assert_eq!(dropdown_filter(&items, "").len(), 3);
1060 }
1061
1062 #[test]
1063 fn the_filter_is_a_case_insensitive_substring_of_the_label() {
1064 let items = fruit();
1065 // Not an anchored prefix: `nan` matches inside `Banana`.
1066 let narrowed = dropdown_filter(&items, "nan");
1067 assert_eq!(narrowed.len(), 1);
1068 assert_eq!(narrowed[0].label(), "Banana");
1069 // Case-folded on both sides.
1070 assert_eq!(dropdown_filter(&items, "APPLE").len(), 1);
1071 assert_eq!(dropdown_filter(&items, "apple").len(), 1);
1072 // Two matches keep their original order.
1073 let b = dropdown_filter(&items, "b");
1074 assert_eq!(
1075 b.iter().map(DropdownItem::label).collect::<Vec<_>>(),
1076 ["Banana", "Blackcurrant"]
1077 );
1078 }
1079
1080 #[test]
1081 fn the_filter_never_matches_a_value_only_the_label() {
1082 let items = vec![dropdown_item("Apple", "pomme")];
1083 assert!(dropdown_filter(&items, "pomme").is_empty());
1084 assert_eq!(dropdown_filter(&items, "app").len(), 1);
1085 }
1086
1087 #[test]
1088 fn a_disabled_item_still_matches_the_filter() {
1089 let items = fruit();
1090 let narrowed = dropdown_filter(&items, "black");
1091 assert_eq!(narrowed.len(), 1);
1092 assert!(narrowed[0].is_disabled());
1093 }
1094
1095 // ---- the composite, end to end ---------------------------------------
1096
1097 /// Field + host mounted the documented way — the trigger in the app's own
1098 /// layout, the panel as the top child of a full-area [`frust::Stack`],
1099 /// both pointed at one [`OverlayAnchor`] — with the app owning every
1100 /// controlled value, exactly as a real consumer does.
1101 mod composite {
1102 use super::super::*;
1103 use frust::FrameTime;
1104 use frust::authoring::{
1105 InputEvent, Key, KeyEvent, Modifiers, NamedKey, Point, PointerButton, PointerEvent,
1106 PointerPhase, Size, any, text::TextContext,
1107 };
1108 use frust_core::RenderRoot;
1109 use std::any::Any;
1110
1111 const WINDOW: Size = Size::new(320.0, 480.0);
1112
1113 #[derive(Default)]
1114 struct AppState {
1115 open: bool,
1116 selected: Vec<String>,
1117 query: String,
1118 }
1119
1120 fn items() -> Vec<DropdownItem> {
1121 vec![
1122 dropdown_item("Apple", "apple"),
1123 dropdown_item("Banana", "banana"),
1124 dropdown_item("Cherry", "cherry"),
1125 ]
1126 }
1127
1128 /// The app's own tree: the trigger in normal layout, the panel host as
1129 /// the stack's topmost child, one anchor shared by both.
1130 fn tree(
1131 anchor: &OverlayAnchor,
1132 multi: bool,
1133 searchable: bool,
1134 state: &AppState,
1135 ) -> frust::StackView<AppState> {
1136 let selected = state.selected.clone();
1137 let open = state.open;
1138 let query = state.query.clone();
1139 frust::Stack(vec![
1140 any(dropdown_field::<AppState>(items())
1141 .anchor(anchor)
1142 .multi(multi)
1143 .selected(selected.clone())
1144 .open(open)
1145 .on_open(|s: &mut AppState, next| s.open = next)
1146 .on_change(|s: &mut AppState, values| s.selected = values)),
1147 any(
1148 dropdown(items(), |s: &mut AppState, values| s.selected = values)
1149 .anchor(anchor)
1150 .multi(multi)
1151 .selected(selected)
1152 .open(open)
1153 .searchable(searchable)
1154 .query(query)
1155 .on_query(|s: &mut AppState, text| s.query = text)
1156 .on_open(|s: &mut AppState, next| s.open = next),
1157 ),
1158 ])
1159 }
1160
1161 struct Harness {
1162 root: RenderRoot<AppState, frust::StackView<AppState>>,
1163 state: AppState,
1164 tcx: TextContext,
1165 anchor: OverlayAnchor,
1166 multi: bool,
1167 searchable: bool,
1168 }
1169
1170 impl Harness {
1171 fn new(multi: bool) -> Self {
1172 Harness::with(multi, false)
1173 }
1174
1175 fn with(multi: bool, searchable: bool) -> Self {
1176 let mut h = Harness {
1177 root: RenderRoot::new(),
1178 state: AppState::default(),
1179 tcx: TextContext::new(),
1180 anchor: OverlayAnchor::new(),
1181 multi,
1182 searchable,
1183 };
1184 h.pass();
1185 h
1186 }
1187
1188 /// One rebuild + layout + paint pass, the way a shell's frame runs
1189 /// it — the paint is what captures the trigger's rect into the
1190 /// anchor and advances every ramp.
1191 fn pass(&mut self) {
1192 let anchor = self.anchor.clone();
1193 let multi = self.multi;
1194 let searchable = self.searchable;
1195 let mut logic = move |state: &mut AppState| tree(&anchor, multi, searchable, state);
1196 self.root.rebuild(&mut logic, &mut self.state);
1197 self.root
1198 .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1199 self.paint();
1200 }
1201
1202 fn paint(&mut self) {
1203 struct Sink;
1204 impl frust::authoring::PaintScene for Sink {
1205 fn fill_rect(
1206 &mut self,
1207 _origin: Point,
1208 _size: Size,
1209 _color: frust::authoring::Color,
1210 ) {
1211 }
1212 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1213 }
1214 self.root.paint(&mut Sink, FrameTime::ZERO);
1215 }
1216
1217 fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
1218 self.root.event(
1219 &mut self.state,
1220 &InputEvent::Pointer(PointerEvent {
1221 phase,
1222 position: Point::new(x, y),
1223 button: PointerButton::Primary,
1224 }),
1225 );
1226 }
1227
1228 fn click(&mut self, x: f64, y: f64) {
1229 self.pointer(PointerPhase::Down, x, y);
1230 self.pointer(PointerPhase::Up, x, y);
1231 }
1232
1233 fn key(&mut self, named: NamedKey) {
1234 self.send(Key::Named(named));
1235 }
1236
1237 fn typed(&mut self, text: &str) {
1238 self.send(Key::Character(text.to_string()));
1239 }
1240
1241 fn send(&mut self, key: Key) {
1242 self.root.event(
1243 &mut self.state,
1244 &InputEvent::Key(KeyEvent {
1245 key,
1246 modifiers: Modifiers::default(),
1247 repeat: false,
1248 }),
1249 );
1250 }
1251
1252 /// The trigger's captured rect — the field is the stack's first
1253 /// child, so it sits at the window origin.
1254 fn field(&self) -> frust::authoring::Rect {
1255 self.anchor.rect()
1256 }
1257
1258 /// The window-space y of option row `i`. A row is its label's line
1259 /// box plus the item padding, floored at the check glyph's own
1260 /// extent, so the check-sized band is the safe centre to aim at.
1261 fn row_y(&self, i: usize) -> f64 {
1262 let band = 2.0 * DROPDOWN_ITEM_V_PADDING + DROPDOWN_ICON_SIZE;
1263 self.list_top() + i as f64 * (band + DROPDOWN_ITEM_GAP) + band / 2.0
1264 }
1265
1266 /// The window-space top of the panel's own box.
1267 fn panel_top(&self) -> f64 {
1268 self.field().y1 + DROPDOWN_PANEL_GAP
1269 }
1270
1271 /// The window-space top of the scrollable option list, past the
1272 /// search header when one is mounted.
1273 fn list_top(&self) -> f64 {
1274 let header = if self.searchable {
1275 panel::SEARCH_MARGIN_TOP
1276 + panel::SEARCH_FIELD_HEIGHT
1277 + panel::SEARCH_MARGIN_BOTTOM
1278 } else {
1279 0.0
1280 };
1281 self.panel_top() + header + DROPDOWN_CONTENT_PADDING
1282 }
1283
1284 /// A point inside the mounted search field.
1285 fn search_point(&self) -> Point {
1286 Point::new(
1287 panel::SEARCH_MARGIN_H + 20.0,
1288 self.panel_top() + panel::SEARCH_MARGIN_TOP + panel::SEARCH_FIELD_HEIGHT / 2.0,
1289 )
1290 }
1291 }
1292
1293 #[test]
1294 fn open_then_pick_then_close_is_one_round_trip_through_the_app_s_own_state() {
1295 let mut h = Harness::new(false);
1296 assert!(!h.state.open);
1297 assert_eq!(h.field().width(), WINDOW.width, "the trigger is captured");
1298
1299 // 1. Open: the press lands on the trigger, since the closed host is
1300 // input-transparent.
1301 let field = h.field();
1302 h.click(40.0, field.center().y);
1303 assert!(h.state.open, "the field requested the open state");
1304 h.pass();
1305
1306 // 2. Pick: the press now lands on the panel, which the host places
1307 // below the trigger.
1308 let y = h.row_y(1);
1309 h.click(40.0, y);
1310 assert_eq!(h.state.selected, vec!["banana".to_string()]);
1311 assert!(!h.state.open, "a single-select pick closes the dropdown");
1312 h.pass();
1313
1314 // 3. The trigger toggles back open from its picked state.
1315 let center = h.field().center().y;
1316 h.click(40.0, center);
1317 assert!(h.state.open);
1318 }
1319
1320 #[test]
1321 fn a_press_outside_the_panel_light_dismisses_through_the_host() {
1322 let mut h = Harness::new(false);
1323 let field = h.field();
1324 h.click(40.0, field.center().y);
1325 h.pass();
1326 assert!(h.state.open);
1327
1328 // Far below the panel: the host's own light dismiss.
1329 h.pointer(PointerPhase::Down, 10.0, WINDOW.height - 10.0);
1330 assert!(!h.state.open);
1331 assert!(h.state.selected.is_empty());
1332 }
1333
1334 #[test]
1335 fn the_arrows_reach_the_panel_once_a_press_inside_it_has_claimed_focus() {
1336 let mut h = Harness::new(false);
1337 let field = h.field();
1338 h.click(40.0, field.center().y);
1339 h.pass();
1340
1341 // A press on the panel's own padding claims focus without
1342 // activating anything.
1343 let panel_top = h.panel_top();
1344 h.click(4.0, panel_top + 2.0);
1345 assert!(h.state.selected.is_empty());
1346 assert!(h.state.open, "a background press never dismisses");
1347
1348 h.key(NamedKey::ArrowDown);
1349 h.key(NamedKey::ArrowDown);
1350 h.key(NamedKey::Enter);
1351 assert_eq!(h.state.selected, vec!["banana".to_string()]);
1352 assert!(!h.state.open);
1353 }
1354
1355 #[test]
1356 fn escape_dismisses_through_the_host_once_it_holds_focus() {
1357 let mut h = Harness::new(false);
1358 let field = h.field();
1359 h.click(40.0, field.center().y);
1360 h.pass();
1361
1362 let panel_top = h.panel_top();
1363 h.click(4.0, panel_top + 2.0);
1364 h.key(NamedKey::Escape);
1365 assert!(!h.state.open);
1366 }
1367
1368 #[test]
1369 fn typing_in_the_search_field_narrows_the_options_it_offers() {
1370 let mut h = Harness::with(false, true);
1371 let field = h.field();
1372 h.click(40.0, field.center().y);
1373 h.pass();
1374
1375 // Focus the header field, then type: the panel forwards every key
1376 // it does not navigate with to the focused editable, which reports
1377 // the query the app then feeds back down.
1378 let at = h.search_point();
1379 h.click(at.x, at.y);
1380 h.typed("b");
1381 assert_eq!(h.state.query, "b");
1382 h.pass();
1383
1384 // `Banana` is the only label containing a `b`, so it is now the
1385 // list's first (and only) row.
1386 let y = h.row_y(0);
1387 h.click(40.0, y);
1388 assert_eq!(h.state.selected, vec!["banana".to_string()]);
1389 }
1390
1391 #[test]
1392 fn a_multi_select_round_trip_keeps_the_panel_open_and_grows_the_field() {
1393 let mut h = Harness::new(true);
1394 let field = h.field();
1395 h.click(40.0, field.center().y);
1396 h.pass();
1397
1398 let first = h.row_y(0);
1399 h.click(40.0, first);
1400 assert_eq!(h.state.selected, vec!["apple".to_string()]);
1401 assert!(h.state.open, "a multi-select pick keeps the panel open");
1402 h.pass();
1403
1404 let third = h.row_y(2);
1405 h.click(40.0, third);
1406 assert_eq!(
1407 h.state.selected,
1408 vec!["apple".to_string(), "cherry".to_string()],
1409 "item order, not click order"
1410 );
1411 // Settle the chips' entrance springs before measuring the growth.
1412 for _ in 0..120 {
1413 h.pass();
1414 }
1415 assert!(
1416 h.field().height() > field.height(),
1417 "two chips grew the trigger past its hint-only height ({} vs {})",
1418 h.field().height(),
1419 field.height()
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn a_selection_is_normalized_into_item_order_and_drops_unknown_values() {
1426 let items = fruit();
1427 let requested = vec![
1428 "banana".to_string(),
1429 "nope".to_string(),
1430 "apple".to_string(),
1431 ];
1432 assert_eq!(
1433 ordered_selection(&items, &requested),
1434 vec!["apple".to_string(), "banana".to_string()],
1435 "item order wins over the order the values arrived in"
1436 );
1437 assert!(ordered_selection(&items, &[]).is_empty());
1438 }
1439}