1use 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
37pub 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 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
166pub 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 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 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}