Skip to main content

ui/components/
segmented_control.rs

1use gpui::{AnyElement, ElementId, IntoElement, white};
2use std::rc::Rc;
3
4use crate::prelude::*;
5
6/// A horizontal row of connected segments where exactly one is active.
7/// Mirrors `RadioButton`'s checked/on_click pattern, rendered as a single
8/// connected pill row (visual family of Phase 2's `ButtonGroup`).
9///
10/// Exclusivity/state is caller-managed: the parent holds the active index
11/// and passes it in via `.active(index)`.
12#[derive(IntoElement, RegisterComponent)]
13pub struct SegmentedControl {
14    id: ElementId,
15    segments: Vec<SharedString>,
16    active: usize,
17    disabled: bool,
18    on_change: Option<Rc<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
19}
20
21impl SegmentedControl {
22    pub fn new(
23        id: impl Into<ElementId>,
24        segments: impl IntoIterator<Item = impl Into<SharedString>>,
25    ) -> Self {
26        Self {
27            id: id.into(),
28            segments: segments.into_iter().map(Into::into).collect(),
29            active: 0,
30            disabled: false,
31            on_change: None,
32        }
33    }
34
35    /// Sets the active segment index.
36    pub fn active(mut self, index: usize) -> Self {
37        self.active = index;
38        self
39    }
40
41    pub fn disabled(mut self, disabled: bool) -> Self {
42        self.disabled = disabled;
43        self
44    }
45
46    /// Binds a handler called with the clicked segment's index.
47    pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
48        self.on_change = Some(Rc::new(handler));
49        self
50    }
51}
52
53impl RenderOnce for SegmentedControl {
54    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
55        let active = self.active;
56        let disabled = self.disabled;
57        let group_id = self.id.clone();
58        let on_change = self.on_change;
59
60        let mut row = h_flex()
61            .id(self.id)
62            .rounded_md()
63            .border_1()
64            .border_color(semantic::border(cx))
65            .bg(semantic::surface(cx))
66            .p(px(2.))
67            .gap_0p5();
68
69        for (i, segment) in self.segments.into_iter().enumerate() {
70            let is_active = i == active;
71            let on_change = on_change.clone();
72
73            let mut cell = h_flex()
74                .id((group_id.clone(), i.to_string()))
75                // Test-only (no-op in release builds, per `debug_selector`'s
76                // own doc comment): lets integration tests locate a segment's
77                // real rendered pixel bounds via `VisualTestContext::debug_bounds`
78                // and drive a genuine `simulate_click`, instead of calling
79                // `on_change` directly. Mirrors the existing `Tab`/`ContextMenu`
80                // precedent for this exact pattern.
81                .debug_selector({
82                    let group_id = group_id.clone();
83                    move || format!("SEGMENT-{}-{}", group_id, i)
84                })
85                .flex_1()
86                .justify_center()
87                .px_3()
88                .py_1()
89                .rounded_sm()
90                .when(is_active, |this| this.bg(palette::primary(600)))
91                .when(disabled, |this| this.opacity(0.5))
92                .child(
93                    Label::new(segment)
94                        .size(LabelSize::Small)
95                        .color(if is_active {
96                            Color::Custom(white())
97                        } else {
98                            Color::Default
99                        }),
100                );
101
102            if !disabled {
103                cell = cell.cursor_pointer().on_click(move |_, window, cx| {
104                    if let Some(handler) = &on_change {
105                        handler(i, window, cx);
106                    }
107                });
108            }
109
110            row = row.child(cell);
111        }
112
113        row
114    }
115}
116
117impl Component for SegmentedControl {
118    fn scope() -> ComponentScope {
119        ComponentScope::Input
120    }
121
122    fn description() -> Option<&'static str> {
123        Some("A connected row of segments where exactly one is active.")
124    }
125
126    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
127        Some(
128            v_flex()
129                .gap_4()
130                .child(
131                    SegmentedControl::new("segmented-default", ["Day", "Week", "Month"])
132                        .active(1)
133                        .into_any_element(),
134                )
135                .child(
136                    SegmentedControl::new("segmented-disabled", ["List", "Grid"])
137                        .disabled(true)
138                        .into_any_element(),
139                )
140                .into_any_element(),
141        )
142    }
143}