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/// State for tracking a popup drag operation.
10///
11/// Encapsulates the begin/drag/end lifecycle and clamps the new position
12/// so the popup keeps at least `MIN_VISIBLE_W` columns and
13/// `MIN_VISIBLE_H` rows on-screen (the widget's `resolve_rect` further
14/// shrinks the popup to fit fully within the area).
15///
16/// Also tracks whether the user has ever dragged from the border.
17/// Once a border drag is detected, the header
18/// area becomes draggable too (progressive disclosure).
19#[derive(Default)]
20pub struct DragState {
21    offset: Option<(u16, u16)>,
22    border_dragged_once: bool,
23}
24
25impl DragState {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Start a drag if `in_drag_area` is true.
31    ///
32    /// * `on_border` — whether the click was on the border (not the header).
33    ///   Only border clicks enable header drag for later interactions.
34    /// * `in_drag_area` — whether the click is inside any drag zone (border
35    ///   or header, depending on `header_drag_enabled()`).
36    pub fn begin(&mut self, popup: Rect, col: u16, row: u16, on_border: bool, in_drag_area: bool) {
37        if in_drag_area {
38            self.offset = Some((col.saturating_sub(popup.x), row.saturating_sub(popup.y)));
39            if on_border {
40                self.border_dragged_once = true;
41            }
42        }
43    }
44
45    /// Whether the header area is draggable.
46    ///
47    /// Returns `true` once the user has dragged from the border at least
48    /// once during this session.
49    pub fn header_drag_enabled(&self) -> bool {
50        self.border_dragged_once
51    }
52
53    /// Update the drag and return the new popup origin `(x, y)`.
54    /// Returns `None` if no drag is in progress.
55    ///
56    /// The origin is clamped so at least `MIN_VISIBLE_W` columns and
57    /// `MIN_VISIBLE_H` rows of the popup always remain on-screen.
58    pub fn update(&self, area: Rect, col: u16, row: u16) -> Option<(u16, u16)> {
59        let (dx, dy) = self.offset?;
60        let max_w = area.width.saturating_sub(MIN_VISIBLE_W);
61        let max_h = area.height.saturating_sub(MIN_VISIBLE_H);
62        let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
63        let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
64        Some((nx, ny))
65    }
66
67    /// End the drag (called on mouse up).
68    pub fn end(&mut self) {
69        self.offset = None;
70    }
71
72    /// Whether a drag is in progress.
73    pub fn is_dragging(&self) -> bool {
74        self.offset.is_some()
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    // ── begin ──
83
84    #[test]
85    fn begin_sets_offset_when_in_drag_area() {
86        let popup = Rect::new(10, 5, 40, 30);
87        let mut drag = DragState::new();
88        drag.begin(popup, 15, 10, false, true);
89        assert!(drag.is_dragging());
90    }
91
92    #[test]
93    fn begin_skips_offset_when_not_in_drag_area() {
94        let popup = Rect::new(10, 5, 40, 30);
95        let mut drag = DragState::new();
96        drag.begin(popup, 15, 10, false, false);
97        assert!(!drag.is_dragging());
98    }
99
100    #[test]
101    fn begin_computes_correct_offset() {
102        let popup = Rect::new(10, 5, 40, 30);
103        let mut drag = DragState::new();
104        drag.begin(popup, 25, 15, false, true);
105        let result = drag.update(Rect::new(0, 0, 100, 50), 25, 15);
106        assert_eq!(result, Some((10, 5)));
107    }
108
109    #[test]
110    fn begin_border_drag_enables_header() {
111        let popup = Rect::new(10, 5, 40, 30);
112        let mut drag = DragState::new();
113        assert!(!drag.header_drag_enabled());
114        drag.begin(popup, 12, 6, true, true);
115        assert!(drag.header_drag_enabled());
116    }
117
118    #[test]
119    fn begin_header_drag_does_not_enable_header() {
120        let popup = Rect::new(10, 5, 40, 30);
121        let mut drag = DragState::new();
122        // header click (on_border = false) before any border drag
123        drag.begin(popup, 15, 7, false, false);
124        assert!(!drag.header_drag_enabled());
125    }
126
127    #[test]
128    fn begin_does_not_need_to_be_in_drag_area_to_enable_header() {
129        let popup = Rect::new(10, 5, 40, 30);
130        let mut drag = DragState::new();
131        // click on border but NOT in drag area — still not a real drag,
132        // but the border flag should not get set because in_drag_area is false
133        drag.begin(popup, 12, 6, true, false);
134        assert!(!drag.header_drag_enabled());
135    }
136
137    // ── header_drag_enabled ──
138
139    #[test]
140    fn header_drag_enabled_false_by_default() {
141        let drag = DragState::new();
142        assert!(!drag.header_drag_enabled());
143    }
144
145    #[test]
146    fn header_drag_enabled_true_after_border_drag() {
147        let popup = Rect::new(10, 5, 40, 30);
148        let mut drag = DragState::new();
149        drag.begin(popup, 12, 6, true, true);
150        assert!(drag.header_drag_enabled());
151    }
152
153    #[test]
154    fn header_drag_enabled_stays_true_after_end() {
155        let popup = Rect::new(10, 5, 40, 30);
156        let mut drag = DragState::new();
157        drag.begin(popup, 12, 6, true, true);
158        drag.end();
159        assert!(drag.header_drag_enabled());
160    }
161
162    #[test]
163    fn header_drag_enabled_stays_true_after_header_drag() {
164        let popup = Rect::new(10, 5, 40, 30);
165        let mut drag = DragState::new();
166        // border drag first to enable header
167        drag.begin(popup, 12, 6, true, true);
168        // header drag (on_border = false) — flag stays true
169        drag.begin(popup, 15, 7, false, true);
170        assert!(drag.header_drag_enabled());
171    }
172
173    // ── update ──
174
175    #[test]
176    fn update_returns_none_when_not_dragging() {
177        let drag = DragState::new();
178        assert!(drag.update(Rect::new(0, 0, 100, 50), 50, 25).is_none());
179    }
180
181    #[test]
182    fn update_returns_origin() {
183        let popup = Rect::new(10, 5, 40, 30);
184        let mut drag = DragState::new();
185        drag.begin(popup, 15, 10, false, true);
186        let result = drag.update(Rect::new(0, 0, 100, 50), 40, 30);
187        assert_eq!(result, Some((35, 25)));
188    }
189
190    #[test]
191    fn update_clamps_nx_to_area_width_minus_min_w() {
192        let popup = Rect::new(10, 5, 40, 30);
193        let mut drag = DragState::new();
194        drag.begin(popup, 15, 10, false, true);
195        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 30);
196        let (nx, _) = result.unwrap();
197        assert!(nx <= 80);
198    }
199
200    #[test]
201    fn update_clamps_ny_to_area_height_minus_min_h() {
202        let popup = Rect::new(10, 5, 40, 30);
203        let mut drag = DragState::new();
204        drag.begin(popup, 15, 10, false, true);
205        let result = drag.update(Rect::new(0, 0, 100, 50), 30, 999);
206        let (_, ny) = result.unwrap();
207        assert!(ny <= 45);
208    }
209
210    #[test]
211    fn update_keeps_minimum_visible_size() {
212        let popup = Rect::new(10, 5, 40, 30);
213        let mut drag = DragState::new();
214        drag.begin(popup, 15, 10, false, true);
215        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 999);
216        let (nx, ny) = result.unwrap();
217        assert_eq!(nx, 80);
218        assert_eq!(ny, 45);
219    }
220
221    #[test]
222    fn update_clamps_to_zero_when_negative() {
223        let popup = Rect::new(10, 5, 40, 30);
224        let mut drag = DragState::new();
225        drag.begin(popup, 15, 10, false, true);
226        let result = drag.update(Rect::new(0, 0, 100, 50), 0, 0);
227        assert_eq!(result, Some((0, 0)));
228    }
229
230    #[test]
231    fn update_allows_drag_below_middle() {
232        let popup = Rect::new(0, 0, 40, 35);
233        let mut drag = DragState::new();
234        drag.begin(popup, 5, 1, false, true);
235        let result = drag.update(Rect::new(0, 0, 100, 50), 5, 26);
236        let (_, ny) = result.unwrap();
237        assert!(ny >= 25, "ny should reach middle of screen, got ny={}", ny);
238    }
239
240    // ── end ──
241
242    #[test]
243    fn end_clears_state() {
244        let popup = Rect::new(10, 5, 40, 30);
245        let mut drag = DragState::new();
246        drag.begin(popup, 15, 10, false, true);
247        assert!(drag.is_dragging());
248        drag.end();
249        assert!(!drag.is_dragging());
250        assert!(drag.update(Rect::new(0, 0, 100, 50), 40, 30).is_none());
251    }
252
253    // ── is_dragging ──
254
255    #[test]
256    fn is_dragging_false_by_default() {
257        let drag = DragState::new();
258        assert!(!drag.is_dragging());
259    }
260
261    #[test]
262    fn is_dragging_true_after_begin() {
263        let popup = Rect::new(10, 5, 40, 30);
264        let mut drag = DragState::new();
265        drag.begin(popup, 15, 10, false, true);
266        assert!(drag.is_dragging());
267    }
268
269    #[test]
270    fn is_dragging_false_after_end() {
271        let popup = Rect::new(10, 5, 40, 30);
272        let mut drag = DragState::new();
273        drag.begin(popup, 15, 10, false, true);
274        drag.end();
275        assert!(!drag.is_dragging());
276    }
277}