Skip to main content

fresh/app/
navigation.rs

1//! Cursor-jump primitives that guarantee viewport visibility.
2//!
3//! All "navigate the cursor to a byte offset" flows — search next/prev,
4//! LSP go-to-definition, jump-to-line, diagnostic jumps, plugin
5//! `scrollBufferToLine`, etc. — should funnel through this module instead
6//! of mutating `cursors` and calling `ensure_cursor_visible` directly.
7//!
8//! The lower-level [`view::split::BufferViewState::ensure_cursor_visible`]
9//! has several short-circuit paths (the `skip_ensure_visible` flag set by
10//! prior scroll actions, the `top_view_line_offset > 0` early-return for
11//! wrapped buffers, `skip_resize_sync`) that can leave a freshly-set cursor
12//! stranded outside the viewport. That class of bug — "status bar updates
13//! but the page never moves" — is why every navigation primitive here ends
14//! with a *post-condition check*: if the cursor is still off-screen when
15//! the call returns, we force a hard recenter (issue #1689).
16//!
17//! Use [`Editor::ensure_active_cursor_visible_for_navigation`] right after
18//! any explicit cursor mutation that represents a user-visible jump. Use
19//! [`Editor::jump_active_cursor_to`] when the call site can also delegate
20//! the cursor mutation itself.
21//!
22//! Edits (typing, paste, indent, …) should keep using the existing
23//! `ensure_cursor_visible` path — they want the "don't undo a deliberate
24//! scroll" behavior of the skip flag.
25
26use crate::model::buffer::LineNumber;
27
28/// Whether the active cursor should be vertically recentered when a jump
29/// causes the viewport to scroll, and whether the selection anchor should
30/// be reset.
31#[derive(Clone, Copy, Debug)]
32pub struct JumpOptions {
33    /// If `true`, drop the selection anchor (the jump becomes a plain move).
34    /// Set to `false` to extend the selection from the previous anchor.
35    pub clear_anchor: bool,
36    /// If the jump caused the viewport to scroll *or* the post-condition
37    /// safety net had to fire, recenter the cursor vertically. This is the
38    /// behavior search/LSP/error navigation want — a cold landing spot
39    /// should show context above and below.
40    pub recenter_on_scroll: bool,
41}
42
43impl Default for JumpOptions {
44    fn default() -> Self {
45        Self {
46            clear_anchor: true,
47            recenter_on_scroll: true,
48        }
49    }
50}
51
52impl JumpOptions {
53    /// Convenience: defaults for navigation jumps (clear anchor, recenter).
54    pub fn navigation() -> Self {
55        Self::default()
56    }
57}
58
59impl crate::app::window::Window {
60    /// Move the active cursor to `position` and guarantee that position is
61    /// rendered in the active viewport.
62    ///
63    /// This is the canonical "jump the cursor somewhere" entry point. It
64    /// performs a direct cursor mutation (no `MoveCursor` event, no undo
65    /// entry, no `cursor_moved` plugin hook) and then funnels through
66    /// [`Editor::ensure_active_cursor_visible_for_navigation`] for the
67    /// visibility invariant.
68    ///
69    /// Callers that need a `MoveCursor` event (undo + plugin hooks) should
70    /// build the event themselves and call
71    /// [`Editor::ensure_active_cursor_visible_for_navigation`] afterwards.
72    pub fn jump_active_cursor_to(&mut self, position: usize, opts: JumpOptions) {
73        let active_split = self
74            .buffers
75            .splits()
76            .map(|(mgr, _)| mgr)
77            .expect("active window must have a populated split layout")
78            .active_split();
79        let active_buffer = self.active_buffer();
80        if let Some(view_state) = Some(&mut *self)
81            .and_then(|w| w.split_view_states_mut())
82            .expect("active window must have a populated split layout")
83            .get_mut(&active_split)
84        {
85            view_state.cursors.primary_mut().position = position;
86            if opts.clear_anchor {
87                view_state.cursors.primary_mut().anchor = None;
88            }
89            if let Some(state) = (&mut self.buffers).get_mut(&active_buffer) {
90                if let Some(pos) = state.buffer.offset_to_position(position) {
91                    state.primary_cursor_line_number = LineNumber::Absolute(pos.line);
92                }
93            }
94        }
95        self.ensure_active_cursor_visible_for_navigation(opts.recenter_on_scroll);
96    }
97
98    /// Guarantee the active cursor is visible in the active viewport.
99    ///
100    /// Call this immediately after any cursor mutation that represents a
101    /// programmatic jump (search match, goto-definition, jump-to-line,
102    /// next-error, plugin scroll-to-position). It:
103    ///
104    /// 1. Clears `skip_ensure_visible` so a stale prior scroll does not
105    ///    suppress this one.
106    /// 2. Calls the lower-level `ensure_cursor_visible`.
107    /// 3. **Verifies** the cursor's line is now within the viewport's line
108    ///    range. If it isn't (the lower-level routine short-circuited, or
109    ///    `view_lines`-aware logic disagreed with byte-line math), forces a
110    ///    hard recenter so the cursor lands roughly mid-viewport.
111    /// 4. If the visible range moved at all and `recenter_on_scroll` is
112    ///    set, recenters for context.
113    ///
114    /// Step 3 is the safety net that makes "cursor moves but viewport
115    /// stalls" (#1689) impossible to reproduce regardless of what the
116    /// lower-level scroll machinery decides to do.
117    pub fn ensure_active_cursor_visible_for_navigation(&mut self, recenter_on_scroll: bool) {
118        let active_buffer = self.active_buffer();
119        self.ensure_cursor_visible_for_navigation(active_buffer, recenter_on_scroll);
120    }
121}
122
123impl crate::app::window::Window {
124    /// Window-level navigation visibility primitive — see
125    /// [`Editor::ensure_active_cursor_visible_for_navigation`] for
126    /// the full contract. Operates on the active split of this
127    /// window and the supplied buffer (typically the
128    /// caller-resolved `active_buffer()`).
129    pub fn ensure_cursor_visible_for_navigation(
130        &mut self,
131        active_buffer: crate::model::event::BufferId,
132        recenter_on_scroll: bool,
133    ) {
134        let Some(active_split) = self.buffers.split_manager().map(|m| m.active_split()) else {
135            return;
136        };
137        self.buffers
138            .with_buffer_and_split(active_buffer, active_split, |state, view_state| {
139                // 1. Clear stale skip flag — a prior recenter (or scroll action) may
140                // have set it, but this navigation step is *new user intent* and must
141                // not be silently suppressed.
142                view_state.viewport.clear_skip_ensure_visible();
143
144                let cursor_pos = view_state.cursors.primary().position;
145                let top_byte_before = view_state.viewport.top_byte;
146
147                // 2. Best-effort scroll via the existing line-aware routine.
148                view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
149
150                let scrolled = view_state.viewport.top_byte != top_byte_before;
151
152                // 3. Post-condition check — derive line numbers (cheap, exact for
153                // non-large files; estimated for large files) and confirm the cursor
154                // line lies within the viewport's line range. If it doesn't, the
155                // lower-level routine bailed out for one of its skip-paths and we
156                // must force a recenter.
157                let cursor_visible = is_cursor_line_visible(view_state, &state.buffer, cursor_pos);
158
159                let needs_recenter = !cursor_visible || (scrolled && recenter_on_scroll);
160                if needs_recenter {
161                    let viewport_height = view_state.viewport.visible_line_count();
162                    let target_rows_from_top = viewport_height / 2;
163                    let mut iter = state.buffer.line_iterator(cursor_pos, 80);
164                    for _ in 0..target_rows_from_top {
165                        if iter.prev().is_none() {
166                            break;
167                        }
168                    }
169                    view_state.viewport.top_byte = iter.current_position();
170                    view_state.viewport.top_view_line_offset = 0;
171                    view_state.viewport.scrolled_up_in_wrap = false;
172                    view_state.viewport.set_skip_ensure_visible();
173                }
174
175                // 4. Horizontal scroll. The byte-oriented `ensure_cursor_visible`
176                // doesn't adjust `left_column`; for matches deep inside a long
177                // line (an EPUB XML element, a minified bundle, …) the cursor
178                // is on the right line but its column is past the viewport —
179                // the user sees an unchanged screen and has to scroll
180                // horizontally manually. See §5 of
181                // docs/internal/search-replace-scope-replan-on-widgets.md
182                // and #1873.
183                //
184                // Skip when line wrapping is on (every column reaches the eye
185                // via wrap) and when the gutter/scrollbar reservation leaves
186                // no usable visible width.
187                if !view_state.viewport.line_wrap_enabled {
188                    let cursor_visual_col = visual_column_of(&mut state.buffer, cursor_pos);
189                    let gutter_width = if view_state.show_line_numbers { 6 } else { 0 };
190                    let scrollbar_width = 1;
191                    let visible_width = (view_state.viewport.width as usize)
192                        .saturating_sub(gutter_width)
193                        .saturating_sub(scrollbar_width);
194                    if visible_width > 0 {
195                        let left = view_state.viewport.left_column;
196                        let right = left + visible_width;
197                        // Small margin so the cursor isn't pinned to the very
198                        // edge — mirrors `ensure_column_visible_simple`'s
199                        // `effective_offset` behaviour.
200                        let margin = (visible_width / 8).min(8);
201                        if cursor_visual_col < left + margin {
202                            view_state.viewport.left_column =
203                                cursor_visual_col.saturating_sub(margin);
204                        } else if cursor_visual_col + margin >= right {
205                            view_state.viewport.left_column =
206                                (cursor_visual_col + margin + 1).saturating_sub(visible_width);
207                        }
208                    }
209                }
210            });
211    }
212}
213
214/// Visual column for `cursor_pos` on its source line. Best-effort:
215/// counts terminal cell widths via `UnicodeWidthChar` (matching what
216/// the layout-aware viewport math uses). Tabs collapse to 1 since
217/// this layer doesn't have the buffer's tab-width setting handy.
218fn visual_column_of(buffer: &mut crate::model::buffer::Buffer, cursor_pos: usize) -> usize {
219    use unicode_width::UnicodeWidthChar;
220    let cursor_line = buffer.get_line_number(cursor_pos);
221    let line_start = buffer.line_start_offset(cursor_line).unwrap_or(cursor_pos);
222    if cursor_pos <= line_start {
223        return 0;
224    }
225    let len = cursor_pos - line_start;
226    let bytes = match buffer.get_text_range_mut(line_start, len) {
227        Ok(b) => b,
228        Err(_) => return 0,
229    };
230    let s = match std::str::from_utf8(&bytes) {
231        Ok(s) => s,
232        Err(_) => return 0,
233    };
234    let mut col = 0usize;
235    for ch in s.chars() {
236        col += UnicodeWidthChar::width(ch).unwrap_or(0);
237    }
238    col
239}
240
241/// Approximate visibility check using line numbers. False negatives only —
242/// if we say "not visible" when it actually is, the helper recenters
243/// unnecessarily but still leaves the cursor on screen, which is
244/// observably indistinguishable from the no-op case.
245fn is_cursor_line_visible(
246    view_state: &crate::view::split::BufferViewState,
247    buffer: &crate::model::buffer::Buffer,
248    cursor_pos: usize,
249) -> bool {
250    let viewport = &view_state.viewport;
251    let top_line = buffer.get_line_number(viewport.top_byte);
252    let cursor_line = buffer.get_line_number(cursor_pos);
253    let viewport_height = viewport.visible_line_count();
254    cursor_line >= top_line && cursor_line < top_line.saturating_add(viewport_height)
255}
256
257/// Reconcile a freshly-restored `(buf_state.viewport, buf_state.cursors)` pair
258/// so the cursor is guaranteed visible.
259///
260/// Session/workspace restore re-applies the previously-saved viewport
261/// `top_byte` (and `top_view_line_offset` in wrap mode) and the previously-
262/// saved cursor position independently. If those two were *already* out of
263/// sync at save time — for example because the cursor moved off-screen via a
264/// prior bug or via plugin scroll-to-position — the restore re-creates an
265/// off-screen cursor that arrow keys can't escape (the wrap-mode early
266/// return in `viewport.rs::ensure_visible` kicks in for any cursor whose
267/// byte position is `>= viewport.top_byte`, which is true for *all* cursors
268/// below the viewport top — so naive Up/Down can never bring the viewport
269/// back to the cursor).
270///
271/// Call this on each restored buffer's state right after writing the
272/// scroll/cursor fields. If the cursor's line is already visible inside the
273/// restored viewport this is a no-op — we keep the user's saved scroll
274/// position for free. If not, recenter so the cursor lands mid-viewport
275/// (#1689 follow-up).
276pub(crate) fn reconcile_restored_buffer_view(
277    buf_state: &mut crate::view::split::BufferViewState,
278    buffer: &mut crate::model::buffer::Buffer,
279) {
280    let cursor_pos = buf_state.cursors.primary().position;
281    if is_cursor_line_visible(buf_state, buffer, cursor_pos) {
282        return;
283    }
284    let viewport_height = buf_state.viewport.visible_line_count();
285    let target_rows_from_top = viewport_height / 2;
286    let mut iter = buffer.line_iterator(cursor_pos, 80);
287    for _ in 0..target_rows_from_top {
288        if iter.prev().is_none() {
289            break;
290        }
291    }
292    buf_state.viewport.top_byte = iter.current_position();
293    buf_state.viewport.top_view_line_offset = 0;
294    // Restore code already calls set_skip_resize_sync; we don't need to also
295    // pin against ensure_visible because the next render will see the cursor
296    // is already inside the viewport range we just chose.
297}