Skip to main content

gpui_cn/select/
menu.rs

1//! The open menu of a select: the panel, the search field, the rows in a
2//! virtual list, and what stands in for them while empty or loading.
3
4use 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/// The state of a row a custom item renderer draws; see
34/// [`Select::render_item`](super::Select::render_item).
35#[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    /// Whether the keyboard or the pointer is on the row.
46    pub fn is_highlighted(&self) -> bool {
47        self.highlighted
48    }
49
50    /// Whether the row's item is selected.
51    pub fn is_selected(&self) -> bool {
52        self.selected
53    }
54
55    /// Whether the row's item is disabled.
56    pub fn is_disabled(&self) -> bool {
57        self.disabled
58    }
59
60    /// The byte ranges of the label that the search matched, to draw in
61    /// the text color at medium weight. Empty without a query.
62    pub fn matched(&self) -> &[Range<usize>] {
63        &self.matched
64    }
65}
66
67/// What the rows of one menu are drawn from, owned by the list's row
68/// builder, which outlives the render that made it.
69pub(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
78/// The menu: the search field, the rows in a scroll region, or the empty
79/// or loading element.
80pub(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    /// Shapes the search field's right-click menu, as a field's does.
90    pub search_menu_builder: Option<TextMenuBuilder>,
91    /// Whether a right click on the search field opens its menu.
92    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        // While a handler is still answering, the loading element stands
131        // in for the rows, so what shows is never the answer to an older
132        // query beside a promise of the new one. Without one the last rows
133        // stay until the answer, and an empty menu shows nothing rather
134        // than "No results" it may take back.
135        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            // The fade sits on the panel itself, since GPUI shapes the
176            // shadow from the element that carries it.
177            .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                    // The search box's touch edit menu and its right-click
195                    // menu float outside the panel; a press on either is
196                    // the search's, not a close.
197                    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                    // A fixed height is what a scroll to a far row counts
319                    // on before the row is laid out.
320                    .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                    // A pointer that moves over a row highlights it; a row that
335                    // scrolls under a resting pointer does not, so the
336                    // keyboard's highlight is not snatched back. A finger has
337                    // no hover.
338                    .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
352/// The inside of an item row as the reference app draws it: the leading
353/// element, the label over its description, and the check.
354fn 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}