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