use ratatui::layout::Rect;
const MIN_VISIBLE_W: u16 = 20;
const MIN_VISIBLE_H: u16 = 5;
const MIN_RESIZE_W: u16 = 20;
const MIN_RESIZE_H: u16 = 5;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct EdgeSet {
pub left: bool,
pub right: bool,
pub top: bool,
pub bottom: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Zone {
None,
Move,
Resize(EdgeSet),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DragUpdate {
None,
Moved { x: u16, y: u16 },
Resized { x: u16, y: u16, w: u16, h: u16 },
}
pub fn contains(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
pub fn is_on_border(popup: Rect, col: u16, row: u16) -> bool {
if !contains(popup, col, row) {
return false;
}
let inner = Rect {
x: popup.x + 1,
y: popup.y + 1,
width: popup.width.saturating_sub(2),
height: popup.height.saturating_sub(2),
};
!contains(inner, col, row)
}
pub fn default_zone_at(popup: Rect, col: u16, row: u16) -> Zone {
if !contains(popup, col, row) {
return Zone::None;
}
if is_on_border(popup, col, row) {
let mut edges = EdgeSet { left: false, right: false, top: false, bottom: false };
if row == popup.y { edges.top = true; }
if row == popup.y + popup.height - 1 { edges.bottom = true; }
if col == popup.x { edges.left = true; }
if col == popup.x + popup.width - 1 { edges.right = true; }
return Zone::Resize(edges);
}
let header_top = popup.y + 1;
let header_bottom = header_top + 2;
if row >= header_top && row < header_bottom {
return Zone::Move;
}
Zone::None
}
enum DragMode {
Idle,
Move,
Resize(EdgeSet),
}
impl Default for DragMode {
fn default() -> Self {
Self::Idle
}
}
#[derive(Default)]
pub struct DragState {
cursor_offset: (u16, u16),
start_popup: Rect,
mode: DragMode,
}
impl DragState {
pub fn new() -> Self {
Self::default()
}
pub fn begin(&mut self, popup: Rect, col: u16, row: u16, zone: Zone) {
match zone {
Zone::None => {}
Zone::Move => {
let dx = col.saturating_sub(popup.x);
let dy = row.saturating_sub(popup.y);
self.cursor_offset = (dx, dy);
self.start_popup = popup;
self.mode = DragMode::Move;
}
Zone::Resize(edges) => {
self.cursor_offset = (col, row);
self.start_popup = popup;
self.mode = DragMode::Resize(edges);
}
}
}
pub fn update(&self, area: Rect, col: u16, row: u16) -> DragUpdate {
match &self.mode {
DragMode::Idle => DragUpdate::None,
DragMode::Move => {
let (dx, dy) = self.cursor_offset;
let max_w = area.width.saturating_sub(MIN_VISIBLE_W);
let max_h = area.height.saturating_sub(MIN_VISIBLE_H);
let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
DragUpdate::Moved { x: nx, y: ny }
}
DragMode::Resize(edges) => {
let p = self.start_popup;
let mut x = p.x;
let mut y = p.y;
let mut w = p.width;
let mut h = p.height;
if edges.right {
w = col.saturating_sub(p.x).max(MIN_RESIZE_W);
}
if edges.bottom {
h = row.saturating_sub(p.y).max(MIN_RESIZE_H);
}
if edges.left {
let right = p.x + p.width;
let new_x = col.min(right.saturating_sub(MIN_RESIZE_W));
w = right.saturating_sub(new_x);
x = new_x;
}
if edges.top {
let bottom = p.y + p.height;
let new_y = row.min(bottom.saturating_sub(MIN_RESIZE_H));
h = bottom.saturating_sub(new_y);
y = new_y;
}
DragUpdate::Resized { x, y, w, h }
}
}
}
pub fn is_dragging(&self) -> bool {
!matches!(self.mode, DragMode::Idle)
}
pub fn end(&mut self) {
self.mode = DragMode::Idle;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contains_inside() {
let r = Rect::new(10, 5, 20, 10);
assert!(contains(r, 15, 8));
}
#[test]
fn contains_outside() {
let r = Rect::new(10, 5, 20, 10);
assert!(!contains(r, 5, 8));
assert!(!contains(r, 35, 8));
}
#[test]
fn contains_excludes_right_and_bottom_edge() {
let r = Rect::new(10, 5, 20, 10);
assert!(!contains(r, 30, 8));
assert!(!contains(r, 15, 15));
}
#[test]
fn is_on_border_top_edge() {
let r = Rect::new(10, 5, 20, 10);
assert!(is_on_border(r, 15, 5));
}
#[test]
fn is_on_border_left_edge() {
let r = Rect::new(10, 5, 20, 10);
assert!(is_on_border(r, 10, 8));
}
#[test]
fn is_on_border_inside() {
let r = Rect::new(10, 5, 20, 10);
assert!(!is_on_border(r, 15, 8));
}
#[test]
fn default_zone_at_header_is_move() {
let r = Rect::new(0, 0, 60, 20);
assert_eq!(default_zone_at(r, 5, 1), Zone::Move);
}
#[test]
fn default_zone_at_border_is_resize() {
let r = Rect::new(0, 0, 60, 20);
assert!(matches!(default_zone_at(r, 0, 0), Zone::Resize(_)));
}
#[test]
fn default_zone_at_body_is_none() {
let r = Rect::new(0, 0, 60, 20);
assert_eq!(default_zone_at(r, 30, 10), Zone::None);
}
#[test]
fn default_zone_at_outside_is_none() {
let r = Rect::new(0, 0, 60, 20);
assert_eq!(default_zone_at(r, 100, 5), Zone::None);
}
#[test]
fn default_zone_at_right_edge_is_resize_right() {
let r = Rect::new(10, 5, 40, 30);
let zone = default_zone_at(r, 49, 15);
assert!(matches!(zone, Zone::Resize(e) if e.right && !e.left && !e.top && !e.bottom));
}
#[test]
fn default_zone_at_bottom_edge_is_resize_bottom() {
let r = Rect::new(10, 5, 40, 30);
let zone = default_zone_at(r, 20, 34);
assert!(matches!(zone, Zone::Resize(e) if e.bottom && !e.top && !e.left && !e.right));
}
#[test]
fn default_zone_at_top_left_corner_is_resize_both() {
let r = Rect::new(10, 5, 40, 30);
let zone = default_zone_at(r, 10, 5);
assert!(matches!(zone, Zone::Resize(e) if e.top && e.left));
}
#[test]
fn begin_move_sets_offset() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
assert!(drag.is_dragging());
}
#[test]
fn begin_none_does_not_drag() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::None);
assert!(!drag.is_dragging());
}
#[test]
fn begin_move_computes_correct_offset() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 25, 15, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 25, 15);
assert_eq!(result, DragUpdate::Moved { x: 10, y: 5 });
}
#[test]
fn begin_resize_starts_dragging() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, default_zone_at(popup, 49, 15));
assert!(drag.is_dragging());
}
#[test]
fn update_returns_none_when_not_dragging() {
let drag = DragState::new();
assert_eq!(drag.update(Rect::new(0, 0, 100, 50), 50, 25), DragUpdate::None);
}
#[test]
fn update_move_returns_origin() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 40, 30);
assert_eq!(result, DragUpdate::Moved { x: 35, y: 25 });
}
#[test]
fn update_move_clamps_nx_to_area_width_minus_min_w() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 999, 30);
if let DragUpdate::Moved { x, .. } = result {
assert!(x <= 80);
} else {
panic!("expected Move");
}
}
#[test]
fn update_move_clamps_ny_to_area_height_minus_min_h() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 30, 999);
if let DragUpdate::Moved { y, .. } = result {
assert!(y <= 45);
} else {
panic!("expected Move");
}
}
#[test]
fn update_move_keeps_minimum_visible_size() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 999, 999);
assert_eq!(result, DragUpdate::Moved { x: 80, y: 45 });
}
#[test]
fn update_move_clamps_to_zero_when_negative() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 0, 0);
assert_eq!(result, DragUpdate::Moved { x: 0, y: 0 });
}
#[test]
fn update_move_allows_drag_below_middle() {
let popup = Rect::new(0, 0, 40, 35);
let mut drag = DragState::new();
drag.begin(popup, 5, 1, Zone::Move);
let result = drag.update(Rect::new(0, 0, 100, 50), 5, 26);
if let DragUpdate::Moved { y, .. } = result {
assert!(y >= 25, "ny should reach middle of screen, got ny={}", y);
} else {
panic!("expected Move");
}
}
#[test]
fn update_resize_right_increases_width() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
let result = drag.update(Rect::new(0, 0, 100, 50), 60, 15);
assert_eq!(result, DragUpdate::Resized { x: 10, y: 5, w: 50, h: 30 });
}
#[test]
fn update_resize_bottom_increases_height() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 20, 34, Zone::Resize(EdgeSet { left: false, right: false, top: false, bottom: true }));
let result = drag.update(Rect::new(0, 0, 100, 50), 20, 40);
assert_eq!(result, DragUpdate::Resized { x: 10, y: 5, w: 40, h: 35 });
}
#[test]
fn update_resize_left_moves_and_shrinks() {
let popup = Rect::new(20, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 20, 15, Zone::Resize(EdgeSet { left: true, right: false, top: false, bottom: false }));
let result = drag.update(Rect::new(0, 0, 100, 50), 30, 15);
if let DragUpdate::Resized { x, w, .. } = result {
assert_eq!(x, 30);
assert!(w <= 30);
} else {
panic!("expected Resized");
}
}
#[test]
fn update_resize_enforces_min_width() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
let result = drag.update(Rect::new(0, 0, 100, 50), 20, 15);
if let DragUpdate::Resized { w, .. } = result {
assert!(w >= MIN_RESIZE_W);
} else {
panic!("expected Resized");
}
}
fn unpack_resized(result: DragUpdate) -> Rect {
match result {
DragUpdate::Resized { x, y, w, h } => Rect::new(x, y, w, h),
_ => panic!("expected Resized"),
}
}
#[test]
fn resize_right_then_right_again() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 60, 15));
assert_eq!(r1, Rect::new(10, 5, 50, 30));
drag.end();
drag.begin(r1, r1.x + r1.width - 1, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
let r2 = unpack_resized(drag.update(area, 65, 15));
assert_eq!(r2, Rect::new(10, 5, 55, 30));
assert_eq!(r2.x + r2.width, 65);
}
#[test]
fn resize_right_then_left_keeps_right_edge() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 60, 15));
assert_eq!(r1, Rect::new(10, 5, 50, 30));
drag.end();
let new_right = r1.x + r1.width;
drag.begin(r1, r1.x, 15, Zone::Resize(EdgeSet { left: true, ..Default::default() }));
let r2 = unpack_resized(drag.update(area, 5, 15));
assert_eq!(r2.x, 5);
assert_eq!(r2.x + r2.width, new_right); }
#[test]
fn resize_top_then_bottom_keeps_bottom_edge() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(10, 5, 40, 30);
let bottom_edge = popup.y + popup.height;
let mut drag = DragState::new();
drag.begin(popup, 15, 5, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 15, 10));
assert_eq!(r1, Rect::new(10, 10, 40, 25));
assert_eq!(r1.y + r1.height, bottom_edge);
drag.end();
drag.begin(r1, 15, 34, Zone::Resize(EdgeSet { bottom: true, ..Default::default() }));
let r2 = unpack_resized(drag.update(area, 15, 40));
assert_eq!(r2, Rect::new(10, 10, 40, 30));
assert_eq!(r2.y + r2.height, 40);
}
#[test]
fn resize_top_then_right_independent_axes() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 5, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 15, 10));
assert_eq!(r1, Rect::new(10, 10, 40, 25));
drag.end();
drag.begin(r1, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
let r2 = unpack_resized(drag.update(area, 70, 15));
assert_eq!(r2, Rect::new(10, 10, 60, 25));
}
#[test]
fn resize_left_then_top_corner_sequence() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(20, 10, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 20, 15, Zone::Resize(EdgeSet { left: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 10, 15));
assert_eq!(r1, Rect::new(10, 10, 50, 30));
drag.end();
drag.begin(r1, 15, 10, Zone::Resize(EdgeSet { top: true, ..Default::default() }));
let r2 = unpack_resized(drag.update(area, 15, 15));
assert_eq!(r2, Rect::new(10, 15, 50, 25));
}
#[test]
fn resize_single_drag_back_and_forth() {
let area = Rect::new(0, 0, 100, 50);
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 49, 15, Zone::Resize(EdgeSet { right: true, ..Default::default() }));
let r1 = unpack_resized(drag.update(area, 70, 15));
assert_eq!(r1, Rect::new(10, 5, 60, 30));
let r2 = unpack_resized(drag.update(area, 55, 15));
assert_eq!(r2, Rect::new(10, 5, 45, 30));
let r3 = unpack_resized(drag.update(area, 65, 15));
assert_eq!(r3, Rect::new(10, 5, 55, 30));
}
#[test]
fn end_clears_state() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
assert!(drag.is_dragging());
drag.end();
assert!(!drag.is_dragging());
assert_eq!(drag.update(Rect::new(0, 0, 100, 50), 40, 30), DragUpdate::None);
}
#[test]
fn is_dragging_false_by_default() {
let drag = DragState::new();
assert!(!drag.is_dragging());
}
#[test]
fn is_dragging_true_after_move_begin() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
assert!(drag.is_dragging());
}
#[test]
fn is_dragging_true_after_resize_begin() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Resize(EdgeSet { left: false, right: true, top: false, bottom: false }));
assert!(drag.is_dragging());
}
#[test]
fn is_dragging_false_after_end() {
let popup = Rect::new(10, 5, 40, 30);
let mut drag = DragState::new();
drag.begin(popup, 15, 10, Zone::Move);
drag.end();
assert!(!drag.is_dragging());
}
}