Skip to main content

gpui_kit_material/components/
feedback.rs

1//! Status and notice surfaces: M3 badges, status chips, banners and
2//! cards (the snackbar lives in `snackbar.rs`).
3use crate::color::ColorRole::{self, *};
4use crate::icons::IconName;
5use crate::theme::color;
6use crate::tokens::{elevation, layout::*, shape::*, state, typography::*};
7
8use super::InteractionPreview;
9use gpui::{prelude::*, *};
10use std::rc::Rc;
11
12use super::text::accessible_text;
13
14/// The tone of a [`notice_banner`].
15pub enum NoticeKind {
16    Info,
17    Danger,
18}
19
20/// A page-level notice: tinted surface, status icon and wrapping text.
21/// Callers append the notice's actions, which wrap below the text when narrow.
22pub fn notice_banner(
23    id: impl Into<ElementId>,
24    kind: NoticeKind,
25    text: impl Into<SharedString>,
26) -> Div {
27    let (bg, tone, icon, role) = match kind {
28        NoticeKind::Info => (
29            ColorRole::SECONDARY_CONTAINER,
30            ColorRole::PRIMARY,
31            IconName::Info,
32            Role::Status,
33        ),
34        NoticeKind::Danger => (
35            ColorRole::ERROR_CONTAINER,
36            ColorRole::ERROR,
37            IconName::Error,
38            Role::Alert,
39        ),
40    };
41    div()
42        .flex()
43        .flex_wrap()
44        .w_full()
45        .min_w_0()
46        .items_center()
47        .gap(rem(12.))
48        .p(rem(12.))
49        .rounded(RADIUS_SMALL)
50        .bg(color(bg))
51        .child(icon.icon().size(ICON_SLOT).color(color(tone)))
52        .child(
53            accessible_text(id, text)
54                .role(role)
55                .flex_1()
56                .flex_basis(rem(200.))
57                .min_w_0()
58                .whitespace_normal(),
59        )
60}
61
62/// The tone of a [`status_chip`]; text always pairs with its tinted
63/// background.
64pub enum StatusKind {
65    Success,
66    Warning,
67    Danger,
68    Progress,
69    Neutral,
70    /// An identity rather than a status, e.g. "Default".
71    Tonal,
72}
73
74/// Former name of [`StatusKind`]: these are status labels, not M3 badges.
75#[deprecated(note = "status labels are not M3 badges: use `StatusKind` with `status_chip`")]
76pub type BadgeKind = StatusKind;
77
78/// A status label chip: a tinted pill with an optional status icon. Icon,
79/// padding and label belong to one container; callers append the label.
80/// For the M3 notification badge, see [`small_badge`] and [`large_badge`].
81pub fn status_chip(kind: StatusKind) -> Div {
82    let (text, bg, icon) = match kind {
83        StatusKind::Success => (SUCCESS, SUCCESS_BG, Some(IconName::CheckCircle)),
84        StatusKind::Warning => (WARNING, WARNING_BG, Some(IconName::Warning)),
85        StatusKind::Danger => (ERROR, ERROR_CONTAINER, Some(IconName::Error)),
86        // A clock would read as waiting; work in progress needs no icon.
87        StatusKind::Progress => (PRIMARY, SECONDARY_CONTAINER, None),
88        // Neutral is a plain text chip without a status icon, on the
89        // highest surface container: a step above the tile in light and dark
90        // palettes alike (INSET sinks below the surface in dark ones).
91        StatusKind::Neutral => (ON_SURFACE_VARIANT, SURFACE_CONTAINER_HIGHEST, None),
92        StatusKind::Tonal => (ON_SECONDARY_CONTAINER, SECONDARY_CONTAINER, None),
93    };
94    let mut view = div()
95        .flex()
96        .items_center()
97        .flex_shrink_0()
98        .gap(rem(4.))
99        .min_h(rem(24.))
100        .px(rem(10.))
101        .py(rem(3.))
102        .rounded(RADIUS_SMALL)
103        .bg(color(bg))
104        .text_color(color(text))
105        .text_size(TEXT_AUX)
106        .font_weight(FontWeight::MEDIUM)
107        .whitespace_nowrap();
108    if let Some(icon) = icon {
109        view = view.child(icon.icon().size(STATUS_ICON));
110    }
111    view
112}
113
114/// The status icon shares the chip's text ruler.
115const STATUS_ICON: Rems = rem(16.);
116
117/// A status label; same as [`status_chip`], kept under its earlier name.
118#[deprecated(
119    note = "a status chip, not an M3 badge: use `status_chip` (M3 badges are `small_badge` and `large_badge`)"
120)]
121pub fn badge(kind: StatusKind) -> Div {
122    status_chip(kind)
123}
124
125/// A fact beside a label in the status chip's size, outlined rather than
126/// filled: e.g. the engine a tile uses.
127pub fn outlined_status_chip(
128    id: impl Into<ElementId>,
129    label: impl Into<SharedString>,
130) -> Stateful<Div> {
131    accessible_text(id, label)
132        .flex_shrink_0()
133        .px(rem(10.))
134        .py(rem(4.))
135        .rounded(RADIUS_SMALL)
136        .border_1()
137        .border_color(color(OUTLINE_VARIANT))
138        .text_size(TEXT_AUX)
139        .line_height(rem(16.))
140        .font_weight(FontWeight::MEDIUM)
141        .text_color(color(ON_SURFACE_VARIANT))
142        .whitespace_nowrap()
143}
144
145/// An outlined status label; same as [`outlined_status_chip`], kept under its
146/// earlier name.
147#[deprecated(note = "a status chip, not an M3 badge: use `outlined_status_chip`")]
148pub fn outlined_badge(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Stateful<Div> {
149    outlined_status_chip(id, label)
150}
151
152/// The M3 small badge: a 6dp dot. It carries no text, so it does not follow
153/// the text scale.
154pub const SMALL_BADGE_SIZE: Pixels = px(6.);
155/// The M3 large badge: at least 16dp square, around label-small text.
156pub const LARGE_BADGE_SIZE: Rems = rem(16.);
157/// Horizontal padding inside a large badge (4dp).
158pub const LARGE_BADGE_PADDING: Rems = rem(4.);
159/// The container and label roles of both M3 badges.
160pub const BADGE_CONTAINER: ColorRole = ERROR;
161pub const BADGE_LABEL: ColorRole = ON_ERROR;
162
163/// M3 small badge: a full-round error dot that marks new content on an icon.
164/// The caller positions it (6dp from the icon's top-trailing corner).
165pub fn small_badge() -> Div {
166    div()
167        .flex_shrink_0()
168        .size(SMALL_BADGE_SIZE)
169        .rounded(RADIUS_FULL)
170        .bg(color(BADGE_CONTAINER))
171}
172
173/// M3 large badge: a count or short label on an error pill, label-small on
174/// on-error, at least 16dp square and full-round. The caller positions it
175/// (12dp across and 14dp down from the icon's top-trailing corner) and
176/// supplies its text, e.g. "3" or "999+".
177pub fn large_badge(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Stateful<Div> {
178    let scale = TypeRole::LabelSmall.scale();
179    accessible_text(id, label)
180        .flex()
181        .flex_shrink_0()
182        .items_center()
183        .justify_center()
184        .h(LARGE_BADGE_SIZE)
185        .min_w(LARGE_BADGE_SIZE)
186        .px(LARGE_BADGE_PADDING)
187        .rounded(RADIUS_FULL)
188        .bg(color(BADGE_CONTAINER))
189        .text_color(color(BADGE_LABEL))
190        .text_size(scale.font_size)
191        .line_height(scale.line_height)
192        .font_weight(FontWeight(scale.font_weight as f32))
193        .whitespace_nowrap()
194}
195
196/// The three M3 card variants.
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub enum CardVariant {
199    /// Surface-container-low with level-1 elevation and no outline.
200    Elevated,
201    /// Surface-container-highest, flat.
202    Filled,
203    /// Surface with a 1dp outline-variant edge, flat.
204    Outlined,
205}
206
207/// The resolved paint of a card variant.
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209struct CardStyle {
210    container: ColorRole,
211    elevation: u8,
212    outline: Option<ColorRole>,
213}
214
215fn card_style(variant: CardVariant) -> CardStyle {
216    match variant {
217        CardVariant::Elevated => CardStyle {
218            container: SURFACE_CONTAINER_LOW,
219            elevation: 1,
220            outline: None,
221        },
222        CardVariant::Filled => CardStyle {
223            container: SURFACE_CONTAINER_HIGHEST,
224            elevation: 0,
225            outline: None,
226        },
227        CardVariant::Outlined => CardStyle {
228            container: SURFACE,
229            elevation: 0,
230            outline: Some(OUTLINE_VARIANT),
231        },
232    }
233}
234
235/// Compatibility helper padding from the reference's 16px content examples.
236/// The reference card surface itself has no padding.
237pub const CARD_PADDING: Rems = rem(16.);
238
239/// Static Material card surface, without implicit padding or full width.
240///
241/// Fast's native overflow handling clips children to the rounded shape. The
242/// upstream backend currently provides rectangular overflow clipping only;
243/// rounded content clipping there remains unsupported. [`Self::state`] and
244/// [`Self::preview_state`] render the kit's interaction states;
245/// [`Self::interactive`] adds the live hover state (its elevation change is
246/// immediate, not animated).
247///
248/// The outline paints above content without participating in layout.
249pub struct CardSurface {
250    inner: Div,
251    outline: Option<ColorRole>,
252    variant: CardVariant,
253    /// A specimen's forced focus: the 3dp secondary ring 2dp outside.
254    focus_ring: bool,
255}
256impl CardSurface {
257    pub fn new(variant: CardVariant) -> Self {
258        let style = card_style(variant);
259        Self {
260            inner: gpui_base::v_flex()
261                .bg(color(style.container))
262                .text_color(color(ON_SURFACE))
263                .overflow_hidden()
264                .rounded(RADIUS_CARD)
265                .shadow(elevation::level(style.elevation)),
266            outline: style.outline,
267            variant,
268            focus_ring: false,
269        }
270    }
271
272    /// Render a fixed interaction state as a specimen: the on-surface state
273    /// layer, the state's elevation and, when focused, the focus ring.
274    pub fn state(mut self, state: CardState) -> Self {
275        let container = color(card_style(self.variant).container);
276        self.inner = self
277            .inner
278            .bg(state::layer(
279                container,
280                color(ON_SURFACE),
281                state.layer_opacity(),
282            ))
283            .shadow(elevation::level(card_elevation(self.variant, state)));
284        self.focus_ring = state == CardState::Focused;
285        self
286    }
287
288    /// [`Self::state`] for one of the shared interaction previews.
289    pub fn preview_state(self, preview: InteractionPreview) -> Self {
290        self.state(preview.into())
291    }
292
293    /// A card the pointer can act on: hovering lays the 8% on-surface
294    /// state layer over it and raises it to its hovered elevation. The
295    /// caller supplies the action (and focus) on the content.
296    pub fn interactive(mut self) -> Self {
297        let container = color(card_style(self.variant).container);
298        let hover = state::hovered(container, color(ON_SURFACE));
299        let shadow = elevation::level(card_elevation(self.variant, CardState::Hovered));
300        self.inner = self
301            .inner
302            .hover(move |style| style.bg(hover).shadow(shadow.clone()));
303        self
304    }
305}
306
307/// The interaction states of a card, including the drag the shared
308/// [`InteractionPreview`] does not cover.
309#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
310pub enum CardState {
311    #[default]
312    Enabled,
313    Hovered,
314    Focused,
315    Pressed,
316    Dragged,
317}
318
319impl CardState {
320    /// Every state in the kit's order.
321    pub const ALL: [Self; 5] = [
322        Self::Enabled,
323        Self::Hovered,
324        Self::Focused,
325        Self::Pressed,
326        Self::Dragged,
327    ];
328
329    pub const fn name(self) -> &'static str {
330        match self {
331            Self::Enabled => "Enabled",
332            Self::Hovered => "Hovered",
333            Self::Focused => "Focused",
334            Self::Pressed => "Pressed",
335            Self::Dragged => "Dragged",
336        }
337    }
338
339    /// The on-surface state layer's opacity.
340    pub const fn layer_opacity(self) -> f32 {
341        match self {
342            Self::Enabled => 0.,
343            Self::Hovered => state::HOVER,
344            Self::Focused => state::FOCUS,
345            Self::Pressed => state::PRESS,
346            Self::Dragged => state::DRAG,
347        }
348    }
349}
350
351impl From<InteractionPreview> for CardState {
352    fn from(preview: InteractionPreview) -> Self {
353        match preview {
354            InteractionPreview::Hovered => Self::Hovered,
355            InteractionPreview::Focused => Self::Focused,
356            InteractionPreview::Pressed => Self::Pressed,
357            InteractionPreview::Enabled | InteractionPreview::Disabled => Self::Enabled,
358        }
359    }
360}
361
362/// The kit's elevation for each card variant and state: hovering raises a
363/// card one level, dragging to level 4 (elevated) or 3; a filled card stays
364/// at level 1 while focused or pressed.
365fn card_elevation(variant: CardVariant, state: CardState) -> u8 {
366    use CardState::*;
367    match (variant, state) {
368        (CardVariant::Elevated, Hovered) => 2,
369        (CardVariant::Elevated, Dragged) => 4,
370        (CardVariant::Elevated, _) => 1,
371        (_, Dragged) => 3,
372        (_, Hovered) => 1,
373        (CardVariant::Filled, Focused | Pressed) => 1,
374        _ => 0,
375    }
376}
377impl Styled for CardSurface {
378    fn style(&mut self) -> &mut StyleRefinement {
379        self.inner.style()
380    }
381}
382impl ParentElement for CardSurface {
383    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
384        self.inner.extend(elements);
385    }
386}
387impl InteractiveElement for CardSurface {
388    fn interactivity(&mut self) -> &mut Interactivity {
389        self.inner.interactivity()
390    }
391}
392impl IntoElement for CardSurface {
393    type Element = Self;
394    fn into_element(self) -> Self {
395        self
396    }
397}
398impl Element for CardSurface {
399    type RequestLayoutState = <Div as Element>::RequestLayoutState;
400    type PrepaintState = <Div as Element>::PrepaintState;
401    fn id(&self) -> Option<ElementId> {
402        Element::id(&self.inner)
403    }
404    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
405        Element::source_location(&self.inner)
406    }
407    fn a11y_role(&self) -> Option<accesskit::Role> {
408        self.inner.a11y_role()
409    }
410    fn write_a11y_info(&self, node: &mut accesskit::Node) {
411        self.inner.write_a11y_info(node);
412    }
413    fn a11y_synthetic_children(
414        &mut self,
415        prepaint: &mut Self::PrepaintState,
416        builder: &mut A11ySubtreeBuilder,
417    ) {
418        self.inner.a11y_synthetic_children(prepaint, builder);
419    }
420    fn request_layout(
421        &mut self,
422        id: Option<&GlobalElementId>,
423        inspector: Option<&InspectorElementId>,
424        window: &mut Window,
425        cx: &mut App,
426    ) -> (LayoutId, Self::RequestLayoutState) {
427        self.inner.request_layout(id, inspector, window, cx)
428    }
429    fn prepaint(
430        &mut self,
431        id: Option<&GlobalElementId>,
432        inspector: Option<&InspectorElementId>,
433        bounds: Bounds<Pixels>,
434        state: &mut Self::RequestLayoutState,
435        window: &mut Window,
436        cx: &mut App,
437    ) -> Self::PrepaintState {
438        self.inner
439            .prepaint(id, inspector, bounds, state, window, cx)
440    }
441    fn paint(
442        &mut self,
443        id: Option<&GlobalElementId>,
444        inspector: Option<&InspectorElementId>,
445        bounds: Bounds<Pixels>,
446        state: &mut Self::RequestLayoutState,
447        prepaint: &mut Self::PrepaintState,
448        window: &mut Window,
449        cx: &mut App,
450    ) {
451        let style = self
452            .inner
453            .interactivity()
454            .compute_style(id, prepaint.as_ref(), window, cx);
455        self.inner
456            .paint(id, inspector, bounds, state, prepaint, window, cx);
457        if let Some(role) = self.outline
458            && style.visibility != Visibility::Hidden
459            && style.display != Display::None
460        {
461            let radii = style
462                .corner_radii
463                .to_pixels(window.rem_size())
464                .clamp_radii_for_quad_size(bounds.size);
465            // Match GPUI's local multiplication; paint_quad applies ancestor
466            // opacity. Neither path clamps the local opacity here.
467            let mut outline: Hsla = color(role).into();
468            outline.a *= style.opacity.unwrap_or(1.);
469            window.paint_quad(quad(
470                bounds,
471                radii,
472                transparent_black(),
473                px(1.),
474                outline,
475                BorderStyle::default(),
476            ));
477        }
478        if self.focus_ring {
479            // The kit's focus indicator: 3dp secondary, 2dp outside.
480            let radius = px(f32::from(RADIUS_CARD) + 5.);
481            window.paint_quad(quad(
482                bounds.dilate(px(5.)),
483                radius,
484                transparent_black(),
485                px(3.),
486                color(SECONDARY),
487                BorderStyle::default(),
488            ));
489        }
490    }
491}
492
493/// Why a [`MaterialCard`] is moving: the pointer holding it, or the
494/// spring carrying it home after release.
495#[derive(Default)]
496struct CardDrag {
497    /// Pointer position and card offset when the press began, while held.
498    grab: Option<(Point<Pixels>, Point<Pixels>)>,
499    /// The card's displacement from its place.
500    offset: Point<Pixels>,
501    /// Whether the current or last press moved the card (it then is not a
502    /// click).
503    moved: bool,
504    /// Bumped on every release, keying the return animation.
505    releases: u64,
506    /// Where the last release left the card.
507    released_at: Point<Pixels>,
508}
509
510/// A [`MaterialCard`]'s action.
511type CardClick = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
512
513/// The distance a press must travel before it drags the card.
514const CARD_DRAG_SLOP: Pixels = px(4.);
515
516/// A Material card that can act and move: the [`CardSurface`] of its
517/// variant, optionally a GPUI Base button (`on_click`: hover and press
518/// state layers, the kit's hover elevation, ripple, keyboard activation
519/// and the focus ring) and optionally draggable (the dragged state layer
520/// and elevation while the pointer carries it; on release it springs back
521/// to its place on the default spatial spring, or returns at once under
522/// reduced motion). The accessible label names the action.
523#[derive(gpui::IntoElement)]
524pub struct MaterialCard {
525    id: ElementId,
526    variant: CardVariant,
527    label: Option<SharedString>,
528    on_click: Option<CardClick>,
529    draggable: bool,
530    preview: Option<CardState>,
531    children: Vec<AnyElement>,
532    style: StyleRefinement,
533}
534
535impl MaterialCard {
536    pub fn new(id: impl Into<ElementId>, variant: CardVariant) -> Self {
537        Self {
538            id: id.into(),
539            variant,
540            label: None,
541            on_click: None,
542            draggable: false,
543            preview: None,
544            children: Vec::new(),
545            style: StyleRefinement::default(),
546        }
547    }
548
549    /// Make the card one action (Base button semantics).
550    pub fn on_click(
551        mut self,
552        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
553    ) -> Self {
554        self.on_click = Some(Rc::new(handler));
555        self
556    }
557
558    /// The action's accessible name.
559    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
560        self.label = Some(label.into());
561        self
562    }
563
564    /// Let the pointer pick the card up and move it.
565    pub fn draggable(mut self, draggable: bool) -> Self {
566        self.draggable = draggable;
567        self
568    }
569
570    /// Render a fixed interaction state as a specimen.
571    pub fn state(mut self, state: CardState) -> Self {
572        self.preview = Some(state);
573        self
574    }
575
576    /// Whether this card acts.
577    pub fn is_clickable(&self) -> bool {
578        self.on_click.is_some()
579    }
580}
581
582impl Styled for MaterialCard {
583    fn style(&mut self) -> &mut StyleRefinement {
584        &mut self.style
585    }
586}
587
588impl ParentElement for MaterialCard {
589    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
590        self.children.extend(elements);
591    }
592}
593
594impl RenderOnce for MaterialCard {
595    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
596        use super::button::{ButtonFocusRing, ButtonRipple};
597        let id = self.id;
598        let drag =
599            window.use_keyed_state((id.clone(), "card-drag"), cx, |_, _| CardDrag::default());
600        let (grabbed, offset, releases, released_at) = {
601            let drag = drag.read(cx);
602            (
603                drag.grab.is_some(),
604                drag.offset,
605                drag.releases,
606                drag.released_at,
607            )
608        };
609        let dragging = grabbed && drag.read(cx).moved;
610        let clickable = self.on_click.is_some() && self.preview.is_none();
611        let pressed = clickable && grabbed && !dragging;
612        let container = color(card_style(self.variant).container);
613        let group: SharedString = format!("material-card-{id:?}").into();
614
615        let mut surface = CardSurface::new(self.variant)
616            .w_full()
617            .children(self.children);
618        surface = match (self.preview, dragging) {
619            (_, true) => surface.state(CardState::Dragged),
620            (Some(state), _) => surface.state(state),
621            (None, false) if pressed => surface.state(CardState::Pressed),
622            (None, false) if clickable => {
623                let hover = state::hovered(container, color(ON_SURFACE));
624                let hovered = elevation::level(card_elevation(self.variant, CardState::Hovered));
625                surface.group_hover(group.clone(), move |style| {
626                    style.bg(hover).shadow(hovered.clone())
627                })
628            }
629            _ => surface,
630        };
631
632        let body: AnyElement = if let Some(on_click) = self.on_click.clone().filter(|_| clickable) {
633            let focus = window
634                .use_keyed_state((id.clone(), "card-focus"), cx, |_, cx| cx.focus_handle())
635                .read(cx)
636                .clone();
637            let click_drag = drag.clone();
638            let button = gpui_base::Button::new((id.clone(), "card-button"))
639                .group(group)
640                .track_focus(&focus)
641                .w_full()
642                .rounded(RADIUS_CARD)
643                .cursor_pointer()
644                .when_some(self.label.clone(), |button, label| {
645                    button.accessibility_label(label)
646                })
647                .child(surface)
648                .on_click(move |event, window, cx| {
649                    // A press that carried the card away is a drag, not a click.
650                    if !click_drag.read(cx).moved {
651                        on_click(event, window, cx);
652                    }
653                });
654            let ripple = ButtonRipple::new(
655                button,
656                (id.clone(), "card-ripple").into(),
657                true,
658                true,
659                super::navigation::ripple_corners(RADIUS_CARD),
660                Edges::all(px(0.).into()),
661                color(ON_SURFACE),
662                window,
663                cx,
664            );
665            ButtonFocusRing::with_corners(ripple, focus, true, Corners::all(RADIUS_CARD))
666                .into_any_element()
667        } else {
668            surface.into_any_element()
669        };
670
671        let draggable = self.draggable && self.preview.is_none();
672        let mut root = div()
673            .id(id.clone())
674            .relative()
675            .flex()
676            .flex_col()
677            .child(body);
678        root.style().refine(&self.style);
679        if draggable {
680            root = root.cursor(if dragging {
681                CursorStyle::ClosedHand
682            } else {
683                CursorStyle::OpenHand
684            });
685        }
686        if draggable || clickable {
687            // The press: the pressed state of an action, and a drag's grab.
688            let grab = drag.clone();
689            root = root.on_mouse_down(MouseButton::Left, move |event, window, cx| {
690                grab.update(cx, |drag, _| {
691                    drag.grab = Some((event.position, drag.offset));
692                    drag.moved = false;
693                });
694                window.refresh();
695            });
696        }
697        if grabbed {
698            // While held, follow the pointer anywhere in the window.
699            let follow = drag.clone();
700            root = root.child(
701                canvas(
702                    |_, _, _| (),
703                    move |_, _, window, _| {
704                        let moving = follow.clone();
705                        window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
706                            if phase != DispatchPhase::Capture {
707                                return;
708                            }
709                            moving.update(cx, |drag, _| {
710                                let Some((start, from)) = drag.grab else {
711                                    return;
712                                };
713                                let delta = event.position - start;
714                                if !draggable {
715                                    return;
716                                }
717                                if drag.moved
718                                    || delta.x.abs() > CARD_DRAG_SLOP
719                                    || delta.y.abs() > CARD_DRAG_SLOP
720                                {
721                                    drag.moved = true;
722                                    drag.offset = from + delta;
723                                }
724                            });
725                            window.refresh();
726                        });
727                        let dropping = follow.clone();
728                        window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
729                            if phase != DispatchPhase::Capture {
730                                return;
731                            }
732                            dropping.update(cx, |drag, _| {
733                                drag.grab = None;
734                                if drag.moved {
735                                    drag.releases += 1;
736                                    drag.released_at = drag.offset;
737                                }
738                                drag.offset = Point::default();
739                            });
740                            // The click this release may complete reads
741                            // `moved`; forget it once the event is handled.
742                            let settled = dropping.clone();
743                            window.defer(cx, move |_, cx| {
744                                settled.update(cx, |drag, _| drag.moved = false);
745                            });
746                            window.refresh();
747                        });
748                    },
749                )
750                .absolute()
751                .size_0(),
752            );
753        }
754        let lifted = dragging || grabbed;
755        if lifted && offset != Point::default() {
756            return root.left(offset.x).top(offset.y).into_any_element();
757        }
758        if releases > 0 && released_at != Point::default() && !cx.reduce_motion() {
759            let curve = crate::motion::curve(crate::motion::MotionToken::DefaultSpatial);
760            let ease = curve.ease();
761            return root
762                .with_animation(
763                    ElementId::NamedChild(
764                        std::sync::Arc::new(id),
765                        format!("card-return-{releases}").into(),
766                    ),
767                    Animation::new(curve.duration()),
768                    move |root, t| {
769                        let remaining = 1. - ease(t);
770                        root.left(released_at.x * remaining)
771                            .top(released_at.y * remaining)
772                    },
773                )
774                .into_any_element();
775        }
776        root.into_any_element()
777    }
778}
779
780/// Compatibility appearance helper: full width and 16dp example padding.
781/// Returns [`CardSurface`] instead of the earlier `Div`; Styled and
782/// ParentElement chains remain supported. Use [`CardSurface::new`] for the
783/// reference's unpadded surface, then supply caller-owned content spacing.
784pub fn card(variant: CardVariant) -> CardSurface {
785    CardSurface::new(variant).w_full().p(CARD_PADDING)
786}
787
788/// Elevated compatibility helper, retaining full width and example padding.
789pub fn card_surface() -> CardSurface {
790    card(CardVariant::Elevated)
791}
792
793#[cfg(test)]
794#[path = "card_tests.rs"]
795mod card_tests;
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    // The glob import of `gpui` shadows the built-in `#[test]` that
801    // `#[gpui::test]` expands to.
802    use core::prelude::v1::test;
803    use gpui::{Context, TestAppContext};
804
805    #[test]
806    fn card_variants_use_m3_container_roles_elevation_and_outline() {
807        assert_eq!(
808            card_style(CardVariant::Elevated),
809            CardStyle {
810                container: SURFACE_CONTAINER_LOW,
811                elevation: 1,
812                outline: None,
813            }
814        );
815        assert_eq!(
816            card_style(CardVariant::Filled),
817            CardStyle {
818                container: SURFACE_CONTAINER_HIGHEST,
819                elevation: 0,
820                outline: None,
821            }
822        );
823        assert_eq!(
824            card_style(CardVariant::Outlined),
825            CardStyle {
826                container: SURFACE,
827                elevation: 0,
828                outline: Some(OUTLINE_VARIANT),
829            }
830        );
831        assert_eq!(RADIUS_CARD, px(12.));
832        assert_eq!(CARD_PADDING, rem(16.));
833    }
834
835    #[test]
836    fn card_states_follow_the_kit_layers_and_elevation() {
837        use CardState::*;
838        let levels = |variant| CardState::ALL.map(|s| card_elevation(variant, s));
839        assert_eq!(levels(CardVariant::Elevated), [1, 2, 1, 1, 4]);
840        assert_eq!(levels(CardVariant::Filled), [0, 1, 1, 1, 3]);
841        assert_eq!(levels(CardVariant::Outlined), [0, 1, 0, 0, 3]);
842        assert_eq!(
843            CardState::ALL.map(CardState::layer_opacity),
844            [0., state::HOVER, state::FOCUS, state::PRESS, state::DRAG]
845        );
846        assert_eq!(CardState::from(InteractionPreview::Disabled), Enabled);
847        assert_eq!(CardState::from(InteractionPreview::Focused), Focused);
848    }
849
850    #[test]
851    fn badges_follow_m3_geometry_and_roles() {
852        assert_eq!(SMALL_BADGE_SIZE, px(6.));
853        assert_eq!(LARGE_BADGE_SIZE, rem(16.));
854        assert_eq!(LARGE_BADGE_PADDING, rem(4.));
855        assert_eq!((BADGE_CONTAINER, BADGE_LABEL), (ERROR, ON_ERROR));
856    }
857
858    #[gpui::test]
859    fn large_badge_hugs_short_labels_and_grows_for_long_ones(cx: &mut TestAppContext) {
860        struct Badges;
861        impl Render for Badges {
862            fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
863                window.set_rem_size(px(14.));
864                div()
865                    .flex()
866                    .items_start()
867                    .child(large_badge("one", "3").debug_selector(|| "badge-short".into()))
868                    .child(large_badge("many", "999+").debug_selector(|| "badge-long".into()))
869                    .child(small_badge().debug_selector(|| "badge-dot".into()))
870            }
871        }
872        let (_, cx) = cx.add_window_view(|_, _| Badges);
873        cx.run_until_parked();
874        let short = cx.debug_bounds("badge-short").unwrap();
875        let long = cx.debug_bounds("badge-long").unwrap();
876        let dot = cx.debug_bounds("badge-dot").unwrap();
877        assert_eq!(short.size.height, px(16.));
878        assert_eq!(short.size.width, px(16.));
879        assert_eq!(long.size.height, px(16.));
880        assert!(long.size.width > px(16.));
881        assert_eq!(dot.size, gpui::size(px(6.), px(6.)));
882    }
883
884    pub(crate) fn redraw(cx: &mut gpui::VisualTestContext) {
885        cx.update(|window, cx| {
886            window.refresh();
887            window.draw(cx).clear(cx);
888        });
889    }
890}