Skip to main content

gpui_kit_material/components/
chips.rs

1//! M3 chips: assist, filter, input and suggestion, each flat (outlined) or
2//! elevated, in every interaction state.
3//!
4//! All chips are 32dp tall with 8dp corners, a label-large label and 18dp
5//! icons 8dp from the label. Hover, focus and press are state layers of the
6//! chip's content color over its container (focus also darkens a flat
7//! chip's outline); a dragged chip lifts to level 4 under the drag layer.
8//! A filter chip's check slides in from its leading edge on the fast
9//! spatial spring as it is selected, and out as it is cleared.
10use super::InteractionPreview;
11use super::focus::{RingPlacement, RingedButton};
12use crate::color::ColorRole::*;
13use crate::icons::{Icon, IconName};
14use crate::motion;
15use crate::theme::color;
16use crate::tokens::{elevation, shape::RADIUS_SMALL, state, typography::*};
17use gpui::{prelude::*, *};
18use std::rc::Rc;
19
20/// M3 chip geometry, in dp: 32 tall, 18dp icons 8 from the label, a 24dp
21/// avatar.
22pub const CHIP_HEIGHT: Rems = rem(32.);
23const CHIP_ICON: f32 = 18.;
24const CHIP_GAP: f32 = 8.;
25const AVATAR: f32 = 24.;
26/// Edge padding beside a label, and beside an icon or avatar.
27const ASSIST_TEXT_EDGE: f32 = 16.;
28const INPUT_TEXT_EDGE: f32 = 12.;
29const ICON_EDGE: f32 = 8.;
30const AVATAR_EDGE: f32 = 4.;
31/// Elevated chips rest at level 1; a dragged chip lifts to level 4.
32const ELEVATED_LEVEL: u8 = 1;
33const HOVERED_LEVEL: u8 = 2;
34const DRAGGED_LEVEL: u8 = 4;
35
36/// The container of an M3 assist chip: its geometry, small corners and
37/// outline-variant edge. Callers give it their label color and size; prefer
38/// [`AssistChip`], which adds the label role, state layers and focus.
39pub fn chip(leading_icon: bool) -> Div {
40    gpui_base::h_flex()
41        .flex_shrink_0()
42        .h(CHIP_HEIGHT)
43        .pl(rem(if leading_icon {
44            ICON_EDGE
45        } else {
46            ASSIST_TEXT_EDGE
47        }))
48        .pr(rem(ASSIST_TEXT_EDGE))
49        .gap(rem(CHIP_GAP))
50        .items_center()
51        .rounded(RADIUS_SMALL)
52        .border_1()
53        .border_color(color(OUTLINE_VARIANT))
54        .whitespace_nowrap()
55}
56
57/// Whether a chip is flat with an outline or raised on a tonal container.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum ChipStyle {
60    /// Transparent with a 1dp outline-variant outline (the kit's "Outlined").
61    #[default]
62    Flat,
63    /// Surface-container-low at elevation level 1, without an outline.
64    Elevated,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68enum ChipKind {
69    Assist,
70    Filter,
71    Input,
72    Suggestion,
73}
74
75/// The colors of a chip in one state.
76#[derive(Clone, Copy, Debug, PartialEq)]
77struct ChipPaint {
78    container: Rgba,
79    outline: Rgba,
80    label: Rgba,
81    leading: Rgba,
82    trailing: Rgba,
83    /// The state layers' content color.
84    layer: Rgba,
85    elevation: u8,
86}
87
88fn clear() -> Rgba {
89    Rgba {
90        r: 0.,
91        g: 0.,
92        b: 0.,
93        a: 0.,
94    }
95}
96
97/// M3 chip colors. Selected filter, input and suggestion chips sit on the
98/// secondary container without an outline; disabled chips use on-surface at
99/// [`state::DISABLED_CONTAINER`] for containers and outlines and at
100/// [`state::DISABLED_CONTENT`] for content, and elevated ones lie flat.
101fn chip_paint(kind: ChipKind, style: ChipStyle, selected: bool, disabled: bool) -> ChipPaint {
102    let selected = selected && kind != ChipKind::Assist;
103    let elevated = style == ChipStyle::Elevated;
104    let on_surface = color(ON_SURFACE);
105    if disabled {
106        let content = state::disabled(on_surface);
107        let faint = on_surface.opacity(state::DISABLED_CONTAINER);
108        return ChipPaint {
109            container: if selected || elevated { faint } else { clear() },
110            outline: if selected || elevated { clear() } else { faint },
111            label: content,
112            leading: content,
113            trailing: content,
114            layer: on_surface,
115            elevation: 0,
116        };
117    }
118    let elevation = if elevated { ELEVATED_LEVEL } else { 0 };
119    if selected {
120        let ink = color(ON_SECONDARY_CONTAINER);
121        return ChipPaint {
122            container: color(SECONDARY_CONTAINER),
123            outline: clear(),
124            label: ink,
125            leading: if kind == ChipKind::Input {
126                color(PRIMARY)
127            } else {
128                ink
129            },
130            trailing: ink,
131            layer: ink,
132            elevation,
133        };
134    }
135    let (label, leading) = match kind {
136        ChipKind::Assist => (ON_SURFACE, PRIMARY),
137        ChipKind::Filter | ChipKind::Suggestion => (ON_SURFACE_VARIANT, PRIMARY),
138        ChipKind::Input => (ON_SURFACE_VARIANT, ON_SURFACE_VARIANT),
139    };
140    let label = color(label);
141    ChipPaint {
142        container: if elevated {
143            color(SURFACE_CONTAINER_LOW)
144        } else {
145            clear()
146        },
147        outline: if elevated {
148            clear()
149        } else {
150            color(OUTLINE_VARIANT)
151        },
152        label,
153        leading: color(leading),
154        trailing: color(ON_SURFACE_VARIANT),
155        layer: label,
156        elevation,
157    }
158}
159
160type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
161type RemoveHandler = Rc<dyn Fn(&mut Window, &mut App)>;
162type ToggleHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
163
164/// What every chip carries; each public chip sets the parts it offers.
165struct ChipBase {
166    id: ElementId,
167    kind: ChipKind,
168    label: SharedString,
169    style: ChipStyle,
170    selected: bool,
171    disabled: bool,
172    dragged: bool,
173    preview: Option<InteractionPreview>,
174    leading: Option<Icon>,
175    avatar: Option<AnyElement>,
176    trailing: Option<Icon>,
177    remove: Option<(SharedString, RemoveHandler)>,
178    on_click: Option<ClickHandler>,
179    /// Called with the next selection; resolved at render so it sees the
180    /// final `selected` whatever the builder order.
181    on_toggle: Option<ToggleHandler>,
182}
183
184impl ChipBase {
185    fn new(id: ElementId, kind: ChipKind, label: SharedString) -> Self {
186        Self {
187            id,
188            kind,
189            label,
190            style: ChipStyle::Flat,
191            selected: false,
192            disabled: false,
193            dragged: false,
194            preview: None,
195            leading: None,
196            avatar: None,
197            trailing: None,
198            remove: None,
199            on_click: None,
200            on_toggle: None,
201        }
202    }
203
204    fn render(self, window: &mut Window, cx: &mut App) -> RingedButton {
205        let focus = window
206            .use_keyed_state((self.id.clone(), "chip-focus"), cx, |_, cx| {
207                cx.focus_handle()
208            })
209            .read(cx)
210            .clone();
211        let preview = self.preview;
212        let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
213        let enabled = !disabled;
214        let live = enabled && preview.is_none();
215        let forced = |state| enabled && preview == Some(state);
216        let keyboard_focus = forced(InteractionPreview::Focused)
217            || (live && focus.is_focused(window) && window.last_input_was_keyboard());
218        let selected = self.selected && self.kind != ChipKind::Assist;
219        let paint = chip_paint(self.kind, self.style, selected, disabled);
220        let dragged = self.dragged && enabled;
221        // A filter chip without its own leading icon slides a check in.
222        let slide = self.kind == ChipKind::Filter && self.leading.is_none();
223        let check = if slide {
224            let target = if selected { 1. } else { 0. };
225            if preview.is_some() {
226                target
227            } else {
228                motion::selection_value((self.id.clone(), "check"), target, window, cx)
229            }
230        } else {
231            0.
232        };
233        let check = if check.is_finite() { check.max(0.) } else { 0. };
234        let base = if dragged {
235            // A lifted flat chip gets the elevated container, so its shadow
236            // does not show through.
237            let lifted = if paint.container.a > 0. {
238                paint.container
239            } else {
240                color(SURFACE_CONTAINER_LOW)
241            };
242            state::layer(lifted, paint.layer, state::DRAG)
243        } else if keyboard_focus {
244            state::layer(paint.container, paint.layer, state::FOCUS)
245        } else {
246            paint.container
247        };
248        let (hover, press) = (
249            state::hovered(paint.container, paint.layer),
250            state::pressed(paint.container, paint.layer),
251        );
252        let elevation = if dragged {
253            DRAGGED_LEVEL
254        } else if paint.elevation > 0 && forced(InteractionPreview::Hovered) {
255            HOVERED_LEVEL
256        } else {
257            paint.elevation
258        };
259        // M3 darkens a flat chip's outline while it has focus.
260        let outline = if keyboard_focus && paint.outline.a > 0. {
261            color(ON_SURFACE_VARIANT)
262        } else {
263            paint.outline
264        };
265        let left = if self.avatar.is_some() {
266            AVATAR_EDGE
267        } else if self.leading.is_some() {
268            ICON_EDGE
269        } else {
270            let text = self.text_edge();
271            if slide {
272                // The edge narrows as the check arrives.
273                text + (ICON_EDGE - text) * check.min(1.)
274            } else {
275                text
276            }
277        };
278        let has_trailing = self.trailing.is_some() || self.remove.is_some();
279        let right = if has_trailing {
280            ICON_EDGE
281        } else {
282            self.text_edge()
283        };
284        let scale = TypeRole::LabelLarge.scale();
285        let icon_size = rem(CHIP_ICON);
286        let leading_icon = self.leading.map(|icon| {
287            // A selected filter chip shows the check in its icon's place.
288            let icon = if selected && self.kind == ChipKind::Filter {
289                IconName::Check.icon()
290            } else {
291                icon
292            };
293            icon.size(icon_size).color(paint.leading)
294        });
295        let check_slot = slide.then(|| {
296            div()
297                .flex_shrink_0()
298                .overflow_hidden()
299                .w(rem((CHIP_ICON + CHIP_GAP) * check))
300                .opacity(check.min(1.))
301                .when(cfg!(test), |slot| {
302                    slot.debug_selector(|| "chip-check".into())
303                })
304                .child(
305                    div()
306                        .w(rem(CHIP_ICON + CHIP_GAP))
307                        .child(IconName::Check.icon().size(icon_size).color(paint.leading)),
308                )
309        });
310        let avatar = self.avatar.map(|avatar| {
311            div()
312                .flex()
313                .flex_shrink_0()
314                .items_center()
315                .justify_center()
316                .size(rem(AVATAR))
317                .mr(rem(CHIP_GAP))
318                .rounded_full()
319                .overflow_hidden()
320                .child(avatar)
321        });
322        let trailing_icon = self.trailing.map(|icon| {
323            div()
324                .flex_shrink_0()
325                .ml(rem(CHIP_GAP))
326                .child(icon.size(icon_size).color(paint.trailing))
327        });
328        let key_remove = self.remove.as_ref().map(|(_, handler)| handler.clone());
329        let remove = self.remove.map(|(label, handler)| {
330            let on_press = handler;
331            div()
332                .id("chip-remove")
333                .flex_shrink_0()
334                .ml(rem(CHIP_GAP))
335                .role(Role::Button)
336                .aria_label(label)
337                .when(cfg!(test), |remove| {
338                    remove.debug_selector(|| "chip-remove".into())
339                })
340                .child(IconName::Close.icon().size(icon_size).color(paint.trailing))
341                .when(live, |remove| {
342                    remove
343                        .cursor_pointer()
344                        // The chip's own click must not see a press that
345                        // belongs to the remove icon.
346                        .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
347                        // A click pairs the press with its release, so a
348                        // drag that ends over the icon removes nothing.
349                        .on_click(move |_, window, cx| {
350                            cx.stop_propagation();
351                            on_press(window, cx);
352                        })
353                })
354                .into_any_element()
355        });
356        let label = self.label;
357        let next = !self.selected;
358        let on_click = self.on_toggle.map(|toggle| -> ClickHandler {
359            Rc::new(move |_, window, cx| toggle(&next, window, cx))
360        });
361        let on_click = on_click.or(self.on_click);
362        let id = self.id;
363        RingedButton::new(id, RingPlacement::Outer(Corners::all(RADIUS_SMALL)))
364            .track_focus(&focus)
365            .map(move |button| {
366                button
367                    .flex_shrink_0()
368                    .h(CHIP_HEIGHT)
369                    .pl(rem(left))
370                    .pr(rem(right))
371                    .rounded(RADIUS_SMALL)
372                    .border_1()
373                    .border_color(outline)
374                    .bg(base)
375                    .shadow(elevation::level(elevation))
376                    .text_color(paint.label)
377                    .text_size(scale.font_size)
378                    .line_height(scale.line_height)
379                    .font_weight(FontWeight(scale.font_weight as f32))
380                    .whitespace_nowrap()
381                    .selected(selected)
382                    .accessibility_label(label.clone())
383                    .when(live, |button| button.cursor_pointer())
384                    .children(check_slot)
385                    .children(avatar)
386                    .when_some(leading_icon, |button, icon| {
387                        button.child(div().flex_shrink_0().mr(rem(CHIP_GAP)).child(icon))
388                    })
389                    .child(label)
390                    .children(trailing_icon)
391                    .children(remove)
392                    .when_some(on_click, |button, handler| {
393                        button.on_click(move |event, window, cx| handler(event, window, cx))
394                    })
395            })
396            .when_some(key_remove.filter(|_| live), |button, remove| {
397                // Backspace and Delete remove a focused input chip.
398                button.on_key_down(move |event, window, cx| {
399                    if matches!(event.keystroke.key.as_str(), "backspace" | "delete") {
400                        cx.stop_propagation();
401                        remove(window, cx);
402                    }
403                })
404            })
405            .state_layers(hover, press)
406            .disabled(disabled)
407            .when_some(preview, |button, preview| button.preview_state(preview))
408    }
409
410    fn text_edge(&self) -> f32 {
411        if self.kind == ChipKind::Input {
412            INPUT_TEXT_EDGE
413        } else {
414            ASSIST_TEXT_EDGE
415        }
416    }
417}
418
419/// Builder methods every chip shares.
420macro_rules! chip_common {
421    ($chip:ident) => {
422        impl $chip {
423            /// Render a static specimen in one forced interaction state,
424            /// painting what the live state paints. See
425            /// [`InteractionPreview`].
426            pub fn preview_state(mut self, state: InteractionPreview) -> Self {
427                self.0.preview = Some(state);
428                self
429            }
430            /// Paint the chip as it looks while dragged: the drag state
431            /// layer and level-4 elevation.
432            pub fn dragged(mut self, dragged: bool) -> Self {
433                self.0.dragged = dragged;
434                self
435            }
436            pub fn disabled(mut self, disabled: bool) -> Self {
437                self.0.disabled = disabled;
438                self
439            }
440            /// A leading icon in its 18dp slot.
441            pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
442                self.0.leading = Some(icon.into());
443                self
444            }
445        }
446        impl RenderOnce for $chip {
447            fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
448                self.0.render(window, cx)
449            }
450        }
451    };
452}
453
454/// M3 assist chip: a smart or automated action, with an optional primary
455/// leading icon.
456#[derive(gpui::IntoElement)]
457pub struct AssistChip(ChipBase);
458chip_common!(AssistChip);
459
460impl AssistChip {
461    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
462        Self(ChipBase::new(id.into(), ChipKind::Assist, label.into()))
463    }
464    pub fn elevated(mut self, elevated: bool) -> Self {
465        self.0.style = if elevated {
466            ChipStyle::Elevated
467        } else {
468            ChipStyle::Flat
469        };
470        self
471    }
472    pub fn on_click(
473        mut self,
474        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
475    ) -> Self {
476        self.0.on_click = Some(Rc::new(handler));
477        self
478    }
479}
480
481/// M3 filter chip: a toggle among filter options. Selecting slides a check
482/// in before the label (or shows it in place of the leading icon).
483#[derive(gpui::IntoElement)]
484pub struct FilterChip(ChipBase);
485chip_common!(FilterChip);
486
487impl FilterChip {
488    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
489        Self(ChipBase::new(id.into(), ChipKind::Filter, label.into()))
490    }
491    pub fn selected(mut self, selected: bool) -> Self {
492        self.0.selected = selected;
493        self
494    }
495    pub fn elevated(mut self, elevated: bool) -> Self {
496        self.0.style = if elevated {
497            ChipStyle::Elevated
498        } else {
499            ChipStyle::Flat
500        };
501        self
502    }
503    /// A trailing icon, e.g. a drop-down arrow for a chip that opens a menu.
504    pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
505        self.0.trailing = Some(icon.into());
506        self
507    }
508    /// Called with the chip's next selection when it is activated.
509    pub fn on_toggle(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
510        self.0.on_toggle = Some(Rc::new(handler));
511        self
512    }
513}
514
515/// M3 input chip: a piece of information the person entered, with an
516/// optional leading icon or avatar and a remove icon.
517#[derive(gpui::IntoElement)]
518pub struct InputChip(ChipBase);
519chip_common!(InputChip);
520
521impl InputChip {
522    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
523        Self(ChipBase::new(id.into(), ChipKind::Input, label.into()))
524    }
525    pub fn selected(mut self, selected: bool) -> Self {
526        self.0.selected = selected;
527        self
528    }
529    /// A 24dp avatar before the label, clipped to a circle.
530    pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
531        self.0.avatar = Some(avatar.into_any_element());
532        self
533    }
534    /// A trailing remove icon named `label` for accessibility; `handler`
535    /// runs when it is clicked.
536    pub fn on_remove(
537        mut self,
538        label: impl Into<SharedString>,
539        handler: impl Fn(&mut Window, &mut App) + 'static,
540    ) -> Self {
541        self.0.remove = Some((label.into(), Rc::new(handler)));
542        self
543    }
544    pub fn on_click(
545        mut self,
546        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
547    ) -> Self {
548        self.0.on_click = Some(Rc::new(handler));
549        self
550    }
551}
552
553/// M3 suggestion chip: a dynamically generated suggestion, such as a reply.
554#[derive(gpui::IntoElement)]
555pub struct SuggestionChip(ChipBase);
556chip_common!(SuggestionChip);
557
558impl SuggestionChip {
559    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
560        Self(ChipBase::new(id.into(), ChipKind::Suggestion, label.into()))
561    }
562    pub fn selected(mut self, selected: bool) -> Self {
563        self.0.selected = selected;
564        self
565    }
566    pub fn elevated(mut self, elevated: bool) -> Self {
567        self.0.style = if elevated {
568            ChipStyle::Elevated
569        } else {
570            ChipStyle::Flat
571        };
572        self
573    }
574    pub fn on_click(
575        mut self,
576        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
577    ) -> Self {
578        self.0.on_click = Some(Rc::new(handler));
579        self
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use core::prelude::v1::test;
587
588    #[test]
589    fn chips_use_m3_geometry() {
590        assert_eq!(CHIP_HEIGHT, rem(32.));
591        assert_eq!(RADIUS_SMALL, px(8.));
592        assert_eq!((CHIP_ICON, CHIP_GAP, AVATAR), (18., 8., 24.));
593        assert_eq!(
594            (ASSIST_TEXT_EDGE, INPUT_TEXT_EDGE, ICON_EDGE, AVATAR_EDGE),
595            (16., 12., 8., 4.)
596        );
597    }
598
599    #[test]
600    fn chips_use_m3_roles() {
601        let c = color;
602        let assist = chip_paint(ChipKind::Assist, ChipStyle::Flat, false, false);
603        assert_eq!(assist.outline, c(OUTLINE_VARIANT));
604        assert_eq!(assist.label, c(ON_SURFACE));
605        assert_eq!(assist.leading, c(PRIMARY));
606        assert_eq!(assist.container.a, 0.);
607        let elevated = chip_paint(ChipKind::Assist, ChipStyle::Elevated, false, false);
608        assert_eq!(elevated.container, c(SURFACE_CONTAINER_LOW));
609        assert_eq!(elevated.outline.a, 0.);
610        assert_eq!(elevated.elevation, 1);
611        let filter = chip_paint(ChipKind::Filter, ChipStyle::Flat, true, false);
612        assert_eq!(filter.container, c(SECONDARY_CONTAINER));
613        assert_eq!(filter.label, c(ON_SECONDARY_CONTAINER));
614        assert_eq!(filter.outline.a, 0.);
615        let input = chip_paint(ChipKind::Input, ChipStyle::Flat, false, false);
616        assert_eq!(input.label, c(ON_SURFACE_VARIANT));
617        assert_eq!(input.trailing, c(ON_SURFACE_VARIANT));
618        let suggestion = chip_paint(ChipKind::Suggestion, ChipStyle::Flat, false, false);
619        assert_eq!(suggestion.label, c(ON_SURFACE_VARIANT));
620        let disabled = chip_paint(ChipKind::Assist, ChipStyle::Flat, false, true);
621        assert_eq!(disabled.label, state::disabled(c(ON_SURFACE)));
622        assert_eq!(
623            disabled.outline,
624            c(ON_SURFACE).opacity(state::DISABLED_CONTAINER)
625        );
626        let disabled = chip_paint(ChipKind::Filter, ChipStyle::Elevated, true, true);
627        assert_eq!(
628            disabled.container,
629            c(ON_SURFACE).opacity(state::DISABLED_CONTAINER)
630        );
631        assert_eq!(disabled.elevation, 0, "disabled chips lie flat");
632        assert_eq!(
633            chip_paint(ChipKind::Assist, ChipStyle::Flat, true, false),
634            assist,
635            "assist chips have no selection"
636        );
637    }
638
639    struct ChipHarness {
640        selected: bool,
641        removed: usize,
642        clicks: usize,
643    }
644    impl Render for ChipHarness {
645        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
646            window.set_rem_size(px(14.));
647            div()
648                .flex()
649                .items_start()
650                .child(
651                    div()
652                        .debug_selector(|| "plain-chip".into())
653                        .child(AssistChip::new("plain-chip", "Plain")),
654                )
655                .child(
656                    div()
657                        .debug_selector(|| "icon-chip".into())
658                        .child(AssistChip::new("icon-chip", "Plain").icon(IconName::Info)),
659                )
660                .child(
661                    div().debug_selector(|| "filter-chip".into()).child(
662                        // The toggle comes before `selected` so the next
663                        // value must be resolved at render.
664                        FilterChip::new("filter-chip", "Plain")
665                            .on_toggle(cx.listener(|this, next: &bool, _, cx| {
666                                this.selected = *next;
667                                cx.notify();
668                            }))
669                            .selected(self.selected),
670                    ),
671                )
672                .child(
673                    div().debug_selector(|| "input-chip".into()).child(
674                        InputChip::new("input-chip", "Plain")
675                            .on_click(cx.listener(|this, _, _, cx| {
676                                this.clicks += 1;
677                                cx.notify();
678                            }))
679                            .on_remove("Remove", {
680                                let view = cx.entity();
681                                move |_, cx| {
682                                    view.update(cx, |this, cx| {
683                                        this.removed += 1;
684                                        cx.notify();
685                                    })
686                                }
687                            }),
688                    ),
689                )
690        }
691    }
692
693    fn draw(cx: &mut VisualTestContext) {
694        cx.update(|window, cx| {
695            window.refresh();
696            window.draw(cx).clear(cx);
697        });
698    }
699
700    #[gpui::test]
701    fn a_leading_icon_takes_the_chip_padding_it_replaces(cx: &mut TestAppContext) {
702        cx.update(gpui_base::init);
703        let (_, cx) = cx.add_window_view(|_, _| ChipHarness {
704            selected: false,
705            removed: 0,
706            clicks: 0,
707        });
708        draw(cx);
709        let plain = cx.debug_bounds("plain-chip").unwrap();
710        let icon = cx.debug_bounds("icon-chip").unwrap();
711        assert_eq!(plain.size.height, px(32.));
712        // 18dp icon + 8dp gap replace 8dp of the 16dp leading padding.
713        assert_eq!(icon.size.width - plain.size.width, px(18. + 8. - 8.));
714    }
715
716    #[gpui::test]
717    fn selecting_a_filter_chip_slides_its_check_in(cx: &mut TestAppContext) {
718        cx.update(|cx| {
719            gpui_base::init(cx);
720            cx.set_reduce_motion(true);
721        });
722        let (view, cx) = cx.add_window_view(|_, _| ChipHarness {
723            selected: false,
724            removed: 0,
725            clicks: 0,
726        });
727        draw(cx);
728        let rest = cx.debug_bounds("filter-chip").unwrap();
729        assert_eq!(cx.debug_bounds("chip-check").unwrap().size.width, px(0.));
730        cx.simulate_click(rest.center(), Modifiers::default());
731        draw(cx);
732        view.read_with(cx, |h, _| assert!(h.selected));
733        let selected = cx.debug_bounds("filter-chip").unwrap();
734        // The 16dp edge becomes 8dp, plus the 18dp check and its 8dp gap.
735        assert_eq!(selected.size.width - rest.size.width, px(18. + 8. - 8.));
736        cx.simulate_click(selected.center(), Modifiers::default());
737        draw(cx);
738        view.read_with(cx, |h, _| assert!(!h.selected, "a second click clears it"));
739    }
740
741    #[gpui::test]
742    fn the_remove_icon_does_not_click_the_chip(cx: &mut TestAppContext) {
743        cx.update(|cx| {
744            gpui_base::init(cx);
745            cx.set_reduce_motion(true);
746        });
747        let (view, cx) = cx.add_window_view(|_, _| ChipHarness {
748            selected: false,
749            removed: 0,
750            clicks: 0,
751        });
752        draw(cx);
753        let remove = cx.debug_bounds("chip-remove").unwrap();
754        cx.simulate_click(remove.center(), Modifiers::default());
755        draw(cx);
756        view.read_with(cx, |h, _| assert_eq!((h.removed, h.clicks), (1, 0)));
757        let chip = cx.debug_bounds("input-chip").unwrap();
758        cx.simulate_click(
759            point(chip.left() + px(14.), chip.center().y),
760            Modifiers::default(),
761        );
762        draw(cx);
763        view.read_with(cx, |h, _| assert_eq!((h.removed, h.clicks), (1, 1)));
764    }
765
766    #[gpui::test]
767    fn a_press_released_over_the_remove_icon_removes_nothing(cx: &mut TestAppContext) {
768        cx.update(|cx| {
769            gpui_base::init(cx);
770            cx.set_reduce_motion(true);
771        });
772        let (view, cx) = cx.add_window_view(|_, _| ChipHarness {
773            selected: false,
774            removed: 0,
775            clicks: 0,
776        });
777        draw(cx);
778        let remove = cx.debug_bounds("chip-remove").unwrap().center();
779        let chip = cx.debug_bounds("input-chip").unwrap();
780        let label = point(chip.left() + px(14.), chip.center().y);
781        cx.simulate_mouse_down(label, MouseButton::Left, Modifiers::default());
782        cx.simulate_mouse_move(remove, Some(MouseButton::Left), Modifiers::default());
783        cx.simulate_mouse_up(remove, MouseButton::Left, Modifiers::default());
784        draw(cx);
785        view.read_with(cx, |h, _| assert_eq!(h.removed, 0));
786    }
787}