Skip to main content

term_wm_layout_engine/
hit_test.rs

1use crate::rect::{LayoutRect, Quadrant};
2
3/// Find the top-most (last-in-slice) region that contains `(col, row)`.
4pub fn hit_test_leaf<Id: Copy + Eq + Ord>(
5    regions: &[(Id, LayoutRect)],
6    col: u16,
7    row: u16,
8) -> Option<Id> {
9    for (id, rect) in regions.iter().rev() {
10        if rect.contains(col, row) {
11            return Some(*id);
12        }
13    }
14    None
15}
16
17/// Determine which diagonal quadrant of `target` the cursor falls in.
18///
19/// Uses dimension-scaled cross-product (no floating-point, no sqrt) to
20/// account for the target's aspect ratio.  The comparison
21/// `|dx| * height > |dy| * width` is equivalent to checking whether the
22/// angle from center to cursor is shallower than the true geometric
23/// diagonal `(±height/±width)`, producing equal-area triangular quadrants
24/// regardless of the rectangle's shape.
25pub fn detect_quadrant(cursor_col: u16, cursor_row: u16, target: &LayoutRect) -> Quadrant {
26    let (cx, cy) = target.center();
27
28    let dx = i32::from(cursor_col).saturating_sub(cx);
29    let dy = i32::from(cursor_row).saturating_sub(cy);
30
31    if dx == 0 && dy == 0 {
32        return Quadrant::East;
33    }
34
35    let adx = dx.unsigned_abs();
36    let ady = dy.unsigned_abs();
37    let w = u32::from(target.width);
38    let h = u32::from(target.height);
39
40    let scaled_dx = adx.saturating_mul(h);
41    let scaled_dy = ady.saturating_mul(w);
42
43    if scaled_dx > scaled_dy || (scaled_dx == scaled_dy && dx >= 0) {
44        if dx >= 0 {
45            Quadrant::East
46        } else {
47            Quadrant::West
48        }
49    } else {
50        if dy < 0 {
51            Quadrant::North
52        } else {
53            Quadrant::South
54        }
55    }
56}
57
58/// Find the region whose center is nearest to `(cx, cy)` using
59/// aspect-ratio-weighted Euclidean distance. Returns `None` for empty input.
60pub fn find_closest_region<Id: Copy>(
61    cx: i32,
62    cy: i32,
63    regions: &[(Id, LayoutRect)],
64    aspect_ratio_weight: u32,
65) -> Option<(Id, LayoutRect)> {
66    if regions.is_empty() {
67        return None;
68    }
69    let weight = aspect_ratio_weight as i64;
70    regions
71        .iter()
72        .map(|(id, rect)| {
73            let (rcx, rcy) = rect.center();
74            let dx = (cx as i64) - (rcx as i64);
75            let dy = ((cy as i64) - (rcy as i64)) * weight;
76            let dist = dx * dx + dy * dy;
77            (*id, *rect, dist)
78        })
79        .min_by_key(|(_, _, d)| *d)
80        .map(|(id, rect, _)| (id, rect))
81}
82
83/// Two-phase target resolution for spatial insertion:
84/// 1. Exact hit-test via `.contains()`.
85/// 2. Euclidean closest-tile fallback via [`find_closest_region`].
86pub fn resolve_target<Id: Copy>(
87    cx: i32,
88    cy: i32,
89    regions: &[(Id, LayoutRect)],
90    aspect_ratio_weight: u32,
91) -> Option<(Id, LayoutRect)> {
92    // Phase 1: exact hit-test
93    if let Some(found) = regions
94        .iter()
95        .find(|(_, r)| r.contains(cx as u16, cy as u16))
96    {
97        return Some(*found);
98    }
99    // Phase 2: Euclidean closest
100    find_closest_region(cx, cy, regions, aspect_ratio_weight)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn rect() -> LayoutRect {
108        LayoutRect {
109            x: 0,
110            y: 0,
111            width: 100,
112            height: 100,
113        }
114    }
115
116    #[test]
117    fn hit_test_finds_topmost() {
118        let regions = vec![
119            (
120                1u8,
121                LayoutRect {
122                    x: 0,
123                    y: 0,
124                    width: 50,
125                    height: 50,
126                },
127            ),
128            (
129                2u8,
130                LayoutRect {
131                    x: 0,
132                    y: 0,
133                    width: 10,
134                    height: 10,
135                },
136            ),
137        ];
138        assert_eq!(hit_test_leaf(&regions, 5, 5), Some(2));
139    }
140
141    #[test]
142    fn hit_test_miss() {
143        let regions = vec![(
144            1u8,
145            LayoutRect {
146                x: 0,
147                y: 0,
148                width: 10,
149                height: 10,
150            },
151        )];
152        assert_eq!(hit_test_leaf(&regions, 20, 20), None);
153    }
154
155    #[test]
156    fn quadrant_east() {
157        assert_eq!(detect_quadrant(75, 50, &rect()), Quadrant::East);
158    }
159
160    #[test]
161    fn quadrant_west() {
162        assert_eq!(detect_quadrant(25, 50, &rect()), Quadrant::West);
163    }
164
165    #[test]
166    fn quadrant_south() {
167        assert_eq!(detect_quadrant(50, 75, &rect()), Quadrant::South);
168    }
169
170    #[test]
171    fn quadrant_north() {
172        assert_eq!(detect_quadrant(50, 25, &rect()), Quadrant::North);
173    }
174
175    #[test]
176    fn quadrant_on_center_defaults_to_east() {
177        assert_eq!(detect_quadrant(50, 50, &rect()), Quadrant::East);
178    }
179
180    #[test]
181    fn quadrant_non_square_wide_target() {
182        let wide = LayoutRect {
183            x: 0,
184            y: 0,
185            width: 100,
186            height: 20,
187        };
188        assert_eq!(detect_quadrant(80, 5, &wide), Quadrant::East);
189        assert_eq!(detect_quadrant(60, 2, &wide), Quadrant::North);
190        assert_eq!(detect_quadrant(55, 0, &wide), Quadrant::North);
191    }
192
193    #[test]
194    fn quadrant_non_square_tall_target() {
195        let tall = LayoutRect {
196            x: 0,
197            y: 0,
198            width: 20,
199            height: 60,
200        };
201        assert_eq!(detect_quadrant(18, 5, &tall), Quadrant::North);
202        assert_eq!(detect_quadrant(19, 10, &tall), Quadrant::East);
203    }
204
205    // ── Quadrant::to_insert_position ──
206
207    #[test]
208    fn quadrant_to_insert_position_mappings() {
209        use crate::snap::InsertPosition;
210        assert_eq!(Quadrant::North.to_insert_position(), InsertPosition::Top);
211        assert_eq!(Quadrant::South.to_insert_position(), InsertPosition::Bottom);
212        assert_eq!(Quadrant::West.to_insert_position(), InsertPosition::Left);
213        assert_eq!(Quadrant::East.to_insert_position(), InsertPosition::Right);
214    }
215
216    // ── resolve_target / find_closest_region ──
217
218    #[test]
219    fn resolve_target_exact_hit() {
220        // Two rects separated by a 1-cell gutter
221        let left = LayoutRect {
222            x: 0,
223            y: 0,
224            width: 50,
225            height: 100,
226        };
227        let right = LayoutRect {
228            x: 51,
229            y: 0,
230            width: 50,
231            height: 100,
232        };
233        let regions = vec![(1u8, left), (2u8, right)];
234        // Coordinate inside left rect — should return left
235        let result = resolve_target(25, 50, &regions, 2);
236        assert_eq!(result, Some((1u8, left)));
237    }
238
239    #[test]
240    fn resolve_target_gap_nearest_left() {
241        // Two rects with a gutter. The gutter is at x=50..51.
242        let left = LayoutRect {
243            x: 0,
244            y: 0,
245            width: 50,
246            height: 100,
247        };
248        let right = LayoutRect {
249            x: 51,
250            y: 0,
251            width: 50,
252            height: 100,
253        };
254        let regions = vec![(1u8, left), (2u8, right)];
255        // Gutter center: x=50. Left center is at 25, right at 76.
256        // With weight=2, distance to left: 625, distance to right: (26*2)^2=2704
257        let result = resolve_target(50, 50, &regions, 2);
258        assert_eq!(result, Some((1u8, left)));
259    }
260
261    #[test]
262    fn resolve_target_gap_nearest_right() {
263        let left = LayoutRect {
264            x: 0,
265            y: 0,
266            width: 50,
267            height: 100,
268        };
269        let right = LayoutRect {
270            x: 51,
271            y: 0,
272            width: 50,
273            height: 100,
274        };
275        let regions = vec![(1u8, left), (2u8, right)];
276        // Place in gutter but closer to right's center
277        let result = resolve_target(50, 75, &regions, 2);
278        // Left center (25, 50): dx=25, dy=25*2=50, dist=3125
279        // Right center (76, 50): dx=-26, dy=25*2=50, dist=3204
280        assert_eq!(result, Some((1u8, left)));
281    }
282
283    #[test]
284    fn resolve_target_aspect_ratio_selects_horizontal() {
285        // Two rects at equal logical distance: one directly above (vertical),
286        // one to the right (horizontal). With weight=2, the vertical neighbor
287        // should appear farther and the horizontal should be selected.
288        let above = LayoutRect {
289            x: 50,
290            y: 0,
291            width: 100,
292            height: 48,
293        };
294        let right = LayoutRect {
295            x: 101,
296            y: 49,
297            width: 100,
298            height: 48,
299        };
300        let regions = vec![(1u8, above), (2u8, right)];
301        // Point between them: x=100, y=50
302        // Above center (100, 24): dx=0, dy=26*2=52, dist=2704
303        // Right center (151, 73): dx=-51, dy=-23*2=-46, dist=4697
304        let result = resolve_target(100, 50, &regions, 2);
305        assert_eq!(result, Some((1u8, above)));
306    }
307
308    #[test]
309    fn resolve_target_empty() {
310        let regions: Vec<(u8, LayoutRect)> = vec![];
311        assert!(resolve_target(50, 50, &regions, 2).is_none());
312    }
313}