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
31 changes: 23 additions & 8 deletions crates/editor/src/render/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,18 @@ impl CharCellState {
self.scroll_offset.get()
}

/// Clamps the retained viewport offset to the current display-row count.
pub fn clamp_scroll_offset(
&self,
cursor_char_offset: CharOffset,
viewport_rows: u32,
hidden_line_ranges: &[Range<usize>],
) {
let (_, total_rows) = self.display_geometry(cursor_char_offset, hidden_line_ranges);
let (offset, _) = self.clamped_scroll_window(total_rows, viewport_rows);
self.scroll_offset.set(offset);
}

/// Scrolls the viewport by `rows` display rows (negative scrolls toward
/// the top), clamped to `[0, total_rows - visible_rows]`. Independent of
/// the cursor: wheel scrolling must not snap the viewport back to it.
Expand Down Expand Up @@ -892,14 +904,7 @@ impl CharCellState {
) {
let (cursor_row, total_rows) =
self.display_geometry(cursor_char_offset, hidden_line_ranges);
let visible_rows = total_rows.min(viewport_rows).max(1);
// A stale offset can point past the last remaining row (e.g. after a
// deletion shrank the content); clamp it so the visible window always
// overlaps real rows before following the cursor.
let mut offset = self
.scroll_offset
.get()
.min(total_rows.saturating_sub(visible_rows));
let (mut offset, visible_rows) = self.clamped_scroll_window(total_rows, viewport_rows);
let Some(cursor_row) = cursor_row else {
self.scroll_offset.set(offset);
return;
Expand All @@ -912,6 +917,16 @@ impl CharCellState {
self.scroll_offset.set(offset);
}

/// Returns the clamped first row and visible-row count for a viewport.
fn clamped_scroll_window(&self, total_rows: u32, viewport_rows: u32) -> (u32, u32) {
let visible_rows = total_rows.min(viewport_rows).max(1);
let offset = self
.scroll_offset
.get()
.min(total_rows.saturating_sub(visible_rows));
(offset, visible_rows)
}

/// The cursor's display row and the total display-row count — including
/// the deferred-wrap phantom row the cursor sits on when a logical line
/// exactly fills the terminal width, which the lattice's rows never count
Expand Down
12 changes: 12 additions & 0 deletions crates/editor/src/render/model/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2086,4 +2086,16 @@ mod char_cell_scroll {
state.follow_cursor(CharOffset::zero(), 2, &[]);
assert_eq!(state.scroll_offset(), 0);
}

#[test]
fn clamp_scroll_offset_repairs_stale_offset_without_following_cursor() {
let state = CharCellState::new(3, None);
state.update_text("abcdef");
state.scroll_by(100, 1, CharOffset::from(6), &[]);
assert_eq!(state.scroll_offset(), 2);

state.set_terminal_width(10);
state.clamp_scroll_offset(CharOffset::from(6), 1, &[]);
assert_eq!(state.scroll_offset(), 0);
}
}
10 changes: 9 additions & 1 deletion crates/warp_tui/src/editor_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const WHEEL_STEP: isize = 2;
/// owning view translates them into its own typed actions and applies them to
/// the editor model (mirroring how the GUI's element dispatches into its view).
#[derive(Debug, Clone)]
pub(crate) enum TuiEditorAction {
pub enum TuiEditorAction {
/// Insert a printable character (only emitted when the element is
/// [`editable`](TuiEditorElement::editable)).
InsertChar(char),
Expand Down Expand Up @@ -312,10 +312,18 @@ impl TuiEditorElement {
0
};
let content_width = full_width.saturating_sub(self.gutter_cols);
let width_changed = char_cell.terminal_width() != content_width;
char_cell.set_terminal_width(content_width);

let chars: Vec<char> = self.text.chars().collect();
let cursor_offset = CharOffset::from(self.cursor_offset.as_usize().saturating_sub(1));
if let Some(viewport_rows) = self.viewport_rows {
if width_changed {
char_cell.follow_cursor(cursor_offset, viewport_rows, &hidden);
} else {
char_cell.clamp_scroll_offset(cursor_offset, viewport_rows, &hidden);
}
}
// The first visible row is model-side scroll state; unwindowed
// consumers always render from the top.
let first_visible_row = if self.viewport_rows.is_some() {
Expand Down
23 changes: 23 additions & 0 deletions crates/warp_tui/src/editor_element_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,26 @@ fn scroll_windows_the_visible_rows() {
});
});
}

#[test]
fn width_change_follows_cursor_after_reflow() {
App::test((), |mut app| async move {
app.update(|ctx| {
ctx.add_singleton_model(|_| Appearance::mock());
let model = model(ctx, "abcde");
model.update(ctx, |model, ctx| {
model.select_at(CharOffset::from(6), false, ctx);
model.end_selection(ctx);
});

let wide = TuiEditorElement::new(&model, ctx).with_viewport_rows(1);
assert_eq!(render_lines(ctx, wide, 10, 10), vec!["abcde"]);

let narrow = TuiEditorElement::new(&model, ctx).with_viewport_rows(1);
assert_eq!(render_lines(ctx, narrow, 3, 10), vec!["de"]);
let render = model.as_ref(ctx).render_state().as_ref(ctx);
let char_cell = render.char_cell().expect("char-cell model");
assert_eq!(char_cell.scroll_offset(), 1);
});
});
}
Loading
Loading