ratatui-spatial-splits 0.1.1

Pure geometry engine for spatial split management in ratatui applications
Documentation
//! Spatial navigation using beam/raycasting against cached areas.

use crate::manager::SplitArea;
use crate::types::AreaId;

/// Direction for spatial navigation and split operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
    /// Navigate left.
    Left,
    /// Navigate right.
    Right,
    /// Navigate up.
    Up,
    /// Navigate down.
    Down,
}

/// Finds the best neighboring area in the given direction using spatial raycasting.
///
/// For each candidate area, computes:
/// 1. **Overlap**: the span of the current area that faces the candidate (must be > 0)
/// 2. **Distance**: the distance from the current area edge to the candidate edge
///
/// The best candidate has the maximum overlap × closeness score.
/// Returns `None` if no neighbor exists in that direction.
pub fn navigate(areas: &[SplitArea], current_id: AreaId, direction: Direction) -> Option<AreaId> {
    let current = areas.iter().find(|a| a.id == current_id)?;
    let current_rect = current.rect;

    let mut best: Option<(AreaId, u16, i32)> = None;

    for candidate in areas {
        if candidate.id == current_id {
            continue;
        }

        let r = candidate.rect;
        let (overlap, distance) = match direction {
            Direction::Left => {
                let distance = i32::from(current_rect.x) - (i32::from(r.x) + i32::from(r.width));
                let overlap = overlap_range(current_rect.y, current_rect.height, r.y, r.height);
                (overlap, distance)
            }
            Direction::Right => {
                let distance =
                    i32::from(r.x) - (i32::from(current_rect.x) + i32::from(current_rect.width));
                let overlap = overlap_range(current_rect.y, current_rect.height, r.y, r.height);
                (overlap, distance)
            }
            Direction::Up => {
                let distance = i32::from(current_rect.y) - (i32::from(r.y) + i32::from(r.height));
                let overlap = overlap_range(current_rect.x, current_rect.width, r.x, r.width);
                (overlap, distance)
            }
            Direction::Down => {
                let distance =
                    i32::from(r.y) - (i32::from(current_rect.y) + i32::from(current_rect.height));
                let overlap = overlap_range(current_rect.x, current_rect.width, r.x, r.width);
                (overlap, distance)
            }
        };

        if overlap == 0 || distance < 0 {
            continue;
        }

        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let is_better = match &best {
            None => true,
            Some((_, best_overlap, best_distance)) => {
                let closeness_score = u32::from(u16::MAX - (distance as u16)) * u32::from(overlap);
                let best_closeness_score =
                    u32::from(u16::MAX - (*best_distance as u16)) * u32::from(*best_overlap);
                closeness_score > best_closeness_score
            }
        };

        if is_better {
            best = Some((candidate.id, overlap, distance));
        }
    }

    best.map(|(id, _, _)| id)
}

/// Computes the overlap of two ranges: [a_start, a_start + a_len) and [b_start, b_start + b_len).
fn overlap_range(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 {
    let a_end = a_start.saturating_add(a_len);
    let b_end = b_start.saturating_add(b_len);
    a_end.min(b_end).saturating_sub(a_start.max(b_start))
}

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

    fn area(id: u64, x: u16, y: u16, w: u16, h: u16) -> SplitArea {
        SplitArea {
            id: AreaId(id),
            rect: Rect::new(x, y, w, h),
        }
    }

    #[test]
    fn navigate_returns_right_neighbor() {
        // Given two areas side by side horizontally.
        let areas = &[area(1, 0, 0, 50, 100), area(2, 50, 0, 50, 100)];

        // When navigating right from the left area.
        let result = navigate(areas, AreaId(1), Direction::Right);

        // Then the right neighbor is returned.
        assert_eq!(result, Some(AreaId(2)));
    }

    #[test]
    fn navigate_returns_left_neighbor() {
        // Given two areas side by side horizontally.
        let areas = &[area(1, 0, 0, 50, 100), area(2, 50, 0, 50, 100)];

        // When navigating left from the right area.
        let result = navigate(areas, AreaId(2), Direction::Left);

        // Then the left neighbor is returned.
        assert_eq!(result, Some(AreaId(1)));
    }

    #[test]
    fn navigate_returns_down_neighbor() {
        // Given two areas stacked vertically.
        let areas = &[area(1, 0, 0, 100, 50), area(2, 0, 50, 100, 50)];

        // When navigating down from the top area.
        let result = navigate(areas, AreaId(1), Direction::Down);

        // Then the bottom neighbor is returned.
        assert_eq!(result, Some(AreaId(2)));
    }

    #[test]
    fn navigate_returns_up_neighbor() {
        // Given two areas stacked vertically.
        let areas = &[area(1, 0, 0, 100, 50), area(2, 0, 50, 100, 50)];

        // When navigating up from the bottom area.
        let result = navigate(areas, AreaId(2), Direction::Up);

        // Then the top neighbor is returned.
        assert_eq!(result, Some(AreaId(1)));
    }

    #[test]
    fn navigate_returns_none_when_no_neighbor() {
        // Given a single area with no neighbors.
        let areas = &[area(1, 0, 0, 100, 100)];

        // When navigating right from the only area.
        let result = navigate(areas, AreaId(1), Direction::Right);

        // Then no neighbor is found.
        assert_eq!(result, None);
    }

    #[test]
    fn navigate_prefers_closest_neighbor_from_leftmost() {
        // Given three areas in a row.
        let areas = &[
            area(1, 0, 0, 30, 100),
            area(2, 30, 0, 30, 100),
            area(3, 60, 0, 40, 100),
        ];

        // When navigating right from the leftmost area.
        let result = navigate(areas, AreaId(1), Direction::Right);

        // Then the immediate neighbor is preferred over the distant one.
        assert_eq!(result, Some(AreaId(2)));
    }

    #[test]
    fn navigate_prefers_closest_neighbor_from_middle() {
        // Given three areas in a row.
        let areas = &[
            area(1, 0, 0, 30, 100),
            area(2, 30, 0, 30, 100),
            area(3, 60, 0, 40, 100),
        ];

        // When navigating right from the middle area.
        let result = navigate(areas, AreaId(2), Direction::Right);

        // Then the immediate neighbor is returned.
        assert_eq!(result, Some(AreaId(3)));
    }

    #[test]
    fn navigate_finds_neighbor_with_partial_overlap() {
        // Given two areas with partial vertical overlap.
        let areas = &[area(1, 0, 0, 50, 100), area(2, 50, 20, 50, 100)];

        // When navigating right from the left area.
        let result = navigate(areas, AreaId(1), Direction::Right);

        // Then the partially overlapping neighbor is found.
        assert_eq!(result, Some(AreaId(2)));
    }

    #[test]
    fn navigate_returns_none_when_no_overlap() {
        // Given two areas with no vertical overlap.
        let areas = &[area(1, 0, 0, 50, 20), area(2, 50, 30, 50, 20)];

        // When navigating right from the left area.
        let result = navigate(areas, AreaId(1), Direction::Right);

        // Then no neighbor is found due to zero overlap.
        assert_eq!(result, None);
    }

    #[test]
    fn overlap_range_computes_intersection() {
        // Given two partially overlapping ranges.
        // When computing the overlap of [0,10) and [5,15).
        let result = overlap_range(0, 10, 5, 10);
        // Then the intersection size is 5.
        assert_eq!(result, 5);

        // Given two identical ranges.
        // When computing the overlap of [0,10) and [0,10).
        let result = overlap_range(0, 10, 0, 10);
        // Then the full range size is returned.
        assert_eq!(result, 10);

        // Given two non-overlapping ranges.
        // When computing the overlap of [0,5) and [10,15).
        let result = overlap_range(0, 5, 10, 5);
        // Then zero is returned.
        assert_eq!(result, 0);

        // Given one range fully contained in another.
        // When computing the overlap of [0,10) and [3,7).
        let result = overlap_range(0, 10, 3, 4);
        // Then the smaller range size is returned.
        assert_eq!(result, 4);
    }
}