edtui/state/
view.rs

1use crate::{
2    helper::{char_width, chars_width},
3    view::line_wrapper::LineWrapper,
4    Lines,
5};
6use ratatui::layout::Rect;
7
8/// Represents the (x, y) offset of the editor's viewport.
9/// It represents the top-left local editor coordinate.
10#[derive(Debug, Clone)]
11pub(crate) struct ViewState {
12    /// The offset of the viewport.
13    pub(crate) viewport: Offset,
14    /// The number of rows that are displayed on the viewport
15    pub(crate) num_rows: usize,
16    /// Sets the area (starting upper-left corner of the terminal window) where
17    /// the editor text is rendered to.
18    ///
19    /// Required to calculate the mouse position in relation to the text within the editor.
20    pub(crate) screen_area: Rect,
21    /// Whether the lines are wrapped.
22    pub(crate) wrap: bool,
23    /// The number of spaces used to display a tab.
24    pub(crate) tab_width: usize,
25}
26
27impl Default for ViewState {
28    fn default() -> Self {
29        Self {
30            viewport: Offset::default(),
31            num_rows: 0,
32            screen_area: Rect::default(),
33            wrap: true,
34            tab_width: 2,
35        }
36    }
37}
38
39#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
40pub(crate) struct Offset {
41    /// The x-offset.
42    pub(crate) x: usize,
43    /// The y-offset.
44    pub(crate) y: usize,
45}
46
47impl Offset {
48    pub(crate) fn new(x: usize, y: usize) -> Self {
49        Self { x, y }
50    }
51}
52
53impl From<Rect> for Offset {
54    fn from(value: Rect) -> Self {
55        Self {
56            x: value.x as usize,
57            y: value.y as usize,
58        }
59    }
60}
61
62impl ViewState {
63    /// Sets the editors area on the screen.
64    ///
65    /// Equivalent to the upper left coordinate of the editor in the
66    /// buffers coordinate system.
67    pub(crate) fn set_screen_area<T: Into<Rect>>(&mut self, area: T) {
68        self.screen_area = area.into();
69    }
70
71    /// Updates the viewports horizontal offset.
72    pub(crate) fn update_viewport_horizontal(
73        &mut self,
74        width: usize,
75        cursor_col: usize,
76        line: Option<&Vec<char>>,
77    ) -> usize {
78        let Some(line) = line else {
79            self.viewport.x = 0;
80            return self.viewport.x;
81        };
82
83        // scroll left
84        if cursor_col < self.viewport.x {
85            self.viewport.x = cursor_col;
86            return self.viewport.x;
87        }
88
89        // Iterate forward from the viewport.x position and calculate width
90        let mut max_cursor_pos = self.viewport.x;
91        let mut current_width = 0;
92        for &ch in line.iter().skip(self.viewport.x) {
93            current_width += char_width(ch, self.tab_width);
94            if current_width >= width {
95                break;
96            }
97            max_cursor_pos += 1;
98        }
99
100        // scroll right
101        if cursor_col > max_cursor_pos {
102            let mut backward_width = 0;
103            let mut new_viewport_x = cursor_col;
104
105            // Iterate backward from max_cursor_pos to find the first fitting character
106            for i in (0..=cursor_col).rev() {
107                let char_width = match line.get(i) {
108                    Some(&ch) => char_width(ch, self.tab_width),
109                    None => 1,
110                };
111                backward_width += char_width;
112                if backward_width >= width {
113                    break;
114                }
115                new_viewport_x = new_viewport_x.saturating_sub(1);
116            }
117
118            self.viewport.x = new_viewport_x;
119        }
120
121        self.viewport.x
122    }
123
124    /// Updates the view ports vertical offset.
125    pub(crate) fn update_viewport_vertical(&mut self, height: usize, cursor_row: usize) -> usize {
126        let max_cursor_pos = height.saturating_sub(1) + self.viewport.y;
127
128        // scroll up
129        if cursor_row < self.viewport.y {
130            self.viewport.y = cursor_row;
131        }
132
133        // scroll down
134        if cursor_row >= max_cursor_pos {
135            self.viewport.y += cursor_row.saturating_sub(max_cursor_pos);
136        }
137
138        self.viewport.y
139    }
140
141    /// Updates the view ports vertical offset.
142    pub(crate) fn update_viewport_vertical_wrap(
143        &mut self,
144        width: usize,
145        height: usize,
146        cursor_row: usize,
147        lines: &Lines,
148    ) -> usize {
149        // scroll up
150        if cursor_row < self.viewport.y {
151            self.viewport.y = cursor_row;
152        }
153
154        // scroll down
155        self.scroll_down(lines, width, height, cursor_row);
156
157        self.viewport.y
158    }
159
160    /// Updates the number of rows that are currently shown on the viewport.
161    /// Refers to the number of editor lines, not visual lines.
162    pub(crate) fn update_num_rows(&mut self, num_rows: usize) {
163        self.num_rows = num_rows;
164    }
165
166    /// Scrolls the viewport down based on the cursor's row position.
167    ///
168    /// This function adjusts the viewport to ensure that the cursor remains visible
169    /// when moving down in a list of lines. It calculates the required scrolling
170    /// based on the line width and wraps the content to fit within the maximum width and height.
171    ///
172    /// # Behavior
173    ///
174    /// If the cursor is already visible within the current viewport, no action is taken.
175    /// Otherwise, the function calculates how many rows the content would need to wrap,
176    /// and adjusts the viewport accordingly.
177    fn scroll_down(
178        &mut self,
179        lines: &Lines,
180        max_width: usize,
181        max_height: usize,
182        cursor_row: usize,
183    ) {
184        // If the cursor is already within the viewport, or there are no rows to display, return early.
185        if cursor_row < self.viewport.y + self.num_rows || self.num_rows == 0 {
186            return;
187        }
188
189        let mut remaining_height = max_height;
190
191        let skip = lines.len().saturating_sub(cursor_row + 1);
192        for (i, line) in lines.iter_row().rev().skip(skip).enumerate() {
193            let line_width = chars_width(line, self.tab_width);
194            let current_row_height = LineWrapper::determine_split(line_width, max_width).len();
195
196            // If we run out of height or exceed it, scroll the viewport.
197            if remaining_height < current_row_height {
198                let first_visible_row = cursor_row.saturating_sub(i.saturating_sub(1));
199                self.viewport.y = first_visible_row;
200                break;
201            }
202
203            // Subtract the number of wrapped rows from the remaining height.
204            remaining_height = remaining_height.saturating_sub(current_row_height);
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    macro_rules! update_view_vertical_test {
214        ($name:ident: {
215        view: $given_view:expr,
216        height: $given_height:expr,
217        cursor: $given_cursor:expr,
218        expected: $expected_offset:expr
219    }) => {
220            #[test]
221            fn $name() {
222                // given
223                let mut view = $given_view;
224                let height = $given_height;
225                let cursor = $given_cursor;
226
227                // when
228                let offset = view.update_viewport_vertical(height, cursor);
229
230                // then
231                assert_eq!(offset, $expected_offset);
232            }
233        };
234    }
235
236    macro_rules! update_view_horizontal_test {
237        ($name:ident: {
238        view: $given_view:expr,
239        width: $given_width:expr,
240        cursor: $given_cursor:expr,
241        expected: $expected_offset:expr
242    }) => {
243            #[test]
244            fn $name() {
245                // given
246                let mut view = $given_view;
247                let width = $given_width;
248                let cursor = $given_cursor;
249                let line = vec![];
250
251                // when
252                let offset = view.update_viewport_horizontal(width, cursor, Some(&line));
253
254                // then
255                assert_eq!(offset, $expected_offset);
256            }
257        };
258    }
259
260    update_view_vertical_test!(
261        // 0      | --<-
262        // 1 --<- | ----
263        // 2 ---- |
264        scroll_up: {
265            view: ViewState{
266                viewport: Offset::new(0, 1),
267                ..Default::default()
268            },
269            height:  2,
270            cursor: 0,
271            expected: 0
272        }
273    );
274
275    update_view_vertical_test!(
276        // 0 ---- |
277        // 1 ---- | ----
278        // 2 <-   | --<-
279        scroll_down: {
280            view: ViewState{
281                viewport: Offset::new(0, 0),
282                ..Default::default()
283            },
284            height:  2,
285            cursor: 2,
286            expected: 1
287        }
288    );
289
290    update_view_horizontal_test!(
291        scroll_left: {
292            view: ViewState{
293                viewport: Offset::new(1, 0),
294                ..Default::default()
295            },
296            width: 2,
297            cursor: 0,
298            expected: 0
299        }
300    );
301
302    update_view_horizontal_test!(
303        scroll_right: {
304            view: ViewState{
305                viewport: Offset::new(0, 0),
306                ..Default::default()
307            },
308            width: 2,
309            cursor: 2,
310            expected: 1
311        }
312    );
313}