Skip to main content

guise/input/
select.rs

1//! `Select` — a stateful dropdown picker (gpui entity).
2//!
3//! Owns its open state and selection; renders a trigger plus a deferred
4//! dropdown list, and emits [`SelectEvent`] when the choice changes.
5
6use gpui::prelude::*;
7use gpui::{
8  deferred, div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, SharedString,
9  Window,
10};
11
12use super::control_metrics;
13use crate::devtools::Probed;
14use crate::reactive::Signal;
15use crate::style::TextOverflowExt;
16use crate::theme::{theme, Size};
17
18/// Emitted when the user picks an option. Carries the option index.
19#[derive(Debug, Clone)]
20pub struct SelectEvent(pub usize);
21
22/// A dropdown picker. Create with `cx.new(|cx| Select::new(cx).data([...]))`.
23pub struct Select {
24  options: Vec<SharedString>,
25  selected: Option<usize>,
26  open: bool,
27  focus: FocusHandle,
28  placeholder: SharedString,
29  label: Option<SharedString>,
30  size: Size,
31  disabled: bool,
32}
33
34impl EventEmitter<SelectEvent> for Select {}
35
36impl Select {
37  pub fn new(cx: &mut Context<Self>) -> Self {
38    Select {
39      options: Vec::new(),
40      selected: None,
41      open: false,
42      focus: cx.focus_handle(),
43      placeholder: SharedString::new_static("Pick one"),
44      label: None,
45      size: Size::Sm,
46      disabled: false,
47    }
48  }
49
50  pub fn data<I, S>(mut self, options: I) -> Self
51  where
52    I: IntoIterator<Item = S>,
53    S: Into<SharedString>,
54  {
55    self.options = options.into_iter().map(Into::into).collect();
56    self.selected = self
57      .selected
58      .and_then(|index| (!self.options.is_empty()).then(|| index.min(self.options.len() - 1)));
59    self
60  }
61
62  pub fn selected(mut self, index: usize) -> Self {
63    self.selected = Some(index);
64    self
65  }
66
67  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
68    self.placeholder = placeholder.into();
69    self
70  }
71
72  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
73    self.label = Some(label.into());
74    self
75  }
76
77  pub fn size(mut self, size: Size) -> Self {
78    self.size = size;
79    self
80  }
81
82  pub fn disabled(mut self, disabled: bool) -> Self {
83    self.disabled = disabled;
84    self
85  }
86
87  pub fn selected_index(&self) -> Option<usize> {
88    self.selected
89  }
90
91  pub fn selected_value(&self) -> Option<SharedString> {
92    self.selected.and_then(|i| self.options.get(i).cloned())
93  }
94
95  /// Two-way bind this picker's selection to a `Signal<usize>`. The signal
96  /// is the source of truth: the picker adopts its index now, picks write
97  /// back through [`Signal::set_if_changed`], and signal writes move the
98  /// selection without emitting [`SelectEvent`]. Equality guards on both
99  /// directions prevent update loops.
100  pub fn bind(entity: &Entity<Select>, signal: &Signal<usize>, cx: &mut App) {
101    let initial = signal.get(cx);
102    entity.update(cx, |this, cx| this.sync_selected(initial, cx));
103    let sink = signal.clone();
104    cx.subscribe(entity, move |_select, event: &SelectEvent, cx| {
105      sink.set_if_changed(cx, event.0);
106    })
107    .detach();
108    let select = entity.downgrade();
109    cx.observe(signal.entity(), move |observed, cx| {
110      let index = *observed.read(cx);
111      select
112        .update(cx, |this, cx| this.sync_selected(index, cx))
113        .ok();
114    })
115    .detach();
116  }
117
118  /// Programmatic set: repaint without emitting an event.
119  fn sync_selected(&mut self, index: usize, cx: &mut Context<Self>) {
120    let selected = (!self.options.is_empty()).then(|| index.min(self.options.len() - 1));
121    if self.selected != selected {
122      self.selected = selected;
123      cx.notify();
124    }
125  }
126}
127
128impl Render for Select {
129  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
130    let t = theme(cx);
131    let (height, pad_x, font) = control_metrics(self.size);
132    let radius = t.radius(t.default_radius);
133    let surface = t.surface().hsla();
134    let surface_hover = t.surface_hover().hsla();
135    let border = t.border().hsla();
136    let text_color = t.text().hsla();
137    let dimmed = t.dimmed().hsla();
138    let selected_bg = t.primary().alpha(0.12);
139    let font_sm = t.font_size(Size::Sm);
140
141    let selected = self.selected;
142    let chosen = selected.and_then(|i| self.options.get(i));
143    let has_value = chosen.is_some();
144    let value_text: SharedString = chosen.cloned().unwrap_or_else(|| self.placeholder.clone());
145
146    let trigger = div()
147      .id("guise-select-trigger")
148      .track_focus(&self.focus)
149      .flex()
150      .items_center()
151      .justify_between()
152      .gap(px(8.0))
153      .h(px(height))
154      .px(px(pad_x))
155      .rounded(px(radius))
156      .border_1()
157      .border_color(border)
158      .bg(surface)
159      .text_size(px(font))
160      .text_color(if has_value { text_color } else { dimmed })
161      .child(div().flex_1().truncate_text().child(value_text))
162      .child(
163        div()
164          .flex_none()
165          .text_color(dimmed)
166          .child(SharedString::new_static("\u{25be}")),
167      )
168      .on_click(cx.listener(|this, _ev, _window, cx| {
169        if !this.disabled {
170          this.open = !this.open;
171          cx.notify();
172        }
173      }));
174
175    let mut wrap = div().relative().child(trigger);
176
177    if self.open && !self.disabled {
178      let mut menu = div()
179        .absolute()
180        .top(px(height + 6.0))
181        .left(px(0.0))
182        .right(px(0.0))
183        .flex()
184        .flex_col()
185        .gap(px(2.0))
186        .p(px(4.0))
187        .rounded(px(radius))
188        .border_1()
189        .border_color(border)
190        .bg(surface)
191        .shadow_md();
192
193      for (i, option) in self.options.iter().enumerate() {
194        let is_selected = Some(i) == selected;
195        let mut row = div()
196          .id(("guise-select-option", i))
197          .px(px(10.0))
198          .py(px(6.0))
199          .rounded(px(4.0))
200          .text_size(px(font))
201          .text_color(text_color)
202          .hover(move |s| s.bg(surface_hover))
203          .child(option.clone())
204          .on_click(cx.listener(move |this, _ev, _window, cx| {
205            this.selected = Some(i);
206            this.open = false;
207            cx.emit(SelectEvent(i));
208            cx.notify();
209          }));
210        if is_selected {
211          row = row.bg(selected_bg);
212        }
213        menu = menu.child(row);
214      }
215
216      wrap = wrap.child(deferred(menu));
217    }
218
219    let mut column = div().flex().flex_col().min_w(px(0.0)).gap(px(4.0));
220    if let Some(label) = self.label.clone() {
221      column = column.child(
222        div()
223          .min_w(px(0.0))
224          .text_size(px(font_sm))
225          .text_color(text_color)
226          .child(label),
227      );
228    }
229    column = column.child(wrap);
230
231    let element = if self.disabled {
232      column.opacity(0.6)
233    } else {
234      column
235    };
236
237    element.probe("Select")
238  }
239}