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 corner_radii = self.style.corner_radii.clone();
181
182        let mut selected_style = StyleRefinement::default();
183        selected_style.corner_radii = corner_radii;
184
185        let is_selectable = !(self.disabled || self.mode.is_separator());
186
187        self.base
188            .test_support()
189            .relative()
190            .gap_x_1()
191            .py_1()
192            .px_3()
193            .text_base()
194            .text_color(cx.theme().foreground)
195            .relative()
196            .items_center()
197            .justify_between()
198            .refine_style(&self.style)
199            .when(is_selectable, |this| {
200                this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
201                    .when_some(self.on_mouse_enter, |this, on_mouse_enter| {
202                        this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
203                    })
204                    .map(|this| {
205                        self.on_mouse_down
206                            .into_iter()
207                            .fold(this, |this, (button, handler)| {
208                                this.on_mouse_down(button, move |ev, window, cx| {
209                                    handler(ev, window, cx)
210                                })
211                            })
212                    })
213                    .when(!is_active, |this| {
214                        this.hover(|this| this.bg(cx.theme().tokens.list_hover))
215                    })
216            })
217            .when(!is_selectable, |this| {
218                this.text_color(cx.theme().muted_foreground)
219            })
220            .child(
221                h_flex()
222                    .w_full()
223                    .items_center()
224                    .justify_between()
225                    .gap_x_1()
226                    .child(div().w_full().children(self.children))
227                    .when_some(self.check_icon, |this, icon| {
228                        this.child(
229                            div().w_5().items_center().justify_center().when(
230                                self.confirmed,
231                                |this| {
232                                    this.child(icon.small().text_color(cx.theme().muted_foreground))
233                                },
234                            ),
235                        )
236                    }),
237            )
238            .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
239            .map(|this| {
240                if is_selectable && (self.selected || self.secondary_selected) {
241                    let bg = if self.selected && cx.theme().list.active_highlight {
242                        cx.theme().list_active
243                    } else {
244                        cx.theme().accent
245                    };
246
247                    this.when(!self.secondary_selected, |this| this.bg(bg))
248                        .when(cx.theme().list.active_highlight, |this| {
249                            this.child(
250                                div()
251                                    .absolute()
252                                    .top_0()
253                                    .left_0()
254                                    .right_0()
255                                    .bottom_0()
256                                    .border_1()
257                                    .border_color(cx.theme().list_active_border)
258                                    .refine_style(&selected_style),
259                            )
260                        })
261                } else {
262                    this
263                }
264            })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use gpui::{AppContext as _, Context, Render};
272
273    struct DragPreview;
274
275    impl Render for DragPreview {
276        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
277            div()
278        }
279    }
280
281    #[gpui::test]
282    fn test_list_item_interactivity(_cx: &mut gpui::TestAppContext) {
283        let mut item = ListItem::new("item")
284            .on_drag(DragPreview, |_, _, _, cx| cx.new(|_| DragPreview))
285            .drag_over::<DragPreview>(|style, _, _, _| style)
286            .on_drop(|_: &DragPreview, _, _| {})
287            .on_hover(|_, _, _| {});
288
289        assert_eq!(item.interactivity().element_id, Some("item".into()));
290    }
291}