tmux_tui/
geometry.rs

1//! Pure geometry: scaling tmux cell-coordinates into screen rectangles and
2//! hit-testing the cursor against them. No tmux or terminal I/O here.
3
4use ratatui::layout::Rect;
5
6use crate::tmux::Pane;
7
8/// Scale a pane's tmux cell-geometry into the on-screen `area`.
9pub fn map_rect(p: &Pane, area: Rect, win_w: u16, win_h: u16) -> Rect {
10    let sx = area.width as f32 / win_w.max(1) as f32;
11    let sy = area.height as f32 / win_h.max(1) as f32;
12    let x = (area.x + (p.left as f32 * sx).round() as u16).min(area.right().saturating_sub(1));
13    let y = (area.y + (p.top as f32 * sy).round() as u16).min(area.bottom().saturating_sub(1));
14    let max_w = area.right().saturating_sub(x).max(1);
15    let max_h = area.bottom().saturating_sub(y).max(1);
16    let w = ((p.width as f32 * sx).round() as u16).clamp(1, max_w);
17    let h = ((p.height as f32 * sy).round() as u16).clamp(1, max_h);
18    Rect { x, y, width: w, height: h }
19}
20
21pub fn rects_for(panes: &[Pane], canvas: Rect, win_w: u16, win_h: u16) -> Vec<(usize, Rect)> {
22    panes.iter().enumerate().map(|(i, p)| (i, map_rect(p, canvas, win_w, win_h))).collect()
23}
24
25pub fn contains(r: Rect, col: u16, row: u16) -> bool {
26    col >= r.x && col < r.right() && row >= r.y && row < r.bottom()
27}
28
29/// Pane under the cursor; the smallest containing box wins so borders never
30/// block selection of an inner pane.
31pub fn hit(rects: &[(usize, Rect)], col: u16, row: u16) -> Option<usize> {
32    rects
33        .iter()
34        .filter(|(_, r)| contains(*r, col, row))
35        .min_by_key(|(_, r)| r.width as u32 * r.height as u32)
36        .map(|(i, _)| *i)
37}
38
39/// Window id of the tile under the cursor (window mode).
40pub fn tile_at(tiles: &[(String, Rect)], col: u16, row: u16) -> Option<String> {
41    tiles.iter().find(|(_, r)| contains(*r, col, row)).map(|(id, _)| id.clone())
42}
43
44/// (window id, pane id) of the mini-pane under the cursor (window mode).
45pub fn mpane_at(mpanes: &[(String, String, Rect)], col: u16, row: u16) -> Option<(String, String)> {
46    mpanes
47        .iter()
48        .filter(|(_, _, r)| contains(*r, col, row))
49        .min_by_key(|(_, _, r)| r.width as u32 * r.height as u32)
50        .map(|(w, p, _)| (w.clone(), p.clone()))
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::tmux::Pane;
57
58    fn pane(left: u16, top: u16, width: u16, height: u16) -> Pane {
59        Pane {
60            id: "%0".into(),
61            index: "0".into(),
62            left,
63            top,
64            width,
65            height,
66            active: false,
67            cmd: "sh".into(),
68        }
69    }
70
71    #[test]
72    fn contains_is_half_open() {
73        let r = Rect { x: 2, y: 3, width: 4, height: 2 };
74        assert!(contains(r, 2, 3));
75        assert!(contains(r, 5, 4));
76        assert!(!contains(r, 6, 3)); // x == right() is outside
77        assert!(!contains(r, 2, 5)); // y == bottom() is outside
78    }
79
80    #[test]
81    fn map_rect_fills_canvas_when_pane_fills_window() {
82        let canvas = Rect { x: 0, y: 0, width: 80, height: 24 };
83        let m = map_rect(&pane(0, 0, 100, 50), canvas, 100, 50);
84        assert_eq!(m, canvas);
85    }
86
87    #[test]
88    fn map_rect_scales_a_right_half_pane() {
89        let canvas = Rect { x: 0, y: 0, width: 80, height: 24 };
90        let m = map_rect(&pane(50, 0, 50, 50), canvas, 100, 50);
91        assert_eq!(m, Rect { x: 40, y: 0, width: 40, height: 24 });
92    }
93
94    #[test]
95    fn hit_prefers_the_smallest_containing_box() {
96        let rects = [
97            (0usize, Rect { x: 0, y: 0, width: 10, height: 10 }),
98            (1usize, Rect { x: 2, y: 2, width: 3, height: 3 }),
99        ];
100        assert_eq!(hit(&rects, 3, 3), Some(1));
101        assert_eq!(hit(&rects, 0, 0), Some(0));
102        assert_eq!(hit(&rects, 50, 50), None);
103    }
104}