From 93a4bca541dfb8399d5a58fbb96fdcdcb3285fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4ng=20Tr=E1=BA=A7n?= Date: Sun, 4 Oct 2026 00:32:18 +0700 Subject: [PATCH] fix(input): let the scroll reach an inline completion's ghost lines 62a6c40 resolves the vertical scroll before layout and clamps it to the text's height, leaving inline-completion ghost lines out on the grounds that they only add height. The paint sizes the scrollable height with them (the larger of the empty bottom and the ghost lines), so a scroll down to ghost lines under the last line persisted, and the next layout clamped it back above them: they could never be scrolled to. The clamp now takes the same larger of the two, counting the ghost lines from the completion's text without shaping them. The new test scrolls an editor with 30 ghost lines under its last line to the foot: the offset held at -900 px; before the fix the next frame pulled it back to -198 px. Co-Authored-By: Claude Opus 5.5 --- crates/base/src/input/base/element.rs | 75 ++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index cb1959c406..cf7bdba68b 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -535,6 +535,7 @@ impl TextElement { state: &InputBaseState, line_height: Pixels, viewport_height: Pixels, + window: &Window, ) -> Pixels { if state.is_single_line() { return px(0.); @@ -615,16 +616,18 @@ impl TextElement { }, }; - // Clamp to the text's height as this layout wraps it, so the frame shows the - // offset that persists. Inline-completion ghost lines are left out: they only - // ever add height, so this range lies inside the one the paint persists. + // Clamp to the height this layout scrolls over, so the frame shows the offset + // that persists: the text as it wraps, and past its last row the larger of the + // empty bottom and an inline completion's ghost lines, as the paint sizes it. + let ghost_lines_height = line_height * Self::ghost_line_count(state, window) as f32; let content_height = line_height * state.display_map.wrap_row_count() + empty_bottom_height( state.is_code_editor(), state.scroll_beyond_last_line, viewport_height, line_height, - ); + ) + .max(ghost_lines_height); let min_top = (viewport_height - content_height).min(px(0.)); scroll_top.clamp(min_top, px(0.)) } @@ -1250,6 +1253,19 @@ impl TextElement { Some(Rc::new(WhitespaceIndicators { space, tab })) } + /// How many lines an inline completion adds under the caret's line: those of its + /// text past the first, shown while the input has the focus. Counted without + /// shaping them, for the scroll resolved before any line is laid out. + fn ghost_line_count(state: &InputBaseState, window: &Window) -> usize { + if !state.focus_handle.is_focused(window) { + return 0; + } + state + .extras + .inline_completion_item() + .map_or(0, |item| item.insert_text.split('\n').count() - 1) + } + /// Compute inline completion ghost lines for rendering. /// /// Returns (first_line, ghost_lines) where: @@ -1747,7 +1763,7 @@ impl TextElement { .iter() .any(|span| !cache.widths.contains_key(span.token())) }); - let scroll_top = Self::resolve_scroll_top(state, line_height, viewport); + let scroll_top = Self::resolve_scroll_top(state, line_height, viewport, window); let (visible, _, _) = self.calculate_visible_range(state, line_height, viewport, scroll_top); let start = state.text.line_start_offset(visible.start); @@ -2664,7 +2680,7 @@ impl Element for TextElement { ); let state = self.state.read(cx); - let scroll_top = Self::resolve_scroll_top(state, line_height, bounds.size.height); + let scroll_top = Self::resolve_scroll_top(state, line_height, bounds.size.height, window); let (visible_range, visible_buffer_lines, visible_top) = self.calculate_visible_range(state, line_height, bounds.size.height, scroll_top); let visible_start_offset = state.text.line_start_offset(visible_range.start); @@ -3694,6 +3710,53 @@ mod tests { (editor.unwrap(), window) } + /// Inline-completion ghost lines under the caret's last line add to the + /// scrollable height, and a scroll down to them holds from frame to frame. + #[gpui::test] + fn ghost_lines_below_the_last_line_can_be_scrolled_to(cx: &mut TestAppContext) { + let text = (0..10) + .map(|n| format!("line {n}")) + .collect::>() + .join("\n"); + let (editor, window) = decoration_editor(cx, &text, false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.set_cursor_position(lsp_types::Position::new(9, 6), window, cx); + state.extras.inline_completion.item = Some(lsp_types::InlineCompletionItem { + insert_text: format!("()\n{}", ["ghost"; 30].join("\n")), + filter_text: None, + range: None, + command: None, + insert_text_format: None, + }); + }); + window.draw(cx).clear(cx); + let (line_height, scroll_height, viewport) = editor.read_with(cx, |state, _| { + let line_height = state.last_layout.as_ref().unwrap().line_height; + ( + line_height, + state.scroll_size.height, + state.input_bounds.size.height, + ) + }); + assert!( + scroll_height >= line_height * 40., + "the ghost lines count in the scrollable height: {scroll_height:?}" + ); + + // Down to the last ghost line, as the wheel scrolls. + let bottom = viewport - scroll_height; + editor.update(cx, |state, _| { + state.scroll_handle.set_offset(point(px(0.), bottom)); + }); + window.draw(cx).clear(cx); + assert_eq!(editor.read(cx).scroll_handle.offset().y, bottom); + window.draw(cx).clear(cx); + assert_eq!(editor.read(cx).scroll_handle.offset().y, bottom); + }); + } + #[gpui::test] fn horizontal_scroll_is_clamped_before_text_and_caret_layout(cx: &mut TestAppContext) { let (editor, window) = decoration_editor(cx, "short text", false);