Skip to main content

frust_material/
chips.rs

1//! M3 `Chip`: four types (assist, filter, input, suggestion), an `elevated`
2//! modifier, and a `selected` state — a 32dp height, 8dp corner radius
3//! pill-shaped control with the shared [`super::state_layer`] interaction
4//! overlay.
5//!
6//! # Attribution
7//!
8//! Ported from `material_3_expressive` v1.0.8 (MIT, © 2026 Paa Developments)
9//! — the files ported here are `lib/components/chips/m3e_chips.dart`,
10//! `enums/m3e_chip_type.dart`, and `styles/m3e_chip_theme.dart`. No license
11//! changes; the container/foreground color table, the padding/icon-size
12//! constants, and the `elevated` shadow are ported verbatim from
13//! `M3EChipTheme`. `M3EChip` is a single widget carrying a `type` enum field
14//! — this port keeps this crate's existing one-function-per-type shape
15//! instead (see *Four types, one color table* below).
16//!
17//! # Four types, one color table
18//!
19//! [`assist_chip`], [`filter_chip`], [`input_chip`], and [`suggestion_chip`]
20//! each build a dedicated `View`/`Widget` pair, following this crate's
21//! established per-type convention rather than the reference's single
22//! `M3EChip` + `M3EChipType` enum shape — but they all resolve their
23//! container/foreground colors through the same two functions
24//! ([`resolve_chip_container`]/[`resolve_chip_foreground`]), because
25//! `M3EChipTheme.containerColor` accepts a `type` parameter but never reads
26//! it in its body: every type paints identically at a given `(selected,
27//! elevated)` state in the reference (confirmed against its
28//! `chips_playground.dart` example, which never varies a chip's color by
29//! type). What *does* vary by type is purely behavioral, per that same
30//! example:
31//!
32//! - **[`assist_chip`]**/**[`suggestion_chip`]**: action-only — a plain
33//!   `on_press`, optional leading glyph, never selectable, never deletable.
34//! - **[`filter_chip`]**: the only type exposing `selected` — a
35//!   **controlled component** mirroring [`frust::Checkbox`]/[`mod@super::switch`]:
36//!   it reports the requested value through `on_select` and never flips its
37//!   own field.
38//! - **[`input_chip`]**: the only type exposing
39//!   [`InputChipView::on_deleted`] — a trailing 18dp delete icon
40//!   ([`crate::icons::CLOSE`]) with its **own** hit region, firing
41//!   `on_deleted` independently of the chip's own `on_press` (mirrors
42//!   [`mod@super::split_button`]'s two-target dispatch). Painted directly
43//!   from its resolved [`frust::IconData`] path rather than as a nested
44//!   [`frust::icon`] child, since the icon's color must track this module's
45//!   own `(selected, elevated)`-derived foreground, resolved at paint time —
46//!   after a nested `IconView`'s own build-time color would already be
47//!   fixed.
48//!
49//! # Elevated modifier
50//!
51//! `AssistChipView::elevated`/`FilterChipView::elevated`/
52//! `InputChipView::elevated`/`SuggestionChipView::elevated` (default
53//! `false`) — M3 elevation level 1: `surface_container_low` fill plus a
54//! themed shadow, painted via [`frust::authoring::PaintScene::draw_shadow`]
55//! (the same primitive [`mod@super::card`]'s elevated variant uses).
56//!
57//! # Container/foreground/outline matrix
58//!
59//! Ported verbatim from `M3EChipTheme.containerColor`/`.foregroundColor`
60//! (this catalog's chips have no disabled state, so the reference's
61//! `!enabled` branch is not modeled):
62//!
63//! | `selected` | `elevated` | container                | outlined?                          | foreground              |
64//! |------------|------------|---------------------------|-------------------------------------|--------------------------|
65//! | `true`     | either     | `secondary_container`     | no                                   | `on_secondary_container` (exact; the nested label text approximates it, see below) |
66//! | `false`    | `true`     | `surface_container_low`   | no                                   | `on_surface_variant`     |
67//! | `false`    | `false`    | transparent                | **yes** (1dp `outline_variant`)     | `on_surface_variant`     |
68//!
69//! `outlined = !selected && !elevated` exactly, and the elevation shadow is
70//! gated on `elevated` alone — a selected **and** elevated chip shows both
71//! the `secondary_container` fill and the shadow. This corrects this
72//! module's original implementation, which painted every unselected chip
73//! (assist always, filter when unselected) with a flat
74//! `surface_container_low` fill regardless of `elevated` — a v1
75//! simplification predating this port; a fully unelevated, unselected chip
76//! is now transparent-filled and outlined, matching the reference exactly.
77//!
78//! # Label color simplification
79//!
80//! Every chip's label paints through a nested [`frust::text`] run, themed
81//! [`ThemeTextColor::OnSurfaceVariant`] (an unselected chip — now exact,
82//! matching the table above) or [`ThemeTextColor::OnSurface`] (a selected
83//! chip — `TextView` has no `OnSecondaryContainer` role yet, so this
84//! remains a documented v1 approximation, tonally close to
85//! `on_secondary_container` in the M3 baseline palette; carried over
86//! unchanged from this module's original filter-chip implementation). A
87//! leading glyph shares the same role as the label (rendered as a second
88//! nested text run, not a dedicated `Icon` widget — matching this module's
89//! original leading-glyph choice; the delete icon above is the one
90//! affordance that does need a real vector icon).
91//!
92//! # Selected state per type
93//!
94//! Only [`filter_chip`] exposes `selected` — confirmed against the
95//! reference's `chips_playground.dart`, which only ever passes `selected:
96//! true` for `M3EChipType.filter` (`selected: type == M3EChipType.filter &&
97//! _selected`); assist/input/suggestion always render unselected. This is a
98//! behavioral fact read off the reference's own example, not an
99//! extrapolation — see this module's `resolution_matrix` tests for the full
100//! pinned table.
101
102use std::rc::Rc;
103
104use frust::authoring::{Action, Role, Toggled};
105use frust::authoring::{
106    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
107    PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
108};
109use frust::authoring::{ThemeTextColor, ThemeTextType};
110use frust::{IconData, ShapeScale, Theme};
111use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
112use peniko::{Brush, Color};
113
114use super::icons;
115use super::press::presses;
116use super::state_layer::StateLayer;
117
118/// Fixed chip height, in logical px (`M3EChipTheme.height`).
119const CHIP_HEIGHT: f64 = 32.0;
120/// Delete icon side length, in logical px (`M3EChipTheme.iconSize`).
121const CHIP_ICON_SIZE: f64 = 18.0;
122/// Left padding when there is no leading glyph (`M3EChipTheme.labelStartPadding`).
123const CHIP_LABEL_START_PAD: f64 = 16.0;
124/// Left padding when there is a leading glyph (`M3EChipTheme.iconStartPadding`).
125const CHIP_ICON_START_PAD: f64 = 8.0;
126/// Right padding, always (`M3EChipTheme.endPadding`) — distinct from the
127/// left-side padding above (this module's original implementation used the
128/// same 16dp value on both sides; the reference's right inset is 12dp).
129const CHIP_END_PAD: f64 = 12.0;
130/// Gap between a leading glyph and the label, and between the label and a
131/// trailing delete icon (`M3EChipTheme.iconLabelGap`).
132const CHIP_ICON_LABEL_GAP: f64 = 8.0;
133/// Corner radius (unthemed fallback; a theme resolves this from
134/// `shape.small`, the same 8dp token in the M3 baseline scale).
135const CHIP_RADIUS: f64 = 8.0;
136/// Outline stroke width, in logical px (Flutter `BorderSide` default).
137const CHIP_STROKE_WIDTH: f64 = 1.0;
138/// Flattening tolerance for the outlined variant's rounded-rect stroke path
139/// (matches [`super::card`]'s `STROKE_TOLERANCE`).
140const CHIP_STROKE_TOLERANCE: f64 = 0.1;
141
142/// Unthemed-fallback selected-chip container fill (a theme resolves this
143/// from `colors.secondary_container`).
144const CHIP_SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
145/// Unthemed-fallback elevated (unselected) chip container fill (a theme
146/// resolves this from `colors.surface_container_low`).
147const CHIP_ELEVATED_CONTAINER: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
148/// Unthemed-fallback outline stroke color (a theme resolves this from
149/// `colors.outline_variant`).
150const CHIP_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
151/// Unthemed-fallback selected-chip label/foreground color (a theme resolves
152/// this from `colors.on_secondary_container` exactly — this is the *raw
153/// paint color* resolver, used for the state layer, the delete icon fill,
154/// and as the nested `TextView` label's approximation target; only the
155/// label itself approximates it, since `TextView` has no
156/// `OnSecondaryContainer` role — see the module docs' label-color
157/// simplification).
158const CHIP_LABEL_SELECTED: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
159/// Unthemed-fallback unselected-chip label/foreground color (a theme
160/// resolves this from `colors.on_surface_variant` exactly).
161const CHIP_LABEL_UNSELECTED: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
162
163/// Unthemed-fallback shadow y-offset, matching
164/// `crate::tokens::elevation().level1`'s `y_offset` exactly (`dp / 2.0 + 1.0`
165/// at `dp = 1.0`).
166const CHIP_SHADOW_Y_OFFSET: f64 = 1.5;
167/// Unthemed-fallback shadow blur std-dev, matching
168/// `crate::tokens::elevation().level1`.
169const CHIP_SHADOW_BLUR: f64 = 1.0;
170/// Unthemed-fallback shadow color (opaque black at
171/// `crate::tokens::elevation().level1`'s `0.3` alpha).
172const CHIP_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
173
174/// Return `color` with its alpha channel replaced by `alpha` (mirrors
175/// [`super::card`]'s/[`super::state_layer`]'s helper of the same shape).
176fn with_alpha(color: Color, alpha: f32) -> Color {
177    let c = color.components;
178    Color::new([c[0], c[1], c[2], alpha])
179}
180
181fn inside(pos: Point, size: Size) -> bool {
182    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
183}
184
185/// The chip corner radius: themed `shape.small`, resolved against the box so
186/// it never exceeds a pill. Unthemed: [`CHIP_RADIUS`] exactly.
187fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
188    match theme {
189        Some(theme) => ShapeScale::resolve(theme.shape.small, size.width, size.height),
190        None => CHIP_RADIUS,
191    }
192}
193
194/// The resolved `(container, outlined)` pair at the given `selected`/
195/// `elevated` state — see the module docs' matrix. `type` never enters this
196/// resolution (see *Four types, one color table*): every chip type calls
197/// this same function.
198fn resolve_chip_container(theme: Option<&Theme>, selected: bool, elevated: bool) -> (Color, bool) {
199    let outlined = !selected && !elevated;
200    let container = if let Some(theme) = theme {
201        let scheme = theme.scheme();
202        if selected {
203            scheme.secondary_container
204        } else if elevated {
205            scheme.surface_container_low
206        } else {
207            Color::TRANSPARENT
208        }
209    } else if selected {
210        CHIP_SELECTED_CONTAINER
211    } else if elevated {
212        CHIP_ELEVATED_CONTAINER
213    } else {
214        Color::TRANSPARENT
215    };
216    (container, outlined)
217}
218
219/// The resolved label/state-layer content color at the given `selected`
220/// state — see the module docs' label-color simplification for why the
221/// selected case is an approximation.
222fn resolve_chip_foreground(theme: Option<&Theme>, selected: bool) -> Color {
223    match theme {
224        Some(theme) => {
225            let scheme = theme.scheme();
226            if selected {
227                scheme.on_secondary_container
228            } else {
229                scheme.on_surface_variant
230            }
231        }
232        None => {
233            if selected {
234                CHIP_LABEL_SELECTED
235            } else {
236                CHIP_LABEL_UNSELECTED
237            }
238        }
239    }
240}
241
242/// The resolved outline stroke color (unselected, unelevated chips only).
243/// Themed: `colors.outline_variant`. Unthemed: [`CHIP_OUTLINE_VARIANT`].
244fn resolve_chip_outline(theme: Option<&Theme>) -> Color {
245    match theme {
246        Some(theme) => theme.scheme().outline_variant,
247        None => CHIP_OUTLINE_VARIANT,
248    }
249}
250
251/// The resolved `(blur_std_dev, y_offset, color)` shadow parameters at M3
252/// elevation level 1 (the `elevated` modifier's fixed level — mirrors
253/// [`super::card`]'s identically-named helper).
254fn resolve_chip_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
255    match theme {
256        Some(theme) => {
257            let level = theme.elevation.level1;
258            let shadow = level.shadow(theme.brightness);
259            let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
260            (shadow.blur_std_dev, shadow.y_offset, color)
261        }
262        None => (CHIP_SHADOW_BLUR, CHIP_SHADOW_Y_OFFSET, CHIP_SHADOW_COLOR),
263    }
264}
265
266/// Build the type-erased label/leading-glyph view, themed per `selected`
267/// (see the module docs' label-color simplification), its family following
268/// the live theme's `labelLarge` role (the M3 chip label token). Shared by
269/// every chip type's build/rebuild/teardown, for both the label and an
270/// optional leading glyph, so the role stays consistent between them.
271fn chip_label_view<State: 'static>(
272    text: String,
273    selected: bool,
274) -> frust::authoring::AnyView<State> {
275    let role = if selected {
276        ThemeTextColor::OnSurface
277    } else {
278        ThemeTextColor::OnSurfaceVariant
279    };
280    any::<State, _>(
281        frust::text(text)
282            .themed_role(role)
283            .themed_family(ThemeTextType::LabelLarge),
284    )
285}
286
287/// Paint the full M3 chip surface: an optional level-1 elevation shadow
288/// (`elevated`), the fill (skipped when fully transparent — mirrors
289/// `frust_widgets::button`'s own don't-paint-a-transparent-fill precedent),
290/// an optional 1dp `outline_variant` stroke (neither `selected` nor
291/// `elevated`), and the shared [`super::state_layer`] interaction overlay.
292/// Shared by all four chip widgets' `paint()`. Resolves the theme from `ctx`
293/// internally (rather than taking it as a separate `Option<&Theme>`
294/// parameter) so its borrow ends before the trailing `state_layer.paint`
295/// call re-borrows `ctx` mutably; returns the resolved foreground color so
296/// the caller can paint its label/leading/delete-icon content with the same
297/// value.
298fn paint_chip_surface(
299    ctx: &mut PaintCtx,
300    scene: &mut dyn PaintScene,
301    selected: bool,
302    elevated: bool,
303    state_layer: &StateLayer,
304) -> Color {
305    let theme = Theme::from_paint_ctx(ctx);
306    let radius = resolve_radius(theme, ctx.size());
307    let (container, outlined) = resolve_chip_container(theme, selected, elevated);
308    let foreground = resolve_chip_foreground(theme, selected);
309    let o = ctx.origin();
310    let size = ctx.size();
311
312    if elevated {
313        let (blur, y_offset, shadow_color) = resolve_chip_shadow(theme);
314        scene.draw_shadow(
315            Point::new(o.x, o.y + y_offset),
316            size,
317            radius,
318            blur,
319            shadow_color,
320        );
321    }
322
323    if container != Color::TRANSPARENT {
324        scene.fill_rounded_rect(o, size, radius, container);
325    }
326
327    if outlined {
328        let outline = resolve_chip_outline(theme);
329        // Inset by half the stroke width so the 1dp line paints fully
330        // inside the chip's own bounds (a stroke is centered on its path) —
331        // mirrors `super::card`'s outlined-variant precedent.
332        let half = CHIP_STROKE_WIDTH / 2.0;
333        let rr = RoundedRect::new(
334            half,
335            half,
336            size.width - half,
337            size.height - half,
338            (radius - half).max(0.0),
339        );
340        let path = rr.to_path(CHIP_STROKE_TOLERANCE);
341        scene.stroke_path(o, &path, CHIP_STROKE_WIDTH, &Brush::Solid(outline));
342    }
343
344    // `theme`'s last use is above (`resolve_chip_outline`, if reached); no
345    // further reads of it happen after this point, so its borrow of `ctx`
346    // has ended by the time `ctx` is passed mutably below.
347    state_layer.paint(
348        ctx,
349        scene,
350        Rect::from_origin_size(o, size),
351        radius,
352        foreground,
353    );
354    foreground
355}
356
357/// A view-held, typed press callback (erased on build) — shared by every
358/// chip type's `on_press`/`on_deleted`.
359type OnPress<State> = Rc<dyn Fn(&mut State)>;
360
361// ---------------------------------------------------------------------
362// AssistChip
363// ---------------------------------------------------------------------
364
365/// A declarative assist chip. See the [module docs](self).
366pub struct AssistChipView<State: 'static> {
367    label: String,
368    leading: Option<String>,
369    elevated: bool,
370    on_press: OnPress<State>,
371}
372
373/// Create an assist chip labelled `label` that runs `on_press` against the
374/// app state when released inside its bounds.
375pub fn assist_chip<State: 'static, F: Fn(&mut State) + 'static>(
376    label: impl Into<String>,
377    on_press: F,
378) -> AssistChipView<State> {
379    AssistChipView {
380        label: label.into(),
381        leading: None,
382        elevated: false,
383        on_press: Rc::new(on_press),
384    }
385}
386
387/// PascalCase alias for [`assist_chip`].
388#[allow(non_snake_case)]
389pub fn AssistChip<State: 'static, F: Fn(&mut State) + 'static>(
390    label: impl Into<String>,
391    on_press: F,
392) -> AssistChipView<State> {
393    assist_chip(label, on_press)
394}
395
396impl<State: 'static> AssistChipView<State> {
397    /// Attach a leading glyph/icon (rendered as a second nested text run —
398    /// see the module docs), painted before the label with a tighter left
399    /// inset.
400    pub fn leading(mut self, glyph: impl Into<String>) -> Self {
401        self.leading = Some(glyph.into());
402        self
403    }
404
405    /// Apply the `elevated` modifier (default `false`) — see the module
406    /// docs' Elevated modifier section.
407    pub fn elevated(mut self, elevated: bool) -> Self {
408        self.elevated = elevated;
409        self
410    }
411}
412
413/// The retained widget for an [`AssistChipView`].
414pub struct AssistChipWidget {
415    leading: Option<ChildPod>,
416    leading_text: Option<String>,
417    label: ChildPod,
418    label_text: String,
419    elevated: bool,
420    /// The pressed *visual* state; follows the cursor in/out while captured.
421    pressed: bool,
422    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
423    captured: bool,
424    state_layer: StateLayer,
425    on_press: frust::authoring::ErasedCallback,
426}
427
428impl<State: 'static> View<State> for AssistChipView<State> {
429    type Element = AssistChipWidget;
430
431    fn build(&self, ctx: &mut BuildCtx<'_>) -> AssistChipWidget {
432        let label_view = chip_label_view::<State>(self.label.clone(), false);
433        let leading = self.leading.as_ref().map(|glyph| {
434            frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
435        });
436        AssistChipWidget {
437            leading,
438            leading_text: self.leading.clone(),
439            label: frust::authoring::build_child(&label_view, ctx),
440            label_text: self.label.clone(),
441            elevated: self.elevated,
442            pressed: false,
443            captured: false,
444            state_layer: StateLayer::new(),
445            on_press: frust::authoring::erase_callback(&self.on_press),
446        }
447    }
448
449    fn rebuild(
450        &self,
451        prev: &Self,
452        element: &mut AssistChipWidget,
453        ctx: &mut BuildCtx<'_>,
454    ) -> ChangeFlags {
455        element.on_press = frust::authoring::erase_callback(&self.on_press);
456        let mut flags = ChangeFlags::NONE;
457
458        if prev.elevated != self.elevated {
459            element.elevated = self.elevated;
460            flags |= ChangeFlags::PAINT;
461        }
462
463        if prev.label != self.label {
464            element.label_text = self.label.clone();
465            let prev_view = chip_label_view::<State>(prev.label.clone(), false);
466            let next_view = chip_label_view::<State>(self.label.clone(), false);
467            flags |=
468                frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
469        }
470
471        match (&prev.leading, &self.leading) {
472            (None, None) => {}
473            (Some(prev_glyph), Some(next_glyph)) => {
474                element.leading_text = self.leading.clone();
475                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
476                let next_view = chip_label_view::<State>(next_glyph.clone(), false);
477                let pod = element
478                    .leading
479                    .as_mut()
480                    .expect("leading pod present when prev.leading is Some");
481                flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
482            }
483            (None, Some(next_glyph)) => {
484                element.leading_text = self.leading.clone();
485                element.leading = Some(frust::authoring::build_child(
486                    &chip_label_view::<State>(next_glyph.clone(), false),
487                    ctx,
488                ));
489                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
490            }
491            (Some(prev_glyph), None) => {
492                element.leading_text = None;
493                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
494                let mut pod = element
495                    .leading
496                    .take()
497                    .expect("leading pod present when prev.leading is Some");
498                frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
499                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
500            }
501        }
502
503        flags
504    }
505
506    fn teardown(&self, element: &mut AssistChipWidget, ctx: &mut BuildCtx<'_>) {
507        let label_view = chip_label_view::<State>(self.label.clone(), false);
508        frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
509        if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
510            let leading_view = chip_label_view::<State>(glyph.clone(), false);
511            frust::authoring::teardown_child(&leading_view, pod, ctx);
512        }
513    }
514}
515
516impl Widget for AssistChipWidget {
517    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
518        let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
519        let mut x = if self.leading.is_some() {
520            CHIP_ICON_START_PAD
521        } else {
522            CHIP_LABEL_START_PAD
523        };
524        if let Some(leading) = &mut self.leading {
525            let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
526            leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
527            x += size.width + CHIP_ICON_LABEL_GAP;
528        }
529        let label_size = self
530            .label
531            .layout_child(ctx, &BoxConstraints::loose(inner_max));
532        self.label
533            .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
534        x += label_size.width + CHIP_END_PAD;
535        bc.constrain(Size::new(x, CHIP_HEIGHT))
536    }
537
538    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
539        paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
540        if let Some(leading) = &mut self.leading {
541            leading.paint_child(ctx, scene);
542        }
543        self.label.paint_child(ctx, scene);
544    }
545
546    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
547        let InputEvent::Pointer(p) = event else {
548            return EventResult::Ignored;
549        };
550        match p.phase {
551            PointerPhase::Down => {
552                if !presses(p) {
553                    return EventResult::Ignored;
554                }
555                self.pressed = true;
556                self.captured = true;
557                self.state_layer.set_pressed(true);
558                ctx.capture_pointer();
559                ctx.request_redraw();
560                EventResult::Handled
561            }
562            PointerPhase::Move => {
563                if !self.captured {
564                    return EventResult::Ignored;
565                }
566                let inside_now = inside(p.position, ctx.size());
567                self.pressed = inside_now;
568                self.state_layer.set_pressed(inside_now);
569                ctx.request_redraw();
570                EventResult::Handled
571            }
572            PointerPhase::Up => {
573                if !self.captured {
574                    return EventResult::Ignored;
575                }
576                if inside(p.position, ctx.size()) {
577                    (self.on_press)(ctx);
578                }
579                self.pressed = false;
580                self.captured = false;
581                self.state_layer.set_pressed(false);
582                ctx.request_redraw();
583                EventResult::Handled
584            }
585            PointerPhase::Cancel => {
586                if !self.captured {
587                    return EventResult::Ignored;
588                }
589                self.pressed = false;
590                self.captured = false;
591                self.state_layer.set_pressed(false);
592                ctx.request_redraw();
593                EventResult::Handled
594            }
595        }
596    }
597
598    fn semantics(&self, ctx: &mut SemanticsCtx) {
599        ctx.push_node(Role::Button, |node| {
600            node.set_label(self.label_text.as_str());
601            node.add_action(Action::Click);
602        });
603    }
604
605    frust::authoring::visit_children!(leading, label);
606}
607
608// ---------------------------------------------------------------------
609// FilterChip
610// ---------------------------------------------------------------------
611
612/// A view-held, typed select callback (erased on build).
613type OnSelect<State> = Rc<dyn Fn(&mut State, bool)>;
614
615/// A declarative filter chip. See the [module docs](self).
616pub struct FilterChipView<State: 'static> {
617    label: String,
618    selected: bool,
619    elevated: bool,
620    on_select: OnSelect<State>,
621}
622
623/// Create a filter chip labelled `label` reflecting `selected`, that fires
624/// `on_select(state, !selected)` on release inside its bounds.
625pub fn filter_chip<State: 'static, F: Fn(&mut State, bool) + 'static>(
626    label: impl Into<String>,
627    selected: bool,
628    on_select: F,
629) -> FilterChipView<State> {
630    FilterChipView {
631        label: label.into(),
632        selected,
633        elevated: false,
634        on_select: Rc::new(on_select),
635    }
636}
637
638/// PascalCase alias for [`filter_chip`].
639#[allow(non_snake_case)]
640pub fn FilterChip<State: 'static, F: Fn(&mut State, bool) + 'static>(
641    label: impl Into<String>,
642    selected: bool,
643    on_select: F,
644) -> FilterChipView<State> {
645    filter_chip(label, selected, on_select)
646}
647
648impl<State: 'static> FilterChipView<State> {
649    /// Apply the `elevated` modifier (default `false`) — see the module
650    /// docs' Elevated modifier section.
651    pub fn elevated(mut self, elevated: bool) -> Self {
652        self.elevated = elevated;
653        self
654    }
655}
656
657/// The retained widget for a [`FilterChipView`].
658pub struct FilterChipWidget {
659    /// The app-confirmed value (source of truth; adopted on `rebuild`).
660    selected: bool,
661    elevated: bool,
662    label: ChildPod,
663    label_text: String,
664    /// The pressed *visual* state; follows the cursor in/out while captured.
665    pressed: bool,
666    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
667    captured: bool,
668    state_layer: StateLayer,
669    on_select: frust::authoring::ErasedArgCallback<bool>,
670}
671
672impl<State: 'static> View<State> for FilterChipView<State> {
673    type Element = FilterChipWidget;
674
675    fn build(&self, ctx: &mut BuildCtx<'_>) -> FilterChipWidget {
676        let label_view = chip_label_view::<State>(self.label.clone(), self.selected);
677        FilterChipWidget {
678            selected: self.selected,
679            elevated: self.elevated,
680            label: frust::authoring::build_child(&label_view, ctx),
681            label_text: self.label.clone(),
682            pressed: false,
683            captured: false,
684            state_layer: StateLayer::new(),
685            on_select: frust::authoring::erase_callback_arg(&self.on_select),
686        }
687    }
688
689    fn rebuild(
690        &self,
691        prev: &Self,
692        element: &mut FilterChipWidget,
693        ctx: &mut BuildCtx<'_>,
694    ) -> ChangeFlags {
695        element.on_select = frust::authoring::erase_callback_arg(&self.on_select);
696        let mut flags = ChangeFlags::NONE;
697        if prev.selected != self.selected {
698            // The app is the source of truth: adopt the new value on rebuild.
699            element.selected = self.selected;
700            flags |= ChangeFlags::PAINT;
701        }
702        if prev.elevated != self.elevated {
703            element.elevated = self.elevated;
704            flags |= ChangeFlags::PAINT;
705        }
706        if prev.label != self.label || prev.selected != self.selected {
707            // The label's themed role tracks `selected` too (see the module
708            // docs' label-color simplification), so a selected-only change
709            // still needs a label rebuild.
710            element.label_text = self.label.clone();
711            let prev_view = chip_label_view::<State>(prev.label.clone(), prev.selected);
712            let next_view = chip_label_view::<State>(self.label.clone(), self.selected);
713            flags |=
714                frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
715        }
716        flags
717    }
718
719    fn teardown(&self, element: &mut FilterChipWidget, ctx: &mut BuildCtx<'_>) {
720        let label_view = chip_label_view::<State>(self.label.clone(), self.selected);
721        frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
722    }
723}
724
725impl Widget for FilterChipWidget {
726    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
727        let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
728        let label_size = self
729            .label
730            .layout_child(ctx, &BoxConstraints::loose(inner_max));
731        self.label.set_origin(Point::new(
732            CHIP_LABEL_START_PAD,
733            (CHIP_HEIGHT - label_size.height) / 2.0,
734        ));
735        bc.constrain(Size::new(
736            label_size.width + CHIP_LABEL_START_PAD + CHIP_END_PAD,
737            CHIP_HEIGHT,
738        ))
739    }
740
741    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
742        paint_chip_surface(ctx, scene, self.selected, self.elevated, &self.state_layer);
743        self.label.paint_child(ctx, scene);
744    }
745
746    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
747        let InputEvent::Pointer(p) = event else {
748            return EventResult::Ignored;
749        };
750        match p.phase {
751            PointerPhase::Down => {
752                if !presses(p) {
753                    return EventResult::Ignored;
754                }
755                self.pressed = true;
756                self.captured = true;
757                self.state_layer.set_pressed(true);
758                ctx.capture_pointer();
759                ctx.request_redraw();
760                EventResult::Handled
761            }
762            PointerPhase::Move => {
763                if !self.captured {
764                    return EventResult::Ignored;
765                }
766                let inside_now = inside(p.position, ctx.size());
767                self.pressed = inside_now;
768                self.state_layer.set_pressed(inside_now);
769                ctx.request_redraw();
770                EventResult::Handled
771            }
772            PointerPhase::Up => {
773                if !self.captured {
774                    return EventResult::Ignored;
775                }
776                if inside(p.position, ctx.size()) {
777                    // Report the *requested* value; never self-toggle.
778                    let requested = !self.selected;
779                    (self.on_select)(ctx, requested);
780                }
781                self.pressed = false;
782                self.captured = false;
783                self.state_layer.set_pressed(false);
784                ctx.request_redraw();
785                EventResult::Handled
786            }
787            PointerPhase::Cancel => {
788                if !self.captured {
789                    return EventResult::Ignored;
790                }
791                self.pressed = false;
792                self.captured = false;
793                self.state_layer.set_pressed(false);
794                ctx.request_redraw();
795                EventResult::Handled
796            }
797        }
798    }
799
800    fn semantics(&self, ctx: &mut SemanticsCtx) {
801        // No dedicated "toggle button"/"selectable chip" accesskit role
802        // exists; a filter chip is exposed as a Button carrying Toggled
803        // state (the common aria-pressed-style pattern for a toggleable
804        // button-shaped control).
805        ctx.push_node(Role::Button, |node| {
806            node.set_label(self.label_text.as_str());
807            node.set_toggled(Toggled::from(self.selected));
808            node.add_action(Action::Click);
809        });
810    }
811
812    frust::authoring::visit_children!(label);
813}
814
815// ---------------------------------------------------------------------
816// SuggestionChip
817// ---------------------------------------------------------------------
818
819/// A declarative suggestion chip: action-only, like [`AssistChipView`] —
820/// its own type since M3 draws a semantic (not visual) distinction between
821/// "an assist action" and "a dynamically generated suggestion". See the
822/// [module docs](self).
823pub struct SuggestionChipView<State: 'static> {
824    label: String,
825    leading: Option<String>,
826    elevated: bool,
827    on_press: OnPress<State>,
828}
829
830/// Create a suggestion chip labelled `label` that runs `on_press` against
831/// the app state when released inside its bounds.
832pub fn suggestion_chip<State: 'static, F: Fn(&mut State) + 'static>(
833    label: impl Into<String>,
834    on_press: F,
835) -> SuggestionChipView<State> {
836    SuggestionChipView {
837        label: label.into(),
838        leading: None,
839        elevated: false,
840        on_press: Rc::new(on_press),
841    }
842}
843
844/// PascalCase alias for [`suggestion_chip`].
845#[allow(non_snake_case)]
846pub fn SuggestionChip<State: 'static, F: Fn(&mut State) + 'static>(
847    label: impl Into<String>,
848    on_press: F,
849) -> SuggestionChipView<State> {
850    suggestion_chip(label, on_press)
851}
852
853impl<State: 'static> SuggestionChipView<State> {
854    /// Attach a leading glyph/icon (rendered as a second nested text run —
855    /// see the module docs), painted before the label with a tighter left
856    /// inset.
857    pub fn leading(mut self, glyph: impl Into<String>) -> Self {
858        self.leading = Some(glyph.into());
859        self
860    }
861
862    /// Apply the `elevated` modifier (default `false`) — see the module
863    /// docs' Elevated modifier section.
864    pub fn elevated(mut self, elevated: bool) -> Self {
865        self.elevated = elevated;
866        self
867    }
868}
869
870/// The retained widget for a [`SuggestionChipView`].
871pub struct SuggestionChipWidget {
872    leading: Option<ChildPod>,
873    leading_text: Option<String>,
874    label: ChildPod,
875    label_text: String,
876    elevated: bool,
877    pressed: bool,
878    captured: bool,
879    state_layer: StateLayer,
880    on_press: frust::authoring::ErasedCallback,
881}
882
883impl<State: 'static> View<State> for SuggestionChipView<State> {
884    type Element = SuggestionChipWidget;
885
886    fn build(&self, ctx: &mut BuildCtx<'_>) -> SuggestionChipWidget {
887        let label_view = chip_label_view::<State>(self.label.clone(), false);
888        let leading = self.leading.as_ref().map(|glyph| {
889            frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
890        });
891        SuggestionChipWidget {
892            leading,
893            leading_text: self.leading.clone(),
894            label: frust::authoring::build_child(&label_view, ctx),
895            label_text: self.label.clone(),
896            elevated: self.elevated,
897            pressed: false,
898            captured: false,
899            state_layer: StateLayer::new(),
900            on_press: frust::authoring::erase_callback(&self.on_press),
901        }
902    }
903
904    fn rebuild(
905        &self,
906        prev: &Self,
907        element: &mut SuggestionChipWidget,
908        ctx: &mut BuildCtx<'_>,
909    ) -> ChangeFlags {
910        element.on_press = frust::authoring::erase_callback(&self.on_press);
911        let mut flags = ChangeFlags::NONE;
912
913        if prev.elevated != self.elevated {
914            element.elevated = self.elevated;
915            flags |= ChangeFlags::PAINT;
916        }
917
918        if prev.label != self.label {
919            element.label_text = self.label.clone();
920            let prev_view = chip_label_view::<State>(prev.label.clone(), false);
921            let next_view = chip_label_view::<State>(self.label.clone(), false);
922            flags |=
923                frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
924        }
925
926        match (&prev.leading, &self.leading) {
927            (None, None) => {}
928            (Some(prev_glyph), Some(next_glyph)) => {
929                element.leading_text = self.leading.clone();
930                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
931                let next_view = chip_label_view::<State>(next_glyph.clone(), false);
932                let pod = element
933                    .leading
934                    .as_mut()
935                    .expect("leading pod present when prev.leading is Some");
936                flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
937            }
938            (None, Some(next_glyph)) => {
939                element.leading_text = self.leading.clone();
940                element.leading = Some(frust::authoring::build_child(
941                    &chip_label_view::<State>(next_glyph.clone(), false),
942                    ctx,
943                ));
944                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
945            }
946            (Some(prev_glyph), None) => {
947                element.leading_text = None;
948                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
949                let mut pod = element
950                    .leading
951                    .take()
952                    .expect("leading pod present when prev.leading is Some");
953                frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
954                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
955            }
956        }
957
958        flags
959    }
960
961    fn teardown(&self, element: &mut SuggestionChipWidget, ctx: &mut BuildCtx<'_>) {
962        let label_view = chip_label_view::<State>(self.label.clone(), false);
963        frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
964        if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
965            let leading_view = chip_label_view::<State>(glyph.clone(), false);
966            frust::authoring::teardown_child(&leading_view, pod, ctx);
967        }
968    }
969}
970
971impl Widget for SuggestionChipWidget {
972    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
973        let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
974        let mut x = if self.leading.is_some() {
975            CHIP_ICON_START_PAD
976        } else {
977            CHIP_LABEL_START_PAD
978        };
979        if let Some(leading) = &mut self.leading {
980            let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
981            leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
982            x += size.width + CHIP_ICON_LABEL_GAP;
983        }
984        let label_size = self
985            .label
986            .layout_child(ctx, &BoxConstraints::loose(inner_max));
987        self.label
988            .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
989        x += label_size.width + CHIP_END_PAD;
990        bc.constrain(Size::new(x, CHIP_HEIGHT))
991    }
992
993    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
994        paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
995        if let Some(leading) = &mut self.leading {
996            leading.paint_child(ctx, scene);
997        }
998        self.label.paint_child(ctx, scene);
999    }
1000
1001    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1002        let InputEvent::Pointer(p) = event else {
1003            return EventResult::Ignored;
1004        };
1005        match p.phase {
1006            PointerPhase::Down => {
1007                if !presses(p) {
1008                    return EventResult::Ignored;
1009                }
1010                self.pressed = true;
1011                self.captured = true;
1012                self.state_layer.set_pressed(true);
1013                ctx.capture_pointer();
1014                ctx.request_redraw();
1015                EventResult::Handled
1016            }
1017            PointerPhase::Move => {
1018                if !self.captured {
1019                    return EventResult::Ignored;
1020                }
1021                let inside_now = inside(p.position, ctx.size());
1022                self.pressed = inside_now;
1023                self.state_layer.set_pressed(inside_now);
1024                ctx.request_redraw();
1025                EventResult::Handled
1026            }
1027            PointerPhase::Up => {
1028                if !self.captured {
1029                    return EventResult::Ignored;
1030                }
1031                if inside(p.position, ctx.size()) {
1032                    (self.on_press)(ctx);
1033                }
1034                self.pressed = false;
1035                self.captured = false;
1036                self.state_layer.set_pressed(false);
1037                ctx.request_redraw();
1038                EventResult::Handled
1039            }
1040            PointerPhase::Cancel => {
1041                if !self.captured {
1042                    return EventResult::Ignored;
1043                }
1044                self.pressed = false;
1045                self.captured = false;
1046                self.state_layer.set_pressed(false);
1047                ctx.request_redraw();
1048                EventResult::Handled
1049            }
1050        }
1051    }
1052
1053    fn semantics(&self, ctx: &mut SemanticsCtx) {
1054        ctx.push_node(Role::Button, |node| {
1055            node.set_label(self.label_text.as_str());
1056            node.add_action(Action::Click);
1057        });
1058    }
1059
1060    frust::authoring::visit_children!(leading, label);
1061}
1062
1063// ---------------------------------------------------------------------
1064// InputChip
1065// ---------------------------------------------------------------------
1066
1067/// Which region of an input chip's surface a pointer interaction targets:
1068/// its own [`InputChipView::on_press`] surface (`Body`), or — only when
1069/// [`InputChipView::on_deleted`] is set — the trailing delete icon
1070/// (`Delete`). Mirrors [`mod@super::split_button`]'s `Half` two-target
1071/// dispatch.
1072#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1073enum InputRegion {
1074    Body,
1075    Delete,
1076}
1077
1078/// A declarative input chip: user-entered information, with an optional
1079/// trailing delete affordance. See the [module docs](self).
1080pub struct InputChipView<State: 'static> {
1081    label: String,
1082    leading: Option<String>,
1083    elevated: bool,
1084    on_press: OnPress<State>,
1085    on_deleted: Option<OnPress<State>>,
1086}
1087
1088/// Create an input chip labelled `label` that runs `on_press` against the
1089/// app state when released inside its bounds (outside the delete icon, if
1090/// one is attached via [`InputChipView::on_deleted`]).
1091pub fn input_chip<State: 'static, F: Fn(&mut State) + 'static>(
1092    label: impl Into<String>,
1093    on_press: F,
1094) -> InputChipView<State> {
1095    InputChipView {
1096        label: label.into(),
1097        leading: None,
1098        elevated: false,
1099        on_press: Rc::new(on_press),
1100        on_deleted: None,
1101    }
1102}
1103
1104/// PascalCase alias for [`input_chip`].
1105#[allow(non_snake_case)]
1106pub fn InputChip<State: 'static, F: Fn(&mut State) + 'static>(
1107    label: impl Into<String>,
1108    on_press: F,
1109) -> InputChipView<State> {
1110    input_chip(label, on_press)
1111}
1112
1113impl<State: 'static> InputChipView<State> {
1114    /// Attach a leading glyph/icon (rendered as a second nested text run —
1115    /// see the module docs), painted before the label with a tighter left
1116    /// inset.
1117    pub fn leading(mut self, glyph: impl Into<String>) -> Self {
1118        self.leading = Some(glyph.into());
1119        self
1120    }
1121
1122    /// Apply the `elevated` modifier (default `false`) — see the module
1123    /// docs' Elevated modifier section.
1124    pub fn elevated(mut self, elevated: bool) -> Self {
1125        self.elevated = elevated;
1126        self
1127    }
1128
1129    /// Attach a trailing 18dp delete icon ([`crate::icons::CLOSE`]), firing
1130    /// `on_deleted` against the app state when released inside its own hit
1131    /// region — independent of [`InputChipView`]'s own `on_press` (see the
1132    /// module docs). Omit this to render a plain, non-deletable input chip.
1133    pub fn on_deleted<F: Fn(&mut State) + 'static>(mut self, on_deleted: F) -> Self {
1134        self.on_deleted = Some(Rc::new(on_deleted));
1135        self
1136    }
1137}
1138
1139/// The retained widget for an [`InputChipView`].
1140pub struct InputChipWidget {
1141    leading: Option<ChildPod>,
1142    leading_text: Option<String>,
1143    label: ChildPod,
1144    label_text: String,
1145    elevated: bool,
1146    has_delete: bool,
1147    /// The delete icon's local hit rect, from [`Widget::layout`]. `Rect::ZERO`
1148    /// when `has_delete` is `false`.
1149    delete_rect: Rect,
1150    /// The armed region of an in-flight press (`None` when idle).
1151    armed: Option<InputRegion>,
1152    /// Whether the armed pointer is currently inside the armed region.
1153    pressed_inside: bool,
1154    state_layer: StateLayer,
1155    on_press: frust::authoring::ErasedCallback,
1156    on_deleted: Option<frust::authoring::ErasedCallback>,
1157}
1158
1159impl<State: 'static> View<State> for InputChipView<State> {
1160    type Element = InputChipWidget;
1161
1162    fn build(&self, ctx: &mut BuildCtx<'_>) -> InputChipWidget {
1163        let label_view = chip_label_view::<State>(self.label.clone(), false);
1164        let leading = self.leading.as_ref().map(|glyph| {
1165            frust::authoring::build_child(&chip_label_view::<State>(glyph.clone(), false), ctx)
1166        });
1167        InputChipWidget {
1168            leading,
1169            leading_text: self.leading.clone(),
1170            label: frust::authoring::build_child(&label_view, ctx),
1171            label_text: self.label.clone(),
1172            elevated: self.elevated,
1173            has_delete: self.on_deleted.is_some(),
1174            delete_rect: Rect::ZERO,
1175            armed: None,
1176            pressed_inside: false,
1177            state_layer: StateLayer::new(),
1178            on_press: frust::authoring::erase_callback(&self.on_press),
1179            on_deleted: self
1180                .on_deleted
1181                .as_ref()
1182                .map(frust::authoring::erase_callback),
1183        }
1184    }
1185
1186    fn rebuild(
1187        &self,
1188        prev: &Self,
1189        element: &mut InputChipWidget,
1190        ctx: &mut BuildCtx<'_>,
1191    ) -> ChangeFlags {
1192        element.on_press = frust::authoring::erase_callback(&self.on_press);
1193        element.on_deleted = self
1194            .on_deleted
1195            .as_ref()
1196            .map(frust::authoring::erase_callback);
1197        let mut flags = ChangeFlags::NONE;
1198
1199        if prev.elevated != self.elevated {
1200            element.elevated = self.elevated;
1201            flags |= ChangeFlags::PAINT;
1202        }
1203
1204        let now_has_delete = self.on_deleted.is_some();
1205        if element.has_delete != now_has_delete {
1206            element.has_delete = now_has_delete;
1207            // Dropping the delete affordance mid-gesture must not leave a
1208            // dangling capture behind (mirrors `super::card`'s
1209            // interactive-flag-drop guard).
1210            if !now_has_delete && element.armed == Some(InputRegion::Delete) {
1211                element.armed = None;
1212                element.pressed_inside = false;
1213            }
1214            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1215        }
1216
1217        if prev.label != self.label {
1218            element.label_text = self.label.clone();
1219            let prev_view = chip_label_view::<State>(prev.label.clone(), false);
1220            let next_view = chip_label_view::<State>(self.label.clone(), false);
1221            flags |=
1222                frust::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
1223        }
1224
1225        match (&prev.leading, &self.leading) {
1226            (None, None) => {}
1227            (Some(prev_glyph), Some(next_glyph)) => {
1228                element.leading_text = self.leading.clone();
1229                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
1230                let next_view = chip_label_view::<State>(next_glyph.clone(), false);
1231                let pod = element
1232                    .leading
1233                    .as_mut()
1234                    .expect("leading pod present when prev.leading is Some");
1235                flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
1236            }
1237            (None, Some(next_glyph)) => {
1238                element.leading_text = self.leading.clone();
1239                element.leading = Some(frust::authoring::build_child(
1240                    &chip_label_view::<State>(next_glyph.clone(), false),
1241                    ctx,
1242                ));
1243                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1244            }
1245            (Some(prev_glyph), None) => {
1246                element.leading_text = None;
1247                let prev_view = chip_label_view::<State>(prev_glyph.clone(), false);
1248                let mut pod = element
1249                    .leading
1250                    .take()
1251                    .expect("leading pod present when prev.leading is Some");
1252                frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
1253                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1254            }
1255        }
1256
1257        flags
1258    }
1259
1260    fn teardown(&self, element: &mut InputChipWidget, ctx: &mut BuildCtx<'_>) {
1261        let label_view = chip_label_view::<State>(self.label.clone(), false);
1262        frust::authoring::teardown_child(&label_view, &mut element.label, ctx);
1263        if let (Some(glyph), Some(pod)) = (&self.leading, &mut element.leading) {
1264            let leading_view = chip_label_view::<State>(glyph.clone(), false);
1265            frust::authoring::teardown_child(&leading_view, pod, ctx);
1266        }
1267    }
1268}
1269
1270impl Widget for InputChipWidget {
1271    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1272        let inner_max = Size::new(f64::INFINITY, CHIP_HEIGHT);
1273        let mut x = if self.leading.is_some() {
1274            CHIP_ICON_START_PAD
1275        } else {
1276            CHIP_LABEL_START_PAD
1277        };
1278        if let Some(leading) = &mut self.leading {
1279            let size = leading.layout_child(ctx, &BoxConstraints::loose(inner_max));
1280            leading.set_origin(Point::new(x, (CHIP_HEIGHT - size.height) / 2.0));
1281            x += size.width + CHIP_ICON_LABEL_GAP;
1282        }
1283        let label_size = self
1284            .label
1285            .layout_child(ctx, &BoxConstraints::loose(inner_max));
1286        self.label
1287            .set_origin(Point::new(x, (CHIP_HEIGHT - label_size.height) / 2.0));
1288        x += label_size.width;
1289        if self.has_delete {
1290            x += CHIP_ICON_LABEL_GAP;
1291            let icon_y = (CHIP_HEIGHT - CHIP_ICON_SIZE) / 2.0;
1292            self.delete_rect = Rect::new(x, icon_y, x + CHIP_ICON_SIZE, icon_y + CHIP_ICON_SIZE);
1293            x += CHIP_ICON_SIZE;
1294        } else {
1295            self.delete_rect = Rect::ZERO;
1296        }
1297        x += CHIP_END_PAD;
1298        bc.constrain(Size::new(x, CHIP_HEIGHT))
1299    }
1300
1301    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1302        // Input chips are never `selected` — see the module docs' Selected
1303        // state per type.
1304        let foreground = paint_chip_surface(ctx, scene, false, self.elevated, &self.state_layer);
1305        if let Some(leading) = &mut self.leading {
1306            leading.paint_child(ctx, scene);
1307        }
1308        self.label.paint_child(ctx, scene);
1309        if self.has_delete {
1310            let (base_path, design) = IconData::from(icons::CLOSE).resolve();
1311            let scale = if design > 0.0 {
1312                CHIP_ICON_SIZE / design
1313            } else {
1314                1.0
1315            };
1316            let scaled = Affine::scale(scale) * base_path;
1317            let icon_origin = Point::new(
1318                ctx.origin().x + self.delete_rect.x0,
1319                ctx.origin().y + self.delete_rect.y0,
1320            );
1321            scene.fill_path(icon_origin, &scaled, &Brush::Solid(foreground));
1322        }
1323    }
1324
1325    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1326        let InputEvent::Pointer(p) = event else {
1327            return EventResult::Ignored;
1328        };
1329        match p.phase {
1330            PointerPhase::Down => {
1331                if !presses(p) {
1332                    return EventResult::Ignored;
1333                }
1334                let region = if self.has_delete && self.delete_rect.contains(p.position) {
1335                    InputRegion::Delete
1336                } else if inside(p.position, ctx.size()) {
1337                    InputRegion::Body
1338                } else {
1339                    return EventResult::Ignored;
1340                };
1341                self.armed = Some(region);
1342                self.pressed_inside = true;
1343                if region == InputRegion::Body {
1344                    // The delete icon carries no interaction-state feedback
1345                    // of its own, matching the reference's bare
1346                    // `GestureDetector` (no state-layer coupling) — see the
1347                    // module docs.
1348                    self.state_layer.set_pressed(true);
1349                }
1350                ctx.capture_pointer();
1351                ctx.request_redraw();
1352                EventResult::Handled
1353            }
1354            PointerPhase::Move => {
1355                let Some(region) = self.armed else {
1356                    return EventResult::Ignored;
1357                };
1358                let now_inside = match region {
1359                    InputRegion::Delete => self.delete_rect.contains(p.position),
1360                    InputRegion::Body => inside(p.position, ctx.size()),
1361                };
1362                self.pressed_inside = now_inside;
1363                if region == InputRegion::Body {
1364                    self.state_layer.set_pressed(now_inside);
1365                }
1366                ctx.request_redraw();
1367                EventResult::Handled
1368            }
1369            PointerPhase::Up => {
1370                let Some(region) = self.armed else {
1371                    return EventResult::Ignored;
1372                };
1373                if self.pressed_inside {
1374                    match region {
1375                        InputRegion::Body => (self.on_press)(ctx),
1376                        InputRegion::Delete => {
1377                            if let Some(on_deleted) = &mut self.on_deleted {
1378                                (on_deleted)(ctx);
1379                            }
1380                        }
1381                    }
1382                }
1383                self.armed = None;
1384                self.pressed_inside = false;
1385                self.state_layer.set_pressed(false);
1386                ctx.request_redraw();
1387                EventResult::Handled
1388            }
1389            PointerPhase::Cancel => {
1390                if self.armed.is_none() {
1391                    return EventResult::Ignored;
1392                }
1393                self.armed = None;
1394                self.pressed_inside = false;
1395                self.state_layer.set_pressed(false);
1396                ctx.request_redraw();
1397                EventResult::Handled
1398            }
1399        }
1400    }
1401
1402    fn semantics(&self, ctx: &mut SemanticsCtx) {
1403        if self.has_delete {
1404            // Two separate interactive targets under one group, mirroring
1405            // `super::split_button`'s Group + two-Button semantics shape.
1406            ctx.push_container(
1407                Role::Group,
1408                |_| {},
1409                |ctx| {
1410                    ctx.push_node(Role::Button, |node| {
1411                        node.set_label(self.label_text.as_str());
1412                        node.add_action(Action::Click);
1413                    });
1414                    ctx.push_node(Role::Button, |node| {
1415                        node.set_label("Remove");
1416                        node.add_action(Action::Click);
1417                    });
1418                },
1419            );
1420        } else {
1421            ctx.push_node(Role::Button, |node| {
1422                node.set_label(self.label_text.as_str());
1423                node.add_action(Action::Click);
1424            });
1425        }
1426    }
1427
1428    frust::authoring::visit_children!(leading, label);
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434    use std::any::Any;
1435
1436    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1437        InputEvent::Pointer(frust::authoring::PointerEvent {
1438            phase,
1439            position: Point::new(x, y),
1440            button: frust::authoring::PointerButton::Primary,
1441        })
1442    }
1443
1444    /// Records each rounded rect's `(origin, size, radius, color)`, each
1445    /// shadow's `(origin, size, radius, std_dev, color)`, each stroked
1446    /// path's `(origin, width, color)`, and each filled path's `(origin,
1447    /// color)` — mirrors `super::card`'s test `Recorder`.
1448    #[derive(Default)]
1449    struct ChipRecorder {
1450        rrects: Vec<(Point, Size, f64, Color)>,
1451        shadows: Vec<(Point, Size, f64, f64, Color)>,
1452        strokes: Vec<(Point, f64, Color)>,
1453        fills: Vec<(Point, Color)>,
1454    }
1455
1456    impl PaintScene for ChipRecorder {
1457        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1458        fn draw_text(&mut self, _o: Point, _t: &str) {}
1459        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1460            self.rrects.push((o, s, radius, color));
1461        }
1462        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1463            self.shadows.push((o, s, radius, std_dev, color));
1464        }
1465        fn stroke_path(
1466            &mut self,
1467            origin: Point,
1468            _path: &kurbo::BezPath,
1469            width: f64,
1470            brush: &Brush,
1471        ) {
1472            if let Brush::Solid(color) = brush {
1473                self.strokes.push((origin, width, *color));
1474            }
1475        }
1476        fn fill_path(&mut self, origin: Point, _path: &kurbo::BezPath, brush: &Brush) {
1477            if let Brush::Solid(color) = brush {
1478                self.fills.push((origin, *color));
1479            }
1480        }
1481    }
1482
1483    fn paint_rec(w: &mut dyn Widget, size: Size, theme: Option<&Theme>) -> ChipRecorder {
1484        let mut rec = ChipRecorder::default();
1485        let mut ctx = match theme {
1486            Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
1487            None => PaintCtx::new(Point::ZERO, size),
1488        };
1489        w.paint(&mut ctx, &mut rec);
1490        rec
1491    }
1492
1493    // --- Shared resolution matrix (acceptance criterion 1) ---
1494
1495    mod resolution_matrix {
1496        use super::*;
1497
1498        #[test]
1499        fn unthemed_unselected_unelevated_is_transparent_and_outlined() {
1500            let (container, outlined) = resolve_chip_container(None, false, false);
1501            assert_eq!(container, Color::TRANSPARENT);
1502            assert!(outlined);
1503        }
1504
1505        #[test]
1506        fn unthemed_elevated_unselected_is_elevated_container_not_outlined() {
1507            let (container, outlined) = resolve_chip_container(None, false, true);
1508            assert_eq!(container, CHIP_ELEVATED_CONTAINER);
1509            assert!(!outlined);
1510        }
1511
1512        #[test]
1513        fn unthemed_selected_is_selected_container_not_outlined_regardless_of_elevated() {
1514            let (container, outlined) = resolve_chip_container(None, true, false);
1515            assert_eq!(container, CHIP_SELECTED_CONTAINER);
1516            assert!(!outlined);
1517
1518            let (container, outlined) = resolve_chip_container(None, true, true);
1519            assert_eq!(container, CHIP_SELECTED_CONTAINER);
1520            assert!(!outlined);
1521        }
1522
1523        #[test]
1524        fn themed_matrix_resolves_exact_scheme_roles() {
1525            let theme = crate::baseline();
1526            let scheme = theme.scheme();
1527
1528            let (unselected_unelevated, outlined) =
1529                resolve_chip_container(Some(&theme), false, false);
1530            assert_eq!(unselected_unelevated, Color::TRANSPARENT);
1531            assert!(outlined);
1532
1533            let (elevated, outlined) = resolve_chip_container(Some(&theme), false, true);
1534            assert_eq!(elevated, scheme.surface_container_low);
1535            assert!(!outlined);
1536
1537            let (selected, outlined) = resolve_chip_container(Some(&theme), true, false);
1538            assert_eq!(selected, scheme.secondary_container);
1539            assert!(!outlined);
1540
1541            // Selected + elevated: still `secondary_container`, still not
1542            // outlined (the shadow is gated on `elevated` alone, in the
1543            // widget-level paint helper, not here).
1544            let (selected_elevated, outlined) = resolve_chip_container(Some(&theme), true, true);
1545            assert_eq!(selected_elevated, scheme.secondary_container);
1546            assert!(!outlined);
1547        }
1548
1549        #[test]
1550        fn themed_foreground_resolves_on_surface_variant_unselected_on_secondary_container_selected()
1551         {
1552            let theme = crate::baseline();
1553            let scheme = theme.scheme();
1554            assert_eq!(
1555                resolve_chip_foreground(Some(&theme), false),
1556                scheme.on_surface_variant
1557            );
1558            // `resolve_chip_foreground` is the exact raw-paint-color
1559            // resolver (state layer, delete icon, outline); only the
1560            // nested `TextView` label approximates the selected case as
1561            // `ThemeTextColor::OnSurface` (see the module docs' label-color
1562            // simplification) — this function itself resolves the real
1563            // `on_secondary_container` role.
1564            assert_eq!(
1565                resolve_chip_foreground(Some(&theme), true),
1566                scheme.on_secondary_container
1567            );
1568        }
1569
1570        #[test]
1571        fn unthemed_foreground_matches_fallback_constants() {
1572            assert_eq!(resolve_chip_foreground(None, false), CHIP_LABEL_UNSELECTED);
1573            assert_eq!(resolve_chip_foreground(None, true), CHIP_LABEL_SELECTED);
1574        }
1575
1576        #[test]
1577        fn themed_outline_resolves_outline_variant() {
1578            let theme = crate::baseline();
1579            assert_eq!(
1580                resolve_chip_outline(Some(&theme)),
1581                theme.scheme().outline_variant
1582            );
1583            assert_eq!(resolve_chip_outline(None), CHIP_OUTLINE_VARIANT);
1584        }
1585    }
1586
1587    // --- AssistChip ---
1588
1589    mod assist {
1590        use super::*;
1591
1592        #[derive(Default)]
1593        struct Presses(u32);
1594
1595        fn widget() -> AssistChipWidget {
1596            let view = assist_chip::<Presses, _>("Go", |s: &mut Presses| s.0 += 1);
1597            let mut counter = 0u64;
1598            View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter))
1599        }
1600
1601        fn dispatch(w: &mut AssistChipWidget, state: &mut Presses, event: &InputEvent) {
1602            let state_any: &mut dyn Any = state;
1603            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1604            w.event(&mut ctx, event);
1605        }
1606
1607        #[test]
1608        fn up_inside_fires_once() {
1609            let mut w = widget();
1610            let mut state = Presses::default();
1611            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1612            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1613            assert_eq!(state.0, 1);
1614        }
1615
1616        #[test]
1617        fn up_outside_does_not_fire() {
1618            let mut w = widget();
1619            let mut state = Presses::default();
1620            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1621            dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1622            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1623            assert_eq!(state.0, 0);
1624        }
1625
1626        #[test]
1627        fn cancel_clears_armed_state_without_firing() {
1628            let mut w = widget();
1629            let mut state = Presses::default();
1630            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1631            assert!(w.captured);
1632            dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
1633            assert!(!w.captured);
1634            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1635            assert_eq!(state.0, 0);
1636        }
1637
1638        #[test]
1639        fn hover_move_without_down_is_ignored_noop() {
1640            let mut w = widget();
1641            let mut state = Presses::default();
1642            let state_any: &mut dyn Any = &mut state;
1643            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1644            let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
1645            assert!(matches!(result, EventResult::Ignored));
1646            assert!(!ctx.needs_redraw());
1647            assert_eq!(state.0, 0);
1648        }
1649
1650        /// Corrects this module's original expectation (a flat
1651        /// `surface_container_low` fill for every unselected chip): an
1652        /// unelevated, unselected assist chip is now transparent-filled and
1653        /// outlined — see the module docs' matrix.
1654        #[test]
1655        fn unthemed_unelevated_paints_transparent_and_outlined() {
1656            let mut w = widget();
1657            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1658            assert!(
1659                rec.rrects.is_empty(),
1660                "a transparent fill is skipped entirely, not painted"
1661            );
1662            assert_eq!(rec.strokes.len(), 1);
1663            assert_eq!(rec.strokes[0].1, CHIP_STROKE_WIDTH);
1664            assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1665        }
1666
1667        #[test]
1668        fn elevated_paints_elevated_container_and_shadow_no_outline() {
1669            let view = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1670            let mut counter = 0u64;
1671            let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1672            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1673            assert_eq!(rec.rrects[0].3, CHIP_ELEVATED_CONTAINER);
1674            assert_eq!(rec.shadows.len(), 1);
1675            assert!(rec.strokes.is_empty(), "an elevated chip is never outlined");
1676        }
1677
1678        #[test]
1679        fn themed_unelevated_paint_resolves_outline_variant_stroke() {
1680            let theme = crate::baseline();
1681            let mut w = widget();
1682            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1683            assert!(rec.rrects.is_empty());
1684            assert_eq!(rec.strokes[0].2, theme.scheme().outline_variant);
1685        }
1686
1687        #[test]
1688        fn themed_elevated_paint_resolves_surface_container_low() {
1689            let theme = crate::baseline();
1690            let view = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1691            let mut counter = 0u64;
1692            let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1693            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1694            assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_low);
1695            assert_eq!(rec.rrects[0].2, theme.shape.small);
1696        }
1697
1698        #[test]
1699        fn leading_glyph_is_optional_and_shifts_label_layout() {
1700            use frust::authoring::text::TextContext;
1701            use std::any::Any;
1702
1703            let mut counter = 0u64;
1704            let plain = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {});
1705            let mut w_plain = View::<Presses>::build(&plain, &mut BuildCtx::new(&mut counter));
1706            let mut tcx = TextContext::new();
1707            let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1708            let size_plain =
1709                w_plain.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
1710
1711            let with_leading = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).leading("*");
1712            let mut w_leading =
1713                View::<Presses>::build(&with_leading, &mut BuildCtx::new(&mut counter));
1714            let mut lctx2 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1715            let size_leading =
1716                w_leading.layout(&mut lctx2, &BoxConstraints::loose(Size::new(400.0, 100.0)));
1717
1718            assert!(
1719                size_leading.width > size_plain.width,
1720                "a leading glyph widens the chip"
1721            );
1722            assert_eq!(size_plain.height, CHIP_HEIGHT);
1723            assert_eq!(size_leading.height, CHIP_HEIGHT);
1724        }
1725
1726        #[test]
1727        fn rebuild_elevated_toggle_flags_paint_only() {
1728            let mut counter = 0u64;
1729            let prev = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {});
1730            let mut w = View::<Presses>::build(&prev, &mut BuildCtx::new(&mut counter));
1731            let next = assist_chip::<Presses, _>("Go", |_s: &mut Presses| {}).elevated(true);
1732            let flags =
1733                View::<Presses>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1734            assert!(w.elevated);
1735            assert!(flags.needs_paint());
1736        }
1737
1738        #[test]
1739        fn semantics_reports_button_role_and_label() {
1740            use frust::authoring::text::TextContext;
1741            use std::any::Any;
1742
1743            fn logic(_s: &mut Presses) -> AssistChipView<Presses> {
1744                assist_chip::<Presses, _>("Go", |_s: &mut Presses| {})
1745            }
1746            let mut root: frust_core::RenderRoot<Presses, AssistChipView<Presses>> =
1747                frust_core::RenderRoot::new();
1748            let mut state = Presses::default();
1749            root.rebuild(&mut logic, &mut state);
1750            let mut tcx = TextContext::new();
1751            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1752            let update = root.semantics();
1753            let (_, node) = update
1754                .nodes
1755                .iter()
1756                .find(|(_, n)| n.role() == Role::Button)
1757                .expect("assist chip contributes a Role::Button node");
1758            assert_eq!(node.label(), Some("Go"));
1759            assert!(node.supports_action(Action::Click));
1760        }
1761    }
1762
1763    // --- FilterChip ---
1764
1765    mod filter {
1766        use super::*;
1767
1768        #[derive(Default)]
1769        struct SelectState {
1770            last: Option<bool>,
1771            selects: u32,
1772        }
1773
1774        fn widget(selected: bool) -> FilterChipWidget {
1775            let view =
1776                filter_chip::<SelectState, _>("Vegan", selected, |s: &mut SelectState, v: bool| {
1777                    s.last = Some(v);
1778                    s.selects += 1;
1779                });
1780            let mut counter = 0u64;
1781            View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
1782        }
1783
1784        fn dispatch(w: &mut FilterChipWidget, state: &mut SelectState, event: &InputEvent) {
1785            let state_any: &mut dyn Any = state;
1786            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1787            w.event(&mut ctx, event);
1788        }
1789
1790        #[test]
1791        fn unselected_fires_true_and_does_not_self_mutate() {
1792            let mut w = widget(false);
1793            let mut state = SelectState::default();
1794            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1795            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1796            assert_eq!(state.last, Some(true));
1797            assert_eq!(state.selects, 1);
1798            assert!(!w.selected, "chip must not mutate its own selected flag");
1799        }
1800
1801        #[test]
1802        fn selected_fires_false() {
1803            let mut w = widget(true);
1804            let mut state = SelectState::default();
1805            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1806            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1807            assert_eq!(state.last, Some(false));
1808            assert!(w.selected, "still selected until the app rebuilds it");
1809        }
1810
1811        #[test]
1812        fn up_outside_does_not_fire() {
1813            let mut w = widget(false);
1814            let mut state = SelectState::default();
1815            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1816            dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1817            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1818            assert_eq!(state.selects, 0);
1819        }
1820
1821        #[test]
1822        fn rebuild_adopts_new_selected_value_without_self_mutation() {
1823            let mut counter = 0u64;
1824            let prev = filter_chip::<SelectState, _>("Vegan", false, |_s, _v| {});
1825            let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
1826            assert!(!w.selected);
1827            let next = filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {});
1828            let flags = View::<SelectState>::rebuild(
1829                &next,
1830                &prev,
1831                &mut w,
1832                &mut BuildCtx::new(&mut counter),
1833            );
1834            assert!(w.selected);
1835            assert!(flags.needs_paint());
1836        }
1837
1838        /// Corrects this module's original expectations (an unselected
1839        /// filter chip filled `surface_container_low`, a selected one
1840        /// filled `secondary_container`): unselected is now
1841        /// transparent-filled and outlined; selected is unchanged (it was
1842        /// already `secondary_container`) — see the module docs' matrix.
1843        #[test]
1844        fn unthemed_paint_matches_the_matrix() {
1845            let mut off = widget(false);
1846            let rec = paint_rec(&mut off, Size::new(100.0, CHIP_HEIGHT), None);
1847            assert!(
1848                rec.rrects.is_empty(),
1849                "unselected+unelevated is transparent"
1850            );
1851            assert_eq!(rec.strokes.len(), 1);
1852            assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1853
1854            let mut on = widget(true);
1855            let rec = paint_rec(&mut on, Size::new(100.0, CHIP_HEIGHT), None);
1856            assert_eq!(rec.rrects[0].3, CHIP_SELECTED_CONTAINER);
1857            assert!(rec.strokes.is_empty(), "a selected chip is never outlined");
1858        }
1859
1860        #[test]
1861        fn themed_paint_resolves_secondary_container_when_selected() {
1862            let theme = crate::baseline();
1863            let mut off = widget(false);
1864            let rec = paint_rec(&mut off, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1865            assert!(rec.rrects.is_empty());
1866            assert_eq!(rec.strokes[0].2, theme.scheme().outline_variant);
1867
1868            let mut on = widget(true);
1869            let rec = paint_rec(&mut on, Size::new(100.0, CHIP_HEIGHT), Some(&theme));
1870            assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1871        }
1872
1873        #[test]
1874        fn elevated_and_selected_paints_both_shadow_and_selected_container() {
1875            let view = filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {}).elevated(true);
1876            let mut counter = 0u64;
1877            let mut w = View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter));
1878            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1879            assert_eq!(rec.rrects[0].3, CHIP_SELECTED_CONTAINER);
1880            assert_eq!(
1881                rec.shadows.len(),
1882                1,
1883                "elevated is gated independently of selected"
1884            );
1885            assert!(rec.strokes.is_empty());
1886        }
1887
1888        #[test]
1889        fn semantics_reports_toggled_state() {
1890            use frust::authoring::text::TextContext;
1891            use std::any::Any;
1892
1893            fn logic(_s: &mut SelectState) -> FilterChipView<SelectState> {
1894                filter_chip::<SelectState, _>("Vegan", true, |_s, _v| {})
1895            }
1896            let mut root: frust_core::RenderRoot<SelectState, FilterChipView<SelectState>> =
1897                frust_core::RenderRoot::new();
1898            let mut state = SelectState::default();
1899            root.rebuild(&mut logic, &mut state);
1900            let mut tcx = TextContext::new();
1901            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1902            let update = root.semantics();
1903            let (_, node) = update
1904                .nodes
1905                .iter()
1906                .find(|(_, n)| n.role() == Role::Button)
1907                .expect("filter chip contributes a Role::Button node");
1908            assert_eq!(node.label(), Some("Vegan"));
1909            assert_eq!(node.toggled(), Some(Toggled::True));
1910        }
1911    }
1912
1913    // --- SuggestionChip ---
1914
1915    mod suggestion {
1916        use super::*;
1917
1918        #[derive(Default)]
1919        struct Presses(u32);
1920
1921        fn widget() -> SuggestionChipWidget {
1922            let view = suggestion_chip::<Presses, _>("Try this", |s: &mut Presses| s.0 += 1);
1923            let mut counter = 0u64;
1924            View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter))
1925        }
1926
1927        fn dispatch(w: &mut SuggestionChipWidget, state: &mut Presses, event: &InputEvent) {
1928            let state_any: &mut dyn Any = state;
1929            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
1930            w.event(&mut ctx, event);
1931        }
1932
1933        #[test]
1934        fn up_inside_fires_once() {
1935            let mut w = widget();
1936            let mut state = Presses::default();
1937            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1938            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
1939            assert_eq!(state.0, 1);
1940        }
1941
1942        #[test]
1943        fn up_outside_does_not_fire() {
1944            let mut w = widget();
1945            let mut state = Presses::default();
1946            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
1947            dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
1948            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
1949            assert_eq!(state.0, 0);
1950        }
1951
1952        #[test]
1953        fn unthemed_unelevated_paints_transparent_and_outlined() {
1954            let mut w = widget();
1955            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1956            assert!(rec.rrects.is_empty());
1957            assert_eq!(rec.strokes[0].2, CHIP_OUTLINE_VARIANT);
1958        }
1959
1960        #[test]
1961        fn elevated_paints_elevated_container_and_shadow() {
1962            let view =
1963                suggestion_chip::<Presses, _>("Try this", |_s: &mut Presses| {}).elevated(true);
1964            let mut counter = 0u64;
1965            let mut w = View::<Presses>::build(&view, &mut BuildCtx::new(&mut counter));
1966            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
1967            assert_eq!(rec.rrects[0].3, CHIP_ELEVATED_CONTAINER);
1968            assert_eq!(rec.shadows.len(), 1);
1969        }
1970
1971        #[test]
1972        fn semantics_reports_button_role_and_label() {
1973            use frust::authoring::text::TextContext;
1974            use std::any::Any;
1975
1976            fn logic(_s: &mut Presses) -> SuggestionChipView<Presses> {
1977                suggestion_chip::<Presses, _>("Try this", |_s: &mut Presses| {})
1978            }
1979            let mut root: frust_core::RenderRoot<Presses, SuggestionChipView<Presses>> =
1980                frust_core::RenderRoot::new();
1981            let mut state = Presses::default();
1982            root.rebuild(&mut logic, &mut state);
1983            let mut tcx = TextContext::new();
1984            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1985            let update = root.semantics();
1986            let (_, node) = update
1987                .nodes
1988                .iter()
1989                .find(|(_, n)| n.role() == Role::Button)
1990                .expect("suggestion chip contributes a Role::Button node");
1991            assert_eq!(node.label(), Some("Try this"));
1992            assert!(node.supports_action(Action::Click));
1993        }
1994    }
1995
1996    // --- InputChip ---
1997
1998    mod input {
1999        use super::*;
2000
2001        #[derive(Default)]
2002        struct Log {
2003            presses: u32,
2004            deletes: u32,
2005        }
2006
2007        fn widget_no_delete() -> InputChipWidget {
2008            let view = input_chip::<Log, _>("Tag", |s: &mut Log| s.presses += 1);
2009            let mut counter = 0u64;
2010            View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
2011        }
2012
2013        fn widget_with_delete() -> InputChipWidget {
2014            let view = input_chip::<Log, _>("Tag", |s: &mut Log| s.presses += 1)
2015                .on_deleted(|s: &mut Log| s.deletes += 1);
2016            let mut counter = 0u64;
2017            View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
2018        }
2019
2020        /// Give the widget a known delete rect without a text-context
2021        /// layout — mirrors `split_button`'s `with_rects` test helper.
2022        fn with_delete_rect(w: &mut InputChipWidget) {
2023            w.delete_rect = Rect::new(70.0, 7.0, 88.0, 25.0);
2024        }
2025
2026        fn dispatch(w: &mut InputChipWidget, state: &mut Log, event: &InputEvent) {
2027            let state_any: &mut dyn Any = state;
2028            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
2029            w.event(&mut ctx, event);
2030        }
2031
2032        #[test]
2033        fn no_delete_configured_means_no_hit_region_and_body_press_fires() {
2034            let mut w = widget_no_delete();
2035            assert!(!w.has_delete);
2036            let mut state = Log::default();
2037            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2038            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
2039            assert_eq!(state.presses, 1);
2040            assert_eq!(state.deletes, 0);
2041        }
2042
2043        #[test]
2044        fn body_press_fires_on_press_only() {
2045            let mut w = widget_with_delete();
2046            with_delete_rect(&mut w);
2047            let mut state = Log::default();
2048            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2049            assert_eq!(w.armed, Some(InputRegion::Body));
2050            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
2051            assert_eq!(state.presses, 1);
2052            assert_eq!(state.deletes, 0, "a body press must not fire on_deleted");
2053        }
2054
2055        #[test]
2056        fn delete_press_fires_on_deleted_only() {
2057            let mut w = widget_with_delete();
2058            with_delete_rect(&mut w);
2059            let mut state = Log::default();
2060            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2061            assert_eq!(w.armed, Some(InputRegion::Delete));
2062            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 75.0, 12.0));
2063            assert_eq!(state.deletes, 1);
2064            assert_eq!(state.presses, 0, "a delete press must not fire on_press");
2065        }
2066
2067        #[test]
2068        fn delete_press_does_not_arm_the_state_layer() {
2069            let mut w = widget_with_delete();
2070            with_delete_rect(&mut w);
2071            let mut state = Log::default();
2072            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2073            assert!(
2074                !w.state_layer.is_active(),
2075                "the delete icon carries no state-layer feedback (see the module docs)"
2076            );
2077        }
2078
2079        #[test]
2080        fn body_press_does_arm_the_state_layer() {
2081            let mut w = widget_with_delete();
2082            with_delete_rect(&mut w);
2083            let mut state = Log::default();
2084            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2085            assert!(w.state_layer.is_active());
2086        }
2087
2088        #[test]
2089        fn release_outside_the_armed_region_fires_nothing() {
2090            let mut w = widget_with_delete();
2091            with_delete_rect(&mut w);
2092            let mut state = Log::default();
2093            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
2094            dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
2095            dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
2096            assert_eq!(state.presses, 0);
2097            assert_eq!(state.deletes, 0);
2098        }
2099
2100        #[test]
2101        fn cancel_disarms_without_firing() {
2102            let mut w = widget_with_delete();
2103            with_delete_rect(&mut w);
2104            let mut state = Log::default();
2105            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 75.0, 12.0));
2106            dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 75.0, 12.0));
2107            assert!(w.armed.is_none());
2108            assert_eq!(state.deletes, 0);
2109        }
2110
2111        #[test]
2112        fn down_outside_both_regions_is_ignored() {
2113            let mut w = widget_with_delete();
2114            with_delete_rect(&mut w);
2115            let mut state = Log::default();
2116            let state_any: &mut dyn Any = &mut state;
2117            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, CHIP_HEIGHT));
2118            let result = w.event(&mut ctx, &ev(PointerPhase::Down, 300.0, 12.0));
2119            assert!(matches!(result, EventResult::Ignored));
2120            assert!(w.armed.is_none());
2121        }
2122
2123        #[test]
2124        fn layout_sizes_the_delete_icon_at_18dp_after_the_label() {
2125            use frust::authoring::text::TextContext;
2126
2127            let view = input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2128            let mut counter = 0u64;
2129            let mut w = View::<Log>::build(&view, &mut BuildCtx::new(&mut counter));
2130            let mut tcx = TextContext::new();
2131            let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2132            let total = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2133
2134            assert_eq!(w.delete_rect.width(), CHIP_ICON_SIZE);
2135            assert_eq!(w.delete_rect.height(), CHIP_ICON_SIZE);
2136            // The delete icon's right edge, plus the end padding, is the
2137            // chip's total width.
2138            assert_eq!(w.delete_rect.x1 + CHIP_END_PAD, total.width);
2139            assert_eq!(total.height, CHIP_HEIGHT);
2140        }
2141
2142        #[test]
2143        fn layout_without_delete_has_a_zero_rect_and_no_trailing_icon_gap() {
2144            use frust::authoring::text::TextContext;
2145
2146            let with_delete =
2147                input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2148            let without_delete = input_chip::<Log, _>("Tag", |_s: &mut Log| {});
2149            let mut counter = 0u64;
2150            let mut w_with = View::<Log>::build(&with_delete, &mut BuildCtx::new(&mut counter));
2151            let mut w_without =
2152                View::<Log>::build(&without_delete, &mut BuildCtx::new(&mut counter));
2153            let mut tcx = TextContext::new();
2154            let mut lctx1 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2155            let size_with =
2156                w_with.layout(&mut lctx1, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2157            let mut lctx2 = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
2158            let size_without =
2159                w_without.layout(&mut lctx2, &BoxConstraints::loose(Size::new(400.0, 100.0)));
2160
2161            assert_eq!(w_without.delete_rect, Rect::ZERO);
2162            assert!(
2163                size_with.width > size_without.width,
2164                "a delete icon widens the chip"
2165            );
2166        }
2167
2168        #[test]
2169        fn paints_a_close_glyph_fill_at_the_delete_rect_origin() {
2170            let mut w = widget_with_delete();
2171            with_delete_rect(&mut w);
2172            let rec = paint_rec(&mut w, Size::new(100.0, CHIP_HEIGHT), None);
2173            let (origin, color) = rec
2174                .fills
2175                .last()
2176                .copied()
2177                .expect("the delete icon paints one filled path");
2178            assert_eq!(origin, Point::new(70.0, 7.0));
2179            assert_eq!(color, CHIP_LABEL_UNSELECTED);
2180        }
2181
2182        #[test]
2183        fn rebuild_dropping_on_deleted_disarms_a_pending_delete_press() {
2184            let mut counter = 0u64;
2185            let prev = input_chip::<Log, _>("Tag", |_s: &mut Log| {}).on_deleted(|_s: &mut Log| {});
2186            let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
2187            with_delete_rect(&mut w);
2188            w.armed = Some(InputRegion::Delete);
2189            w.pressed_inside = true;
2190
2191            let next = input_chip::<Log, _>("Tag", |_s: &mut Log| {});
2192            let flags =
2193                View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
2194            assert!(!w.has_delete);
2195            assert!(w.armed.is_none());
2196            assert!(!w.pressed_inside);
2197            assert!(flags.needs_layout());
2198        }
2199
2200        #[test]
2201        fn semantics_with_delete_reports_a_group_with_two_buttons() {
2202            fn logic(_s: &mut ()) -> InputChipView<()> {
2203                input_chip::<(), _>("Tag", |_s: &mut ()| {}).on_deleted(|_s: &mut ()| {})
2204            }
2205            let mut root: frust_core::RenderRoot<(), InputChipView<()>> =
2206                frust_core::RenderRoot::new();
2207            let mut state = ();
2208            root.rebuild(&mut logic, &mut state);
2209            let mut tcx = frust::authoring::text::TextContext::new();
2210            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
2211            let update = root.semantics();
2212
2213            let group = update
2214                .nodes
2215                .iter()
2216                .find(|(_, n)| n.role() == Role::Group)
2217                .expect("a Group container node is contributed");
2218            assert_eq!(group.1.children().len(), 2);
2219
2220            let label_node = update
2221                .nodes
2222                .iter()
2223                .find(|(_, n)| n.label() == Some("Tag"))
2224                .expect("the label button node is present");
2225            assert!(label_node.1.supports_action(Action::Click));
2226
2227            let delete_node = update
2228                .nodes
2229                .iter()
2230                .find(|(_, n)| n.label() == Some("Remove"))
2231                .expect("the delete button node is present");
2232            assert!(delete_node.1.supports_action(Action::Click));
2233        }
2234
2235        #[test]
2236        fn semantics_without_delete_reports_a_single_button() {
2237            fn logic(_s: &mut ()) -> InputChipView<()> {
2238                input_chip::<(), _>("Tag", |_s: &mut ()| {})
2239            }
2240            let mut root: frust_core::RenderRoot<(), InputChipView<()>> =
2241                frust_core::RenderRoot::new();
2242            let mut state = ();
2243            root.rebuild(&mut logic, &mut state);
2244            let mut tcx = frust::authoring::text::TextContext::new();
2245            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
2246            let update = root.semantics();
2247            assert!(
2248                update.nodes.iter().all(|(_, n)| n.role() != Role::Group),
2249                "no delete affordance means no Group container"
2250            );
2251            let (_, node) = update
2252                .nodes
2253                .iter()
2254                .find(|(_, n)| n.role() == Role::Button)
2255                .expect("input chip without delete contributes a single Role::Button node");
2256            assert_eq!(node.label(), Some("Tag"));
2257        }
2258    }
2259}