Skip to main content

ccf_gpui_widgets/widgets/
checkbox_group.rs

1//! Checkbox group widget
2//!
3//! A group of checkboxes for multi-selection from multiple choices.
4//! Supports keyboard navigation (Up/Down arrows, Space to toggle).
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::CheckboxGroup;
10//!
11//! let group = cx.new(|cx| {
12//!     CheckboxGroup::new(cx)
13//!         .choices(vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()])
14//!         .selected(vec!["Red".to_string(), "Blue".to_string()])
15//! });
16//!
17//! // Subscribe to changes
18//! cx.subscribe(&group, |this, _group, event: &CheckboxGroupEvent, cx| {
19//!     if let CheckboxGroupEvent::Change(selected) = event {
20//!         println!("Selected: {:?}", selected);
21//!     }
22//! }).detach();
23//! ```
24
25use std::collections::HashSet;
26
27use gpui::prelude::*;
28use gpui::*;
29
30use crate::theme::{get_theme_or, Theme};
31use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
32
33/// Events emitted by CheckboxGroup
34#[derive(Clone, Debug)]
35pub enum CheckboxGroupEvent {
36    /// Selection changed (contains all currently selected values)
37    Change(Vec<String>),
38}
39
40/// Checkbox group widget for multi-selection
41pub struct CheckboxGroup {
42    choices: Vec<String>,
43    selected: HashSet<String>,
44    focus_handle: FocusHandle,
45    highlight_index: usize,
46    custom_theme: Option<Theme>,
47    /// Whether the widget is enabled (interactive)
48    enabled: bool,
49}
50
51impl EventEmitter<CheckboxGroupEvent> for CheckboxGroup {}
52
53impl Focusable for CheckboxGroup {
54    fn focus_handle(&self, _cx: &App) -> FocusHandle {
55        self.focus_handle.clone()
56    }
57}
58
59impl CheckboxGroup {
60    /// Create a new checkbox group
61    pub fn new(cx: &mut Context<Self>) -> Self {
62        Self {
63            choices: Vec::new(),
64            selected: HashSet::new(),
65            focus_handle: cx.focus_handle().tab_stop(true),
66            highlight_index: 0,
67            custom_theme: None,
68            enabled: true,
69        }
70    }
71
72    /// Set choices (builder pattern)
73    #[must_use]
74    pub fn choices(mut self, choices: Vec<String>) -> Self {
75        self.choices = choices;
76        self
77    }
78
79    /// Set initially selected values (builder pattern)
80    #[must_use]
81    pub fn with_selected(mut self, selected: Vec<String>) -> Self {
82        self.selected = selected.into_iter().collect();
83        self
84    }
85
86    /// Set custom theme (builder pattern)
87    #[must_use]
88    pub fn theme(mut self, theme: Theme) -> Self {
89        self.custom_theme = Some(theme);
90        self
91    }
92
93    /// Set enabled state (builder pattern)
94    #[must_use]
95    pub fn with_enabled(mut self, enabled: bool) -> Self {
96        self.enabled = enabled;
97        self
98    }
99
100    /// Get the currently selected values (sorted)
101    pub fn get_selected(&self) -> Vec<String> {
102        let mut result: Vec<String> = self.selected.iter().cloned().collect();
103        result.sort();
104        result
105    }
106
107    /// Check if a specific value is selected
108    pub fn is_selected(&self, value: &str) -> bool {
109        self.selected.contains(value)
110    }
111
112    /// Set selected values programmatically
113    pub fn set_selected(&mut self, selected: Vec<String>, cx: &mut Context<Self>) {
114        self.selected = selected.into_iter().collect();
115        cx.emit(CheckboxGroupEvent::Change(self.get_selected()));
116        cx.notify();
117    }
118
119    /// Get the focus handle
120    pub fn focus_handle(&self) -> &FocusHandle {
121        &self.focus_handle
122    }
123
124    /// Check if the checkbox group is enabled
125    pub fn is_enabled(&self) -> bool {
126        self.enabled
127    }
128
129    /// Set enabled state programmatically
130    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
131        if self.enabled != enabled {
132            self.enabled = enabled;
133            cx.notify();
134        }
135    }
136
137    fn toggle_choice(&mut self, choice: String, cx: &mut Context<Self>) {
138        if self.selected.contains(&choice) {
139            self.selected.remove(&choice);
140        } else {
141            self.selected.insert(choice);
142        }
143        cx.emit(CheckboxGroupEvent::Change(self.get_selected()));
144    }
145}
146
147impl Render for CheckboxGroup {
148    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
149        let theme = get_theme_or(cx, self.custom_theme.as_ref());
150        let focus_handle = self.focus_handle.clone();
151        let is_focused = self.focus_handle.is_focused(window);
152        let highlight_index = self.highlight_index;
153        let num_choices = self.choices.len();
154        let enabled = self.enabled;
155
156        with_focus_actions(
157            div()
158                .id("ccf_checkbox_group")
159                .track_focus(&focus_handle)
160                .tab_stop(enabled),
161            cx,
162        )
163        .on_key_down(cx.listener(move |group, event: &KeyDownEvent, window, cx| {
164                if !group.enabled {
165                    return;
166                }
167                if handle_tab_navigation(event, window) {
168                    return;
169                }
170                match event.keystroke.key.as_str() {
171                    "up" => {
172                        if group.highlight_index > 0 {
173                            group.highlight_index -= 1;
174                        } else if num_choices > 0 {
175                            group.highlight_index = num_choices - 1;
176                        }
177                        cx.notify();
178                    }
179                    "down" => {
180                        if group.highlight_index < num_choices.saturating_sub(1) {
181                            group.highlight_index += 1;
182                        } else {
183                            group.highlight_index = 0;
184                        }
185                        cx.notify();
186                    }
187                    "space" => {
188                        if let Some(choice) = group.choices.get(group.highlight_index).cloned() {
189                            group.toggle_choice(choice, cx);
190                        }
191                        cx.notify();
192                    }
193                    _ => {}
194                }
195            }))
196            .flex()
197            .flex_col()
198            .gap_1()
199            .p_2()
200            .when(enabled, |d| d.bg(rgb(theme.bg_input)))
201            .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
202            .border_1()
203            .when(enabled, |d| {
204                d.border_color(if is_focused { rgb(theme.border_focus) } else { rgb(theme.border_input) })
205            })
206            .when(!enabled, |d| d.border_color(rgb(theme.disabled_bg)))
207            .rounded_md()
208            .children(self.choices.iter().enumerate().map(|(idx, choice)| {
209                let choice_clone = choice.clone();
210                let is_selected = self.selected.contains(choice);
211                let is_highlighted = is_focused && idx == highlight_index && enabled;
212
213                div()
214                    .id(("ccf_checkbox_group_choice", idx))
215                    .flex()
216                    .flex_row()
217                    .gap_2()
218                    .items_center()
219                    .py_1()
220                    .px_1()
221                    .cursor_for_enabled(enabled)
222                    .rounded_sm()
223                    .when(is_highlighted, |d| d.bg(rgb(theme.bg_input_hover)))
224                    .when(!is_highlighted && enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_input_hover))))
225                    .when(enabled, |d| {
226                        d.on_click(cx.listener(move |group, _event, window, cx| {
227                            group.focus_handle.focus(window);
228                            group.highlight_index = idx;
229                            group.toggle_choice(choice_clone.clone(), cx);
230                            cx.notify();
231                        }))
232                    })
233                    .child({
234                        // Checkbox
235                        let (bg_color, border_color, check_color) = if enabled {
236                            if is_selected {
237                                (theme.accent, theme.border_checkbox, theme.bg_white)
238                            } else {
239                                (theme.bg_input, theme.border_checkbox, 0)
240                            }
241                        } else if is_selected {
242                            (theme.disabled_text, theme.disabled_text, theme.disabled_bg)
243                        } else {
244                            (theme.disabled_bg, theme.disabled_text, 0)
245                        };
246
247                        div()
248                            .w(px(16.))
249                            .h(px(16.))
250                            .border_1()
251                            .border_color(rgb(border_color))
252                            .bg(rgb(bg_color))
253                            .rounded(px(3.))
254                            .when(is_selected, |d| {
255                                d.child(
256                                    div()
257                                        .flex()
258                                        .items_center()
259                                        .justify_center()
260                                        .size_full()
261                                        .text_color(rgb(check_color))
262                                        .text_xs()
263                                        .child("✓")
264                                )
265                            })
266                    })
267                    .child(
268                        div()
269                            .text_sm()
270                            .when(enabled, |d| d.text_color(rgb(theme.text_value)))
271                            .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
272                            .child(choice.clone())
273                    )
274            }))
275    }
276}