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
28use super::Editor;
29
30/// Whether the active cursor should be vertically recentered when a jump
31/// causes the viewport to scroll, and whether the selection anchor should
32/// be reset.
33#[derive(Clone, Copy, Debug)]
34pub struct JumpOptions {
35    /// If `true`, drop the selection anchor (the jump becomes a plain move).
36    /// Set to `false` to extend the selection from the previous anchor.
37    pub clear_anchor: bool,
38    /// If the jump caused the viewport to scroll *or* the post-condition
39    /// safety net had to fire, recenter the cursor vertically. This is the
40    /// behavior search/LSP/error navigation want — a cold landing spot
41    /// should show context above and below.
42    pub recenter_on_scroll: bool,
43}
44
45impl Default for JumpOptions {
46    fn default() -> Self {
47        Self {
48            clear_anchor: true,
49            recenter_on_scroll: true,
50        }
51    }
52}
53
54impl JumpOptions {
55    /// Convenience: defaults for navigation jumps (clear anchor, recenter).
56    pub fn navigation() -> Self {
57        Self::default()
58    }
59}
60
61impl Editor {
62    /// Move the active cursor to `position` and guarantee that position is
63    /// rendered in the active viewport.
64    ///
65    /// This is the canonical "jump the cursor somewhere" entry point. It
66    /// performs a direct cursor mutation (no `MoveCursor` event, no undo
67    /// entry, no `cursor_moved` plugin hook) and then funnels through
68    /// [`Editor::ensure_active_cursor_visible_for_navigation`] for the
69    /// visibility invariant.
70    ///
71    /// Callers that need a `MoveCursor` event (undo + plugin hooks) should
72    /// build the event themselves and call
73    /// [`Editor::ensure_active_cursor_visible_for_navigation`] afterwards.
74    pub fn jump_active_cursor_to(&mut self, position: usize, opts: JumpOptions) {
75        let active_split = self.split_manager.active_split();
76        let active_buffer = self.active_buffer();
77        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
78            view_state.cursors.primary_mut().position = position;
79            if opts.clear_anchor {
80                view_state.cursors.primary_mut().anchor = None;
81            }
82            if let Some(state) = self.buffers.get_mut(&active_buffer) {
83                if let Some(pos) = state.buffer.offset_to_position(position) {
84                    state.primary_cursor_line_number = LineNumber::Absolute(pos.line);
85                }
86            }
87        }
88        self.ensure_active_cursor_visible_for_navigation(opts.recenter_on_scroll);
89    }
90
91    /// Guarantee the active cursor is visible in the active viewport.
92    ///
93    /// Call this immediately after any cursor mutation that represents a
94    /// programmatic jump (search match, goto-definition, jump-to-line,
95    /// next-error, plugin scroll-to-position). It:
96    ///
97    /// 1. Clears `skip_ensure_visible` so a stale prior scroll does not
98    ///    suppress this one.
99    /// 2. Calls the lower-level `ensure_cursor_visible`.
100    /// 3. **Verifies** the cursor's line is now within the viewport's line
101    ///    range. If it isn't (the lower-level routine short-circuited, or
102    ///    `view_lines`-aware logic disagreed with byte-line math), forces a
103    ///    hard recenter so the cursor lands roughly mid-viewport.
104    /// 4. If the visible range moved at all and `recenter_on_scroll` is
105    ///    set, recenters for context.
106    ///
107    /// Step 3 is the safety net that makes "cursor moves but viewport
108    /// stalls" (#1689) impossible to reproduce regardless of what the
109    /// lower-level scroll machinery decides to do.
110    pub fn ensure_active_cursor_visible_for_navigation(&mut self, recenter_on_scroll: bool) {
111        let active_split = self.split_manager.active_split();
112        let active_buffer = self.active_buffer();
113
114        let Some(view_state) = self.split_view_states.get_mut(&active_split) else {
115            return;
116        };
117        let Some(state) = self.buffers.get_mut(&active_buffer) else {
118            return;
119        };
120
121        // 1. Clear stale skip flag — a prior recenter (or scroll action) may
122        // have set it, but this navigation step is *new user intent* and must
123        // not be silently suppressed.
124        view_state.viewport.clear_skip_ensure_visible();
125
126        let cursor_pos = view_state.cursors.primary().position;
127        let top_byte_before = view_state.viewport.top_byte;
128
129        // 2. Best-effort scroll via the existing line-aware routine.
130        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
131
132        let scrolled = view_state.viewport.top_byte != top_byte_before;
133
134        // 3. Post-condition check — derive line numbers (cheap, exact for
135        // non-large files; estimated for large files) and confirm the cursor
136        // line lies within the viewport's line range. If it doesn't, the
137        // lower-level routine bailed out for one of its skip-paths and we
138        // must force a recenter.
139        let cursor_visible = is_cursor_line_visible(view_state, &state.buffer, cursor_pos);
140
141        let needs_recenter = !cursor_visible || (scrolled && recenter_on_scroll);
142        if needs_recenter {
143            let viewport_height = view_state.viewport.visible_line_count();
144            let target_rows_from_top = viewport_height / 2;
145            let mut iter = state.buffer.line_iterator(cursor_pos, 80);
146            for _ in 0..target_rows_from_top {
147                if iter.prev().is_none() {
148                    break;
149                }
150            }
151            view_state.viewport.top_byte = iter.current_position();
152            view_state.viewport.top_view_line_offset = 0;
153            // The byte-oriented `ensure_cursor_visible` above may have set
154            // `scrolled_up_in_wrap` so the next `ensure_visible_in_layout`
155            // would fine-tune `top_view_line_offset` to place the cursor
156            // exactly `effective_offset` rows from the top. That hint
157            // matched the byte-oriented routine's chosen `top_byte`; we've
158            // since overridden `top_byte` to a recentered position, so the
159            // pending fine-tune is stale and would shift the viewport away
160            // from the recenter on the very first non-skipped render after
161            // a keypress.
162            view_state.viewport.scrolled_up_in_wrap = false;
163            // The next render-time `ensure_visible_in_layout` would otherwise
164            // immediately undo this recenter to satisfy its own scroll-margin
165            // invariants. Tell it to keep the position we just chose.
166            view_state.viewport.set_skip_ensure_visible();
167        }
168    }
169}
170
171/// Approximate visibility check using line numbers. False negatives only —
172/// if we say "not visible" when it actually is, the helper recenters
173/// unnecessarily but still leaves the cursor on screen, which is
174/// observably indistinguishable from the no-op case.
175fn is_cursor_line_visible(
176    view_state: &crate::view::split::BufferViewState,
177    buffer: &crate::model::buffer::Buffer,
178    cursor_pos: usize,
179) -> bool {
180    let viewport = &view_state.viewport;
181    let top_line = buffer.get_line_number(viewport.top_byte);
182    let cursor_line = buffer.get_line_number(cursor_pos);
183    let viewport_height = viewport.visible_line_count();
184    cursor_line >= top_line && cursor_line < top_line.saturating_add(viewport_height)
185}
186
187/// Reconcile a freshly-restored `(buf_state.viewport, buf_state.cursors)` pair
188/// so the cursor is guaranteed visible.
189///
190/// Session/workspace restore re-applies the previously-saved viewport
191/// `top_byte` (and `top_view_line_offset` in wrap mode) and the previously-
192/// saved cursor position independently. If those two were *already* out of
193/// sync at save time — for example because the cursor moved off-screen via a
194/// prior bug or via plugin scroll-to-position — the restore re-creates an
195/// off-screen cursor that arrow keys can't escape (the wrap-mode early
196/// return in `viewport.rs::ensure_visible` kicks in for any cursor whose
197/// byte position is `>= viewport.top_byte`, which is true for *all* cursors
198/// below the viewport top — so naive Up/Down can never bring the viewport
199/// back to the cursor).
200///
201/// Call this on each restored buffer's state right after writing the
202/// scroll/cursor fields. If the cursor's line is already visible inside the
203/// restored viewport this is a no-op — we keep the user's saved scroll
204/// position for free. If not, recenter so the cursor lands mid-viewport
205/// (#1689 follow-up).
206pub(crate) fn reconcile_restored_buffer_view(
207    buf_state: &mut crate::view::split::BufferViewState,
208    buffer: &mut crate::model::buffer::Buffer,
209) {
210    let cursor_pos = buf_state.cursors.primary().position;
211    if is_cursor_line_visible(buf_state, buffer, cursor_pos) {
212        return;
213    }
214    let viewport_height = buf_state.viewport.visible_line_count();
215    let target_rows_from_top = viewport_height / 2;
216    let mut iter = buffer.line_iterator(cursor_pos, 80);
217    for _ in 0..target_rows_from_top {
218        if iter.prev().is_none() {
219            break;
220        }
221    }
222    buf_state.viewport.top_byte = iter.current_position();
223    buf_state.viewport.top_view_line_offset = 0;
224    // Restore code already calls set_skip_resize_sync; we don't need to also
225    // pin against ensure_visible because the next render will see the cursor
226    // is already inside the viewport range we just chose.
227}