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    TopLeft,
10    TopRight,
11    BottomLeft,
12    BottomRight,
13}
14
15impl InsertPosition {
16    pub fn to_orientation(&self) -> Orientation {
17        match self {
18            InsertPosition::Left | InsertPosition::Right => Orientation::Horizontal,
19            InsertPosition::Top | InsertPosition::Bottom => Orientation::Vertical,
20            // Corners use horizontal orientation for BSP split (vertical divider)
21            InsertPosition::TopLeft
22            | InsertPosition::TopRight
23            | InsertPosition::BottomLeft
24            | InsertPosition::BottomRight => Orientation::Horizontal,
25        }
26    }
27
28    pub fn ratio(&self) -> Ratio {
29        match self {
30            InsertPosition::Left
31            | InsertPosition::Top
32            | InsertPosition::Right
33            | InsertPosition::Bottom
34            | InsertPosition::TopLeft
35            | InsertPosition::TopRight
36            | InsertPosition::BottomLeft
37            | InsertPosition::BottomRight => Ratio(1, 1),
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SnapTarget<Id: Copy + Eq + Ord> {
44    Edge(InsertPosition),
45    TiledInsert {
46        target: Id,
47        position: InsertPosition,
48    },
49}
50
51#[derive(Debug, Clone)]
52pub struct SnapPreview<Id: Copy + Eq + Ord> {
53    pub target: SnapTarget<Id>,
54    pub preview_rect: LayoutRect,
55}
56
57#[derive(Debug, Clone)]
58pub struct EdgeResistanceConfig {
59    pub magnetic_zone: u16,
60    pub spatial_threshold: u16,
61    /// Time in nanoseconds the cursor must stay within the magnetic zone
62    /// before the resistance is broken.  None disables temporal resistance.
63    pub temporal_threshold_ns: Option<u64>,
64}
65
66impl EdgeResistanceConfig {
67    pub fn default_tui() -> Self {
68        Self {
69            magnetic_zone: 3,
70            spatial_threshold: 8,
71            temporal_threshold_ns: Some(500_000_000), // 500ms
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct EdgeResistance {
78    pub config: EdgeResistanceConfig,
79    pub prev_x: Option<i32>,
80    pub prev_y: Option<i32>,
81    /// Wall-clock nanosecond timestamp when the cursor first entered the
82    /// magnetic zone on the current axis.  Pure integer; no `std::time` types.
83    pub entered_magnetic_x_at: Option<u64>,
84    pub entered_magnetic_y_at: Option<u64>,
85}
86
87impl EdgeResistance {
88    pub fn new(config: EdgeResistanceConfig) -> Self {
89        Self {
90            config,
91            prev_x: None,
92            prev_y: None,
93            entered_magnetic_x_at: None,
94            entered_magnetic_y_at: None,
95        }
96    }
97
98    pub fn default_tui() -> Self {
99        Self::new(EdgeResistanceConfig::default_tui())
100    }
101
102    /// Apply X-axis magnetic resistance.
103    ///
104    /// `now_ns` is a raw nanosecond timestamp (from `Instant::now()`
105    /// converted to nanos-since-epoch in the caller).  The layout engine
106    /// treats it as an opaque integer — no `std::time` types are used.
107    pub fn apply_x(&mut self, new_x: i32, bounds: LayoutRect, now_ns: u64) -> i32 {
108        let low = bounds.x;
109        let high = bounds
110            .x
111            .saturating_add(i32::from(bounds.width.saturating_sub(1)));
112        let result = snap_axis(&SnapAxisParams {
113            new_val: new_x,
114            low,
115            high,
116            magnetic_zone: self.config.magnetic_zone,
117            spatial_threshold: self.config.spatial_threshold,
118            temporal_threshold_ns: self.config.temporal_threshold_ns,
119            prev: self.prev_x,
120            entered_at: self.entered_magnetic_x_at,
121            now_ns,
122            enable_low_snap: true,
123            enable_high_snap: true,
124        });
125        // Track entry into magnetic zone for temporal threshold
126        let is_snapped = result == low || result == high;
127        if is_snapped && self.entered_magnetic_x_at.is_none() {
128            self.entered_magnetic_x_at = Some(now_ns);
129        } else if !is_snapped {
130            self.entered_magnetic_x_at = None;
131        }
132        self.prev_x = Some(new_x);
133        result
134    }
135
136    /// Apply Y-axis magnetic resistance.
137    pub fn apply_y(&mut self, new_y: i32, bounds: LayoutRect, now_ns: u64) -> i32 {
138        let low = bounds.y;
139        let high = bounds
140            .y
141            .saturating_add(i32::from(bounds.height.saturating_sub(1)));
142        let result = snap_axis(&SnapAxisParams {
143            new_val: new_y,
144            low,
145            high,
146            magnetic_zone: self.config.magnetic_zone,
147            spatial_threshold: self.config.spatial_threshold,
148            temporal_threshold_ns: self.config.temporal_threshold_ns,
149            prev: self.prev_y,
150            entered_at: self.entered_magnetic_y_at,
151            now_ns,
152            enable_low_snap: false,
153            enable_high_snap: true,
154        });
155        let is_snapped = result == low || result == high;
156        if is_snapped && self.entered_magnetic_y_at.is_none() {
157            self.entered_magnetic_y_at = Some(now_ns);
158        } else if !is_snapped {
159            self.entered_magnetic_y_at = None;
160        }
161        self.prev_y = Some(new_y);
162        result
163    }
164
165    pub fn apply(&mut self, new_x: i32, bounds: LayoutRect, now_ns: u64) -> i32 {
166        self.apply_x(new_x, bounds, now_ns)
167    }
168}
169
170struct SnapAxisParams {
171    new_val: i32,
172    low: i32,
173    high: i32,
174    magnetic_zone: u16,
175    spatial_threshold: u16,
176    temporal_threshold_ns: Option<u64>,
177    prev: Option<i32>,
178    entered_at: Option<u64>,
179    now_ns: u64,
180    enable_low_snap: bool,
181    enable_high_snap: bool,
182}
183
184fn snap_axis(params: &SnapAxisParams) -> i32 {
185    let zone = i32::from(params.magnetic_zone);
186    let hysteresis = i32::from(params.spatial_threshold);
187
188    let d_low = params.new_val.saturating_sub(params.low).unsigned_abs();
189    let d_high = params.high.saturating_sub(params.new_val).unsigned_abs();
190
191    let already_snapped = params
192        .prev
193        .map(|p| {
194            let pd_low = p.saturating_sub(params.low).unsigned_abs();
195            let pd_high = params.high.saturating_sub(p).unsigned_abs();
196            (params.enable_low_snap && pd_low <= zone as u32)
197                || (params.enable_high_snap && pd_high <= zone as u32)
198        })
199        .unwrap_or(false);
200
201    let threshold = if already_snapped { hysteresis } else { zone };
202
203    let snap_low = d_low <= threshold as u32;
204    let snap_high = d_high <= threshold as u32;
205
206    // If already snapped and temporal threshold has elapsed, break the lock
207    if already_snapped
208        && let (Some(threshold_ns), Some(entry_ns)) =
209            (params.temporal_threshold_ns, params.entered_at)
210        && params.now_ns.saturating_sub(entry_ns) >= threshold_ns
211    {
212        return params.new_val;
213    }
214
215    if params.enable_low_snap && snap_low && d_low <= d_high {
216        params.low
217    } else if params.enable_high_snap && snap_high {
218        params.high
219    } else {
220        params.new_val
221    }
222}
223
224pub fn detect_edge_snap(
225    col: u16,
226    row: u16,
227    managed_area: LayoutRect,
228    sensitivity: u16,
229) -> Option<InsertPosition> {
230    let d_left = col.saturating_sub(managed_area.x as u16);
231    let d_right = (managed_area
232        .x
233        .saturating_add(i32::from(managed_area.width))
234        .saturating_sub(1) as u16)
235        .saturating_sub(col);
236    let d_top = row.saturating_sub(managed_area.y as u16);
237    let d_bottom = (managed_area
238        .y
239        .saturating_add(i32::from(managed_area.height))
240        .saturating_sub(1) as u16)
241        .saturating_sub(row);
242
243    let min_dist = d_left.min(d_right).min(d_top).min(d_bottom);
244
245    if min_dist >= sensitivity {
246        return None;
247    }
248
249    if d_left == min_dist {
250        Some(InsertPosition::Left)
251    } else if d_right == min_dist {
252        Some(InsertPosition::Right)
253    } else if d_top == min_dist {
254        Some(InsertPosition::Top)
255    } else {
256        Some(InsertPosition::Bottom)
257    }
258}
259
260pub fn edge_preview_rect(managed_area: LayoutRect, pos: InsertPosition) -> LayoutRect {
261    match pos {
262        InsertPosition::Left => LayoutRect {
263            width: managed_area.width / 2,
264            ..managed_area
265        },
266        InsertPosition::Right => LayoutRect {
267            x: managed_area
268                .x
269                .saturating_add(i32::from(managed_area.width / 2)),
270            width: managed_area.width / 2,
271            ..managed_area
272        },
273        InsertPosition::Top => LayoutRect {
274            height: managed_area.height / 2,
275            ..managed_area
276        },
277        InsertPosition::Bottom => LayoutRect {
278            y: managed_area
279                .y
280                .saturating_add(i32::from(managed_area.height / 2)),
281            height: managed_area.height / 2,
282            ..managed_area
283        },
284        // Corner previews: quarter-screen (50% × 50%)
285        InsertPosition::TopLeft => LayoutRect {
286            width: managed_area.width / 2,
287            height: managed_area.height / 2,
288            ..managed_area
289        },
290        InsertPosition::TopRight => LayoutRect {
291            x: managed_area
292                .x
293                .saturating_add(i32::from(managed_area.width / 2)),
294            width: managed_area.width / 2,
295            height: managed_area.height / 2,
296            ..managed_area
297        },
298        InsertPosition::BottomLeft => LayoutRect {
299            y: managed_area
300                .y
301                .saturating_add(i32::from(managed_area.height / 2)),
302            width: managed_area.width / 2,
303            height: managed_area.height / 2,
304            ..managed_area
305        },
306        InsertPosition::BottomRight => LayoutRect {
307            x: managed_area
308                .x
309                .saturating_add(i32::from(managed_area.width / 2)),
310            y: managed_area
311                .y
312                .saturating_add(i32::from(managed_area.height / 2)),
313            width: managed_area.width / 2,
314            height: managed_area.height / 2,
315        },
316    }
317}
318
319pub fn tiled_preview_rect(target_rect: LayoutRect, position: InsertPosition) -> LayoutRect {
320    match position {
321        InsertPosition::Left => LayoutRect {
322            width: target_rect.width / 2,
323            ..target_rect
324        },
325        InsertPosition::Right => LayoutRect {
326            x: target_rect
327                .x
328                .saturating_add(i32::from(target_rect.width / 2)),
329            width: target_rect.width / 2,
330            ..target_rect
331        },
332        InsertPosition::Top => LayoutRect {
333            height: target_rect.height / 2,
334            ..target_rect
335        },
336        InsertPosition::Bottom => LayoutRect {
337            y: target_rect
338                .y
339                .saturating_add(i32::from(target_rect.height / 2)),
340            height: target_rect.height / 2,
341            ..target_rect
342        },
343        // Corner tiled previews: quarter of the target pane
344        InsertPosition::TopLeft => LayoutRect {
345            width: target_rect.width / 2,
346            height: target_rect.height / 2,
347            ..target_rect
348        },
349        InsertPosition::TopRight => LayoutRect {
350            x: target_rect
351                .x
352                .saturating_add(i32::from(target_rect.width / 2)),
353            width: target_rect.width / 2,
354            height: target_rect.height / 2,
355            ..target_rect
356        },
357        InsertPosition::BottomLeft => LayoutRect {
358            y: target_rect
359                .y
360                .saturating_add(i32::from(target_rect.height / 2)),
361            width: target_rect.width / 2,
362            height: target_rect.height / 2,
363            ..target_rect
364        },
365        InsertPosition::BottomRight => LayoutRect {
366            x: target_rect
367                .x
368                .saturating_add(i32::from(target_rect.width / 2)),
369            y: target_rect
370                .y
371                .saturating_add(i32::from(target_rect.height / 2)),
372            width: target_rect.width / 2,
373            height: target_rect.height / 2,
374        },
375    }
376}
377
378/// Compute a corner (quarter-screen) preview rect.
379pub fn corner_preview_rect(managed_area: LayoutRect, pos: InsertPosition) -> LayoutRect {
380    match pos {
381        InsertPosition::TopLeft => LayoutRect {
382            width: managed_area.width / 2,
383            height: managed_area.height / 2,
384            ..managed_area
385        },
386        InsertPosition::TopRight => LayoutRect {
387            x: managed_area
388                .x
389                .saturating_add(i32::from(managed_area.width / 2)),
390            width: managed_area.width / 2,
391            height: managed_area.height / 2,
392            ..managed_area
393        },
394        InsertPosition::BottomLeft => LayoutRect {
395            y: managed_area
396                .y
397                .saturating_add(i32::from(managed_area.height / 2)),
398            width: managed_area.width / 2,
399            height: managed_area.height / 2,
400            ..managed_area
401        },
402        InsertPosition::BottomRight => LayoutRect {
403            x: managed_area
404                .x
405                .saturating_add(i32::from(managed_area.width / 2)),
406            y: managed_area
407                .y
408                .saturating_add(i32::from(managed_area.height / 2)),
409            width: managed_area.width / 2,
410            height: managed_area.height / 2,
411        },
412        // Non-corner positions: delegate to edge_preview_rect
413        _ => edge_preview_rect(managed_area, pos),
414    }
415}
416
417/// Detect a corner snap when the cursor is simultaneously within `sensitivity`
418/// of both an X edge and a Y edge. Uses saturating arithmetic exclusively.
419pub fn detect_corner_snap(
420    col: u16,
421    row: u16,
422    managed_area: LayoutRect,
423    sensitivity: u16,
424) -> Option<InsertPosition> {
425    let margin_left = managed_area.x as u16;
426    let margin_right = (managed_area.x as u16)
427        .saturating_add(managed_area.width)
428        .saturating_sub(1);
429    let margin_top = managed_area.y as u16;
430    let margin_bottom = (managed_area.y as u16)
431        .saturating_add(managed_area.height)
432        .saturating_sub(1);
433
434    let near_left = col <= sensitivity.saturating_add(margin_left);
435    let near_right = col >= margin_right.saturating_sub(sensitivity);
436    let near_top = row <= sensitivity.saturating_add(margin_top);
437    let near_bottom = row >= margin_bottom.saturating_sub(sensitivity);
438
439    if near_top && near_left {
440        Some(InsertPosition::TopLeft)
441    } else if near_top && near_right {
442        Some(InsertPosition::TopRight)
443    } else if near_bottom && near_left {
444        Some(InsertPosition::BottomLeft)
445    } else if near_bottom && near_right {
446        Some(InsertPosition::BottomRight)
447    } else {
448        None
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn area() -> LayoutRect {
457        LayoutRect {
458            x: 0,
459            y: 0,
460            width: 80,
461            height: 24,
462        }
463    }
464
465    #[test]
466    fn edge_snap_left() {
467        assert_eq!(
468            detect_edge_snap(1, 12, area(), 2),
469            Some(InsertPosition::Left)
470        );
471    }
472
473    #[test]
474    fn edge_snap_right() {
475        assert_eq!(
476            detect_edge_snap(79, 12, area(), 2),
477            Some(InsertPosition::Right)
478        );
479    }
480
481    #[test]
482    fn edge_snap_top() {
483        assert_eq!(
484            detect_edge_snap(40, 0, area(), 2),
485            Some(InsertPosition::Top)
486        );
487    }
488
489    #[test]
490    fn edge_snap_bottom() {
491        assert_eq!(
492            detect_edge_snap(40, 23, area(), 2),
493            Some(InsertPosition::Bottom)
494        );
495    }
496
497    #[test]
498    fn no_snap_when_far_from_edge() {
499        assert_eq!(detect_edge_snap(40, 12, area(), 2), None);
500    }
501
502    #[test]
503    fn edge_resistance_snaps_to_left_edge() {
504        let mut er = EdgeResistance::default_tui();
505        let bounds = LayoutRect {
506            x: 0,
507            y: 0,
508            width: 80,
509            height: 24,
510        };
511        assert_eq!(er.apply_x(2, bounds, 0), 0);
512    }
513
514    #[test]
515    fn edge_resistance_snaps_to_right_edge() {
516        let mut er = EdgeResistance::default_tui();
517        let bounds = LayoutRect {
518            x: 0,
519            y: 0,
520            width: 80,
521            height: 24,
522        };
523        assert_eq!(er.apply_x(78, bounds, 0), 79);
524    }
525
526    #[test]
527    fn edge_resistance_passes_through_middle() {
528        let mut er = EdgeResistance::default_tui();
529        let bounds = LayoutRect {
530            x: 0,
531            y: 0,
532            width: 80,
533            height: 24,
534        };
535        assert_eq!(er.apply_x(40, bounds, 0), 40);
536    }
537
538    #[test]
539    fn edge_resistance_does_not_snap_y_to_top() {
540        let mut er = EdgeResistance::default_tui();
541        let bounds = LayoutRect {
542            x: 0,
543            y: 0,
544            width: 80,
545            height: 24,
546        };
547        // Y-axis low snap (title-bar area) is intentionally disabled
548        assert_eq!(er.apply_y(2, bounds, 0), 2);
549    }
550
551    #[test]
552    fn edge_resistance_snaps_y_to_bottom() {
553        let mut er = EdgeResistance::default_tui();
554        let bounds = LayoutRect {
555            x: 0,
556            y: 0,
557            width: 80,
558            height: 24,
559        };
560        assert_eq!(er.apply_y(22, bounds, 0), 23);
561    }
562
563    #[test]
564    fn temporal_resistance_breaks_after_threshold() {
565        let mut er = EdgeResistance::default_tui();
566        let bounds = LayoutRect {
567            x: 0,
568            y: 0,
569            width: 80,
570            height: 24,
571        };
572        // First call: enters magnetic zone
573        assert_eq!(er.apply_x(2, bounds, 0), 0);
574        // Still within threshold — should stay snapped
575        assert_eq!(er.apply_x(5, bounds, 100_000_000), 0);
576        // After 500ms threshold — should break free
577        assert_eq!(er.apply_x(5, bounds, 600_000_000), 5);
578    }
579
580    #[test]
581    fn corner_snap_top_left() {
582        let a = area();
583        assert_eq!(
584            detect_corner_snap(0, 0, a, 2),
585            Some(InsertPosition::TopLeft)
586        );
587        assert_eq!(
588            detect_corner_snap(1, 0, a, 2),
589            Some(InsertPosition::TopLeft)
590        );
591        assert_eq!(
592            detect_corner_snap(0, 1, a, 2),
593            Some(InsertPosition::TopLeft)
594        );
595    }
596
597    #[test]
598    fn corner_snap_top_right() {
599        let a = area();
600        assert_eq!(
601            detect_corner_snap(79, 0, a, 2),
602            Some(InsertPosition::TopRight)
603        );
604        assert_eq!(
605            detect_corner_snap(78, 0, a, 2),
606            Some(InsertPosition::TopRight)
607        );
608    }
609
610    #[test]
611    fn corner_snap_bottom_left() {
612        let a = area();
613        assert_eq!(
614            detect_corner_snap(0, 23, a, 2),
615            Some(InsertPosition::BottomLeft)
616        );
617        assert_eq!(
618            detect_corner_snap(1, 23, a, 2),
619            Some(InsertPosition::BottomLeft)
620        );
621    }
622
623    #[test]
624    fn corner_snap_bottom_right() {
625        let a = area();
626        assert_eq!(
627            detect_corner_snap(79, 23, a, 2),
628            Some(InsertPosition::BottomRight)
629        );
630    }
631
632    #[test]
633    fn corner_snap_none_when_far() {
634        let a = area();
635        assert_eq!(detect_corner_snap(40, 12, a, 2), None);
636    }
637
638    #[test]
639    fn edge_preview_left() {
640        let p = edge_preview_rect(area(), InsertPosition::Left);
641        assert_eq!(p.width, 40);
642        assert_eq!(p.x, 0);
643    }
644
645    #[test]
646    fn edge_preview_right() {
647        let p = edge_preview_rect(area(), InsertPosition::Right);
648        assert_eq!(p.width, 40);
649        assert_eq!(p.x, 40);
650    }
651
652    #[test]
653    fn edge_preview_top() {
654        let p = edge_preview_rect(area(), InsertPosition::Top);
655        assert_eq!(p.height, 12);
656        assert_eq!(p.y, 0);
657    }
658
659    #[test]
660    fn edge_preview_bottom() {
661        let p = edge_preview_rect(area(), InsertPosition::Bottom);
662        assert_eq!(p.height, 12);
663        assert_eq!(p.y, 12);
664    }
665
666    #[test]
667    fn tiled_preview_rect_left() {
668        let target = LayoutRect {
669            x: 10,
670            y: 10,
671            width: 60,
672            height: 20,
673        };
674        let p = tiled_preview_rect(target, InsertPosition::Left);
675        assert_eq!(p.width, 30);
676        assert_eq!(p.x, 10);
677    }
678
679    #[test]
680    fn inserting_position_to_orientation() {
681        assert_eq!(
682            InsertPosition::Left.to_orientation(),
683            Orientation::Horizontal
684        );
685        assert_eq!(InsertPosition::Top.to_orientation(), Orientation::Vertical);
686    }
687}