Skip to main content

pandora_kit/
drag.rs

1use ratatui::layout::Rect;
2
3/// Minimum columns the popup must retain when dragged to an edge.
4const MIN_VISIBLE_W: u16 = 20;
5
6/// Minimum rows the popup must retain when dragged to an edge.
7const MIN_VISIBLE_H: u16 = 5;
8
9/// Minimum popup width when resizing.
10const MIN_RESIZE_W: u16 = 20;
11
12/// Minimum popup height when resizing.
13const MIN_RESIZE_H: u16 = 5;
14
15/// Which edges of the popup are being resized.
16#[derive(Clone, Copy, Debug, Default, PartialEq)]
17pub struct EdgeSet {
18    pub left: bool,
19    pub right: bool,
20    pub top: bool,
21    pub bottom: bool,
22}
23
24/// The interactive zone at a given screen position.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub enum Zone {
27    None,
28    Move,
29    Resize(EdgeSet),
30}
31
32/// Result of a drag update.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub enum DragUpdate {
35    None,
36    Moved { x: u16, y: u16 },
37    Resized { x: u16, y: u16, w: u16, h: u16 },
38}
39
40// ── Geometry helpers ──
41
42pub fn contains(rect: Rect, col: u16, row: u16) -> bool {
43    col >= rect.x
44        && col < rect.x + rect.width
45        && row >= rect.y
46        && row < rect.y + rect.height
47}
48
49pub fn is_on_border(popup: Rect, col: u16, row: u16) -> bool {
50    if !contains(popup, col, row) {
51        return false;
52    }
53    let inner = Rect {
54        x: popup.x + 1,
55        y: popup.y + 1,
56        width: popup.width.saturating_sub(2),
57        height: popup.height.saturating_sub(2),
58    };
59    !contains(inner, col, row)
60}
61
62/// Default zone detection for standard popups:
63/// - Border → Resize (with appropriate edges)
64/// - Header (rows y+1 to y+3) → Move
65/// - Else → None
66pub fn default_zone_at(popup: Rect, col: u16, row: u16) -> Zone {
67    if !contains(popup, col, row) {
68        return Zone::None;
69    }
70
71    if is_on_border(popup, col, row) {
72        let mut edges = EdgeSet { left: false, right: false, top: false, bottom: false };
73        if row == popup.y { edges.top = true; }
74        if row == popup.y + popup.height - 1 { edges.bottom = true; }
75        if col == popup.x { edges.left = true; }
76        if col == popup.x + popup.width - 1 { edges.right = true; }
77        return Zone::Resize(edges);
78    }
79
80    // Header area (title + divider line)
81    let header_top = popup.y + 1;
82    let header_bottom = header_top + 2;
83    if row >= header_top && row < header_bottom {
84        return Zone::Move;
85    }
86
87    Zone::None
88}
89
90// ── Drag state machine ──
91
92enum DragMode {
93    Idle,
94    Move,
95    Resize(EdgeSet),
96}
97
98impl Default for DragMode {
99    fn default() -> Self {
100        Self::Idle
101    }
102}
103
104/// State for tracking a popup drag or resize operation.
105#[derive(Default)]
106pub struct DragState {
107    cursor_offset: (u16, u16),
108    start_popup: Rect,
109    mode: DragMode,
110}
111
112impl DragState {
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Start a drag based on the zone at the click position.
118    pub fn begin(&mut self, popup: Rect, col: u16, row: u16, zone: Zone) {
119        match zone {
120            Zone::None => {}
121            Zone::Move => {
122                let dx = col.saturating_sub(popup.x);
123                let dy = row.saturating_sub(popup.y);
124                self.cursor_offset = (dx, dy);
125                self.start_popup = popup;
126                self.mode = DragMode::Move;
127            }
128            Zone::Resize(edges) => {
129                self.cursor_offset = (col, row);
130                self.start_popup = popup;
131                self.mode = DragMode::Resize(edges);
132            }
133        }
134    }
135
136    /// Update the drag and return the new popup state.
137    /// Returns `None`/`DragUpdate::None` if no drag is in progress.
138    pub fn update(&self, area: Rect, col: u16, row: u16) -> DragUpdate {
139        match &self.mode {
140            DragMode::Idle => DragUpdate::None,
141            DragMode::Move => {
142                let (dx, dy) = self.cursor_offset;
143                let max_w = area.width.saturating_sub(MIN_VISIBLE_W);
144                let max_h = area.height.saturating_sub(MIN_VISIBLE_H);
145                let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
146                let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
147                DragUpdate::Moved { x: nx, y: ny }
148            }
149            DragMode::Resize(edges) => {
150                let p = self.start_popup;
151                let mut x = p.x;
152                let mut y = p.y;
153                let mut w = p.width;
154                let mut h = p.height;
155
156                if edges.right {
157                    w = col.saturating_sub(p.x).max(MIN_RESIZE_W);
158                }
159                if edges.bottom {
160                    h = row.saturating_sub(p.y).max(MIN_RESIZE_H);
161                }
162                if edges.left {
163                    let right = p.x + p.width;
164                    let new_x = col.min(right.saturating_sub(MIN_RESIZE_W));
165                    w = right.saturating_sub(new_x);
166                    x = new_x;
167                }
168                if edges.top {
169                    let bottom = p.y + p.height;
170                    let new_y = row.min(bottom.saturating_sub(MIN_RESIZE_H));
171                    h = bottom.saturating_sub(new_y);
172                    y = new_y;
173                }
174
175                DragUpdate::Resized { x, y, w, h }
176            }
177        }
178    }
179
180    /// Whether a drag is in progress.
181    pub fn is_dragging(&self) -> bool {
182        !matches!(self.mode, DragMode::Idle)
183    }
184
185    /// End the drag (called on mouse up).
186    pub fn end(&mut self) {
187        self.mode = DragMode::Idle;
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    // ── contains / is_on_border / default_zone_at ──
196
197    #[test]
198    fn contains_inside() {
199        let r = Rect::new(10, 5, 20, 10);
200        assert!(contains(r, 15, 8));
201    }
202
203    #[test]
204    fn contains_outside() {
205        let r = Rect::new(10, 5, 20, 10);
206        assert!(!contains(r, 5, 8));
207        assert!(!contains(r, 35, 8));
208    }
209
210    #[test]
211    fn contains_excludes_right_and_bottom_edge() {
212        let r = Rect::new(10, 5, 20, 10);
213        assert!(!contains(r, 30, 8));
214        assert!(!contains(r, 15, 15));
215    }
216
217    #[test]
218    fn is_on_border_top_edge() {
219        let r = Rect::new(10, 5, 20, 10);
220        assert!(is_on_border(r, 15, 5));
221    }
222
223    #[test]
224    fn is_on_border_left_edge() {
225        let r = Rect::new(10, 5, 20, 10);
226        assert!(is_on_border(r, 10, 8));
227    }
228
229    #[test]
230    fn is_on_border_inside() {
231        let r = Rect::new(10, 5, 20, 10);
232        assert!(!is_on_border(r, 15, 8));
233    }
234
235    #[test]
236    fn default_zone_at_header_is_move() {
237        let r = Rect::new(0, 0, 60, 20);
238        assert_eq!(default_zone_at(r, 5, 1), Zone::Move);
239    }
240
241    #[test]
242    fn default_zone_at_border_is_resize() {
243        let r = Rect::new(0, 0, 60, 20);
244        assert!(matches!(default_zone_at(r, 0, 0), Zone::Resize(_)));
245    }
246
247    #[test]
248    fn default_zone_at_body_is_none() {
249        let r = Rect::new(0, 0, 60, 20);
250        assert_eq!(default_zone_at(r, 30, 10), Zone::None);
251    }
252
253    #[test]
254    fn default_zone_at_outside_is_none() {
255        let r = Rect::new(0, 0, 60, 20);
256        assert_eq!(default_zone_at(r, 100, 5), Zone::None);
257    }
258
259    #[test]
260    fn default_zone_at_right_edge_is_resize_right() {
261        let r = Rect::new(10, 5, 40, 30);
262        let zone = default_zone_at(r, 49, 15);
263        assert!(matches!(zone, Zone::Resize(e) if e.right && !e.left && !e.top && !e.bottom));
264    }
265
266    #[test]
267    fn default_zone_at_bottom_edge_is_resize_bottom() {
268        let r = Rect::new(10, 5, 40, 30);
269        let zone = default_zone_at(r, 20, 34);
270        assert!(matches!(zone, Zone::Resize(e) if e.bottom && !e.top && !e.left && !e.right));
271    }
272
273    #[test]
274    fn default_zone_at_top_left_corner_is_resize_both() {
275        let r = Rect::new(10, 5, 40, 30);
276        let zone = default_zone_at(r, 10, 5);
277        assert!(matches!(zone, Zone::Resize(e) if e.top && e.left));
278    }
279
280    // ── begin ──
281
282    #[test]
283    fn begin_move_sets_offset() {
284        let popup = Rect::new(10, 5, 40, 30);
285        let mut drag = DragState::new();
286        drag.begin(popup, 15, 10, Zone::Move);
287        assert!(drag.is_dragging());
288    }
289
290    #[test]
291    fn begin_none_does_not_drag() {
292        let popup = Rect::new(10, 5, 40, 30);
293        let mut drag = DragState::new();
294        drag.begin(popup, 15, 10, Zone::None);
295        assert!(!drag.is_dragging());
296    }
297
298    #[test]
299    fn begin_move_computes_correct_offset() {
300        let popup = Rect::new(10, 5, 40, 30);
301        let mut drag = DragState::new();
302        drag.begin(popup, 25, 15, Zone::Move);
303        let result = drag.update(Rect::new(0, 0, 100, 50), 25, 15);
304        assert_eq!(result, DragUpdate::Moved { x: 10, y: 5 });
305    }
306
307    #[test]
308    fn begin_resize_starts_dragging() {
309        let popup = Rect::new(10, 5, 40, 30);
310        let mut drag = DragState::new();
311        drag.begin(popup, 49, 15, default_zone_at(popup, 49, 15));
312        assert!(drag.is_dragging());
313    }
314
315    // ── update (move) ──
316
317    #[test]
318    fn update_returns_none_when_not_dragging() {
319        let drag = DragState::new();
320        assert_eq!(drag.update(Rect::new(0, 0, 100, 50), 50, 25), DragUpdate::None);
321    }
322
323    #[test]
324    fn update_move_returns_origin() {
325        let popup = Rect::new(10, 5, 40, 30);
326        let mut drag = DragState::new();
327        drag.begin(popup, 15, 10, Zone::Move);
328        let result = drag.update(Rect::new(0, 0, 100, 50), 40, 30);
329        assert_eq!(result, DragUpdate::Moved { x: 35, y: 25 });
330    }
331
332    #[test]
333    fn update_move_clamps_nx_to_area_width_minus_min_w() {
334        let popup = Rect::new(10, 5, 40, 30);
335        let mut drag = DragState::new();
336        drag.begin(popup, 15, 10, Zone::Move);
337        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 30);
338        if let DragUpdate::Moved { x, .. } = result {
339            assert!(x <= 80);
340        } else {
341            panic!("expected Move");
342        }
343    }
344
345    #[test]
346    fn update_move_clamps_ny_to_area_height_minus_min_h() {
347        let popup = Rect::new(10, 5, 40, 30);
348        let mut drag = DragState::new();
349        drag.begin(popup, 15, 10, Zone::Move);
350        let result = drag.update(Rect::new(0, 0, 100, 50), 30, 999);
351        if let DragUpdate::Moved { y, .. } = result {
352            assert!(y <= 45);
353        } else {
354            panic!("expected Move");
355        }
356    }
357
358    #[test]
359    fn update_move_keeps_minimum_visible_size() {
360        let popup = Rect::new(10, 5, 40, 30);
361        let mut drag = DragState::new();
362        drag.begin(popup, 15, 10, Zone::Move);
363        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 999);
364        assert_eq!(result, DragUpdate::Moved { x: 80, y: 45 });
365    }
366
367    #[test]
368    fn update_move_clamps_to_zero_when_negative() {
369        let popup = Rect::new(10, 5, 40, 30);
370        let mut drag = DragState::new();
371        drag.begin(popup, 15, 10, Zone::Move);
372        let result = drag.update(Rect::new(0, 0, 100, 50), 0, 0);
373        assert_eq!(result, DragUpdate::Moved { x: 0, y: 0 });
374    }
375
376    #[test]
377    fn update_move_allows_drag_below_middle() {
378        let popup = Rect::new(0, 0, 40, 35);
379        let mut drag = DragState::new();
380        drag.begin(popup, 5, 1, Zone::Move);
381        let result = drag.update(Rect::new(0, 0, 100, 50), 5, 26);
382        if let DragUpdate::Moved { y, .. } = result {
383            assert!(y >= 25, "ny should reach middle of screen, got ny={}", y);
384        } else {
385            panic!("expected Move");
386        }
387    }
388
389    // ── update (resize) ──
390
391    #[test]
392    fn update_resize_right_increases_width() {
393        let popup = Rect::new(10, 5, 40, 30);
394        let mut drag = DragState::new();
395        drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
396        let result = drag.update(Rect::new(0, 0, 100, 50), 60, 15);
397        assert_eq!(result, DragUpdate::Resized { x: 10, y: 5, w: 50, h: 30 });
398    }
399
400    #[test]
401    fn update_resize_bottom_increases_height() {
402        let popup = Rect::new(10, 5, 40, 30);
403        let mut drag = DragState::new();
404        drag.begin(popup, 20, 34, Zone::Resize(EdgeSet { left: false, right: false, top: false, bottom: true }));
405        let result = drag.update(Rect::new(0, 0, 100, 50), 20, 40);
406        assert_eq!(result, DragUpdate::Resized { x: 10, y: 5, w: 40, h: 35 });
407    }
408
409    #[test]
410    fn update_resize_left_moves_and_shrinks() {
411        let popup = Rect::new(20, 5, 40, 30);
412        let mut drag = DragState::new();
413        drag.begin(popup, 20, 15, Zone::Resize(EdgeSet { left: true, right: false, top: false, bottom: false }));
414        let result = drag.update(Rect::new(0, 0, 100, 50), 30, 15);
415        // left edge moved right → smaller width, x changes
416        if let DragUpdate::Resized { x, w, .. } = result {
417            assert_eq!(x, 30);
418            assert!(w <= 30);
419        } else {
420            panic!("expected Resized");
421        }
422    }
423
424    #[test]
425    fn update_resize_enforces_min_width() {
426        let popup = Rect::new(10, 5, 40, 30);
427        let mut drag = DragState::new();
428        drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
429        let result = drag.update(Rect::new(0, 0, 100, 50), 20, 15);
430        if let DragUpdate::Resized { w, .. } = result {
431            assert!(w >= MIN_RESIZE_W);
432        } else {
433            panic!("expected Resized");
434        }
435    }
436
437    // ── multi-step resize ──
438
439    fn unpack_resized(result: DragUpdate) -> Rect {
440        match result {
441            DragUpdate::Resized { x, y, w, h } => Rect::new(x, y, w, h),
442            _ => panic!("expected Resized"),
443        }
444    }
445
446    #[test]
447    fn resize_right_then_right_again() {
448        let area = Rect::new(0, 0, 100, 50);
449        let popup = Rect::new(10, 5, 40, 30);
450
451        // First drag: resize right to w=50
452        let mut drag = DragState::new();
453        drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
454        let r1 = unpack_resized(drag.update(area, 60, 15));
455        assert_eq!(r1, Rect::new(10, 5, 50, 30));
456        drag.end();
457
458        // Second drag: resize right from new rect to w=55
459        drag.begin(r1, r1.x + r1.width - 1, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
460        let r2 = unpack_resized(drag.update(area, 65, 15));
461        assert_eq!(r2, Rect::new(10, 5, 55, 30));
462
463        // Right edge should be at 10+50=60, now at 10+55=65
464        assert_eq!(r2.x + r2.width, 65);
465    }
466
467    #[test]
468    fn resize_right_then_left_keeps_right_edge() {
469        let area = Rect::new(0, 0, 100, 50);
470        let popup = Rect::new(10, 5, 40, 30);
471        // First drag: resize right to w=50
472        let mut drag = DragState::new();
473        drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
474        let r1 = unpack_resized(drag.update(area, 60, 15));
475        assert_eq!(r1, Rect::new(10, 5, 50, 30));
476        drag.end();
477
478        // The right edge is now at 60
479        let new_right = r1.x + r1.width; // 60
480
481        // Second drag: resize left edge to col=5
482        // Left click at x=10, drag to x=5
483        drag.begin(r1, r1.x, 15, Zone::Resize(EdgeSet { left: true, ..Default::default() }));
484        let r2 = unpack_resized(drag.update(area, 5, 15));
485        // x should be 5, width should be 60-5=55
486        assert_eq!(r2.x, 5);
487        assert_eq!(r2.x + r2.width, new_right); // right edge preserved
488    }
489
490    #[test]
491    fn resize_top_then_bottom_keeps_bottom_edge() {
492        let area = Rect::new(0, 0, 100, 50);
493        let popup = Rect::new(10, 5, 40, 30);
494        let bottom_edge = popup.y + popup.height; // 35
495
496        // First drag: resize top to row=10
497        let mut drag = DragState::new();
498        drag.begin(popup, 15, 5, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
499        let r1 = unpack_resized(drag.update(area, 15, 10));
500        // y=10, h=35-10=25, bottom still at 35
501        assert_eq!(r1, Rect::new(10, 10, 40, 25));
502        assert_eq!(r1.y + r1.height, bottom_edge);
503        drag.end();
504
505        // Second drag: resize bottom from row=34 to row=40
506        drag.begin(r1, 15, 34, Zone::Resize(EdgeSet { bottom: true, ..Default::default() }));
507        let r2 = unpack_resized(drag.update(area, 15, 40));
508        // y=10, h=40-10=30
509        assert_eq!(r2, Rect::new(10, 10, 40, 30));
510        assert_eq!(r2.y + r2.height, 40);
511    }
512
513    #[test]
514    fn resize_top_then_right_independent_axes() {
515        let area = Rect::new(0, 0, 100, 50);
516        let popup = Rect::new(10, 5, 40, 30);
517
518        // First drag: resize top to row=10
519        let mut drag = DragState::new();
520        drag.begin(popup, 15, 5, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
521        let r1 = unpack_resized(drag.update(area, 15, 10));
522        assert_eq!(r1, Rect::new(10, 10, 40, 25));
523        drag.end();
524
525        // Second drag: resize right to col=70
526        drag.begin(r1, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
527        let r2 = unpack_resized(drag.update(area, 70, 15));
528        // y=10 (unchanged), w=70-10=60
529        assert_eq!(r2, Rect::new(10, 10, 60, 25));
530    }
531
532    #[test]
533    fn resize_left_then_top_corner_sequence() {
534        let area = Rect::new(0, 0, 100, 50);
535        let popup = Rect::new(20, 10, 40, 30);
536
537        // First: resize left edge to col=10, makes popup wider
538        let mut drag = DragState::new();
539        drag.begin(popup, 20, 15, Zone::Resize(EdgeSet { left: true, ..Default::default() }));
540        let r1 = unpack_resized(drag.update(area, 10, 15));
541        // x=10, w=60-10=50 (right edge at 20+40=60)
542        assert_eq!(r1, Rect::new(10, 10, 50, 30));
543        drag.end();
544
545        // Second: resize top to row=15
546        drag.begin(r1, 15, 10, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
547        let r2 = unpack_resized(drag.update(area, 15, 15));
548        // y=15, h=40-15=25 (bottom edge at 10+30=40)
549        assert_eq!(r2, Rect::new(10, 15, 50, 25));
550    }
551
552    #[test]
553    fn resize_single_drag_back_and_forth() {
554        let area = Rect::new(0, 0, 100, 50);
555        let popup = Rect::new(10, 5, 40, 30);
556
557        let mut drag = DragState::new();
558        drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
559
560        // Drag wide to w=60
561        let r1 = unpack_resized(drag.update(area, 70, 15));
562        assert_eq!(r1, Rect::new(10, 5, 60, 30));
563
564        // Drag back narrower to w=45 (same drag, mouse moved left)
565        let r2 = unpack_resized(drag.update(area, 55, 15));
566        assert_eq!(r2, Rect::new(10, 5, 45, 30));
567
568        // Drag wide again to w=55
569        let r3 = unpack_resized(drag.update(area, 65, 15));
570        assert_eq!(r3, Rect::new(10, 5, 55, 30));
571    }
572
573    // ── end ──
574
575    #[test]
576    fn end_clears_state() {
577        let popup = Rect::new(10, 5, 40, 30);
578        let mut drag = DragState::new();
579        drag.begin(popup, 15, 10, Zone::Move);
580        assert!(drag.is_dragging());
581        drag.end();
582        assert!(!drag.is_dragging());
583        assert_eq!(drag.update(Rect::new(0, 0, 100, 50), 40, 30), DragUpdate::None);
584    }
585
586    // ── is_dragging ──
587
588    #[test]
589    fn is_dragging_false_by_default() {
590        let drag = DragState::new();
591        assert!(!drag.is_dragging());
592    }
593
594    #[test]
595    fn is_dragging_true_after_move_begin() {
596        let popup = Rect::new(10, 5, 40, 30);
597        let mut drag = DragState::new();
598        drag.begin(popup, 15, 10, Zone::Move);
599        assert!(drag.is_dragging());
600    }
601
602    #[test]
603    fn is_dragging_true_after_resize_begin() {
604        let popup = Rect::new(10, 5, 40, 30);
605        let mut drag = DragState::new();
606        drag.begin(popup, 15, 10, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
607        assert!(drag.is_dragging());
608    }
609
610    #[test]
611    fn is_dragging_false_after_end() {
612        let popup = Rect::new(10, 5, 40, 30);
613        let mut drag = DragState::new();
614        drag.begin(popup, 15, 10, Zone::Move);
615        drag.end();
616        assert!(!drag.is_dragging());
617    }
618}