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    /// Cells per `\t` expansion stop. The renderer uses this to align
30    /// tab characters; cursor_screen_pos uses it to map char column to
31    /// visual column. `0` is treated as the renderer's fallback (4) so
32    /// hosts that don't publish a value still render legibly.
33    pub tab_width: u16,
34}
35
36impl Viewport {
37    pub const fn new() -> Self {
38        Self {
39            top_row: 0,
40            top_col: 0,
41            width: 0,
42            height: 0,
43            wrap: Wrap::None,
44            text_width: 0,
45            tab_width: 0,
46        }
47    }
48
49    /// Effective tab width — falls back to 4 when `tab_width == 0` so
50    /// uninitialized viewports still expand tabs sensibly.
51    pub fn effective_tab_width(self) -> usize {
52        if self.tab_width == 0 {
53            4
54        } else {
55            self.tab_width as usize
56        }
57    }
58
59    /// Last document row that's currently on screen (inclusive).
60    /// Returns `top_row` when `height == 0` so callers don't have
61    /// to special-case the pre-first-draw state.
62    pub fn bottom_row(self) -> usize {
63        self.top_row
64            .saturating_add((self.height as usize).max(1).saturating_sub(1))
65    }
66
67    /// True when `pos` lies inside the current viewport rect.
68    pub fn contains(self, pos: Position) -> bool {
69        let in_rows = pos.row >= self.top_row && pos.row <= self.bottom_row();
70        let in_cols = pos.col >= self.top_col
71            && pos.col < self.top_col.saturating_add((self.width as usize).max(1));
72        in_rows && in_cols
73    }
74
75    /// Adjust `top_row` / `top_col` so `pos` is visible, scrolling by
76    /// the minimum amount needed. Used after motions and after
77    /// content edits that move the cursor.
78    pub fn ensure_visible(&mut self, pos: Position) {
79        if self.height == 0 || self.width == 0 {
80            return;
81        }
82        let rows = self.height as usize;
83        if pos.row < self.top_row {
84            self.top_row = pos.row;
85        } else if pos.row >= self.top_row + rows {
86            self.top_row = pos.row + 1 - rows;
87        }
88        let cols = self.width as usize;
89        if pos.col < self.top_col {
90            self.top_col = pos.col;
91        } else if pos.col >= self.top_col + cols {
92            self.top_col = pos.col + 1 - cols;
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn vp(top_row: usize, height: u16) -> Viewport {
102        Viewport {
103            top_row,
104            top_col: 0,
105            width: 80,
106            height,
107            wrap: Wrap::None,
108            text_width: 80,
109            tab_width: 0,
110        }
111    }
112
113    #[test]
114    fn contains_inside_window() {
115        let v = vp(10, 5);
116        assert!(v.contains(Position::new(10, 0)));
117        assert!(v.contains(Position::new(14, 79)));
118    }
119
120    #[test]
121    fn contains_outside_window() {
122        let v = vp(10, 5);
123        assert!(!v.contains(Position::new(9, 0)));
124        assert!(!v.contains(Position::new(15, 0)));
125        assert!(!v.contains(Position::new(12, 80)));
126    }
127
128    #[test]
129    fn ensure_visible_scrolls_down() {
130        let mut v = vp(0, 5);
131        v.ensure_visible(Position::new(10, 0));
132        assert_eq!(v.top_row, 6);
133    }
134
135    #[test]
136    fn ensure_visible_scrolls_up() {
137        let mut v = vp(20, 5);
138        v.ensure_visible(Position::new(15, 0));
139        assert_eq!(v.top_row, 15);
140    }
141
142    #[test]
143    fn ensure_visible_no_scroll_when_inside() {
144        let mut v = vp(10, 5);
145        v.ensure_visible(Position::new(12, 4));
146        assert_eq!(v.top_row, 10);
147    }
148
149    #[test]
150    fn ensure_visible_zero_dim_is_noop() {
151        let mut v = Viewport::default();
152        v.ensure_visible(Position::new(100, 100));
153        assert_eq!(v.top_row, 0);
154        assert_eq!(v.top_col, 0);
155    }
156}