1use crate::color::ColorRole;
4use crate::icons::{Icon, IconName};
5use crate::theme::color;
6use crate::tokens::{layout::*, shape::*, state, typography::*};
7use gpui::{prelude::*, *};
8
9use super::focus::{RingPlacement, RingedButton};
10use super::text::accessible_text;
11
12pub fn tile(outer: OuterCorners) -> Div {
14 tile_corners(
15 div().w_full().min_w_0().bg(color(ColorRole::SURFACE)),
16 outer,
17 )
18}
19
20pub(crate) fn outer_corner_radii(outer: OuterCorners) -> Corners<Pixels> {
22 let radius = |outside: bool| {
23 if outside {
24 TILE_RADIUS
25 } else {
26 TILE_INNER_RADIUS
27 }
28 };
29 Corners {
30 top_left: radius(outer[0]),
31 top_right: radius(outer[1]),
32 bottom_right: radius(outer[2]),
33 bottom_left: radius(outer[3]),
34 }
35}
36
37pub fn list_tile(index: usize, count: usize) -> Div {
39 tile(list_corners(index, count))
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum TileScale {
46 Regular,
49 Compact,
51}
52
53impl TileScale {
54 pub fn label_size(self) -> Rems {
55 match self {
56 TileScale::Regular => TypeRole::BodyLarge.scale().font_size,
57 TileScale::Compact => rem(16.),
58 }
59 }
60
61 pub fn support_size(self) -> Rems {
62 match self {
63 TileScale::Regular => TEXT_SETTINGS_AUX,
64 TileScale::Compact => rem(13.),
65 }
66 }
67
68 pub fn label_line_height(self) -> Rems {
72 match self {
73 TileScale::Regular => rem(24.),
74 TileScale::Compact => rem(22.),
75 }
76 }
77
78 pub fn support_line_height(self) -> Rems {
79 match self {
80 TileScale::Regular => rem(20.),
81 TileScale::Compact => rem(18.),
82 }
83 }
84
85 pub fn label(
87 self,
88 id: impl Into<gpui::ElementId>,
89 value: impl Into<gpui::SharedString>,
90 ) -> gpui::Stateful<gpui::Div> {
91 use gpui::prelude::*;
92 accessible_text(id, value)
93 .text_size(self.label_size())
94 .line_height(self.label_line_height())
95 .font_weight(gpui::FontWeight::MEDIUM)
96 .text_color(color(ColorRole::ON_SURFACE))
97 }
98
99 pub fn support(
101 self,
102 id: impl Into<gpui::ElementId>,
103 value: impl Into<gpui::SharedString>,
104 ) -> gpui::Stateful<gpui::Div> {
105 use gpui::prelude::*;
106 accessible_text(id, value)
107 .min_w_0()
108 .whitespace_normal()
109 .text_size(self.support_size())
110 .line_height(self.support_line_height())
111 .font_weight(gpui::FontWeight::NORMAL)
112 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
113 }
114
115 pub fn surface(self, outer: OuterCorners) -> gpui::Div {
118 use gpui::prelude::*;
119 tile(outer)
120 .flex()
121 .flex_col()
122 .flex_shrink_0()
123 .when(self == TileScale::Regular, |tile| {
124 tile.text_size(TEXT_SETTINGS_BODY)
125 })
126 }
127
128 pub fn button(self, id: impl Into<ElementId>, outer: OuterCorners) -> RingedButton {
132 let (bg, ink) = (color(ColorRole::SURFACE), color(ColorRole::ON_SURFACE));
133 let scale = self;
134 RingedButton::new(id, RingPlacement::Inner(outer_corner_radii(outer)))
137 .map(move |button| {
138 let button = button
139 .w_full()
140 .min_w_0()
141 .flex()
142 .items_center()
143 .justify_start()
144 .text_left()
145 .whitespace_normal()
146 .bg(bg)
147 .text_color(ink);
148 let button = match scale {
149 TileScale::Regular => button.min_h(rem(64.)).px(ITEM_INSET).py(rem(16.)),
150 TileScale::Compact => button
151 .min_h(rem(68.))
152 .pl(ITEM_INSET)
153 .pr(rem(16.))
154 .py(rem(12.)),
155 };
156 tile_corners(button, outer)
157 })
158 .state_layers(state::hovered(bg, ink), state::pressed(bg, ink))
159 }
160
161 pub fn gap(self) -> Rems {
163 match self {
164 TileScale::Regular => rem(16.),
165 TileScale::Compact => rem(12.),
166 }
167 }
168
169 pub fn row(self) -> gpui::Div {
175 use gpui::prelude::*;
176 gpui_base::h_flex()
177 .flex_1()
178 .w_full()
179 .min_w_0()
180 .items_center()
181 .whitespace_normal()
182 .gap(self.gap())
183 }
184
185 pub fn text(
188 self,
189 id: impl Into<gpui::ElementId>,
190 label: impl Into<gpui::SharedString>,
191 support: Option<gpui::SharedString>,
192 ) -> gpui::Div {
193 use gpui::prelude::*;
194 let id = id.into();
195 gpui_base::v_flex()
196 .flex_1()
197 .min_w_0()
198 .gap(rem(2.))
199 .child(self.label(id.clone(), label))
200 .when_some(support, |column, support| {
201 column.child(
202 self.support(
205 gpui::ElementId::NamedChild(std::sync::Arc::new(id), "support".into()),
206 support,
207 ),
208 )
209 })
210 }
211}
212
213pub use tile_group::TileGroup;
214mod tile_group {
215 use super::TileScale;
216 use crate::tokens::{layout::*, shape::*};
217 use gpui::{
218 AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
219 };
220 use gpui_base::v_flex;
221
222 type TileBuilder = Box<dyn FnOnce(OuterCorners) -> AnyElement>;
224
225 enum TileMain {
226 Content(AnyElement),
228 Built(TileBuilder),
230 Pair(TileBuilder, TileBuilder),
232 }
233
234 struct GroupTile {
235 main: TileMain,
236 details: Vec<AnyElement>,
237 }
238
239 #[derive(gpui::IntoElement)]
243 pub struct TileGroup {
244 id: SharedString,
245 title: SharedString,
246 scale: TileScale,
247 tiles: Vec<GroupTile>,
248 }
249
250 impl TileGroup {
251 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
252 Self {
253 id: id.into(),
254 title: title.into(),
255 scale: TileScale::Regular,
256 tiles: Vec::new(),
257 }
258 }
259
260 pub fn scale(mut self, scale: TileScale) -> Self {
262 self.scale = scale;
263 self
264 }
265
266 pub fn tile(mut self, build: impl FnOnce(OuterCorners) -> AnyElement + 'static) -> Self {
269 self.tiles.push(GroupTile {
270 main: TileMain::Built(Box::new(build)),
271 details: Vec::new(),
272 });
273 self
274 }
275
276 pub fn pair(
278 mut self,
279 leading: impl FnOnce(OuterCorners) -> AnyElement + 'static,
280 trailing: impl FnOnce(OuterCorners) -> AnyElement + 'static,
281 ) -> Self {
282 self.tiles.push(GroupTile {
283 main: TileMain::Pair(Box::new(leading), Box::new(trailing)),
284 details: Vec::new(),
285 });
286 self
287 }
288
289 pub fn detail(mut self, element: impl IntoElement) -> Self {
292 match self.tiles.last_mut() {
293 Some(tile) => tile.details.push(element.into_any_element()),
294 None => self.tiles.push(GroupTile {
295 main: TileMain::Content(element.into_any_element()),
296 details: Vec::new(),
297 }),
298 }
299 self
300 }
301 }
302
303 impl ParentElement for TileGroup {
304 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
305 self.tiles
306 .extend(elements.into_iter().map(|element| GroupTile {
307 main: TileMain::Content(element),
308 details: Vec::new(),
309 }));
310 }
311 }
312
313 impl RenderOnce for TileGroup {
314 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
315 let count = self.tiles.len();
316 let scale = self.scale;
317 let title = super::super::section_title(self.id, self.title).px(ITEM_INSET);
318 let mut group = v_flex().w_full().min_w_0().gap(TILE_GAP);
319 for (index, tile) in self.tiles.into_iter().enumerate() {
320 let first = index == 0;
321 let last = index + 1 == count;
322 let outer = list_corners(index, count);
323 let element = match tile.main {
324 TileMain::Content(content) => scale
325 .surface(outer)
326 .child(content)
327 .children(tile.details)
328 .into_any_element(),
329 TileMain::Built(build) if tile.details.is_empty() => build(outer),
330 TileMain::Built(build) => scale
333 .surface(outer)
334 .child(build([outer[0], outer[1], false, false]))
335 .children(tile.details)
336 .into_any_element(),
337 TileMain::Pair(leading, trailing) => v_flex()
338 .w_full()
339 .min_w_0()
340 .child(
341 div()
342 .grid()
343 .grid_cols(2)
344 .w_full()
345 .min_w_0()
346 .gap(TILE_GAP)
347 .child(leading([first, false, false, last]))
348 .child(trailing([false, first, last, false])),
349 )
350 .children(tile.details)
351 .into_any_element(),
352 };
353 group = group.child(element);
354 }
355 v_flex()
358 .w_full()
359 .min_w_0()
360 .flex_shrink_0()
361 .gap(SECTION_TITLE_SPACE)
362 .child(title)
363 .child(group)
364 }
365 }
366}
367
368pub fn tile_info(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Div {
370 gpui_base::h_flex()
371 .w_full()
372 .min_w_0()
373 .items_start()
374 .gap(rem(10.))
375 .child(
376 div()
377 .flex()
378 .flex_shrink_0()
379 .h(rem(20.))
380 .items_center()
381 .child(
382 IconName::Info
383 .icon()
384 .size(rem(18.))
385 .color(color(ColorRole::ON_SURFACE_VARIANT)),
386 ),
387 )
388 .child(TileScale::Regular.support(id, text).flex_1())
389}
390
391pub fn tile_chevron() -> Icon {
393 IconName::ChevronRight
394 .icon()
395 .size(rem(20.))
396 .color(color(ColorRole::ON_SURFACE_VARIANT))
397}
398
399pub fn disclosure_chevron(open: bool) -> Icon {
401 if open {
402 IconName::ChevronUp
403 } else {
404 IconName::ChevronDown
405 }
406 .icon()
407 .size(rem(20.))
408 .color(color(ColorRole::ON_SURFACE_VARIANT))
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::components::focus::test_rings;
415 use core::prelude::v1::test;
416 use gpui::{Context, Modifiers, TestAppContext, VisualTestContext};
417
418 struct Harness;
419 impl Render for Harness {
420 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
421 window.set_rem_size(px(14.));
422 div().p(px(20.)).w(px(300.)).child(
423 TileScale::Regular
424 .button("ring-tile", [true, true, false, false])
425 .debug_selector(|| "ring-tile".into())
426 .on_click(|_, _, _| {})
427 .child(TileScale::Regular.row().child("Tile")),
428 )
429 }
430 }
431
432 fn draw(cx: &mut VisualTestContext) {
433 cx.update(|window, cx| {
434 window.refresh();
435 window.draw(cx).clear(cx);
436 });
437 }
438
439 #[gpui::test]
440 fn tile_buttons_show_the_inner_ring_only_for_keyboard_focus(cx: &mut TestAppContext) {
441 cx.update(gpui_base::init);
442 let (_, cx) = cx.add_window_view(|_, _| Harness);
443 draw(cx);
444 let tile = cx.debug_bounds("ring-tile").unwrap();
445 cx.simulate_click(tile.center(), Modifiers::default());
446 draw(cx);
447 assert!(test_rings::painted("ring-tile").is_none());
448 let keystroke =
449 cx.update(|_, cx| gpui::Keystroke::parse_with_os("a", cx.platform_info().os).unwrap());
450 cx.simulate_event(gpui::KeyDownEvent {
451 keystroke,
452 is_held: false,
453 prefer_character_input: false,
454 });
455 draw(cx);
456 assert_eq!(test_rings::painted("ring-tile"), Some(tile.inset(px(3.))));
457 }
458
459 struct GroupHarness;
460 impl Render for GroupHarness {
461 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
462 window.set_rem_size(px(21.));
463 div().w(px(300.)).debug_selector(|| "group".into()).child(
464 TileGroup::new("group-title", "Title")
465 .child(div().h(px(30.)).debug_selector(|| "first".into()))
466 .child(div().h(px(30.)).debug_selector(|| "second".into())),
467 )
468 }
469 }
470
471 #[gpui::test]
472 fn the_title_gap_follows_the_text_scale_and_tiles_keep_their_ground(cx: &mut TestAppContext) {
473 let (_, cx) = cx.add_window_view(|_, _| GroupHarness);
474 draw(cx);
475 let group = cx.debug_bounds("group").unwrap();
476 let first = cx.debug_bounds("first").unwrap();
477 let second = cx.debug_bounds("second").unwrap();
478 assert_eq!(first.top() - group.top(), px(30. + 15.));
480 assert_eq!(second.top() - first.bottom(), TILE_GAP);
481 }
482
483 #[test]
484 fn regular_tile_labels_use_body_large() {
485 assert_eq!(
486 TileScale::Regular.label_size(),
487 TypeRole::BodyLarge.scale().font_size
488 );
489 assert_eq!(TileScale::Regular.label_line_height(), rem(24.));
490 assert_eq!(TileScale::Regular.support_size(), TEXT_SETTINGS_AUX);
491 }
492
493 #[test]
494 fn disabled_and_previewed_tiles_are_inert() {
495 use crate::components::focus::shows_pointer;
496 assert!(shows_pointer(true, None));
497 assert!(
498 !shows_pointer(false, None),
499 "disabled: no pointer, no layers"
500 );
501 assert!(!shows_pointer(
502 false,
503 Some(crate::components::InteractionPreview::Disabled)
504 ));
505 assert!(!shows_pointer(
506 true,
507 Some(crate::components::InteractionPreview::Hovered)
508 ));
509 }
510
511 struct DisabledHarness;
512 impl Render for DisabledHarness {
513 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
514 div().p(px(20.)).w(px(300.)).child(
515 TileScale::Regular
516 .button("disabled-tile", [true; 4])
517 .disabled(true)
518 .debug_selector(|| "disabled-tile".into())
519 .child(TileScale::Regular.text("disabled-text", "Tile", Some("Off".into()))),
520 )
521 }
522 }
523
524 #[gpui::test]
525 fn a_disabled_tile_is_not_a_tab_stop(cx: &mut TestAppContext) {
526 cx.update(gpui_base::init);
527 let (_, cx) = cx.add_window_view(|_, _| DisabledHarness);
528 draw(cx);
529 assert!(cx.debug_bounds("disabled-tile").is_some());
530 cx.update(|window, cx| window.focus_next(cx));
531 assert!(cx.update(|window, cx| window.focused(cx)).is_none());
532 }
533
534 #[test]
535 fn ring_corners_match_the_tile_corners() {
536 let corners = outer_corner_radii([true, true, false, false]);
537 assert_eq!(corners.top_left, TILE_RADIUS);
538 assert_eq!(corners.top_right, TILE_RADIUS);
539 assert_eq!(corners.bottom_right, TILE_INNER_RADIUS);
540 assert_eq!(corners.bottom_left, TILE_INNER_RADIUS);
541 }
542}