Skip to main content

ccf_gpui_widgets/widgets/
radio_group.rs

1//! Radio group widget
2//!
3//! A group of radio buttons for single-selection from multiple choices.
4//! Supports keyboard navigation (Up/Down arrows, Space to select).
5//!
6//! # Generic Selection
7//!
8//! RadioGroup is generic over any type implementing `SelectionItem`. For simple string
9//! selections, use the `choices()` builder which creates `StringItem` instances internally.
10//!
11//! ## Example: Using choices() for strings (backward compatible)
12//!
13//! ```ignore
14//! use ccf_gpui_widgets::widgets::RadioGroup;
15//!
16//! let radio = cx.new(|cx| {
17//!     RadioGroup::new(cx)
18//!         .choices(vec!["Small".to_string(), "Medium".to_string(), "Large".to_string()])
19//!         .with_selected_index(1)
20//! });
21//!
22//! // Subscribe to changes
23//! cx.subscribe(&radio, |this, _radio, event: &RadioGroupEvent, cx| {
24//!     if let RadioGroupEvent::Change(item) = event {
25//!         println!("Selected: {}", item.value());
26//!     }
27//! }).detach();
28//! ```
29//!
30//! ## Example: Using custom SelectionItem types
31//!
32//! ```ignore
33//! use ccf_gpui_widgets::widgets::{RadioGroup, SelectionItem};
34//! use gpui::*;
35//!
36//! #[derive(Clone, PartialEq)]
37//! enum Size { Small, Medium, Large }
38//!
39//! impl SelectionItem for Size {
40//!     fn label(&self) -> SharedString { /* ... */ }
41//!     fn id(&self) -> ElementId { /* ... */ }
42//! }
43//!
44//! let radio = cx.new(|cx| {
45//!     RadioGroup::new_with_items(
46//!         vec![Size::Small, Size::Medium, Size::Large],
47//!         Size::Medium,
48//!         cx,
49//!     )
50//! });
51//! ```
52//!
53//! # API Changes (2025-02)
54//!
55//! Previously used String directly; now generic over SelectionItem.
56//! - `with_selected_value(&str)` → `with_selected(T)` or `with_selected_index(usize)`
57//! - `selected()` now returns `&T` instead of `&str`
58//! - Event `Change(String)` → `Change(T)`
59//! - Use `choices()` for backward-compatible string selection
60
61use gpui::prelude::*;
62use gpui::*;
63
64use crate::theme::{get_theme_or, Theme};
65use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
66use super::selection::{SelectionItem, StringItem};
67
68/// Events emitted by RadioGroup
69#[derive(Clone, Debug)]
70pub enum RadioGroupEvent<T: SelectionItem> {
71    /// Selected value changed
72    Change(T),
73}
74
75/// Radio group widget for single-selection
76///
77/// Generic over `T: SelectionItem`. For simple string selections, use the default
78/// `RadioGroup<StringItem>` via the `choices()` builder.
79pub struct RadioGroup<T: SelectionItem = StringItem> {
80    items: Vec<T>,
81    selected: T,
82    focus_handle: FocusHandle,
83    highlight_index: usize,
84    custom_theme: Option<Theme>,
85    /// Whether the widget is enabled (interactive)
86    enabled: bool,
87}
88
89impl<T: SelectionItem> EventEmitter<RadioGroupEvent<T>> for RadioGroup<T> {}
90
91impl<T: SelectionItem> Focusable for RadioGroup<T> {
92    fn focus_handle(&self, _cx: &App) -> FocusHandle {
93        self.focus_handle.clone()
94    }
95}
96
97impl RadioGroup<StringItem> {
98    /// Create a new radio group for string selections
99    ///
100    /// Use `choices()` to set the available options.
101    pub fn new(cx: &mut Context<Self>) -> Self {
102        Self {
103            items: Vec::new(),
104            selected: StringItem::new(""),
105            focus_handle: cx.focus_handle().tab_stop(true),
106            highlight_index: 0,
107            custom_theme: None,
108            enabled: true,
109        }
110    }
111
112    /// Set choices from strings (builder pattern)
113    ///
114    /// This is the backward-compatible API for string-based selections.
115    #[must_use]
116    pub fn choices(mut self, choices: Vec<String>) -> Self {
117        self.items = choices.into_iter().map(StringItem::new).collect();
118        if !self.items.is_empty() && self.selected.value().is_empty() {
119            self.selected = self.items[0].clone();
120        }
121        self
122    }
123
124    /// Set selected value by string (builder pattern)
125    ///
126    /// For use with `choices()` API.
127    #[must_use]
128    pub fn with_selected_value(mut self, value: &str) -> Self {
129        if let Some(index) = self.items.iter().position(|c| c.value() == value) {
130            self.selected = self.items[index].clone();
131            self.highlight_index = index;
132        }
133        self
134    }
135
136    /// Set selected by string value programmatically
137    ///
138    /// For use with `choices()` API. Emits Change event if value changes.
139    pub fn set_selected_value(&mut self, value: &str, cx: &mut Context<Self>) {
140        if let Some(index) = self.items.iter().position(|c| c.value() == value) {
141            if self.selected.value() != value {
142                self.selected = self.items[index].clone();
143                self.highlight_index = index;
144                cx.emit(RadioGroupEvent::Change(self.selected.clone()));
145                cx.notify();
146            }
147        }
148    }
149
150    /// Get the selected value as a string
151    ///
152    /// Convenience method for string-based radio groups.
153    pub fn selected_value(&self) -> &str {
154        self.selected.value()
155    }
156}
157
158impl<T: SelectionItem> RadioGroup<T> {
159    /// Create a new radio group with items and initial selection
160    pub fn new_with_items(items: Vec<T>, selected: T, cx: &mut Context<Self>) -> Self {
161        let highlight_index = items.iter().position(|i| *i == selected).unwrap_or(0);
162        Self {
163            items,
164            selected,
165            focus_handle: cx.focus_handle().tab_stop(true),
166            highlight_index,
167            custom_theme: None,
168            enabled: true,
169        }
170    }
171
172    /// Set items (builder pattern)
173    #[must_use]
174    pub fn with_items(mut self, items: Vec<T>) -> Self {
175        self.items = items;
176        if !self.items.is_empty() {
177            self.selected = self.items[0].clone();
178            self.highlight_index = 0;
179        }
180        self
181    }
182
183    /// Set selected item (builder pattern)
184    #[must_use]
185    pub fn with_selected(mut self, item: T) -> Self {
186        if let Some(index) = self.items.iter().position(|i| *i == item) {
187            self.selected = item;
188            self.highlight_index = index;
189        }
190        self
191    }
192
193    /// Set selected by index (builder pattern)
194    #[must_use]
195    pub fn with_selected_index(mut self, index: usize) -> Self {
196        if let Some(item) = self.items.get(index) {
197            self.selected = item.clone();
198            self.highlight_index = index;
199        }
200        self
201    }
202
203    /// Set custom theme (builder pattern)
204    #[must_use]
205    pub fn theme(mut self, theme: Theme) -> Self {
206        self.custom_theme = Some(theme);
207        self
208    }
209
210    /// Set enabled state (builder pattern)
211    #[must_use]
212    pub fn with_enabled(mut self, enabled: bool) -> Self {
213        self.enabled = enabled;
214        self
215    }
216
217    /// Get the currently selected item
218    pub fn selected(&self) -> &T {
219        &self.selected
220    }
221
222    /// Get the currently selected index
223    pub fn selected_index(&self) -> usize {
224        self.items.iter().position(|i| *i == self.selected).unwrap_or(0)
225    }
226
227    /// Set selected item programmatically
228    ///
229    /// Emits Change event if selection changes.
230    pub fn set_selected(&mut self, item: T, cx: &mut Context<Self>) {
231        if let Some(index) = self.items.iter().position(|i| *i == item) {
232            if self.selected != item {
233                self.selected = item;
234                self.highlight_index = index;
235                cx.emit(RadioGroupEvent::Change(self.selected.clone()));
236                cx.notify();
237            }
238        }
239    }
240
241    /// Set selected by index programmatically
242    ///
243    /// Emits Change event if selection changes.
244    pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
245        if let Some(item) = self.items.get(index).cloned() {
246            if self.selected != item {
247                self.selected = item;
248                self.highlight_index = index;
249                cx.emit(RadioGroupEvent::Change(self.selected.clone()));
250                cx.notify();
251            }
252        }
253    }
254
255    /// Get the focus handle
256    pub fn focus_handle(&self) -> &FocusHandle {
257        &self.focus_handle
258    }
259
260    /// Check if the radio group is enabled
261    pub fn is_enabled(&self) -> bool {
262        self.enabled
263    }
264
265    /// Set enabled state programmatically
266    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
267        if self.enabled != enabled {
268            self.enabled = enabled;
269            cx.notify();
270        }
271    }
272
273    fn select_by_index(&mut self, cx: &mut Context<Self>) {
274        if let Some(item) = self.items.get(self.highlight_index) {
275            if self.selected != *item {
276                self.selected = item.clone();
277                cx.emit(RadioGroupEvent::Change(self.selected.clone()));
278            }
279        }
280    }
281}
282
283impl<T: SelectionItem> Render for RadioGroup<T> {
284    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
285        let theme = get_theme_or(cx, self.custom_theme.as_ref());
286        let focus_handle = self.focus_handle.clone();
287        let is_focused = self.focus_handle.is_focused(window);
288        let highlight_index = self.highlight_index;
289        let num_items = self.items.len();
290        let enabled = self.enabled;
291
292        with_focus_actions(
293            div()
294                .id("ccf_radio_group")
295                .track_focus(&focus_handle)
296                .tab_stop(enabled),
297            cx,
298        )
299        .on_key_down(cx.listener(move |radio_group, event: &KeyDownEvent, window, cx| {
300                if !radio_group.enabled {
301                    return;
302                }
303                if handle_tab_navigation(event, window) {
304                    return;
305                }
306                match event.keystroke.key.as_str() {
307                    "up" => {
308                        if radio_group.highlight_index > 0 {
309                            radio_group.highlight_index -= 1;
310                        } else if num_items > 0 {
311                            radio_group.highlight_index = num_items - 1;
312                        }
313                        radio_group.select_by_index(cx);
314                        cx.notify();
315                    }
316                    "down" => {
317                        if radio_group.highlight_index < num_items.saturating_sub(1) {
318                            radio_group.highlight_index += 1;
319                        } else {
320                            radio_group.highlight_index = 0;
321                        }
322                        radio_group.select_by_index(cx);
323                        cx.notify();
324                    }
325                    "space" => {
326                        radio_group.select_by_index(cx);
327                        cx.notify();
328                    }
329                    _ => {}
330                }
331            }))
332            .flex()
333            .flex_col()
334            .gap_1()
335            .p_2()
336            .when(enabled, |d| d.bg(rgb(theme.bg_input)))
337            .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
338            .border_1()
339            .when(enabled, |d| {
340                d.border_color(if is_focused { rgb(theme.border_focus) } else { rgb(theme.border_input) })
341            })
342            .when(!enabled, |d| d.border_color(rgb(theme.disabled_bg)))
343            .rounded_md()
344            .children(self.items.iter().enumerate().map(|(idx, item)| {
345                let item_clone = item.clone();
346                let is_selected = self.selected == *item;
347                let is_highlighted = is_focused && idx == highlight_index && enabled;
348
349                div()
350                    .id(item.id())
351                    .flex()
352                    .flex_row()
353                    .gap_2()
354                    .items_center()
355                    .py_1()
356                    .px_1()
357                    .cursor_for_enabled(enabled)
358                    .rounded_sm()
359                    .when(is_highlighted, |d| d.bg(rgb(theme.bg_input_hover)))
360                    .when(!is_highlighted && enabled, |d| d.hover(|d| d.bg(rgb(theme.bg_input_hover))))
361                    .when(enabled, |d| {
362                        d.on_click(cx.listener(move |radio_group, _event, window, cx| {
363                            radio_group.focus_handle.focus(window);
364                            radio_group.selected = item_clone.clone();
365                            radio_group.highlight_index = idx;
366                            cx.emit(RadioGroupEvent::Change(item_clone.clone()));
367                            cx.notify();
368                        }))
369                    })
370                    .child({
371                        // Radio button (circle)
372                        let border_color = if enabled { theme.border_checkbox } else { theme.disabled_text };
373                        let inner_color = if enabled { theme.accent } else { theme.disabled_text };
374
375                        div()
376                            .w(px(16.))
377                            .h(px(16.))
378                            .border_1()
379                            .border_color(rgb(border_color))
380                            .rounded(px(8.))
381                            .when(is_selected, |d| {
382                                d.child(
383                                    div()
384                                        .flex()
385                                        .items_center()
386                                        .justify_center()
387                                        .size_full()
388                                        .child(
389                                            div()
390                                                .w(px(8.))
391                                                .h(px(8.))
392                                                .bg(rgb(inner_color))
393                                                .rounded(px(4.))
394                                        )
395                                )
396                            })
397                    })
398                    .child(
399                        div()
400                            .text_sm()
401                            .when(enabled, |d| d.text_color(rgb(theme.text_value)))
402                            .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
403                            .child(item.label())
404                    )
405            }))
406    }
407}