Skip to main content

frus_widgets/
chip.rs

1//! [`Chip`]: a compact element for an attribute, a filter or an entry — pressable,
2//! selectable, and removable.
3//!
4//! The reference has four of these (assist, filter, input, suggestion), and what separates
5//! them is not shape but **affordance**: whether the chip can be selected, whether it
6//! carries a leading icon, whether it can be deleted. They share one set of measurements —
7//! 32 px high, an 8 px radius, a `label_large` label, an outline that gives way to a filled
8//! surface once selected — so there is one `Chip` here with those affordances as builders:
9//!
10//! ```ignore
11//! Chip::new("Draft")                                   // an attribute
12//! Chip::new("Unread").selected(on).on_press(Msg::Toggle)   // a filter
13//! Chip::new(name).leading(Icons::STAR).on_remove(Msg::Drop(id))  // an entry
14//! ```
15//!
16//! Every measurement and colour is overridable, per call or through
17//! [`ChipTheme`](crate::ChipTheme).
18
19use frus_core::{BorderRadius, Color, Insets, Point, Rect, Scene, ShapeBorder, TextStyle};
20use frus_layout::{Align, Dimension, FlexDirection, Style};
21
22use crate::disabled::{disabled_container, disabled_content};
23use crate::icons::{IconData, Icons};
24use crate::interaction::Status;
25use crate::theme::Theme;
26use crate::widget::Widget;
27
28/// A chip's height.
29pub const CHIP_HEIGHT: f32 = 32.0;
30/// Its corner radius — a rounded rectangle, not a stadium.
31pub const CHIP_RADIUS: f32 = 8.0;
32/// The room inside the outline, on every side.
33pub const CHIP_PADDING: f32 = 8.0;
34/// The room on either side of the label, inside that.
35pub const CHIP_LABEL_PADDING: f32 = 8.0;
36/// A leading icon, a checkmark or a delete cross.
37pub const CHIP_ICON_SIZE: f32 = 18.0;
38/// The outline's thickness.
39pub const CHIP_BORDER_WIDTH: f32 = 1.0;
40
41/// Everything about a chip's appearance, each part `None` until someone says otherwise.
42#[derive(Clone, Copy, Debug, Default, PartialEq)]
43pub(crate) struct ChipStyle {
44    pub color: Option<Color>,
45    pub selected_color: Option<Color>,
46    pub label_color: Option<Color>,
47    pub selected_label_color: Option<Color>,
48    pub label_style: Option<TextStyle>,
49    pub border_color: Option<Color>,
50    pub border_width: Option<f32>,
51    /// What shape it is, over the theme's. A radius named through
52    /// [`Chip::radius`](crate::Chip::radius) arrives here as a rounded rectangle.
53    pub shape: Option<ShapeBorder>,
54    pub padding: Option<f32>,
55    pub label_padding: Option<f32>,
56    pub height: Option<f32>,
57    pub icon_size: Option<f32>,
58    pub show_checkmark: Option<bool>,
59}
60
61impl ChipStyle {
62    /// The surface under the label. A chip at rest has **none**: the outline is what marks
63    /// it out, and a fill would make an attribute look like a button.
64    fn background(&self, theme: &Theme, selected: bool) -> Color {
65        if selected {
66            self.selected_color
67                .or(theme.widgets.chip.selected_color)
68                .unwrap_or(theme.scheme.secondary_container)
69        } else {
70            self.color
71                .or(theme.widgets.chip.color)
72                .unwrap_or(Color::TRANSPARENT)
73        }
74    }
75
76    fn label_color(&self, theme: &Theme, selected: bool) -> Color {
77        if selected {
78            self.selected_label_color
79                .or(theme.widgets.chip.selected_label_color)
80                .unwrap_or(theme.scheme.on_secondary_container)
81        } else {
82            self.label_color
83                .or(theme.widgets.chip.label_color)
84                .unwrap_or(theme.scheme.on_surface_variant)
85        }
86    }
87
88    fn label_style(&self, theme: &Theme) -> TextStyle {
89        self.label_style
90            .or(theme.widgets.chip.label_style)
91            .unwrap_or(theme.text.label_large)
92    }
93
94    /// The outline. A **selected** chip has none by default — it is filled, and an outline
95    /// around a filled surface reads as a second border.
96    fn border(&self, theme: &Theme, selected: bool) -> (f32, Color) {
97        let width = self
98            .border_width
99            .or(theme.widgets.chip.border_width)
100            .unwrap_or(if selected { 0.0 } else { CHIP_BORDER_WIDTH });
101        let color = self
102            .border_color
103            .or(theme.widgets.chip.border_color)
104            .unwrap_or(theme.scheme.outline_variant);
105        (width, color)
106    }
107
108    /// **What shape this chip is**: its own word, then the theme's shape, then the
109    /// theme's plain radius, then the reference's 8.
110    fn shape(&self, theme: &Theme) -> ShapeBorder {
111        crate::resolve_shape(
112            self.shape,
113            theme.widgets.chip.shape,
114            theme.widgets.chip.radius,
115            ShapeBorder::rounded(CHIP_RADIUS),
116        )
117    }
118
119    /// The corners that shape resolves to in a box of this chip's height — what the ink
120    /// is clipped to, and what a shape with no rounded form has none of.
121    fn radius(&self, theme: &Theme) -> BorderRadius {
122        let side = self.height(theme);
123        self.shape(theme)
124            .as_rounded(Rect::new(0.0, 0.0, side, side))
125            .map(|(_, radius)| radius)
126            .unwrap_or(BorderRadius::ZERO)
127    }
128
129    fn padding(&self, theme: &Theme) -> f32 {
130        self.padding
131            .or(theme.widgets.chip.padding)
132            .unwrap_or(CHIP_PADDING)
133    }
134
135    fn label_padding(&self, theme: &Theme) -> f32 {
136        self.label_padding
137            .or(theme.widgets.chip.label_padding)
138            .unwrap_or(CHIP_LABEL_PADDING)
139    }
140
141    fn height(&self, theme: &Theme) -> f32 {
142        self.height
143            .or(theme.widgets.chip.height)
144            .unwrap_or(CHIP_HEIGHT)
145    }
146
147    fn icon_size(&self, theme: &Theme) -> f32 {
148        self.icon_size
149            .or(theme.widgets.chip.icon_size)
150            .unwrap_or(CHIP_ICON_SIZE)
151    }
152
153    fn show_checkmark(&self, theme: &Theme) -> bool {
154        self.show_checkmark
155            .or(theme.widgets.chip.show_checkmark)
156            .unwrap_or(true)
157    }
158}
159
160/// A chip's delete cross: its own hit target, so that removing a chip and pressing it are
161/// two different gestures.
162struct Remove<Msg> {
163    message: Msg,
164    /// The chip's style and state, so that the cross resolves its size and colour from the
165    /// same theme the chip does — a cross on a selected chip has to read against the fill.
166    style: ChipStyle,
167    selected: bool,
168    /// The chip's availability. A cross that still answered on a disabled chip would be
169    /// the one live control on an inert thing.
170    enabled: bool,
171}
172
173impl<Msg: Clone> Widget<Msg> for Remove<Msg> {
174    fn style(&self) -> Style {
175        Widget::<Msg>::style_themed(self, &Theme::default())
176    }
177
178    fn style_themed(&self, theme: &Theme) -> Style {
179        let size = self.style.icon_size(theme);
180        Style {
181            width: Dimension::Length(size),
182            height: Dimension::Length(size),
183            ..Default::default()
184        }
185    }
186
187    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
188        &[]
189    }
190
191    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
192        // Brighter under the pointer: the cross is the one part of a chip that does
193        // something different from the chip, and hovering has to say so.
194        let size = self.style.icon_size(theme);
195        let color = if self.enabled {
196            self.style
197                .label_color(theme, self.selected)
198                .lerp(theme.scheme.on_surface, status.hover_progress)
199        } else {
200            disabled_content(theme)
201        }
202        .fade(status.opacity);
203        let path = Icons::CLOSE.placed(
204            size,
205            bounds.x,
206            bounds.y + (bounds.height - size) * 0.5,
207            theme.direction,
208        );
209        scene.fill_path(&path, color);
210    }
211
212    fn on_click(&self) -> Option<Msg> {
213        if !self.enabled {
214            return None;
215        }
216        Some(self.message.clone())
217    }
218
219    fn focusable(&self) -> bool {
220        self.enabled
221    }
222
223    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
224        let semantics =
225            frus_core::SemanticsProperties::new(frus_core::Role::Button).label("Remove");
226        // Milestone 320 stopped this cross answering a tap and stopped there. Tab still
227        // landed on it and a reader was still told it could be pressed — the same control
228        // reported three different ways, and the two that were wrong are the two nobody
229        // looks at in a screenshot. Milestone 322's guard is what found it.
230        Some(if self.enabled {
231            semantics.clickable()
232        } else {
233            semantics.disabled(true)
234        })
235    }
236}
237
238/// A compact label — an attribute, a filter or an entry.
239pub struct Chip<Msg = crate::callback::Callback> {
240    label: String,
241    selected: bool,
242    leading: Option<IconData>,
243    on_press: Option<Msg>,
244    on_remove: Option<Msg>,
245    /// A chip that is shown but cannot be acted on: greyed out and inert, its label still
246    /// legible. The reference dims a disabled control rather than hiding it — it is often
247    /// the answer to why the rest of a row looks the way it does.
248    enabled: bool,
249    style: ChipStyle,
250    /// The delete cross, if there is one — the chip's only child. The label and the
251    /// leading icon are painted by the chip, so that their colour can follow its state.
252    children: Vec<Box<dyn Widget<Msg>>>,
253}
254
255impl<Msg: Clone + 'static> Chip<Msg> {
256    /// Creates a chip with the given label.
257    pub fn new(label: impl Into<String>) -> Self {
258        Self {
259            label: label.into(),
260            selected: false,
261            leading: None,
262            on_press: None,
263            on_remove: None,
264            enabled: true,
265            style: ChipStyle::default(),
266            children: Vec::new(),
267        }
268    }
269
270    /// (Re)builds the delete cross, which carries a copy of the chip's style and state.
271    ///
272    /// Every builder calls this, because a builder run after `on_remove(…)` would
273    /// otherwise be written into a cross that had already been built — the order of a
274    /// builder chain is not something a caller should have to think about.
275    fn rebuild(mut self) -> Self {
276        self.children.clear();
277        if let Some(message) = self.on_remove.clone() {
278            self.children.push(Box::new(Remove {
279                message,
280                style: self.style,
281                selected: self.selected,
282                enabled: self.enabled,
283            }));
284        }
285        self
286    }
287
288    /// Marks the chip **selected**: it fills, its label takes the colour that goes with
289    /// the fill, and a checkmark appears in the leading slot unless there is an icon there
290    /// already (or [`Chip::show_checkmark`] says otherwise).
291    pub fn selected(mut self, selected: bool) -> Self {
292        self.selected = selected;
293        self.rebuild()
294    }
295
296    /// A leading icon — the avatar or glyph an entry chip carries.
297    pub fn leading(mut self, icon: IconData) -> Self {
298        self.leading = Some(icon);
299        self.rebuild()
300    }
301
302    /// Greys the chip out and makes it inert: no press, no ink, out of the tab order,
303    /// and announced to a reader as disabled rather than simply going quiet. Its label
304    /// stays legible — the reference dims a disabled control rather than hiding it.
305    pub fn enabled(mut self, enabled: bool) -> Self {
306        self.enabled = enabled;
307        self.rebuild()
308    }
309
310    /// The message the chip emits when pressed. A chip with none is inert, which is what
311    /// an attribute chip is.
312    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
313        self.on_press = Some(message.into());
314        self
315    }
316
317    /// Adds a delete cross that emits `message`.
318    pub fn on_remove(mut self, message: impl Into<Msg>) -> Self {
319        self.on_remove = Some(message.into());
320        self.rebuild()
321    }
322
323    /// Whether a selected chip shows a checkmark. On by default.
324    pub fn show_checkmark(mut self, show: bool) -> Self {
325        self.style.show_checkmark = Some(show);
326        self.rebuild()
327    }
328
329    /// The surface under an **unselected** chip. Transparent by default.
330    pub fn color(mut self, color: Color) -> Self {
331        self.style.color = Some(color);
332        self.rebuild()
333    }
334
335    /// The surface under a **selected** one.
336    pub fn selected_color(mut self, color: Color) -> Self {
337        self.style.selected_color = Some(color);
338        self.rebuild()
339    }
340
341    /// The label's colour when unselected.
342    pub fn label_color(mut self, color: Color) -> Self {
343        self.style.label_color = Some(color);
344        self.rebuild()
345    }
346
347    /// The label's colour when selected.
348    pub fn selected_label_color(mut self, color: Color) -> Self {
349        self.style.selected_label_color = Some(color);
350        self.rebuild()
351    }
352
353    /// The label's type. Defaults to the theme's `label_large` step.
354    pub fn label_style(mut self, style: TextStyle) -> Self {
355        self.style.label_style = Some(style);
356        self.rebuild()
357    }
358
359    /// The outline's colour.
360    pub fn border_color(mut self, color: Color) -> Self {
361        self.style.border_color = Some(color);
362        self.rebuild()
363    }
364
365    /// The outline's thickness; `0.0` removes it.
366    pub fn border_width(mut self, width: f32) -> Self {
367        self.style.border_width = Some(width);
368        self.rebuild()
369    }
370
371    /// The corner radii (uniform via `f32`, per corner via [`BorderRadius`]) — the
372    /// shorthand for a rounded rectangle. See [`Chip::shape`].
373    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
374        self.style.shape = Some(ShapeBorder::rounded(radius));
375        self.rebuild()
376    }
377
378    /// **What shape it is** (`chip.dart`), over the corners named above. The last of the
379    /// two to be called is the one that counts: they are one property.
380    pub fn shape(mut self, shape: ShapeBorder) -> Self {
381        self.style.shape = Some(shape);
382        self.rebuild()
383    }
384
385    /// The room inside the outline.
386    pub fn padding(mut self, padding: f32) -> Self {
387        self.style.padding = Some(padding);
388        self.rebuild()
389    }
390
391    /// The room on either side of the label.
392    pub fn label_padding(mut self, padding: f32) -> Self {
393        self.style.label_padding = Some(padding);
394        self.rebuild()
395    }
396
397    /// The chip's height.
398    pub fn height(mut self, height: f32) -> Self {
399        self.style.height = Some(height);
400        self.rebuild()
401    }
402
403    /// The size of the leading icon, the checkmark and the delete cross.
404    pub fn icon_size(mut self, size: f32) -> Self {
405        self.style.icon_size = Some(size);
406        self.rebuild()
407    }
408
409    /// Whether anything is drawn in the leading slot, and what: an icon if there is one,
410    /// otherwise the checkmark of a selected chip.
411    fn leading_glyph(&self, theme: &Theme) -> Option<IconData> {
412        self.leading
413            .or_else(|| (self.selected && self.style.show_checkmark(theme)).then_some(Icons::CHECK))
414    }
415
416    /// Where the label starts, measured from the chip's left edge.
417    fn label_offset(&self, theme: &Theme) -> f32 {
418        let leading = self
419            .leading_glyph(theme)
420            .map(|_| self.style.icon_size(theme))
421            .unwrap_or(0.0);
422        self.style.padding(theme) + leading + self.style.label_padding(theme)
423    }
424
425    fn label_size(&self, theme: &Theme) -> frus_core::Size {
426        frus_text::measure_resolved(&self.label, &self.style.label_style(theme).resolved())
427    }
428
429    /// The chip's height: the one it was given, **or what its label needs** when the reader
430    /// asked for type that does not fit inside it.
431    ///
432    /// The reference's chip does the same arithmetic —
433    /// `max(chipHeight - padding.vertical, labelHeight) + padding.vertical` — because a
434    /// component's default height is a **floor** and not a ceiling. Ours was a ceiling, so
435    /// at a text scale of 2 a chip's glyphs needed 34 px inside a 32 px box and were cut.
436    ///
437    /// The room asked for around the line is `0`, not the chip's padding: that padding is
438    /// applied on the horizontal axis only (the label is painted, not laid out, and is
439    /// centred in the box), so counting it here would move every chip that fits perfectly
440    /// well today.
441    fn box_height(&self, theme: &Theme) -> f32 {
442        frus_text::line_box(
443            self.style.height(theme),
444            &self.style.label_style(theme).resolved(),
445            0.0,
446        )
447    }
448}
449
450impl<Msg: Clone + 'static> Widget<Msg> for Chip<Msg> {
451    fn style(&self) -> Style {
452        Widget::<Msg>::style_themed(self, &Theme::default())
453    }
454
455    fn style_themed(&self, theme: &Theme) -> Style {
456        // The chip is sized here rather than by its content, because its content is
457        // mostly painted rather than laid out: the label and the leading glyph belong to
458        // the chip, so that their colour can follow its state. What is left is the delete
459        // cross, and the padding is what puts it after the label.
460        let pad = self.style.padding(theme);
461        let label_pad = self.style.label_padding(theme);
462        let icon = self.style.icon_size(theme);
463        let after_label = self.label_offset(theme) + self.label_size(theme).width + label_pad;
464        let removable = !self.children.is_empty();
465        Style {
466            width: Dimension::Length(after_label + if removable { icon } else { 0.0 } + pad),
467            height: Dimension::Length(self.box_height(theme)),
468            flex_direction: FlexDirection::Row,
469            align: Align::Center,
470            padding: Insets::new(0.0, pad, 0.0, after_label),
471            ..Default::default()
472        }
473    }
474
475    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
476        &self.children
477    }
478
479    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
480        let o = status.opacity;
481        let selected = self.selected;
482        let (border_width, border_color) = self.style.border(theme, selected);
483        // Disabled flattens the same way `Button` does, and for the same reason: the
484        // reference collapses every variant to `on_surface` at 12 % under a label at 38 %,
485        // so unavailable reads as unavailable rather than as a quieter kind of chip.
486        // Selected or not stops mattering, which is the point — a disabled filter is not
487        // offering to tell you whether it is on.
488        let (background, border_color, label_color) = if self.enabled {
489            (
490                self.style.background(theme, selected),
491                border_color,
492                self.style.label_color(theme, selected),
493            )
494        } else {
495            (
496                if selected {
497                    disabled_container(theme)
498                } else {
499                    Color::TRANSPARENT
500                },
501                disabled_container(theme),
502                disabled_content(theme),
503            )
504        };
505
506        scene.draw_shape(
507            bounds,
508            self.style
509                .shape(theme)
510                .with_side(frus_core::BorderSide::new(
511                    if border_width > 0.0 {
512                        border_color.fade(o)
513                    } else {
514                        Color::TRANSPARENT
515                    },
516                    border_width,
517                )),
518            background.fade(o),
519        );
520
521        // The leading slot: an icon if the chip has one, otherwise a selected chip's
522        // checkmark. An icon takes the accent when the chip is at rest, and the colour of
523        // the fill once it is selected — the reference's icon theme, which is a different
524        // colour from the label's only in the first case.
525        let icon_size = self.style.icon_size(theme);
526        if let Some(name) = self.leading_glyph(theme) {
527            let color = if selected {
528                label_color
529            } else {
530                theme.scheme.primary
531            };
532            let path = name.placed(
533                icon_size,
534                bounds.x + self.style.padding(theme),
535                bounds.y + (bounds.height - icon_size) * 0.5,
536                theme.direction,
537            );
538            scene.fill_path(&path, color.fade(o));
539        }
540
541        let label_style = self.style.label_style(theme);
542        let measured = self.label_size(theme);
543        scene.text(
544            Point::new(
545                bounds.x + self.label_offset(theme),
546                bounds.y + (bounds.height - measured.height) * 0.5,
547            ),
548            self.label.clone(),
549            &label_style.resolved(),
550            label_color.fade(o),
551        );
552    }
553
554    fn on_click(&self) -> Option<Msg> {
555        self.enabled.then(|| self.on_press.clone()).flatten()
556    }
557
558    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
559        // Only where there is something to press: ink on an inert attribute chip — or on
560        // a disabled one — would promise an action it does not have.
561        if !self.enabled {
562            return None;
563        }
564        self.on_press.as_ref()?;
565        Some(crate::InkStyle::of(theme).radius(self.style.radius(theme)))
566    }
567
568    fn focusable(&self) -> bool {
569        self.enabled && self.on_press.is_some()
570    }
571
572    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
573        let semantics =
574            frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
575        // A chip that can be selected says whether it is: a filter that is on and one that
576        // is off are the same words otherwise.
577        let semantics = if self.on_press.is_some() && self.enabled {
578            semantics.toggled(self.selected).clickable()
579        } else {
580            semantics
581        };
582        // Still announced, and announced as unavailable: a reader that simply stopped
583        // hearing about a chip would be told the filter had gone away.
584        let semantics = if self.enabled {
585            semantics
586        } else {
587            semantics.disabled(true)
588        };
589        Some(semantics)
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::{build_ui, Point as P, Runtime, Size};
597    use frus_core::Primitive;
598
599    #[derive(Clone, Debug, PartialEq)]
600    enum Msg {
601        Press,
602        Remove,
603    }
604
605    fn frame(chip: Chip<Msg>) -> crate::Ui<Msg> {
606        let root = crate::flex::Flex::column()
607            .width(200.0)
608            .height(60.0)
609            .child(chip);
610        build_ui(
611            &root,
612            Size::new(200.0, 60.0),
613            &Runtime::default(),
614            &Theme::default(),
615        )
616    }
617
618    fn primitives(chip: Chip<Msg>) -> Vec<Primitive> {
619        frame(chip).scene().primitives().to_vec()
620    }
621
622    /// The chip's own box: the first rectangle painted.
623    fn surface(chip: Chip<Msg>) -> (Rect, Color, BorderRadius, f32) {
624        primitives(chip)
625            .iter()
626            .find_map(|p| match p {
627                Primitive::Rect {
628                    rect,
629                    color,
630                    radius,
631                    border_width,
632                    ..
633                } => Some((*rect, *color, *radius, *border_width)),
634                _ => None,
635            })
636            .expect("a chip paints its box")
637    }
638
639    /// The same gap, in the widget that recorded it first (milestone 312). A disabled
640    /// chip is greyed out **and** inert: no press, no ink, out of the tab order, its
641    /// delete cross dead too — a live cross on an inert chip would be the one thing on it
642    /// that still answered.
643    #[test]
644    fn a_disabled_chip_is_inert_including_its_cross() {
645        let live = Chip::new("Filter")
646            .on_press(Msg::Press)
647            .on_remove(Msg::Remove);
648        let dead = Chip::new("Filter")
649            .on_press(Msg::Press)
650            .on_remove(Msg::Remove)
651            .enabled(false);
652        assert_eq!(Widget::on_click(&live), Some(Msg::Press));
653        assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
654        assert!(Widget::<Msg>::focusable(&live));
655        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
656        assert!(Widget::<Msg>::ink(&live, &Theme::default()).is_some());
657        assert!(
658            Widget::<Msg>::ink(&dead, &Theme::default()).is_none(),
659            "nothing to splash for"
660        );
661        // The cross is a child, and it has to follow the chip it sits on.
662        let cross = |c: &Chip<Msg>| Widget::<Msg>::children(c)[0].on_click();
663        assert_eq!(cross(&live), Some(Msg::Remove));
664        assert_eq!(cross(&dead), None, "the cross follows the chip");
665        // Announced, and announced as unavailable: a reader that stopped hearing about a
666        // chip would be told the filter had gone away.
667        let semantics = Widget::<Msg>::semantics(&dead).unwrap();
668        assert!(semantics.disabled);
669        assert_eq!(semantics.label.as_deref(), Some("Filter"));
670    }
671
672    /// Disabled must never draw the eye more than live. The measure is **contrast
673    /// against the surface**, not brightness: on a light theme a quieter colour is
674    /// closer to white, on a dark one closer to black, and a test that compared raw
675    /// luminance would pass on one and fail on the other for no real reason.
676    ///
677    /// It is not obvious from the rule that this holds: a live unselected chip's label
678    /// is `on_surface_variant` and a disabled one's is `on_surface` at 38 % — two
679    /// different tokens, and which reads louder depends on the palette.
680    #[test]
681    fn disabled_is_never_louder_than_live() {
682        for (name, theme) in [("dark", Theme::dark()), ("light", Theme::light())] {
683            let bg = theme.scheme.surface;
684            let lum = |r: f32, g: f32, b: f32| 0.2126 * r + 0.7152 * g + 0.0722 * b;
685            let ground = lum(bg.r, bg.g, bg.b);
686            // How far the colour ends up from the surface behind it, alpha included.
687            let contrast = |c: frus_core::Color| {
688                let over = |f: f32, b: f32| f * c.a + b * (1.0 - c.a);
689                (lum(over(c.r, bg.r), over(c.g, bg.g), over(c.b, bg.b)) - ground).abs()
690            };
691            let live = ChipStyle::default().label_color(&theme, false);
692            let dead = theme.scheme.on_surface.fade(0.38);
693            assert!(
694                contrast(dead) < contrast(live),
695                "{name}: the disabled label must be quieter, got {} against {}",
696                contrast(dead),
697                contrast(live)
698            );
699            // The **outline** now clears the same bar. Milestone 320 left this assertion
700            // out rather than weaken it: the rule was the reference's exactly, but both
701            // shipped palettes put `outline_variant` on top of `on_surface` at 12 %, so a
702            // disabled hairline carried marginally *more* contrast than a live one. J325
703            // moved the two outline roles to their reference tones, which is what lets the
704            // claim be asserted instead of written up.
705            let live_edge = ChipStyle::default().border(&theme, false).1;
706            let dead_edge = theme.scheme.on_surface.fade(0.12);
707            assert!(
708                contrast(dead_edge) < contrast(live_edge),
709                "{name}: the disabled outline must be quieter, got {} against {}",
710                contrast(dead_edge),
711                contrast(live_edge)
712            );
713        }
714    }
715
716    /// Disabled **flattens** rather than fades, following `Button`: the reference
717    /// collapses every state to one grey, so unavailable does not read as a quieter kind
718    /// of selected.
719    #[test]
720    fn a_disabled_chip_flattens_rather_than_fades() {
721        let theme = Theme::default();
722        let accent = ChipStyle::default().background(&theme, true);
723        let fills = |chip: Chip<Msg>| -> Vec<frus_core::Color> {
724            frame(chip)
725                .scene()
726                .primitives()
727                .iter()
728                .filter_map(|p| match p {
729                    Primitive::Rect { color, .. } if color.a > 0.0 => Some(*color),
730                    _ => None,
731                })
732                .collect()
733        };
734        let selected = || Chip::new("On").on_press(Msg::Press).selected(true);
735        assert!(
736            fills(selected()).contains(&accent),
737            "a live selected chip fills with the accent"
738        );
739        assert!(
740            !fills(selected().enabled(false)).contains(&accent),
741            "a disabled one never does"
742        );
743    }
744
745    #[test]
746    fn a_chip_is_the_references_size_and_shape() {
747        // Not a stadium: the reference's chip is a 32 px rounded rectangle with an 8 px
748        // radius, and a pill is a different component.
749        let (rect, _, radius, _) = surface(Chip::new("Draft"));
750        assert_eq!(rect.height, CHIP_HEIGHT);
751        assert_eq!(radius, BorderRadius::uniform(CHIP_RADIUS));
752        assert!(
753            radius.top_left < rect.height / 2.0,
754            "a rounded rectangle, not a pill"
755        );
756    }
757
758    #[test]
759    fn at_rest_it_is_an_outline_and_nothing_else() {
760        let theme = Theme::default();
761        let (_, color, _, border) = surface(Chip::new("Draft"));
762        assert_eq!(
763            color,
764            Color::TRANSPARENT,
765            "no fill under an unselected chip"
766        );
767        assert_eq!(border, CHIP_BORDER_WIDTH);
768        // Selected: it fills, and the outline goes.
769        let (_, color, _, border) = surface(Chip::new("Draft").selected(true));
770        assert_eq!(color, theme.scheme.secondary_container);
771        assert_eq!(border, 0.0, "an outline around a fill reads as two borders");
772    }
773
774    #[test]
775    fn a_selected_chip_shows_a_checkmark_and_the_label_makes_room_for_it() {
776        let count_paths = |chip: Chip<Msg>| {
777            primitives(chip)
778                .iter()
779                .filter(|p| matches!(p, Primitive::Path { .. }))
780                .count()
781        };
782        assert_eq!(count_paths(Chip::new("Unread")), 0);
783        assert_eq!(count_paths(Chip::new("Unread").selected(true)), 1);
784        assert_eq!(
785            count_paths(Chip::new("Unread").selected(true).show_checkmark(false)),
786            0,
787            "and it can be refused"
788        );
789        // The label moves aside for it rather than sitting under it.
790        let text_x = |chip: Chip<Msg>| {
791            primitives(chip)
792                .iter()
793                .find_map(|p| match p {
794                    Primitive::Text { position, .. } => Some(position.x),
795                    _ => None,
796                })
797                .expect("a label")
798        };
799        assert_eq!(
800            text_x(Chip::new("Unread").selected(true)) - text_x(Chip::new("Unread")),
801            CHIP_ICON_SIZE
802        );
803    }
804
805    #[test]
806    fn a_leading_icon_takes_the_slot_the_checkmark_would_have() {
807        // Both at once would be two glyphs in a 32 px chip; the icon wins, as it does in
808        // the reference for an input chip with an avatar.
809        let paths = |chip: Chip<Msg>| {
810            primitives(chip)
811                .iter()
812                .filter(|p| matches!(p, Primitive::Path { .. }))
813                .count()
814        };
815        assert_eq!(paths(Chip::new("Ada").leading(Icons::STAR)), 1);
816        assert_eq!(
817            paths(Chip::new("Ada").leading(Icons::STAR).selected(true)),
818            1,
819            "the icon, not the icon and a checkmark"
820        );
821    }
822
823    #[test]
824    fn the_cross_is_a_target_of_its_own() {
825        // Pressing a chip and removing it are two gestures, so the cross has to be its own
826        // hit target rather than a region of the chip's.
827        let chip = || Chip::new("Ada").on_press(Msg::Press).on_remove(Msg::Remove);
828        let ui = frame(chip());
829        let at = |x: f32| ui.hit(P::new(x, 16.0)).and_then(|id| ui.msg_for(id));
830        let width = surface(chip()).0.width;
831        assert_eq!(at(20.0), Some(Msg::Press), "the chip presses");
832        assert_eq!(at(width - 14.0), Some(Msg::Remove), "the cross removes");
833    }
834
835    #[test]
836    fn the_cross_is_visible_before_anyone_hovers_it() {
837        // It was built with a transparent colour and only appeared once the pointer
838        // reached it, which is a delete affordance nobody can find. It resolves its colour
839        // against the theme now — and against the chip's **state**, set after it or not.
840        let theme = Theme::default();
841        let cross = |chip: Chip<Msg>| {
842            primitives(chip)
843                .iter()
844                .rev()
845                .find_map(|p| match p {
846                    Primitive::Path { fill, .. } => *fill,
847                    _ => None,
848                })
849                .expect("the cross is painted")
850        };
851        assert_eq!(
852            cross(Chip::new("Ada").on_remove(Msg::Remove)),
853            theme.scheme.on_surface_variant
854        );
855        assert_eq!(
856            cross(Chip::new("Ada").on_remove(Msg::Remove).selected(true)),
857            theme.scheme.on_secondary_container,
858            "a builder after `on_remove` still reaches the cross"
859        );
860    }
861
862    #[test]
863    fn an_inert_chip_neither_splashes_nor_takes_focus() {
864        // An attribute chip is a label. Ink on it would promise an action it has not got.
865        let plain = Chip::<Msg>::new("Draft");
866        assert!(Widget::<Msg>::ink(&plain, &Theme::default()).is_none());
867        assert!(!Widget::<Msg>::focusable(&plain));
868        let pressable = Chip::new("Draft").on_press(Msg::Press);
869        assert!(Widget::<Msg>::ink(&pressable, &Theme::default()).is_some());
870        assert!(Widget::<Msg>::focusable(&pressable));
871    }
872
873    #[test]
874    fn a_filter_chip_announces_whether_it_is_on() {
875        let on = Chip::new("Unread").selected(true).on_press(Msg::Press);
876        let semantics = Widget::<Msg>::semantics(&on).expect("semantics");
877        assert_eq!(semantics.label.as_deref(), Some("Unread"));
878        assert_eq!(semantics.toggled, frus_core::Toggled::True);
879        let off = Chip::new("Unread").on_press(Msg::Press);
880        assert_eq!(
881            Widget::<Msg>::semantics(&off).unwrap().toggled,
882            frus_core::Toggled::False
883        );
884    }
885
886    #[test]
887    fn every_measurement_is_the_callers_and_then_the_themes() {
888        let mut theme = Theme::default();
889        theme.widgets.chip.height = Some(40.0);
890        let height =
891            |chip: Chip<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(&chip, theme).height
892            {
893                Dimension::Length(h) => h,
894                other => panic!("{other:?}"),
895            };
896        assert_eq!(
897            height(Chip::new("x"), &Theme::default()),
898            CHIP_HEIGHT,
899            "the framework's"
900        );
901        assert_eq!(height(Chip::new("x"), &theme), 40.0, "the theme's");
902        assert_eq!(
903            height(Chip::new("x").height(24.0), &theme),
904            24.0,
905            "the caller's, over the theme's"
906        );
907    }
908}