1use crate::color::ColorRole;
4use crate::icons::Icon;
5use crate::theme::{blend, color};
6use crate::tokens::{layout::*, shape::*, state, typography::*};
7use gpui::{prelude::*, *};
8
9pub use super::radio::choice_radio;
10
11pub use choice_tile::{ChoiceGroup, ChoiceTile};
12mod choice_tile {
13 use super::*;
14 use crate::components::InteractionPreview;
15 use crate::components::focus::{RingPlacement, RingedButton};
16 use crate::components::radio::animated_choice_radio;
17 use crate::components::roving::{Orientation, Roving, Select, index_handles};
18 use crate::components::tile::outer_corner_radii;
19 use gpui_base::v_flex;
20 use std::rc::Rc;
21
22 #[derive(Clone, Copy, Debug, PartialEq)]
24 pub(super) struct TilePaint {
25 pub(super) background: Rgba,
26 pub(super) ink: Rgba,
28 pub(super) label: Rgba,
30 pub(super) hover: Rgba,
31 pub(super) press: Rgba,
32 }
33
34 pub(super) fn tile_paint(amount: f32, selected: bool, enabled: bool) -> TilePaint {
36 let background = blend(
37 color(ColorRole::SURFACE),
38 color(ColorRole::SECONDARY_CONTAINER),
39 amount,
40 );
41 let ink = color(if selected {
42 ColorRole::ON_SECONDARY_CONTAINER
43 } else {
44 ColorRole::ON_SURFACE
45 });
46 TilePaint {
47 background,
48 ink,
49 label: if enabled { ink } else { state::disabled(ink) },
50 hover: state::hovered(background, ink),
51 press: state::pressed(background, ink),
52 }
53 }
54
55 pub struct ChoiceTile {
64 id: ElementId,
65 index: usize,
66 count: usize,
67 outer: OuterCorners,
68 icon: Option<AnyElement>,
69 tinted: Option<(Icon, Tint, Pixels)>,
70 title: SharedString,
71 description: Option<SharedString>,
72 status: Option<AnyElement>,
73 selected: bool,
74 enabled: bool,
75 preview: Option<InteractionPreview>,
76 }
77
78 impl ChoiceTile {
79 pub fn new(
81 id: impl Into<ElementId>,
82 index: usize,
83 count: usize,
84 title: impl Into<SharedString>,
85 selected: bool,
86 ) -> Self {
87 Self {
88 id: id.into(),
89 index,
90 count,
91 outer: list_corners(index, count),
92 icon: None,
93 tinted: None,
94 title: title.into(),
95 description: None,
96 status: None,
97 selected,
98 enabled: true,
99 preview: None,
100 }
101 }
102
103 pub fn corners(mut self, outer: OuterCorners) -> Self {
106 self.outer = outer;
107 self
108 }
109
110 pub fn icon(mut self, icon: impl IntoElement) -> Self {
111 self.icon = Some(icon.into_any_element());
112 self
113 }
114
115 pub fn tinted_icon(mut self, icon: Icon, tint: Tint, size: Pixels) -> Self {
119 self.tinted = Some((icon, tint, size));
120 self
121 }
122
123 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
124 let description = description.into();
125 self.description = (!description.is_empty()).then_some(description);
126 self
127 }
128
129 pub fn status(mut self, status: Option<impl IntoElement>) -> Self {
130 self.status = status.map(IntoElement::into_any_element);
131 self
132 }
133
134 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
137 self.preview = Some(state);
138 self
139 }
140
141 pub fn enabled(mut self, enabled: bool) -> Self {
142 self.enabled = enabled;
143 self
144 }
145
146 pub fn render(self, window: &mut Window, cx: &mut App) -> RingedButton {
151 let id = self.id.clone();
152 let selected = self.selected;
153 let preview = self.preview;
154 let enabled = self.enabled && !preview.is_some_and(|p| p.is_disabled());
155 let amount = crate::motion::selection_value(
156 (id.clone(), "selection"),
157 if selected { 1. } else { 0. },
158 window,
159 cx,
160 );
161 let paint = tile_paint(amount, selected, enabled);
162 let icon = self.icon.or_else(|| {
163 self.tinted.map(|(icon, tint, size)| {
164 tinted_icon(icon, tint, size)
165 .bg(blend(tint.container(), color(ColorRole::SURFACE), amount))
166 .into_any_element()
167 })
168 });
169 let title = self.title;
170 let (outer, index, count) = (self.outer, self.index, self.count);
171 let description = self.description;
172 let status = self.status;
173 RingedButton::new(id, RingPlacement::Inner(outer_corner_radii(outer)))
176 .map(move |button| {
177 tile_corners(
178 button
179 .selected(selected)
180 .role(Role::RadioButton)
181 .aria_toggled(if selected {
182 gpui::accesskit::Toggled::True
183 } else {
184 gpui::accesskit::Toggled::False
185 })
186 .aria_selected(selected)
187 .aria_position_in_set(index + 1)
188 .aria_size_of_set(count)
189 .accessibility_label(title.clone())
190 .w_full()
191 .min_w_0()
192 .h_auto()
193 .min_h(rem(64.))
194 .justify_start()
195 .gap(rem(16.))
196 .pl(ITEM_INSET)
197 .pr(rem(20.))
198 .py(rem(12.)),
199 outer,
200 )
201 .bg(paint.background)
202 .text_color(paint.label)
203 .when(enabled && preview.is_none(), |button| {
204 button.cursor_pointer()
205 })
206 .children(icon)
207 .child(
208 v_flex()
209 .flex_1()
210 .min_w_0()
211 .gap(rem(2.))
212 .child(
213 div()
214 .min_w_0()
215 .whitespace_normal()
216 .text_size(TEXT_SETTINGS_BODY)
217 .line_height(rem(22.))
218 .font_weight(FontWeight::MEDIUM)
219 .child(title),
220 )
221 .when_some(description, |column, description| {
222 column.child(
223 div()
224 .min_w_0()
225 .whitespace_normal()
226 .text_size(TEXT_SETTINGS_AUX)
227 .line_height(rem(20.))
228 .text_color(if selected {
229 Rgba {
230 a: paint.label.a * 0.8,
231 ..paint.label
232 }
233 } else if enabled {
234 color(ColorRole::ON_SURFACE_VARIANT)
235 } else {
236 state::disabled(color(ColorRole::ON_SURFACE_VARIANT))
237 })
238 .child(description),
239 )
240 }),
241 )
242 .children(status)
243 .child(animated_choice_radio(amount, selected, enabled))
244 })
245 .state_layers(paint.hover, paint.press)
246 .disabled(!enabled)
247 .when_some(preview, |tile, preview| tile.preview_state(preview))
248 }
249 }
250
251 type GroupChange = Rc<dyn Fn(&usize, &mut Window, &mut App)>;
252
253 #[derive(gpui::IntoElement)]
258 pub struct ChoiceGroup {
259 id: ElementId,
260 title: SharedString,
261 tiles: Vec<ChoiceTile>,
262 on_change: Option<GroupChange>,
263 }
264
265 impl ChoiceGroup {
266 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
268 Self {
269 id: id.into(),
270 title: title.into(),
271 tiles: Vec::new(),
272 on_change: None,
273 }
274 }
275
276 pub fn tile(mut self, tile: ChoiceTile) -> Self {
278 self.tiles.push(tile);
279 self
280 }
281
282 pub fn on_change(mut self, f: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
284 self.on_change = Some(Rc::new(f));
285 self
286 }
287 }
288
289 impl RenderOnce for ChoiceGroup {
290 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
291 let count = self.tiles.len();
292 let focus = index_handles((self.id.clone(), "choice-focus"), count, window, cx);
293 let skip: Rc<[bool]> = self.tiles.iter().map(|tile| !tile.enabled).collect();
294 let selected = self.tiles.iter().position(|tile| tile.selected);
295 let on_select = self.on_change.clone().map(|on_change| {
296 Rc::new(move |index: usize, window: &mut Window, cx: &mut App| {
297 on_change(&index, window, cx)
298 }) as Select
299 });
300 let roving = Roving::new(focus.clone(), Orientation::Both)
301 .skip(skip)
302 .selected(selected)
303 .select_on_focus(on_select);
304 let entry = roving.tab_stop(window);
305 let mut group = super::super::TileGroup::new("choice-group-title", self.title.clone());
308 for (index, tile) in self.tiles.into_iter().enumerate() {
309 let chosen = tile.selected;
310 let tile = ChoiceTile {
311 index,
312 count,
313 outer: list_corners(index, count),
314 ..tile
315 };
316 let handle = focus[index].clone();
317 let on_change = self.on_change.clone();
318 let button = tile
319 .render(window, cx)
320 .track_focus(&handle)
321 .tab_stop(entry == Some(index))
322 .on_click(move |_, window, cx| {
323 handle.focus(window, cx);
324 if !chosen && let Some(on_change) = &on_change {
325 on_change(&index, window, cx);
326 }
327 });
328 group = group.tile(move |_| button.into_any_element());
329 }
330 roving
331 .install(
332 div()
333 .id(self.id)
334 .role(Role::RadioGroup)
335 .aria_label(self.title)
336 .w_full()
337 .min_w_0(),
338 )
339 .child(group)
340 }
341 }
342}
343
344#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum Tint {
350 Orange,
352 Blue,
354 Teal,
356 Amber,
358 Green,
360 Pink,
362 Purple,
364}
365
366impl Tint {
367 pub const ALL: [Tint; 7] = [
368 Tint::Orange,
369 Tint::Blue,
370 Tint::Teal,
371 Tint::Amber,
372 Tint::Green,
373 Tint::Pink,
374 Tint::Purple,
375 ];
376
377 pub const fn roles(self) -> (ColorRole, ColorRole) {
379 match self {
380 Tint::Orange => (ColorRole::ERROR_CONTAINER, ColorRole::ON_ERROR_CONTAINER),
381 Tint::Blue => (
382 ColorRole::PRIMARY_CONTAINER,
383 ColorRole::ON_PRIMARY_CONTAINER,
384 ),
385 Tint::Teal => (
386 ColorRole::SECONDARY_CONTAINER,
387 ColorRole::ON_SECONDARY_CONTAINER,
388 ),
389 Tint::Amber => (ColorRole::WARNING_BG, ColorRole::WARNING),
390 Tint::Green => (ColorRole::SUCCESS_BG, ColorRole::SUCCESS),
391 Tint::Pink => (ColorRole::TERTIARY_FIXED, ColorRole::ON_TERTIARY_FIXED),
392 Tint::Purple => (
393 ColorRole::TERTIARY_CONTAINER,
394 ColorRole::ON_TERTIARY_CONTAINER,
395 ),
396 }
397 }
398
399 pub fn container(self) -> Rgba {
400 color(self.roles().0)
401 }
402
403 pub fn content(self) -> Rgba {
404 color(self.roles().1)
405 }
406}
407
408pub fn tinted_icon(icon: impl Into<Icon>, tint: Tint, size: impl Into<AbsoluteLength>) -> Div {
412 let size = size.into();
413 let glyph = match size {
414 AbsoluteLength::Pixels(size) => AbsoluteLength::Pixels(size * 0.5),
415 AbsoluteLength::Rems(size) => AbsoluteLength::Rems(rems(size.0 * 0.5)),
416 };
417 div()
418 .flex()
419 .flex_shrink_0()
420 .items_center()
421 .justify_center()
422 .size(size)
423 .rounded_full()
424 .bg(tint.container())
425 .text_color(tint.content())
426 .child(icon.into().size(glyph))
427}
428
429#[cfg(test)]
430mod tests {
431 use super::choice_tile::tile_paint;
432 use super::*;
433 use crate::components::focus::test_rings;
434 use core::prelude::v1::test;
435 use gpui::{Context, Modifiers, TestAppContext, VisualTestContext};
436
437 #[test]
438 fn selection_paints_the_secondary_container_and_keeps_its_state_layers() {
439 let rest = tile_paint(0., false, true);
440 assert_eq!(rest.background, color(ColorRole::SURFACE));
441 assert_eq!(rest.ink, color(ColorRole::ON_SURFACE));
442 let chosen = tile_paint(1., true, true);
443 assert_eq!(chosen.background, color(ColorRole::SECONDARY_CONTAINER));
444 assert_eq!(chosen.ink, color(ColorRole::ON_SECONDARY_CONTAINER));
445 for paint in [rest, chosen] {
446 assert_eq!(paint.hover, state::hovered(paint.background, paint.ink));
447 assert_eq!(paint.press, state::pressed(paint.background, paint.ink));
448 assert_eq!(paint.label, paint.ink);
449 }
450 let unavailable = tile_paint(0., false, false);
451 assert_eq!(unavailable.label, state::disabled(unavailable.ink));
452 }
453
454 struct GroupHarness {
455 selected: usize,
456 changes: Vec<usize>,
457 }
458
459 impl Render for GroupHarness {
460 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
461 window.set_rem_size(px(14.));
462 let mut group = ChoiceGroup::new("engines", "Engine").on_change(cx.listener(
463 |this, index: &usize, _, cx| {
464 this.selected = *index;
465 this.changes.push(*index);
466 cx.notify();
467 },
468 ));
469 for (index, title) in ["Local", "Cloud", "Remote"].into_iter().enumerate() {
470 group = group.tile(
471 ChoiceTile::new(("engine", index), 0, 0, title, self.selected == index)
472 .enabled(index != 1),
473 );
474 }
475 div().w(px(400.)).child(group)
476 }
477 }
478
479 fn draw(cx: &mut VisualTestContext) {
480 cx.update(|window, cx| {
481 window.refresh();
482 window.draw(cx).clear(cx);
483 });
484 }
485
486 #[gpui::test]
487 fn a_choice_group_is_a_radio_group_with_arrow_keys(cx: &mut TestAppContext) {
488 cx.update(|cx| {
489 gpui_base::init(cx);
490 crate::components::init(cx);
491 cx.set_reduce_motion(true);
492 });
493 let (view, cx) = cx.add_window_view(|_, _| GroupHarness {
494 selected: 0,
495 changes: Vec::new(),
496 });
497 draw(cx);
498 cx.update(|window, cx| window.focus_next(cx));
500 let first = cx.update(|window, cx| window.focused(cx));
501 assert!(first.is_some());
502 cx.update(|window, cx| window.focus_next(cx));
503 assert_eq!(cx.update(|window, cx| window.focused(cx)), first);
504 cx.simulate_keystrokes("a");
505 draw(cx);
506 assert!(
507 test_rings::painted("engine").is_some(),
508 "keyboard focus shows the ring"
509 );
510 cx.simulate_keystrokes("down");
512 draw(cx);
513 view.read_with(cx, |h, _| {
514 assert_eq!(h.selected, 2);
515 assert_eq!(h.changes, [2]);
516 });
517 cx.simulate_keystrokes("down");
519 draw(cx);
520 view.read_with(cx, |h, _| assert_eq!(h.changes, [2, 0]));
521 }
522
523 struct TileHarness;
524 impl Render for TileHarness {
525 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
526 window.set_rem_size(px(14.));
527 div().w(px(400.)).child(
528 ChoiceTile::new("pointer-tile", 0, 1, "Only", false)
529 .render(window, cx)
530 .debug_selector(|| "pointer-tile".into())
531 .on_click(|_, _, _| {}),
532 )
533 }
534 }
535
536 #[gpui::test]
537 fn pointer_focus_shows_no_ring(cx: &mut TestAppContext) {
538 cx.update(gpui_base::init);
539 let (_, cx) = cx.add_window_view(|_, _| TileHarness);
540 draw(cx);
541 let tile = cx.debug_bounds("pointer-tile").unwrap();
542 cx.simulate_click(tile.center(), Modifiers::default());
543 draw(cx);
544 assert!(test_rings::painted("pointer-tile").is_none());
545 }
546
547 #[test]
548 fn tints_are_distinct_semantic_role_pairs() {
549 let mut containers = Tint::ALL.map(|tint| tint.roles().0 as usize).to_vec();
550 containers.sort_unstable();
551 containers.dedup();
552 assert_eq!(containers.len(), Tint::ALL.len(), "one container per tint");
553 for tint in Tint::ALL {
554 let (container, content) = tint.roles();
555 assert_ne!(container, content);
556 assert_eq!(tint.container(), color(container));
557 assert_eq!(tint.content(), color(content));
558 }
559 }
560}