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
//! Cursor-jump primitives that guarantee viewport visibility.
//!
//! All "navigate the cursor to a byte offset" flows — search next/prev,
//! LSP go-to-definition, jump-to-line, diagnostic jumps, plugin
//! `scrollBufferToLine`, etc. — should funnel through this module instead
//! of mutating `cursors` and calling `ensure_cursor_visible` directly.
//!
//! The lower-level [`view::split::BufferViewState::ensure_cursor_visible`]
//! has several short-circuit paths (the `skip_ensure_visible` flag set by
//! prior scroll actions, the `top_view_line_offset > 0` early-return for
//! wrapped buffers, `skip_resize_sync`) that can leave a freshly-set cursor
//! stranded outside the viewport. That class of bug — "status bar updates
//! but the page never moves" — is why every navigation primitive here ends
//! with a *post-condition check*: if the cursor is still off-screen when
//! the call returns, we force a hard recenter (issue #1689).
//!
//! Use [`Editor::ensure_active_cursor_visible_for_navigation`] right after
//! any explicit cursor mutation that represents a user-visible jump. Use
//! [`Editor::jump_active_cursor_to`] when the call site can also delegate
//! the cursor mutation itself.
//!
//! Edits (typing, paste, indent, …) should keep using the existing
//! `ensure_cursor_visible` path — they want the "don't undo a deliberate
//! scroll" behavior of the skip flag.
use crate::model::buffer::LineNumber;
/// Whether the active cursor should be vertically recentered when a jump
/// causes the viewport to scroll, and whether the selection anchor should
/// be reset.
#[derive(Clone, Copy, Debug)]
pub struct JumpOptions {
/// If `true`, drop the selection anchor (the jump becomes a plain move).
/// Set to `false` to extend the selection from the previous anchor.
pub clear_anchor: bool,
/// If the jump caused the viewport to scroll *or* the post-condition
/// safety net had to fire, recenter the cursor vertically. This is the
/// behavior search/LSP/error navigation want — a cold landing spot
/// should show context above and below.
pub recenter_on_scroll: bool,
}
impl Default for JumpOptions {
fn default() -> Self {
Self {
clear_anchor: true,
recenter_on_scroll: true,
}
}
}
impl JumpOptions {
/// Convenience: defaults for navigation jumps (clear anchor, recenter).
pub fn navigation() -> Self {
Self::default()
}
}
impl crate::app::window::Window {
/// Move the active cursor to `position` and guarantee that position is
/// rendered in the active viewport.
///
/// This is the canonical "jump the cursor somewhere" entry point. It
/// performs a direct cursor mutation (no `MoveCursor` event, no undo
/// entry, no `cursor_moved` plugin hook) and then funnels through
/// [`Editor::ensure_active_cursor_visible_for_navigation`] for the
/// visibility invariant.
///
/// Callers that need a `MoveCursor` event (undo + plugin hooks) should
/// build the event themselves and call
/// [`Editor::ensure_active_cursor_visible_for_navigation`] afterwards.
pub fn jump_active_cursor_to(&mut self, position: usize, opts: JumpOptions) {
let active_split = self
.buffers
.splits()
.map(|(mgr, _)| mgr)
.expect("active window must have a populated split layout")
.active_split();
let active_buffer = self.active_buffer();
if let Some(view_state) = Some(&mut *self)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&active_split)
{
view_state.cursors.primary_mut().position = position;
if opts.clear_anchor {
view_state.cursors.primary_mut().anchor = None;
}
if let Some(state) = (&mut self.buffers).get_mut(&active_buffer) {
if let Some(pos) = state.buffer.offset_to_position(position) {
state.primary_cursor_line_number = LineNumber::Absolute(pos.line);
}
}
}
self.ensure_active_cursor_visible_for_navigation(opts.recenter_on_scroll);
}
/// Guarantee the active cursor is visible in the active viewport.
///
/// Call this immediately after any cursor mutation that represents a
/// programmatic jump (search match, goto-definition, jump-to-line,
/// next-error, plugin scroll-to-position). It:
///
/// 1. Clears `skip_ensure_visible` so a stale prior scroll does not
/// suppress this one.
/// 2. Calls the lower-level `ensure_cursor_visible`.
/// 3. **Verifies** the cursor's line is now within the viewport's line
/// range. If it isn't (the lower-level routine short-circuited, or
/// `view_lines`-aware logic disagreed with byte-line math), forces a
/// hard recenter so the cursor lands roughly mid-viewport.
/// 4. If the visible range moved at all and `recenter_on_scroll` is
/// set, recenters for context.
///
/// Step 3 is the safety net that makes "cursor moves but viewport
/// stalls" (#1689) impossible to reproduce regardless of what the
/// lower-level scroll machinery decides to do.
pub fn ensure_active_cursor_visible_for_navigation(&mut self, recenter_on_scroll: bool) {
let active_buffer = self.active_buffer();
self.ensure_cursor_visible_for_navigation(active_buffer, recenter_on_scroll);
}
}
impl crate::app::window::Window {
/// Window-level navigation visibility primitive — see
/// [`Editor::ensure_active_cursor_visible_for_navigation`] for
/// the full contract. Operates on the active split of this
/// window and the supplied buffer (typically the
/// caller-resolved `active_buffer()`).
pub fn ensure_cursor_visible_for_navigation(
&mut self,
active_buffer: crate::model::event::BufferId,
recenter_on_scroll: bool,
) {
let Some(active_split) = self.buffers.split_manager().map(|m| m.active_split()) else {
return;
};
self.buffers
.with_buffer_and_split(active_buffer, active_split, |state, view_state| {
// 1. Clear stale skip flag — a prior recenter (or scroll action) may
// have set it, but this navigation step is *new user intent* and must
// not be silently suppressed.
view_state.viewport.clear_skip_ensure_visible();
let cursor_pos = view_state.cursors.primary().position;
let top_byte_before = view_state.viewport.top_byte;
// 2. Best-effort scroll via the existing line-aware routine.
view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
let scrolled = view_state.viewport.top_byte != top_byte_before;
// 3. Post-condition check — derive line numbers (cheap, exact for
// non-large files; estimated for large files) and confirm the cursor
// line lies within the viewport's line range. If it doesn't, the
// lower-level routine bailed out for one of its skip-paths and we
// must force a recenter.
let cursor_visible = is_cursor_line_visible(view_state, &state.buffer, cursor_pos);
let needs_recenter = !cursor_visible || (scrolled && recenter_on_scroll);
if needs_recenter {
// Count real visual rows so a recenter in a wrapped
// document doesn't under-scroll and leave the cursor
// below the viewport — each logical line above the
// cursor can span many rows (e.g. an EPUB/XML paragraph
// on one very long line).
view_state
.viewport
.center_on_position(&mut state.buffer, cursor_pos);
view_state.viewport.scrolled_up_in_wrap = false;
view_state.viewport.set_skip_ensure_visible();
}
// 4. Horizontal scroll. The byte-oriented `ensure_cursor_visible`
// doesn't adjust `left_column`; for matches deep inside a long
// line (an EPUB XML element, a minified bundle, …) the cursor
// is on the right line but its column is past the viewport —
// the user sees an unchanged screen and has to scroll
// horizontally manually. See §5 of
// docs/internal/search-replace-scope-replan-on-widgets.md
// and #1873.
//
// Skip when line wrapping is on (every column reaches the eye
// via wrap) and when the gutter/scrollbar reservation leaves
// no usable visible width.
if !view_state.viewport.line_wrap_enabled {
let cursor_visual_col = visual_column_of(&mut state.buffer, cursor_pos);
let gutter_width = if view_state.show_line_numbers { 6 } else { 0 };
let scrollbar_width = 1;
let visible_width = (view_state.viewport.width as usize)
.saturating_sub(gutter_width)
.saturating_sub(scrollbar_width);
if visible_width > 0 {
let left = view_state.viewport.left_column;
let right = left + visible_width;
// Small margin so the cursor isn't pinned to the very
// edge — mirrors `ensure_column_visible_simple`'s
// `effective_offset` behaviour.
let margin = (visible_width / 8).min(8);
if cursor_visual_col < left + margin {
view_state.viewport.left_column =
cursor_visual_col.saturating_sub(margin);
} else if cursor_visual_col + margin >= right {
view_state.viewport.left_column =
(cursor_visual_col + margin + 1).saturating_sub(visible_width);
}
}
}
});
}
}
/// Visual column for `cursor_pos` on its source line. Best-effort:
/// counts terminal cell widths via `UnicodeWidthChar` (matching what
/// the layout-aware viewport math uses). Tabs collapse to 1 since
/// this layer doesn't have the buffer's tab-width setting handy.
fn visual_column_of(buffer: &mut crate::model::buffer::Buffer, cursor_pos: usize) -> usize {
use unicode_width::UnicodeWidthChar;
let cursor_line = buffer.get_line_number(cursor_pos);
let line_start = buffer.line_start_offset(cursor_line).unwrap_or(cursor_pos);
if cursor_pos <= line_start {
return 0;
}
let len = cursor_pos - line_start;
let bytes = match buffer.get_text_range_mut(line_start, len) {
Ok(b) => b,
Err(_) => return 0,
};
let s = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => return 0,
};
let mut col = 0usize;
for ch in s.chars() {
col += UnicodeWidthChar::width(ch).unwrap_or(0);
}
col
}
/// Approximate visibility check using line numbers. False negatives only —
/// if we say "not visible" when it actually is, the helper recenters
/// unnecessarily but still leaves the cursor on screen, which is
/// observably indistinguishable from the no-op case.
fn is_cursor_line_visible(
view_state: &crate::view::split::BufferViewState,
buffer: &crate::model::buffer::Buffer,
cursor_pos: usize,
) -> bool {
let viewport = &view_state.viewport;
let top_line = buffer.get_line_number(viewport.top_byte);
let cursor_line = buffer.get_line_number(cursor_pos);
let viewport_height = viewport.visible_line_count();
cursor_line >= top_line && cursor_line < top_line.saturating_add(viewport_height)
}
/// Reconcile a freshly-restored `(buf_state.viewport, buf_state.cursors)` pair
/// so the cursor is guaranteed visible.
///
/// Session/workspace restore re-applies the previously-saved viewport
/// `top_byte` (and `top_view_line_offset` in wrap mode) and the previously-
/// saved cursor position independently. If those two were *already* out of
/// sync at save time — for example because the cursor moved off-screen via a
/// prior bug or via plugin scroll-to-position — the restore re-creates an
/// off-screen cursor that arrow keys can't escape (the wrap-mode early
/// return in `viewport.rs::ensure_visible` kicks in for any cursor whose
/// byte position is `>= viewport.top_byte`, which is true for *all* cursors
/// below the viewport top — so naive Up/Down can never bring the viewport
/// back to the cursor).
///
/// Call this on each restored buffer's state right after writing the
/// scroll/cursor fields. If the cursor's line is already visible inside the
/// restored viewport this is a no-op — we keep the user's saved scroll
/// position for free. If not, recenter so the cursor lands mid-viewport
/// (#1689 follow-up).
pub(crate) fn reconcile_restored_buffer_view(
buf_state: &mut crate::view::split::BufferViewState,
buffer: &mut crate::model::buffer::Buffer,
) {
let cursor_pos = buf_state.cursors.primary().position;
if is_cursor_line_visible(buf_state, buffer, cursor_pos) {
return;
}
let viewport_height = buf_state.viewport.visible_line_count();
let target_rows_from_top = viewport_height / 2;
let mut iter = buffer.line_iterator(cursor_pos, 80);
for _ in 0..target_rows_from_top {
if iter.prev().is_none() {
break;
}
}
buf_state.viewport.top_byte = iter.current_position();
buf_state.viewport.top_view_line_offset = 0;
// Restore code already calls set_skip_resize_sync; we don't need to also
// pin against ensure_visible because the next render will see the cursor
// is already inside the viewport range we just chose.
}