Skip to content

Commit 950c3a4

Browse files
committed
fix(viewport): eliminate O(N) freeze when opening large files
ensure_visible() iterated from scroll_position (0) to selected_index (52M+ for large files) on every resolve, freezing the app. The nested scroll-forward loop made it O(N²) in the worst case. Fix: when the gap exceeds 2× viewport height, jump scroll_position directly near the selection, then fine-tune in O(height). Also replace the O(N²) nested scroll loop with incremental subtraction.
1 parent 84ef9c2 commit 950c3a4

File tree

1 file changed

+43
-32
lines changed

1 file changed

+43
-32
lines changed

src/app/viewport.rs

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -131,47 +131,50 @@ impl Viewport {
131131

132132
let padding = self.edge_padding.min(self.height / 4);
133133

134-
// Count visual rows from scroll_position to selected_index
135-
let mut rows_above: usize = 0;
136-
for i in self.scroll_position..selected_index.min(total_lines) {
137-
rows_above += line_height(i);
138-
}
139-
let selected_h = line_height(selected_index.min(total_lines - 1));
140-
let total_rows_through_selected = rows_above + selected_h;
141-
142134
// Selection is above the viewport — scroll up
143135
if selected_index < self.scroll_position {
144136
self.scroll_position = selected_index;
145-
// Apply top padding: try to show `padding` visual rows above
146137
let mut pad_rows = 0;
147138
while self.scroll_position > 0 && pad_rows < padding {
148139
self.scroll_position -= 1;
149140
pad_rows += line_height(self.scroll_position);
150141
}
142+
return;
143+
}
144+
145+
// Fast path: if selection is far below scroll_position, jump directly
146+
// near the selection instead of counting millions of rows.
147+
let gap = selected_index - self.scroll_position;
148+
if gap > self.height * 2 {
149+
// Jump scroll_position close to selection, then fine-tune below
150+
self.scroll_position = selected_index.saturating_sub(self.height.saturating_sub(1));
151+
}
152+
153+
// Count visual rows from scroll_position to selected_index
154+
let mut rows_above: usize = 0;
155+
let end = selected_index.min(total_lines);
156+
for i in self.scroll_position..end {
157+
rows_above += line_height(i);
151158
}
159+
let selected_h = line_height(selected_index.min(total_lines - 1));
160+
let total_rows_through_selected = rows_above + selected_h;
161+
152162
// Selection is below the viewport — scroll down
153-
else if total_rows_through_selected > self.height {
163+
if total_rows_through_selected > self.height {
154164
// Walk scroll_position forward until selected line fits on screen
155-
while total_lines > 0 && self.scroll_position < selected_index {
156-
let mut rows: usize = 0;
157-
for i in self.scroll_position..=selected_index.min(total_lines - 1) {
158-
rows += line_height(i);
159-
}
160-
if rows <= self.height {
161-
break;
162-
}
165+
let mut rows = total_rows_through_selected;
166+
while rows > self.height && self.scroll_position < selected_index {
167+
rows -= line_height(self.scroll_position);
163168
self.scroll_position += 1;
164169
}
165-
// Apply bottom padding: try to show `padding` visual rows below
166-
// by scrolling further if there's content below
170+
// Apply bottom padding
167171
let mut pad_rows = 0;
168172
let mut pad_idx = selected_index + 1;
169173
while pad_idx < total_lines && pad_rows < padding {
170174
pad_rows += line_height(pad_idx);
171175
pad_idx += 1;
172176
}
173177
if pad_rows > 0 {
174-
// Check if padding lines fit; if not, scroll more
175178
let mut rows: usize = 0;
176179
for i in self.scroll_position..pad_idx.min(total_lines) {
177180
rows += line_height(i);
@@ -184,12 +187,10 @@ impl Viewport {
184187
}
185188
// Selection is within the visible area — check edge padding
186189
else if padding > 0 {
187-
// Top padding: visual rows from scroll_position to selected
190+
// Top padding
188191
let mut top_visual = 0;
189-
let mut top_count = 0;
190192
for i in self.scroll_position..selected_index {
191193
top_visual += line_height(i);
192-
top_count += 1;
193194
if top_visual >= padding {
194195
break;
195196
}
@@ -202,9 +203,8 @@ impl Viewport {
202203
}
203204
}
204205

205-
// Bottom padding: visual rows from selected to end of viewport
206+
// Bottom padding
206207
let rows_after = self.height.saturating_sub(total_rows_through_selected);
207-
// Count visual rows of `padding` lines below selection
208208
let mut pad_visual = 0;
209209
let mut i = selected_index + 1;
210210
let mut pad_count = 0;
@@ -213,7 +213,6 @@ impl Viewport {
213213
pad_count += 1;
214214
i += 1;
215215
}
216-
let _ = top_count; // used for padding logic above
217216
if rows_after < pad_visual.min(padding) {
218217
let mut excess = pad_visual.min(padding) - rows_after;
219218
while excess > 0 && self.scroll_position < selected_index {
@@ -224,11 +223,7 @@ impl Viewport {
224223
}
225224
}
226225

227-
// Clamp: don't scroll past the point where last line is at bottom
228-
// Compute max_scroll in visual terms: find earliest scroll_position
229-
// where the last line still fits on screen.
230-
// For simplicity (and O(1) for non-wrap), just ensure scroll_position
231-
// doesn't exceed total_lines - 1.
226+
// Clamp scroll_position
232227
if self.scroll_position >= total_lines {
233228
self.scroll_position = total_lines.saturating_sub(1);
234229
}
@@ -846,4 +841,20 @@ mod tests {
846841
assert_eq!(vp.scroll_position, 0);
847842
assert_eq!(vp.selected_line(), 9);
848843
}
844+
845+
#[test]
846+
fn test_resolve_large_file_scroll_position_zero() {
847+
// Simulates opening a large file where anchor is at the end
848+
// but scroll_position starts at 0. Must not iterate millions of lines.
849+
let total = 50_000_000;
850+
let mut vp = Viewport::new(total - 1);
851+
vp.scroll_position = 0;
852+
let lines: Vec<usize> = (0..total).collect();
853+
854+
let view = vp.resolve(&lines, 50);
855+
856+
assert_eq!(view.selected_index, total - 1);
857+
// scroll_position should be near the end
858+
assert!(view.scroll_position >= total - 50);
859+
}
849860
}

0 commit comments

Comments
 (0)