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
22pub const SEARCH_MIN_OPTIONS: usize = 3;
27
28#[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 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 pub fn trailing(mut self, trailing: impl Into<SharedString>) -> Self {
64 self.trailing = Some(trailing.into());
65 self
66 }
67
68 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
70 self.icon = Some(icon.into());
71 self
72 }
73
74 pub fn trailing_label(&self) -> Option<&SharedString> {
76 self.trailing.as_ref()
77 }
78
79 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
81 self.description = Some(description.into());
82 self
83 }
84
85 pub fn id(&self) -> &SharedString {
87 &self.id
88 }
89
90 pub fn label(&self) -> &SharedString {
92 &self.label
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum StatusSelectEvent {
100 Changed(SharedString),
102}
103
104pub struct StatusSelectState {
109 options: Vec<StatusOption>,
110 selected: SharedString,
111}
112
113impl EventEmitter<StatusSelectEvent> for StatusSelectState {}
114
115impl StatusSelectState {
116 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 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 pub fn options(&self) -> &[StatusOption] {
139 &self.options
140 }
141
142 pub fn selected(&self) -> &SharedString {
144 &self.selected
145 }
146
147 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 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 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#[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 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 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
246 self.icon = Some(icon.into());
247 self
248 }
249
250 pub fn searchable(mut self, searchable: bool) -> Self {
255 self.searchable = Some(searchable);
256 self
257 }
258
259 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
262 self.placeholder = placeholder.into();
263 self
264 }
265
266 pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
269 self.empty = text.into();
270 self
271 }
272
273 pub fn icon_only(mut self, icon_only: bool) -> Self {
277 self.icon_only = icon_only;
278 self
279 }
280
281 pub fn width(mut self, width: Pixels) -> Self {
284 self.width = Some(width);
285 self
286 }
287
288 pub fn is_searchable(&self, options: usize) -> bool {
291 self.searchable.unwrap_or(options >= SEARCH_MIN_OPTIONS)
292 }
293
294 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
332struct Inner {
335 select: Entity<SelectState<SharedString>>,
336 synced: SharedString,
339 options: Rc<RefCell<Vec<StatusOption>>>,
341 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
478fn 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
490fn 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}