Skip to main content

gpui_kit/controls/
segmented.rs

1//! A single choice presented as a strip of adjacent segments.
2//!
3//! A segmented control is a radio group that looks like a strip, so that is
4//! what it publishes: a group of `Radio` nodes, exactly one of which is
5//! checked. The choice is the caller's; the strip reports which segment was
6//! asked for and draws whichever one the caller says holds.
7
8use std::rc::Rc;
9
10use gpui::{
11    App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
12    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
13};
14use gpui_kit_assets::{Icon, icon};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Surface, TypeScale};
17
18use crate::foundation::direction::ActiveDirection;
19use crate::foundation::stepping::bounded_step;
20use crate::foundation::{
21    Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
22};
23use crate::motion::{Flipping, flip};
24
25type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
26
27/// One choice in the strip, identified by business identity.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Segment {
30    id: SharedString,
31    label: SharedString,
32    icon: Option<Icon>,
33    disabled: bool,
34}
35
36impl Segment {
37    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
38        Self {
39            id: id.into(),
40            label: label.into(),
41            icon: None,
42            disabled: false,
43        }
44    }
45
46    pub fn icon(mut self, glyph: Icon) -> Self {
47        self.icon = Some(glyph);
48        self
49    }
50
51    /// Refuses the segment. A refused segment installs no handler and the
52    /// keyboard steps over it.
53    pub fn disabled(mut self, disabled: bool) -> Self {
54        self.disabled = disabled;
55        self
56    }
57
58    pub fn id(&self) -> &SharedString {
59        &self.id
60    }
61
62    pub fn label(&self) -> &SharedString {
63        &self.label
64    }
65
66    pub fn is_disabled(&self) -> bool {
67        self.disabled
68    }
69}
70
71/// A strip where exactly one segment holds.
72#[derive(IntoElement)]
73pub struct SegmentedControl {
74    ident: Ident,
75    label: Option<SharedString>,
76    segments: Vec<Segment>,
77    selected: Option<SharedString>,
78    size: ControlSize,
79    disabled: bool,
80    on_select: Option<SelectHandler>,
81}
82
83impl std::fmt::Debug for SegmentedControl {
84    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        formatter
86            .debug_struct("SegmentedControl")
87            .field("ident", &self.ident)
88            .field("segments", &self.segments.len())
89            .field("selected", &self.selected)
90            .field("disabled", &self.disabled)
91            .field("has_handler", &self.on_select.is_some())
92            .finish()
93    }
94}
95
96impl SegmentedControl {
97    pub fn new(ident: impl Into<Ident>) -> Self {
98        Self {
99            ident: ident.into(),
100            label: None,
101            segments: Vec::new(),
102            selected: None,
103            size: ControlSize::Md,
104            disabled: false,
105            on_select: None,
106        }
107    }
108
109    /// What the whole strip is asking, for assistive technology.
110    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
111        self.label = Some(label.into());
112        self
113    }
114
115    pub fn segments(mut self, segments: impl IntoIterator<Item = Segment>) -> Self {
116        self.segments = segments.into_iter().collect();
117        self
118    }
119
120    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
121        self.selected = Some(id.into());
122        self
123    }
124
125    pub fn on_select(
126        mut self,
127        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
128    ) -> Self {
129        self.on_select = Some(Rc::new(handler));
130        self
131    }
132
133    fn actionable(&self) -> bool {
134        !self.disabled && self.on_select.is_some()
135    }
136
137    fn selected_index(&self) -> Option<usize> {
138        let id = self.selected.as_ref()?;
139        self.segments.iter().position(|segment| &segment.id == id)
140    }
141}
142
143impl Disableable for SegmentedControl {
144    fn disabled(mut self, disabled: bool) -> Self {
145        self.disabled = disabled;
146        self
147    }
148}
149
150impl Sizable for SegmentedControl {
151    fn control_size(mut self, size: ControlSize) -> Self {
152        self.size = size;
153        self
154    }
155}
156
157/// The segment `delta` steps away from `from`, skipping refusals and
158/// stopping at the ends rather than wrapping onto the other side of the
159/// strip, which a strip does not look like it does.
160fn neighbour(segments: &[Segment], from: Option<usize>, delta: isize) -> Option<usize> {
161    bounded_step(segments.len(), from, delta, |index| {
162        segments[index].disabled
163    })
164}
165
166/// The first segment that can be chosen, from whichever end.
167fn edge(segments: &[Segment], from_start: bool) -> Option<usize> {
168    if from_start {
169        neighbour(segments, None, 1)
170    } else {
171        neighbour(segments, None, -1)
172    }
173}
174
175impl RenderOnce for SegmentedControl {
176    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
177        let theme = cx.theme().clone();
178        let metrics = theme.control.get(self.size);
179        let actionable = self.actionable();
180        let strip_id = self.ident.semantic_id();
181        // One background for the whole strip, drawn inside whichever segment
182        // holds. Because it is the same element from frame to frame, changing
183        // the choice moves it rather than redrawing it somewhere else.
184        let selection = flip(self.ident.child("selection").semantic_id(), cx);
185
186        let segments = self
187            .segments
188            .iter()
189            .map(|segment| {
190                let selected = self.selected.as_ref() == Some(&segment.id);
191                let refused = self.disabled || segment.disabled;
192                let ident = self.ident.child(segment.id.as_ref());
193                let hover_group = ident.child("hover").semantic_id();
194                let id = segment.id.clone();
195                let label_color = if refused {
196                    theme.colors.text_faint
197                } else if selected {
198                    theme.colors.text
199                } else {
200                    theme.colors.text_muted
201                };
202
203                let fill = selected.then(|| {
204                    div()
205                        .absolute()
206                        .inset_0()
207                        .radius(&theme, Radius::Control)
208                        .bg(theme.colors.raised)
209                        .shadow(theme.shadow(gpui_kit_theme::Elevation::Raised).to_vec())
210                        .flip(&selection, window, cx)
211                });
212
213                div()
214                    .id(ident.element_id())
215                    .group(hover_group.clone())
216                    .row()
217                    .justify_center()
218                    .flex_none()
219                    .relative()
220                    .h(px(metrics.height - 2.0 * theme.borders.hairline))
221                    .gap(px(metrics.gap))
222                    .px(px(metrics.padding_x))
223                    .radius(&theme, Radius::Control)
224                    .children(fill)
225                    .when(segment.disabled, |element| {
226                        element.opacity(theme.opacity.disabled)
227                    })
228                    .when(actionable && !segment.disabled, |element| {
229                        element.cursor_pointer().pressable(cx).on_click({
230                            let handler = self.on_select.clone().expect("checked above");
231                            move |_, window, cx| handler(id.clone(), window, cx)
232                        })
233                    })
234                    .children(segment.icon.map(|glyph| {
235                        icon(glyph)
236                            .size(px(metrics.icon_size * 0.9))
237                            .text_color(if selected {
238                                theme.colors.text
239                            } else {
240                                theme.colors.text_muted
241                            })
242                    }))
243                    .child(
244                        foundation_text(&theme, TypeScale::Label, segment.label.clone())
245                            .text_size(px(metrics.font_size))
246                            .text_color(label_color)
247                            .when(!refused && !selected, |element| {
248                                element.group_hover(hover_group, |style| {
249                                    style.text_color(theme.colors.text)
250                                })
251                            }),
252                    )
253                    .semantic_in(
254                        cx,
255                        NodeSpec::new(ident.semantic_id(), Role::Radio)
256                            .parent(strip_id.clone())
257                            .text(segment.label.clone())
258                            .checked(selected)
259                            .disabled(refused),
260                    )
261            })
262            .collect::<Vec<_>>();
263
264        let mut strip = div()
265            .id(self.ident.child("strip").element_id())
266            .row()
267            .flex_none()
268            .gap(px(2.0))
269            .p(px(2.0))
270            .radius(&theme, Radius::Control)
271            .surface(&theme, Surface::Sunken)
272            .when(self.disabled, |element| {
273                element.opacity(theme.opacity.disabled)
274            })
275            .when(actionable, |element| {
276                element.tab_index(0).focus_ring(&theme)
277            })
278            .children(segments);
279
280        if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
281            let items = self.segments.clone();
282            let current = self.selected_index();
283            // The strip runs in reading order, so the horizontal arrows step
284            // with it. Up and down are the same two moves spelled on an axis
285            // the reading direction does not touch.
286            let direction = cx.layout_direction();
287            strip.interactivity().on_key_down(move |event, window, cx| {
288                let key = event.keystroke.key.as_str();
289                let next = match direction.arrow_step(key) {
290                    Some(step) => neighbour(&items, current, step as isize),
291                    None => match key {
292                        "up" => neighbour(&items, current, -1),
293                        "down" => neighbour(&items, current, 1),
294                        "home" => edge(&items, true),
295                        "end" => edge(&items, false),
296                        _ => return,
297                    },
298                };
299                if let Some(index) = next {
300                    handler(items[index].id.clone(), window, cx);
301                    cx.stop_propagation();
302                }
303            });
304        }
305
306        div()
307            .column()
308            .gap(px(theme.space(Space::Xs)))
309            .child(strip)
310            .semantic_in(cx, {
311                let mut spec = NodeSpec::new(strip_id, Role::Group).disabled(self.disabled);
312                if let Some(label) = self.label.clone() {
313                    spec = spec.text(label);
314                }
315                spec
316            })
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn segments() -> Vec<Segment> {
325        vec![
326            Segment::new("day", "Day"),
327            Segment::new("week", "Week").disabled(true),
328            Segment::new("month", "Month"),
329        ]
330    }
331
332    #[test]
333    fn moving_steps_over_a_refused_segment() {
334        assert_eq!(neighbour(&segments(), Some(0), 1), Some(2));
335        assert_eq!(neighbour(&segments(), Some(2), -1), Some(0));
336    }
337
338    #[test]
339    fn a_strip_has_ends_rather_than_wrapping() {
340        assert_eq!(neighbour(&segments(), Some(2), 1), None);
341        assert_eq!(neighbour(&segments(), Some(0), -1), None);
342    }
343
344    #[test]
345    fn the_ends_are_the_first_segments_that_can_be_chosen() {
346        assert_eq!(edge(&segments(), true), Some(0));
347        assert_eq!(edge(&segments(), false), Some(2));
348        let refused = vec![Segment::new("only", "Only").disabled(true)];
349        assert_eq!(edge(&refused, true), None);
350    }
351}