ccf_gpui_widgets/widgets/
dropdown.rs1use gpui::prelude::*;
29use gpui::*;
30
31use crate::theme::{get_theme_or, Theme};
32use super::focus_navigation::{handle_tab_navigation, with_focus_actions};
33
34actions!(ccf_dropdown, [CloseDropdown, SelectPrevious, SelectNext, ConfirmSelection, ToggleDropdown]);
36
37pub fn register_keybindings(cx: &mut App) {
44 cx.bind_keys([
45 KeyBinding::new("escape", CloseDropdown, Some("CcfDropdown")),
46 KeyBinding::new("up", SelectPrevious, Some("CcfDropdown")),
47 KeyBinding::new("down", SelectNext, Some("CcfDropdown")),
48 KeyBinding::new("enter", ConfirmSelection, Some("CcfDropdown")),
49 KeyBinding::new("space", ToggleDropdown, Some("CcfDropdown")),
50 ]);
51}
52
53#[derive(Clone, Debug)]
55pub enum DropdownEvent {
56 Change(String),
58 Open,
60 Close,
62}
63
64pub struct Dropdown {
66 choices: Vec<String>,
67 selected_index: usize,
68 is_open: bool,
69 focus_handle: FocusHandle,
70 custom_theme: Option<Theme>,
71 focus_out_subscribed: bool,
73 enabled: bool,
75}
76
77impl EventEmitter<DropdownEvent> for Dropdown {}
78
79impl Focusable for Dropdown {
80 fn focus_handle(&self, _cx: &App) -> FocusHandle {
81 self.focus_handle.clone()
82 }
83}
84
85impl Dropdown {
86 pub fn new(cx: &mut Context<Self>) -> Self {
88 Self {
89 choices: Vec::new(),
90 selected_index: 0,
91 is_open: false,
92 focus_handle: cx.focus_handle().tab_stop(true),
93 custom_theme: None,
94 focus_out_subscribed: false,
95 enabled: true,
96 }
97 }
98
99 #[must_use]
101 pub fn choices(mut self, choices: Vec<String>) -> Self {
102 self.choices = choices;
103 self
104 }
105
106 #[must_use]
108 pub fn with_selected_index(mut self, index: usize) -> Self {
109 self.selected_index = index.min(self.choices.len().saturating_sub(1));
110 self
111 }
112
113 #[must_use]
115 pub fn with_selected_value(mut self, value: &str) -> Self {
116 if let Some(index) = self.choices.iter().position(|c| c == value) {
117 self.selected_index = index;
118 }
119 self
120 }
121
122 #[must_use]
124 pub fn theme(mut self, theme: Theme) -> Self {
125 self.custom_theme = Some(theme);
126 self
127 }
128
129 #[must_use]
131 pub fn with_enabled(mut self, enabled: bool) -> Self {
132 self.enabled = enabled;
133 self
134 }
135
136 pub fn selected(&self) -> &str {
138 self.choices.get(self.selected_index).map_or("", |s| s.as_str())
139 }
140
141 pub fn selected_index(&self) -> usize {
143 self.selected_index
144 }
145
146 pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
148 let index = index.min(self.choices.len().saturating_sub(1));
149 if self.selected_index != index {
150 self.selected_index = index;
151 if let Some(choice) = self.choices.get(index) {
152 cx.emit(DropdownEvent::Change(choice.clone()));
153 }
154 cx.notify();
155 }
156 }
157
158 pub fn focus_handle(&self) -> &FocusHandle {
160 &self.focus_handle
161 }
162
163 pub fn is_open(&self) -> bool {
165 self.is_open
166 }
167
168 pub fn is_enabled(&self) -> bool {
170 self.enabled
171 }
172
173 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
175 if self.enabled != enabled {
176 self.enabled = enabled;
177 if !enabled && self.is_open {
179 self.is_open = false;
180 cx.emit(DropdownEvent::Close);
181 }
182 cx.notify();
183 }
184 }
185
186 fn select_by_offset(&mut self, offset: isize, cx: &mut Context<Self>) {
187 let new_index = (self.selected_index as isize + offset)
188 .clamp(0, self.choices.len().saturating_sub(1) as isize) as usize;
189 if new_index != self.selected_index {
190 self.selected_index = new_index;
191 if let Some(choice) = self.choices.get(self.selected_index) {
192 cx.emit(DropdownEvent::Change(choice.clone()));
193 }
194 cx.notify();
195 }
196 }
197
198 fn select_previous(&mut self, cx: &mut Context<Self>) {
199 self.select_by_offset(-1, cx);
200 }
201
202 fn select_next(&mut self, cx: &mut Context<Self>) {
203 self.select_by_offset(1, cx);
204 }
205
206 fn close(&mut self, cx: &mut Context<Self>) {
207 if self.is_open {
208 self.is_open = false;
209 cx.emit(DropdownEvent::Close);
210 cx.notify();
211 }
212 }
213
214 fn toggle(&mut self, cx: &mut Context<Self>) {
215 self.is_open = !self.is_open;
216 if self.is_open {
217 cx.emit(DropdownEvent::Open);
218 } else {
219 cx.emit(DropdownEvent::Close);
220 }
221 cx.notify();
222 }
223}
224
225impl Render for Dropdown {
226 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
227 let theme = get_theme_or(cx, self.custom_theme.as_ref());
228 let is_focused = self.focus_handle.is_focused(window);
229 let enabled = self.enabled;
230
231 if !self.focus_out_subscribed {
233 self.focus_out_subscribed = true;
234 let focus_handle = self.focus_handle.clone();
235 cx.on_focus_out(&focus_handle, window, |this: &mut Self, _event, _window, cx| {
236 if this.is_open {
237 this.is_open = false;
238 cx.emit(DropdownEvent::Close);
239 cx.notify();
240 }
241 }).detach();
242 }
243
244 let selected = self.choices
245 .get(self.selected_index)
246 .cloned()
247 .unwrap_or_default();
248 let is_open = self.is_open && enabled;
249 let focus_handle = self.focus_handle.clone();
250
251 let bg_input = theme.bg_input;
252 let bg_input_hover = theme.bg_input_hover;
253 let bg_white = theme.bg_white;
254 let border_focus = theme.border_focus;
255 let border_input = theme.border_input;
256 let text_primary = theme.text_primary;
257 let text_muted = theme.text_muted;
258 let primary = theme.primary;
259 let disabled_bg = theme.disabled_bg;
260 let disabled_text = theme.disabled_text;
261
262 with_focus_actions(
263 div()
264 .id("ccf_dropdown")
265 .relative()
266 .key_context("CcfDropdown")
267 .track_focus(&focus_handle)
268 .tab_stop(enabled),
269 cx,
270 )
271 .on_action(cx.listener(|dropdown, _: &CloseDropdown, _window, cx| {
272 if dropdown.enabled {
273 dropdown.close(cx);
274 }
275 }))
276 .on_action(cx.listener(|dropdown, _: &SelectPrevious, _window, cx| {
277 if dropdown.enabled {
278 dropdown.select_previous(cx);
279 }
280 }))
281 .on_action(cx.listener(|dropdown, _: &SelectNext, _window, cx| {
282 if dropdown.enabled {
283 dropdown.select_next(cx);
284 }
285 }))
286 .on_action(cx.listener(|dropdown, _: &ConfirmSelection, _window, cx| {
287 if dropdown.enabled {
288 dropdown.close(cx);
289 }
290 }))
291 .on_action(cx.listener(|dropdown, _: &ToggleDropdown, window, cx| {
292 if dropdown.enabled {
293 dropdown.toggle(cx);
294 dropdown.focus_handle.focus(window);
295 }
296 }))
297 .on_key_down(cx.listener(|_dropdown, event: &KeyDownEvent, window, _cx| {
298 handle_tab_navigation(event, window);
299 }))
300 .child(
301 div()
303 .id("ccf_dropdown_button")
304 .flex()
305 .flex_row()
306 .justify_between()
307 .items_center()
308 .w_full()
309 .h(px(32.))
310 .px_3()
311 .border_1()
312 .when(enabled, |d| {
313 d.border_color(if is_focused { rgb(border_focus) } else { rgb(border_input) })
314 .bg(rgb(bg_input))
315 .text_color(rgb(text_primary))
316 .cursor_pointer()
317 .hover(|d| d.bg(rgb(bg_input_hover)))
318 .on_click(cx.listener(move |dropdown, _event, window, cx| {
319 dropdown.toggle(cx);
320 dropdown.focus_handle.focus(window);
321 }))
322 })
323 .when(!enabled, |d| {
324 d.border_color(rgb(disabled_bg))
325 .bg(rgb(disabled_bg))
326 .text_color(rgb(disabled_text))
327 .cursor_default()
328 })
329 .rounded_md()
330 .text_sm()
331 .child(selected.clone())
332 .child(
333 div()
334 .text_xs()
335 .when(enabled, |d| d.text_color(rgb(text_muted)))
336 .when(!enabled, |d| d.text_color(rgb(disabled_text)))
337 .child("▼")
338 )
339 )
340 .when(is_open, |parent| {
341 let selected_index = self.selected_index;
342 let choices_list: Vec<_> = self.choices.iter().enumerate().map(|(i, choice)| {
343 let is_selected = i == selected_index;
344 let choice_clone = choice.clone();
345
346 div()
347 .id(("ccf_dropdown_choice", i))
348 .px_3()
349 .py_2()
350 .cursor_pointer()
351 .text_sm()
352 .when(is_selected, |d| {
353 d.bg(rgb(primary)).text_color(rgb(bg_white))
354 })
355 .when(!is_selected, |d| {
356 d.text_color(rgb(text_primary))
357 .hover(|d| d.bg(rgb(bg_input_hover)))
358 })
359 .child(choice.clone())
360 .on_mouse_down(MouseButton::Left, cx.listener(move |dropdown, _event, _window, cx| {
362 dropdown.selected_index = i;
363 dropdown.is_open = false;
364 cx.emit(DropdownEvent::Change(choice_clone.clone()));
365 cx.emit(DropdownEvent::Close);
366 cx.notify();
367 }))
368 }).collect();
369
370 parent.child(
371 deferred(
372 anchored()
373 .anchor(Corner::TopLeft)
374 .child(
375 div()
376 .id("ccf_dropdown_menu")
377 .occlude() .absolute()
379 .top(px(2.))
380 .left_0()
381 .w_full()
382 .min_w(px(200.))
383 .border_1()
384 .border_color(rgb(border_input))
385 .rounded_md()
386 .bg(rgb(bg_input))
387 .max_h(px(200.))
388 .overflow_y_scroll()
389 .shadow_lg()
390 .children(choices_list)
391 .on_mouse_down_out(cx.listener(|dropdown, _event, _window, cx| {
392 dropdown.is_open = false;
393 cx.emit(DropdownEvent::Close);
394 cx.notify();
395 }))
396 )
397 )
398 )
399 })
400 }
401}