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/// `Viewport` is an **input** to [`crate::Buffer::ensure_cursor_visible`],
6/// not a derived value. The host writes `top_row`, `top_col`, `width`, and
7/// `height` per render frame; the buffer clamps the cursor inside the
8/// declared area.
9///
10/// `top_row` and `top_col` are the first visible row / column; `top_col` is
11/// a char index, matching [`Position`].
12///
13/// `wrap` and `text_width` together drive soft-wrap-aware scrolling and
14/// motion. `text_width` is the cell width of the text area (i.e. `width`
15/// minus any gutter the host renders) so the buffer can compute screen-line
16/// splits without duplicating gutter logic.
17///
18/// `scroll_off` is not a field on `Viewport` itself; the host computes it
19/// and adjusts `top_row` before handing the viewport to
20/// [`crate::Buffer::ensure_cursor_visible`].
21///
22/// [`Wrap::None`] / [`crate::Wrap::Char`] / [`crate::Wrap::Word`] change
23/// which screen-row arithmetic the buffer uses. Switching mid-session is
24/// supported but the host must call
25/// [`crate::Buffer::ensure_cursor_visible`] afterwards.
26#[derive(Debug, Clone, Copy, Default)]
27pub struct Viewport {
28    pub top_row: usize,
29    pub top_col: usize,
30    pub width: u16,
31    pub height: u16,
32    /// Soft-wrap mode the renderer + scroll math is using. Default
33    /// is [`Wrap::None`] (no wrap, horizontal scroll via `top_col`).
34    pub wrap: Wrap,
35    /// Cell width of the text area (after the host's gutter is
36    /// subtracted from the editor area). Used by wrap-aware scroll
37    /// and motion code; ignored when `wrap == Wrap::None`. Set to 0
38    /// before the first frame; wrap math falls back to no-op then.
39    pub text_width: u16,
40    /// Cells per `\t` expansion stop. The renderer uses this to align
41    /// tab characters; cursor_screen_pos uses it to map char column to
42    /// visual column. `0` is treated as the renderer's fallback (4) so
43    /// hosts that don't publish a value still render legibly.
44    pub tab_width: u16,
45}
46
47impl Viewport {
48    pub const fn new() -> Self {
49        Self {
50            top_row: 0,
51            top_col: 0,
52            width: 0,
53            height: 0,
54            wrap: Wrap::None,
55            text_width: 0,
56            tab_width: 0,
57        }
58    }
59
60    /// Effective tab width — falls back to 4 when `tab_width == 0` so
61    /// uninitialized viewports still expand tabs sensibly.
62    pub fn effective_tab_width(self) -> usize {
63        if self.tab_width == 0 {
64            4
65        } else {
66            self.tab_width as usize
67        }
68    }
69
70    /// Last document row that's currently on screen (inclusive).
71    /// Returns `top_row` when `height == 0` so callers don't have
72    /// to special-case the pre-first-draw state.
73    pub fn bottom_row(self) -> usize {
74        self.top_row
75            .saturating_add((self.height as usize).max(1).saturating_sub(1))
76    }
77
78    /// True when `pos` lies inside the current viewport rect.
79    pub fn contains(self, pos: Position) -> bool {
80        let in_rows = pos.row >= self.top_row && pos.row <= self.bottom_row();
81        let in_cols = pos.col >= self.top_col
82            && pos.col < self.top_col.saturating_add((self.width as usize).max(1));
83        in_rows && in_cols
84    }
85
86    /// Adjust `top_row` / `top_col` so `pos` is visible, scrolling by
87    /// the minimum amount needed. Used after motions and after
88    /// content edits that move the cursor.
89    pub fn ensure_visible(&mut self, pos: Position) {
90        if self.height == 0 || self.width == 0 {
91            return;
92        }
93        let rows = self.height as usize;
94        if pos.row < self.top_row {
95            self.top_row = pos.row;
96        } else if pos.row >= self.top_row + rows {
97            self.top_row = pos.row + 1 - rows;
98        }
99        let cols = self.width as usize;
100        if pos.col < self.top_col {
101            self.top_col = pos.col;
102        } else if pos.col >= self.top_col + cols {
103            self.top_col = pos.col + 1 - cols;
104        }
105    }
106}
107
108/// `true` when a viewport scroll from `prev_top` to `cur_top` lands
109/// more than `viewport_height` rows away from the previous position —
110/// hosts use this signal to decide whether to block briefly on a fresh
111/// parse (avoids the un-highlighted flash on `gg` / `G` / `<C-d>` / `:N`).
112///
113/// Host-agnostic: pure math.
114pub fn is_big_viewport_jump(prev_top: usize, cur_top: usize, viewport_height: usize) -> bool {
115    prev_top.abs_diff(cur_top) > viewport_height
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn vp(top_row: usize, height: u16) -> Viewport {
123        Viewport {
124            top_row,
125            top_col: 0,
126            width: 80,
127            height,
128            wrap: Wrap::None,
129            text_width: 80,
130            tab_width: 0,
131        }
132    }
133
134    #[test]
135    fn contains_inside_window() {
136        let v = vp(10, 5);
137        assert!(v.contains(Position::new(10, 0)));
138        assert!(v.contains(Position::new(14, 79)));
139    }
140
141    #[test]
142    fn contains_outside_window() {
143        let v = vp(10, 5);
144        assert!(!v.contains(Position::new(9, 0)));
145        assert!(!v.contains(Position::new(15, 0)));
146        assert!(!v.contains(Position::new(12, 80)));
147    }
148
149    #[test]
150    fn ensure_visible_scrolls_down() {
151        let mut v = vp(0, 5);
152        v.ensure_visible(Position::new(10, 0));
153        assert_eq!(v.top_row, 6);
154    }
155
156    #[test]
157    fn ensure_visible_scrolls_up() {
158        let mut v = vp(20, 5);
159        v.ensure_visible(Position::new(15, 0));
160        assert_eq!(v.top_row, 15);
161    }
162
163    #[test]
164    fn ensure_visible_no_scroll_when_inside() {
165        let mut v = vp(10, 5);
166        v.ensure_visible(Position::new(12, 4));
167        assert_eq!(v.top_row, 10);
168    }
169
170    #[test]
171    fn ensure_visible_zero_dim_is_noop() {
172        let mut v = Viewport::default();
173        v.ensure_visible(Position::new(100, 100));
174        assert_eq!(v.top_row, 0);
175        assert_eq!(v.top_col, 0);
176    }
177
178    #[test]
179    fn is_big_viewport_jump_within_one_height_is_not_big() {
180        // Scroll within ±1 viewport-height stays in the over-provisioned band.
181        assert!(!is_big_viewport_jump(100, 100, 30));
182        assert!(!is_big_viewport_jump(100, 130, 30));
183        assert!(!is_big_viewport_jump(100, 70, 30));
184    }
185
186    #[test]
187    fn is_big_viewport_jump_past_one_height_is_big() {
188        // gg from row 500 to row 0 — clearly past 30.
189        assert!(is_big_viewport_jump(500, 0, 30));
190        // G to last row from row 0.
191        assert!(is_big_viewport_jump(0, 9999, 30));
192        // Exactly one height + 1 row is the boundary (jump > viewport_height).
193        assert!(is_big_viewport_jump(0, 31, 30));
194        // Exactly viewport_height is NOT a big jump (the row at the band's edge).
195        assert!(!is_big_viewport_jump(0, 30, 30));
196    }
197}