gpui_component/grid_layout.rs
1//! What this design system paints on a grid layout.
2//!
3//! Base owns the grid: where items sit, the drag, the resize, the keyboard and
4//! the layout arithmetic. Everything here is appearance — the gap, the motion
5//! items settle with, the drop placeholder, the resize grip, and how the
6//! dragged and the keyboard-selected items stand out.
7
8use gpui::{
9 App, ElementId, Entity, InteractiveElement as _, IntoElement as _, ParentElement as _,
10 Styled as _, div, prelude::FluentBuilder as _, px,
11};
12use gpui_base::{Transition, box_shadow};
13
14pub use gpui_base::grid_layout::*;
15
16use crate::{
17 styled::{FOCUS_RING_OPACITY, FOCUS_RING_WIDTH},
18 theme::ActiveTheme as _,
19};
20
21/// The resize corner, which brightens the grip it holds while hovered.
22const GRIP_GROUP: &str = "grid-layout-resize-grip";
23
24/// Create a [`GridLayout`] wearing this design system: the theme's medium
25/// spacing between items, its move motion, a dashed drop placeholder, a
26/// resize grip in each resizable item's corner and a raised dragged item.
27///
28/// While the grid is editable — its items can be moved or resized — the
29/// selected item carries a ring in the primary color, so an editor shows which
30/// item its settings apply to. While the keyboard drives the grid the focus
31/// ring takes its place, never both. A read-only grid marks no selection.
32///
33/// Every part can still be overridden on the returned builder — `.gap_4()` for
34/// more room, say.
35///
36/// ```ignore
37/// grid_layout("dashboard", &state, cx)
38/// .item(GridItem::new("revenue").rounded(cx.theme().radius_lg).child(revenue_card))
39/// ```
40pub fn grid_layout(
41 id: impl Into<ElementId>,
42 state: &Entity<GridLayoutState>,
43 cx: &App,
44) -> GridLayout {
45 let theme = cx.theme();
46 let motion = theme.motion_tokens();
47 let editable = {
48 let state = state.read(cx);
49 state.is_draggable() || state.is_resizable()
50 };
51 let selected = box_shadow(0., 0., 0., px(2.), theme.primary);
52 let ring = if theme.focus_ring {
53 box_shadow(
54 0.,
55 0.,
56 0.,
57 FOCUS_RING_WIDTH,
58 theme.ring.alpha(FOCUS_RING_OPACITY),
59 )
60 } else {
61 box_shadow(0., 0., 0., px(1.), theme.ring)
62 };
63
64 GridLayout::new(id, state)
65 .gap(theme.spacing_tokens().md)
66 .transition(Transition::new(motion.duration_normal).easing(motion.easing_move.clone()))
67 .dragging_item(|style| style.shadow_lg())
68 // Both rings are the item's shadow, so the focus ring replaces the
69 // selection ring rather than drawing a second one around it.
70 .when(editable, |grid| {
71 grid.selected_item(move |style| style.shadow(vec![selected]))
72 })
73 .focused_item(move |style| style.shadow(vec![ring]))
74 .placeholder(|_, cx| {
75 let theme = cx.theme();
76 div()
77 .size_full()
78 .rounded(theme.radius_lg)
79 .bg(theme.primary.opacity(0.08))
80 .border_1()
81 .border_dashed()
82 .border_color(theme.primary.opacity(0.5))
83 .into_any_element()
84 })
85 .resize_handle(|_, cx| {
86 let color = cx.theme().muted_foreground;
87 // The corner mark sits a few pixels in from the item's corner, and
88 // strengthens while the pointer is on the corner it marks.
89 div()
90 .group(GRIP_GROUP)
91 .size_full()
92 .child(
93 div()
94 .absolute()
95 .right(px(3.))
96 .bottom(px(3.))
97 .size(px(6.))
98 .border_r_2()
99 .border_b_2()
100 .border_color(color.opacity(0.35))
101 .group_hover(GRIP_GROUP, |this| this.border_color(color.opacity(0.9))),
102 )
103 .into_any_element()
104 })
105}