Skip to main content

term_wm_layout_engine/
snap.rs

1use crate::rect::{LayoutRect, Orientation, Ratio};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum InsertPosition {
5    Left,
6    Right,
7    Top,
8    Bottom,
9}
10
11impl InsertPosition {
12    pub fn to_orientation(&self) -> Orientation {
13        match self {
14            InsertPosition::Left | InsertPosition::Right => Orientation::Horizontal,
15            InsertPosition::Top | InsertPosition::Bottom => Orientation::Vertical,
16        }
17    }
18
19    pub fn ratio(&self) -> Ratio {
20        match self {
21            InsertPosition::Left | InsertPosition::Top => Ratio(1, 1),
22            InsertPosition::Right | InsertPosition::Bottom => Ratio(1, 1),
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SnapTarget<Id: Copy + Eq + Ord> {
29    Edge(InsertPosition),
30    TiledInsert {
31        target: Id,
32        position: InsertPosition,
33    },
34}
35
36#[derive(Debug, Clone)]
37pub struct SnapPreview<Id: Copy + Eq + Ord> {
38    pub target: SnapTarget<Id>,
39    pub preview_rect: LayoutRect,
40}
41
42#[derive(Debug, Clone)]
43pub struct EdgeResistanceConfig {
44    pub magnetic_zone: u16,
45    pub spatial_threshold: u16,
46    // temporal_threshold and velocity tracking planned but not yet wired;
47    // requires frame timestamp plumbing from the runtime.
48    pub _temporal_threshold_ns: Option<u64>,
49}
50
51impl EdgeResistanceConfig {
52    pub fn default_tui() -> Self {
53        Self {
54            magnetic_zone: 3,
55            spatial_threshold: 8,
56            _temporal_threshold_ns: None,
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct EdgeResistance {
63    pub config: EdgeResistanceConfig,
64    pub prev_x: Option<i32>,
65    pub prev_y: Option<i32>,
66}
67
68impl EdgeResistance {
69    pub fn new(config: EdgeResistanceConfig) -> Self {
70        Self {
71            config,
72            prev_x: None,
73            prev_y: None,
74        }
75    }
76
77    pub fn default_tui() -> Self {
78        Self::new(EdgeResistanceConfig::default_tui())
79    }
80
81    pub fn apply_x(&mut self, new_x: i32, bounds: LayoutRect) -> i32 {
82        let low = bounds.x;
83        let high = bounds
84            .x
85            .saturating_add(i32::from(bounds.width.saturating_sub(1)));
86        let result = snap_axis(
87            new_x,
88            low,
89            high,
90            self.config.magnetic_zone,
91            self.config.spatial_threshold,
92            &self.prev_x,
93        );
94        self.prev_x = Some(new_x);
95        result
96    }
97
98    pub fn apply_y(&mut self, new_y: i32, bounds: LayoutRect) -> i32 {
99        let low = bounds.y;
100        let high = bounds
101            .y
102            .saturating_add(i32::from(bounds.height.saturating_sub(1)));
103        let result = snap_axis(
104            new_y,
105            low,
106            high,
107            self.config.magnetic_zone,
108            self.config.spatial_threshold,
109            &self.prev_y,
110        );
111        self.prev_y = Some(new_y);
112        result
113    }
114
115    pub fn apply(&mut self, new_x: i32, bounds: LayoutRect) -> i32 {
116        self.apply_x(new_x, bounds)
117    }
118}
119
120fn snap_axis(
121    new_val: i32,
122    low: i32,
123    high: i32,
124    magnetic_zone: u16,
125    spatial_threshold: u16,
126    prev: &Option<i32>,
127) -> i32 {
128    let zone = i32::from(magnetic_zone);
129    let hysteresis = i32::from(spatial_threshold);
130
131    let d_low = new_val.saturating_sub(low).unsigned_abs();
132    let d_high = high.saturating_sub(new_val).unsigned_abs();
133
134    let already_snapped = prev
135        .map(|p| {
136            let pd_low = p.saturating_sub(low).unsigned_abs();
137            let pd_high = high.saturating_sub(p).unsigned_abs();
138            pd_low <= zone as u32 || pd_high <= zone as u32
139        })
140        .unwrap_or(false);
141
142    let threshold = if already_snapped { hysteresis } else { zone };
143
144    let snap_low = d_low <= threshold as u32;
145    let snap_high = d_high <= threshold as u32;
146
147    if snap_low && d_low <= d_high {
148        low
149    } else if snap_high {
150        high
151    } else {
152        new_val
153    }
154}
155
156pub fn detect_edge_snap(
157    col: u16,
158    row: u16,
159    managed_area: LayoutRect,
160    sensitivity: u16,
161) -> Option<InsertPosition> {
162    let d_left = col.saturating_sub(managed_area.x as u16);
163    let d_right = (managed_area
164        .x
165        .saturating_add(i32::from(managed_area.width))
166        .saturating_sub(1) as u16)
167        .saturating_sub(col);
168    let d_top = row.saturating_sub(managed_area.y as u16);
169    let d_bottom = (managed_area
170        .y
171        .saturating_add(i32::from(managed_area.height))
172        .saturating_sub(1) as u16)
173        .saturating_sub(row);
174
175    let min_dist = d_left.min(d_right).min(d_top).min(d_bottom);
176
177    if min_dist >= sensitivity {
178        return None;
179    }
180
181    if d_left == min_dist {
182        Some(InsertPosition::Left)
183    } else if d_right == min_dist {
184        Some(InsertPosition::Right)
185    } else if d_top == min_dist {
186        Some(InsertPosition::Top)
187    } else {
188        Some(InsertPosition::Bottom)
189    }
190}
191
192pub fn edge_preview_rect(managed_area: LayoutRect, pos: InsertPosition) -> LayoutRect {
193    match pos {
194        InsertPosition::Left => LayoutRect {
195            width: managed_area.width / 2,
196            ..managed_area
197        },
198        InsertPosition::Right => LayoutRect {
199            x: managed_area
200                .x
201                .saturating_add(i32::from(managed_area.width / 2)),
202            width: managed_area.width / 2,
203            ..managed_area
204        },
205        InsertPosition::Top => LayoutRect {
206            height: managed_area.height / 2,
207            ..managed_area
208        },
209        InsertPosition::Bottom => LayoutRect {
210            y: managed_area
211                .y
212                .saturating_add(i32::from(managed_area.height / 2)),
213            height: managed_area.height / 2,
214            ..managed_area
215        },
216    }
217}
218
219pub fn tiled_preview_rect(target_rect: LayoutRect, position: InsertPosition) -> LayoutRect {
220    match position {
221        InsertPosition::Left => LayoutRect {
222            width: target_rect.width / 2,
223            ..target_rect
224        },
225        InsertPosition::Right => LayoutRect {
226            x: target_rect
227                .x
228                .saturating_add(i32::from(target_rect.width / 2)),
229            width: target_rect.width / 2,
230            ..target_rect
231        },
232        InsertPosition::Top => LayoutRect {
233            height: target_rect.height / 2,
234            ..target_rect
235        },
236        InsertPosition::Bottom => LayoutRect {
237            y: target_rect
238                .y
239                .saturating_add(i32::from(target_rect.height / 2)),
240            height: target_rect.height / 2,
241            ..target_rect
242        },
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn area() -> LayoutRect {
251        LayoutRect {
252            x: 0,
253            y: 0,
254            width: 80,
255            height: 24,
256        }
257    }
258
259    #[test]
260    fn edge_snap_left() {
261        assert_eq!(
262            detect_edge_snap(1, 12, area(), 2),
263            Some(InsertPosition::Left)
264        );
265    }
266
267    #[test]
268    fn edge_snap_right() {
269        assert_eq!(
270            detect_edge_snap(79, 12, area(), 2),
271            Some(InsertPosition::Right)
272        );
273    }
274
275    #[test]
276    fn edge_snap_top() {
277        assert_eq!(
278            detect_edge_snap(40, 0, area(), 2),
279            Some(InsertPosition::Top)
280        );
281    }
282
283    #[test]
284    fn edge_snap_bottom() {
285        assert_eq!(
286            detect_edge_snap(40, 23, area(), 2),
287            Some(InsertPosition::Bottom)
288        );
289    }
290
291    #[test]
292    fn no_snap_when_far_from_edge() {
293        assert_eq!(detect_edge_snap(40, 12, area(), 2), None);
294    }
295
296    #[test]
297    fn edge_resistance_snaps_to_left_edge() {
298        let mut er = EdgeResistance::default_tui();
299        let bounds = LayoutRect {
300            x: 0,
301            y: 0,
302            width: 80,
303            height: 24,
304        };
305        assert_eq!(er.apply_x(2, bounds), 0);
306    }
307
308    #[test]
309    fn edge_resistance_snaps_to_right_edge() {
310        let mut er = EdgeResistance::default_tui();
311        let bounds = LayoutRect {
312            x: 0,
313            y: 0,
314            width: 80,
315            height: 24,
316        };
317        assert_eq!(er.apply_x(78, bounds), 79);
318    }
319
320    #[test]
321    fn edge_resistance_passes_through_middle() {
322        let mut er = EdgeResistance::default_tui();
323        let bounds = LayoutRect {
324            x: 0,
325            y: 0,
326            width: 80,
327            height: 24,
328        };
329        assert_eq!(er.apply_x(40, bounds), 40);
330    }
331
332    #[test]
333    fn edge_resistance_snaps_y_to_top() {
334        let mut er = EdgeResistance::default_tui();
335        let bounds = LayoutRect {
336            x: 0,
337            y: 0,
338            width: 80,
339            height: 24,
340        };
341        assert_eq!(er.apply_y(2, bounds), 0);
342    }
343
344    #[test]
345    fn edge_resistance_snaps_y_to_bottom() {
346        let mut er = EdgeResistance::default_tui();
347        let bounds = LayoutRect {
348            x: 0,
349            y: 0,
350            width: 80,
351            height: 24,
352        };
353        assert_eq!(er.apply_y(22, bounds), 23);
354    }
355
356    #[test]
357    fn edge_preview_left() {
358        let p = edge_preview_rect(area(), InsertPosition::Left);
359        assert_eq!(p.width, 40);
360        assert_eq!(p.x, 0);
361    }
362
363    #[test]
364    fn edge_preview_right() {
365        let p = edge_preview_rect(area(), InsertPosition::Right);
366        assert_eq!(p.width, 40);
367        assert_eq!(p.x, 40);
368    }
369
370    #[test]
371    fn edge_preview_top() {
372        let p = edge_preview_rect(area(), InsertPosition::Top);
373        assert_eq!(p.height, 12);
374        assert_eq!(p.y, 0);
375    }
376
377    #[test]
378    fn edge_preview_bottom() {
379        let p = edge_preview_rect(area(), InsertPosition::Bottom);
380        assert_eq!(p.height, 12);
381        assert_eq!(p.y, 12);
382    }
383
384    #[test]
385    fn tiled_preview_rect_left() {
386        let target = LayoutRect {
387            x: 10,
388            y: 10,
389            width: 60,
390            height: 20,
391        };
392        let p = tiled_preview_rect(target, InsertPosition::Left);
393        assert_eq!(p.width, 30);
394        assert_eq!(p.x, 10);
395    }
396
397    #[test]
398    fn inserting_position_to_orientation() {
399        assert_eq!(
400            InsertPosition::Left.to_orientation(),
401            Orientation::Horizontal
402        );
403        assert_eq!(InsertPosition::Top.to_orientation(), Orientation::Vertical);
404    }
405}