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#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn vp(top_row: usize, height: u16) -> Viewport {
113 Viewport {
114 top_row,
115 top_col: 0,
116 width: 80,
117 height,
118 wrap: Wrap::None,
119 text_width: 80,
120 tab_width: 0,
121 }
122 }
123
124 #[test]
125 fn contains_inside_window() {
126 let v = vp(10, 5);
127 assert!(v.contains(Position::new(10, 0)));
128 assert!(v.contains(Position::new(14, 79)));
129 }
130
131 #[test]
132 fn contains_outside_window() {
133 let v = vp(10, 5);
134 assert!(!v.contains(Position::new(9, 0)));
135 assert!(!v.contains(Position::new(15, 0)));
136 assert!(!v.contains(Position::new(12, 80)));
137 }
138
139 #[test]
140 fn ensure_visible_scrolls_down() {
141 let mut v = vp(0, 5);
142 v.ensure_visible(Position::new(10, 0));
143 assert_eq!(v.top_row, 6);
144 }
145
146 #[test]
147 fn ensure_visible_scrolls_up() {
148 let mut v = vp(20, 5);
149 v.ensure_visible(Position::new(15, 0));
150 assert_eq!(v.top_row, 15);
151 }
152
153 #[test]
154 fn ensure_visible_no_scroll_when_inside() {
155 let mut v = vp(10, 5);
156 v.ensure_visible(Position::new(12, 4));
157 assert_eq!(v.top_row, 10);
158 }
159
160 #[test]
161 fn ensure_visible_zero_dim_is_noop() {
162 let mut v = Viewport::default();
163 v.ensure_visible(Position::new(100, 100));
164 assert_eq!(v.top_row, 0);
165 assert_eq!(v.top_col, 0);
166 }
167}