Skip to main content

hjkl_buffer/
viewport.rs

1use crate::{Position, Wrap};
2
3/// Where the buffer is scrolled to and how big the visible area is.
4///
5/// Mirrors what tui-textarea exposed today: the host publishes
6/// `(width, height)` from the render path each frame, and the buffer
7/// uses the cached values to clamp the cursor / scroll offsets when
8/// motions ask for it. `top_row` and `top_col` are the first visible
9/// row / column; `top_col` is a char index, matching [`Position`].
10///
11/// `wrap` and `text_width` together drive soft-wrap-aware scrolling
12/// and motion. `text_width` is the cell width of the text area
13/// (i.e. `width` minus any gutter the host renders) so the buffer
14/// can compute screen-line splits without duplicating gutter logic.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct Viewport {
17    pub top_row: usize,
18    pub top_col: usize,
19    pub width: u16,
20    pub height: u16,
21    /// Soft-wrap mode the renderer + scroll math is using. Default
22    /// is [`Wrap::None`] (no wrap, horizontal scroll via `top_col`).
23    pub wrap: Wrap,
24    /// Cell width of the text area (after the host's gutter is
25    /// subtracted from the editor area). Used by wrap-aware scroll
26    /// and motion code; ignored when `wrap == Wrap::None`. Set to 0
27    /// before the first frame; wrap math falls back to no-op then.
28    pub text_width: u16,
29}
30
31impl Viewport {
32    pub const fn new() -> Self {
33        Self {
34            top_row: 0,
35            top_col: 0,
36            width: 0,
37            height: 0,
38            wrap: Wrap::None,
39            text_width: 0,
40        }
41    }
42
43    /// Last document row that's currently on screen (inclusive).
44    /// Returns `top_row` when `height == 0` so callers don't have
45    /// to special-case the pre-first-draw state.
46    pub fn bottom_row(self) -> usize {
47        self.top_row
48            .saturating_add((self.height as usize).max(1).saturating_sub(1))
49    }
50
51    /// True when `pos` lies inside the current viewport rect.
52    pub fn contains(self, pos: Position) -> bool {
53        let in_rows = pos.row >= self.top_row && pos.row <= self.bottom_row();
54        let in_cols = pos.col >= self.top_col
55            && pos.col < self.top_col.saturating_add((self.width as usize).max(1));
56        in_rows && in_cols
57    }
58
59    /// Adjust `top_row` / `top_col` so `pos` is visible, scrolling by
60    /// the minimum amount needed. Used after motions and after
61    /// content edits that move the cursor.
62    pub fn ensure_visible(&mut self, pos: Position) {
63        if self.height == 0 || self.width == 0 {
64            return;
65        }
66        let rows = self.height as usize;
67        if pos.row < self.top_row {
68            self.top_row = pos.row;
69        } else if pos.row >= self.top_row + rows {
70            self.top_row = pos.row + 1 - rows;
71        }
72        let cols = self.width as usize;
73        if pos.col < self.top_col {
74            self.top_col = pos.col;
75        } else if pos.col >= self.top_col + cols {
76            self.top_col = pos.col + 1 - cols;
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn vp(top_row: usize, height: u16) -> Viewport {
86        Viewport {
87            top_row,
88            top_col: 0,
89            width: 80,
90            height,
91            wrap: Wrap::None,
92            text_width: 80,
93        }
94    }
95
96    #[test]
97    fn contains_inside_window() {
98        let v = vp(10, 5);
99        assert!(v.contains(Position::new(10, 0)));
100        assert!(v.contains(Position::new(14, 79)));
101    }
102
103    #[test]
104    fn contains_outside_window() {
105        let v = vp(10, 5);
106        assert!(!v.contains(Position::new(9, 0)));
107        assert!(!v.contains(Position::new(15, 0)));
108        assert!(!v.contains(Position::new(12, 80)));
109    }
110
111    #[test]
112    fn ensure_visible_scrolls_down() {
113        let mut v = vp(0, 5);
114        v.ensure_visible(Position::new(10, 0));
115        assert_eq!(v.top_row, 6);
116    }
117
118    #[test]
119    fn ensure_visible_scrolls_up() {
120        let mut v = vp(20, 5);
121        v.ensure_visible(Position::new(15, 0));
122        assert_eq!(v.top_row, 15);
123    }
124
125    #[test]
126    fn ensure_visible_no_scroll_when_inside() {
127        let mut v = vp(10, 5);
128        v.ensure_visible(Position::new(12, 4));
129        assert_eq!(v.top_row, 10);
130    }
131
132    #[test]
133    fn ensure_visible_zero_dim_is_noop() {
134        let mut v = Viewport::default();
135        v.ensure_visible(Position::new(100, 100));
136        assert_eq!(v.top_row, 0);
137        assert_eq!(v.top_col, 0);
138    }
139}