1use 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#[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 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#[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 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
157fn 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
166fn 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 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 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}