1use std::ops::Range;
5use std::rc::Rc;
6
7use gpui_kit::{
8 AnyElement, App, ElementId, Entity, FocusHandle, InteractiveElement as _, IntoElement, Length,
9 MouseButton, ParentElement as _, Pixels, SharedString, StatefulInteractiveElement as _,
10 Styled as _, Window,
11 base::{TestSupportExt as _, v_flex},
12 div,
13 prelude::FluentBuilder as _,
14};
15
16use super::{
17 item::{SelectEntry, SelectItem, SelectValue},
18 state::SelectState,
19};
20use crate::{
21 ScrollArea,
22 menu::{
23 MenuLook, MenuMotion, MenuPanels, TextMenuBuilder, check_slot, label_block, line_slot,
24 open_text_menu, row_frame, row_line, search_row, separator,
25 },
26};
27
28pub(super) type ItemRenderer<V> =
29 Rc<dyn Fn(&SelectItem<V>, SelectRow, &mut Window, &mut App) -> AnyElement>;
30pub(super) type LabelRenderer = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> AnyElement>;
31pub(super) type PartRenderer = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
32
33#[derive(Clone, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct SelectRow {
38 highlighted: bool,
39 selected: bool,
40 disabled: bool,
41 matched: Vec<Range<usize>>,
42}
43
44impl SelectRow {
45 pub fn is_highlighted(&self) -> bool {
47 self.highlighted
48 }
49
50 pub fn is_selected(&self) -> bool {
52 self.selected
53 }
54
55 pub fn is_disabled(&self) -> bool {
57 self.disabled
58 }
59
60 pub fn matched(&self) -> &[Range<usize>] {
63 &self.matched
64 }
65}
66
67pub(super) struct Rows<V: SelectValue> {
70 pub id: ElementId,
71 pub state: Entity<SelectState<V>>,
72 pub look: MenuLook,
73 pub render_item: Option<ItemRenderer<V>>,
74 pub render_label: Option<LabelRenderer>,
75 pub pointer_cursors: bool,
76}
77
78pub(super) struct Menu<V: SelectValue> {
81 pub rows: Rows<V>,
82 pub content_focus: FocusHandle,
83 pub width: Option<Length>,
84 pub trigger_width: Pixels,
85 pub empty_text: SharedString,
86 pub render_empty: Option<PartRenderer>,
87 pub render_loading: Option<PartRenderer>,
88 pub motion: MenuMotion,
89 pub search_menu_builder: Option<TextMenuBuilder>,
91 pub search_menu_enabled: bool,
93}
94
95impl<V: SelectValue> Menu<V> {
96 pub fn render(self, window: &mut Window, cx: &mut App) -> AnyElement {
97 let Menu {
98 rows,
99 content_focus,
100 width,
101 trigger_width,
102 empty_text,
103 render_empty,
104 render_loading,
105 motion,
106 search_menu_builder,
107 search_menu_enabled,
108 } = self;
109 let look = rows.look.clone();
110 let look = &look;
111 let id = rows.id.clone();
112 let state = rows.state.clone();
113 let (list, row_count, row_hint, searching, search, search_menu) = {
114 let state = state.read(cx);
115 (
116 state.list().clone(),
117 state.row_count(),
118 state.row_hint(cx),
119 state.is_searching(),
120 state.search_input().cloned(),
121 state.search_menu().cloned(),
122 )
123 };
124 let search_menu_open = search_menu
125 .as_ref()
126 .is_some_and(|menu| menu.read(cx).is_open());
127 let menu_id = ElementId::NamedChild(id.clone().into(), "menu".into());
128 let rows_id = ElementId::NamedChild(id.clone().into(), "rows".into());
129 let rows_max_height = look.rows_max_height(search.is_some());
130 let loading = searching
136 .then(|| render_loading.as_ref().map(|render| render(window, cx)))
137 .flatten();
138 let body = if let Some(loading) = loading {
139 loading
140 } else if row_count == 0 {
141 match (searching, render_empty) {
142 (true, _) => div().into_any_element(),
143 (false, Some(render)) => render(window, cx),
144 (false, None) => div()
145 .py_6()
146 .text_center()
147 .text_color(look.muted_foreground)
148 .child(empty_text)
149 .into_any_element(),
150 }
151 } else {
152 ScrollArea::list(rows_id, &list, row_hint, move |row, window, cx| {
153 rows.render(row, window, cx)
154 })
155 .max_h(rows_max_height)
156 .into_any_element()
157 };
158 v_flex()
159 .id(menu_id)
160 .test_support()
161 .track_focus(&content_focus)
162 .tab_group()
163 .map(|this| match width {
164 Some(width) => this.w(width),
165 None => {
166 let min_width = if search.is_some() {
167 look.search_min_width
168 } else {
169 look.min_width
170 };
171 this.min_w(trigger_width.max(min_width))
172 .max_w(look.max_width)
173 }
174 })
175 .relative()
178 .top(motion.offset)
179 .opacity(motion.opacity)
180 .p(look.padding)
181 .rounded(look.radius)
182 .bg(look.surface)
183 .border_1()
184 .border_color(look.border)
185 .shadow(look.shadow(motion.shadow_strength))
186 .text_size(look.text_size)
187 .line_height(look.line_height)
188 .text_color(look.foreground)
189 .on_mouse_down_out({
190 let state = state.clone();
191 let search = search.clone();
192 let search_menu = search_menu.clone();
193 move |_, window, cx| {
194 let edit_menu_open = search.as_ref().is_some_and(|search| {
198 search
199 .read(cx)
200 .touch_selection()
201 .is_some_and(|selection| selection.is_menu_open())
202 }) || search_menu
203 .as_ref()
204 .is_some_and(|menu| menu.read(cx).is_open());
205 if !edit_menu_open {
206 state.update(cx, |state, cx| state.close(window, cx));
207 }
208 }
209 })
210 .when_some(search, |this, search| {
211 this.child(
212 search_row(&search, look, window, cx)
213 .id(ElementId::NamedChild(id.clone().into(), "search".into()))
214 .test_support()
215 .when_some(search_menu.filter(|_| search_menu_enabled), |this, menu| {
216 let input = search.clone();
217 let opener = menu.clone();
218 this.on_mouse_up(MouseButton::Right, move |event, window, cx| {
219 open_text_menu(
220 &opener,
221 &input,
222 event.position,
223 search_menu_builder.as_ref(),
224 window,
225 cx,
226 );
227 })
228 .when(search_menu_open, |this| {
229 this.child(MenuPanels::new(
230 ElementId::NamedChild(id.clone().into(), "search-menu".into()),
231 &menu,
232 ))
233 })
234 }),
235 )
236 .child(separator(look))
237 })
238 .child(body)
239 .into_any_element()
240 }
241}
242
243impl<V: SelectValue> Rows<V> {
244 fn render(&self, row: usize, window: &mut Window, cx: &mut App) -> AnyElement {
245 let Rows {
246 id,
247 state,
248 look,
249 render_item,
250 render_label,
251 pointer_cursors,
252 } = self;
253 let pointer_cursors = *pointer_cursors;
254 let (entry, highlighted, selected, row_height, query) = {
255 let state = state.read(cx);
256 let Some(entry) = state.entry_at(row) else {
257 return div().into_any_element();
258 };
259 let selected = entry
260 .item()
261 .is_some_and(|item| state.is_selected(item.value()));
262 (
263 entry.clone(),
264 state.is_highlighted(row),
265 selected,
266 state.row_height(),
267 state.query(cx),
268 )
269 };
270 match entry {
271 SelectEntry::Separator => separator(look),
272 SelectEntry::Label(text) => match render_label {
273 Some(render) => div()
274 .w_full()
275 .child(render(&text, window, cx))
276 .into_any_element(),
277 None => div()
278 .w_full()
279 .h(look.row_height)
280 .px(look.row_padding)
281 .flex()
282 .items_center()
283 .text_color(look.muted_foreground)
284 .child(text)
285 .into_any_element(),
286 },
287 SelectEntry::Item(item) => {
288 let disabled = item.is_disabled();
289 let value = item.value().clone();
290 let choose_state = state.clone();
291 let hover_state = state.clone();
292 let matched = crate::fuzzy::matched(&query, item.label()).unwrap_or_default();
293 let content: Vec<AnyElement> = match render_item {
294 Some(render) => vec![render(
295 &item,
296 SelectRow {
297 highlighted,
298 selected,
299 disabled,
300 matched,
301 },
302 window,
303 cx,
304 )],
305 None => default_row_content(
306 &item,
307 &ElementId::NamedChild(id.clone().into(), item.key()),
308 selected,
309 &matched,
310 look,
311 window,
312 cx,
313 ),
314 };
315 row_frame(look, highlighted && !disabled)
316 .id(ElementId::NamedChild(id.clone().into(), item.key()))
317 .test_support()
318 .when_some(row_height, |this, height| this.h(height).min_h(height))
321 .text_color(if disabled {
322 look.muted_foreground
323 } else {
324 look.foreground
325 })
326 .map(|this| {
327 if !disabled && pointer_cursors {
328 this.cursor_pointer()
329 } else {
330 this.cursor_default()
331 }
332 })
333 .children(content)
334 .when(!look.touch, |this| {
339 this.on_mouse_move(move |_, _, cx| {
340 hover_state.update(cx, |state, cx| state.highlight_row(row, cx));
341 })
342 })
343 .on_click(move |_, window, cx| {
344 choose_state.update(cx, |state, cx| state.choose(&value, window, cx));
345 })
346 .into_any_element()
347 }
348 }
349 }
350}
351
352fn default_row_content<V: SelectValue>(
355 item: &SelectItem<V>,
356 row_id: &ElementId,
357 selected: bool,
358 matched: &[Range<usize>],
359 look: &MenuLook,
360 window: &mut Window,
361 cx: &mut App,
362) -> Vec<AnyElement> {
363 let disabled = item.is_disabled();
364 let part = |name: &'static str| ElementId::NamedChild(row_id.clone().into(), name.into());
365 let leading = item.render_leading(window, cx).map(|leading| {
366 line_slot(look)
367 .id(part("leading"))
368 .test_support()
369 .child(leading)
370 });
371 let check = check_slot(look, selected, disabled);
372 vec![
373 row_line()
374 .children(leading)
375 .child(
376 div()
377 .id(part("label"))
378 .test_support()
379 .flex_1()
380 .min_w_0()
381 .child(label_block(
382 item.label().clone(),
383 matched,
384 item.description_text().cloned(),
385 look,
386 )),
387 )
388 .child(check)
389 .into_any_element(),
390 ]
391}