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/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 170d7fb25d..e0313b4de1 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -217,6 +217,60 @@ 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/bottom edges before auto-scroll engages. Backs +/// [`InputState::cursor_surrounding_lines`]. +/// +/// 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, + 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 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. Backs [`InputState::scroll_beyond_last_line`]. +/// +/// `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, + 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 +367,14 @@ 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 - }; + // 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, + 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 +1763,25 @@ 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_beyond_last_line, + bounds.size.height, + line_height, + ); + // 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 } 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 +2537,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 d5ea3cabdd..b6800fb30c 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -371,6 +371,10 @@ pub struct InputState { pub(super) clean_on_escape: bool, pub(super) submit_on_enter: bool, pub(super) soft_wrap: bool, + /// 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. pub(crate) cursor_line_end_affinity: bool, @@ -488,6 +492,8 @@ impl InputState { clean_on_escape: false, submit_on_enter: false, soft_wrap: true, + scroll_beyond_last_line: None, + cursor_surrounding_lines: None, show_whitespaces: false, loading: false, pattern: None, @@ -924,6 +930,63 @@ impl InputState { cx.notify(); } + /// 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`. + /// + /// - `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_beyond_last_line`] after construction. + pub fn set_scroll_beyond_last_line( + &mut self, + rows: Option, + _: &mut Window, + cx: &mut Context, + ) { + if self.scroll_beyond_last_line == rows { + return; + } + self.scroll_beyond_last_line = rows; + cx.notify(); + } + + /// 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. + /// + /// - `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 + } + + /// 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. @@ -1669,10 +1732,17 @@ 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 + // 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() { - 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 }; @@ -1691,8 +1761,12 @@ impl InputState { scroll_offset.y = scroll_offset.y.min(was_offset.y); } + // 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.min(px(0.)); + scroll_offset.y = scroll_offset.y.clamp(safe_y_min, px(0.)); self.deferred_scroll_offset = Some(scroll_offset); cx.notify(); } @@ -2848,4 +2922,70 @@ ORDER BY id colored_before, colored_after ); } + + /// 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; + + // JetBrains-style: 1 trailing empty row + 1-line cursor surrounding. + cx.update(|window, cx| { + input.update(cx, |state, 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}")) + .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, + ); + }); + }); + } } 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