From 0510d34e6007928e6fae46a6dc46ae63e83c06b4 Mon Sep 17 00:00:00 2001 From: des Date: Wed, 29 Apr 2026 23:34:07 +0200 Subject: [PATCH 1/3] input: Make trailing-empty-rows + cursor-surrounding-lines configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related but distinct knobs are exposed on `InputState`. Both default to `None` to preserve the historical behavior — strict addition, no breaking change. In code-editor mode the editor reserves empty rows below the last line of content (the "scroll past last line" affordance) and keeps the cursor at least a few lines clear of the viewport's top/bottom edges (the "cursor surrounding lines" auto-scroll padding). These are two separate concepts that happen to share the same default constant (`BOTTOM_MARGIN_ROWS = 3`): - "Scroll past last line" answers "how far below the last line can the user manually scroll?" — about the *scrollable region*'s size. - "Cursor surrounding lines" answers "where in the viewport does the cursor stay as it moves?" — about the *cursor*'s relative position during typing / arrow navigation. VSCode and JetBrains IDEs (WebStorm, IntelliJ) both expose the pair as separate settings (`editor.scrollBeyondLastLine` + `editor.cursorSurroundingLines` in VSCode). Different surfaces want different defaults — full-pane editors typically want a small trailing band so the bottom half of the pane doesn't read as "missing content"; embedded editors usually want zero. This PR gives a host the same dial without reaching into upstream. ```rust // On InputState — both default to `None`. pub fn scroll_past_last_line_rows(self, rows: Option) -> Self; pub fn set_scroll_past_last_line_rows(&mut self, rows: Option, …); pub fn cursor_surrounding_lines(self, lines: Option) -> Self; pub fn set_cursor_surrounding_lines(&mut self, lines: Option, …); ``` `None` (default) preserves the prior behavior in both cases: - `scroll_past_last_line_rows = None` → roughly half the viewport, floored at `BOTTOM_MARGIN_ROWS * line_height`. - `cursor_surrounding_lines = None` → `BOTTOM_MARGIN_ROWS` lines on normal viewports, falls back to one line on small viewports (`< BOTTOM_MARGIN_ROWS × 8` rows tall). `Some(n)` overrides each with exactly `n` line-heights. The two settings compose. Example: a JetBrains-style "Enter at end-of-buffer keeps a single trailing empty row visible" surface wants `scroll_past_last_line_rows = Some(1)` and `cursor_surrounding_lines = Some(1)` — together they let the cursor sit one row from the bottom edge with one empty row below it as content grows. `scroll_size.height` previously summed `empty_bottom_height` and `ghost_lines_height`, producing an unreachable band of empty space below the cursor-traversable area when scrolled to `max_scroll` (visible as an unscrollable black void in code-editor surfaces). Both terms describe extra height past the last real content row; they should not stack. The PR uses `max` instead of `sum` so the scrollable region is sized to whichever extension is actually in play, leaving no unreachable empty pixels at scroll-bottom. `cursor_surrounding_padding` is now clamped against half the visible region. An aggressive override on a small viewport (e.g. `Some(20)` on a 10-line viewport) used to send the bottom-edge auto-scroll threshold below the top-edge threshold, with the per-frame scroll-into-view computation losing a stable fixed point. With saturation, the contract is honored when it fits and gracefully degrades when the host asks for the impossible. Doc-comment on `InputState::cursor_surrounding_lines` documents the saturation explicitly so misconfiguration recovers instead of looping. A third related defect surfaced once the API above was exercised with the JB-style combo (`scroll_past_last_line_rows = Some(1)` + `cursor_surrounding_lines = Some(1)`): pressing `Down` at end-of-buffer produced one frame of visible scroll overshoot followed by a snap-back to the clamped position on the next frame. Held `Down` reproduced 2-3 jitter cycles before settling. Two scroll-into-view computations existed and disagreed on the edge clearance: - `state.rs::scroll_to` hardcoded `edge_height = 3 * line_height` whenever `direction.is_some() && code_editor`, ignoring the new `cursor_surrounding_lines` override, and wrote the resulting target into `deferred_scroll_offset` capped only against `min(px(0.))` — the safe-range clamp lived elsewhere (`update_scroll_offset`, run after paint). - `element.rs::layout_cursor` honored the override via the `cursor_surrounding_padding` helper, demanding only `1 × line_height` clearance under `Some(1)`. With the empty band trimmed to one line, state's 3-line demand landed below `safe_y_min`. Element's `layout_cursor` overrides its local computation with `state.deferred_scroll_offset`, so paint ran at the unclamped position. The post-paint clamp in `update_scroll_offset` pulled `scroll_handle` back into range. The next render painted at the clamped value — visible as a one-line jump-then-snap-back per `Down` keystroke. Two changes that together eliminate both the demand and the exposure: 1. State and element now agree on the edge clearance. `cursor_surrounding_padding` is widened to `pub(super)` and `scroll_to` calls it with the same arguments `layout_cursor` uses. With `Some(1)` both sides demand 1 line clear; with the default `None` both sides demand `BOTTOM_MARGIN_ROWS` (the prior behavior). Non-code-editor / no-direction paths keep the historical 1-line edge guard for source-compat. 2. Defense-in-depth at the producer. `scroll_to` now clamps `scroll_offset.y` against `safe_y_min = (-scroll_size.height + input_bounds.size.height).min(px(0.))` before storing it in `deferred_scroll_offset` — the same range `update_scroll_offset` enforces on persist. Even if a future caller routes around `scroll_to` and writes `deferred_scroll_offset` directly with an over-aggressive target, paint and persist agree on the value. `test_scroll_to_eob_does_not_overshoot_safe_range` exercises the JB-style `Some(1) + Some(1)` configuration on a 50-line buffer, moves the cursor to EOB with `MoveDirection::Down`, and asserts `deferred_scroll_offset.y >= safe_y_min` — the producer-side contract that paint and persist agree on the same value. The test fails on the prior code (deferred lands roughly two line-heights below `safe_y_min`) and passes after the fix. Placed in `input::state::tests` alongside the existing `test_highlighting_preserved_after_fold` runtime test (the pure helpers covered by `test_cursor_surrounding_padding_*` in `element.rs` remain unchanged). Two free helpers extracted from `TextElement::prepaint` / `layout_cursor` so the math is unit-testable: ```rust fn empty_bottom_height( is_code_editor: bool, override_rows: Option, viewport_height: Pixels, line_height: Pixels, ) -> Pixels { … } pub(super) fn cursor_surrounding_padding( is_auto_grow: bool, override_lines: Option, visible_lines: usize, line_height: Pixels, ) -> Pixels { … } ``` `cursor_surrounding_padding` is widened to `pub(super)` so `state.rs::scroll_to` can call it with the same arguments `element.rs::layout_cursor` uses (Bug C fix). Pure-function unit tests cover the regimes: - `empty_bottom_height`: outside code-editor (always 0), code-editor + `None` (half-viewport floored at `BOTTOM_MARGIN_ROWS * line_height`), code-editor + `Some(n)` (exactly `n × line_height`, no floor). - `cursor_surrounding_padding`: auto-grow (always one line, no override), non-auto-grow + `None` (small-viewport fallback + large-viewport `BOTTOM_MARGIN_ROWS`), non-auto-grow + `Some(n)` (exactly `n × line_height` when it fits), saturation against half-viewport when it doesn't. A runtime regression test (`test_scroll_to_eob_does_not_overshoot_safe_range`) covers Bug C end-to-end through `InputState::move_to` → `scroll_to`. Has no effect outside `InputMode::CodeEditor` for the trailing-rows knob; cursor-surrounding-lines applies to all multi-line modes. 🤖 AI assistance: this PR was drafted with AI help. Field, setters, and helper extraction follow existing patterns (`soft_wrap`, `set_soft_wrap`); test layout mirrors the existing pure-function tests in `input/element.rs::tests` (`test_runs_for_range`, `test_placeholder_line_runs`, etc.). No rendering-code performance impact — each helper is called once per layout, identical work to the inline expressions they replace. --- crates/ui/src/input/element.rs | 280 ++++++++++++++++++++++++++++++--- crates/ui/src/input/state.rs | 272 +++++++++++++++++++++++++++++++- 2 files changed, 530 insertions(+), 22 deletions(-) diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 170d7fb25d..57246a517d 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -217,6 +217,91 @@ fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() } +/// Minimum pixel padding the cursor is kept clear of the viewport's top +/// and bottom edges before auto-scroll engages — the "cursor +/// surrounding lines" setting. +/// +/// Auto-grow input always uses one line. For other modes: +/// +/// - `override_lines = None` (the historical default): on small +/// viewports (less than [`BOTTOM_MARGIN_ROWS`] × 8 lines tall) the +/// editor falls back to one line so the cursor isn't stranded; +/// otherwise [`BOTTOM_MARGIN_ROWS`] lines. +/// - `override_lines = Some(n)`: exactly `n × line_height`, regardless +/// of viewport size. Caller is trusted to pick a sensible value for +/// the surface (e.g. `Some(1)` for JetBrains-style "keep one trailing +/// row visible" behaviour). +/// +/// In all cases the result is saturated against half the visible +/// region so the auto-scroll-into-view computation always has room +/// — without this clamp, an aggressive override on a small viewport +/// (e.g. `Some(20)` on a 10-line viewport) yields a bottom-edge +/// threshold below the top edge, sending the per-frame scroll-offset +/// adjustment into a feedback loop. +/// +/// See [`InputState::cursor_surrounding_lines`] for caller-facing +/// documentation. +pub(super) fn cursor_surrounding_padding( + is_auto_grow: bool, + override_lines: Option, + visible_lines: usize, + line_height: Pixels, +) -> Pixels { + if is_auto_grow { + return line_height; + } + let raw = match override_lines { + Some(lines) => lines as f32 * line_height, + None => { + if visible_lines < BOTTOM_MARGIN_ROWS * 8 { + line_height + } else { + BOTTOM_MARGIN_ROWS * line_height + } + } + }; + // Saturate against half the viewport so top + bottom margins can + // always coexist with at least one cursor-line of clear space + // between them. + let viewport_half = (visible_lines as f32 * line_height).half(); + raw.min(viewport_half) +} + +/// Pixel height of the empty area below the last line in the editor's +/// scrollable region — the "scroll past last line" affordance. +/// +/// Outside code-editor mode the result is always `0`. Inside code-editor +/// mode the height is determined by the host-provided override: +/// +/// - `override_rows = None` (the historical default): roughly half the +/// viewport, never less than [`BOTTOM_MARGIN_ROWS`] line-heights — +/// matches the prior behavior and JetBrains IDEs / VSCode default. +/// The cursor can sit anywhere in the viewport without being painted +/// at the very edge. +/// - `override_rows = Some(n)`: exactly `n` line-heights. `Some(0)` +/// produces the tightest possible scroll bounds (`max_scroll = +/// max(0, content_height − viewport_height)`) but, with the current +/// per-frame scroll-into-view calculation, can flicker on `Down` at +/// end-of-buffer. `Some(3..=8)` is the recommended sweet spot for +/// full-pane editors that don't want the half-viewport affordance. +/// +/// See [`InputState::scroll_past_last_line_rows`] for caller-facing +/// documentation. +fn empty_bottom_height( + is_code_editor: bool, + override_rows: Option, + viewport_height: Pixels, + line_height: Pixels, +) -> Pixels { + if !is_code_editor { + return px(0.); + } + match override_rows { + Some(rows) => rows as f32 * line_height, + None => viewport_height.half().max(BOTTOM_MARGIN_ROWS * line_height), + } +} + /// Layout information for fold icons. struct FoldIconLayout { /// Hitbox for the line number area (used for hover detection) @@ -313,14 +398,18 @@ impl TextElement { let mut scroll_offset = state.scroll_handle.offset(); let mut cursor_bounds = None; - // If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input. - let top_bottom_margin = if state.mode.is_auto_grow() { - line_height - } else if visible_range.len() < BOTTOM_MARGIN_ROWS * 8 { - line_height - } else { - BOTTOM_MARGIN_ROWS * line_height - }; + // Minimum padding kept between the cursor and the viewport's + // top/bottom edges. The auto-scroll-into-view computation below + // uses this to decide when to advance the scroll offset as the + // cursor moves toward an edge. Honors + // `state.cursor_surrounding_lines` if set; otherwise falls back + // to the historical heuristic. + let top_bottom_margin = cursor_surrounding_padding( + state.mode.is_auto_grow(), + state.cursor_surrounding_lines, + visible_range.len(), + line_height, + ); // The cursor corresponds to the current cursor position in the text no only the line. let mut cursor_pos = None; @@ -1709,24 +1798,28 @@ impl Element for TextElement { let ghost_lines_height = ghost_line_count as f32 * line_height; let total_wrapped_lines = state.display_map.wrap_row_count(); - let empty_bottom_height = if state.mode.is_code_editor() { - bounds - .size - .height - .half() - .max(BOTTOM_MARGIN_ROWS * line_height) - } else { - px(0.) - }; + let empty_bottom_height = empty_bottom_height( + state.mode.is_code_editor(), + state.scroll_past_last_line_rows, + bounds.size.height, + line_height, + ); + // Empty bottom and ghost lines both describe extra height past + // the last real content row; they should not stack. Taking the + // max produces a tight `max_scroll` so the cursor can reach + // every empty pixel of the scrollable region. Summing them + // (the prior behavior) left a band of unreachable empty space + // below the cursor-traversable area visible at scroll-max. let mut scroll_size = size( if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { longest_line_width + line_number_width + RIGHT_MARGIN } else { longest_line_width }, - (total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height) - .max(bounds.size.height), + (total_wrapped_lines as f32 * line_height + + empty_bottom_height.max(ghost_lines_height)) + .max(bounds.size.height), ); // TODO: should be add some gap to right, to convenient to focus on boundary position @@ -2482,4 +2575,153 @@ mod tests { assert_eq!(result[4].color, gpui::black()); assert_eq!(result[5].color, gpui::blue()); } + + #[test] + fn test_empty_bottom_height_outside_code_editor() { + // Single-line / plain-text / auto-grow modes never reserve empty + // bottom space, regardless of any override. + for override_rows in [None, Some(0), Some(3), Some(99)] { + assert_eq!( + empty_bottom_height(false, override_rows, px(800.), px(20.)), + px(0.), + ); + } + } + + #[test] + fn test_empty_bottom_height_code_editor_default() { + // `None`: roughly half the viewport, floored at + // `BOTTOM_MARGIN_ROWS * line_height` so the empty area never + // collapses to "less than a few lines" on tiny viewports. + let line_height = px(20.); + + // Viewport much taller than the floor → half-viewport wins. + assert_eq!( + empty_bottom_height(true, None, px(800.), line_height), + px(400.), + ); + + // Viewport shorter than 2 × floor → floor wins. + let floor = BOTTOM_MARGIN_ROWS * line_height; + assert_eq!(empty_bottom_height(true, None, px(40.), line_height), floor); + } + + #[test] + fn test_empty_bottom_height_explicit_row_count() { + // `Some(n)`: exactly `n` line-heights. Caller fully controls + // the trailing empty space; viewport size doesn't amplify it. + let line_height = px(20.); + + for rows in [0_usize, 1, 3, 8, 64] { + let expected = rows as f32 * line_height; + assert_eq!( + empty_bottom_height(true, Some(rows), px(800.), line_height), + expected, + ); + // Tiny viewport: still exactly `n × line_height`, no floor + // applied when caller supplied an explicit count. + assert_eq!( + empty_bottom_height(true, Some(rows), px(20.), line_height), + expected, + ); + } + } + + #[test] + fn test_cursor_surrounding_padding_auto_grow() { + // Auto-grow inputs always pad by one line, regardless of any + // override or visible-lines count. + let line_height = px(20.); + for override_lines in [None, Some(0), Some(3), Some(99)] { + for visible_lines in [0_usize, 1, 8, 64] { + assert_eq!( + cursor_surrounding_padding(true, override_lines, visible_lines, line_height,), + line_height, + ); + } + } + } + + #[test] + fn test_cursor_surrounding_padding_default() { + // `None`: historical heuristic — `BOTTOM_MARGIN_ROWS` for normal + // viewports, falls back to one line on small viewports (less + // than `BOTTOM_MARGIN_ROWS × 8` rows tall). + let line_height = px(20.); + + // Small viewport → 1-line fallback. + let small = BOTTOM_MARGIN_ROWS * 8 - 1; + assert_eq!( + cursor_surrounding_padding(false, None, small, line_height), + line_height, + ); + + // Boundary at `BOTTOM_MARGIN_ROWS × 8` flips to the full margin. + let boundary = BOTTOM_MARGIN_ROWS * 8; + assert_eq!( + cursor_surrounding_padding(false, None, boundary, line_height), + BOTTOM_MARGIN_ROWS * line_height, + ); + + // Comfortably-large viewport. + assert_eq!( + cursor_surrounding_padding(false, None, 100, line_height), + BOTTOM_MARGIN_ROWS * line_height, + ); + } + + #[test] + fn test_cursor_surrounding_padding_explicit() { + // `Some(n)`: exactly `n × line_height` when the viewport has + // room for it; saturated against half the viewport when it + // doesn't. + let line_height = px(20.); + + for lines in [0_usize, 1, 2, 5, 50] { + let raw = lines as f32 * line_height; + for visible_lines in [0_usize, 1, 8, 100] { + let viewport_half = (visible_lines as f32 * line_height).half(); + assert_eq!( + cursor_surrounding_padding(false, Some(lines), visible_lines, line_height,), + raw.min(viewport_half), + ); + } + } + } + + #[test] + fn test_cursor_surrounding_padding_saturates_against_viewport() { + // An aggressive override on a small viewport must not produce a + // padding larger than half the visible region — otherwise the + // bottom-edge auto-scroll-into-view threshold sinks below the + // top-edge threshold and the per-frame scroll adjustment loses + // a stable fixed point. + let line_height = px(20.); + + // Override much larger than viewport → clamped to half. + let visible_lines = 10; + let viewport_half = (visible_lines as f32 * line_height).half(); + assert_eq!( + cursor_surrounding_padding(false, Some(50), visible_lines, line_height), + viewport_half, + ); + + // Override that fits → returned unchanged. + let visible_lines = 40; + assert_eq!( + cursor_surrounding_padding(false, Some(3), visible_lines, line_height), + 3.0 * line_height, + ); + + // Default heuristic still saturates if BOTTOM_MARGIN_ROWS would + // exceed the half-viewport bound (only possible at extreme + // sizes — kept for defensive completeness). + let visible_lines = BOTTOM_MARGIN_ROWS * 8; + let half = (visible_lines as f32 * line_height).half(); + let raw = BOTTOM_MARGIN_ROWS * line_height; + assert_eq!( + cursor_surrounding_padding(false, None, visible_lines, line_height), + raw.min(half), + ); + } } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index b58a3255df..ca8e2340cd 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -370,6 +370,28 @@ pub struct InputState { pub(super) clean_on_escape: bool, pub(super) submit_on_enter: bool, pub(super) soft_wrap: bool, + /// Number of empty rows the editor reserves below the last line of + /// content in code-editor mode (the "scroll past last line" affordance). + /// + /// `None` (default) preserves the historical heuristic: roughly half + /// the viewport, never less than [`BOTTOM_MARGIN_ROWS`] line-heights. + /// `Some(n)` overrides with exactly `n` line-heights. Has no effect + /// outside [`InputMode::CodeEditor`]. + /// + /// See [`Self::scroll_past_last_line_rows`] for usage and the + /// trade-offs for typical values. + pub(super) scroll_past_last_line_rows: Option, + /// Minimum number of lines the cursor is kept clear of the viewport + /// edges when the cursor moves toward the top or bottom — used by + /// the auto-scroll-into-view computation in + /// [`TextElement::layout_cursor`]. + /// + /// `None` (default) preserves the historical heuristic: + /// [`BOTTOM_MARGIN_ROWS`] for normal viewports, falls back to one + /// line when the viewport is small. `Some(n)` overrides with + /// exactly `n` lines. See + /// [`Self::cursor_surrounding_lines`] for usage notes. + pub(super) cursor_surrounding_lines: Option, pub(super) show_whitespaces: bool, /// This flag tells the renderer to prefer the end of the current visual line. pub(crate) cursor_line_end_affinity: bool, @@ -486,6 +508,8 @@ impl InputState { clean_on_escape: false, submit_on_enter: false, soft_wrap: true, + scroll_past_last_line_rows: None, + cursor_surrounding_lines: None, show_whitespaces: false, loading: false, pattern: None, @@ -916,6 +940,150 @@ impl InputState { cx.notify(); } + /// Number of empty rows reserved below the last line of content + /// ("scroll past last line"). Code-editor mode only. + /// + /// # What this controls + /// + /// In a multi-line code editor, the scrollable region is normally a + /// little taller than the document so the cursor can sit anywhere in + /// the viewport without ever being painted at the very bottom edge. + /// That extra height is what makes scrolling "past" the last line + /// possible — empty rows appear under the document and the cursor + /// can be parked there. JetBrains IDEs (WebStorm, IntelliJ) and + /// VSCode both expose the same behaviour via settings (Editor → + /// "Show empty space at the end of the file" / `editor. + /// scrollBeyondLastLine`). + /// + /// # Why this matters + /// + /// 1. **Cursor placement.** Without bottom padding the cursor at the + /// last line is glued to the viewport edge — uncomfortable for + /// long sessions. + /// 2. **Visual taste.** Some surfaces (full-pane editors with no + /// chrome below) feel "broken" when half the viewport is empty + /// after the last line. Embedded editors (chunked into a form + /// layout) usually want zero or minimal empty rows. + /// 3. **Matching IDE conventions.** Different editors set different + /// defaults. Exposing the value lets a host pick the right one + /// for the surface without reaching into upstream. + /// + /// # Values + /// + /// - `None` (default): preserves historical behaviour — roughly + /// half the viewport, floored at [`BOTTOM_MARGIN_ROWS`] + /// line-heights. Good for traditional file-tree-plus-editor IDE + /// layouts where the editor is one panel among many. + /// - `Some(0)`: clamps `max_scroll = max(0, content_height − + /// viewport_height)` exactly. The cursor sits flush with the last + /// content row when scrolled to the bottom. + /// - `Some(1..=2)`: minimal trailing empty space, similar to + /// GitHub's web diff viewer. Cursor padding still adequate. + /// - `Some(3..=8)`: WebStorm-ish — small but visible empty band + /// below the document. Recommended sweet spot for full-pane + /// editors. + /// - `Some(n)` for large `n`: explicit half-viewport-equivalent — + /// normally not needed because `None` already gives that. + /// + /// Outside [`InputMode::CodeEditor`] this value is ignored. + pub fn scroll_past_last_line_rows(mut self, rows: Option) -> Self { + self.scroll_past_last_line_rows = rows; + self + } + + /// Update [`Self::scroll_past_last_line_rows`] after construction. + pub fn set_scroll_past_last_line_rows( + &mut self, + rows: Option, + _: &mut Window, + cx: &mut Context, + ) { + if self.scroll_past_last_line_rows == rows { + return; + } + self.scroll_past_last_line_rows = rows; + cx.notify(); + } + + /// Minimum number of lines the cursor is kept clear of the viewport + /// edges. Code-editor mode primarily; other multi-line modes also + /// honor it via [`TextElement::layout_cursor`]. + /// + /// # What this controls + /// + /// Different from [`Self::scroll_past_last_line_rows`]. That + /// controls how much *empty space* exists below the document. + /// `cursor_surrounding_lines` controls how close the cursor is + /// allowed to get to the viewport's top or bottom edge before the + /// editor auto-scrolls. When you press `Enter` at the end of a + /// buffer, the new cursor position is computed first, and then + /// the scroll offset is adjusted so the cursor sits at least this + /// many lines from the edge — which is what makes JetBrains' + /// behaviour of "Enter keeps adding rows AND keeps a trailing + /// empty row visible" possible without the cursor disappearing + /// into the bottom edge. + /// + /// # Why both are needed + /// + /// The two settings answer different questions: + /// + /// - `scroll_past_last_line_rows` answers "how far below the last + /// line can the user scroll?" — about the *scrollable region*. + /// - `cursor_surrounding_lines` answers "where in the viewport + /// does the cursor stay as it moves?" — about the *cursor's + /// relative position*. + /// + /// A short file with `scroll_past_last_line_rows = Some(8)` but + /// `cursor_surrounding_lines = Some(1)` lets the user scroll into + /// 8 empty rows manually but keeps the cursor itself 1 line clear + /// of edges during normal typing — VSCode and JetBrains both + /// expose them as separate settings (`editor.scrollBeyondLastLine` + /// + `editor.cursorSurroundingLines` in VSCode). + /// + /// # Values + /// + /// - `None` (default): preserves historical behaviour — + /// [`BOTTOM_MARGIN_ROWS`] lines for normal viewports, one line + /// on small viewports (less than `BOTTOM_MARGIN_ROWS × 8` rows + /// tall). + /// - `Some(0)`: cursor can sit on the very edge row of the + /// viewport. Tight; useful when paired with a non-zero + /// `scroll_past_last_line_rows` so the cursor visually has + /// room "below" it via the trailing empty space. + /// - `Some(1..=2)`: JetBrains-ish — the cursor stays 1–2 rows + /// clear of edges; pressing `Enter` at end-of-buffer keeps a + /// small visible band below the cursor as new lines are added. + /// - `Some(3..=8)`: more generous; common in editors that want + /// the cursor visually centred in the viewport's lower half + /// during long bursts of typing. + /// + /// # Saturation + /// + /// The effective padding is clamped against half the visible + /// region. An aggressive override on a small viewport (e.g. + /// `Some(20)` on a 10-line viewport) is capped at half-viewport + /// rather than producing a degenerate auto-scroll feedback loop. + /// Hosts shouldn't rely on this — pick a value that fits — but it + /// keeps misconfiguration recoverable instead of crashing. + pub fn cursor_surrounding_lines(mut self, lines: Option) -> Self { + self.cursor_surrounding_lines = lines; + self + } + + /// Update [`Self::cursor_surrounding_lines`] after construction. + pub fn set_cursor_surrounding_lines( + &mut self, + lines: Option, + _: &mut Window, + cx: &mut Context, + ) { + if self.cursor_surrounding_lines == lines { + return; + } + self.cursor_surrounding_lines = lines; + cx.notify(); + } + /// Set the regular expression pattern of the input field. /// /// Only for [`InputMode::SingleLine`] mode. @@ -1662,9 +1830,22 @@ impl InputState { } // Check if row_offset_y is out of the viewport - // If row offset is not in the viewport, scroll to make it visible + // If row offset is not in the viewport, scroll to make it visible. + // For code-editor moves with a direction, honor the configured + // `cursor_surrounding_lines` (default heuristic = `BOTTOM_MARGIN_ROWS` + // lines) — same helper `TextElement::layout_cursor` uses, so the two + // scroll-into-view computations agree on the edge clearance. Without + // this, state-side demanded a hardcoded 3 lines of clearance while + // element-side honored the override, and the mismatched targets at + // end-of-buffer with small overrides (e.g. `Some(1)`) produced the + // jump-then-snap-back flicker on `Down`. let edge_height = if direction.is_some() && self.mode.is_code_editor() { - 3 * line_height + super::element::cursor_surrounding_padding( + self.mode.is_auto_grow(), + self.cursor_surrounding_lines, + last_layout.visible_range.len(), + line_height, + ) } else { line_height }; @@ -1683,8 +1864,15 @@ impl InputState { scroll_offset.y = scroll_offset.y.min(was_offset.y); } + // Defense-in-depth: clamp the deferred target into the same safe + // range that `update_scroll_offset` enforces on persist. Without + // this, an over-aggressive demand here paints one frame at the + // unclamped position before the post-paint clamp pulls + // `scroll_handle` back, producing the same flicker the helper + // unification above prevents at the source. + let safe_y_min = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.)); scroll_offset.x = scroll_offset.x.min(px(0.)); - scroll_offset.y = scroll_offset.y.min(px(0.)); + scroll_offset.y = scroll_offset.y.clamp(safe_y_min, px(0.)); self.deferred_scroll_offset = Some(scroll_offset); cx.notify(); } @@ -2840,4 +3028,82 @@ ORDER BY id colored_before, colored_after ); } + + /// Regression test for "scroll flicker on `Down` at end-of-buffer" with + /// a small `cursor_surrounding_lines` override. Pairs with the pure-helper + /// coverage in `element.rs::test_cursor_surrounding_padding_*` — those + /// assert the helper saturates correctly; this asserts the state-side + /// `scroll_to` consumer of the helper produces a deferred scroll target + /// that lies within the safe scroll range, so the painted frame matches + /// what `update_scroll_offset` would persist (no jitter). + /// + /// Before the Bug C fix, `scroll_to` hardcoded `edge_height = 3 * line_height` + /// regardless of `cursor_surrounding_lines`, and the deferred target was + /// only capped at `min(px(0.))` — not against `safe_y_min`. With + /// `scroll_past_last_line_rows = Some(1)` + `cursor_surrounding_lines = Some(1)`, + /// the demanded target landed two line-heights below the safe range, so + /// paint used the unclamped value before the post-paint clamp pulled + /// `scroll_handle` back — visible as a one-line jump-then-snap-back. + #[gpui::test] + fn test_scroll_to_eob_does_not_overshoot_safe_range(cx: &mut TestAppContext) { + let input_view = InputView::new(cx); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + // JB-style: 1 trailing empty row + 1-line cursor surrounding. + // Mismatched against the historical 3-line edge_height in `scroll_to` + // — the exact configuration that surfaced Bug C in the heretic showcase. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_scroll_past_last_line_rows(Some(1), window, cx); + state.set_cursor_surrounding_lines(Some(1), window, cx); + let text: String = (1..=50) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + state.set_value(text, window, cx); + }); + }); + cx.run_until_parked(); + + // Sanity: paint populated `scroll_size` and `input_bounds` — without + // these, `safe_y_min` below collapses to 0 and the assertion is vacuous. + cx.update(|_, cx| { + input.read_with(cx, |state, _| { + assert!( + state.scroll_size.height > px(0.), + "scroll_size not populated by initial paint" + ); + assert!( + state.input_bounds.size.height > px(0.), + "input_bounds not populated by initial paint" + ); + }); + }); + + // Move cursor to end with downward direction — same code path as a + // `Down` keystroke at EOB. `scroll_to` runs synchronously inside + // `move_to`; inspect `deferred_scroll_offset` in the same closure + // before the next paint consumes and clears it. + cx.update(|_, cx| { + input.update(cx, |state, cx| { + let end = state.text.len(); + state.move_to(end, Some(MoveDirection::Down), cx); + + let deferred = state + .deferred_scroll_offset + .expect("scroll_to should populate deferred_scroll_offset"); + let safe_y_min = + (-state.scroll_size.height + state.input_bounds.size.height).min(px(0.)); + + assert!( + deferred.y >= safe_y_min, + "deferred_scroll_offset.y = {:?} below safe_y_min = {:?} \ + — paint would jitter (Bug C regression)", + deferred.y, + safe_y_min, + ); + }); + }); + } } From 795d12116ab5d202cbf0aceb61b67d9ff0ad2666 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 2 Jun 2026 18:02:10 +0800 Subject: [PATCH 2/3] Cleanup --- crates/ui/src/input/element.rs | 76 +++--------- crates/ui/src/input/state.rs | 208 +++++++-------------------------- 2 files changed, 60 insertions(+), 224 deletions(-) diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 57246a517d..e0313b4de1 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -217,30 +217,15 @@ fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() } -/// Minimum pixel padding the cursor is kept clear of the viewport's top -/// and bottom edges before auto-scroll engages — the "cursor -/// surrounding lines" setting. +/// Minimum pixel padding the cursor is kept clear of the viewport's +/// top/bottom edges before auto-scroll engages. Backs +/// [`InputState::cursor_surrounding_lines`]. /// -/// Auto-grow input always uses one line. For other modes: -/// -/// - `override_lines = None` (the historical default): on small -/// viewports (less than [`BOTTOM_MARGIN_ROWS`] × 8 lines tall) the -/// editor falls back to one line so the cursor isn't stranded; -/// otherwise [`BOTTOM_MARGIN_ROWS`] lines. -/// - `override_lines = Some(n)`: exactly `n × line_height`, regardless -/// of viewport size. Caller is trusted to pick a sensible value for -/// the surface (e.g. `Some(1)` for JetBrains-style "keep one trailing -/// row visible" behaviour). -/// -/// In all cases the result is saturated against half the visible -/// region so the auto-scroll-into-view computation always has room -/// — without this clamp, an aggressive override on a small viewport -/// (e.g. `Some(20)` on a 10-line viewport) yields a bottom-edge -/// threshold below the top edge, sending the per-frame scroll-offset -/// adjustment into a feedback loop. -/// -/// See [`InputState::cursor_surrounding_lines`] for caller-facing -/// documentation. +/// Auto-grow uses one line. Otherwise `None` falls back to the historical +/// heuristic ([`BOTTOM_MARGIN_ROWS`] lines, or one line on small +/// viewports); `Some(n)` uses `n` lines. The result is saturated against +/// half the viewport so an oversized override can't invert the +/// top/bottom thresholds into a scroll feedback loop. pub(super) fn cursor_surrounding_padding( is_auto_grow: bool, override_lines: Option, @@ -260,33 +245,17 @@ pub(super) fn cursor_surrounding_padding( } } }; - // Saturate against half the viewport so top + bottom margins can - // always coexist with at least one cursor-line of clear space - // between them. + // Saturate against half the viewport so top + bottom margins can coexist. let viewport_half = (visible_lines as f32 * line_height).half(); raw.min(viewport_half) } /// Pixel height of the empty area below the last line in the editor's -/// scrollable region — the "scroll past last line" affordance. -/// -/// Outside code-editor mode the result is always `0`. Inside code-editor -/// mode the height is determined by the host-provided override: -/// -/// - `override_rows = None` (the historical default): roughly half the -/// viewport, never less than [`BOTTOM_MARGIN_ROWS`] line-heights — -/// matches the prior behavior and JetBrains IDEs / VSCode default. -/// The cursor can sit anywhere in the viewport without being painted -/// at the very edge. -/// - `override_rows = Some(n)`: exactly `n` line-heights. `Some(0)` -/// produces the tightest possible scroll bounds (`max_scroll = -/// max(0, content_height − viewport_height)`) but, with the current -/// per-frame scroll-into-view calculation, can flicker on `Down` at -/// end-of-buffer. `Some(3..=8)` is the recommended sweet spot for -/// full-pane editors that don't want the half-viewport affordance. +/// scrollable region. Backs [`InputState::scroll_beyond_last_line`]. /// -/// See [`InputState::scroll_past_last_line_rows`] for caller-facing -/// documentation. +/// `0` outside code-editor mode. Inside it, `None` is half the viewport +/// (floored at [`BOTTOM_MARGIN_ROWS`] line-heights); `Some(n)` is exactly +/// `n` line-heights. fn empty_bottom_height( is_code_editor: bool, override_rows: Option, @@ -398,12 +367,8 @@ impl TextElement { let mut scroll_offset = state.scroll_handle.offset(); let mut cursor_bounds = None; - // Minimum padding kept between the cursor and the viewport's - // top/bottom edges. The auto-scroll-into-view computation below - // uses this to decide when to advance the scroll offset as the - // cursor moves toward an edge. Honors - // `state.cursor_surrounding_lines` if set; otherwise falls back - // to the historical heuristic. + // Padding kept between the cursor and the viewport's top/bottom + // edges, used by the auto-scroll-into-view computation below. let top_bottom_margin = cursor_surrounding_padding( state.mode.is_auto_grow(), state.cursor_surrounding_lines, @@ -1800,17 +1765,14 @@ impl Element for TextElement { let total_wrapped_lines = state.display_map.wrap_row_count(); let empty_bottom_height = empty_bottom_height( state.mode.is_code_editor(), - state.scroll_past_last_line_rows, + state.scroll_beyond_last_line, bounds.size.height, line_height, ); - // Empty bottom and ghost lines both describe extra height past - // the last real content row; they should not stack. Taking the - // max produces a tight `max_scroll` so the cursor can reach - // every empty pixel of the scrollable region. Summing them - // (the prior behavior) left a band of unreachable empty space - // below the cursor-traversable area visible at scroll-max. + // Empty bottom and ghost lines both describe extra height past the + // last content row, so take the max rather than summing — summing + // left a band of empty space the cursor could never reach. let mut scroll_size = size( if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { longest_line_width + line_number_width + RIGHT_MARGIN diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index b3e0bfa44c..b6800fb30c 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -371,27 +371,9 @@ pub struct InputState { pub(super) clean_on_escape: bool, pub(super) submit_on_enter: bool, pub(super) soft_wrap: bool, - /// Number of empty rows the editor reserves below the last line of - /// content in code-editor mode (the "scroll past last line" affordance). - /// - /// `None` (default) preserves the historical heuristic: roughly half - /// the viewport, never less than [`BOTTOM_MARGIN_ROWS`] line-heights. - /// `Some(n)` overrides with exactly `n` line-heights. Has no effect - /// outside [`InputMode::CodeEditor`]. - /// - /// See [`Self::scroll_past_last_line_rows`] for usage and the - /// trade-offs for typical values. - pub(super) scroll_past_last_line_rows: Option, - /// Minimum number of lines the cursor is kept clear of the viewport - /// edges when the cursor moves toward the top or bottom — used by - /// the auto-scroll-into-view computation in - /// [`TextElement::layout_cursor`]. - /// - /// `None` (default) preserves the historical heuristic: - /// [`BOTTOM_MARGIN_ROWS`] for normal viewports, falls back to one - /// line when the viewport is small. `Some(n)` overrides with - /// exactly `n` lines. See - /// [`Self::cursor_surrounding_lines`] for usage notes. + /// See [`Self::scroll_beyond_last_line`]. + pub(super) scroll_beyond_last_line: Option, + /// See [`Self::cursor_surrounding_lines`]. pub(super) cursor_surrounding_lines: Option, pub(super) show_whitespaces: bool, /// This flag tells the renderer to prefer the end of the current visual line. @@ -510,7 +492,7 @@ impl InputState { clean_on_escape: false, submit_on_enter: false, soft_wrap: true, - scroll_past_last_line_rows: None, + scroll_beyond_last_line: None, cursor_surrounding_lines: None, show_whitespaces: false, loading: false, @@ -948,131 +930,44 @@ impl InputState { cx.notify(); } - /// Number of empty rows reserved below the last line of content - /// ("scroll past last line"). Code-editor mode only. - /// - /// # What this controls - /// - /// In a multi-line code editor, the scrollable region is normally a - /// little taller than the document so the cursor can sit anywhere in - /// the viewport without ever being painted at the very bottom edge. - /// That extra height is what makes scrolling "past" the last line - /// possible — empty rows appear under the document and the cursor - /// can be parked there. JetBrains IDEs (WebStorm, IntelliJ) and - /// VSCode both expose the same behaviour via settings (Editor → - /// "Show empty space at the end of the file" / `editor. - /// scrollBeyondLastLine`). + /// Empty rows reserved below the last line of content ("scroll + /// beyond last line"), code-editor mode only. Mirrors VSCode's + /// `editor.scrollBeyondLastLine` / Zed's `scroll_beyond_last_line`. /// - /// # Why this matters - /// - /// 1. **Cursor placement.** Without bottom padding the cursor at the - /// last line is glued to the viewport edge — uncomfortable for - /// long sessions. - /// 2. **Visual taste.** Some surfaces (full-pane editors with no - /// chrome below) feel "broken" when half the viewport is empty - /// after the last line. Embedded editors (chunked into a form - /// layout) usually want zero or minimal empty rows. - /// 3. **Matching IDE conventions.** Different editors set different - /// defaults. Exposing the value lets a host pick the right one - /// for the surface without reaching into upstream. - /// - /// # Values - /// - /// - `None` (default): preserves historical behaviour — roughly - /// half the viewport, floored at [`BOTTOM_MARGIN_ROWS`] - /// line-heights. Good for traditional file-tree-plus-editor IDE - /// layouts where the editor is one panel among many. - /// - `Some(0)`: clamps `max_scroll = max(0, content_height − - /// viewport_height)` exactly. The cursor sits flush with the last - /// content row when scrolled to the bottom. - /// - `Some(1..=2)`: minimal trailing empty space, similar to - /// GitHub's web diff viewer. Cursor padding still adequate. - /// - `Some(3..=8)`: WebStorm-ish — small but visible empty band - /// below the document. Recommended sweet spot for full-pane - /// editors. - /// - `Some(n)` for large `n`: explicit half-viewport-equivalent — - /// normally not needed because `None` already gives that. - /// - /// Outside [`InputMode::CodeEditor`] this value is ignored. - pub fn scroll_past_last_line_rows(mut self, rows: Option) -> Self { - self.scroll_past_last_line_rows = rows; + /// - `None` (default): half the viewport, floored at + /// [`BOTTOM_MARGIN_ROWS`] line-heights. + /// - `Some(0)`: no trailing space; the cursor sits flush with the + /// last row at scroll-max. + /// - `Some(n)`: exactly `n` rows. + pub fn scroll_beyond_last_line(mut self, rows: Option) -> Self { + self.scroll_beyond_last_line = rows; self } - /// Update [`Self::scroll_past_last_line_rows`] after construction. - pub fn set_scroll_past_last_line_rows( + /// Update [`Self::scroll_beyond_last_line`] after construction. + pub fn set_scroll_beyond_last_line( &mut self, rows: Option, _: &mut Window, cx: &mut Context, ) { - if self.scroll_past_last_line_rows == rows { + if self.scroll_beyond_last_line == rows { return; } - self.scroll_past_last_line_rows = rows; + self.scroll_beyond_last_line = rows; cx.notify(); } - /// Minimum number of lines the cursor is kept clear of the viewport - /// edges. Code-editor mode primarily; other multi-line modes also - /// honor it via [`TextElement::layout_cursor`]. - /// - /// # What this controls + /// Minimum number of lines the cursor is kept clear of the viewport's + /// top/bottom edge before auto-scroll engages. Mirrors VSCode's + /// `editor.cursorSurroundingLines` / Zed's `vertical_scroll_margin`. + /// Orthogonal to [`Self::scroll_beyond_last_line`], which sizes the + /// empty region; this controls the cursor's resting distance from the + /// edge. /// - /// Different from [`Self::scroll_past_last_line_rows`]. That - /// controls how much *empty space* exists below the document. - /// `cursor_surrounding_lines` controls how close the cursor is - /// allowed to get to the viewport's top or bottom edge before the - /// editor auto-scrolls. When you press `Enter` at the end of a - /// buffer, the new cursor position is computed first, and then - /// the scroll offset is adjusted so the cursor sits at least this - /// many lines from the edge — which is what makes JetBrains' - /// behaviour of "Enter keeps adding rows AND keeps a trailing - /// empty row visible" possible without the cursor disappearing - /// into the bottom edge. - /// - /// # Why both are needed - /// - /// The two settings answer different questions: - /// - /// - `scroll_past_last_line_rows` answers "how far below the last - /// line can the user scroll?" — about the *scrollable region*. - /// - `cursor_surrounding_lines` answers "where in the viewport - /// does the cursor stay as it moves?" — about the *cursor's - /// relative position*. - /// - /// A short file with `scroll_past_last_line_rows = Some(8)` but - /// `cursor_surrounding_lines = Some(1)` lets the user scroll into - /// 8 empty rows manually but keeps the cursor itself 1 line clear - /// of edges during normal typing — VSCode and JetBrains both - /// expose them as separate settings (`editor.scrollBeyondLastLine` - /// + `editor.cursorSurroundingLines` in VSCode). - /// - /// # Values - /// - /// - `None` (default): preserves historical behaviour — - /// [`BOTTOM_MARGIN_ROWS`] lines for normal viewports, one line - /// on small viewports (less than `BOTTOM_MARGIN_ROWS × 8` rows - /// tall). - /// - `Some(0)`: cursor can sit on the very edge row of the - /// viewport. Tight; useful when paired with a non-zero - /// `scroll_past_last_line_rows` so the cursor visually has - /// room "below" it via the trailing empty space. - /// - `Some(1..=2)`: JetBrains-ish — the cursor stays 1–2 rows - /// clear of edges; pressing `Enter` at end-of-buffer keeps a - /// small visible band below the cursor as new lines are added. - /// - `Some(3..=8)`: more generous; common in editors that want - /// the cursor visually centred in the viewport's lower half - /// during long bursts of typing. - /// - /// # Saturation - /// - /// The effective padding is clamped against half the visible - /// region. An aggressive override on a small viewport (e.g. - /// `Some(20)` on a 10-line viewport) is capped at half-viewport - /// rather than producing a degenerate auto-scroll feedback loop. - /// Hosts shouldn't rely on this — pick a value that fits — but it - /// keeps misconfiguration recoverable instead of crashing. + /// - `None` (default): [`BOTTOM_MARGIN_ROWS`] lines, falling back to + /// one line on small viewports. + /// - `Some(n)`: exactly `n` lines, clamped to half the viewport. pub fn cursor_surrounding_lines(mut self, lines: Option) -> Self { self.cursor_surrounding_lines = lines; self @@ -1837,16 +1732,10 @@ impl InputState { } } - // Check if row_offset_y is out of the viewport - // If row offset is not in the viewport, scroll to make it visible. - // For code-editor moves with a direction, honor the configured - // `cursor_surrounding_lines` (default heuristic = `BOTTOM_MARGIN_ROWS` - // lines) — same helper `TextElement::layout_cursor` uses, so the two - // scroll-into-view computations agree on the edge clearance. Without - // this, state-side demanded a hardcoded 3 lines of clearance while - // element-side honored the override, and the mismatched targets at - // end-of-buffer with small overrides (e.g. `Some(1)`) produced the - // jump-then-snap-back flicker on `Down`. + // Scroll the row into view. Use the same edge clearance helper as + // `TextElement::layout_cursor` so both scroll-into-view paths agree + // (a mismatch flickered on `Down` at end-of-buffer with a small + // `cursor_surrounding_lines` override). let edge_height = if direction.is_some() && self.mode.is_code_editor() { super::element::cursor_surrounding_padding( self.mode.is_auto_grow(), @@ -1872,12 +1761,9 @@ impl InputState { scroll_offset.y = scroll_offset.y.min(was_offset.y); } - // Defense-in-depth: clamp the deferred target into the same safe - // range that `update_scroll_offset` enforces on persist. Without - // this, an over-aggressive demand here paints one frame at the - // unclamped position before the post-paint clamp pulls - // `scroll_handle` back, producing the same flicker the helper - // unification above prevents at the source. + // Clamp the deferred target into the same safe range that + // `update_scroll_offset` enforces on persist, so paint never shows an + // over-scrolled frame before the post-paint clamp pulls it back. let safe_y_min = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.)); scroll_offset.x = scroll_offset.x.min(px(0.)); scroll_offset.y = scroll_offset.y.clamp(safe_y_min, px(0.)); @@ -3037,33 +2923,21 @@ ORDER BY id ); } - /// Regression test for "scroll flicker on `Down` at end-of-buffer" with - /// a small `cursor_surrounding_lines` override. Pairs with the pure-helper - /// coverage in `element.rs::test_cursor_surrounding_padding_*` — those - /// assert the helper saturates correctly; this asserts the state-side - /// `scroll_to` consumer of the helper produces a deferred scroll target - /// that lies within the safe scroll range, so the painted frame matches - /// what `update_scroll_offset` would persist (no jitter). - /// - /// Before the Bug C fix, `scroll_to` hardcoded `edge_height = 3 * line_height` - /// regardless of `cursor_surrounding_lines`, and the deferred target was - /// only capped at `min(px(0.))` — not against `safe_y_min`. With - /// `scroll_past_last_line_rows = Some(1)` + `cursor_surrounding_lines = Some(1)`, - /// the demanded target landed two line-heights below the safe range, so - /// paint used the unclamped value before the post-paint clamp pulled - /// `scroll_handle` back — visible as a one-line jump-then-snap-back. + /// Regression test: `scroll_to` at end-of-buffer must produce a deferred + /// scroll target within the safe scroll range, so the painted frame + /// matches what `update_scroll_offset` persists (no jitter). A small + /// `cursor_surrounding_lines` override used to mismatch the hardcoded + /// 3-line edge clearance in `scroll_to`, overshooting `safe_y_min`. #[gpui::test] fn test_scroll_to_eob_does_not_overshoot_safe_range(cx: &mut TestAppContext) { let input_view = InputView::new(cx); let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); let input = input_view.input; - // JB-style: 1 trailing empty row + 1-line cursor surrounding. - // Mismatched against the historical 3-line edge_height in `scroll_to` - // — the exact configuration that surfaced Bug C in the heretic showcase. + // JetBrains-style: 1 trailing empty row + 1-line cursor surrounding. cx.update(|window, cx| { input.update(cx, |state, cx| { - state.set_scroll_past_last_line_rows(Some(1), window, cx); + state.set_scroll_beyond_last_line(Some(1), window, cx); state.set_cursor_surrounding_lines(Some(1), window, cx); let text: String = (1..=50) .map(|i| format!("line {i}")) From 9bc43ac07bd89c2919751199ca7dfe7265774778 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 2 Jun 2026 18:18:34 +0800 Subject: [PATCH 3/3] Update example and docs. --- crates/story/examples/editor.rs | 67 +++++++++++++++++++++++++++- docs/docs/components/editor.md | 23 ++++++++++ docs/zh-CN/docs/components/editor.md | 23 ++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index 53a230615e..d78159c18b 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -76,6 +76,8 @@ pub struct Example { soft_wrap: bool, show_whitespaces: bool, folding: bool, + scroll_beyond_last_line: Option, + cursor_surrounding_lines: Option, lsp_store: ExampleLspStore, _subscriptions: Vec, _lint_task: Task<()>, @@ -740,6 +742,8 @@ impl Example { soft_wrap: false, show_whitespaces: false, folding: true, + scroll_beyond_last_line: None, + cursor_surrounding_lines: None, lsp_store, _subscriptions, _lint_task: Task::ready(()), @@ -1051,6 +1055,65 @@ impl Example { })) } + /// Cycle an `Option` row setting through a few demo values. + fn cycle_rows(v: Option) -> Option { + match v { + None => Some(0), + Some(0) => Some(3), + Some(3) => Some(8), + _ => None, + } + } + + fn rows_label(v: Option) -> String { + match v { + None => "default".to_string(), + Some(n) => n.to_string(), + } + } + + fn render_scroll_beyond_last_line_button( + &self, + _: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + Button::new("scroll-beyond-last-line") + .ghost() + .xsmall() + .label(format!( + "Scroll Beyond: {}", + Self::rows_label(self.scroll_beyond_last_line) + )) + .on_click(cx.listener(|this, _, window, cx| { + this.scroll_beyond_last_line = Self::cycle_rows(this.scroll_beyond_last_line); + this.editor.update(cx, |state, cx| { + state.set_scroll_beyond_last_line(this.scroll_beyond_last_line, window, cx); + }); + cx.notify(); + })) + } + + fn render_cursor_surrounding_lines_button( + &self, + _: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + Button::new("cursor-surrounding-lines") + .ghost() + .xsmall() + .label(format!( + "Cursor Surrounding: {}", + Self::rows_label(self.cursor_surrounding_lines) + )) + .on_click(cx.listener(|this, _, window, cx| { + this.cursor_surrounding_lines = Self::cycle_rows(this.cursor_surrounding_lines); + this.editor.update(cx, |state, cx| { + state.set_cursor_surrounding_lines(this.cursor_surrounding_lines, window, cx); + }); + cx.notify(); + })) + } + fn render_go_to_line_button(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let position = self.editor.read(cx).cursor_position(); let cursor = self.editor.read(cx).cursor(); @@ -1126,7 +1189,9 @@ impl Render for Example { .child(self.render_soft_wrap_button(window, cx)) .child(self.render_show_whitespaces_button(window, cx)) .child(self.render_indent_guides_button(window, cx)) - .child(self.render_folding_button(window, cx)), + .child(self.render_folding_button(window, cx)) + .child(self.render_scroll_beyond_last_line_button(window, cx)) + .child(self.render_cursor_surrounding_lines_button(window, cx)), ) .child(self.render_go_to_line_button(window, cx)), ), diff --git a/docs/docs/components/editor.md b/docs/docs/components/editor.md index 17111ed3ad..6cef8e6871 100644 --- a/docs/docs/components/editor.md +++ b/docs/docs/components/editor.md @@ -153,6 +153,29 @@ let state = cx.new(|cx| ); ``` +### Scroll Behavior + +In `code_editor` mode you can tune how the editor scrolls around the cursor and the end of the document. Both options mirror the equivalent VSCode / JetBrains settings and only take effect in `code_editor` mode. + +- `scroll_beyond_last_line(Option)` — empty rows reserved below the last line ("scroll beyond last line", like VSCode's `editor.scrollBeyondLastLine`). `None` (default) keeps the historical heuristic of roughly half the viewport; `Some(0)` removes the trailing space so the cursor sits flush with the last line; `Some(n)` reserves exactly `n` rows. +- `cursor_surrounding_lines(Option)` — minimum number of lines the cursor is kept clear of the viewport's top/bottom edge before auto-scroll engages (like VSCode's `editor.cursorSurroundingLines`). `None` (default) keeps the historical heuristic; `Some(n)` keeps exactly `n` lines. + +```rust +let state = cx.new(|cx| + InputState::new(window, cx) + .code_editor("rust") + // Reserve 3 empty rows below the last line. + .scroll_beyond_last_line(Some(3)) + // Keep the cursor at least 1 line from the top/bottom edge. + .cursor_surrounding_lines(Some(1)) +); + +Input::new(&state) + .h_full() +``` + +Both can also be changed at runtime via `set_scroll_beyond_last_line` and `set_cursor_surrounding_lines`. + ### Text Manipulation ```rust diff --git a/docs/zh-CN/docs/components/editor.md b/docs/zh-CN/docs/components/editor.md index a7838a362b..c149c8a6ce 100644 --- a/docs/zh-CN/docs/components/editor.md +++ b/docs/zh-CN/docs/components/editor.md @@ -144,6 +144,29 @@ let state = cx.new(|cx| ); ``` +### 滚动行为 + +在 `code_editor` 模式下,可以调整编辑器在光标附近以及文档末尾的滚动方式。两个选项分别对应 VSCode / JetBrains 中的同名设置,且仅在 `code_editor` 模式下生效。 + +- `scroll_beyond_last_line(Option)` — 在最后一行下方保留的空行数(“scroll beyond last line”,对应 VSCode 的 `editor.scrollBeyondLastLine`)。`None`(默认)保留历史行为,约为半个视口高度;`Some(0)` 不保留空白,光标紧贴最后一行;`Some(n)` 精确保留 `n` 行。 +- `cursor_surrounding_lines(Option)` — 触发自动滚动前,光标距视口上/下边缘保持的最小行数(对应 VSCode 的 `editor.cursorSurroundingLines`)。`None`(默认)保留历史行为;`Some(n)` 精确保留 `n` 行。 + +```rust +let state = cx.new(|cx| + InputState::new(window, cx) + .code_editor("rust") + // 在最后一行下方保留 3 个空行。 + .scroll_beyond_last_line(Some(3)) + // 让光标距上/下边缘至少保持 1 行。 + .cursor_surrounding_lines(Some(1)) +); + +Input::new(&state) + .h_full() +``` + +两者也可以在运行时通过 `set_scroll_beyond_last_line` 和 `set_cursor_surrounding_lines` 修改。 + ### 文本操作 ```rust