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 pub fn separator(mut self) -> Self {
67 self.mode = ListItemMode::Separator;
68 self
69 }
70
71 pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
73 self.check_icon = Some(icon.into());
74 self
75 }
76
77 pub fn selected(mut self, selected: bool) -> Self {
79 self.selected = selected;
80 self
81 }
82
83 pub fn confirmed(mut self, confirmed: bool) -> Self {
85 self.confirmed = confirmed;
86 self
87 }
88
89 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 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
176impl 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 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 .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}