Skip to main content

gpui_base/grid_layout/
grid_layout.rs

1use std::{rc::Rc, sync::Arc, time::Duration};
2
3use gpui::{
4    AbsoluteLength, AnyElement, App, AppContext as _, CursorStyle, DefiniteLength, Div, ElementId,
5    Empty, Entity, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels,
6    Refineable as _, Rems, Render, RenderOnce, Role, SharedString, Size, Stateful,
7    StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
8    prelude::FluentBuilder as _, px, relative, rems, size,
9};
10use smallvec::SmallVec;
11
12use super::{
13    CONTEXT, GrowHeight, GrowWidth, MoveDown, MoveLeft, MoveRight, MoveUp, SelectNextItem,
14    SelectPreviousItem, ShrinkHeight, ShrinkWidth,
15    layout::{self, GridArea},
16    state::{DragGridItem, GridDrag, GridGesture, GridLayoutState, GridMetrics},
17};
18use crate::{StyledExt as _, Transition, actions::Cancel, state_style::StateStyle, transition};
19
20/// The side of the square, at an item's trailing-bottom corner, that the
21/// pointer resizes the item by.
22const RESIZE_HANDLE_SIZE: Pixels = px(16.);
23
24/// Draws one part of a grid layout — the drop placeholder or a resize handle.
25type PartRenderer = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
26
27/// A grid whose items the user drags and resizes, the way dashboards and
28/// widget boards are arranged.
29///
30/// The [`GridLayoutState`] owns where every item sits; this element pairs each
31/// [`GridItem`] with the [`GridArea`] of the same id and places it there. An
32/// item without an area is not drawn.
33///
34/// Like every Base control it paints nothing of its own. Gaps come from the
35/// element's own `gap`, `gap_x` and `gap_y`, as on a CSS grid; the look of the
36/// drop placeholder, the resize handle, and the dragged and selected items,
37/// and the motion items settle with, come from the builders below. GPUI
38/// Component's `grid_layout` supplies all of them from the theme.
39///
40/// ```ignore
41/// GridLayout::new("dashboard", &state)
42///     .row_height(rems(2.5))
43///     .gap_3()
44///     .item(GridItem::new("revenue").child(revenue_card))
45///     .item(GridItem::new("orders").child(orders_card))
46/// ```
47///
48/// The grid is as wide as its container and as tall as its rows. Put it in a
49/// scrollable region when the rows can outgrow the window.
50///
51/// Click an item to select it. With the grid focused, the arrow keys move the
52/// selected item, Shift with the arrow keys resizes it, Tab and Shift-Tab move
53/// the selection — and out of the grid past either end — and Escape cancels a
54/// drag.
55#[derive(IntoElement)]
56pub struct GridLayout {
57    id: ElementId,
58    state: Entity<GridLayoutState>,
59    accessibility_label: SharedString,
60    items: Vec<GridItem>,
61    style: StyleRefinement,
62    row_height: Rems,
63    transition: Option<Transition>,
64    dragging_item: StyleRefinement,
65    selected_item: StyleRefinement,
66    focused_item: StyleRefinement,
67    reflow: Option<usize>,
68    placeholder: Option<PartRenderer>,
69    resize_handle: Option<PartRenderer>,
70}
71
72impl GridLayout {
73    pub fn new(id: impl Into<ElementId>, state: &Entity<GridLayoutState>) -> Self {
74        Self {
75            id: id.into(),
76            state: state.clone(),
77            accessibility_label: "Grid layout".into(),
78            items: Vec::new(),
79            style: StyleRefinement::default(),
80            row_height: rems(2.5),
81            transition: None,
82            dragging_item: StyleRefinement::default(),
83            selected_item: StyleRefinement::default(),
84            focused_item: StyleRefinement::default(),
85            reflow: None,
86            placeholder: None,
87            resize_handle: None,
88        }
89    }
90
91    /// Name the grid for assistive technology. Defaults to "Grid layout".
92    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
93        self.accessibility_label = label.into();
94        self
95    }
96
97    /// Add an item, placed where the area with its id says.
98    pub fn item(mut self, item: GridItem) -> Self {
99        self.items.push(item);
100        self
101    }
102
103    /// Add several items.
104    pub fn items(mut self, items: impl IntoIterator<Item = GridItem>) -> Self {
105        self.items.extend(items);
106        self
107    }
108
109    /// The height of one row. Defaults to `2.5rem`, so rows follow the
110    /// application's zoom.
111    pub fn row_height(mut self, row_height: Rems) -> Self {
112        self.row_height = row_height;
113        self
114    }
115
116    /// How items ease into their cells when the layout changes. Without one
117    /// they move at once; the item under the pointer always tracks it exactly.
118    pub fn transition(mut self, transition: Transition) -> Self {
119        self.transition = Some(transition);
120        self
121    }
122
123    /// The style of the item under the pointer, layered over its own.
124    pub fn dragging_item(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
125        self.dragging_item = build(StateStyle::default()).into_refinement();
126        self
127    }
128
129    /// The style of the selected item, layered over its own — whether the
130    /// pointer or the keyboard selected it. An editor shows which item its
131    /// settings apply to with this.
132    pub fn selected_item(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
133        self.selected_item = build(StateStyle::default()).into_refinement();
134        self
135    }
136
137    /// The style of the selected item while the keyboard drives the grid,
138    /// layered over [`GridLayout::selected_item`]: the focus ring. A pointer
139    /// press selects without showing it.
140    pub fn focused_item(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
141        self.focused_item = build(StateStyle::default()).into_refinement();
142        self
143    }
144
145    /// Lay the items out over `cols` columns for display, without changing the
146    /// layout the state holds — a one-column stack for a narrow window, say.
147    ///
148    /// The areas are derived from the state's: each is fitted to the width and
149    /// the result compacted upward in reading order, with locks set aside.
150    /// While reflowed the grid is read-only — items cannot be dragged,
151    /// resized or moved from the keyboard — so nothing is saved at the
152    /// narrower width. Selection still works.
153    ///
154    /// ```ignore
155    /// GridLayout::new("dashboard", &state)
156    ///     .when(window.viewport_size().width < px(768.), |grid| grid.reflow(1))
157    /// ```
158    pub fn reflow(mut self, cols: usize) -> Self {
159        self.reflow = Some(cols.max(1));
160        self
161    }
162
163    /// What marks the cells the dragged item will land in. It fills those
164    /// cells; without one, nothing marks them.
165    pub fn placeholder(
166        mut self,
167        render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
168    ) -> Self {
169        self.placeholder = Some(Rc::new(render));
170        self
171    }
172
173    /// What a resizable item shows in the trailing-bottom corner the pointer
174    /// resizes it by. The corner works without one.
175    pub fn resize_handle(
176        mut self,
177        render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
178    ) -> Self {
179        self.resize_handle = Some(Rc::new(render));
180        self
181    }
182}
183
184impl Styled for GridLayout {
185    fn style(&mut self) -> &mut StyleRefinement {
186        &mut self.style
187    }
188}
189
190/// One item of a [`GridLayout`], paired with the [`GridArea`] of the same id.
191///
192/// The item fills the cells its area covers. Style it to match its content —
193/// round it like the card inside it, say — so the dragged and selected styles
194/// follow the same corners.
195pub struct GridItem {
196    id: SharedString,
197    accessibility_label: Option<SharedString>,
198    handle_only: bool,
199    style: StyleRefinement,
200    children: SmallVec<[AnyElement; 2]>,
201}
202
203impl GridItem {
204    pub fn new(id: impl Into<SharedString>) -> Self {
205        Self {
206            id: id.into(),
207            accessibility_label: None,
208            handle_only: false,
209            style: StyleRefinement::default(),
210            children: SmallVec::new(),
211        }
212    }
213
214    /// Name the item for assistive technology — usually its visible title.
215    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
216        self.accessibility_label = Some(label.into());
217        self
218    }
219
220    /// Move the item only from a [`grid_drag_handle`] inside it, leaving the
221    /// rest of its content free for its own pointer interaction.
222    pub fn drag_handle_only(mut self, handle_only: bool) -> Self {
223        self.handle_only = handle_only;
224        self
225    }
226}
227
228impl gpui::prelude::FluentBuilder for GridItem {}
229
230impl Styled for GridItem {
231    fn style(&mut self) -> &mut StyleRefinement {
232        &mut self.style
233    }
234}
235
236impl ParentElement for GridItem {
237    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
238        self.children.extend(elements);
239    }
240}
241
242/// The part of an item the pointer moves it by, for an item built with
243/// [`GridItem::drag_handle_only`] — a card's header, say.
244///
245/// ```ignore
246/// GridItem::new("orders")
247///     .drag_handle_only(true)
248///     .child(grid_drag_handle(&state, "orders").child("Orders"))
249///     .child(orders_table)
250/// ```
251pub fn grid_drag_handle(
252    state: &Entity<GridLayoutState>,
253    item: impl Into<SharedString>,
254) -> Stateful<Div> {
255    let item = item.into();
256    div()
257        .id(ElementId::NamedChild(
258            Arc::new(ElementId::from(item.clone())),
259            "grid-drag-handle".into(),
260        ))
261        .on_drag(
262            drag_payload(state, &item, GridGesture::Move),
263            start_drag(state),
264        )
265}
266
267/// The view GPUI draws under the pointer during a drag. The dragged item
268/// itself follows the pointer inside the grid, so this draws nothing.
269struct GridDragPreview;
270
271impl Render for GridDragPreview {
272    fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
273        Empty
274    }
275}
276
277fn drag_payload(
278    state: &Entity<GridLayoutState>,
279    item: &SharedString,
280    gesture: GridGesture,
281) -> DragGridItem {
282    DragGridItem {
283        grid: state.entity_id(),
284        item: item.clone(),
285        gesture,
286    }
287}
288
289fn start_drag(
290    state: &Entity<GridLayoutState>,
291) -> impl Fn(&DragGridItem, gpui::Point<Pixels>, &mut Window, &mut App) -> Entity<GridDragPreview>
292+ 'static {
293    let state = state.clone();
294    move |drag, _, window, cx| {
295        state.update(cx, |state, cx| {
296            // A handle may outlive the permission to move: then the drag
297            // carries nothing and the layout ignores it.
298            if state.is_gesture_allowed(&drag.item, drag.gesture) {
299                state.begin_drag(drag.item.clone(), drag.gesture, window, cx)
300            }
301        });
302        cx.new(|_| GridDragPreview)
303    }
304}
305
306/// Resolve the grid's gap from its own style: the column gap across, the row
307/// gap down. A gap given as a fraction has nothing to be a fraction of here,
308/// so it counts as none.
309fn resolve_gap(style: &StyleRefinement, rem_size: Pixels) -> Size<Pixels> {
310    let resolve = |length: Option<DefiniteLength>| match length {
311        Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels))) => pixels,
312        Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems))) => rems.to_pixels(rem_size),
313        _ => px(0.),
314    };
315    size(resolve(style.gap.width), resolve(style.gap.height))
316}
317
318/// Where an item is drawn, as fractions of the column track for its
319/// horizontal extent and pixels for its vertical one.
320///
321/// Fractions keep idle items exact at any container width without measuring
322/// it; see [`GridLayout::render`] for the track that makes them work.
323#[derive(Clone, Copy, Debug, PartialEq)]
324struct ItemFrame {
325    left: f32,
326    width: f32,
327    top: Pixels,
328    height: Pixels,
329}
330
331impl ItemFrame {
332    fn of(area: &GridArea, columns: usize, row_height: Pixels, gap: Size<Pixels>) -> Self {
333        let columns = columns.max(1) as f32;
334        Self {
335            left: area.col as f32 / columns,
336            width: area.col_span as f32 / columns,
337            top: (row_height + gap.height) * area.row as f32,
338            height: (row_height * area.row_span as f32
339                + gap.height * area.row_span.saturating_sub(1) as f32)
340                .max(px(0.)),
341        }
342    }
343
344    /// The frame of an item under the pointer: its start position plus the
345    /// pointer's travel, kept inside the grid.
346    fn following(drag: &GridDrag, metrics: GridMetrics, columns: usize) -> Option<Self> {
347        let start = drag.start_area()?;
348        let track = metrics.width + metrics.gap.width;
349        if track <= px(0.) {
350            return None;
351        }
352        let (mut left, mut top) = (metrics.left(start.col), metrics.top(start.row));
353        let (mut width, mut height) = (
354            metrics.span_width(start.col_span),
355            metrics.span_height(start.row_span),
356        );
357        match drag.gesture {
358            GridGesture::Move => {
359                left = (left + drag.delta.x).clamp(px(0.), (metrics.width - width).max(px(0.)));
360                top = (top + drag.delta.y).max(px(0.));
361            }
362            GridGesture::Resize => {
363                let (min_cols, min_rows) = start.clamp_size(1, 1, columns);
364                let (max_cols, _) = start.clamp_size(usize::MAX, 1, columns);
365                width = (width + drag.delta.x)
366                    .clamp(metrics.span_width(min_cols), metrics.span_width(max_cols));
367                height = (height + drag.delta.y).max(metrics.span_height(min_rows));
368                if let Some(max_rows) = start.max_row_span {
369                    height = height.min(metrics.span_height(max_rows));
370                }
371            }
372        }
373        Some(Self {
374            left: left / track,
375            width: (width + metrics.gap.width) / track,
376            top,
377            height,
378        })
379    }
380}
381
382impl RenderOnce for GridLayout {
383    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
384        let state = self.state.read(cx);
385        let grid = state_id(&self.state);
386        let reflow = self.reflow;
387        let columns = reflow.unwrap_or_else(|| state.column_count());
388        let gap = resolve_gap(&self.style, window.rem_size());
389        let row_height = self.row_height.to_pixels(window.rem_size());
390        let displayed = match reflow {
391            Some(cols) => layout::reflowed(state.areas(), cols),
392            None => state.displayed_areas(cx).to_vec(),
393        };
394        let drag = state.active_drag(cx).filter(|_| reflow.is_none());
395        let active = drag.map(|drag| (drag.item.clone(), drag.gesture));
396        let following = drag.and_then(|drag| ItemFrame::following(drag, drag.metrics?, columns));
397        let focus_handle = state.focus_handle().clone();
398        let focus_visible = state.is_keyboard_active() && focus_handle.is_focused(window);
399        let selected = state.selected_item().cloned();
400        let rows = layout::rows(&displayed);
401        let height =
402            (row_height * rows as f32 + gap.height * rows.saturating_sub(1) as f32).max(px(0.));
403        let settle = self
404            .transition
405            .clone()
406            .unwrap_or_else(|| Transition::new(Duration::ZERO));
407
408        let mut items = self.items;
409        // The item under the pointer paints last, above the ones it displaces.
410        if let Some((active_id, _)) = &active {
411            items.sort_by_key(|item| &item.id == active_id);
412        }
413
414        let placeholder = active.as_ref().and_then(|(id, _)| {
415            displayed
416                .iter()
417                .find(|area| &area.id == id)
418                .map(|area| ItemFrame::of(area, columns, row_height, gap))
419        });
420
421        let parts = ItemParts {
422            state: &self.state,
423            gap,
424            dragging_item: &self.dragging_item,
425            selected_item: &self.selected_item,
426            focused_item: &self.focused_item,
427            resize_handle: self.resize_handle.as_ref(),
428            read_only: reflow.is_some(),
429        };
430        let rendered = items
431            .into_iter()
432            .filter_map(|item| {
433                let area = displayed.iter().find(|area| area.id == item.id)?;
434                let gesture = active
435                    .as_ref()
436                    .filter(|(id, _)| id == &item.id)
437                    .map(|(_, gesture)| *gesture);
438                let target = match (gesture, following) {
439                    (Some(_), Some(frame)) => frame,
440                    _ => ItemFrame::of(area, columns, row_height, gap),
441                };
442                // The item under the pointer tracks it exactly; everything else,
443                // and that item once released, eases into its cells.
444                let policy = if gesture.is_some() {
445                    Transition::new(Duration::ZERO)
446                } else {
447                    settle.clone()
448                };
449                let frame = animate_frame(&grid, &item.id, target, policy, window, cx);
450                let selected = selected.as_ref() == Some(&area.id);
451                Some(parts.render(
452                    item,
453                    area,
454                    frame,
455                    gesture,
456                    selected,
457                    focus_visible,
458                    window,
459                    cx,
460                ))
461            })
462            .collect::<Vec<_>>();
463
464        div().id(self.id).w_full().refine_style(&self.style).child(
465            div()
466                .id("grid-layout-surface")
467                .role(Role::Group)
468                .aria_label(self.accessibility_label)
469                .key_context(CONTEXT)
470                .track_focus(&focus_handle)
471                .relative()
472                .w_full()
473                .h(height)
474                // A reflowed grid shows a derived layout; a drag begun from a
475                // handle inside it must not edit the stored one.
476                .when(reflow.is_none(), |surface| {
477                    surface.on_drag_move(window.listener_for(
478                        &self.state,
479                        move |state, event: &gpui::DragMoveEvent<DragGridItem>, _, cx| {
480                            if event.drag(cx).grid == cx.entity_id() {
481                                state.drag_to(
482                                    event.event.position,
483                                    event.bounds,
484                                    row_height,
485                                    gap,
486                                    cx,
487                                );
488                            }
489                        },
490                    ))
491                })
492                // A drop anywhere ends the gesture, inside the grid or not;
493                // capturing keeps a child that stops propagation from
494                // swallowing the release.
495                .capture_any_mouse_up(
496                    window.listener_for(&self.state, |state, _, _, cx| state.end_drag(cx)),
497                )
498                .on_mouse_up_out(
499                    MouseButton::Left,
500                    window.listener_for(&self.state, |state, _, _, cx| state.end_drag(cx)),
501                )
502                .on_action(
503                    window.listener_for(&self.state, |state, _: &Cancel, window, cx| {
504                        if !state.cancel_drag(window, cx) {
505                            cx.propagate();
506                        }
507                    }),
508                )
509                // Moving and resizing edit the stored layout, which a
510                // reflowed grid is not showing.
511                .when(reflow.is_none(), |surface| {
512                    surface
513                        .on_action(keyboard(
514                            &self.state,
515                            window,
516                            |state: &mut GridLayoutState, _: &MoveLeft, cx| {
517                                state.nudge_selected(-1, 0, cx)
518                            },
519                        ))
520                        .on_action(keyboard(&self.state, window, |state, _: &MoveRight, cx| {
521                            state.nudge_selected(1, 0, cx)
522                        }))
523                        .on_action(keyboard(&self.state, window, |state, _: &MoveUp, cx| {
524                            state.nudge_selected(0, -1, cx)
525                        }))
526                        .on_action(keyboard(&self.state, window, |state, _: &MoveDown, cx| {
527                            state.nudge_selected(0, 1, cx)
528                        }))
529                        .on_action(keyboard(
530                            &self.state,
531                            window,
532                            |state, _: &ShrinkWidth, cx| state.resize_selected(-1, 0, cx),
533                        ))
534                        .on_action(keyboard(&self.state, window, |state, _: &GrowWidth, cx| {
535                            state.resize_selected(1, 0, cx)
536                        }))
537                        .on_action(keyboard(
538                            &self.state,
539                            window,
540                            |state, _: &ShrinkHeight, cx| state.resize_selected(0, -1, cx),
541                        ))
542                        .on_action(keyboard(
543                            &self.state,
544                            window,
545                            |state, _: &GrowHeight, cx| state.resize_selected(0, 1, cx),
546                        ))
547                })
548                .on_action(keyboard(
549                    &self.state,
550                    window,
551                    |state, _: &SelectNextItem, cx| state.select_adjacent(true, cx),
552                ))
553                .on_action(keyboard(
554                    &self.state,
555                    window,
556                    |state, _: &SelectPreviousItem, cx| state.select_adjacent(false, cx),
557                ))
558                // The column track: as wide as the grid plus one column gap, so
559                // each column owns an equal fraction of it and every item
560                // reaches its cells with relative units alone. Each item gives
561                // the trailing gap back as padding.
562                .child(
563                    div()
564                        .absolute()
565                        .top_0()
566                        .left_0()
567                        .bottom_0()
568                        .right(-gap.width)
569                        .when_some(
570                            placeholder.zip(self.placeholder.as_ref()),
571                            |track, (frame, render)| {
572                                track.child(
573                                    place(div(), frame, gap)
574                                        .child(div().size_full().child(render(window, cx))),
575                                )
576                            },
577                        )
578                        .children(rendered),
579                ),
580        )
581    }
582}
583
584/// Position an element over the cells `frame` describes.
585fn place<E: Styled>(element: E, frame: ItemFrame, gap: Size<Pixels>) -> E {
586    element
587        .absolute()
588        .left(relative(frame.left))
589        .w(relative(frame.width))
590        .top(frame.top)
591        .h(frame.height)
592        .pr(gap.width)
593}
594
595/// A keyboard command on the selected item, which passes the key on when it
596/// does not apply — at an edge, or with nothing selected.
597fn keyboard<A: gpui::Action>(
598    state: &Entity<GridLayoutState>,
599    window: &Window,
600    command: impl Fn(&mut GridLayoutState, &A, &mut gpui::Context<GridLayoutState>) -> bool + 'static,
601) -> impl Fn(&A, &mut Window, &mut App) + 'static {
602    window.listener_for(state, move |state, action: &A, _, cx| {
603        if !command(state, action, cx) {
604            cx.propagate();
605        }
606    })
607}
608
609fn state_id(state: &Entity<GridLayoutState>) -> ElementId {
610    ElementId::NamedChild(
611        Arc::new(ElementId::View(state.entity_id())),
612        "grid-layout".into(),
613    )
614}
615
616fn animate_frame(
617    grid: &ElementId,
618    item: &SharedString,
619    target: ItemFrame,
620    policy: Transition,
621    window: &mut Window,
622    cx: &mut App,
623) -> ItemFrame {
624    let key = ElementId::NamedChild(Arc::new(grid.clone()), item.clone());
625    ItemFrame {
626        left: transition(
627            (key.clone(), "left"),
628            target.left,
629            policy.clone(),
630            window,
631            cx,
632        ),
633        width: transition(
634            (key.clone(), "width"),
635            target.width,
636            policy.clone(),
637            window,
638            cx,
639        ),
640        top: transition((key.clone(), "top"), target.top, policy.clone(), window, cx),
641        height: transition((key, "height"), target.height, policy, window, cx),
642    }
643}
644
645/// What every item of one grid is rendered with.
646struct ItemParts<'a> {
647    state: &'a Entity<GridLayoutState>,
648    gap: Size<Pixels>,
649    dragging_item: &'a StyleRefinement,
650    selected_item: &'a StyleRefinement,
651    focused_item: &'a StyleRefinement,
652    resize_handle: Option<&'a PartRenderer>,
653    /// A reflowed grid shows a derived layout, so nothing in it can move.
654    read_only: bool,
655}
656
657impl ItemParts<'_> {
658    #[allow(clippy::too_many_arguments)]
659    fn render(
660        &self,
661        item: GridItem,
662        area: &GridArea,
663        frame: ItemFrame,
664        gesture: Option<GridGesture>,
665        selected: bool,
666        focus_visible: bool,
667        window: &mut Window,
668        cx: &mut App,
669    ) -> AnyElement {
670        let state = self.state;
671        let snapshot = state.read(cx);
672        let movable = !self.read_only
673            && !item.handle_only
674            && snapshot.is_gesture_allowed(&area.id, GridGesture::Move);
675        let resizable =
676            !self.read_only && snapshot.is_gesture_allowed(&area.id, GridGesture::Resize);
677        let item_id = ElementId::NamedChild(
678            Arc::new(ElementId::from(area.id.clone())),
679            "grid-layout-item".into(),
680        );
681        let mut surface_style = item.style;
682        if selected {
683            surface_style.refine(self.selected_item);
684            if focus_visible {
685                surface_style.refine(self.focused_item);
686            }
687        }
688        if gesture.is_some() {
689            surface_style.refine(self.dragging_item);
690        }
691
692        place(div().id(item_id), frame, self.gap)
693            .role(Role::Group)
694            .when_some(item.accessibility_label, |this, label| {
695                this.aria_label(label)
696            })
697            .aria_selected(selected)
698            .on_mouse_down(MouseButton::Left, {
699                let state = state.clone();
700                let id = area.id.clone();
701                move |event, window, cx| {
702                    state.update(cx, |state, cx| state.press(&id, event.position, window, cx))
703                }
704            })
705            .when(movable, |this| {
706                this.on_drag(
707                    drag_payload(state, &area.id, GridGesture::Move),
708                    start_drag(state),
709                )
710            })
711            .child(
712                div()
713                    .relative()
714                    .size_full()
715                    .refine_style(&surface_style)
716                    .children(item.children)
717                    .when(resizable, |this| {
718                        this.child(self.resize_corner(&area.id, window, cx))
719                    }),
720            )
721            .into_any_element()
722    }
723
724    /// The trailing-bottom corner the pointer resizes an item by.
725    fn resize_corner(
726        &self,
727        item: &SharedString,
728        window: &mut Window,
729        cx: &mut App,
730    ) -> impl IntoElement {
731        let state = self.state;
732        div()
733            .id(ElementId::NamedChild(
734                Arc::new(ElementId::from(item.clone())),
735                "grid-layout-resize".into(),
736            ))
737            .absolute()
738            .right_0()
739            .bottom_0()
740            .size(RESIZE_HANDLE_SIZE)
741            .cursor(CursorStyle::ResizeUpLeftDownRight)
742            // The press resizes; it must not also begin the item's move.
743            .on_mouse_down(MouseButton::Left, {
744                let state = state.clone();
745                let id = item.clone();
746                move |event, window, cx| {
747                    cx.stop_propagation();
748                    state.update(cx, |state, cx| state.press(&id, event.position, window, cx))
749                }
750            })
751            .on_drag(
752                drag_payload(state, item, GridGesture::Resize),
753                start_drag(state),
754            )
755            .when_some(self.resize_handle, |this, render| {
756                this.child(render(window, cx))
757            })
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use std::{cell::RefCell, rc::Rc};
764
765    use gpui::{Context, Modifiers, Point, TestAppContext, VisualTestContext, point};
766
767    use super::*;
768    use crate::grid_layout::{GridLayoutEvent, layout::tests::area};
769
770    /// Twelve 90px columns with 10px gaps: column `n` starts at `100 * n`.
771    const WIDTH: Pixels = px(1190.);
772
773    struct Harness {
774        state: Entity<GridLayoutState>,
775        reflow: Option<usize>,
776    }
777
778    impl Render for Harness {
779        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
780            div().w(WIDTH).child(
781                GridLayout::new("grid", &self.state)
782                    .gap(px(10.))
783                    .when_some(self.reflow, |grid, cols| grid.reflow(cols))
784                    .item(GridItem::new("a").child(div().size_full()))
785                    .item(GridItem::new("b").child(div().size_full())),
786            )
787        }
788    }
789
790    /// A grid of `a` and `b` side by side, and the layout changes it emits.
791    fn harness(
792        cx: &mut TestAppContext,
793    ) -> (
794        Entity<GridLayoutState>,
795        Rc<RefCell<Vec<GridLayoutEvent>>>,
796        &mut VisualTestContext,
797    ) {
798        harness_with(cx, None)
799    }
800
801    fn harness_with(
802        cx: &mut TestAppContext,
803        reflow: Option<usize>,
804    ) -> (
805        Entity<GridLayoutState>,
806        Rc<RefCell<Vec<GridLayoutEvent>>>,
807        &mut VisualTestContext,
808    ) {
809        cx.update(crate::init);
810        let state = cx.update(|cx| {
811            cx.new(|cx| {
812                GridLayoutState::new(cx)
813                    .default_areas([area("a", 0, 0, 4, 2), area("b", 4, 0, 4, 2)])
814            })
815        });
816        let events = Rc::new(RefCell::new(Vec::new()));
817        cx.update(|cx| {
818            let events = events.clone();
819            cx.subscribe(&state, move |_, event: &GridLayoutEvent, _| {
820                if matches!(event, GridLayoutEvent::Change(_)) {
821                    events.borrow_mut().push(event.clone())
822                }
823            })
824            .detach();
825        });
826        let (_, cx) = cx.add_window_view({
827            let state = state.clone();
828            move |_, _| Harness { state, reflow }
829        });
830        draw(cx);
831        (state, events, cx)
832    }
833
834    fn draw(cx: &mut VisualTestContext) {
835        cx.update(|window, cx| window.draw(cx).clear(cx));
836    }
837
838    fn drag(cx: &mut VisualTestContext, from: Point<Pixels>, path: &[Point<Pixels>]) {
839        cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::default());
840        draw(cx);
841        for &to in path {
842            cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::default());
843            draw(cx);
844        }
845    }
846
847    fn release(cx: &mut VisualTestContext, at: Point<Pixels>) {
848        cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::default());
849        draw(cx);
850    }
851
852    fn place_of(state: &Entity<GridLayoutState>, cx: &mut VisualTestContext, id: &str) -> GridArea {
853        state.read_with(cx, |state, _| state.area(id).unwrap().clone())
854    }
855
856    #[gpui::test]
857    fn dragging_an_item_moves_it_and_commits_on_release(cx: &mut TestAppContext) {
858        let (state, events, cx) = harness(cx);
859
860        drag(
861            cx,
862            point(px(50.), px(30.)),
863            &[point(px(60.), px(30.)), point(px(450.), px(30.))],
864        );
865        // While the pointer is down, only the preview changes.
866        assert_eq!(place_of(&state, cx, "a").col, 0);
867        assert!(events.borrow().is_empty());
868
869        release(cx, point(px(450.), px(30.)));
870        let (a, b) = (place_of(&state, cx, "a"), place_of(&state, cx, "b"));
871        assert_eq!((a.col, a.row), (4, 0));
872        assert_eq!((b.col, b.row), (4, 2));
873        assert_eq!(events.borrow().len(), 1);
874        assert!(state.read_with(cx, |state, _| state.active_item().is_none()));
875    }
876
877    #[gpui::test]
878    fn dragging_the_corner_resizes_an_item(cx: &mut TestAppContext) {
879        let (state, events, cx) = harness(cx);
880        let row_height = cx.update(|window, _| rems(2.5).to_pixels(window.rem_size()));
881        // Item `b` spans 400..790 across and two rows, with one gap, down.
882        let corner = point(px(784.), row_height * 2. + px(4.));
883
884        drag(
885            cx,
886            corner,
887            &[
888                corner + point(px(10.), px(0.)),
889                corner + point(px(200.), px(0.)),
890            ],
891        );
892        release(cx, corner + point(px(200.), px(0.)));
893
894        let b = place_of(&state, cx, "b");
895        assert_eq!((b.col, b.col_span, b.row_span), (4, 6, 2));
896        assert_eq!(place_of(&state, cx, "a").col, 0);
897        assert_eq!(events.borrow().len(), 1);
898    }
899
900    #[gpui::test]
901    fn escape_cancels_a_drag(cx: &mut TestAppContext) {
902        let (state, events, cx) = harness(cx);
903
904        drag(
905            cx,
906            point(px(50.), px(30.)),
907            &[point(px(60.), px(30.)), point(px(450.), px(30.))],
908        );
909        assert!(state.read_with(cx, |state, _| state.active_item().is_some()));
910        cx.simulate_keystrokes("escape");
911        draw(cx);
912        release(cx, point(px(450.), px(30.)));
913
914        assert_eq!(place_of(&state, cx, "a").col, 0);
915        assert!(events.borrow().is_empty());
916    }
917
918    #[gpui::test]
919    fn arrow_keys_move_the_clicked_item_and_tab_leaves_the_grid(cx: &mut TestAppContext) {
920        let (state, events, cx) = harness(cx);
921
922        cx.simulate_click(point(px(50.), px(30.)), Modifiers::default());
923        draw(cx);
924        cx.simulate_keystrokes("right shift-down");
925        draw(cx);
926
927        let a = place_of(&state, cx, "a");
928        assert_eq!((a.col, a.row_span), (1, 3));
929        assert_eq!(events.borrow().len(), 2);
930
931        // Tab reaches `b`, and the next Tab is not the grid's to handle.
932        cx.simulate_keystrokes("tab");
933        assert_eq!(
934            state.read_with(cx, |state, _| state.selected_item().cloned()),
935            Some("b".into())
936        );
937        cx.simulate_keystrokes("tab");
938        assert_eq!(
939            state.read_with(cx, |state, _| state.selected_item().cloned()),
940            Some("b".into())
941        );
942    }
943
944    /// The selections the state announces.
945    fn selections(
946        state: &Entity<GridLayoutState>,
947        cx: &mut VisualTestContext,
948    ) -> Rc<RefCell<Vec<Option<SharedString>>>> {
949        let selections = Rc::new(RefCell::new(Vec::new()));
950        cx.update(|_, cx| {
951            let selections = selections.clone();
952            cx.subscribe(state, move |_, event: &GridLayoutEvent, _| {
953                if let GridLayoutEvent::Select(id) = event {
954                    selections.borrow_mut().push(id.clone())
955                }
956            })
957            .detach();
958        });
959        selections
960    }
961
962    #[gpui::test]
963    fn selecting_an_item_is_announced_once_per_change(cx: &mut TestAppContext) {
964        let (state, _, cx) = harness(cx);
965        let selections = selections(&state, cx);
966
967        cx.simulate_click(point(px(50.), px(30.)), Modifiers::default());
968        draw(cx);
969        // Pressing the selected item again changes nothing.
970        cx.simulate_click(point(px(60.), px(30.)), Modifiers::default());
971        draw(cx);
972        cx.simulate_keystrokes("tab");
973        // Past the last item Tab leaves the grid and selects nothing.
974        cx.simulate_keystrokes("tab");
975        // The application already knows what it selects itself.
976        state.update(cx, |state, cx| {
977            state.set_selected_item(Some("a".into()), cx)
978        });
979
980        assert_eq!(*selections.borrow(), [Some("a".into()), Some("b".into())]);
981    }
982
983    #[gpui::test]
984    fn a_reflowed_grid_shows_a_copy_and_edits_nothing(cx: &mut TestAppContext) {
985        let (state, events, cx) = harness_with(cx, Some(1));
986        let stored = state.read_with(cx, |state, _| state.areas().to_vec());
987
988        // `b` is stacked under `a`, which now spans the full width.
989        drag(
990            cx,
991            point(px(50.), px(30.)),
992            &[point(px(60.), px(30.)), point(px(450.), px(200.))],
993        );
994        release(cx, point(px(450.), px(200.)));
995        cx.simulate_keystrokes("right shift-down");
996        draw(cx);
997
998        assert_eq!(
999            state.read_with(cx, |state, _| state.areas().to_vec()),
1000            stored
1001        );
1002        assert!(events.borrow().is_empty());
1003        // Selection still works.
1004        assert_eq!(
1005            state.read_with(cx, |state, _| state.selected_item().cloned()),
1006            Some("a".into())
1007        );
1008    }
1009
1010    #[gpui::test]
1011    fn test_grid_layout_builder(cx: &mut TestAppContext) {
1012        let state = cx.update(|cx| cx.new(|cx| GridLayoutState::new(cx)));
1013        let grid = GridLayout::new("grid", &state)
1014            .row_height(rems(3.))
1015            .gap_x(px(8.))
1016            .gap_y(px(12.))
1017            .dragging_item(|style| style.opacity(0.5))
1018            .selected_item(|style| style.opacity(0.75))
1019            .focused_item(|style| style.opacity(0.6))
1020            .reflow(0)
1021            .placeholder(|_, _| div().into_any_element())
1022            .resize_handle(|_, _| div().into_any_element())
1023            .item(GridItem::new("a").drag_handle_only(true))
1024            .items([
1025                GridItem::new("b"),
1026                // Items take conditional styles like any element.
1027                GridItem::new("c").when(true, |item| item.p_2()),
1028            ]);
1029        assert_eq!(grid.items.len(), 3);
1030        assert!(grid.items[0].handle_only);
1031        assert_eq!(grid.row_height, rems(3.));
1032        assert_eq!(grid.dragging_item.opacity, Some(0.5));
1033        assert_eq!(grid.selected_item.opacity, Some(0.75));
1034        assert_eq!(grid.focused_item.opacity, Some(0.6));
1035        assert_eq!(grid.reflow, Some(1));
1036        assert!(grid.items[2].style.padding.left.is_some());
1037        assert!(grid.placeholder.is_some() && grid.resize_handle.is_some());
1038        assert_eq!(resolve_gap(&grid.style, px(16.)), size(px(8.), px(12.)));
1039    }
1040}