Skip to main content

gpui_macos/
button_group.rs

1//! Exclusive settings and page navigation with one keyboard stop per group.
2use crate::{ActiveTheme, ChoiceItem};
3use gpui_kit::base::{
4    Radio, RadioGroup, Tabs,
5    actions::{Confirm, SelectLeft, SelectRight},
6};
7use gpui_kit::rems;
8use gpui_kit::{App, Context, ElementId, Entity, FocusHandle, KeyBinding, Window, prelude::*};
9use std::rc::Rc;
10
11type Change = Rc<dyn Fn(usize, &mut Window, &mut App)>;
12struct Cursor {
13    focus: FocusHandle,
14    index: Option<usize>,
15    was_focused: bool,
16}
17
18pub(crate) fn init(cx: &mut App) {
19    cx.bind_keys([
20        KeyBinding::new("left", SelectLeft, Some("macOSOptionGroup")),
21        KeyBinding::new("h", SelectLeft, Some("macOSOptionGroup")),
22        KeyBinding::new("right", SelectRight, Some("macOSOptionGroup")),
23        KeyBinding::new("l", SelectRight, Some("macOSOptionGroup")),
24        KeyBinding::new(
25            "enter",
26            Confirm { secondary: false },
27            Some("macOSOptionGroup"),
28        ),
29        KeyBinding::new(
30            "space",
31            Confirm { secondary: false },
32            Some("macOSOptionGroup"),
33        ),
34    ]);
35}
36
37/// A single-choice setting rendered as content-sized segments in a shared frame.
38/// The application owns `selected` and updates it in `on_change`.
39/// Left/Right (or h/l) move the cursor; Return/Space commit the choice.
40pub fn button_group(
41    id: impl Into<ElementId>,
42    items: Vec<ChoiceItem>,
43    selected: Option<usize>,
44    on_change: impl Fn(usize, &mut Window, &mut App) + 'static,
45    window: &mut Window,
46    cx: &mut App,
47) -> RadioGroup {
48    let id = id.into();
49    let cursor = cursor(&id, &items, selected, window, cx);
50    let on_change: Change = Rc::new(move |index, window, cx| {
51        if selected != Some(index) {
52            on_change(index, window, cx);
53        }
54    });
55    let t = cx.macos().clone();
56    let window_active = window.is_window_active();
57    let count = items.len();
58    let rows = items
59        .iter()
60        .enumerate()
61        .map(|(index, item)| {
62            let change = on_change.clone();
63            let pointer_cursor = cursor.clone();
64            let focus = cursor.read(cx).focus.clone();
65            Radio::new(index)
66                .debug_selector(move || format!("macos-option-{index}"))
67                .checked(selected == Some(index))
68                .disabled(item.disabled)
69                .accessibility_label(item.label.clone())
70                .set_position(index + 1, count)
71                .tab_stop(false)
72                .flex()
73                .relative()
74                .items_center()
75                .flex_grow(1.)
76                .flex_shrink(1.)
77                .min_w_0()
78                .justify_center()
79                .px_3()
80                .h(rems(1.375))
81                .py(rems(0.))
82                .border_1()
83                .rounded(rems(0.3125))
84                .border_color(t.foreground.opacity(0.))
85                .font_family(t.font.clone())
86                .text_size(rems(0.8125))
87                .line_height(gpui_kit::relative(1.2))
88                .text_color(if window_active {
89                    t.foreground
90                } else {
91                    t.secondary
92                })
93                .bg(if selected == Some(index) {
94                    if t.is_dark() {
95                        t.control_fill().blend(t.foreground.opacity(0.16))
96                    } else if window_active {
97                        t.surface
98                    } else {
99                        crate::controls::control_selection(&t, false)
100                    }
101                } else {
102                    t.foreground.opacity(0.)
103                })
104                .when(
105                    window_active
106                        && focus.is_focused(window)
107                        && cursor.read(cx).index == Some(index),
108                    |row| row.border_color(t.accent).shadow(t.focus_shadow()),
109                )
110                .when(window_active && !item.disabled, |row| {
111                    row.hover(|row| row.bg(t.hover_fill()))
112                        .active(|row| row.bg(t.pressed_fill()))
113                })
114                .styles(|styles| styles.disabled(|row| row.opacity(0.45)))
115                .child(item.label.clone())
116                .when(
117                    index > 0 && selected != Some(index) && selected != Some(index - 1),
118                    |row| {
119                        row.child(
120                            gpui_kit::div()
121                                .absolute()
122                                .left(rems(-0.0625))
123                                .top(rems(0.25))
124                                .bottom(rems(0.25))
125                                .w(rems(0.0625))
126                                .bg(t.divider()),
127                        )
128                    },
129                )
130                .when(!item.disabled, |row| {
131                    row.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
132                        pointer_cursor.update(cx, |state, cx| {
133                            state.focus.focus(window, cx);
134                            // Preserve the pointer cursor when focus enters the group.
135                            state.was_focused = true;
136                            state.index = Some(index);
137                            cx.notify();
138                        });
139                    })
140                })
141                .on_change(move |_, _, window, cx| {
142                    focus.focus(window, cx);
143                    change(index, window, cx);
144                })
145                .into_any_element()
146        })
147        .collect::<Vec<_>>();
148    navigate(
149        RadioGroup::new(id)
150            .axis(gpui_kit::Axis::Horizontal)
151            .flex()
152            .w_full()
153            .h(rems(1.5))
154            .gap(rems(0.125))
155            .p(rems(0.0625))
156            .rounded(rems(0.375))
157            .bg(t.control_fill())
158            .children(rows),
159        cursor,
160        items,
161        on_change,
162        cx,
163    )
164}
165
166/// A page tab list with the same restrained visual language and independent
167/// tab-list semantics. Use the selected value to render the associated panel.
168/// `show_shortcuts` renders one-based number hints; applications own key bindings.
169pub fn tab_list(
170    id: impl Into<ElementId>,
171    items: Vec<ChoiceItem>,
172    selected: Option<usize>,
173    show_shortcuts: bool,
174    on_change: impl Fn(usize, &mut Window, &mut App) + 'static,
175    window: &mut Window,
176    cx: &mut App,
177) -> Tabs {
178    let id = id.into();
179    let cursor = cursor(&id, &items, selected, window, cx);
180    let on_change: Change = Rc::new(move |index, window, cx| {
181        if selected != Some(index) {
182            on_change(index, window, cx);
183        }
184    });
185    let count = items.len();
186    let rows = items
187        .iter()
188        .enumerate()
189        .map(|(index, item)| {
190            let change = on_change.clone();
191            let focus = cursor.read(cx).focus.clone();
192            crate::tab(index, item.label.clone(), selected == Some(index), cx)
193                .debug_selector(move || format!("macos-tab-{index}"))
194                .disabled(item.disabled)
195                .set_position(index + 1, count)
196                .when(show_shortcuts, |tab| {
197                    tab.child(
198                        gpui_kit::div()
199                            .self_start()
200                            .text_size(gpui_kit::rems(0.625))
201                            .child((index + 1).to_string()),
202                    )
203                })
204                .on_click({
205                    let cursor = cursor.clone();
206                    move |_, window, cx| {
207                        cursor.update(cx, |state, cx| {
208                            state.index = Some(index);
209                            cx.notify();
210                        });
211                        focus.focus(window, cx);
212                        change(index, window, cx);
213                    }
214                })
215                .into_any_element()
216        })
217        .collect::<Vec<_>>();
218    navigate(
219        crate::tabs(id, cx).children(rows),
220        cursor,
221        items,
222        on_change,
223        cx,
224    )
225}
226
227fn cursor(
228    id: &ElementId,
229    items: &[ChoiceItem],
230    selected: Option<usize>,
231    window: &mut Window,
232    cx: &mut App,
233) -> Entity<Cursor> {
234    let initial = selected
235        .filter(|&i| items.get(i).is_some_and(|item| !item.disabled))
236        .or_else(|| items.iter().position(|item| !item.disabled));
237    let cursor = window.use_keyed_state((id.clone(), "cursor"), cx, |_, cx| Cursor {
238        focus: cx.focus_handle(),
239        index: initial,
240        was_focused: false,
241    });
242    cursor.update(cx, |state, _| {
243        let focused = state.focus.is_focused(window);
244        if (focused && !state.was_focused)
245            || !state
246                .index
247                .is_some_and(|i| items.get(i).is_some_and(|item| !item.disabled))
248        {
249            state.index = initial;
250        }
251        state.was_focused = focused;
252    });
253    cursor
254}
255
256fn navigate<T: StatefulInteractiveElement + FluentBuilder>(
257    root: T,
258    cursor: Entity<Cursor>,
259    items: Vec<ChoiceItem>,
260    change: Change,
261    cx: &App,
262) -> T {
263    let focus = cursor.read(cx).focus.clone();
264    let enabled: Vec<_> = items
265        .iter()
266        .enumerate()
267        .filter_map(|(i, item)| (!item.disabled).then_some(i))
268        .collect();
269    let mut root = root
270        .key_context("macOSOptionGroup")
271        .when(!enabled.is_empty(), |root| {
272            root.track_focus(&focus.clone().tab_stop(true))
273        });
274    if !enabled.is_empty() {
275        let pointer_focus = focus.clone();
276        root = root.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
277            pointer_focus.focus(window, cx)
278        });
279    }
280    let left = cursor.clone();
281    let left_items = enabled.clone();
282    root = root.on_action(move |_: &SelectLeft, window, cx| {
283        left.update(cx, |state, cx| step(state, &left_items, false, cx));
284        window.refresh();
285    });
286    let right = cursor.clone();
287    let right_items = enabled;
288    root = root.on_action(move |_: &SelectRight, window, cx| {
289        right.update(cx, |state, cx| step(state, &right_items, true, cx));
290        window.refresh();
291    });
292    root.on_action(move |_: &Confirm, window, cx| {
293        if let Some(index) = cursor
294            .read(cx)
295            .index
296            .filter(|&i| items.get(i).is_some_and(|item| !item.disabled))
297        {
298            change(index, window, cx);
299        }
300    })
301}
302fn step(cursor: &mut Cursor, enabled: &[usize], forward: bool, cx: &mut Context<Cursor>) {
303    if enabled.is_empty() {
304        return;
305    }
306    let current = cursor
307        .index
308        .and_then(|i| enabled.iter().position(|&v| v == i))
309        .unwrap_or(0);
310    cursor.index = Some(
311        enabled[if forward {
312            (current + 1).min(enabled.len() - 1)
313        } else {
314            current.saturating_sub(1)
315        }],
316    );
317    cx.notify();
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use gpui_kit::{Render, TestAppContext};
324
325    #[gpui_kit::test]
326    fn native_segments_fit_their_labels_and_share_extra_width(cx: &mut TestAppContext) {
327        struct NativeSegments;
328        impl Render for NativeSegments {
329            fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
330                button_group(
331                    "native-segments",
332                    vec![
333                        ChoiceItem::new("general", "General"),
334                        ChoiceItem::new("network", "Network"),
335                        ChoiceItem::new("advanced", "Advanced"),
336                    ],
337                    Some(0),
338                    |_, _, _| {},
339                    window,
340                    cx,
341                )
342                .w(rems(13.8125))
343                .debug_selector(|| "native-segments".into())
344            }
345        }
346        cx.update(crate::init);
347        let (view, cx) = cx.add_window_view(|_, _| NativeSegments);
348        for zoom in [1., 1.25, 2.] {
349            cx.update(|window, cx| {
350                window.set_rem_size(gpui_kit::px(16. * zoom));
351                view.update(cx, |_, cx| cx.notify());
352                window.draw(cx).clear(cx);
353            });
354            let frame = cx.debug_bounds("native-segments").unwrap();
355            let general = cx.debug_bounds("macos-option-0").unwrap();
356            let network = cx.debug_bounds("macos-option-1").unwrap();
357            let advanced = cx.debug_bounds("macos-option-2").unwrap();
358            assert!((frame.size.width.as_f32() - 221. * zoom).abs() <= 0.5);
359            // The equal-length labels depend on font shaping and pixel snapping;
360            // the longer label must keep a larger share of the frame.
361            assert!(general.size.width < advanced.size.width);
362            assert!(network.size.width < advanced.size.width);
363            assert!(((general.left() - frame.left()).as_f32() - zoom).abs() <= 0.5);
364            assert!(((frame.right() - advanced.right()).as_f32() - zoom).abs() <= 0.5);
365            assert!((general.size.height.as_f32() - 22. * zoom).abs() <= 0.5);
366        }
367    }
368    struct Harness {
369        tab_list: bool,
370        selected: usize,
371        before: FocusHandle,
372        after: FocusHandle,
373    }
374    impl Render for Harness {
375        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
376            let items = vec![
377                ChoiceItem::new("first", "First"),
378                ChoiceItem::new("disabled", "Disabled").disabled(true),
379                ChoiceItem::new("last", "Last"),
380            ];
381            let target = cx.entity();
382            let change = move |index, _: &mut Window, cx: &mut App| {
383                target.update(cx, |this, cx| {
384                    this.selected = index;
385                    cx.notify();
386                })
387            };
388            let group = if self.tab_list {
389                tab_list(
390                    "options",
391                    items,
392                    Some(self.selected),
393                    true,
394                    change,
395                    window,
396                    cx,
397                )
398                .into_any_element()
399            } else {
400                button_group("options", items, Some(self.selected), change, window, cx)
401                    .into_any_element()
402            };
403            crate::focus_scope("root")
404                .size_full()
405                .flex()
406                .flex_col()
407                .items_start()
408                .child(
409                    crate::button("before", "Before", crate::ButtonVariant::Secondary, cx)
410                        .track_focus(&self.before),
411                )
412                .child(group)
413                .child(
414                    crate::button("after", "After", crate::ButtonVariant::Secondary, cx)
415                        .track_focus(&self.after),
416                )
417        }
418    }
419    #[gpui_kit::test]
420    fn clicking_a_tab_moves_the_keyboard_cursor_to_it(cx: &mut TestAppContext) {
421        cx.update(crate::init);
422        let (view, cx) = cx.add_window_view(|_, cx| Harness {
423            tab_list: true,
424            selected: 0,
425            before: cx.focus_handle(),
426            after: cx.focus_handle(),
427        });
428        cx.update(|window, cx| window.draw(cx).clear(cx));
429        for (index, selector) in [(2, "macos-tab-2"), (0, "macos-tab-0")] {
430            let point = cx.debug_bounds(selector).unwrap().center();
431            cx.simulate_click(point, Default::default());
432            cx.update(|window, cx| window.draw(cx).clear(cx));
433            cx.simulate_keystrokes("enter");
434            cx.update(|_, cx| {
435                assert_eq!(view.read(cx).selected, index);
436            });
437        }
438    }
439
440    #[gpui_kit::test]
441    fn clicking_a_button_moves_the_keyboard_cursor_to_it(cx: &mut TestAppContext) {
442        cx.update(crate::init);
443        let (view, cx) = cx.add_window_view(|_, cx| Harness {
444            tab_list: false,
445            selected: 0,
446            before: cx.focus_handle(),
447            after: cx.focus_handle(),
448        });
449        cx.update(|window, cx| window.draw(cx).clear(cx));
450        for (index, selector) in [
451            (2, "macos-option-2"),
452            (0, "macos-option-0"),
453            (2, "macos-option-2"),
454            (0, "macos-option-0"),
455        ] {
456            let point = cx.debug_bounds(selector).unwrap().center();
457            cx.simulate_click(point, Default::default());
458            cx.update(|window, cx| {
459                window.draw(cx).clear(cx);
460                assert_eq!(view.read(cx).selected, index);
461            });
462            cx.simulate_keystrokes("enter space");
463            cx.update(|_, cx| assert_eq!(view.read(cx).selected, index));
464        }
465        // Clicking the selected radio must also reset an uncommitted arrow cursor.
466        cx.simulate_keystrokes("right");
467        let point = cx.debug_bounds("macos-option-0").unwrap().center();
468        cx.simulate_click(point, Default::default());
469        cx.simulate_keystrokes("enter");
470        cx.update(|_, cx| assert_eq!(view.read(cx).selected, 0));
471    }
472
473    #[gpui_kit::test]
474    fn groups_have_one_tab_stop_and_skip_disabled_choices(cx: &mut TestAppContext) {
475        cx.update(crate::init);
476        for tab_list in [false, true] {
477            let (view, cx) = cx.add_window_view(move |_, cx| Harness {
478                tab_list,
479                selected: 0,
480                before: cx.focus_handle(),
481                after: cx.focus_handle(),
482            });
483            cx.update(|window, cx| {
484                view.read(cx).before.clone().focus(window, cx);
485                window.draw(cx).clear(cx);
486            });
487            cx.simulate_keystrokes("tab");
488            cx.update(|window, cx| {
489                window.draw(cx).clear(cx);
490            });
491            cx.simulate_keystrokes("right");
492            cx.update(|_, cx| {
493                assert_eq!(
494                    view.read(cx).selected,
495                    0,
496                    "arrows move the cursor without committing"
497                )
498            });
499            cx.simulate_keystrokes("enter");
500            cx.update(|window, cx| {
501                window.draw(cx).clear(cx);
502                assert_eq!(view.read(cx).selected, 2, "disabled option is skipped");
503            });
504            cx.simulate_keystrokes("tab");
505            cx.update(|window, cx| {
506                assert!(
507                    view.read(cx).after.is_focused(window),
508                    "one tab stop per group"
509                )
510            });
511            cx.simulate_keystrokes("shift-tab");
512            cx.update(|window, cx| {
513                window.draw(cx).clear(cx);
514            });
515            cx.simulate_keystrokes("left space");
516            cx.update(|_, cx| assert_eq!(view.read(cx).selected, 0));
517        }
518    }
519}