pandora-kit 0.5.6

Interactive TUI toolkit for the Hefesto framework
Documentation
use ratatui::layout::Rect;

/// Minimum columns the popup must retain when dragged to an edge.
const MIN_VISIBLE_W: u16 = 20;

/// Minimum rows the popup must retain when dragged to an edge.
const MIN_VISIBLE_H: u16 = 5;

/// State for tracking a popup drag operation.
///
/// Encapsulates the begin/drag/end lifecycle and clamps the new position
/// so the popup keeps at least `MIN_VISIBLE_W` columns and
/// `MIN_VISIBLE_H` rows on-screen (the widget's `resolve_rect` further
/// shrinks the popup to fit fully within the area).
///
/// Also tracks whether the user has ever dragged from the border.
/// Once a border drag is detected, the header
/// area becomes draggable too (progressive disclosure).
#[derive(Default)]
pub struct DragState {
    offset: Option<(u16, u16)>,
    border_dragged_once: bool,
}

impl DragState {
    pub fn new() -> Self {
        Self::default()
    }

    /// Start a drag if `in_drag_area` is true.
    ///
    /// * `on_border` — whether the click was on the border (not the header).
    ///   Only border clicks enable header drag for later interactions.
    /// * `in_drag_area` — whether the click is inside any drag zone (border
    ///   or header, depending on `header_drag_enabled()`).
    pub fn begin(&mut self, popup: Rect, col: u16, row: u16, on_border: bool, in_drag_area: bool) {
        if in_drag_area {
            self.offset = Some((col.saturating_sub(popup.x), row.saturating_sub(popup.y)));
            if on_border {
                self.border_dragged_once = true;
            }
        }
    }

    /// Whether the header area is draggable.
    ///
    /// Returns `true` once the user has dragged from the border at least
    /// once during this session.
    pub fn header_drag_enabled(&self) -> bool {
        self.border_dragged_once
    }

    /// Update the drag and return the new popup origin `(x, y)`.
    /// Returns `None` if no drag is in progress.
    ///
    /// The origin is clamped so at least `MIN_VISIBLE_W` columns and
    /// `MIN_VISIBLE_H` rows of the popup always remain on-screen.
    pub fn update(&self, area: Rect, col: u16, row: u16) -> Option<(u16, u16)> {
        let (dx, dy) = self.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;
        Some((nx, ny))
    }

    /// End the drag (called on mouse up).
    pub fn end(&mut self) {
        self.offset = None;
    }

    /// Whether a drag is in progress.
    pub fn is_dragging(&self) -> bool {
        self.offset.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── begin ──

    #[test]
    fn begin_sets_offset_when_in_drag_area() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        assert!(drag.is_dragging());
    }

    #[test]
    fn begin_skips_offset_when_not_in_drag_area() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, false);
        assert!(!drag.is_dragging());
    }

    #[test]
    fn begin_computes_correct_offset() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 25, 15, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 25, 15);
        assert_eq!(result, Some((10, 5)));
    }

    #[test]
    fn begin_border_drag_enables_header() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        assert!(!drag.header_drag_enabled());
        drag.begin(popup, 12, 6, true, true);
        assert!(drag.header_drag_enabled());
    }

    #[test]
    fn begin_header_drag_does_not_enable_header() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        // header click (on_border = false) before any border drag
        drag.begin(popup, 15, 7, false, false);
        assert!(!drag.header_drag_enabled());
    }

    #[test]
    fn begin_does_not_need_to_be_in_drag_area_to_enable_header() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        // click on border but NOT in drag area — still not a real drag,
        // but the border flag should not get set because in_drag_area is false
        drag.begin(popup, 12, 6, true, false);
        assert!(!drag.header_drag_enabled());
    }

    // ── header_drag_enabled ──

    #[test]
    fn header_drag_enabled_false_by_default() {
        let drag = DragState::new();
        assert!(!drag.header_drag_enabled());
    }

    #[test]
    fn header_drag_enabled_true_after_border_drag() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 12, 6, true, true);
        assert!(drag.header_drag_enabled());
    }

    #[test]
    fn header_drag_enabled_stays_true_after_end() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 12, 6, true, true);
        drag.end();
        assert!(drag.header_drag_enabled());
    }

    #[test]
    fn header_drag_enabled_stays_true_after_header_drag() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        // border drag first to enable header
        drag.begin(popup, 12, 6, true, true);
        // header drag (on_border = false) — flag stays true
        drag.begin(popup, 15, 7, false, true);
        assert!(drag.header_drag_enabled());
    }

    // ── update ──

    #[test]
    fn update_returns_none_when_not_dragging() {
        let drag = DragState::new();
        assert!(drag.update(Rect::new(0, 0, 100, 50), 50, 25).is_none());
    }

    #[test]
    fn update_returns_origin() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 40, 30);
        assert_eq!(result, Some((35, 25)));
    }

    #[test]
    fn update_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, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 30);
        let (nx, _) = result.unwrap();
        assert!(nx <= 80);
    }

    #[test]
    fn update_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, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 30, 999);
        let (_, ny) = result.unwrap();
        assert!(ny <= 45);
    }

    #[test]
    fn update_keeps_minimum_visible_size() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 999, 999);
        let (nx, ny) = result.unwrap();
        assert_eq!(nx, 80);
        assert_eq!(ny, 45);
    }

    #[test]
    fn update_clamps_to_zero_when_negative() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 0, 0);
        assert_eq!(result, Some((0, 0)));
    }

    #[test]
    fn update_allows_drag_below_middle() {
        let popup = Rect::new(0, 0, 40, 35);
        let mut drag = DragState::new();
        drag.begin(popup, 5, 1, false, true);
        let result = drag.update(Rect::new(0, 0, 100, 50), 5, 26);
        let (_, ny) = result.unwrap();
        assert!(ny >= 25, "ny should reach middle of screen, got ny={}", ny);
    }

    // ── end ──

    #[test]
    fn end_clears_state() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        assert!(drag.is_dragging());
        drag.end();
        assert!(!drag.is_dragging());
        assert!(drag.update(Rect::new(0, 0, 100, 50), 40, 30).is_none());
    }

    // ── is_dragging ──

    #[test]
    fn is_dragging_false_by_default() {
        let drag = DragState::new();
        assert!(!drag.is_dragging());
    }

    #[test]
    fn is_dragging_true_after_begin() {
        let popup = Rect::new(10, 5, 40, 30);
        let mut drag = DragState::new();
        drag.begin(popup, 15, 10, false, true);
        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, false, true);
        drag.end();
        assert!(!drag.is_dragging());
    }
}