Skip to main content

gpui_cn/composer/
status_select.rs

1use std::{cell::RefCell, collections::HashMap, rc::Rc};
2
3use gpui_kit::{
4    AnyElement, App, AppContext as _, Context, ElementId, Entity, EventEmitter,
5    InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
6    StyleRefinement, Styled, Subscription, Task, Window,
7    base::{Align, StyledExt as _, h_flex},
8    div,
9    prelude::FluentBuilder as _,
10    px, rems,
11};
12
13use crate::{
14    ActiveTheme as _, ButtonSize, Icon, Select, SelectEntry, SelectEvent, SelectItem, SelectState,
15    TooltipExt as _,
16    collapse::{self, Need},
17    fuzzy,
18    menu::{MenuLook, check_slot, label_block, line_slot},
19    middle_text::MiddleText,
20};
21
22/// How many options make a [`StatusSelect`] searchable when it is not told
23/// either way: three. A list of two, such as a device, is a plain menu; a
24/// list of three or more, such as projects and branches, has a search
25/// field and ranks what is typed.
26pub const SEARCH_MIN_OPTIONS: usize = 3;
27
28/// One choice of a [`StatusSelect`]: an id from the domain, a label, and
29/// optionally a leading icon, a line of description in a menu, and a short
30/// muted word at the right of a search list row, such as "current" or
31/// "remote".
32///
33/// ```
34/// use gpui_cn::StatusOption;
35///
36/// let option = StatusOption::new("main", "main").description("The default branch");
37/// assert_eq!(option.label().as_ref(), "main");
38/// ```
39#[derive(Clone)]
40#[non_exhaustive]
41pub struct StatusOption {
42    id: SharedString,
43    label: SharedString,
44    description: Option<SharedString>,
45    trailing: Option<SharedString>,
46    icon: Option<Icon>,
47}
48
49impl StatusOption {
50    /// An option with a stable id and the label it shows.
51    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52        Self {
53            id: id.into(),
54            label: label.into(),
55            description: None,
56            trailing: None,
57            icon: None,
58        }
59    }
60
61    /// A short muted word at the right of the row in a search list, such
62    /// as "current".
63    pub fn trailing(mut self, trailing: impl Into<SharedString>) -> Self {
64        self.trailing = Some(trailing.into());
65        self
66    }
67
68    /// An icon before the label in the list.
69    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
70        self.icon = Some(icon.into());
71        self
72    }
73
74    /// The word at the right of the row, when there is one.
75    pub fn trailing_label(&self) -> Option<&SharedString> {
76        self.trailing.as_ref()
77    }
78
79    /// A line under the label in the menu.
80    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
81        self.description = Some(description.into());
82        self
83    }
84
85    /// The id.
86    pub fn id(&self) -> &SharedString {
87        &self.id
88    }
89
90    /// The label.
91    pub fn label(&self) -> &SharedString {
92        &self.label
93    }
94}
95
96/// What a [`StatusSelectState`] reports.
97#[derive(Clone, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum StatusSelectEvent {
100    /// Another option was chosen. The payload is its id.
101    Changed(SharedString),
102}
103
104/// The options of a status item and the chosen one.
105///
106/// Owned by the view that shows the item, which observes it so a change
107/// renders. The choice is kept by option id.
108pub struct StatusSelectState {
109    options: Vec<StatusOption>,
110    selected: SharedString,
111}
112
113impl EventEmitter<StatusSelectEvent> for StatusSelectState {}
114
115impl StatusSelectState {
116    /// A state over `options` with the first chosen.
117    pub fn new(options: impl IntoIterator<Item = StatusOption>, _: &mut Context<Self>) -> Self {
118        let options: Vec<_> = options.into_iter().collect();
119        Self {
120            selected: options
121                .first()
122                .map(|option| option.id.clone())
123                .unwrap_or_default(),
124            options,
125        }
126    }
127
128    /// Chooses the option with `id`, if there is one.
129    pub fn with_selected(mut self, id: impl Into<SharedString>) -> Self {
130        let id = id.into();
131        if self.options.iter().any(|option| option.id == id) {
132            self.selected = id;
133        }
134        self
135    }
136
137    /// The options, in order.
138    pub fn options(&self) -> &[StatusOption] {
139        &self.options
140    }
141
142    /// The id of the chosen option.
143    pub fn selected(&self) -> &SharedString {
144        &self.selected
145    }
146
147    /// The label of the chosen option.
148    pub fn selected_label(&self) -> Option<&SharedString> {
149        self.options
150            .iter()
151            .find(|option| option.id == self.selected)
152            .map(|option| &option.label)
153    }
154
155    /// Replaces the options. The choice stays when its id is still there,
156    /// and reports nothing. When it is gone the first option is chosen and
157    /// `Changed` is emitted. The menu follows the new options. Whether the
158    /// list has a search field is decided when the item is first shown.
159    pub fn set_options(
160        &mut self,
161        options: impl IntoIterator<Item = StatusOption>,
162        cx: &mut Context<Self>,
163    ) {
164        self.options = options.into_iter().collect();
165        if !self.options.iter().any(|option| option.id == self.selected) {
166            self.selected = self
167                .options
168                .first()
169                .map(|option| option.id.clone())
170                .unwrap_or_default();
171            cx.emit(StatusSelectEvent::Changed(self.selected.clone()));
172        }
173        cx.notify();
174    }
175
176    /// Chooses the option with `id` and emits `Changed`. An unknown id, or
177    /// the one already chosen, changes nothing.
178    pub fn select(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
179        let id = id.into();
180        if id == self.selected || !self.options.iter().any(|option| option.id == id) {
181            return;
182        }
183        self.selected = id.clone();
184        cx.emit(StatusSelectEvent::Changed(id));
185        cx.notify();
186    }
187}
188
189/// An item of a [`ComposerStatusTab`](crate::ComposerStatusTab) that opens
190/// a [`Select`]: a small ghost trigger with an icon, the chosen option's
191/// label, and a chevron, over the select's menu with a check on the chosen
192/// option. A list of [`SEARCH_MIN_OPTIONS`] options or more has the
193/// select's search field, which ranks what is typed fuzzily.
194///
195/// ```no_run
196/// use gpui_cn::{StatusOption, StatusSelect, StatusSelectState};
197/// use gpui_kit::{AppContext as _, Context};
198/// use gpui_kit::assets::IconName;
199///
200/// fn branch(cx: &mut Context<()>) -> StatusSelect {
201///     let state = cx.new(|cx| {
202///         StatusSelectState::new(
203///             [StatusOption::new("main", "main"), StatusOption::new("dev", "dev")],
204///             cx,
205///         )
206///     });
207///     StatusSelect::new("branch", &state).icon(IconName::GitBranch)
208/// }
209/// ```
210///
211/// The default icons (the mic, the shield, the file kinds, the git branch) are
212/// not in the default `gpui_kit::assets::Assets`: register
213/// [`ComposerAssets`](crate::ComposerAssets).
214#[derive(IntoElement)]
215#[non_exhaustive]
216pub struct StatusSelect {
217    id: ElementId,
218    state: Entity<StatusSelectState>,
219    icon: Option<Icon>,
220    searchable: Option<bool>,
221    placeholder: SharedString,
222    empty: SharedString,
223    icon_only: bool,
224    width: Option<Pixels>,
225    style: StyleRefinement,
226}
227
228impl StatusSelect {
229    /// An item on `state`, with a stable id.
230    pub fn new(id: impl Into<ElementId>, state: &Entity<StatusSelectState>) -> Self {
231        Self {
232            id: id.into(),
233            state: state.clone(),
234            icon: None,
235            searchable: None,
236            placeholder: "Search...".into(),
237            empty: "No results".into(),
238            icon_only: false,
239            width: None,
240            style: StyleRefinement::default(),
241        }
242    }
243
244    /// The icon before the label.
245    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
246        self.icon = Some(icon.into());
247        self
248    }
249
250    /// Forces the presentation: `true` gives the menu a search field,
251    /// `false` a plain list. Left alone, a select with
252    /// [`SEARCH_MIN_OPTIONS`] options or more is searchable. It is read
253    /// when the item first renders.
254    pub fn searchable(mut self, searchable: bool) -> Self {
255        self.searchable = Some(searchable);
256        self
257    }
258
259    /// The hint in the search field. The default is "Search...". It is
260    /// read when the item first renders.
261    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
262        self.placeholder = placeholder.into();
263        self
264    }
265
266    /// The text shown while a search matches nothing. The default is "No
267    /// results".
268    pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
269        self.empty = text.into();
270        self
271    }
272
273    /// Shows the icon alone, with the label in a tooltip and no chevron, as
274    /// a tab does when it is too narrow for the labels. An item with no
275    /// icon ignores it.
276    pub fn icon_only(mut self, icon_only: bool) -> Self {
277        self.icon_only = icon_only;
278        self
279    }
280
281    /// Cuts the trigger to `width`, which the label ends in an ellipsis to
282    /// fit. A tab sets it when the labels do not all fit.
283    pub fn width(mut self, width: Pixels) -> Self {
284        self.width = Some(width);
285        self
286    }
287
288    /// Whether the item opens a menu with a search field, for a list of
289    /// `options` options.
290    pub fn is_searchable(&self, options: usize) -> bool {
291        self.searchable.unwrap_or(options >= SEARCH_MIN_OPTIONS)
292    }
293
294    /// What the item needs of a row: its natural width, its width with the
295    /// label at the minimum, and its width as an icon alone.
296    pub(crate) fn need(&self, window: &Window, cx: &App) -> Need {
297        let theme = cx.theme();
298        let padding = theme.metrics.control_padding_sm;
299        let rem = window.rem_size();
300        let gap = rems(0.375).to_pixels(rem);
301        let border = px(2.);
302        let icon = rems(1.).to_pixels(rem);
303        let chevron = rems(1.).to_pixels(rem);
304        let label = self
305            .state
306            .read(cx)
307            .selected_label()
308            .cloned()
309            .unwrap_or_default();
310        let natural_label = collapse::text_width(&label, window);
311        let kept = natural_label.min(collapse::min_label_width(window, collapse::MIN_LABEL_CHARS));
312        let lead = if self.icon.is_some() {
313            icon + gap
314        } else {
315            px(0.)
316        };
317        let chrome = padding * 2. + border + lead + gap + chevron;
318        Need {
319            natural: chrome + natural_label,
320            min: chrome + kept,
321            icon_only: self.icon.is_some().then_some(theme.metrics.control_sm),
322        }
323    }
324}
325
326impl Styled for StatusSelect {
327    fn style(&mut self) -> &mut StyleRefinement {
328        &mut self.style
329    }
330}
331
332/// The select an item shows, made once, and the subscription that carries
333/// its choice back to the state.
334struct Inner {
335    select: Entity<SelectState<SharedString>>,
336    /// The state's choice the select last agreed with, so a choice made in
337    /// the select is not undone before the state hears of it.
338    synced: SharedString,
339    /// The options the search answers from, shared so it follows changes.
340    options: Rc<RefCell<Vec<StatusOption>>>,
341    /// The ids and labels the select was last given.
342    shown: Vec<(SharedString, SharedString)>,
343    _subscription: Subscription,
344}
345
346impl RenderOnce for StatusSelect {
347    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
348        let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
349        let (options, selected, label) = {
350            let read = self.state.read(cx);
351            (
352                read.options.clone(),
353                read.selected.clone(),
354                read.selected_label().cloned().unwrap_or_default(),
355            )
356        };
357        let searchable = self.is_searchable(options.len());
358        let by_id: Rc<HashMap<SharedString, StatusOption>> = Rc::new(
359            options
360                .iter()
361                .map(|option| (option.id.clone(), option.clone()))
362                .collect(),
363        );
364        let inner = window.use_keyed_state(child("inner"), cx, {
365            let (state, options, selected, placeholder) = (
366                self.state.clone(),
367                options.clone(),
368                selected.clone(),
369                self.placeholder.clone(),
370            );
371            move |window, cx| {
372                let shared = Rc::new(RefCell::new(options.clone()));
373                let shown: Vec<_> = options
374                    .iter()
375                    .map(|option| (option.id.clone(), option.label.clone()))
376                    .collect();
377                let selected_for_inner = selected.clone();
378                let entries: Vec<SelectEntry<SharedString>> = options
379                    .iter()
380                    .map(|option| SelectItem::new(option.id.clone(), option.label.clone()).into())
381                    .collect();
382                let select = cx.new(|cx| {
383                    let select = SelectState::new(entries, cx).with_selected([selected]);
384                    if searchable {
385                        let options = shared.clone();
386                        select.with_search_handler(
387                            placeholder,
388                            move |query, _| Task::ready(ranked(&options.borrow(), &query)),
389                            window,
390                            cx,
391                        )
392                    } else {
393                        select
394                    }
395                });
396                let subscription = cx.subscribe(
397                    &select,
398                    move |inner: &mut Inner, _, event: &SelectEvent<SharedString>, cx| {
399                        if let SelectEvent::Changed(values) = event
400                            && let Some(value) = values.first()
401                        {
402                            let value = value.clone();
403                            inner.synced = value.clone();
404                            state.update(cx, |state, cx| state.select(value, cx));
405                        }
406                    },
407                );
408                Inner {
409                    select,
410                    synced: selected_for_inner,
411                    options: shared,
412                    shown,
413                    _subscription: subscription,
414                }
415            }
416        });
417        let select_state = inner.read(cx).select.clone();
418        let now: Vec<_> = options
419            .iter()
420            .map(|option| (option.id.clone(), option.label.clone()))
421            .collect();
422        if inner.read(cx).shown != now {
423            let entries: Vec<SelectEntry<SharedString>> = options
424                .iter()
425                .map(|option| SelectItem::new(option.id.clone(), option.label.clone()).into())
426                .collect();
427            *inner.read(cx).options.borrow_mut() = options.clone();
428            inner.update(cx, |inner, _| inner.shown = now);
429            select_state.update(cx, |state, cx| state.set_entries(entries, cx));
430        }
431        if inner.read(cx).synced != selected {
432            inner.update(cx, |inner, _| inner.synced = selected.clone());
433            select_state.update(cx, |state, cx| state.set_selected([selected.clone()], cx));
434        }
435
436        let theme = cx.theme();
437        let menu_width = theme.metrics.menu_max_width;
438        let icon_width = theme.metrics.control_sm;
439        let icon_only = self.icon_only && self.icon.is_some();
440        let mut select = Select::new(child("select"), &select_state)
441            .size(ButtonSize::Sm)
442            .ghost()
443            .align(Align::Start)
444            .accessibility_label(label.clone())
445            .empty_text(self.empty.clone())
446            .when(searchable, |select| select.menu_width(menu_width))
447            .refine_style(&self.style)
448            .render_item({
449                let by_id = by_id.clone();
450                move |item, row, window, cx| option_row(&by_id, item, row, window, cx)
451            });
452        if icon_only {
453            let icon = self.icon.clone().expect("checked above");
454            select = select
455                .chevron(false)
456                .render_value(move |_, _, _| {
457                    div().w_full().flex().justify_center().child(icon.clone())
458                })
459                .w(icon_width);
460        } else {
461            select = select
462                .when_some(self.icon.clone(), |select, icon| select.icon(icon))
463                .when_some(self.width, |select, width| select.w(width));
464        }
465        if icon_only {
466            div()
467                .id(child("tip"))
468                .flex_shrink_0()
469                .child(select)
470                .managed_tooltip(label, window, &*cx)
471                .into_any_element()
472        } else {
473            select.into_any_element()
474        }
475    }
476}
477
478/// The options a query matches, best first, as the select's rows. An empty
479/// query lists every option in order.
480fn ranked(options: &[StatusOption], query: &str) -> Vec<SelectEntry<SharedString>> {
481    fuzzy::rank(query, options, |option| &option.label)
482        .into_iter()
483        .map(|found| {
484            let option = &options[found.index];
485            SelectItem::new(option.id.clone(), option.label.clone()).into()
486        })
487        .collect()
488}
489
490/// The inside of an option's row: its icon, its label cut in the middle to
491/// fit, a muted word at the right, and the check, in the select's own row.
492fn option_row(
493    by_id: &HashMap<SharedString, StatusOption>,
494    item: &SelectItem<SharedString>,
495    row: crate::SelectRow,
496    window: &mut Window,
497    cx: &mut App,
498) -> AnyElement {
499    let look = MenuLook::of(cx.theme(), window.rem_size());
500    let option = by_id.get(&item.key());
501    let caption_text = cx.theme().base.typography.xs;
502    let label = item.label().clone();
503    let description = option.and_then(|option| option.description.clone());
504    h_flex()
505        .flex_1()
506        .min_w_0()
507        .items_center()
508        .gap_2()
509        .children(
510            option
511                .and_then(|option| option.icon.clone())
512                .map(|icon| line_slot(&look).child(icon.size_4())),
513        )
514        .child(match description {
515            Some(description) => label_block(label, row.matched(), Some(description), &look),
516            None => div()
517                .flex_1()
518                .min_w_0()
519                .child(MiddleText::new(label).marked(row.matched(), look.foreground))
520                .into_any_element(),
521        })
522        .children(
523            option
524                .and_then(|option| option.trailing.clone())
525                .map(|word| {
526                    line_slot(&look)
527                        .text_size(caption_text.size)
528                        .text_color(look.muted_foreground)
529                        .child(word)
530                }),
531        )
532        .child(check_slot(&look, row.is_selected(), row.is_disabled()))
533        .into_any_element()
534}