Skip to main content

gpui_kit_material/components/
list.rs

1//! M3 list items: one-, two- and three-line rows with leading and trailing
2//! elements, dividers, selection and every interaction state.
3//!
4//! Geometry and roles follow the Figma "Material 3 Design Kit" list item
5//! (`List item/List Item: -4 Density (baseline)`) and m3e.kyralabs.dev's
6//! list: a surface row with 16dp side padding and 16dp gaps, body-large
7//! headline (on-surface), label-medium overline and body-medium supporting
8//! text (on-surface-variant), label-small trailing text, a 40dp monogram,
9//! 24dp icons, 56dp image and 114×64 video thumbnails, and an
10//! outline-variant divider. The Expressive segmented list groups items 2dp
11//! apart with 16dp outer and 4dp inner corners ([`segmented_list`]).
12//!
13//! An item that acts is a GPUI Base button ([`RingedButton`]) with the M3
14//! state layers and the inner keyboard focus ring; embedded checkboxes,
15//! radios and switches show the row's state and are not separate tab stops,
16//! so the whole row is the one target, as M3 specifies.
17use super::InteractionPreview;
18use super::focus::{RingPlacement, RingedButton};
19use super::{CheckboxState, MaterialCheckbox, MaterialRadio, MaterialSwitch};
20use crate::{
21    color::ColorRole,
22    icons::Icon,
23    theme::color,
24    tokens::{
25        shape::{OuterCorners, list_corners},
26        state,
27        typography::{TypeRole, rem},
28    },
29};
30use gpui::{prelude::*, *};
31use std::rc::Rc;
32
33/// Outer corners of an Expressive segmented list.
34pub const LIST_SEGMENT_RADIUS: Pixels = px(16.);
35/// Corners between neighbouring items of a segmented list.
36pub const LIST_SEGMENT_INNER_RADIUS: Pixels = px(4.);
37/// The ground between items of a segmented list.
38pub const LIST_SEGMENT_GAP: Pixels = px(2.);
39
40/// The leading element of a list item.
41// A short-lived builder value consumed by one render; boxing the icon
42// would only complicate callers.
43#[allow(clippy::large_enum_variant)]
44pub enum ListLeading {
45    None,
46    /// A 40dp monogram avatar on primary-container.
47    Monogram(SharedString),
48    /// A 24dp icon in on-surface-variant.
49    Icon(Icon),
50    /// A 56dp square thumbnail; `None` paints the placeholder container.
51    Image(Option<AnyElement>),
52    /// A 114×64 video thumbnail; `None` paints the placeholder container.
53    Video(Option<AnyElement>),
54    Checkbox(bool),
55    Radio(bool),
56    Switch(bool),
57}
58
59/// The trailing element of a list item.
60// A short-lived builder value consumed by one render; boxing the icon
61// would only complicate callers.
62#[allow(clippy::large_enum_variant)]
63pub enum ListTrailing {
64    None,
65    /// A 24dp icon in on-surface-variant (e.g. an arrow).
66    Icon(Icon),
67    Checkbox(bool),
68    Radio(bool),
69    Switch(bool),
70}
71
72/// Vertical density. Standard is the M3 list (56/72/88dp); Compact is the
73/// design kit's −4 density baseline (40/56/72dp, 88dp with an overline over
74/// two supporting lines).
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub enum ListDensity {
77    #[default]
78    Standard,
79    Compact,
80}
81
82/// How many lines an item's text occupies, which sets its height and the
83/// leading element's alignment.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum ListLines {
86    One,
87    Two,
88    Three,
89}
90
91impl ListLines {
92    /// The lines of an item with these texts. Supporting text alone wraps to
93    /// `supporting_lines`; an overline adds a line.
94    pub fn of(overline: bool, supporting: bool, supporting_lines: usize) -> Self {
95        let lines = 1 + usize::from(overline) + if supporting { supporting_lines } else { 0 };
96        match lines {
97            1 => Self::One,
98            2 => Self::Two,
99            _ => Self::Three,
100        }
101    }
102}
103
104/// The minimum height and vertical padding of a row, in dp.
105fn row_metrics(lines: ListLines, density: ListDensity) -> (f32, f32) {
106    match (density, lines) {
107        (ListDensity::Standard, ListLines::One) => (56., 8.),
108        (ListDensity::Standard, ListLines::Two) => (72., 8.),
109        (ListDensity::Standard, ListLines::Three) => (88., 12.),
110        (ListDensity::Compact, ListLines::One) => (40., 0.),
111        (ListDensity::Compact, ListLines::Two) => (56., 4.),
112        (ListDensity::Compact, ListLines::Three) => (72., 4.),
113    }
114}
115
116/// Paint of a row in one state.
117#[derive(Clone, Copy, Debug, PartialEq)]
118struct ListPaint {
119    container: Rgba,
120    headline: Rgba,
121    support: Rgba,
122    /// The state layer's content color.
123    layer: Rgba,
124}
125
126/// Selected rows take the secondary container (m3e list item); disabled
127/// content is on-surface at the disabled-content opacity over the surface.
128fn list_paint(selected: bool, disabled: bool) -> ListPaint {
129    if disabled {
130        let ink = state::disabled(color(ColorRole::ON_SURFACE));
131        return ListPaint {
132            container: if selected {
133                color(ColorRole::ON_SURFACE).opacity(state::DISABLED_CONTAINER)
134            } else {
135                color(ColorRole::SURFACE)
136            },
137            headline: ink,
138            support: ink,
139            layer: color(ColorRole::ON_SURFACE),
140        };
141    }
142    if selected {
143        ListPaint {
144            container: color(ColorRole::SECONDARY_CONTAINER),
145            headline: color(ColorRole::ON_SECONDARY_CONTAINER),
146            support: color(ColorRole::ON_SECONDARY_CONTAINER),
147            layer: color(ColorRole::ON_SECONDARY_CONTAINER),
148        }
149    } else {
150        ListPaint {
151            container: color(ColorRole::SURFACE),
152            headline: color(ColorRole::ON_SURFACE),
153            support: color(ColorRole::ON_SURFACE_VARIANT),
154            layer: color(ColorRole::ON_SURFACE),
155        }
156    }
157}
158
159type Click = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
160
161/// One M3 list item. Strings come from the caller. Give it `on_click` to make
162/// the whole row a button; without one it is static content.
163#[derive(gpui::IntoElement)]
164pub struct ListItem {
165    id: ElementId,
166    headline: SharedString,
167    overline: Option<SharedString>,
168    supporting: Option<SharedString>,
169    supporting_lines: usize,
170    trailing_text: Option<SharedString>,
171    leading: ListLeading,
172    trailing: ListTrailing,
173    divider: bool,
174    divider_inset: bool,
175    selected: bool,
176    disabled: bool,
177    density: ListDensity,
178    corners: Option<OuterCorners>,
179    container: ColorRole,
180    preview: Option<InteractionPreview>,
181    on_click: Option<Click>,
182}
183
184impl ListItem {
185    pub fn new(id: impl Into<ElementId>, headline: impl Into<SharedString>) -> Self {
186        Self {
187            id: id.into(),
188            headline: headline.into(),
189            overline: None,
190            supporting: None,
191            supporting_lines: 1,
192            trailing_text: None,
193            leading: ListLeading::None,
194            trailing: ListTrailing::None,
195            divider: false,
196            divider_inset: false,
197            selected: false,
198            disabled: false,
199            density: ListDensity::Standard,
200            corners: None,
201            container: ColorRole::SURFACE,
202            preview: None,
203            on_click: None,
204        }
205    }
206    /// A label-medium line above the headline.
207    pub fn overline(mut self, text: impl Into<SharedString>) -> Self {
208        self.overline = Some(text.into());
209        self
210    }
211    /// Body-medium text under the headline, on one line (ellipsized).
212    pub fn supporting(mut self, text: impl Into<SharedString>) -> Self {
213        self.supporting = Some(text.into());
214        self
215    }
216    /// Let the supporting text wrap to two lines: the three-line item.
217    pub fn three_line(mut self, three_line: bool) -> Self {
218        self.supporting_lines = if three_line { 2 } else { 1 };
219        self
220    }
221    /// Label-small text before the trailing element (e.g. "100+").
222    pub fn trailing_text(mut self, text: impl Into<SharedString>) -> Self {
223        self.trailing_text = Some(text.into());
224        self
225    }
226    pub fn leading(mut self, leading: ListLeading) -> Self {
227        self.leading = leading;
228        self
229    }
230    pub fn trailing(mut self, trailing: ListTrailing) -> Self {
231        self.trailing = trailing;
232        self
233    }
234    /// A full-width outline-variant divider under the item.
235    pub fn divider(mut self, divider: bool) -> Self {
236        self.divider = divider;
237        self
238    }
239    /// Inset the divider 16dp from the start edge (the design kit's
240    /// `Horizontal/Inset` divider) instead of running full width.
241    pub fn divider_inset(mut self, inset: bool) -> Self {
242        self.divider_inset = inset;
243        self
244    }
245    pub fn selected(mut self, selected: bool) -> Self {
246        self.selected = selected;
247        self
248    }
249    pub fn disabled(mut self, disabled: bool) -> Self {
250        self.disabled = disabled;
251        self
252    }
253    /// The resting container role (surface by default), e.g. a dialog's
254    /// surface-container-high so items sit on it. Selected rows keep the
255    /// secondary container.
256    pub fn container(mut self, role: ColorRole) -> Self {
257        self.container = role;
258        self
259    }
260    pub fn density(mut self, density: ListDensity) -> Self {
261        self.density = density;
262        self
263    }
264    /// Round the item as a segment of an Expressive segmented list
265    /// (top-left, top-right, bottom-right, bottom-left; true = outside).
266    pub fn segment(mut self, corners: OuterCorners) -> Self {
267        self.corners = Some(corners);
268        self
269    }
270    /// Render a static specimen in one forced interaction state: the state
271    /// layer, the inner focus ring or the disabled row. See
272    /// [`InteractionPreview`].
273    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
274        self.preview = Some(state);
275        self
276    }
277    /// Make the whole row a button. Rows with a checkbox, radio or switch
278    /// toggle it here: the embedded control only shows the state.
279    pub fn on_click(mut self, f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
280        self.on_click = Some(Rc::new(f));
281        self
282    }
283
284    /// The lines this item occupies.
285    pub fn lines(&self) -> ListLines {
286        ListLines::of(
287            self.overline.is_some(),
288            self.supporting.is_some(),
289            self.supporting_lines,
290        )
291    }
292}
293
294/// The radii of a segment with `outer` corners.
295fn segment_radii(outer: OuterCorners) -> Corners<Pixels> {
296    let radius = |outside: bool| {
297        if outside {
298            LIST_SEGMENT_RADIUS
299        } else {
300            LIST_SEGMENT_INNER_RADIUS
301        }
302    };
303    Corners {
304        top_left: radius(outer[0]),
305        top_right: radius(outer[1]),
306        bottom_right: radius(outer[2]),
307        bottom_left: radius(outer[3]),
308    }
309}
310
311fn typed<E: Styled>(element: E, role: TypeRole) -> E {
312    let scale = role.scale();
313    element
314        .font_family(scale.font_family)
315        .text_size(scale.font_size)
316        .line_height(scale.line_height)
317        .font_weight(FontWeight(scale.font_weight as f32))
318}
319
320/// A 24dp slot that centers a 48dp control target on the item's ruler.
321fn control_slot(width: f32, control: impl IntoElement) -> Div {
322    div()
323        .flex()
324        .flex_shrink_0()
325        .items_center()
326        .justify_center()
327        .w(rem(width))
328        .h(rem(24.))
329        .child(control)
330}
331
332/// The preview an embedded control shows: the row's own disabled state,
333/// otherwise enabled at rest. Embedded controls never take focus.
334fn control_preview(disabled: bool) -> InteractionPreview {
335    if disabled {
336        InteractionPreview::Disabled
337    } else {
338        InteractionPreview::Enabled
339    }
340}
341
342/// The selection control embedded in a list item.
343#[derive(Clone, Copy, Debug, PartialEq, Eq)]
344enum Control {
345    Checkbox,
346    Radio,
347    Switch,
348}
349
350fn control(
351    id: &'static str,
352    kind: Control,
353    label: &SharedString,
354    on: bool,
355    disabled: bool,
356) -> AnyElement {
357    let preview = control_preview(disabled);
358    match kind {
359        Control::Checkbox => control_slot(
360            24.,
361            MaterialCheckbox::new(id, label.clone())
362                .state(if on {
363                    CheckboxState::Checked
364                } else {
365                    CheckboxState::Unchecked
366                })
367                .preview_state(preview),
368        )
369        .into_any_element(),
370        Control::Radio => control_slot(
371            24.,
372            MaterialRadio::new(id, label.clone())
373                .selected(on)
374                .preview_state(preview),
375        )
376        .into_any_element(),
377        Control::Switch => div()
378            .flex_shrink_0()
379            .child(
380                MaterialSwitch::new(id, label.clone())
381                    .checked(on)
382                    .preview_state(preview),
383            )
384            .into_any_element(),
385    }
386}
387
388/// A placeholder or caller thumbnail of `width`×`height` dp.
389fn thumbnail(width: f32, height: f32, content: Option<AnyElement>) -> Div {
390    div()
391        .flex_shrink_0()
392        .w(rem(width))
393        .h(rem(height))
394        .overflow_hidden()
395        .bg(color(ColorRole::SURFACE_CONTAINER_HIGH))
396        .children(content)
397}
398
399impl ListItem {
400    fn leading_element(&mut self, ink: Rgba, disabled: bool) -> Option<AnyElement> {
401        let label = self.headline.clone();
402        let leading = std::mem::replace(&mut self.leading, ListLeading::None);
403        Some(match leading {
404            ListLeading::None => return None,
405            ListLeading::Monogram(text) => typed(
406                div()
407                    .flex()
408                    .flex_shrink_0()
409                    .items_center()
410                    .justify_center()
411                    .size(rem(40.))
412                    .rounded_full()
413                    .bg(if disabled {
414                        color(ColorRole::ON_SURFACE).opacity(state::DISABLED_CONTAINER)
415                    } else {
416                        color(ColorRole::PRIMARY_CONTAINER)
417                    })
418                    .text_color(if disabled {
419                        ink
420                    } else {
421                        color(ColorRole::ON_PRIMARY_CONTAINER)
422                    }),
423                TypeRole::TitleMedium,
424            )
425            .child(text)
426            .into_any_element(),
427            ListLeading::Icon(icon) => div()
428                .flex_shrink_0()
429                .size(rem(24.))
430                .child(icon.size(rem(24.)).color(ink))
431                .into_any_element(),
432            ListLeading::Image(content) => thumbnail(56., 56., content).into_any_element(),
433            ListLeading::Video(content) => thumbnail(114., 64., content).into_any_element(),
434            ListLeading::Checkbox(checked) => {
435                control("checkbox", Control::Checkbox, &label, checked, disabled)
436            }
437            ListLeading::Radio(selected) => {
438                control("radio", Control::Radio, &label, selected, disabled)
439            }
440            ListLeading::Switch(checked) => {
441                control("switch", Control::Switch, &label, checked, disabled)
442            }
443        })
444    }
445
446    fn trailing_element(&mut self, ink: Rgba, disabled: bool) -> Option<AnyElement> {
447        let label = self.headline.clone();
448        let trailing = std::mem::replace(&mut self.trailing, ListTrailing::None);
449        Some(match trailing {
450            ListTrailing::None => return None,
451            ListTrailing::Icon(icon) => div()
452                .flex_shrink_0()
453                .size(rem(24.))
454                .child(icon.size(rem(24.)).color(ink))
455                .into_any_element(),
456            ListTrailing::Checkbox(checked) => control(
457                "trailing-checkbox",
458                Control::Checkbox,
459                &label,
460                checked,
461                disabled,
462            ),
463            ListTrailing::Radio(selected) => {
464                control("trailing-radio", Control::Radio, &label, selected, disabled)
465            }
466            ListTrailing::Switch(checked) => control(
467                "trailing-switch",
468                Control::Switch,
469                &label,
470                checked,
471                disabled,
472            ),
473        })
474    }
475}
476
477impl ListItem {
478    /// The row's toggle for assistive technology: a checkbox or switch is
479    /// toggled; a radio is announced through the selected state.
480    fn toggle_state(&self) -> (Option<bool>, bool) {
481        let toggled = match (&self.leading, &self.trailing) {
482            (ListLeading::Checkbox(on) | ListLeading::Switch(on), _)
483            | (_, ListTrailing::Checkbox(on) | ListTrailing::Switch(on)) => Some(*on),
484            _ => None,
485        };
486        let radio = matches!(
487            (&self.leading, &self.trailing),
488            (ListLeading::Radio(true), _) | (_, ListTrailing::Radio(true))
489        );
490        (toggled, radio)
491    }
492}
493
494impl RenderOnce for ListItem {
495    fn render(mut self, _: &mut Window, _: &mut App) -> impl IntoElement {
496        let preview = self.preview;
497        let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
498        let lines = self.lines();
499        let (min_height, pad_y) = row_metrics(lines, self.density);
500        let mut paint = list_paint(self.selected, disabled);
501        if !self.selected {
502            paint.container = color(self.container);
503        }
504        let (toggled, radio) = self.toggle_state();
505        let selected = self.selected || radio;
506        let description = self.supporting.clone();
507        let leading = self.leading_element(paint.support, disabled);
508        let trailing = self.trailing_element(paint.support, disabled);
509        let top_aligned = lines == ListLines::Three;
510        let supporting_lines = self.supporting_lines;
511
512        let text = div()
513            .flex()
514            .flex_col()
515            .flex_1()
516            .min_w_0()
517            .when_some(self.overline.clone(), |column, overline| {
518                column.child(
519                    typed(div().id("overline"), TypeRole::LabelMedium)
520                        .text_color(paint.support)
521                        .truncate()
522                        .child(overline),
523                )
524            })
525            .child(
526                typed(div().id("headline"), TypeRole::BodyLarge)
527                    .text_color(paint.headline)
528                    .truncate()
529                    .child(self.headline.clone()),
530            )
531            .when_some(self.supporting.clone(), |column, supporting| {
532                column.child(
533                    typed(div().id("supporting"), TypeRole::BodyMedium)
534                        .text_color(paint.support)
535                        .whitespace_normal()
536                        .line_clamp(supporting_lines)
537                        .text_ellipsis()
538                        .child(supporting),
539                )
540            });
541
542        let trailing_text = self.trailing_text.clone().map(|value| {
543            typed(div().flex_shrink_0(), TypeRole::LabelSmall)
544                .text_color(paint.support)
545                .child(value)
546        });
547
548        let row = div()
549            .flex()
550            .flex_1()
551            .w_full()
552            .min_w_0()
553            .gap(rem(16.))
554            .px(rem(16.))
555            .py(rem(pad_y))
556            .min_h(rem(min_height))
557            .map(|row| {
558                if top_aligned {
559                    row.items_start()
560                } else {
561                    row.items_center()
562                }
563            })
564            .children(leading)
565            .child(text)
566            .when(trailing_text.is_some() || trailing.is_some(), |row| {
567                row.child(
568                    div()
569                        .flex()
570                        .flex_shrink_0()
571                        .items_center()
572                        .gap(rem(10.))
573                        .children(trailing_text)
574                        .children(trailing),
575                )
576            });
577
578        let radii = self.corners.map(segment_radii).unwrap_or_default();
579        let divider = self.divider;
580        let inset = self.divider_inset;
581        let container = paint.container;
582        let decorate = move |element: Div| {
583            element
584                .w_full()
585                .min_w_0()
586                .flex()
587                .flex_col()
588                .bg(container)
589                .rounded_tl(radii.top_left)
590                .rounded_tr(radii.top_right)
591                .rounded_br(radii.bottom_right)
592                .rounded_bl(radii.bottom_left)
593                .when(divider && !inset, |item| {
594                    item.border_b_1()
595                        .border_color(color(ColorRole::OUTLINE_VARIANT))
596                })
597        };
598        let inset_line = (divider && inset).then(inset_divider);
599
600        let Some(on_click) = self.on_click.take() else {
601            // Static content: a plain row that still shows a forced state's
602            // layer for specimens.
603            let layer = preview.map(|p| p.layer_opacity()).unwrap_or(0.);
604            return decorate(div())
605                .id(self.id)
606                .when(layer > 0. && !disabled, |item| {
607                    item.bg(state::layer(container, paint.layer, layer))
608                })
609                .relative()
610                .child(row)
611                .children(inset_line)
612                .into_any_element();
613        };
614
615        let (hover, press) = (
616            state::hovered(container, paint.layer),
617            state::pressed(container, paint.layer),
618        );
619        let mut button = RingedButton::new(self.id, RingPlacement::Inner(radii))
620            .map(|button| {
621                button
622                    .w_full()
623                    .min_w_0()
624                    .flex()
625                    .flex_col()
626                    .items_stretch()
627                    .text_left()
628                    .whitespace_normal()
629                    .bg(container)
630                    .rounded_tl(radii.top_left)
631                    .rounded_tr(radii.top_right)
632                    .rounded_br(radii.bottom_right)
633                    .rounded_bl(radii.bottom_left)
634                    .relative()
635                    .when(divider && !inset, |item| {
636                        item.border_b_1()
637                            .border_color(color(ColorRole::OUTLINE_VARIANT))
638                    })
639            })
640            .accessibility_label(self.headline)
641            // The row announces its state and supporting text, not only the
642            // headline.
643            .map(|button| {
644                button
645                    .aria_selected(selected)
646                    .when_some(toggled, |button, on| {
647                        button.aria_toggled(if on {
648                            gpui::accesskit::Toggled::True
649                        } else {
650                            gpui::accesskit::Toggled::False
651                        })
652                    })
653                    .when_some(description, |button, text| button.aria_description(text))
654            })
655            .state_layers(hover, press)
656            .disabled(disabled)
657            .on_click(move |event, window, cx| on_click(event, window, cx))
658            .child(row)
659            .children(inset_line);
660        if let Some(state) = preview {
661            button = button.preview_state(state);
662        }
663        button.into_any_element()
664    }
665}
666
667/// The design kit's inset divider under an item: 1dp outline-variant,
668/// 16dp in from the start edge, over the item's bottom edge.
669fn inset_divider() -> Div {
670    div()
671        .absolute()
672        .left(rem(16.))
673        .right_0()
674        .bottom_0()
675        .h(px(1.))
676        .bg(color(ColorRole::OUTLINE_VARIANT))
677}
678
679/// A list subheader: title-small on-surface-variant text heading a group
680/// of items, aligned with their 16dp padding, 48dp tall.
681pub fn list_subheader(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Stateful<Div> {
682    typed(div().id(id), TypeRole::TitleSmall)
683        .role(Role::Heading)
684        .flex()
685        .items_center()
686        .min_h(rem(48.))
687        .px(rem(16.))
688        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
689        .child(text.into())
690}
691
692/// An Expressive segmented list: each item on its own surface, 2dp apart,
693/// with 16dp outer and 4dp inner corners.
694pub fn segmented_list(items: impl IntoIterator<Item = ListItem>) -> Div {
695    let items: Vec<ListItem> = items.into_iter().collect();
696    let count = items.len();
697    div()
698        .flex()
699        .flex_col()
700        .w_full()
701        .gap(LIST_SEGMENT_GAP)
702        .children(
703            items
704                .into_iter()
705                .enumerate()
706                .map(move |(index, item)| item.segment(list_corners(index, count))),
707        )
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::components::focus::test_rings;
714    use core::prelude::v1::test;
715    use gpui::{Context, TestAppContext, VisualTestContext};
716    use std::cell::Cell;
717
718    #[test]
719    fn lines_follow_the_overline_and_supporting_text() {
720        assert_eq!(ListLines::of(false, false, 1), ListLines::One);
721        assert_eq!(ListLines::of(false, true, 1), ListLines::Two);
722        assert_eq!(ListLines::of(true, false, 1), ListLines::Two);
723        assert_eq!(ListLines::of(false, true, 2), ListLines::Three);
724        assert_eq!(ListLines::of(true, true, 1), ListLines::Three);
725    }
726
727    #[test]
728    fn heights_match_m3_and_the_kit_density() {
729        assert_eq!(row_metrics(ListLines::One, ListDensity::Standard).0, 56.);
730        assert_eq!(row_metrics(ListLines::Two, ListDensity::Standard).0, 72.);
731        assert_eq!(row_metrics(ListLines::Three, ListDensity::Standard).0, 88.);
732        assert_eq!(row_metrics(ListLines::One, ListDensity::Compact).0, 40.);
733        assert_eq!(row_metrics(ListLines::Two, ListDensity::Compact).0, 56.);
734        assert_eq!(row_metrics(ListLines::Three, ListDensity::Compact).0, 72.);
735    }
736
737    #[test]
738    fn selected_and_disabled_rows_use_their_roles() {
739        let enabled = list_paint(false, false);
740        assert_eq!(enabled.container, color(ColorRole::SURFACE));
741        assert_eq!(enabled.headline, color(ColorRole::ON_SURFACE));
742        assert_eq!(enabled.support, color(ColorRole::ON_SURFACE_VARIANT));
743        let selected = list_paint(true, false);
744        assert_eq!(selected.container, color(ColorRole::SECONDARY_CONTAINER));
745        assert_eq!(selected.headline, color(ColorRole::ON_SECONDARY_CONTAINER));
746        let disabled = list_paint(false, true);
747        assert_eq!(
748            disabled.headline,
749            state::disabled(color(ColorRole::ON_SURFACE))
750        );
751    }
752
753    #[test]
754    fn segments_round_only_their_outer_corners() {
755        let radii = segment_radii(list_corners(0, 3));
756        assert_eq!(radii.top_left, LIST_SEGMENT_RADIUS);
757        assert_eq!(radii.bottom_left, LIST_SEGMENT_INNER_RADIUS);
758        let last = segment_radii(list_corners(2, 3));
759        assert_eq!(last.bottom_right, LIST_SEGMENT_RADIUS);
760        assert_eq!(last.top_right, LIST_SEGMENT_INNER_RADIUS);
761    }
762
763    struct Harness {
764        clicks: Rc<Cell<usize>>,
765        disabled: bool,
766        checked: bool,
767    }
768    impl Render for Harness {
769        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
770            window.set_rem_size(px(16.));
771            let clicks = self.clicks.clone();
772            div()
773                .w(px(360.))
774                .child(
775                    ListItem::new("item", "List item")
776                        .supporting("Supporting")
777                        .leading(ListLeading::Monogram("A".into()))
778                        .trailing(ListTrailing::Switch(self.checked))
779                        .disabled(self.disabled)
780                        .on_click(cx.listener(move |this, _, _, cx| {
781                            clicks.set(clicks.get() + 1);
782                            this.checked = !this.checked;
783                            cx.notify();
784                        })),
785                )
786                .child(
787                    div()
788                        .debug_selector(|| "one".into())
789                        .child(ListItem::new("one", "One")),
790                )
791                .child(
792                    div().debug_selector(|| "three".into()).child(
793                        ListItem::new("three", "Three")
794                            .supporting("Short")
795                            .three_line(true),
796                    ),
797                )
798        }
799    }
800
801    fn draw(cx: &mut VisualTestContext) {
802        cx.update(|window, cx| {
803            window.refresh();
804            window.draw(cx).clear(cx);
805        });
806    }
807
808    #[gpui::test]
809    fn the_row_toggles_its_switch_and_ignores_input_while_disabled(cx: &mut TestAppContext) {
810        cx.update(gpui_base::init);
811        let clicks = Rc::new(Cell::new(0));
812        let (view, cx) = cx.add_window_view({
813            let clicks = clicks.clone();
814            move |_, _| Harness {
815                clicks,
816                disabled: false,
817                checked: false,
818            }
819        });
820        draw(cx);
821        let three = cx.debug_bounds("three").unwrap();
822        let one = cx.debug_bounds("one").unwrap();
823        // The test text system's line boxes differ from the platform's, so
824        // only the minimum heights are exact here.
825        assert!(
826            one.size.height >= px(56.),
827            "one-line items are at least 56dp"
828        );
829        assert!(
830            three.size.height >= px(88.),
831            "three-line items are at least 88dp"
832        );
833        let row = point(px(100.), one.top() - px(20.));
834        cx.simulate_click(row, Modifiers::default());
835        draw(cx);
836        assert_eq!(clicks.get(), 1);
837        assert!(view.read_with(cx, |v, _| v.checked));
838        // Keyboard: the row is one tab stop with the inner ring.
839        cx.update(|window, cx| window.focus_next(cx));
840        let keystroke =
841            cx.update(|_, cx| gpui::Keystroke::parse_with_os("a", cx.platform_info().os).unwrap());
842        cx.simulate_event(gpui::KeyDownEvent {
843            keystroke,
844            is_held: false,
845            prefer_character_input: false,
846        });
847        draw(cx);
848        assert!(test_rings::painted("item").is_some());
849        view.update(cx, |v, cx| {
850            v.disabled = true;
851            cx.notify();
852        });
853        draw(cx);
854        cx.simulate_click(row, Modifiers::default());
855        draw(cx);
856        assert_eq!(clicks.get(), 1, "a disabled row is inert");
857    }
858
859    struct Dividers;
860    impl Render for Dividers {
861        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
862            window.set_rem_size(px(14.));
863            div()
864                .w(px(360.))
865                .child(list_subheader("subheader", "Today").debug_selector(|| "subheader".into()))
866                .child(
867                    ListItem::new("inset", "Inset")
868                        .divider(true)
869                        .divider_inset(true),
870                )
871        }
872    }
873
874    #[gpui::test]
875    fn inset_dividers_start_16dp_in_under_a_48dp_subheader(cx: &mut TestAppContext) {
876        let (_, cx) = cx.add_window_view(|_, _| Dividers);
877        cx.run_until_parked();
878        cx.update(|window, cx| {
879            window.refresh();
880            window.draw(cx).clear(cx);
881        });
882        let subheader = cx.debug_bounds("subheader").unwrap();
883        assert_eq!(subheader.size.height, px(48.));
884        cx.update(|window, _| {
885            let scale = window.scale_factor();
886            let line = window
887                .painted_quads()
888                .into_iter()
889                .find(|quad| quad.bounds.size.height.0 == scale)
890                .expect("the inset divider paints a 1dp line");
891            assert_eq!(line.bounds.origin.x.0, 16. * scale);
892            assert_eq!(line.bounds.size.width.0, (360. - 16.) * scale);
893            // Under the 48dp subheader and the 56dp one-line item.
894            assert_eq!(line.bounds.origin.y.0, (48. + 56. - 1.) * scale);
895            assert_eq!(line.border_widths.bottom.0, 0., "a fill, not the border");
896        });
897    }
898}