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
20const RESIZE_HANDLE_SIZE: Pixels = px(16.);
23
24type PartRenderer = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
26
27#[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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
93 self.accessibility_label = label.into();
94 self
95 }
96
97 pub fn item(mut self, item: GridItem) -> Self {
99 self.items.push(item);
100 self
101 }
102
103 pub fn items(mut self, items: impl IntoIterator<Item = GridItem>) -> Self {
105 self.items.extend(items);
106 self
107 }
108
109 pub fn row_height(mut self, row_height: Rems) -> Self {
112 self.row_height = row_height;
113 self
114 }
115
116 pub fn transition(mut self, transition: Transition) -> Self {
119 self.transition = Some(transition);
120 self
121 }
122
123 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 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 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 pub fn reflow(mut self, cols: usize) -> Self {
159 self.reflow = Some(cols.max(1));
160 self
161 }
162
163 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 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
190pub 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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
216 self.accessibility_label = Some(label.into());
217 self
218 }
219
220 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
242pub 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
267struct 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 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
306fn 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#[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 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 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 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 .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 .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 .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 .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
584fn 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
595fn 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
645struct 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 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 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 .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 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 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 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 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 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 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 cx.simulate_click(point(px(60.), px(30.)), Modifiers::default());
971 draw(cx);
972 cx.simulate_keystrokes("tab");
973 cx.simulate_keystrokes("tab");
975 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 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 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 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}