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
//! Viewport / scroll arithmetic for `EditorState`.
//!
//! Scroll bounds are measured in **visual** rows for Rendered/Preview mode
//! (long lines wrap at `viewport_width` and consume multiple rows) and in
//! buffer lines for Raw mode. Methods here keep that distinction
//! transparent to callers: pass viewport width and let the implementation
//! pick the right ruler.
use crate::document::visual_cache::VisualRowCache;
use crate::editor::state::{line_text_trimmed, raw_cursor_visual_row, rendered_cursor_visual_row};
use crate::editor::{EditorState, Mode};
/// Raw-mode visual-row cache entry. Pairs a [`VisualRowCache`] with the
/// `Buffer::version()` it was built from so a cheap `u64` comparison
/// detects edits that have invalidated the cached prefix sum.
///
/// Width-mismatch invalidation is handled by [`VisualRowCache`] itself —
/// only the buffer-version key is unique to raw mode.
#[derive(Debug, Clone)]
pub(crate) struct RawVisualRowCache {
/// `Buffer::version()` snapshot at the time the cache was built.
buffer_version: u64,
/// The actual prefix-sum table.
inner: VisualRowCache,
}
impl EditorState {
pub fn scroll_up(&mut self, n: usize) {
self.scroll = self.scroll.saturating_sub(n);
}
/// Scroll down by `n` visual rows. The maximum scroll is set so that the
/// last visual row can reach the very top of the viewport.
pub fn scroll_down(&mut self, n: usize, _viewport_height: usize) {
let total = self.total_visual_rows_for_mode(self.viewport_width);
let max = total.saturating_sub(1);
self.scroll = (self.scroll + n).min(max);
}
pub fn scroll_to_top(&mut self) {
self.scroll = 0;
}
/// Scroll so the last document line sits at the bottom of the viewport.
///
/// `viewport_width` is used to compute visual-wrap-aware scroll positions
/// in Rendered/Preview mode, where long rendered lines may wrap onto
/// multiple visual rows. In Raw mode visibility is measured by logical
/// buffer lines, so `viewport_width` is ignored.
pub fn scroll_to_bottom(&mut self, viewport_height: usize, viewport_width: usize) {
let total = self.total_visual_rows_for_mode(viewport_width);
if total == 0 {
self.scroll = 0;
} else {
self.scroll = total.saturating_sub(viewport_height);
}
}
/// Smallest scroll offset such that rendered line `target_last` fits on the
/// last visual row of a viewport of `viewport_height` rows, accounting for
/// word-wrap at `viewport_width`. Walks backward from `target_last`,
/// accumulating visual rows, and stops when adding another line would
/// overflow the viewport.
#[allow(dead_code)]
pub(crate) fn scroll_for_last_visible(
&self,
target_last: usize,
viewport_height: usize,
viewport_width: usize,
) -> usize {
if viewport_height == 0 {
return target_last;
}
let lines = &self.parsed.lines;
if lines.is_empty() {
return 0;
}
let target_last = target_last.min(lines.len() - 1);
let mut rows_used = 0usize;
let mut line_idx = target_last;
loop {
// O(1) cache lookup — the historical inline `visual_rows_for_line`
// call here was a per-keystroke cost on long documents.
let rows = self
.parsed
.visual_rows_for_line_at(line_idx, viewport_width);
if rows_used + rows > viewport_height {
// Including this line would overflow — start from the next one.
return line_idx + 1;
}
rows_used += rows;
if line_idx == 0 {
return 0;
}
line_idx -= 1;
}
}
/// If the cursor has scrolled above the top of the viewport (because the
/// user scrolled down past it), move the cursor to the first visible line.
/// No-op in Preview mode (no editing cursor there).
pub fn clamp_cursor_to_viewport_top(&mut self) {
if self.mode == Mode::Preview {
return;
}
let cursor_row = self.cursor_visual_row(self.viewport_width);
if cursor_row < self.scroll {
self.cursor.offset = self.char_offset_at_visual_row(self.scroll, self.viewport_width);
self.cursor.preferred_col = self.cursor.cell_col(&self.buffer);
self.update_cursor_block();
}
}
/// Total visual rows to use for scroll-bound calculations, based on current mode.
pub fn total_visual_rows_for_mode(&self, width: usize) -> usize {
match self.mode {
Mode::Raw => self.raw_total_visual_rows(width),
Mode::Diff => self
.diff
.as_ref()
.map(|d| d.total_visual_rows(width))
.unwrap_or(0),
_ => self.parsed.total_visual_rows(width),
}
}
/// Ensure the cursor is visible within the viewport.
///
/// In Raw mode, visibility is based on buffer line numbers.
/// In Rendered/Preview mode, visibility is measured in visual rows —
/// long rendered lines wrap at `viewport_width` and consume multiple rows,
/// so scroll bounds must account for that or the last lines of the
/// document get pushed off-screen.
pub fn ensure_cursor_visible(&mut self, viewport_height: usize, viewport_width: usize) {
if viewport_height == 0 {
return;
}
let cursor_row = self.cursor_visual_row(viewport_width);
if cursor_row < self.scroll {
self.scroll = cursor_row;
} else if cursor_row >= self.scroll + viewport_height {
self.scroll = cursor_row + 1 - viewport_height;
}
}
/// Sum of visual rows for rendered lines `first..=last`, wrapped at
/// `width`. Delegates to the per-frame visual-row cache. Used by
/// tests in this crate.
#[allow(dead_code)]
pub(crate) fn visual_rows_between(&self, first: usize, last: usize, width: usize) -> usize {
self.parsed.visual_rows_between(first, last, width)
}
pub fn rendered_line_at_visual_row(&self, visual_row: usize, width: usize) -> (usize, usize) {
self.parsed.line_at_visual_row(visual_row, width)
}
pub fn raw_line_at_visual_row(&self, visual_row: usize, width: usize) -> (usize, usize) {
self.with_raw_visual_cache(width, |c| c.find_visual_row(visual_row))
}
pub fn visual_rows_before_raw_line(&self, line_idx: usize, width: usize) -> usize {
self.with_raw_visual_cache(width, |c| c.before(line_idx))
}
pub(crate) fn raw_total_visual_rows(&self, width: usize) -> usize {
self.with_raw_visual_cache(width, |c| c.total())
}
/// Run `f` against the raw-mode visual-row cache, rebuilding it first
/// if either the buffer version or the width has changed since the
/// last build. Keeps a small LRU of recent (width) entries — the
/// editor-view layout queries at two distinct widths per frame
/// (the full doc-area width to decide on a scrollbar gutter, and
/// the post-gutter width for scrollbar metrics + `viewport_width`),
/// so a single-slot cache thrashes on every frame. Two-phase borrow
/// keeps the immutable check separate from the `borrow_mut` so we
/// don't alias the `RefCell`.
fn with_raw_visual_cache<R>(&self, width: usize, f: impl FnOnce(&VisualRowCache) -> R) -> R {
/// Distinct widths kept warm — must be ≥ 2 so the per-frame
/// "decide bar / display bar" two-width pattern doesn't churn.
const LRU_CAP: usize = 2;
let width = width.max(1);
let buffer_version = self.buffer.version();
// Promote-or-build pass. If a warm entry matches both the
// buffer version and width, move it to the front of the LRU.
// Otherwise build a fresh entry and prepend, evicting beyond
// LRU_CAP. A buffer-version mismatch invalidates the entire
// cache because every entry is keyed off the same buffer.
let mut entries = self.raw_visual_rows.borrow_mut();
if entries
.first()
.is_some_and(|e| e.buffer_version != buffer_version)
{
entries.clear();
}
if let Some(pos) = entries
.iter()
.position(|e| e.buffer_version == buffer_version && e.inner.width() == width)
{
let entry = entries.remove(pos);
entries.insert(0, entry);
} else {
let inner = VisualRowCache::build(self.buffer.line_count(), width, |i| {
let text = line_text_trimmed(&self.buffer, i);
crate::ui::line_render::visual_rows_of_str(&text, width).len()
});
entries.insert(
0,
RawVisualRowCache {
buffer_version,
inner,
},
);
entries.truncate(LRU_CAP);
}
drop(entries);
let borrow = self.raw_visual_rows.borrow();
f(&borrow
.first()
.expect("raw visual cache populated above")
.inner)
}
pub(crate) fn cursor_visual_row(&self, width: usize) -> usize {
match self.mode {
Mode::Raw => raw_cursor_visual_row(self, width),
_ => rendered_cursor_visual_row(self, width),
}
}
pub(crate) fn char_offset_at_visual_row(&self, visual_row: usize, width: usize) -> usize {
match self.mode {
Mode::Raw => {
let (line, sub) = self.raw_line_at_visual_row(visual_row, width);
if line >= self.buffer.line_count() {
return self.buffer.len_chars();
}
let text = line_text_trimmed(&self.buffer, line);
let rows = crate::ui::line_render::visual_rows_of_str(&text, width.max(1));
let raw_col = rows.get(sub).map(|r| r.0).unwrap_or(0);
self.buffer.line_to_char(line) + raw_col
}
_ => {
let (line_idx, _sub) = self.rendered_line_at_visual_row(visual_row, width.max(1));
if line_idx >= self.parsed.lines.len() {
return self.buffer.len_chars();
}
self.parsed
.source_map
.original_byte_for_rendered_line(line_idx)
.map(|byte| {
self.buffer
.rope()
.byte_to_char(byte)
.min(self.buffer.len_chars())
})
.unwrap_or(self.buffer.len_chars())
}
}
}
}