1use super::InteractionPreview;
9use super::button::ButtonFocusRing;
10use super::roving::{self, Orientation, Roving, index_handles};
11use crate::{
12 color::ColorRole,
13 motion,
14 theme::{blend, color},
15 tokens::{
16 state,
17 typography::{TypeRole, rem},
18 },
19};
20use gpui::{prelude::*, *};
21use std::rc::Rc;
22
23const RING: f32 = 20.;
25const RING_STROKE: f32 = 2.;
26const DOT: f32 = 10.;
27const STATE_LAYER: f32 = 40.;
28const TARGET: f32 = 48.;
29
30#[derive(Clone, Copy, Debug, PartialEq)]
33pub(crate) struct SelectionPaint {
34 pub(crate) ink: Rgba,
36 pub(crate) ink_active: Rgba,
38 pub(crate) hover: Rgba,
39 pub(crate) focus: Rgba,
40 pub(crate) press: Rgba,
41}
42
43pub(crate) fn selection_paint(
51 selected: bool,
52 enabled: bool,
53 accent: ColorRole,
54 error: bool,
55) -> SelectionPaint {
56 let clear = Rgba {
57 r: 0.,
58 g: 0.,
59 b: 0.,
60 a: 0.,
61 };
62 if !enabled {
63 let ink = state::disabled(color(ColorRole::ON_SURFACE));
64 return SelectionPaint {
65 ink,
66 ink_active: ink,
67 hover: clear,
68 focus: clear,
69 press: clear,
70 };
71 }
72 let accent = color(accent);
73 let on_surface = color(ColorRole::ON_SURFACE);
74 let (ink, ink_active, layer, press_layer) = if error {
75 (accent, accent, accent, accent)
76 } else if selected {
77 (accent, accent, accent, on_surface)
78 } else {
79 (
80 color(ColorRole::ON_SURFACE_VARIANT),
81 on_surface,
82 on_surface,
83 accent,
84 )
85 };
86 SelectionPaint {
87 ink,
88 ink_active,
89 hover: state::layer(clear, layer, state::HOVER),
90 focus: state::layer(clear, layer, state::FOCUS),
91 press: state::layer(clear, press_layer, state::PRESS),
92 }
93}
94
95fn radio_indicator(amount: f32, selected: bool, enabled: bool, active: bool) -> Div {
98 let amount = if amount.is_finite() { amount } else { 0. };
99 let rest = selection_paint(false, enabled, ColorRole::PRIMARY, false);
100 let chosen = selection_paint(true, enabled, ColorRole::PRIMARY, false);
101 let from = if active { rest.ink_active } else { rest.ink };
102 let tone = blend(from, chosen.ink, amount.clamp(0., 1.));
103 let dot = (DOT * amount).clamp(0., RING - 2. * RING_STROKE - 2.);
105 div()
106 .flex()
107 .flex_shrink_0()
108 .items_center()
109 .justify_center()
110 .size(rem(RING))
111 .rounded_full()
112 .border(rem(RING_STROKE))
113 .border_color(tone)
114 .when(selected || dot > 0., |ring| {
115 ring.child(
116 div()
117 .when(cfg!(test), |dot| dot.debug_selector(|| "radio-dot".into()))
118 .size(rem(dot))
119 .rounded_full()
120 .bg(tone),
121 )
122 })
123}
124
125pub fn choice_radio(selected: bool, enabled: bool) -> Div {
129 radio_indicator(if selected { 1. } else { 0. }, selected, enabled, false)
130}
131
132pub(crate) fn animated_choice_radio(amount: f32, selected: bool, enabled: bool) -> Div {
134 radio_indicator(amount, selected, enabled, false)
135}
136
137type Select = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
138
139#[derive(gpui::IntoElement)]
143pub struct MaterialRadio {
144 id: ElementId,
145 label: SharedString,
146 visible_label: bool,
147 label_before: bool,
148 selected: bool,
149 disabled: bool,
150 preview: Option<InteractionPreview>,
151 focus: Option<FocusHandle>,
152 tab_stop: bool,
153 position: Option<(usize, usize)>,
154 on_select: Option<Select>,
155}
156
157impl MaterialRadio {
158 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
159 Self {
160 id: id.into(),
161 label: label.into(),
162 visible_label: false,
163 label_before: false,
164 selected: false,
165 disabled: false,
166 preview: None,
167 focus: None,
168 tab_stop: true,
169 position: None,
170 on_select: None,
171 }
172 }
173 pub fn selected(mut self, selected: bool) -> Self {
174 self.selected = selected;
175 self
176 }
177 pub fn disabled(mut self, disabled: bool) -> Self {
178 self.disabled = disabled;
179 self
180 }
181 pub fn visible_label(mut self, visible: bool) -> Self {
183 self.visible_label = visible;
184 self
185 }
186 pub fn label_before(mut self, before: bool) -> Self {
189 self.label_before = before;
190 self
191 }
192 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
195 self.preview = Some(state);
196 self
197 }
198 pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
200 self.focus = Some(focus.clone());
201 self
202 }
203 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
204 self.tab_stop = tab_stop;
205 self
206 }
207 pub fn position(mut self, position: usize, size: usize) -> Self {
209 self.position = Some((position, size));
210 self
211 }
212 pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
215 self.on_select = Some(Rc::new(f));
216 self
217 }
218}
219
220const RADIO_GROUP_STYLE: &str = "material-radio";
222
223impl RenderOnce for MaterialRadio {
224 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
225 let focus = self.focus.clone().unwrap_or_else(|| {
226 window
227 .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
228 .read(cx)
229 .clone()
230 });
231 let preview = self.preview;
232 let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
233 let enabled = !disabled;
234 let live = enabled && preview.is_none();
235 let forced = |state| enabled && preview == Some(state);
236 let keyboard_focus = forced(InteractionPreview::Focused)
237 || (live && focus.is_focused(window) && window.last_input_was_keyboard());
238 let amount = if preview.is_some() {
239 if self.selected { 1. } else { 0. }
240 } else {
241 motion::selection_value(
242 (self.id.clone(), "dot"),
243 if self.selected { 1. } else { 0. },
244 window,
245 cx,
246 )
247 };
248 let paint = selection_paint(self.selected, enabled, ColorRole::PRIMARY, false);
249 let active = keyboard_focus
250 || forced(InteractionPreview::Hovered)
251 || forced(InteractionPreview::Pressed);
252 let ring = radio_indicator(amount, self.selected, enabled, active).when(
253 live && !self.selected,
254 |ring| {
255 let ink = paint.ink_active;
256 ring.group_hover(RADIO_GROUP_STYLE, move |style| style.border_color(ink))
257 },
258 );
259 let layer = div()
260 .id("state-layer")
261 .flex()
262 .flex_shrink_0()
263 .items_center()
264 .justify_center()
265 .size(rem(STATE_LAYER))
266 .rounded_full()
267 .when(keyboard_focus, |layer| layer.bg(paint.focus))
268 .when(forced(InteractionPreview::Hovered), |layer| {
269 layer.bg(paint.hover)
270 })
271 .when(forced(InteractionPreview::Pressed), |layer| {
272 layer.bg(paint.press)
273 })
274 .when(live && !keyboard_focus, |layer| {
275 let (hover, press) = (paint.hover, paint.press);
276 layer
277 .group_hover(RADIO_GROUP_STYLE, move |style| style.bg(hover))
278 .group_active(RADIO_GROUP_STYLE, move |style| style.bg(press))
279 })
280 .child(ring);
281 let ringed = ButtonFocusRing::new(layer, focus.clone(), enabled, rem(STATE_LAYER / 2.))
282 .forced(forced(InteractionPreview::Focused));
283 let label_color = if enabled {
284 color(ColorRole::ON_SURFACE)
285 } else {
286 state::disabled(color(ColorRole::ON_SURFACE))
287 };
288 let scale = TypeRole::BodyLarge.scale();
289 let on_select = self.on_select;
290 let before = self.label_before && self.visible_label;
291 let (near, far) = (
292 (TARGET - STATE_LAYER) / 2.,
293 if self.visible_label {
294 8.
295 } else {
296 (TARGET - STATE_LAYER) / 2.
297 },
298 );
299 let mut radio = gpui_base::Radio::new(self.id)
300 .track_focus(&focus)
301 .accessibility_label(self.label.clone())
302 .checked(self.selected)
303 .disabled(disabled)
304 .tab_stop(self.tab_stop && preview.is_none());
305 if let Some((position, size)) = self.position {
306 radio = radio.set_position(position, size);
307 }
308 radio
309 .when_some(on_select, |radio, on_select| {
310 radio.on_change(move |value, _, window, cx| on_select(&value, window, cx))
311 })
312 .group(RADIO_GROUP_STYLE)
313 .flex()
314 .flex_shrink_0()
315 .items_center()
316 .h(rem(TARGET))
317 .min_w(rem(TARGET))
318 .map(|radio| {
319 if before {
320 radio
321 .flex_row_reverse()
322 .justify_between()
323 .gap(rem(16.))
324 .pr(rem(near))
325 .pl(rem(far))
326 } else {
327 radio.pl(rem(near)).pr(rem(far))
328 }
329 })
330 .when(live, |radio| radio.cursor_pointer())
331 .child(ringed)
332 .when(self.visible_label, |radio| {
333 radio.child(
334 div()
335 .when(!before, |label| label.pl(rem(4.)))
336 .whitespace_nowrap()
337 .text_color(label_color)
338 .text_size(scale.font_size)
339 .line_height(scale.line_height)
340 .child(self.label),
341 )
342 })
343 }
344}
345
346type GroupChange = Rc<dyn Fn(&usize, &mut Window, &mut App)>;
347
348struct GroupOption {
349 label: SharedString,
350 enabled: bool,
351}
352
353#[derive(gpui::IntoElement)]
357pub struct MaterialRadioGroup {
358 id: ElementId,
359 label: SharedString,
360 axis: Axis,
361 options: Vec<GroupOption>,
362 selected: Option<usize>,
363 labels_before: bool,
364 disabled: bool,
365 on_change: Option<GroupChange>,
366}
367
368impl MaterialRadioGroup {
369 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
371 Self {
372 id: id.into(),
373 label: label.into(),
374 axis: Axis::Vertical,
375 options: Vec::new(),
376 selected: None,
377 labels_before: false,
378 disabled: false,
379 on_change: None,
380 }
381 }
382 pub fn axis(mut self, axis: Axis) -> Self {
384 self.axis = axis;
385 self
386 }
387 pub fn option(mut self, label: impl Into<SharedString>, enabled: bool) -> Self {
389 self.options.push(GroupOption {
390 label: label.into(),
391 enabled,
392 });
393 self
394 }
395 pub fn selected(mut self, selected: Option<usize>) -> Self {
396 self.selected = selected;
397 self
398 }
399 pub fn labels_before(mut self, before: bool) -> Self {
401 self.labels_before = before;
402 self
403 }
404 pub fn disabled(mut self, disabled: bool) -> Self {
407 self.disabled = disabled;
408 self
409 }
410 pub fn on_change(mut self, f: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
412 self.on_change = Some(Rc::new(f));
413 self
414 }
415}
416
417impl RenderOnce for MaterialRadioGroup {
418 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
419 let count = self.options.len();
420 let focus = index_handles((self.id.clone(), "radio-focus"), count, window, cx);
421 let disabled = self.disabled;
422 let skip: Rc<[bool]> = self
423 .options
424 .iter()
425 .map(|option| disabled || !option.enabled)
426 .collect();
427 let selected = self.selected.filter(|&index| index < count);
428 let on_select = self.on_change.clone().map(|on_change| {
429 Rc::new(move |index: usize, window: &mut Window, cx: &mut App| {
430 on_change(&index, window, cx)
431 }) as roving::Select
432 });
433 let roving = Roving::new(focus.clone(), Orientation::Both)
434 .skip(skip)
435 .selected(selected)
436 .select_on_focus(on_select);
437 let entry = roving.tab_stop(window);
438 let radios = self.options.into_iter().enumerate().map(|(index, option)| {
439 let on_change = self.on_change.clone();
440 MaterialRadio::new(("radio", index), option.label)
441 .visible_label(true)
442 .label_before(self.labels_before)
443 .selected(selected == Some(index))
444 .disabled(disabled || !option.enabled)
445 .track_focus(&focus[index])
446 .tab_stop(entry == Some(index))
447 .position(index + 1, count)
448 .on_change(move |_, window, cx| {
449 if let Some(on_change) = &on_change {
450 on_change(&index, window, cx);
451 }
452 })
453 });
454 let group = gpui_base::RadioGroup::new(self.id)
455 .axis(self.axis)
456 .aria_label(self.label)
457 .flex()
458 .map(|group| match self.axis {
459 Axis::Vertical => group.flex_col(),
460 Axis::Horizontal => group.flex_row().flex_wrap(),
461 })
462 .children(radios);
463 roving.install(group)
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use core::prelude::v1::test;
471
472 #[test]
473 fn radio_uses_the_m3_selection_state_policy() {
474 let c = color;
475 let on = selection_paint(true, true, ColorRole::PRIMARY, false);
476 assert_eq!(on.ink, c(ColorRole::PRIMARY));
477 let clear = Rgba {
478 r: 0.,
479 g: 0.,
480 b: 0.,
481 a: 0.,
482 };
483 assert_eq!(
484 on.hover,
485 state::layer(clear, c(ColorRole::PRIMARY), state::HOVER)
486 );
487 assert_eq!(
488 on.press,
489 state::layer(clear, c(ColorRole::ON_SURFACE), state::PRESS),
490 "a selected press layer is on-surface"
491 );
492 let off = selection_paint(false, true, ColorRole::PRIMARY, false);
493 assert_eq!(off.ink, c(ColorRole::ON_SURFACE_VARIANT));
494 assert_eq!(off.ink_active, c(ColorRole::ON_SURFACE));
495 assert_eq!(
496 off.press,
497 state::layer(clear, c(ColorRole::PRIMARY), state::PRESS),
498 "an unselected press layer is primary"
499 );
500 let disabled = selection_paint(true, false, ColorRole::PRIMARY, false);
501 assert_eq!(disabled.ink, state::disabled(c(ColorRole::ON_SURFACE)));
502 assert_eq!(disabled.hover.a, 0.);
503 let error = selection_paint(false, true, ColorRole::ERROR, true);
504 assert_eq!(error.ink, c(ColorRole::ERROR));
505 assert_eq!(
506 error.press,
507 state::layer(clear, c(ColorRole::ERROR), state::PRESS)
508 );
509 assert_eq!((RING, DOT, STATE_LAYER, TARGET), (20., 10., 40., 48.));
510 }
511
512 struct GroupHarness {
513 selected: Option<usize>,
514 changes: Vec<usize>,
515 }
516 impl Render for GroupHarness {
517 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
518 window.set_rem_size(px(14.));
519 div().w(px(400.)).child(
520 MaterialRadioGroup::new("fruit", "Fruit")
521 .option("Apple", true)
522 .option("Banana", false)
523 .option("Cherry", true)
524 .selected(self.selected)
525 .on_change(cx.listener(|this, index: &usize, _, cx| {
526 this.selected = Some(*index);
527 this.changes.push(*index);
528 cx.notify();
529 })),
530 )
531 }
532 }
533
534 fn draw(cx: &mut VisualTestContext) {
535 cx.update(|window, cx| {
536 window.refresh();
537 window.draw(cx).clear(cx);
538 });
539 }
540
541 #[gpui::test]
542 fn a_radio_group_roves_with_arrow_keys(cx: &mut TestAppContext) {
543 cx.update(|cx| {
544 gpui_base::init(cx);
545 crate::components::init(cx);
546 cx.set_reduce_motion(true);
547 });
548 let (view, cx) = cx.add_window_view(|_, _| GroupHarness {
549 selected: Some(0),
550 changes: Vec::new(),
551 });
552 draw(cx);
553 cx.update(|window, cx| window.focus_next(cx));
554 let first = cx.update(|window, cx| window.focused(cx));
555 assert!(first.is_some());
556 cx.update(|window, cx| window.focus_next(cx));
557 assert_eq!(
558 cx.update(|window, cx| window.focused(cx)),
559 first,
560 "the group is one tab stop"
561 );
562 cx.simulate_keystrokes("down");
563 draw(cx);
564 view.read_with(cx, |h, _| assert_eq!(h.changes, [2]));
565 cx.simulate_keystrokes("down");
566 draw(cx);
567 view.read_with(cx, |h, _| assert_eq!(h.changes, [2, 0]));
568 cx.simulate_keystrokes("end");
569 draw(cx);
570 view.read_with(cx, |h, _| assert_eq!(h.selected, Some(2)));
571 }
572
573 struct DotHarness(bool);
574 impl Render for DotHarness {
575 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
576 window.set_rem_size(px(14.));
577 div().child(MaterialRadio::new("dot", "Dot").selected(self.0))
578 }
579 }
580
581 #[gpui::test]
582 fn the_dot_scales_in_and_lands_at_10dp(cx: &mut TestAppContext) {
583 cx.update(gpui_base::init);
584 let (view, cx) = cx.add_window_view(|_, _| DotHarness(false));
585 draw(cx);
586 assert!(cx.debug_bounds("radio-dot").is_none());
587 view.update(cx, |view, cx| {
588 view.0 = true;
589 cx.notify();
590 });
591 draw(cx);
592 cx.executor()
593 .advance_clock(std::time::Duration::from_millis(60));
594 draw(cx);
595 let growing = cx.debug_bounds("radio-dot").expect("the dot is growing");
596 assert!(growing.size.width < px(10.), "mid-animation: {growing:?}");
597 cx.executor()
598 .advance_clock(std::time::Duration::from_secs(1));
599 draw(cx);
600 assert_eq!(
601 cx.debug_bounds("radio-dot").unwrap().size,
602 size(px(10.), px(10.))
603 );
604 }
605
606 struct BeforeHarness(bool);
607 impl Render for BeforeHarness {
608 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
609 window.set_rem_size(px(14.));
610 div().flex().flex_col().w(px(240.)).child(
611 div().debug_selector(|| "before-radio".into()).child(
612 MaterialRadio::new("before", "Express")
613 .visible_label(true)
614 .label_before(self.0)
615 .selected(true),
616 ),
617 )
618 }
619 }
620
621 #[gpui::test]
622 fn a_label_before_puts_the_radio_at_the_far_end(cx: &mut TestAppContext) {
623 cx.update(|cx| {
624 gpui_base::init(cx);
625 cx.set_reduce_motion(true);
626 });
627 let (view, cx) = cx.add_window_view(|_, _| BeforeHarness(false));
628 draw(cx);
629 let row = cx.debug_bounds("before-radio").unwrap();
630 let dot = cx.debug_bounds("radio-dot").unwrap();
631 assert_eq!(
632 dot.center().x - row.left(),
633 px(24.),
634 "after: the radio leads"
635 );
636 view.update(cx, |view, cx| {
637 view.0 = true;
638 cx.notify();
639 });
640 draw(cx);
641 let dot = cx.debug_bounds("radio-dot").unwrap();
642 assert_eq!(
643 row.right() - dot.center().x,
644 px(24.),
645 "before: the radio trails"
646 );
647 }
648
649 struct ClickHarness {
650 selected: bool,
651 disabled: bool,
652 }
653 impl Render for ClickHarness {
654 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
655 window.set_rem_size(px(14.));
656 div().flex().items_start().child(
657 div().debug_selector(|| "click-radio".into()).child(
658 MaterialRadio::new("click", "Click")
659 .selected(self.selected)
660 .disabled(self.disabled)
661 .on_change(cx.listener(|this, _: &bool, _, cx| {
662 this.selected = true;
663 cx.notify();
664 })),
665 ),
666 )
667 }
668 }
669
670 #[gpui::test]
671 fn clicking_selects_unless_disabled(cx: &mut TestAppContext) {
672 cx.update(|cx| {
673 gpui_base::init(cx);
674 cx.set_reduce_motion(true);
675 });
676 let (view, cx) = cx.add_window_view(|_, _| ClickHarness {
677 selected: false,
678 disabled: true,
679 });
680 draw(cx);
681 let target = cx.debug_bounds("click-radio").unwrap();
682 assert_eq!(target.size.height, px(48.));
683 cx.simulate_click(target.center(), Modifiers::default());
684 draw(cx);
685 view.read_with(cx, |h, _| assert!(!h.selected));
686 view.update(cx, |h, cx| {
687 h.disabled = false;
688 cx.notify();
689 });
690 draw(cx);
691 cx.simulate_click(target.center(), Modifiers::default());
692 draw(cx);
693 view.read_with(cx, |h, _| assert!(h.selected));
694 }
695}