1use ratatui::layout::Rect;
2
3const MIN_VISIBLE_W: u16 = 20;
5
6const MIN_VISIBLE_H: u16 = 5;
8
9const MIN_RESIZE_W: u16 = 20;
11
12const MIN_RESIZE_H: u16 = 5;
14
15#[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#[derive(Clone, Copy, Debug, PartialEq)]
26pub enum Zone {
27 None,
28 Move,
29 Resize(EdgeSet),
30}
31
32#[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
40pub 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
62pub 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 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
90enum 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#[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 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 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 pub fn is_dragging(&self) -> bool {
182 !matches!(self.mode, DragMode::Idle)
183 }
184
185 pub fn end(&mut self) {
187 self.mode = DragMode::Idle;
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[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 #[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 #[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 #[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 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 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 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 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 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 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 let new_right = r1.x + r1.width; drag.begin(r1, r1.x, 15, Zone::Resize(EdgeSet { left: true, ..Default::default() }));
484 let r2 = unpack_resized(drag.update(area, 5, 15));
485 assert_eq!(r2.x, 5);
487 assert_eq!(r2.x + r2.width, new_right); }
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; 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 assert_eq!(r1, Rect::new(10, 10, 40, 25));
502 assert_eq!(r1.y + r1.height, bottom_edge);
503 drag.end();
504
505 drag.begin(r1, 15, 34, Zone::Resize(EdgeSet { bottom: true, ..Default::default() }));
507 let r2 = unpack_resized(drag.update(area, 15, 40));
508 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 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 drag.begin(r1, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
527 let r2 = unpack_resized(drag.update(area, 70, 15));
528 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 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 assert_eq!(r1, Rect::new(10, 10, 50, 30));
543 drag.end();
544
545 drag.begin(r1, 15, 10, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
547 let r2 = unpack_resized(drag.update(area, 15, 15));
548 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 let r1 = unpack_resized(drag.update(area, 70, 15));
562 assert_eq!(r1, Rect::new(10, 5, 60, 30));
563
564 let r2 = unpack_resized(drag.update(area, 55, 15));
566 assert_eq!(r2, Rect::new(10, 5, 45, 30));
567
568 let r3 = unpack_resized(drag.update(area, 65, 15));
570 assert_eq!(r3, Rect::new(10, 5, 55, 30));
571 }
572
573 #[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 #[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}