Skip to main content

gpui/
resize.rs

1//! Hit testing for rectangular resize handles.
2
3use crate::{Bounds, Edges, Pixels, Point, ResizeEdge, Tiling};
4
5/// Resize handles around a rectangle.
6#[derive(Debug, Copy, Clone, Eq, PartialEq)]
7pub struct ResizeRegion {
8    border_width: Pixels,
9    corner_size: Pixels,
10    enabled_edges: Edges<bool>,
11}
12
13impl ResizeRegion {
14    /// Creates handles with the given border width.
15    /// Invalid widths become zero; hits must lie inside the bounds.
16    pub fn new(border_width: Pixels) -> Self {
17        Self {
18            border_width: sane(border_width),
19            corner_size: sane(border_width),
20            enabled_edges: Edges::all(true),
21        }
22    }
23
24    /// Sets diagonal reach; invalid sizes become zero.
25    pub fn corner_size(mut self, corner_size: Pixels) -> Self {
26        self.corner_size = sane(corner_size);
27        self
28    }
29
30    /// Sets which sides can resize.
31    pub fn enabled_edges(mut self, enabled_edges: Edges<bool>) -> Self {
32        self.enabled_edges = enabled_edges;
33        self
34    }
35
36    /// Disables handles on tiled sides.
37    pub fn without_tiled_edges(mut self, tiling: Tiling) -> Self {
38        self.enabled_edges.top &= !tiling.top;
39        self.enabled_edges.right &= !tiling.right;
40        self.enabled_edges.bottom &= !tiling.bottom;
41        self.enabled_edges.left &= !tiling.left;
42        self
43    }
44
45    /// Returns the nearest enabled handle under `position`.
46    /// Bounds use half-open edges.
47    pub fn hit_test(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> Option<ResizeEdge> {
48        let left = bounds.origin.x.0;
49        let top = bounds.origin.y.0;
50        let width = bounds.size.width.0;
51        let height = bounds.size.height.0;
52        let right = left + width;
53        let bottom = top + height;
54        let x = position.x.0;
55        let y = position.y.0;
56        let bw = self.border_width.0;
57        let corner = self.corner_size.0;
58
59        if !(left.is_finite()
60            && top.is_finite()
61            && width.is_finite()
62            && height.is_finite()
63            && x.is_finite()
64            && y.is_finite())
65            || width <= 0.0
66            || height <= 0.0
67            || bw <= 0.0
68            || !right.is_finite()
69            || !bottom.is_finite()
70        {
71            return None;
72        }
73
74        if x < left || x >= right || y < top || y >= bottom {
75            return None;
76        }
77
78        let top_hit = self.enabled_edges.top
79            && y >= top
80            && y < (top + bw).min(bottom)
81            && x >= left
82            && x < right;
83        let right_hit = self.enabled_edges.right
84            && x >= (right - bw).max(left)
85            && x < right
86            && y >= top
87            && y < bottom;
88        let bottom_hit = self.enabled_edges.bottom
89            && y >= (bottom - bw).max(top)
90            && y < bottom
91            && x >= left
92            && x < right;
93        let left_hit = self.enabled_edges.left
94            && x >= left
95            && x < (left + bw).min(right)
96            && y >= top
97            && y < bottom;
98
99        let near_left = x - left < right - x && x - left < corner;
100        let near_right = right - x <= x - left && right - x <= corner;
101        let near_top = y - top < bottom - y && y - top < corner;
102        let near_bottom = bottom - y <= y - top && bottom - y <= corner;
103        let in_left_strip = x - left < bw;
104        let in_right_strip = right - x <= bw;
105        let in_top_strip = y - top < bw;
106        let in_bottom_strip = bottom - y <= bw;
107        let corner_hit = if near_top
108            && near_left
109            && (in_top_strip || in_left_strip)
110            && self.enabled_edges.top
111            && self.enabled_edges.left
112        {
113            Some(ResizeEdge::TopLeft)
114        } else if near_top
115            && near_right
116            && (in_top_strip || in_right_strip)
117            && self.enabled_edges.top
118            && self.enabled_edges.right
119        {
120            Some(ResizeEdge::TopRight)
121        } else if near_bottom
122            && near_left
123            && (in_bottom_strip || in_left_strip)
124            && self.enabled_edges.bottom
125            && self.enabled_edges.left
126        {
127            Some(ResizeEdge::BottomLeft)
128        } else if near_bottom
129            && near_right
130            && (in_bottom_strip || in_right_strip)
131            && self.enabled_edges.bottom
132            && self.enabled_edges.right
133        {
134            Some(ResizeEdge::BottomRight)
135        } else {
136            None
137        };
138        if corner_hit.is_some() {
139            return corner_hit;
140        }
141
142        // Choose the nearest side when strips overlap.
143        let mut best: Option<(f32, ResizeEdge)> = None;
144        for (hit, distance, edge) in [
145            (top_hit, y - top, ResizeEdge::Top),
146            (right_hit, right - x, ResizeEdge::Right),
147            (bottom_hit, bottom - y, ResizeEdge::Bottom),
148            (left_hit, x - left, ResizeEdge::Left),
149        ] {
150            if hit && best.map_or(true, |(best_distance, _)| distance < best_distance) {
151                best = Some((distance, edge));
152            }
153        }
154        best.map(|(_, edge)| edge)
155    }
156}
157
158fn sane(value: Pixels) -> Pixels {
159    if value.0.is_finite() && value.0 > 0.0 {
160        value
161    } else {
162        Pixels(0.0)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::{CursorStyle, point, px, size};
170    use proptest::prelude::*;
171
172    fn bounds() -> Bounds<Pixels> {
173        Bounds::new(point(px(10.), px(20.)), size(px(100.), px(80.)))
174    }
175
176    fn region() -> ResizeRegion {
177        ResizeRegion::new(px(4.)).corner_size(px(8.))
178    }
179
180    #[test]
181    fn resize_handles_and_cursor_directions_agree() {
182        // Offset bounds catch origin-relative hit tests.
183        for (x, y, expected) in [
184            (60., 20., Some((ResizeEdge::Top, CursorStyle::ResizeUp))),
185            (
186                109.,
187                60.,
188                Some((ResizeEdge::Right, CursorStyle::ResizeRight)),
189            ),
190            (
191                60.,
192                99.,
193                Some((ResizeEdge::Bottom, CursorStyle::ResizeDown)),
194            ),
195            (10., 60., Some((ResizeEdge::Left, CursorStyle::ResizeLeft))),
196            (
197                10.,
198                20.,
199                Some((ResizeEdge::TopLeft, CursorStyle::ResizeUpLeft)),
200            ),
201            (
202                109.,
203                20.,
204                Some((ResizeEdge::TopRight, CursorStyle::ResizeUpRight)),
205            ),
206            (
207                10.,
208                99.,
209                Some((ResizeEdge::BottomLeft, CursorStyle::ResizeDownLeft)),
210            ),
211            (
212                109.,
213                99.,
214                Some((ResizeEdge::BottomRight, CursorStyle::ResizeDownRight)),
215            ),
216            (60., 60., None),
217            (9.75, 60., None),
218            (110., 60., None),
219            (60., 19.75, None),
220            (60., 100., None),
221        ] {
222            let actual = region().hit_test(point(px(x), px(y)), bounds());
223            assert_eq!(actual, expected.map(|(edge, _)| edge), "({x}, {y})");
224            assert_eq!(
225                actual.map(CursorStyle::from),
226                expected.map(|(_, cursor)| cursor)
227            );
228        }
229    }
230
231    #[test]
232    fn strip_and_corner_boundaries_follow_half_open_bounds() {
233        for (x, y, expected) in [
234            (60., 23.75, Some(ResizeEdge::Top)),
235            (60., 24., None),
236            (106., 60., Some(ResizeEdge::Right)),
237            (105.75, 60., None),
238            (60., 96., Some(ResizeEdge::Bottom)),
239            (60., 95.75, None),
240            (13.75, 60., Some(ResizeEdge::Left)),
241            (14., 60., None),
242            (17.75, 21., Some(ResizeEdge::TopLeft)),
243            (18., 21., Some(ResizeEdge::Top)),
244            (102., 21., Some(ResizeEdge::TopRight)),
245            (101.75, 21., Some(ResizeEdge::Top)),
246            (11., 27.75, Some(ResizeEdge::TopLeft)),
247            (11., 28., Some(ResizeEdge::Left)),
248            (11., 92., Some(ResizeEdge::BottomLeft)),
249            (11., 91.75, Some(ResizeEdge::Left)),
250            (17., 27., None), // Inside the corner square, outside both strips.
251            (103., 93., None),
252        ] {
253            assert_eq!(
254                region().hit_test(point(px(x), px(y)), bounds()),
255                expected,
256                "({x}, {y})"
257            );
258        }
259    }
260
261    fn edges(mask: u8) -> Edges<bool> {
262        Edges {
263            top: mask & 1 != 0,
264            right: mask & 2 != 0,
265            bottom: mask & 4 != 0,
266            left: mask & 8 != 0,
267        }
268    }
269
270    #[test]
271    fn every_edge_mask_disables_sides_and_demotes_corners() {
272        for mask in 0..16 {
273            let region = region().enabled_edges(edges(mask));
274            for (x, y, bit, edge) in [
275                (60., 21., 1, ResizeEdge::Top),
276                (109., 60., 2, ResizeEdge::Right),
277                (60., 99., 4, ResizeEdge::Bottom),
278                (11., 60., 8, ResizeEdge::Left),
279            ] {
280                assert_eq!(
281                    region.hit_test(point(px(x), px(y)), bounds()),
282                    (mask & bit != 0).then_some(edge),
283                    "mask={mask}, {edge:?}"
284                );
285            }
286            // These samples hit only the horizontal strip; disabling it leaves a hole.
287            for (x, y, horizontal, vertical, side, corner) in [
288                (16., 21., 1, 8, ResizeEdge::Top, ResizeEdge::TopLeft),
289                (104., 21., 1, 2, ResizeEdge::Top, ResizeEdge::TopRight),
290                (16., 99., 4, 8, ResizeEdge::Bottom, ResizeEdge::BottomLeft),
291                (104., 99., 4, 2, ResizeEdge::Bottom, ResizeEdge::BottomRight),
292            ] {
293                let expected = match (mask & horizontal != 0, mask & vertical != 0) {
294                    (true, true) => Some(corner),
295                    (true, false) => Some(side),
296                    (false, _) => None,
297                };
298                assert_eq!(
299                    region.hit_test(point(px(x), px(y)), bounds()),
300                    expected,
301                    "mask={mask}, {corner:?}"
302                );
303            }
304        }
305    }
306
307    #[test]
308    fn tiling_intersects_enabled_edges_and_never_reenables_a_side() {
309        for enabled in 0..16 {
310            for tiled in 0..16 {
311                let e = edges(tiled);
312                let tiling = Tiling {
313                    top: e.top,
314                    right: e.right,
315                    bottom: e.bottom,
316                    left: e.left,
317                };
318                let actual = region()
319                    .enabled_edges(edges(enabled))
320                    .without_tiled_edges(tiling);
321                let expected = region().enabled_edges(edges(enabled & !tiled));
322                for (x, y) in [
323                    (60., 21.),
324                    (109., 60.),
325                    (60., 99.),
326                    (11., 60.),
327                    (11., 21.),
328                    (109., 21.),
329                    (11., 99.),
330                    (109., 99.),
331                ] {
332                    let p = point(px(x), px(y));
333                    assert_eq!(
334                        actual.hit_test(p, bounds()),
335                        expected.hit_test(p, bounds()),
336                        "enabled={enabled}, tiled={tiled}, {p:?}"
337                    );
338                }
339                assert_eq!(actual.without_tiled_edges(tiling), actual);
340            }
341        }
342    }
343
344    #[test]
345    fn overlapping_strips_and_corners_choose_the_nearest_boundaries() {
346        let b = Bounds::new(point(px(10.), px(20.)), size(px(2.), px(2.)));
347        let sides = ResizeRegion::new(px(5.)).corner_size(px(0.));
348        for (x, y, side, corner) in [
349            (10.25, 20.5, ResizeEdge::Left, ResizeEdge::TopLeft),
350            (11.75, 20.5, ResizeEdge::Right, ResizeEdge::TopRight),
351            (10.5, 20.25, ResizeEdge::Top, ResizeEdge::TopLeft),
352            (10.5, 21.75, ResizeEdge::Bottom, ResizeEdge::BottomLeft),
353            (11.75, 21.5, ResizeEdge::Right, ResizeEdge::BottomRight),
354            (10.25, 20.25, ResizeEdge::Top, ResizeEdge::TopLeft),
355            (11., 21., ResizeEdge::Top, ResizeEdge::BottomRight),
356        ] {
357            let p = point(px(x), px(y));
358            assert_eq!(sides.hit_test(p, b), Some(side), "{p:?}");
359            assert_eq!(
360                sides.corner_size(px(5.)).hit_test(p, b),
361                Some(corner),
362                "{p:?}"
363            );
364        }
365    }
366
367    #[test]
368    fn invalid_dimensions_and_nonfinite_coordinates_never_activate_handles() {
369        for invalid in [0., -1., f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
370            assert_eq!(
371                ResizeRegion::new(px(invalid)).hit_test(point(px(10.), px(20.)), bounds()),
372                None
373            );
374            // Invalid corner sizes disable diagonals but keep side handles.
375            assert_eq!(
376                region()
377                    .corner_size(px(invalid))
378                    .hit_test(point(px(11.), px(21.)), bounds()),
379                Some(ResizeEdge::Top)
380            );
381            for b in [
382                Bounds::new(bounds().origin, size(px(invalid), px(80.))),
383                Bounds::new(bounds().origin, size(px(100.), px(invalid))),
384            ] {
385                assert_eq!(region().hit_test(bounds().origin, b), None, "{b:?}");
386            }
387        }
388        for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
389            for p in [point(px(invalid), px(20.)), point(px(10.), px(invalid))] {
390                assert_eq!(region().hit_test(p, bounds()), None);
391                assert_eq!(
392                    region().hit_test(bounds().origin, Bounds::new(p, bounds().size)),
393                    None
394                );
395            }
396        }
397        for b in [
398            Bounds::new(point(px(f32::MAX), px(0.)), size(px(f32::MAX), px(80.))),
399            Bounds::new(point(px(0.), px(f32::MAX)), size(px(100.), px(f32::MAX))),
400        ] {
401            assert_eq!(
402                region().hit_test(b.origin, b),
403                None,
404                "overflowed bounds: {b:?}"
405            );
406        }
407    }
408
409    proptest! {
410        #[test]
411        fn translating_a_rectangle_preserves_all_hits(
412            x in -8i32..=108, y in -8i32..=88,
413            dx in -1000i32..=1000, dy in -1000i32..=1000,
414            border in 0u8..=120, corner in 0u8..=120, mask in 0u8..16,
415        ) {
416            // Quarter pixels keep boundary samples exact.
417            let p = point(px(x as f32 / 4.),px(y as f32 / 4.));
418            let offset = point(px(dx as f32),px(dy as f32));
419            let b = Bounds::new(Point::default(),size(px(25.),px(20.)));
420            let r = ResizeRegion::new(px(border as f32 / 4.))
421                .corner_size(px(corner as f32 / 4.))
422                .enabled_edges(edges(mask));
423            prop_assert_eq!(r.hit_test(p,b),r.hit_test(p+offset,Bounds::new(offset,b.size)));
424        }
425    }
426}