Skip to main content

ccf_gpui_widgets/widgets/
dropdown.rs

1//! Dropdown widget
2//!
3//! A select/dropdown widget with keyboard navigation support.
4//! Use `register_keybindings()` at app startup to enable keyboard shortcuts.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::Dropdown;
10//!
11//! // Register keybindings at app startup
12//! ccf_gpui_widgets::widgets::dropdown::register_keybindings(cx);
13//!
14//! let dropdown = cx.new(|cx| {
15//!     Dropdown::new(cx)
16//!         .choices(vec!["Option 1".to_string(), "Option 2".to_string()])
17//!         .selected_index(0)
18//! });
19//!
20//! // Subscribe to changes
21//! cx.subscribe(&dropdown, |this, _dropdown, event: &DropdownEvent, cx| {
22//!     if let DropdownEvent::Change(value) = event {
23//!         println!("Selected: {}", value);
24//!     }
25//! }).detach();
26//! ```
27
28use gpui::prelude::*;
29use gpui::*;
30
31use crate::theme::{get_theme_or, Theme};
32use super::focus_navigation::{handle_tab_navigation, with_focus_actions};
33
34// Actions for keyboard navigation
35actions!(ccf_dropdown, [CloseDropdown, SelectPrevious, SelectNext, ConfirmSelection, ToggleDropdown]);
36
37/// Register key bindings for dropdown components
38///
39/// Call this once at application startup:
40/// ```ignore
41/// ccf_gpui_widgets::widgets::dropdown::register_keybindings(cx);
42/// ```
43pub 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/// Events emitted by Dropdown
54#[derive(Clone, Debug)]
55pub enum DropdownEvent {
56    /// Selected value changed
57    Change(String),
58    /// Dropdown opened
59    Open,
60    /// Dropdown closed
61    Close,
62}
63
64/// Dropdown/Select widget
65pub struct Dropdown {
66    choices: Vec<String>,
67    selected_index: usize,
68    is_open: bool,
69    focus_handle: FocusHandle,
70    custom_theme: Option<Theme>,
71    /// Whether focus-out subscription has been set up
72    focus_out_subscribed: bool,
73    /// Whether the widget is enabled (interactive)
74    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    /// Create a new dropdown
87    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    /// Set choices (builder pattern)
100    #[must_use]
101    pub fn choices(mut self, choices: Vec<String>) -> Self {
102        self.choices = choices;
103        self
104    }
105
106    /// Set selected index (builder pattern)
107    #[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    /// Set selected value by string (builder pattern)
114    #[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    /// Set custom theme (builder pattern)
123    #[must_use]
124    pub fn theme(mut self, theme: Theme) -> Self {
125        self.custom_theme = Some(theme);
126        self
127    }
128
129    /// Set enabled state (builder pattern)
130    #[must_use]
131    pub fn with_enabled(mut self, enabled: bool) -> Self {
132        self.enabled = enabled;
133        self
134    }
135
136    /// Get the currently selected value
137    pub fn selected(&self) -> &str {
138        self.choices.get(self.selected_index).map_or("", |s| s.as_str())
139    }
140
141    /// Get the currently selected index
142    pub fn selected_index(&self) -> usize {
143        self.selected_index
144    }
145
146    /// Set selected index programmatically
147    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    /// Get the focus handle
159    pub fn focus_handle(&self) -> &FocusHandle {
160        &self.focus_handle
161    }
162
163    /// Check if the dropdown menu is currently open
164    pub fn is_open(&self) -> bool {
165        self.is_open
166    }
167
168    /// Check if the dropdown is enabled
169    pub fn is_enabled(&self) -> bool {
170        self.enabled
171    }
172
173    /// Set enabled state programmatically
174    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
175        if self.enabled != enabled {
176            self.enabled = enabled;
177            // Close dropdown if disabling while open
178            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        // Set up focus-out subscription once
232        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                // Dropdown button
302                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                        // Use on_mouse_down to handle selection immediately and prevent click-through
361                        .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()  // Block all mouse events from reaching elements below
378                                    .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}