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