Skip to main content

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}