Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/base/src/input/base/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ fn ime_marked_display_range(
}
}

/// Viewport capacity in display rows, independent of logical buffer lines.
///
/// Shared by both scroll-into-view paths (`layout_cursors` and
/// `scroll_to_with_padding`) so they agree on the visible-line count even
/// when soft-wrapped lines make logical and display rows diverge.
pub(super) fn viewport_visible_lines(viewport_height: Pixels, line_height: Pixels) -> usize {
(viewport_height / line_height) as usize
}

/// Minimum pixel padding the cursor is kept clear of the viewport's
/// top/bottom edges before auto-scroll engages. Backs
/// [`InputBaseState::cursor_surrounding_lines`].
Expand All @@ -333,7 +342,8 @@ fn ime_marked_display_range(
/// 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.
/// top/bottom thresholds into a scroll feedback loop. `visible_lines` is the
/// viewport capacity in display rows, independent of logical buffer lines.
pub(super) fn cursor_surrounding_padding(
is_auto_grow: bool,
override_lines: Option<usize>,
Expand Down Expand Up @@ -462,7 +472,7 @@ impl<M: InputModeKind> TextElement<M> {
let top_bottom_margin = cursor_surrounding_padding(
state.mode.is_auto_grow(),
state.cursor_surrounding_lines,
visible_range.len(),
viewport_visible_lines(bounds.size.height, line_height),
line_height,
);

Expand Down
65 changes: 54 additions & 11 deletions crates/base/src/input/base/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2445,25 +2445,29 @@ impl<M: InputModeKind> InputBaseState<M> {

let row = point.row;

// Calculate row offset by multiplying the number of lines before it with the line height
let mut row_offset_y = line_height * self.display_map.buffer_line_to_display_row(row);
// Resolve the wrapped row even when the target is outside the last layout.
let display_pos = self
.display_map
.buffer_pos_to_display_pos(crate::input::BufferPoint::new(row, point.column));
let row_offset_y = line_height * display_pos.row;

// For Right alignment use 0 margin: the cursor indicator is clamped inside bounds
// in layout_cursor, so shifting the text here would cause a first-click visual jump.
// in layout_cursors, so shifting the text here would cause a first-click visual jump.
let safety_margin = match last_layout.text_align {
TextAlign::Left => RIGHT_MARGIN,
TextAlign::Right => px(0.),
TextAlign::Center => CURSOR_WIDTH,
};
if let Some(line) = last_layout
.lines
.get(row.saturating_sub(last_layout.visible_range.start))
if let Some(vi) = last_layout
.visible_buffer_lines
.iter()
.position(|&line| line == row)
{
// Check to scroll horizontally and soft wrap lines
if let Some(pos) = line.position_for_index(point.column, last_layout, false) {
let line = &last_layout.lines[vi];
let local_offset = offset.saturating_sub(last_layout.visible_line_byte_offsets[vi]);
if let Some(pos) = line.position_for_index(local_offset, last_layout, false) {
let bounds_width = bounds.size.width - last_layout.line_number_width;
let col_offset_x = pos.x;
row_offset_y += pos.y;
if col_offset_x - safety_margin < -scroll_offset.x {
// If the position is out of the visible area, scroll to make it visible
scroll_offset.x = -col_offset_x + safety_margin;
Expand All @@ -2474,15 +2478,15 @@ impl<M: InputModeKind> InputBaseState<M> {
}

// Scroll the row into view. Use the same edge clearance helper as
// `TextElement::layout_cursor` so both scroll-into-view paths agree
// `TextElement::layout_cursors` 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 matches!(padding, ScrollPadding::SurroundingLines) && self.is_code_editor() {
super::element::cursor_surrounding_padding(
self.mode.is_auto_grow(),
self.cursor_surrounding_lines,
last_layout.visible_range.len(),
super::element::viewport_visible_lines(bounds.size.height, line_height),
line_height,
)
} else {
Expand Down Expand Up @@ -4899,6 +4903,45 @@ mod tests {
});
}

#[gpui::test]
fn test_search_reveals_offscreen_wrapped_match(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;
let text = format!(
"match\n{}\n{}match\n{}",
"line\n".repeat(80),
"wrapped text ".repeat(500),
"line\n".repeat(80)
);
cx.update(|window, cx| {
input.update(cx, |state, cx| {
state.set_cursor_surrounding_lines(Some(3), window, cx);
state.set_value(text, window, cx);
state.set_search_query("match", true, cx);
});
});
cx.run_until_parked();
cx.update(|_, cx| {
input.update(cx, |state, cx| {
state.next_search_match(cx).unwrap();
});
});
cx.run_until_parked();
input.read_with(&cx, |state, _| {
let range = state.search_session.matcher.matched_ranges()[1].clone();
let layout = state.last_layout.as_ref().unwrap();
let (_, _, position) = state.line_and_position_for_offset(range.end);
let y = position.expect("wrapped match must be laid out").y
+ state.scroll_handle.offset().y;
assert!(y >= layout.line_height * 2. - px(0.1));
assert!(
y + layout.line_height * 3. <= state.last_bounds.unwrap().size.height + px(0.1),
"wrapped match must retain surrounding display rows"
);
});
}

#[gpui::test]
fn test_number_step(cx: &mut TestAppContext) {
let input = InputView::build(cx, |state| state).input;
Expand Down
Loading