Skip to main content

guise/input/
segmented.rs

1//! `SegmentedControl` — a stateful single-choice segmented switch (gpui entity).
2
3use gpui::prelude::*;
4use gpui::{div, px, App, Context, Entity, EventEmitter, IntoElement, SharedString, Window};
5
6use super::control_metrics;
7use crate::reactive::Signal;
8use crate::theme::{theme, ColorName, Size};
9
10/// Emitted when the selected segment changes. Carries the option index.
11#[derive(Debug, Clone)]
12pub struct SegmentedControlEvent(pub usize);
13
14/// A segmented control. Create with
15/// `cx.new(|cx| SegmentedControl::new(cx).data(["Day", "Week", "Month"]))`.
16pub struct SegmentedControl {
17    options: Vec<SharedString>,
18    selected: usize,
19    size: Size,
20}
21
22impl EventEmitter<SegmentedControlEvent> for SegmentedControl {}
23
24impl SegmentedControl {
25    pub fn new(_cx: &mut Context<Self>) -> Self {
26        SegmentedControl {
27            options: Vec::new(),
28            selected: 0,
29            size: Size::Sm,
30        }
31    }
32
33    pub fn data<I, S>(mut self, options: I) -> Self
34    where
35        I: IntoIterator<Item = S>,
36        S: Into<SharedString>,
37    {
38        self.options = options.into_iter().map(Into::into).collect();
39        self
40    }
41
42    pub fn selected(mut self, index: usize) -> Self {
43        self.selected = index;
44        self
45    }
46
47    pub fn size(mut self, size: Size) -> Self {
48        self.size = size;
49        self
50    }
51
52    pub fn selected_index(&self) -> usize {
53        self.selected
54    }
55
56    /// Two-way bind this control's selection to a `Signal<usize>`. The signal
57    /// is the source of truth: the control adopts its index now, clicks write
58    /// back through [`Signal::set_if_changed`], and signal writes move the
59    /// selection without emitting [`SegmentedControlEvent`]. Equality guards
60    /// on both directions prevent update loops.
61    pub fn bind(entity: &Entity<SegmentedControl>, signal: &Signal<usize>, cx: &mut App) {
62        let initial = signal.get(cx);
63        entity.update(cx, |this, cx| this.sync_selected(initial, cx));
64        let sink = signal.clone();
65        cx.subscribe(
66            entity,
67            move |_control, event: &SegmentedControlEvent, cx| {
68                sink.set_if_changed(cx, event.0);
69            },
70        )
71        .detach();
72        let control = entity.downgrade();
73        cx.observe(signal.entity(), move |observed, cx| {
74            let index = *observed.read(cx);
75            control
76                .update(cx, |this, cx| this.sync_selected(index, cx))
77                .ok();
78        })
79        .detach();
80    }
81
82    /// Programmatic set: repaint without emitting an event.
83    fn sync_selected(&mut self, index: usize, cx: &mut Context<Self>) {
84        if self.selected != index {
85            self.selected = index;
86            cx.notify();
87        }
88    }
89}
90
91impl Render for SegmentedControl {
92    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
93        let t = theme(cx);
94        let (height, pad_x, font) = control_metrics(self.size);
95        let radius = t.radius(Size::Sm);
96        let track = t
97            .color(ColorName::Gray, if t.scheme.is_dark() { 8 } else { 1 })
98            .hsla();
99        let active_bg = t.surface().hsla();
100        let active_fg = t.text().hsla();
101        let inactive_fg = t.dimmed().hsla();
102
103        let count = self.options.len();
104        let selected = if count == 0 {
105            0
106        } else {
107            self.selected.min(count - 1)
108        };
109
110        let mut row = div()
111            .flex()
112            .items_center()
113            .gap(px(2.0))
114            .p(px(3.0))
115            .rounded(px(radius + 2.0))
116            .bg(track);
117
118        for (i, option) in self.options.iter().enumerate() {
119            let is_active = i == selected;
120            let mut seg = div()
121                .id(("guise-segment", i))
122                .flex()
123                .items_center()
124                .justify_center()
125                .h(px(height - 6.0))
126                .px(px(pad_x))
127                .rounded(px(radius))
128                .text_size(px(font))
129                .text_color(if is_active { active_fg } else { inactive_fg })
130                .child(option.clone())
131                .on_click(cx.listener(move |this, _ev, _window, cx| {
132                    this.selected = i;
133                    cx.emit(SegmentedControlEvent(i));
134                    cx.notify();
135                }));
136            if is_active {
137                seg = seg.bg(active_bg).shadow_sm();
138            }
139            row = row.child(seg);
140        }
141        row
142    }
143}