list_item.rs

  1use std::sync::Arc;
  2
  3use component::{Component, ComponentScope, example_group_with_title, single_example};
  4use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px};
  5use smallvec::SmallVec;
  6
  7use crate::{Disclosure, GradientFade, prelude::*};
  8
  9#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 10pub enum ListItemSpacing {
 11    #[default]
 12    Dense,
 13    ExtraDense,
 14    Sparse,
 15}
 16
 17#[derive(IntoElement, RegisterComponent)]
 18pub struct ListItem {
 19    id: ElementId,
 20    group_name: Option<SharedString>,
 21    disabled: bool,
 22    selected: bool,
 23    spacing: ListItemSpacing,
 24    indent_level: usize,
 25    indent_step_size: Pixels,
 26    /// A slot for content that appears before the children, like an icon or avatar.
 27    start_slot: Option<AnyElement>,
 28    /// A slot for content that appears after the children, usually on the other side of the header.
 29    /// This might be a button, a disclosure arrow, a face pile, etc.
 30    end_slot: Option<AnyElement>,
 31    /// A slot for content that appears on hover after the children
 32    /// It will obscure the `end_slot` when visible.
 33    end_hover_slot: Option<AnyElement>,
 34    /// When true, renders a gradient fade overlay before the `end_hover_slot`
 35    /// to smoothly truncate overflowing content.
 36    end_hover_gradient_overlay: bool,
 37    toggle: Option<bool>,
 38    inset: bool,
 39    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 40    on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
 41    on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 42    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 43    on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
 44    children: SmallVec<[AnyElement; 2]>,
 45    selectable: bool,
 46    always_show_disclosure_icon: bool,
 47    outlined: bool,
 48    rounded: bool,
 49    overflow_x: bool,
 50    focused: Option<bool>,
 51    docked_right: bool,
 52}
 53
 54impl ListItem {
 55    pub fn new(id: impl Into<ElementId>) -> Self {
 56        Self {
 57            id: id.into(),
 58            group_name: None,
 59            disabled: false,
 60            selected: false,
 61            spacing: ListItemSpacing::Dense,
 62            indent_level: 0,
 63            indent_step_size: px(12.),
 64            start_slot: None,
 65            end_slot: None,
 66            end_hover_slot: None,
 67            end_hover_gradient_overlay: false,
 68            toggle: None,
 69            inset: false,
 70            on_click: None,
 71            on_secondary_mouse_down: None,
 72            on_toggle: None,
 73            on_hover: None,
 74            tooltip: None,
 75            children: SmallVec::new(),
 76            selectable: true,
 77            always_show_disclosure_icon: false,
 78            outlined: false,
 79            rounded: false,
 80            overflow_x: false,
 81            focused: None,
 82            docked_right: false,
 83        }
 84    }
 85
 86    pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
 87        self.group_name = Some(group_name.into());
 88        self
 89    }
 90
 91    pub fn spacing(mut self, spacing: ListItemSpacing) -> Self {
 92        self.spacing = spacing;
 93        self
 94    }
 95
 96    pub fn selectable(mut self, has_hover: bool) -> Self {
 97        self.selectable = has_hover;
 98        self
 99    }
100
101    pub fn always_show_disclosure_icon(mut self, show: bool) -> Self {
102        self.always_show_disclosure_icon = show;
103        self
104    }
105
106    pub fn on_click(
107        mut self,
108        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
109    ) -> Self {
110        self.on_click = Some(Box::new(handler));
111        self
112    }
113
114    pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
115        self.on_hover = Some(Box::new(handler));
116        self
117    }
118
119    pub fn on_secondary_mouse_down(
120        mut self,
121        handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
122    ) -> Self {
123        self.on_secondary_mouse_down = Some(Box::new(handler));
124        self
125    }
126
127    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
128        self.tooltip = Some(Box::new(tooltip));
129        self
130    }
131
132    pub fn inset(mut self, inset: bool) -> Self {
133        self.inset = inset;
134        self
135    }
136
137    pub fn indent_level(mut self, indent_level: usize) -> Self {
138        self.indent_level = indent_level;
139        self
140    }
141
142    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
143        self.indent_step_size = indent_step_size;
144        self
145    }
146
147    pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
148        self.toggle = toggle.into();
149        self
150    }
151
152    pub fn on_toggle(
153        mut self,
154        on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
155    ) -> Self {
156        self.on_toggle = Some(Arc::new(on_toggle));
157        self
158    }
159
160    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
161        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
162        self
163    }
164
165    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
166        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
167        self
168    }
169
170    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
171        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
172        self
173    }
174
175    pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self {
176        self.end_hover_gradient_overlay = show;
177        self
178    }
179
180    pub fn outlined(mut self) -> Self {
181        self.outlined = true;
182        self
183    }
184
185    pub fn rounded(mut self) -> Self {
186        self.rounded = true;
187        self
188    }
189
190    pub fn overflow_x(mut self) -> Self {
191        self.overflow_x = true;
192        self
193    }
194
195    pub fn focused(mut self, focused: bool) -> Self {
196        self.focused = Some(focused);
197        self
198    }
199
200    pub fn docked_right(mut self, docked_right: bool) -> Self {
201        self.docked_right = docked_right;
202        self
203    }
204}
205
206impl Disableable for ListItem {
207    fn disabled(mut self, disabled: bool) -> Self {
208        self.disabled = disabled;
209        self
210    }
211}
212
213impl Toggleable for ListItem {
214    fn toggle_state(mut self, selected: bool) -> Self {
215        self.selected = selected;
216        self
217    }
218}
219
220impl ParentElement for ListItem {
221    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
222        self.children.extend(elements)
223    }
224}
225
226impl RenderOnce for ListItem {
227    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
228        let color = cx.theme().colors();
229
230        let base_bg = if self.selected {
231            color.element_active
232        } else {
233            color.panel_background
234        };
235
236        let end_hover_gradient_overlay =
237            GradientFade::new(base_bg, color.element_hover, color.element_active)
238                .width(px(96.0))
239                .when_some(self.group_name.clone(), |fade, group| {
240                    fade.group_name(group)
241                });
242
243        h_flex()
244            .id(self.id)
245            .when_some(self.group_name, |this, group| this.group(group))
246            .w_full()
247            .relative()
248            // When an item is inset draw the indent spacing outside of the item
249            .when(self.inset, |this| {
250                this.ml(self.indent_level as f32 * self.indent_step_size)
251                    .px(DynamicSpacing::Base04.rems(cx))
252            })
253            .when(!self.inset && !self.disabled, |this| {
254                this.when_some(self.focused, |this, focused| {
255                    if focused {
256                        this.border_1()
257                            .when(self.docked_right, |this| this.border_r_2())
258                            .border_color(cx.theme().colors().border_focused)
259                    } else {
260                        this.border_1()
261                    }
262                })
263                .when(self.selectable, |this| {
264                    this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
265                        .active(|style| style.bg(cx.theme().colors().ghost_element_active))
266                        .when(self.outlined, |this| this.rounded_sm())
267                        .when(self.selected, |this| {
268                            this.bg(cx.theme().colors().ghost_element_selected)
269                        })
270                })
271            })
272            .when(self.rounded, |this| this.rounded_sm())
273            .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
274            .child(
275                h_flex()
276                    .id("inner_list_item")
277                    .group("list_item")
278                    .w_full()
279                    .relative()
280                    .gap_1()
281                    .px(DynamicSpacing::Base06.rems(cx))
282                    .map(|this| match self.spacing {
283                        ListItemSpacing::Dense => this,
284                        ListItemSpacing::ExtraDense => this.py_neg_px(),
285                        ListItemSpacing::Sparse => this.py_1(),
286                    })
287                    .when(self.inset && !self.disabled, |this| {
288                        this
289                            // TODO: Add focus state
290                            //.when(self.state == InteractionState::Focused, |this| {
291                            .when_some(self.focused, |this, focused| {
292                                if focused {
293                                    this.border_1()
294                                        .border_color(cx.theme().colors().border_focused)
295                                } else {
296                                    this.border_1()
297                                }
298                            })
299                            .when(self.selectable, |this| {
300                                this.hover(|style| {
301                                    style.bg(cx.theme().colors().ghost_element_hover)
302                                })
303                                .active(|style| style.bg(cx.theme().colors().ghost_element_active))
304                                .when(self.selected, |this| {
305                                    this.bg(cx.theme().colors().ghost_element_selected)
306                                })
307                            })
308                    })
309                    .when_some(
310                        self.on_click.filter(|_| !self.disabled),
311                        |this, on_click| this.cursor_pointer().on_click(on_click),
312                    )
313                    .when(self.outlined, |this| {
314                        this.border_1()
315                            .border_color(cx.theme().colors().border)
316                            .rounded_sm()
317                            .overflow_hidden()
318                    })
319                    .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
320                        this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
321                            (on_mouse_down)(event, window, cx)
322                        })
323                    })
324                    .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
325                    .map(|this| {
326                        if self.inset {
327                            this.rounded_sm()
328                        } else {
329                            // When an item is not inset draw the indent spacing inside of the item
330                            this.ml(self.indent_level as f32 * self.indent_step_size)
331                        }
332                    })
333                    .children(self.toggle.map(|is_open| {
334                        div()
335                            .flex()
336                            .absolute()
337                            .left(rems(-1.))
338                            .when(is_open && !self.always_show_disclosure_icon, |this| {
339                                this.visible_on_hover("")
340                            })
341                            .child(
342                                Disclosure::new("toggle", is_open)
343                                    .on_toggle_expanded(self.on_toggle),
344                            )
345                    }))
346                    .child(
347                        h_flex()
348                            .flex_grow()
349                            .flex_shrink_0()
350                            .flex_basis(relative(0.25))
351                            .gap(DynamicSpacing::Base06.rems(cx))
352                            .map(|list_content| {
353                                if self.overflow_x {
354                                    list_content
355                                } else {
356                                    list_content.overflow_hidden()
357                                }
358                            })
359                            .children(self.start_slot)
360                            .children(self.children),
361                    )
362                    .when_some(self.end_slot, |this, end_slot| {
363                        this.justify_between().child(
364                            h_flex()
365                                .flex_shrink()
366                                .overflow_hidden()
367                                .when(self.end_hover_slot.is_some(), |this| {
368                                    this.visible()
369                                        .group_hover("list_item", |this| this.invisible())
370                                })
371                                .child(end_slot),
372                        )
373                    })
374                    .when_some(self.end_hover_slot, |this, end_hover_slot| {
375                        this.child(
376                            h_flex()
377                                .h_full()
378                                .absolute()
379                                .right(DynamicSpacing::Base06.rems(cx))
380                                .top_0()
381                                .visible_on_hover("list_item")
382                                .when(self.end_hover_gradient_overlay, |this| {
383                                    this.child(end_hover_gradient_overlay)
384                                })
385                                .child(end_hover_slot),
386                        )
387                    }),
388            )
389    }
390}
391
392impl Component for ListItem {
393    fn scope() -> ComponentScope {
394        ComponentScope::DataDisplay
395    }
396
397    fn description() -> Option<&'static str> {
398        Some(
399            "A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.",
400        )
401    }
402
403    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
404        Some(
405            v_flex()
406                .gap_6()
407                .children(vec![
408                    example_group_with_title(
409                        "Basic List Items",
410                        vec![
411                            single_example(
412                                "Simple",
413                                ListItem::new("simple")
414                                    .child(Label::new("Simple list item"))
415                                    .into_any_element(),
416                            ),
417                            single_example(
418                                "With Icon",
419                                ListItem::new("with_icon")
420                                    .start_slot(Icon::new(IconName::File))
421                                    .child(Label::new("List item with icon"))
422                                    .into_any_element(),
423                            ),
424                            single_example(
425                                "Selected",
426                                ListItem::new("selected")
427                                    .toggle_state(true)
428                                    .start_slot(Icon::new(IconName::Check))
429                                    .child(Label::new("Selected item"))
430                                    .into_any_element(),
431                            ),
432                        ],
433                    ),
434                    example_group_with_title(
435                        "List Item Spacing",
436                        vec![
437                            single_example(
438                                "Dense",
439                                ListItem::new("dense")
440                                    .spacing(ListItemSpacing::Dense)
441                                    .child(Label::new("Dense spacing"))
442                                    .into_any_element(),
443                            ),
444                            single_example(
445                                "Extra Dense",
446                                ListItem::new("extra_dense")
447                                    .spacing(ListItemSpacing::ExtraDense)
448                                    .child(Label::new("Extra dense spacing"))
449                                    .into_any_element(),
450                            ),
451                            single_example(
452                                "Sparse",
453                                ListItem::new("sparse")
454                                    .spacing(ListItemSpacing::Sparse)
455                                    .child(Label::new("Sparse spacing"))
456                                    .into_any_element(),
457                            ),
458                        ],
459                    ),
460                    example_group_with_title(
461                        "With Slots",
462                        vec![
463                            single_example(
464                                "End Slot",
465                                ListItem::new("end_slot")
466                                    .child(Label::new("Item with end slot"))
467                                    .end_slot(Icon::new(IconName::ChevronRight))
468                                    .into_any_element(),
469                            ),
470                            single_example(
471                                "With Toggle",
472                                ListItem::new("with_toggle")
473                                    .toggle(Some(true))
474                                    .child(Label::new("Expandable item"))
475                                    .into_any_element(),
476                            ),
477                        ],
478                    ),
479                    example_group_with_title(
480                        "States",
481                        vec![
482                            single_example(
483                                "Disabled",
484                                ListItem::new("disabled")
485                                    .disabled(true)
486                                    .child(Label::new("Disabled item"))
487                                    .into_any_element(),
488                            ),
489                            single_example(
490                                "Non-selectable",
491                                ListItem::new("non_selectable")
492                                    .selectable(false)
493                                    .child(Label::new("Non-selectable item"))
494                                    .into_any_element(),
495                            ),
496                        ],
497                    ),
498                ])
499                .into_any_element(),
500        )
501    }
502}