Skip to main content

gpui_base/grid_layout/
layout.rs

1//! The arithmetic of a grid layout: where items may sit, what pushes what, and
2//! how a layout settles. Everything here is pure, in grid cells, and knows
3//! nothing about pixels, entities or rendering.
4
5use gpui::{Axis, SharedString};
6use serde::{Deserialize, Serialize};
7
8/// Where one item sits in a grid layout, in zero-based grid cells.
9///
10/// `col` and `row` are the item's leading column and top row, counted from
11/// zero; `col_span` and `row_span` are how many columns and rows it covers.
12/// Unlike GPUI's `col_start`, which names a one-based CSS grid line, `col` is
13/// a cell index, so a layout reads the same in code and in its saved form.
14///
15/// A layout is a list of areas keyed by `id`, which is what an application
16/// persists to restore a user's arrangement. Every field after the place has a
17/// serde default, so a layout saved with only the place still loads.
18///
19/// ```ignore
20/// GridArea::new("revenue").col_span(6).row_span(4).min_span(3, 2)
21/// ```
22#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[non_exhaustive]
24pub struct GridArea {
25    /// The stable identity that pairs this area with its [`GridItem`](super::GridItem).
26    pub id: SharedString,
27    /// The leading column, from zero.
28    pub col: usize,
29    /// The top row, from zero.
30    pub row: usize,
31    /// The number of columns covered; at least 1.
32    pub col_span: usize,
33    /// The number of rows covered; at least 1.
34    pub row_span: usize,
35    /// The fewest columns a resize may leave the item.
36    #[serde(default = "one")]
37    pub min_col_span: usize,
38    /// The fewest rows a resize may leave the item.
39    #[serde(default = "one")]
40    pub min_row_span: usize,
41    /// The most columns a resize may give the item; `None` allows the full width.
42    #[serde(default)]
43    pub max_col_span: Option<usize>,
44    /// The most rows a resize may give the item; `None` is unbounded.
45    #[serde(default)]
46    pub max_row_span: Option<usize>,
47    /// A locked item is neither dragged, resized nor pushed; other items flow
48    /// around it.
49    #[serde(default)]
50    pub locked: bool,
51}
52
53fn one() -> usize {
54    1
55}
56
57impl GridArea {
58    /// A one-cell area at the top-leading corner; place and size it with the
59    /// builders.
60    pub fn new(id: impl Into<SharedString>) -> Self {
61        Self {
62            id: id.into(),
63            col: 0,
64            row: 0,
65            col_span: 1,
66            row_span: 1,
67            min_col_span: 1,
68            min_row_span: 1,
69            max_col_span: None,
70            max_row_span: None,
71            locked: false,
72        }
73    }
74
75    /// The leading column, from zero.
76    pub fn col(mut self, col: usize) -> Self {
77        self.col = col;
78        self
79    }
80
81    /// The top row, from zero.
82    pub fn row(mut self, row: usize) -> Self {
83        self.row = row;
84        self
85    }
86
87    /// How many columns the item covers.
88    pub fn col_span(mut self, col_span: usize) -> Self {
89        self.col_span = col_span.max(1);
90        self
91    }
92
93    /// How many rows the item covers.
94    pub fn row_span(mut self, row_span: usize) -> Self {
95        self.row_span = row_span.max(1);
96        self
97    }
98
99    /// The fewest columns and rows a resize may leave the item.
100    pub fn min_span(mut self, col_span: usize, row_span: usize) -> Self {
101        self.min_col_span = col_span.max(1);
102        self.min_row_span = row_span.max(1);
103        self
104    }
105
106    /// The most columns and rows a resize may give the item.
107    pub fn max_span(mut self, col_span: usize, row_span: usize) -> Self {
108        self.max_col_span = Some(col_span.max(1));
109        self.max_row_span = Some(row_span.max(1));
110        self
111    }
112
113    /// Lock the item in place; see [`GridArea::locked`].
114    pub fn locked(mut self, locked: bool) -> Self {
115        self.locked = locked;
116        self
117    }
118
119    /// The row just below the item.
120    pub(crate) fn row_end(&self) -> usize {
121        self.row + self.row_span
122    }
123
124    /// The column just past the item's trailing edge.
125    pub(crate) fn col_end(&self) -> usize {
126        self.col + self.col_span
127    }
128
129    fn rect(&self) -> Rect {
130        Rect {
131            col: self.col,
132            row: self.row,
133            col_span: self.col_span,
134            row_span: self.row_span,
135        }
136    }
137
138    /// Clamp a requested span to the item's limits and the grid's width.
139    pub(crate) fn clamp_size(&self, w: usize, h: usize, columns: usize) -> (usize, usize) {
140        let max_col_span = self
141            .max_col_span
142            .unwrap_or(usize::MAX)
143            .min(columns.saturating_sub(self.col))
144            .max(1);
145        let max_row_span = self.max_row_span.unwrap_or(usize::MAX).max(1);
146        (
147            w.clamp(self.min_col_span.min(max_col_span), max_col_span),
148            h.clamp(self.min_row_span.min(max_row_span), max_row_span),
149        )
150    }
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154struct Rect {
155    col: usize,
156    row: usize,
157    col_span: usize,
158    row_span: usize,
159}
160
161impl Rect {
162    fn overlaps(&self, other: &Rect) -> bool {
163        self.col < other.col + other.col_span
164            && other.col < self.col + self.col_span
165            && self.row < other.row + other.row_span
166            && other.row < self.row + self.row_span
167    }
168}
169
170/// How a layout responds to edits, shared by every operation in this module.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub(crate) struct LayoutRules {
173    pub columns: usize,
174    /// The direction items float toward; `None` leaves them where they are put.
175    pub compaction: Option<Axis>,
176    /// Reject a move or resize that would overlap another item instead of
177    /// pushing that item away.
178    pub prevent_collision: bool,
179}
180
181/// The number of rows a layout occupies.
182pub(crate) fn rows(items: &[GridArea]) -> usize {
183    items.iter().map(GridArea::row_end).max().unwrap_or(0)
184}
185
186/// Bring a layout into a valid state: every item fits the columns, no locked
187/// items overlap, and the remainder are compacted around them.
188pub(crate) fn normalize(items: &mut [GridArea], rules: LayoutRules) {
189    let columns = rules.columns.max(1);
190    for item in items.iter_mut() {
191        // An item's own limits first, which may have changed since it was
192        // placed, then the grid's width.
193        let max_col_span = item.max_col_span.unwrap_or(usize::MAX).max(1);
194        let max_row_span = item.max_row_span.unwrap_or(usize::MAX).max(1);
195        item.col_span = item
196            .col_span
197            .clamp(item.min_col_span.min(max_col_span), max_col_span);
198        item.row_span = item
199            .row_span
200            .clamp(item.min_row_span.min(max_row_span), max_row_span);
201        item.col_span = item.col_span.clamp(1, columns);
202        item.row_span = item.row_span.max(1);
203        if item.col_end() > columns {
204            item.col = columns - item.col_span;
205        }
206    }
207
208    // Locked items cannot be pushed by others, so settle them among
209    // themselves first, in reading order.
210    let locked = sorted_indices(items, rules.compaction)
211        .into_iter()
212        .filter(|&ix| items[ix].locked)
213        .collect::<Vec<_>>();
214    for (n, &ix) in locked.iter().enumerate() {
215        while let Some(other) = first_collision(items, &locked[..n], items[ix].rect(), ix) {
216            items[ix].row = items[other].row_end();
217        }
218    }
219
220    settle(items, rules, None);
221}
222
223/// Compact the layout, or without compaction just push overlapping items
224/// down. `priority` names an item that keeps its place when it overlaps
225/// another — the one the user is moving.
226fn settle(items: &mut [GridArea], rules: LayoutRules, priority: Option<usize>) {
227    if rules.compaction.is_some() {
228        compact(items, rules);
229        return;
230    }
231    let mut placed = (0..items.len())
232        .filter(|&ix| items[ix].locked)
233        .collect::<Vec<_>>();
234    if let Some(priority) = priority.filter(|&ix| !items[ix].locked) {
235        placed.push(priority);
236    }
237    for ix in sorted_indices(items, None) {
238        if placed.contains(&ix) {
239            continue;
240        }
241        while let Some(other) = first_collision(items, &placed, items[ix].rect(), ix) {
242            items[ix].row = items[other].row_end();
243        }
244        placed.push(ix);
245    }
246}
247
248/// The layout `areas` would take over `columns` columns, for display only:
249/// every area fitted to the width and the whole compacted upward in reading
250/// order. Locks are dropped from the copy, since a display reflow pins nothing
251/// — a locked area pinned to a row of the wide layout would only open gaps in
252/// the narrow one. `areas` itself is left as it is.
253pub(crate) fn reflowed(areas: &[GridArea], columns: usize) -> Vec<GridArea> {
254    let mut reflowed = areas.to_vec();
255    // Fitting to fewer columns moves areas to the leading edge, and areas
256    // that shared a row would then tie in the compaction order. Order the copy
257    // as the wide layout reads first; compaction sorts stably, so ties keep it.
258    reflowed.sort_by_key(|area| (area.row, area.col));
259    for area in &mut reflowed {
260        area.locked = false;
261    }
262    normalize(
263        &mut reflowed,
264        LayoutRules {
265            columns,
266            compaction: Some(Axis::Vertical),
267            prevent_collision: false,
268        },
269    );
270    reflowed
271}
272
273/// Float every unlocked item toward `axis` as far as the others allow.
274pub(crate) fn compact(items: &mut [GridArea], rules: LayoutRules) {
275    let Some(axis) = rules.compaction else {
276        return;
277    };
278    let columns = rules.columns.max(1);
279    let mut placed = (0..items.len())
280        .filter(|&ix| items[ix].locked)
281        .collect::<Vec<_>>();
282    for ix in sorted_indices(items, Some(axis)) {
283        if items[ix].locked {
284            continue;
285        }
286        compact_item(items, &placed, ix, axis, columns);
287        placed.push(ix);
288    }
289}
290
291fn compact_item(items: &mut [GridArea], placed: &[usize], ix: usize, axis: Axis, columns: usize) {
292    match axis {
293        Axis::Vertical => {
294            // Start no lower than the bottom of what is already placed, so a
295            // tall gap closes in one step instead of row by row.
296            let floor = placed
297                .iter()
298                .map(|&p| items[p].row_end())
299                .max()
300                .unwrap_or(0);
301            items[ix].row = items[ix].row.min(floor);
302            loop {
303                while items[ix].row > 0 {
304                    let mut probe = items[ix].rect();
305                    probe.row -= 1;
306                    if first_collision(items, placed, probe, ix).is_some() {
307                        break;
308                    }
309                    items[ix].row -= 1;
310                }
311                match first_collision(items, placed, items[ix].rect(), ix) {
312                    Some(other) => items[ix].row = items[other].row_end(),
313                    None => break,
314                }
315            }
316        }
317        Axis::Horizontal => loop {
318            while items[ix].col > 0 {
319                let mut probe = items[ix].rect();
320                probe.col -= 1;
321                if first_collision(items, placed, probe, ix).is_some() {
322                    break;
323                }
324                items[ix].col -= 1;
325            }
326            // Past the trailing edge, wrap to the next row and try again from
327            // the leading edge.
328            if items[ix].col_end() > columns {
329                items[ix].col = 0;
330                items[ix].row += 1;
331                continue;
332            }
333            match first_collision(items, placed, items[ix].rect(), ix) {
334                Some(other) => {
335                    items[ix].col = items[other].col_end();
336                    if items[ix].col_end() > columns {
337                        items[ix].col = 0;
338                        items[ix].row += 1;
339                    }
340                }
341                None => break,
342            }
343        },
344    }
345}
346
347/// Move the item `id` to `(x, y)`, pushing whatever it lands on out of the
348/// way, then compact. Returns `None` when the rules refuse the move.
349///
350/// The move is computed from `items` as they were when the gesture began, not
351/// from the previous step of the gesture, so items the pointer merely passed
352/// over return to where they were.
353pub(crate) fn move_item(
354    items: &[GridArea],
355    id: &str,
356    x: usize,
357    y: usize,
358    rules: LayoutRules,
359) -> Option<Vec<GridArea>> {
360    let ix = items.iter().position(|item| item.id.as_ref() == id)?;
361    if items[ix].locked {
362        return None;
363    }
364    let mut next = items.to_vec();
365    let x = x.min(rules.columns.saturating_sub(next[ix].col_span));
366    if rules.prevent_collision {
367        let mut probe = next[ix].rect();
368        probe.col = x;
369        probe.row = y;
370        if any_collision(&next, probe, ix) {
371            return None;
372        }
373    }
374    Push::new(rules, next.len()).move_element(&mut next, ix, Some(x), Some(y), true);
375    settle(&mut next, rules, Some(ix));
376    Some(next)
377}
378
379/// Resize the item `id` to span `w` by `h`, clamped to its limits, pushing
380/// whatever it grows over, then compact. Returns `None` when the rules refuse
381/// the resize.
382pub(crate) fn resize_item(
383    items: &[GridArea],
384    id: &str,
385    w: usize,
386    h: usize,
387    rules: LayoutRules,
388) -> Option<Vec<GridArea>> {
389    let ix = items.iter().position(|item| item.id.as_ref() == id)?;
390    if items[ix].locked {
391        return None;
392    }
393    let mut next = items.to_vec();
394    let (w, h) = next[ix].clamp_size(w, h, rules.columns);
395
396    // A locked neighbour is a wall. Rather than refuse the whole gesture, try
397    // keeping the other dimension where it started.
398    let start = next[ix].clone();
399    let fits = |w: usize, h: usize, next: &[GridArea]| {
400        let probe = Rect {
401            col_span: w,
402            row_span: h,
403            ..start.rect()
404        };
405        !next
406            .iter()
407            .enumerate()
408            .any(|(other, item)| other != ix && item.locked && probe.overlaps(&item.rect()))
409    };
410    let (w, h) = [(w, h), (w, start.row_span), (start.col_span, h)]
411        .into_iter()
412        .find(|&(w, h)| fits(w, h, &next))?;
413
414    next[ix].col_span = w;
415    next[ix].row_span = h;
416    if rules.prevent_collision && any_collision(&next, next[ix].rect(), ix) {
417        return None;
418    }
419
420    let mut push = Push::new(rules, next.len());
421    push.moved[ix] = true;
422    for other in collisions(&next, sorted_indices(&next, rules.compaction), ix) {
423        let below = next[ix].row_end();
424        push.move_element(&mut next, other, None, Some(below), false);
425    }
426    settle(&mut next, rules, Some(ix));
427    Some(next)
428}
429
430/// The cascade of a move: an item that lands on others pushes them, and they
431/// push the items they land on in turn.
432struct Push {
433    rules: LayoutRules,
434    /// Items already settled by this move, which later collisions leave alone.
435    moved: Vec<bool>,
436}
437
438impl Push {
439    fn new(rules: LayoutRules, len: usize) -> Self {
440        Self {
441            rules,
442            moved: vec![false; len],
443        }
444    }
445
446    fn move_element(
447        &mut self,
448        items: &mut [GridArea],
449        ix: usize,
450        x: Option<usize>,
451        y: Option<usize>,
452        user_action: bool,
453    ) {
454        if items[ix].locked {
455            return;
456        }
457        let (old_x, old_y) = (items[ix].col, items[ix].row);
458        let x = x.unwrap_or(old_x);
459        let y = y.unwrap_or(old_y);
460        if (x, y) == (old_x, old_y) {
461            return;
462        }
463        items[ix].col = x;
464        items[ix].row = y;
465        self.moved[ix] = true;
466
467        // Moving toward the compaction direction, resolve the nearest
468        // collisions last so they win the space the item vacated.
469        let mut order = sorted_indices(items, self.rules.compaction);
470        let moving_back = match self.rules.compaction {
471            Some(Axis::Horizontal) => old_x >= x,
472            _ => old_y >= y,
473        };
474        if moving_back {
475            order.reverse();
476        }
477
478        for other in collisions(items, order, ix) {
479            if self.moved[other] {
480                continue;
481            }
482            if items[other].locked {
483                // A locked item cannot give way, so the mover does.
484                self.away(items, other, ix, user_action);
485            } else {
486                self.away(items, ix, other, user_action);
487            }
488        }
489    }
490
491    /// Move `mover` clear of `obstacle`.
492    fn away(&mut self, items: &mut [GridArea], obstacle: usize, mover: usize, user_action: bool) {
493        let horizontal = self.rules.compaction == Some(Axis::Horizontal);
494        if user_action {
495            // A dragged item that lands on another first tries to trade places
496            // with it: if the space on the near side of the obstacle is free,
497            // the pushed item goes there instead of being shoved further away.
498            let (o, m) = (&items[obstacle], &items[mover]);
499            let probe = Rect {
500                col: if horizontal {
501                    o.col.saturating_sub(m.col_span)
502                } else {
503                    m.col
504                },
505                row: if horizontal {
506                    m.row
507                } else {
508                    o.row.saturating_sub(m.row_span)
509                },
510                col_span: m.col_span,
511                row_span: m.row_span,
512            };
513            let blocker = (0..items.len()).find(|&other| {
514                other != mover && other != obstacle && probe.overlaps(&items[other].rect())
515            });
516            let obstacle_rect = items[obstacle].rect();
517            match blocker {
518                None if !probe.overlaps(&obstacle_rect) => {
519                    let (x, y) = if horizontal {
520                        (Some(probe.col), None)
521                    } else {
522                        (None, Some(probe.row))
523                    };
524                    return self.move_element(items, mover, x, y, false);
525                }
526                _ => {}
527            }
528        }
529
530        // Otherwise push it past the obstacle along the compaction axis.
531        let (x, y) = if horizontal {
532            (Some(items[obstacle].col_end()), None)
533        } else {
534            (None, Some(items[obstacle].row_end()))
535        };
536        self.move_element(items, mover, x, y, false);
537    }
538}
539
540/// Item indices in the order compaction visits them: row-major for vertical
541/// layouts and column-major for horizontal ones.
542fn sorted_indices(items: &[GridArea], compaction: Option<Axis>) -> Vec<usize> {
543    let mut order = (0..items.len()).collect::<Vec<_>>();
544    match compaction {
545        Some(Axis::Horizontal) => order.sort_by_key(|&ix| (items[ix].col, items[ix].row)),
546        _ => order.sort_by_key(|&ix| (items[ix].row, items[ix].col)),
547    }
548    order
549}
550
551fn first_collision(items: &[GridArea], among: &[usize], rect: Rect, skip: usize) -> Option<usize> {
552    among
553        .iter()
554        .copied()
555        .find(|&other| other != skip && rect.overlaps(&items[other].rect()))
556}
557
558fn any_collision(items: &[GridArea], rect: Rect, skip: usize) -> bool {
559    items
560        .iter()
561        .enumerate()
562        .any(|(other, item)| other != skip && rect.overlaps(&item.rect()))
563}
564
565fn collisions(items: &[GridArea], order: Vec<usize>, ix: usize) -> Vec<usize> {
566    let rect = items[ix].rect();
567    order
568        .into_iter()
569        .filter(|&other| other != ix && rect.overlaps(&items[other].rect()))
570        .collect()
571}
572
573#[cfg(test)]
574pub(crate) mod tests {
575    use super::*;
576
577    /// An area at `col`, `row` covering `col_span` by `row_span` cells.
578    pub(crate) fn area(
579        id: impl Into<SharedString>,
580        col: usize,
581        row: usize,
582        col_span: usize,
583        row_span: usize,
584    ) -> GridArea {
585        GridArea::new(id)
586            .col(col)
587            .row(row)
588            .col_span(col_span)
589            .row_span(row_span)
590    }
591
592    fn rules(compaction: Option<Axis>) -> LayoutRules {
593        LayoutRules {
594            columns: 12,
595            compaction,
596            prevent_collision: false,
597        }
598    }
599
600    fn at<'a>(items: &'a [GridArea], id: &str) -> &'a GridArea {
601        items.iter().find(|item| item.id.as_ref() == id).unwrap()
602    }
603
604    fn pos(items: &[GridArea], id: &str) -> (usize, usize) {
605        let item = at(items, id);
606        (item.col, item.row)
607    }
608
609    fn assert_no_overlaps(items: &[GridArea]) {
610        for (a, first) in items.iter().enumerate() {
611            for second in &items[a + 1..] {
612                assert!(
613                    !first.rect().overlaps(&second.rect()),
614                    "{} overlaps {}",
615                    first.id,
616                    second.id
617                );
618            }
619        }
620    }
621
622    #[test]
623    fn test_grid_area_builder() {
624        let item = GridArea::new("a")
625            .col(1)
626            .row(2)
627            .col_span(0)
628            .row_span(3)
629            .min_span(2, 0)
630            .max_span(6, 4)
631            .locked(true);
632        assert_eq!(
633            (item.col, item.row, item.col_span, item.row_span),
634            (1, 2, 1, 3)
635        );
636        assert_eq!((item.min_col_span, item.min_row_span), (2, 1));
637        assert_eq!((item.max_col_span, item.max_row_span), (Some(6), Some(4)));
638        assert!(item.locked);
639        assert_eq!((item.col_end(), item.row_end()), (2, 5));
640    }
641
642    #[test]
643    fn vertical_compaction_floats_items_up() {
644        let mut items = vec![
645            area("a", 0, 3, 4, 2),
646            area("b", 0, 9, 4, 2),
647            area("c", 4, 7, 4, 1),
648        ];
649        compact(&mut items, rules(Some(Axis::Vertical)));
650        assert_eq!(pos(&items, "a"), (0, 0));
651        assert_eq!(pos(&items, "b"), (0, 2));
652        assert_eq!(pos(&items, "c"), (4, 0));
653    }
654
655    #[test]
656    fn horizontal_compaction_floats_items_leading() {
657        let mut items = vec![area("a", 5, 0, 2, 2), area("b", 9, 0, 2, 2)];
658        compact(&mut items, rules(Some(Axis::Horizontal)));
659        assert_eq!(pos(&items, "a"), (0, 0));
660        assert_eq!(pos(&items, "b"), (2, 0));
661    }
662
663    #[test]
664    fn compaction_flows_around_locked_items() {
665        let mut items = vec![
666            area("wall", 0, 0, 12, 1).locked(true),
667            area("a", 0, 0, 4, 2),
668        ];
669        normalize(&mut items, rules(Some(Axis::Vertical)));
670        assert_eq!(pos(&items, "wall"), (0, 0));
671        assert_eq!(pos(&items, "a"), (0, 1));
672    }
673
674    #[test]
675    fn normalize_fits_items_to_the_columns_and_removes_overlaps() {
676        let mut items = vec![
677            area("wide", 4, 0, 20, 1),
678            area("a", 0, 0, 2, 1),
679            area("b", 0, 0, 2, 1),
680        ];
681        normalize(&mut items, rules(None));
682        assert_eq!(at(&items, "wide").col_span, 12);
683        assert_eq!(at(&items, "wide").col, 0);
684        assert_no_overlaps(&items);
685    }
686
687    #[test]
688    fn normalize_applies_changed_size_limits() {
689        let mut items = vec![area("a", 0, 0, 2, 6).min_span(4, 1).max_span(12, 3)];
690        normalize(&mut items, rules(None));
691        assert_eq!((items[0].col_span, items[0].row_span), (4, 3));
692    }
693
694    /// `GridArea` is what applications persist, so its JSON is a format
695    /// contract: field names stay put, and a layout saved with only the place
696    /// still loads.
697    #[test]
698    fn grid_area_json_is_a_stable_format() {
699        let saved = r#"{"id":"a","col":1,"row":2,"col_span":3,"row_span":4}"#;
700        let item: GridArea = serde_json::from_str(saved).unwrap();
701        assert_eq!(item, area("a", 1, 2, 3, 4));
702        assert_eq!(
703            serde_json::to_value(area("a", 1, 2, 3, 4).locked(true)).unwrap(),
704            serde_json::json!({
705                "id": "a", "col": 1, "row": 2, "col_span": 3, "row_span": 4,
706                "min_col_span": 1, "min_row_span": 1,
707                "max_col_span": null, "max_row_span": null, "locked": true
708            })
709        );
710    }
711
712    #[test]
713    fn reflowing_stacks_a_copy_in_reading_order() {
714        let areas = vec![
715            area("b", 6, 0, 6, 2),
716            area("a", 0, 0, 6, 3).min_span(4, 1),
717            area("c", 0, 3, 12, 1).locked(true),
718        ];
719        let stacked = reflowed(&areas, 1);
720        // One column, in reading order, each keeping its height.
721        assert_eq!(pos(&stacked, "a"), (0, 0));
722        assert_eq!(pos(&stacked, "b"), (0, 3));
723        assert_eq!(pos(&stacked, "c"), (0, 5));
724        assert!(stacked.iter().all(|area| area.col_span == 1));
725        assert_no_overlaps(&stacked);
726        // The layout it was derived from is untouched.
727        assert_eq!(at(&areas, "a").col_span, 6);
728        assert!(at(&areas, "c").locked);
729    }
730
731    #[test]
732    fn moving_onto_an_item_pushes_it_down() {
733        let items = vec![area("a", 0, 0, 4, 2), area("b", 4, 0, 4, 2)];
734        let next = move_item(&items, "a", 4, 1, rules(None)).unwrap();
735        assert_eq!(pos(&next, "a"), (4, 1));
736        assert_eq!(pos(&next, "b"), (4, 3));
737        assert_no_overlaps(&next);
738    }
739
740    #[test]
741    fn moving_up_onto_an_item_swaps_places() {
742        let items = vec![area("a", 0, 0, 4, 2), area("b", 0, 2, 4, 2)];
743        let next = move_item(&items, "b", 0, 0, rules(Some(Axis::Vertical))).unwrap();
744        assert_eq!(pos(&next, "b"), (0, 0));
745        assert_eq!(pos(&next, "a"), (0, 2));
746        assert_no_overlaps(&next);
747    }
748
749    #[test]
750    fn moves_and_resizes_never_leave_overlaps() {
751        let mut items = (0..7)
752            .map(|n| area(format!("item-{n}"), (n % 3) * 4, (n / 3) * 2, 4, 2 + n % 2))
753            .collect::<Vec<_>>();
754        items.push(area("wall", 5, 5, 2, 1).locked(true));
755        for compaction in [None, Some(Axis::Vertical), Some(Axis::Horizontal)] {
756            let rules = rules(compaction);
757            let mut start = items.clone();
758            normalize(&mut start, rules);
759            assert_no_overlaps(&start);
760            for n in 0..7 {
761                let id = format!("item-{n}");
762                for (x, y) in (0..12).flat_map(|x| (0..9).map(move |y| (x, y))) {
763                    let next = move_item(&start, &id, x, y, rules).unwrap();
764                    assert_no_overlaps(&next);
765                    assert_eq!(pos(&next, "wall"), pos(&start, "wall"));
766                    if let Some(next) = resize_item(&start, &id, x + 1, y + 1, rules) {
767                        assert_no_overlaps(&next);
768                    }
769                }
770            }
771        }
772    }
773
774    #[test]
775    fn a_move_is_clamped_to_the_columns() {
776        let items = vec![area("a", 0, 0, 4, 2)];
777        let next = move_item(&items, "a", 11, 0, rules(None)).unwrap();
778        assert_eq!(pos(&next, "a"), (8, 0));
779    }
780
781    #[test]
782    fn locked_items_neither_move_nor_get_pushed() {
783        let items = vec![area("wall", 4, 0, 4, 2).locked(true), area("a", 0, 0, 4, 2)];
784        assert!(move_item(&items, "wall", 0, 4, rules(None)).is_none());
785        let next = move_item(&items, "a", 4, 0, rules(Some(Axis::Vertical))).unwrap();
786        assert_eq!(pos(&next, "wall"), (4, 0));
787        assert_no_overlaps(&next);
788    }
789
790    #[test]
791    fn prevent_collision_refuses_overlapping_moves() {
792        let items = vec![area("a", 0, 0, 4, 2), area("b", 4, 0, 4, 2)];
793        let rules = LayoutRules {
794            prevent_collision: true,
795            ..rules(None)
796        };
797        assert!(move_item(&items, "a", 2, 0, rules).is_none());
798        assert_eq!(
799            pos(&move_item(&items, "a", 0, 3, rules).unwrap(), "a"),
800            (0, 3)
801        );
802    }
803
804    #[test]
805    fn resize_respects_limits_and_pushes_neighbours() {
806        let items = vec![
807            area("a", 0, 0, 4, 2).min_span(2, 2).max_span(8, 4),
808            area("b", 4, 0, 4, 2),
809        ];
810        let next = resize_item(&items, "a", 20, 1, rules(Some(Axis::Vertical))).unwrap();
811        assert_eq!((at(&next, "a").col_span, at(&next, "a").row_span), (8, 2));
812        assert_eq!(pos(&next, "b"), (4, 2));
813        assert_no_overlaps(&next);
814    }
815
816    #[test]
817    fn resize_stops_at_locked_items() {
818        let items = vec![area("a", 0, 0, 4, 2), area("wall", 4, 0, 4, 2).locked(true)];
819        let next = resize_item(&items, "a", 6, 3, rules(None)).unwrap();
820        assert_eq!((at(&next, "a").col_span, at(&next, "a").row_span), (4, 3));
821        assert_no_overlaps(&next);
822    }
823}