Skip to main content

gpui_component/list/
list_item.rs

1use crate::{ActiveTheme, Disableable, Icon, Selectable, Sizable as _, StyledExt, h_flex};
2use gpui::{
3    AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement,
4    MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, RenderOnce, SharedString, Stateful,
5    StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
6};
7use gpui_base::TestSupportExt as _;
8use smallvec::SmallVec;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12enum ListItemMode {
13    #[default]
14    Entry,
15    Separator,
16}
17
18impl ListItemMode {
19    #[inline]
20    fn is_separator(&self) -> bool {
21        matches!(self, ListItemMode::Separator)
22    }
23}
24
25#[derive(IntoElement)]
26pub struct ListItem {
27    base: Stateful<Div>,
28    mode: ListItemMode,
29    style: StyleRefinement,
30    disabled: bool,
31    selected: bool,
32    secondary_selected: bool,
33    confirmed: bool,
34    check_icon: Option<Icon>,
35    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
36    on_mouse_down:
37        HashMap<MouseButton, Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
38    on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
39    suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
40    children: SmallVec<[AnyElement; 2]>,
41    accessibility_label: Option<SharedString>,
42}
43
44impl ListItem {
45    pub fn new(id: impl Into<ElementId>) -> Self {
46        let id: ElementId = id.into();
47        Self {
48            mode: ListItemMode::Entry,
49            base: h_flex().id(id),
50            style: StyleRefinement::default(),
51            disabled: false,
52            selected: false,
53            secondary_selected: false,
54            confirmed: false,
55            on_click: None,
56            on_mouse_down: HashMap::new(),
57            on_mouse_enter: None,
58            check_icon: None,
59            suffix: None,
60            children: SmallVec::new(),
61            accessibility_label: None,
62        }
63    }
64
65    /// Set this list item to as a separator, it not able to be selected.
66    pub fn separator(mut self) -> Self {
67        self.mode = ListItemMode::Separator;
68        self
69    }
70
71    /// Set to show check icon, default is None.
72    pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
73        self.check_icon = Some(icon.into());
74        self
75    }
76
77    /// Set ListItem as the selected item style.
78    pub fn selected(mut self, selected: bool) -> Self {
79        self.selected = selected;
80        self
81    }
82
83    /// Set ListItem as the confirmed item style, it will show a check icon.
84    pub fn confirmed(mut self, confirmed: bool) -> Self {
85        self.confirmed = confirmed;
86        self
87    }
88
89    /// Set the accessibility label of the list item.
90    ///
91    /// Without this, list rows are exposed to assistive technology without a
92    /// name — the visible children do not become the item's accessible name.
93    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
94        self.accessibility_label = Some(label.into());
95        self
96    }
97
98    pub fn disabled(mut self, disabled: bool) -> Self {
99        self.disabled = disabled;
100        self
101    }
102
103    /// Set the suffix element of the input field, for example a clear button.
104    pub fn suffix<F, E>(mut self, builder: F) -> Self
105    where
106        F: Fn(&mut Window, &mut App) -> E + 'static,
107        E: IntoElement,
108    {
109        self.suffix = Some(Box::new(move |window, cx| {
110            builder(window, cx).into_any_element()
111        }));
112        self
113    }
114
115    pub fn on_click(
116        mut self,
117        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
118    ) -> Self {
119        self.on_click = Some(Box::new(handler));
120        self
121    }
122
123    pub fn on_mouse_down(
124        mut self,
125        button: MouseButton,
126        handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
127    ) -> Self {
128        self.on_mouse_down.insert(button, Box::new(handler));
129        self
130    }
131
132    pub fn on_mouse_enter(
133        mut self,
134        handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static,
135    ) -> Self {
136        self.on_mouse_enter = Some(Box::new(handler));
137        self
138    }
139}
140
141impl Disableable for ListItem {
142    fn disabled(mut self, disabled: bool) -> Self {
143        self.disabled = disabled;
144        self
145    }
146}
147
148impl Selectable for ListItem {
149    fn selected(mut self, selected: bool) -> Self {
150        self.selected = selected;
151        self
152    }
153
154    fn is_selected(&self) -> bool {
155        self.selected
156    }
157
158    fn secondary_selected(mut self, selected: bool) -> Self {
159        self.secondary_selected = selected;
160        self
161    }
162}
163
164impl Styled for ListItem {
165    fn style(&mut self) -> &mut gpui::StyleRefinement {
166        &mut self.style
167    }
168}
169
170impl ParentElement for ListItem {
171    fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
172        self.children.extend(elements);
173    }
174}
175
176/// Note: Listeners registered via these traits are not gated by
177/// `disabled`/`separator`. The hover style is managed internally, use
178/// `on_hover` instead of `.hover()`.
179impl InteractiveElement for ListItem {
180    fn interactivity(&mut self) -> &mut Interactivity {
181        self.base.interactivity()
182    }
183}
184
185impl StatefulInteractiveElement for ListItem {}
186
187impl RenderOnce for ListItem {
188    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
189        let accessibility_label = self.accessibility_label;
190        let is_active = self.confirmed || self.selected || self.secondary_selected;
191
192        let is_selectable = !(self.disabled || self.mode.is_separator());
193
194        // The outline is an absolute child, so it has to repeat the item's own radius.
195        let mut outline_style = StyleRefinement::default();
196        outline_style.corner_radii = self.style.corner_radii.clone();
197
198        self.base
199            .test_support()
200            .relative()
201            .gap_x_1()
202            .py_1()
203            .px_3()
204            .text_base()
205            .text_color(cx.theme().foreground)
206            .relative()
207            .items_center()
208            .justify_between()
209            .refine_style(&self.style)
210            .when_some(accessibility_label, |this, label| this.aria_label(label))
211            .when(is_selectable, |this| {
212                this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
213                    .when_some(self.on_mouse_enter, |this, on_mouse_enter| {
214                        this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
215                    })
216                    .map(|this| {
217                        self.on_mouse_down
218                            .into_iter()
219                            .fold(this, |this, (button, handler)| {
220                                this.on_mouse_down(button, move |ev, window, cx| {
221                                    handler(ev, window, cx)
222                                })
223                            })
224                    })
225                    // Register `hover` unconditionally, a conditional registration
226                    // leaves a stale hover style behind when the item turns active.
227                    .hover(|this| {
228                        if is_active {
229                            this
230                        } else {
231                            this.bg(cx.theme().tokens.list_hover)
232                        }
233                    })
234            })
235            .when(!is_selectable, |this| {
236                this.text_color(cx.theme().muted_foreground)
237            })
238            .child(
239                h_flex()
240                    .w_full()
241                    .items_center()
242                    .justify_between()
243                    .gap_x_1()
244                    .child(div().w_full().children(self.children))
245                    .when_some(self.check_icon, |this, icon| {
246                        this.child(
247                            div().w_5().items_center().justify_center().when(
248                                self.confirmed,
249                                |this| {
250                                    this.child(icon.small().text_color(cx.theme().muted_foreground))
251                                },
252                            ),
253                        )
254                    }),
255            )
256            .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
257            .when(is_selectable && self.selected, |this| {
258                let bg = if cx.theme().list.active_highlight {
259                    cx.theme().list_active
260                } else {
261                    cx.theme().accent
262                };
263
264                this.bg(bg)
265            })
266            .when(is_selectable && self.secondary_selected, |this| {
267                this.child(
268                    div()
269                        .absolute()
270                        .inset_0()
271                        .border_1()
272                        .border_color(cx.theme().selection)
273                        .refine_style(&outline_style),
274                )
275            })
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use gpui::{AppContext as _, Context, Render};
283
284    struct DragPreview;
285
286    impl Render for DragPreview {
287        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
288            div()
289        }
290    }
291
292    #[gpui::test]
293    fn test_list_item_interactivity(_cx: &mut gpui::TestAppContext) {
294        let mut item = ListItem::new("item")
295            .on_drag(DragPreview, |_, _, _, cx| cx.new(|_| DragPreview))
296            .drag_over::<DragPreview>(|style, _, _, _| style)
297            .on_drop(|_: &DragPreview, _, _| {})
298            .on_hover(|_, _, _| {});
299
300        assert_eq!(item.interactivity().element_id, Some("item".into()));
301    }
302}