Skip to main content

ccf_gpui_widgets/widgets/
segmented_control.rs

1//! Segmented control widget
2//!
3//! A horizontal row of mutually exclusive button-style options.
4//! Similar to iOS segmented controls or button groups in other UI frameworks.
5//!
6//! # Features
7//!
8//! - Horizontal layout of button-style segments
9//! - Single selection (like radio buttons but compact horizontal style)
10//! - Keyboard navigation (Left/Right arrows, Space/Enter to select)
11//! - Themeable with focus ring support
12//!
13//! # Generic Selection
14//!
15//! SegmentedControl is generic over any type implementing `SelectionItem`.
16//! For convenience, `SegmentOption` implements `SelectionItem` and provides
17//! a simple value/label pair for string-based selections.
18//!
19//! ## Example: Using options() for simple selections
20//!
21//! ```ignore
22//! use ccf_gpui_widgets::widgets::SegmentedControl;
23//!
24//! let control = cx.new(|cx| {
25//!     SegmentedControl::new(cx)
26//!         .options(vec![
27//!             ("fit", "Fit to Window"),
28//!             ("100", "100%"),
29//!             ("200", "200%"),
30//!         ])
31//!         .with_selected_index(0)
32//! });
33//!
34//! // Subscribe to changes
35//! cx.subscribe(&control, |this, _control, event: &SegmentedControlEvent, cx| {
36//!     if let SegmentedControlEvent::Change(option) = event {
37//!         println!("Selected: {} ({})", option.label, option.value);
38//!     }
39//! }).detach();
40//! ```
41//!
42//! ## Example: Using custom SelectionItem types
43//!
44//! ```ignore
45//! use ccf_gpui_widgets::widgets::{SegmentedControl, SelectionItem};
46//!
47//! #[derive(Clone, PartialEq)]
48//! enum ZoomLevel { Fit, Hundred, TwoHundred }
49//!
50//! impl SelectionItem for ZoomLevel { /* ... */ }
51//!
52//! let control = cx.new(|cx| {
53//!     SegmentedControl::new_with_items(
54//!         vec![ZoomLevel::Fit, ZoomLevel::Hundred, ZoomLevel::TwoHundred],
55//!         ZoomLevel::Fit,
56//!         cx,
57//!     )
58//! });
59//! ```
60//!
61//! # API Changes (2025-02)
62//!
63//! Previously used String for selection; now generic over SelectionItem.
64//! - `with_selected(&str)` → `with_selected(T)` or `with_selected_index(usize)`
65//! - `selected()` now returns `&T` instead of `&str`
66//! - Event `Change(String)` → `Change(T)`
67
68use gpui::prelude::*;
69use gpui::*;
70
71use crate::theme::{get_theme_or, Theme};
72use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
73use super::selection::SelectionItem;
74
75/// Events emitted by SegmentedControl
76#[derive(Clone, Debug)]
77pub enum SegmentedControlEvent<T: SelectionItem> {
78    /// Selected value changed
79    Change(T),
80}
81
82/// A single option in the segmented control
83///
84/// Use with `options()` builder for simple value/label pair selections.
85/// Implements `SelectionItem` for use with generic SegmentedControl.
86#[derive(Clone, PartialEq, Debug)]
87pub struct SegmentOption {
88    /// The value (used for identification and events)
89    pub value: String,
90    /// The display label
91    pub label: String,
92}
93
94impl SegmentOption {
95    /// Create a new option
96    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
97        Self {
98            value: value.into(),
99            label: label.into(),
100        }
101    }
102}
103
104impl SelectionItem for SegmentOption {
105    fn label(&self) -> SharedString {
106        self.label.clone().into()
107    }
108
109    fn id(&self) -> ElementId {
110        let id_str = format!("segment_{}", self.value.to_lowercase().replace(' ', "_"));
111        ElementId::Name(id_str.into())
112    }
113}
114
115/// Segmented control widget for single-selection from horizontal options
116///
117/// Generic over `T: SelectionItem`. For simple string-based selections,
118/// use the default `SegmentedControl<SegmentOption>` via the `options()` builder.
119pub struct SegmentedControl<T: SelectionItem = SegmentOption> {
120    items: Vec<T>,
121    selected: T,
122    focus_handle: FocusHandle,
123    highlight_index: usize,
124    custom_theme: Option<Theme>,
125    enabled: bool,
126    /// Gap between segment buttons
127    button_gap: Pixels,
128}
129
130impl<T: SelectionItem> EventEmitter<SegmentedControlEvent<T>> for SegmentedControl<T> {}
131
132impl<T: SelectionItem> Focusable for SegmentedControl<T> {
133    fn focus_handle(&self, _cx: &App) -> FocusHandle {
134        self.focus_handle.clone()
135    }
136}
137
138impl SegmentedControl<SegmentOption> {
139    /// Create a new segmented control for SegmentOption selections
140    ///
141    /// Use `options()` to set the available options.
142    pub fn new(cx: &mut Context<Self>) -> Self {
143        Self {
144            items: Vec::new(),
145            selected: SegmentOption::new("", ""),
146            focus_handle: cx.focus_handle().tab_stop(true),
147            highlight_index: 0,
148            custom_theme: None,
149            enabled: true,
150            button_gap: px(8.0),
151        }
152    }
153
154    /// Set options from a slice of (value, label) tuples (builder pattern)
155    #[must_use]
156    pub fn options(mut self, options: Vec<(&str, &str)>) -> Self {
157        self.items = options
158            .into_iter()
159            .map(|(v, l)| SegmentOption::new(v, l))
160            .collect();
161        if !self.items.is_empty() && self.selected.value.is_empty() {
162            self.selected = self.items[0].clone();
163        }
164        self
165    }
166
167    /// Set options from SegmentOption structs (builder pattern)
168    #[must_use]
169    pub fn with_options(mut self, options: Vec<SegmentOption>) -> Self {
170        self.items = options;
171        if !self.items.is_empty() && self.selected.value.is_empty() {
172            self.selected = self.items[0].clone();
173        }
174        self
175    }
176
177    /// Set selected value by string (builder pattern)
178    ///
179    /// For use with `options()` API. Matches by value field.
180    #[must_use]
181    pub fn with_selected_value(mut self, value: &str) -> Self {
182        if let Some(index) = self.items.iter().position(|o| o.value == value) {
183            self.selected = self.items[index].clone();
184            self.highlight_index = index;
185        }
186        self
187    }
188
189    /// Set selected by string value programmatically
190    ///
191    /// For use with `options()` API. Emits Change event if value changes.
192    pub fn set_selected_value(&mut self, value: &str, cx: &mut Context<Self>) {
193        if let Some(index) = self.items.iter().position(|o| o.value == value) {
194            if self.selected.value != value {
195                self.selected = self.items[index].clone();
196                self.highlight_index = index;
197                cx.emit(SegmentedControlEvent::Change(self.selected.clone()));
198                cx.notify();
199            }
200        }
201    }
202
203    /// Get the selected value as a string
204    ///
205    /// Convenience method for SegmentOption-based controls.
206    pub fn selected_value(&self) -> &str {
207        &self.selected.value
208    }
209}
210
211impl<T: SelectionItem> SegmentedControl<T> {
212    /// Create a new segmented control with items and initial selection
213    pub fn new_with_items(items: Vec<T>, selected: T, cx: &mut Context<Self>) -> Self {
214        let highlight_index = items.iter().position(|i| *i == selected).unwrap_or(0);
215        Self {
216            items,
217            selected,
218            focus_handle: cx.focus_handle().tab_stop(true),
219            highlight_index,
220            custom_theme: None,
221            enabled: true,
222            button_gap: px(8.0),
223        }
224    }
225
226    /// Set items (builder pattern)
227    #[must_use]
228    pub fn with_items(mut self, items: Vec<T>) -> Self {
229        self.items = items;
230        if !self.items.is_empty() {
231            self.selected = self.items[0].clone();
232            self.highlight_index = 0;
233        }
234        self
235    }
236
237    /// Set selected item (builder pattern)
238    #[must_use]
239    pub fn with_selected(mut self, item: T) -> Self {
240        if let Some(index) = self.items.iter().position(|i| *i == item) {
241            self.selected = item;
242            self.highlight_index = index;
243        }
244        self
245    }
246
247    /// Set selected by index (builder pattern)
248    #[must_use]
249    pub fn with_selected_index(mut self, index: usize) -> Self {
250        if let Some(item) = self.items.get(index) {
251            self.selected = item.clone();
252            self.highlight_index = index;
253        }
254        self
255    }
256
257    /// Set custom theme (builder pattern)
258    #[must_use]
259    pub fn theme(mut self, theme: Theme) -> Self {
260        self.custom_theme = Some(theme);
261        self
262    }
263
264    /// Set enabled state (builder pattern)
265    #[must_use]
266    pub fn with_enabled(mut self, enabled: bool) -> Self {
267        self.enabled = enabled;
268        self
269    }
270
271    /// Set gap between segment buttons (builder pattern)
272    ///
273    /// Default is 8px.
274    #[must_use]
275    pub fn with_button_gap(mut self, gap: impl Into<Pixels>) -> Self {
276        self.button_gap = gap.into();
277        self
278    }
279
280    /// Get the currently selected item
281    pub fn selected(&self) -> &T {
282        &self.selected
283    }
284
285    /// Get the currently selected index
286    pub fn selected_index(&self) -> usize {
287        self.items.iter().position(|i| *i == self.selected).unwrap_or(0)
288    }
289
290    /// Set selected item programmatically
291    ///
292    /// Emits Change event if selection changes.
293    pub fn set_selected(&mut self, item: T, cx: &mut Context<Self>) {
294        if let Some(index) = self.items.iter().position(|i| *i == item) {
295            if self.selected != item {
296                self.selected = item;
297                self.highlight_index = index;
298                cx.emit(SegmentedControlEvent::Change(self.selected.clone()));
299                cx.notify();
300            }
301        }
302    }
303
304    /// Set selected by index programmatically
305    ///
306    /// Emits Change event if selection changes.
307    pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
308        if let Some(item) = self.items.get(index).cloned() {
309            if self.selected != item {
310                self.selected = item;
311                self.highlight_index = index;
312                cx.emit(SegmentedControlEvent::Change(self.selected.clone()));
313                cx.notify();
314            }
315        }
316    }
317
318    /// Get the focus handle
319    pub fn focus_handle(&self) -> &FocusHandle {
320        &self.focus_handle
321    }
322
323    /// Check if enabled
324    pub fn is_enabled(&self) -> bool {
325        self.enabled
326    }
327
328    /// Set enabled state programmatically
329    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
330        if self.enabled != enabled {
331            self.enabled = enabled;
332            cx.notify();
333        }
334    }
335
336    fn select_by_index(&mut self, cx: &mut Context<Self>) {
337        if let Some(item) = self.items.get(self.highlight_index) {
338            if self.selected != *item {
339                self.selected = item.clone();
340                cx.emit(SegmentedControlEvent::Change(self.selected.clone()));
341            }
342        }
343    }
344}
345
346impl<T: SelectionItem> Render for SegmentedControl<T> {
347    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
348        let theme = get_theme_or(cx, self.custom_theme.as_ref());
349        let focus_handle = self.focus_handle.clone();
350        let is_focused = self.focus_handle.is_focused(window);
351        let highlight_index = self.highlight_index;
352        let num_items = self.items.len();
353        let enabled = self.enabled;
354
355        with_focus_actions(
356            div()
357                .id("ccf_segmented_control")
358                .track_focus(&focus_handle)
359                .tab_stop(enabled),
360            cx,
361        )
362        .on_key_down(cx.listener(move |control, event: &KeyDownEvent, window, cx| {
363            if !control.enabled {
364                return;
365            }
366            if handle_tab_navigation(event, window) {
367                return;
368            }
369            match event.keystroke.key.as_str() {
370                "left" => {
371                    if control.highlight_index > 0 {
372                        control.highlight_index -= 1;
373                    } else if num_items > 0 {
374                        control.highlight_index = num_items - 1;
375                    }
376                    control.select_by_index(cx);
377                    cx.notify();
378                    cx.stop_propagation();
379                }
380                "right" => {
381                    if control.highlight_index < num_items.saturating_sub(1) {
382                        control.highlight_index += 1;
383                    } else {
384                        control.highlight_index = 0;
385                    }
386                    control.select_by_index(cx);
387                    cx.notify();
388                    cx.stop_propagation();
389                }
390                "space" | "enter" => {
391                    control.select_by_index(cx);
392                    cx.notify();
393                    cx.stop_propagation();
394                }
395                _ => {}
396            }
397        }))
398        .flex()
399        .flex_row()
400        .gap(self.button_gap)
401        .children(self.items.iter().enumerate().map(|(idx, item)| {
402            let item_clone = item.clone();
403            let is_selected = self.selected == *item;
404            let is_highlighted = is_focused && idx == highlight_index && enabled;
405
406            let mut segment = div()
407                .id(item.id())
408                .px_3()
409                .py_1()
410                .rounded(px(4.0))
411                .border_1()
412                .text_sm()
413                .cursor_for_enabled(enabled);
414
415            // Apply styling based on state
416            // Create semi-transparent version of focus color for selected background
417            let selected_bg = (theme.border_focus << 8) | 0x22;
418
419            segment = if !enabled {
420                segment
421                    .border_color(rgb(theme.disabled_bg))
422                    .text_color(rgb(theme.disabled_text))
423                    .bg(rgb(theme.disabled_bg))
424            } else if is_selected {
425                segment
426                    .border_color(rgb(theme.border_focus))
427                    .bg(rgba(selected_bg))
428                    .text_color(rgb(theme.text_primary))
429            } else if is_highlighted {
430                segment
431                    .border_color(rgb(theme.border_input))
432                    .bg(rgb(theme.bg_hover))
433                    .text_color(rgb(theme.text_primary))
434            } else {
435                segment
436                    .border_color(rgb(theme.border_default))
437                    .text_color(rgb(theme.text_value))
438            };
439
440            if enabled {
441                segment = segment
442                    .hover(|s| s.bg(rgb(theme.bg_hover)))
443                    .on_mouse_down(MouseButton::Left, cx.listener(move |control, _event: &MouseDownEvent, window, cx| {
444                        control.focus_handle.focus(window);
445                        if let Some(index) = control.items.iter().position(|i| *i == item_clone) {
446                            control.highlight_index = index;
447                            control.select_by_index(cx);
448                        }
449                        cx.notify();
450                    }));
451            }
452
453            // Inner focus ring around text (border always present to prevent layout shift)
454            segment.child(
455                div()
456                    .px_1()
457                    .border_1()
458                    .rounded_sm()
459                    .when(is_highlighted, |d| d.border_color(rgb(theme.border_focus)))
460                    .when(!is_highlighted, |d| d.border_color(rgba(0x00000000)))
461                    .child(item.label())
462            )
463        }))
464    }
465}