ccf-gpui-widgets 0.1.0

Reusable GPUI widgets for building desktop applications
Documentation
//! Dropdown widget
//!
//! A select/dropdown widget with keyboard navigation support.
//! Use `register_keybindings()` at app startup to enable keyboard shortcuts.
//!
//! # Example
//!
//! ```ignore
//! use ccf_gpui_widgets::widgets::Dropdown;
//!
//! // Register keybindings at app startup
//! ccf_gpui_widgets::widgets::dropdown::register_keybindings(cx);
//!
//! let dropdown = cx.new(|cx| {
//!     Dropdown::new(cx)
//!         .choices(vec!["Option 1".to_string(), "Option 2".to_string()])
//!         .selected_index(0)
//! });
//!
//! // Subscribe to changes
//! cx.subscribe(&dropdown, |this, _dropdown, event: &DropdownEvent, cx| {
//!     if let DropdownEvent::Change(value) = event {
//!         println!("Selected: {}", value);
//!     }
//! }).detach();
//! ```

use gpui::prelude::*;
use gpui::*;

use crate::theme::{get_theme_or, Theme};
use super::focus_navigation::{handle_tab_navigation, with_focus_actions};

// Actions for keyboard navigation
actions!(ccf_dropdown, [CloseDropdown, SelectPrevious, SelectNext, ConfirmSelection, ToggleDropdown]);

/// Register key bindings for dropdown components
///
/// Call this once at application startup:
/// ```ignore
/// ccf_gpui_widgets::widgets::dropdown::register_keybindings(cx);
/// ```
pub fn register_keybindings(cx: &mut App) {
    cx.bind_keys([
        KeyBinding::new("escape", CloseDropdown, Some("CcfDropdown")),
        KeyBinding::new("up", SelectPrevious, Some("CcfDropdown")),
        KeyBinding::new("down", SelectNext, Some("CcfDropdown")),
        KeyBinding::new("enter", ConfirmSelection, Some("CcfDropdown")),
        KeyBinding::new("space", ToggleDropdown, Some("CcfDropdown")),
    ]);
}

/// Events emitted by Dropdown
#[derive(Clone, Debug)]
pub enum DropdownEvent {
    /// Selected value changed
    Change(String),
    /// Dropdown opened
    Open,
    /// Dropdown closed
    Close,
}

/// Dropdown/Select widget
pub struct Dropdown {
    choices: Vec<String>,
    selected_index: usize,
    is_open: bool,
    focus_handle: FocusHandle,
    custom_theme: Option<Theme>,
    /// Whether focus-out subscription has been set up
    focus_out_subscribed: bool,
    /// Whether the widget is enabled (interactive)
    enabled: bool,
}

impl EventEmitter<DropdownEvent> for Dropdown {}

impl Focusable for Dropdown {
    fn focus_handle(&self, _cx: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl Dropdown {
    /// Create a new dropdown
    pub fn new(cx: &mut Context<Self>) -> Self {
        Self {
            choices: Vec::new(),
            selected_index: 0,
            is_open: false,
            focus_handle: cx.focus_handle().tab_stop(true),
            custom_theme: None,
            focus_out_subscribed: false,
            enabled: true,
        }
    }

    /// Set choices (builder pattern)
    #[must_use]
    pub fn choices(mut self, choices: Vec<String>) -> Self {
        self.choices = choices;
        self
    }

    /// Set selected index (builder pattern)
    #[must_use]
    pub fn with_selected_index(mut self, index: usize) -> Self {
        self.selected_index = index.min(self.choices.len().saturating_sub(1));
        self
    }

    /// Set selected value by string (builder pattern)
    #[must_use]
    pub fn with_selected_value(mut self, value: &str) -> Self {
        if let Some(index) = self.choices.iter().position(|c| c == value) {
            self.selected_index = index;
        }
        self
    }

    /// Set custom theme (builder pattern)
    #[must_use]
    pub fn theme(mut self, theme: Theme) -> Self {
        self.custom_theme = Some(theme);
        self
    }

    /// Set enabled state (builder pattern)
    #[must_use]
    pub fn with_enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    /// Get the currently selected value
    pub fn selected(&self) -> &str {
        self.choices.get(self.selected_index).map_or("", |s| s.as_str())
    }

    /// Get the currently selected index
    pub fn selected_index(&self) -> usize {
        self.selected_index
    }

    /// Set selected index programmatically
    pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
        let index = index.min(self.choices.len().saturating_sub(1));
        if self.selected_index != index {
            self.selected_index = index;
            if let Some(choice) = self.choices.get(index) {
                cx.emit(DropdownEvent::Change(choice.clone()));
            }
            cx.notify();
        }
    }

    /// Get the focus handle
    pub fn focus_handle(&self) -> &FocusHandle {
        &self.focus_handle
    }

    /// Check if the dropdown menu is currently open
    pub fn is_open(&self) -> bool {
        self.is_open
    }

    /// Check if the dropdown is enabled
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Set enabled state programmatically
    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
        if self.enabled != enabled {
            self.enabled = enabled;
            // Close dropdown if disabling while open
            if !enabled && self.is_open {
                self.is_open = false;
                cx.emit(DropdownEvent::Close);
            }
            cx.notify();
        }
    }

    fn select_by_offset(&mut self, offset: isize, cx: &mut Context<Self>) {
        let new_index = (self.selected_index as isize + offset)
            .clamp(0, self.choices.len().saturating_sub(1) as isize) as usize;
        if new_index != self.selected_index {
            self.selected_index = new_index;
            if let Some(choice) = self.choices.get(self.selected_index) {
                cx.emit(DropdownEvent::Change(choice.clone()));
            }
            cx.notify();
        }
    }

    fn select_previous(&mut self, cx: &mut Context<Self>) {
        self.select_by_offset(-1, cx);
    }

    fn select_next(&mut self, cx: &mut Context<Self>) {
        self.select_by_offset(1, cx);
    }

    fn close(&mut self, cx: &mut Context<Self>) {
        if self.is_open {
            self.is_open = false;
            cx.emit(DropdownEvent::Close);
            cx.notify();
        }
    }

    fn toggle(&mut self, cx: &mut Context<Self>) {
        self.is_open = !self.is_open;
        if self.is_open {
            cx.emit(DropdownEvent::Open);
        } else {
            cx.emit(DropdownEvent::Close);
        }
        cx.notify();
    }
}

impl Render for Dropdown {
    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
        let theme = get_theme_or(cx, self.custom_theme.as_ref());
        let is_focused = self.focus_handle.is_focused(window);
        let enabled = self.enabled;

        // Set up focus-out subscription once
        if !self.focus_out_subscribed {
            self.focus_out_subscribed = true;
            let focus_handle = self.focus_handle.clone();
            cx.on_focus_out(&focus_handle, window, |this: &mut Self, _event, _window, cx| {
                if this.is_open {
                    this.is_open = false;
                    cx.emit(DropdownEvent::Close);
                    cx.notify();
                }
            }).detach();
        }

        let selected = self.choices
            .get(self.selected_index)
            .cloned()
            .unwrap_or_default();
        let is_open = self.is_open && enabled;
        let focus_handle = self.focus_handle.clone();

        let bg_input = theme.bg_input;
        let bg_input_hover = theme.bg_input_hover;
        let bg_white = theme.bg_white;
        let border_focus = theme.border_focus;
        let border_input = theme.border_input;
        let text_primary = theme.text_primary;
        let text_muted = theme.text_muted;
        let primary = theme.primary;
        let disabled_bg = theme.disabled_bg;
        let disabled_text = theme.disabled_text;

        with_focus_actions(
            div()
                .id("ccf_dropdown")
                .relative()
                .key_context("CcfDropdown")
                .track_focus(&focus_handle)
                .tab_stop(enabled),
            cx,
        )
        .on_action(cx.listener(|dropdown, _: &CloseDropdown, _window, cx| {
                if dropdown.enabled {
                    dropdown.close(cx);
                }
            }))
            .on_action(cx.listener(|dropdown, _: &SelectPrevious, _window, cx| {
                if dropdown.enabled {
                    dropdown.select_previous(cx);
                }
            }))
            .on_action(cx.listener(|dropdown, _: &SelectNext, _window, cx| {
                if dropdown.enabled {
                    dropdown.select_next(cx);
                }
            }))
            .on_action(cx.listener(|dropdown, _: &ConfirmSelection, _window, cx| {
                if dropdown.enabled {
                    dropdown.close(cx);
                }
            }))
            .on_action(cx.listener(|dropdown, _: &ToggleDropdown, window, cx| {
                if dropdown.enabled {
                    dropdown.toggle(cx);
                    dropdown.focus_handle.focus(window);
                }
            }))
            .on_key_down(cx.listener(|_dropdown, event: &KeyDownEvent, window, _cx| {
                handle_tab_navigation(event, window);
            }))
            .child(
                // Dropdown button
                div()
                    .id("ccf_dropdown_button")
                    .flex()
                    .flex_row()
                    .justify_between()
                    .items_center()
                    .w_full()
                    .h(px(32.))
                    .px_3()
                    .border_1()
                    .when(enabled, |d| {
                        d.border_color(if is_focused { rgb(border_focus) } else { rgb(border_input) })
                            .bg(rgb(bg_input))
                            .text_color(rgb(text_primary))
                            .cursor_pointer()
                            .hover(|d| d.bg(rgb(bg_input_hover)))
                            .on_click(cx.listener(move |dropdown, _event, window, cx| {
                                dropdown.toggle(cx);
                                dropdown.focus_handle.focus(window);
                            }))
                    })
                    .when(!enabled, |d| {
                        d.border_color(rgb(disabled_bg))
                            .bg(rgb(disabled_bg))
                            .text_color(rgb(disabled_text))
                            .cursor_default()
                    })
                    .rounded_md()
                    .text_sm()
                    .child(selected.clone())
                    .child(
                        div()
                            .text_xs()
                            .when(enabled, |d| d.text_color(rgb(text_muted)))
                            .when(!enabled, |d| d.text_color(rgb(disabled_text)))
                            .child("")
                    )
            )
            .when(is_open, |parent| {
                let selected_index = self.selected_index;
                let choices_list: Vec<_> = self.choices.iter().enumerate().map(|(i, choice)| {
                    let is_selected = i == selected_index;
                    let choice_clone = choice.clone();

                    div()
                        .id(("ccf_dropdown_choice", i))
                        .px_3()
                        .py_2()
                        .cursor_pointer()
                        .text_sm()
                        .when(is_selected, |d| {
                            d.bg(rgb(primary)).text_color(rgb(bg_white))
                        })
                        .when(!is_selected, |d| {
                            d.text_color(rgb(text_primary))
                                .hover(|d| d.bg(rgb(bg_input_hover)))
                        })
                        .child(choice.clone())
                        // Use on_mouse_down to handle selection immediately and prevent click-through
                        .on_mouse_down(MouseButton::Left, cx.listener(move |dropdown, _event, _window, cx| {
                            dropdown.selected_index = i;
                            dropdown.is_open = false;
                            cx.emit(DropdownEvent::Change(choice_clone.clone()));
                            cx.emit(DropdownEvent::Close);
                            cx.notify();
                        }))
                }).collect();

                parent.child(
                    deferred(
                        anchored()
                            .anchor(Corner::TopLeft)
                            .child(
                                div()
                                    .id("ccf_dropdown_menu")
                                    .occlude()  // Block all mouse events from reaching elements below
                                    .absolute()
                                    .top(px(2.))
                                    .left_0()
                                    .w_full()
                                    .min_w(px(200.))
                                    .border_1()
                                    .border_color(rgb(border_input))
                                    .rounded_md()
                                    .bg(rgb(bg_input))
                                    .max_h(px(200.))
                                    .overflow_y_scroll()
                                    .shadow_lg()
                                    .children(choices_list)
                                    .on_mouse_down_out(cx.listener(|dropdown, _event, _window, cx| {
                                        dropdown.is_open = false;
                                        cx.emit(DropdownEvent::Close);
                                        cx.notify();
                                    }))
                            )
                    )
                )
            })
    }
}