1use gpui::{
2 App, Axis, Bounds, Context, EntityId, EventEmitter, FocusHandle, Pixels, Point, SharedString,
3 Size, Window, px,
4};
5
6use super::layout::{self, GridArea, LayoutRules};
7
8#[derive(Clone, Debug, PartialEq)]
10#[non_exhaustive]
11pub enum GridLayoutEvent {
12 Change(Vec<GridArea>),
17 Select(Option<SharedString>),
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub(crate) enum GridGesture {
28 Move,
29 Resize,
30}
31
32#[derive(Clone)]
34pub(crate) struct DragGridItem {
35 pub(crate) grid: EntityId,
36 pub(crate) item: SharedString,
37 pub(crate) gesture: GridGesture,
38}
39
40#[derive(Clone, Copy, Debug, PartialEq)]
46pub(crate) struct GridMetrics {
47 pub(crate) width: Pixels,
48 pub(crate) column_width: Pixels,
49 pub(crate) row_height: Pixels,
50 pub(crate) gap: Size<Pixels>,
52}
53
54impl GridMetrics {
55 pub(crate) fn new(
56 width: Pixels,
57 columns: usize,
58 row_height: Pixels,
59 gap: Size<Pixels>,
60 ) -> Self {
61 let columns = columns.max(1) as f32;
62 Self {
63 width,
64 column_width: ((width - gap.width * (columns - 1.)) / columns).max(px(0.)),
65 row_height,
66 gap,
67 }
68 }
69
70 pub(crate) fn left(&self, col: usize) -> Pixels {
71 (self.column_width + self.gap.width) * col as f32
72 }
73
74 pub(crate) fn top(&self, row: usize) -> Pixels {
75 (self.row_height + self.gap.height) * row as f32
76 }
77
78 pub(crate) fn span_width(&self, col_span: usize) -> Pixels {
79 (self.column_width * col_span as f32 + self.gap.width * col_span.saturating_sub(1) as f32)
80 .max(px(0.))
81 }
82
83 pub(crate) fn span_height(&self, row_span: usize) -> Pixels {
84 (self.row_height * row_span as f32 + self.gap.height * row_span.saturating_sub(1) as f32)
85 .max(px(0.))
86 }
87
88 fn column_at(&self, left: Pixels) -> usize {
90 snap(left, self.column_width + self.gap.width)
91 }
92
93 fn row_at(&self, top: Pixels) -> usize {
95 snap(top, self.row_height + self.gap.height)
96 }
97
98 fn columns_spanning(&self, width: Pixels) -> usize {
100 snap(width + self.gap.width, self.column_width + self.gap.width).max(1)
101 }
102
103 fn rows_spanning(&self, height: Pixels) -> usize {
105 snap(height + self.gap.height, self.row_height + self.gap.height).max(1)
106 }
107}
108
109fn snap(length: Pixels, step: Pixels) -> usize {
110 if step <= px(0.) {
111 return 0;
112 }
113 (length / step).round().max(0.) as usize
114}
115
116pub(crate) struct GridDrag {
118 pub(crate) item: SharedString,
119 pub(crate) gesture: GridGesture,
120 origin: Point<Pixels>,
122 start: Vec<GridArea>,
124 pub(crate) preview: Vec<GridArea>,
126 pub(crate) delta: Point<Pixels>,
128 pub(crate) metrics: Option<GridMetrics>,
130}
131
132impl GridDrag {
133 pub(crate) fn start_area(&self) -> Option<&GridArea> {
135 self.start.iter().find(|area| area.id == self.item)
136 }
137}
138
139pub struct GridLayoutState {
155 areas: Vec<GridArea>,
156 columns: usize,
157 compaction: Option<Axis>,
158 prevent_collision: bool,
159 draggable: bool,
160 resizable: bool,
161 focus_handle: FocusHandle,
162 selected: Option<SharedString>,
163 keyboard_active: bool,
166 press_position: Option<Point<Pixels>>,
169 pub(crate) drag: Option<GridDrag>,
170}
171
172impl EventEmitter<GridLayoutEvent> for GridLayoutState {}
173
174impl GridLayoutState {
175 pub fn new(cx: &mut App) -> Self {
177 Self {
178 areas: Vec::new(),
179 columns: 12,
180 compaction: Some(Axis::Vertical),
181 prevent_collision: false,
182 draggable: true,
183 resizable: true,
184 focus_handle: cx.focus_handle().tab_stop(true),
185 selected: None,
186 keyboard_active: false,
187 press_position: None,
188 drag: None,
189 }
190 }
191
192 pub fn cols(mut self, cols: usize) -> Self {
194 self.columns = cols.max(1);
195 self.normalize();
196 self
197 }
198
199 pub fn compaction(mut self, compaction: Option<Axis>) -> Self {
202 self.compaction = compaction;
203 self.normalize();
204 self
205 }
206
207 pub fn prevent_collision(mut self, prevent_collision: bool) -> Self {
210 self.prevent_collision = prevent_collision;
211 self
212 }
213
214 pub fn draggable(mut self, draggable: bool) -> Self {
216 self.draggable = draggable;
217 self
218 }
219
220 pub fn resizable(mut self, resizable: bool) -> Self {
222 self.resizable = resizable;
223 self
224 }
225
226 pub fn default_areas(mut self, areas: impl IntoIterator<Item = GridArea>) -> Self {
230 self.areas = areas.into_iter().collect();
231 self.normalize();
232 self
233 }
234
235 pub fn areas(&self) -> &[GridArea] {
237 &self.areas
238 }
239
240 pub fn area(&self, id: &str) -> Option<&GridArea> {
242 self.areas.iter().find(|area| area.id.as_ref() == id)
243 }
244
245 pub fn column_count(&self) -> usize {
246 self.columns
247 }
248
249 pub fn compaction_axis(&self) -> Option<Axis> {
250 self.compaction
251 }
252
253 pub fn is_collision_prevented(&self) -> bool {
254 self.prevent_collision
255 }
256
257 pub fn is_draggable(&self) -> bool {
258 self.draggable
259 }
260
261 pub fn is_resizable(&self) -> bool {
262 self.resizable
263 }
264
265 pub fn selected_item(&self) -> Option<&SharedString> {
267 self.selected.as_ref()
268 }
269
270 pub fn active_item(&self) -> Option<&SharedString> {
272 self.drag.as_ref().map(|drag| &drag.item)
273 }
274
275 pub(crate) fn focus_handle(&self) -> &FocusHandle {
276 &self.focus_handle
277 }
278
279 pub(crate) fn is_keyboard_active(&self) -> bool {
280 self.keyboard_active
281 }
282
283 pub fn set_areas(&mut self, areas: impl IntoIterator<Item = GridArea>, cx: &mut Context<Self>) {
286 self.areas = areas.into_iter().collect();
287 self.drag = None;
288 self.normalize();
289 if let Some(selected) = &self.selected
290 && self.area(selected).is_none()
291 {
292 self.selected = None;
293 }
294 cx.notify();
295 }
296
297 pub fn set_cols(&mut self, cols: usize, cx: &mut Context<Self>) {
298 self.columns = cols.max(1);
299 self.drag = None;
300 self.normalize();
301 cx.notify();
302 }
303
304 pub fn set_compaction(&mut self, compaction: Option<Axis>, cx: &mut Context<Self>) {
305 self.compaction = compaction;
306 self.drag = None;
307 self.normalize();
308 cx.notify();
309 }
310
311 pub fn set_prevent_collision(&mut self, prevent_collision: bool, cx: &mut Context<Self>) {
312 self.prevent_collision = prevent_collision;
313 cx.notify();
314 }
315
316 pub fn set_draggable(&mut self, draggable: bool, cx: &mut Context<Self>) {
317 self.draggable = draggable;
318 cx.notify();
319 }
320
321 pub fn set_resizable(&mut self, resizable: bool, cx: &mut Context<Self>) {
322 self.resizable = resizable;
323 cx.notify();
324 }
325
326 pub fn set_selected_item(&mut self, id: Option<SharedString>, cx: &mut Context<Self>) {
328 let id = id.filter(|id| self.area(id).is_some());
329 if self.selected != id {
330 self.selected = id;
331 cx.notify();
332 }
333 }
334
335 pub fn move_item(&mut self, id: &str, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
339 let next = layout::move_item(&self.areas, id, col, row, self.rules());
340 self.commit(next, cx)
341 }
342
343 pub fn resize_item(
346 &mut self,
347 id: &str,
348 col_span: usize,
349 row_span: usize,
350 cx: &mut Context<Self>,
351 ) -> bool {
352 let next = layout::resize_item(&self.areas, id, col_span, row_span, self.rules());
353 self.commit(next, cx)
354 }
355
356 fn rules(&self) -> LayoutRules {
357 LayoutRules {
358 columns: self.columns,
359 compaction: self.compaction,
360 prevent_collision: self.prevent_collision,
361 }
362 }
363
364 fn normalize(&mut self) {
365 let rules = self.rules();
366 layout::normalize(&mut self.areas, rules);
367 }
368
369 fn commit(&mut self, next: Option<Vec<GridArea>>, cx: &mut Context<Self>) -> bool {
370 match next {
371 Some(next) if next != self.areas => {
372 self.areas = next;
373 cx.emit(GridLayoutEvent::Change(self.areas.clone()));
374 cx.notify();
375 true
376 }
377 _ => false,
378 }
379 }
380
381 pub(crate) fn displayed_areas(&self, cx: &App) -> &[GridArea] {
383 match &self.drag {
384 Some(drag) if cx.has_active_drag() => &drag.preview,
385 _ => &self.areas,
386 }
387 }
388
389 pub(crate) fn active_drag(&self, cx: &App) -> Option<&GridDrag> {
391 self.drag.as_ref().filter(|_| cx.has_active_drag())
392 }
393
394 pub(crate) fn is_gesture_allowed(&self, id: &str, gesture: GridGesture) -> bool {
395 let allowed = match gesture {
396 GridGesture::Move => self.draggable,
397 GridGesture::Resize => self.resizable,
398 };
399 allowed && self.area(id).is_some_and(|area| !area.locked)
400 }
401
402 pub(crate) fn press(
404 &mut self,
405 id: &SharedString,
406 position: Point<Pixels>,
407 window: &mut Window,
408 cx: &mut Context<Self>,
409 ) {
410 self.press_position = Some(position);
411 self.keyboard_active = false;
412 self.select(Some(id.clone()), cx);
413 window.focus(&self.focus_handle, cx);
414 cx.notify();
415 }
416
417 pub(crate) fn begin_drag(
418 &mut self,
419 id: SharedString,
420 gesture: GridGesture,
421 window: &mut Window,
422 cx: &mut Context<Self>,
423 ) {
424 let origin = self
425 .press_position
426 .take()
427 .unwrap_or_else(|| window.mouse_position());
428 self.select(Some(id.clone()), cx);
429 self.drag = Some(GridDrag {
430 item: id,
431 gesture,
432 origin,
433 start: self.areas.clone(),
434 preview: self.areas.clone(),
435 delta: Point::default(),
436 metrics: None,
437 });
438 cx.notify();
439 }
440
441 pub(crate) fn drag_to(
444 &mut self,
445 position: Point<Pixels>,
446 bounds: Bounds<Pixels>,
447 row_height: Pixels,
448 gap: Size<Pixels>,
449 cx: &mut Context<Self>,
450 ) {
451 let metrics = GridMetrics::new(bounds.size.width, self.columns, row_height, gap);
452 let rules = self.rules();
453 let Some(drag) = self.drag.as_mut() else {
454 return;
455 };
456 let Some(start) = drag.start_area().cloned() else {
457 return;
458 };
459 drag.delta = position - drag.origin;
460 drag.metrics = Some(metrics);
461
462 let next = match drag.gesture {
463 GridGesture::Move => {
464 let left = metrics.left(start.col) + drag.delta.x;
465 let top = metrics.top(start.row) + drag.delta.y;
466 let col = metrics
467 .column_at(left)
468 .min(self.columns.saturating_sub(start.col_span));
469 layout::move_item(&drag.start, &start.id, col, metrics.row_at(top), rules)
470 }
471 GridGesture::Resize => {
472 let col_span =
473 metrics.columns_spanning(metrics.span_width(start.col_span) + drag.delta.x);
474 let row_span =
475 metrics.rows_spanning(metrics.span_height(start.row_span) + drag.delta.y);
476 layout::resize_item(&drag.start, &start.id, col_span, row_span, rules)
477 }
478 };
479 if let Some(next) = next {
481 drag.preview = next;
482 }
483 cx.notify();
484 }
485
486 pub(crate) fn end_drag(&mut self, cx: &mut Context<Self>) {
488 self.press_position = None;
489 let Some(drag) = self.drag.take() else {
490 return;
491 };
492 cx.notify();
493 self.commit(Some(drag.preview), cx);
494 }
495
496 pub(crate) fn cancel_drag(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
498 if self.drag.take().is_none() {
499 return false;
500 }
501 cx.stop_active_drag(window);
502 cx.notify();
503 true
504 }
505
506 pub(crate) fn nudge_selected(
508 &mut self,
509 cols: isize,
510 rows: isize,
511 cx: &mut Context<Self>,
512 ) -> bool {
513 let Some(area) = self.selected_area() else {
514 return false;
515 };
516 if !self.is_gesture_allowed(&area.id, GridGesture::Move) {
517 return false;
518 }
519 let (Some(col), Some(row)) = (
520 area.col.checked_add_signed(cols),
521 area.row.checked_add_signed(rows),
522 ) else {
523 return false;
524 };
525 self.keyboard_active = true;
526 let moved = self.move_item(&area.id, col, row, cx);
527 cx.notify();
528 moved
529 }
530
531 pub(crate) fn resize_selected(
533 &mut self,
534 cols: isize,
535 rows: isize,
536 cx: &mut Context<Self>,
537 ) -> bool {
538 let Some(area) = self.selected_area() else {
539 return false;
540 };
541 if !self.is_gesture_allowed(&area.id, GridGesture::Resize) {
542 return false;
543 }
544 let (Some(col_span), Some(row_span)) = (
545 area.col_span.checked_add_signed(cols),
546 area.row_span.checked_add_signed(rows),
547 ) else {
548 return false;
549 };
550 self.keyboard_active = true;
551 let resized = self.resize_item(&area.id, col_span, row_span, cx);
552 cx.notify();
553 resized
554 }
555
556 pub(crate) fn select_adjacent(&mut self, forward: bool, cx: &mut Context<Self>) -> bool {
561 let mut order = self.areas.iter().collect::<Vec<_>>();
562 order.sort_by_key(|area| (area.row, area.col));
563 if order.is_empty() {
564 return false;
565 }
566 let current = self
567 .selected
568 .as_ref()
569 .and_then(|id| order.iter().position(|area| &area.id == id));
570 let next = match (current, forward) {
571 (None, true) => 0,
572 (None, false) => order.len() - 1,
573 (Some(ix), true) if ix + 1 < order.len() => ix + 1,
574 (Some(ix), false) if ix > 0 => ix - 1,
575 (Some(_), _) => return false,
576 };
577 self.keyboard_active = true;
578 self.select(Some(order[next].id.clone()), cx);
579 cx.notify();
580 true
581 }
582
583 fn select(&mut self, id: Option<SharedString>, cx: &mut Context<Self>) {
586 if self.selected != id {
587 self.selected = id.clone();
588 cx.emit(GridLayoutEvent::Select(id));
589 }
590 }
591
592 fn selected_area(&self) -> Option<GridArea> {
593 self.selected.as_ref().and_then(|id| self.area(id)).cloned()
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use crate::grid_layout::layout::tests::area;
601 use gpui::{AppContext as _, TestAppContext, size};
602
603 fn state(cx: &mut TestAppContext) -> gpui::Entity<GridLayoutState> {
604 cx.update(|cx| {
605 cx.new(|cx| {
606 GridLayoutState::new(cx)
607 .default_areas([area("a", 0, 0, 4, 2), area("b", 4, 0, 4, 2)])
608 })
609 })
610 }
611
612 #[test]
613 fn metrics_share_the_width_between_columns_and_gaps() {
614 let metrics = GridMetrics::new(px(1190.), 12, px(40.), size(px(10.), px(20.)));
615 assert_eq!(metrics.column_width, px(90.));
616 assert_eq!(metrics.left(1), px(100.));
617 assert_eq!(metrics.span_width(12), px(1190.));
618 assert_eq!(metrics.top(1), px(60.));
620 assert_eq!(metrics.span_height(2), px(100.));
621 assert_eq!(metrics.column_at(px(140.)), 1);
622 assert_eq!(metrics.column_at(px(160.)), 2);
623 assert_eq!(metrics.column_at(px(-50.)), 0);
624 assert_eq!(metrics.columns_spanning(metrics.span_width(3) + px(20.)), 3);
625 assert_eq!(metrics.rows_spanning(px(0.)), 1);
626 }
627
628 #[gpui::test]
629 fn test_grid_layout_state_builder(cx: &mut TestAppContext) {
630 let state = cx.update(|cx| {
631 cx.new(|cx| {
632 GridLayoutState::new(cx)
633 .cols(6)
634 .compaction(None)
635 .prevent_collision(true)
636 .draggable(false)
637 .resizable(false)
638 .default_areas([area("a", 4, 5, 4, 1)])
639 })
640 });
641 state.read_with(cx, |state, _| {
642 assert_eq!(state.column_count(), 6);
643 assert_eq!(state.compaction_axis(), None);
644 assert!(state.is_collision_prevented());
645 assert!(!state.is_draggable());
646 assert!(!state.is_resizable());
647 let area = state.area("a").unwrap();
649 assert_eq!((area.col, area.row), (2, 5));
650 });
651 }
652
653 #[gpui::test]
654 fn programmatic_changes_emit_the_new_layout(cx: &mut TestAppContext) {
655 let state = state(cx);
656 let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
657 cx.update(|cx| {
658 let events = events.clone();
659 cx.subscribe(&state, move |_, event: &GridLayoutEvent, _| {
660 events.borrow_mut().push(event.clone())
661 })
662 .detach();
663 });
664
665 state.update(cx, |state, cx| {
666 assert!(state.move_item("a", 8, 0, cx));
667 assert!(!state.move_item("a", 8, 0, cx));
669 assert!(state.resize_item("b", 2, 3, cx));
670 state.set_areas([area("c", 0, 0, 1, 1)], cx);
671 });
672
673 let events = events.borrow();
674 assert_eq!(events.len(), 2);
675 let GridLayoutEvent::Change(areas) = &events[1] else {
676 panic!("expected a layout change, got {:?}", events[1]);
677 };
678 assert!(
679 areas
680 .iter()
681 .any(|area| area.id == "b" && area.col_span == 2 && area.row_span == 3)
682 );
683 }
684
685 #[gpui::test]
686 fn keyboard_commands_act_on_the_selected_item(cx: &mut TestAppContext) {
687 let state = state(cx);
688 state.update(cx, |state, cx| {
689 assert!(!state.nudge_selected(1, 0, cx));
690 assert!(state.select_adjacent(true, cx));
691 assert_eq!(state.selected_item().map(|id| id.as_ref()), Some("a"));
692 assert!(state.select_adjacent(true, cx));
693 assert_eq!(state.selected_item().map(|id| id.as_ref()), Some("b"));
694 assert!(!state.select_adjacent(true, cx));
697 assert_eq!(state.selected_item().map(|id| id.as_ref()), Some("b"));
698
699 assert!(state.nudge_selected(1, 0, cx));
700 assert_eq!(state.area("b").unwrap().col, 5);
701 assert!(!state.nudge_selected(0, -1, cx));
703
704 assert!(state.resize_selected(0, 1, cx));
705 assert_eq!(state.area("b").unwrap().row_span, 3);
706 assert!(state.is_keyboard_active());
707
708 state.set_draggable(false, cx);
709 assert!(!state.nudge_selected(-1, 0, cx));
710
711 state.set_selected_item(Some("a".into()), cx);
713 assert!(!state.select_adjacent(false, cx));
714 });
715 }
716}