feat: scroll viewport independently of the cursor

This commit is contained in:
Valdemar Lorenzen
2026-10-16 09:44:50 +02:00
parent 60a12110fa
commit 08fdf2bd0f
6 changed files with 246 additions and 1 deletions
+183
View File
@@ -973,6 +973,17 @@ impl BufferViewports {
}
history.caret = Some(identity);
}
let scroll = editor.take_viewport_scroll(height);
for _ in 0..scroll.unsigned_abs() {
let previous = history.top;
history.step(scroll > 0);
if history.top == previous {
break;
}
}
if scroll != 0 {
history.fill(height);
}
for (column, pane, content) in panes.into_iter().flatten() {
editor.with_render_pane(pane, |view| {
let diff = &history.columns[column];
@@ -2339,6 +2350,19 @@ fn draw_document(
}
}
if height > 0 && width > 0 {
let scroll = editor.take_viewport_scroll(height);
if scroll != 0 {
document.anchor = None;
document.manual = Some(ManualViewport {
buffer_id: editor.buffer_id(),
revision: editor.text_revision(),
caret_reveal_revision: editor.caret_reveal_revision(),
mode: editor.mode(),
cursor,
carets: carets.clone(),
selections: selections.clone(),
});
}
if wrapped {
document
.wrap
@@ -2354,6 +2378,15 @@ fn draw_document(
source: *top,
continuation: *continuation,
};
for _ in 0..scroll.unsigned_abs() {
let next = if scroll > 0 {
next_visual(&mut document.wrap, editor.lines(), start)
} else {
previous_visual(&mut document.wrap, editor.lines(), start)
};
let Some(next) = next else { break };
start = next;
}
if document.manual.is_none() {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
let caret = VisualRow {
@@ -2414,6 +2447,11 @@ fn draw_document(
position = next;
}
} else {
if scroll != 0 {
*top = top
.saturating_add_signed(scroll)
.min(editor.lines().len().saturating_sub(height));
}
if document.manual.is_none() {
if toggle_row.is_none() {
*top = scroll_offset(*top, cursor.row, height, editor.scroll_distance());
@@ -3743,6 +3781,36 @@ fn draw_diff(
editor.map_or(Some(0), Editor::scroll_distance),
syntax,
);
let height = editor
.and_then(|editor| editor.diff_buffer_focus())
.map_or(0, |focus| {
usize::from(
diff_inner(if focus == Focus::Before {
before_area
} else {
after_area
})
.height,
)
});
if height > 0 {
let scroll = editor.map_or(0, |editor| editor.take_viewport_scroll(height));
for _ in 0..scroll.unsigned_abs() {
let next = viewport.step(diff, file, viewport.top, scroll > 0);
if next == viewport.top {
break;
}
viewport.top = next;
}
if scroll != 0 {
viewport.fill(
diff,
file,
viewport.heights[0].max(viewport.heights[1]),
syntax,
);
}
}
top = viewport.top.display;
} else {
panels.diff_wrap = None;
@@ -12455,6 +12523,121 @@ end
assert!(!views.parked.contains_key(&bottom));
}
#[test]
fn keyboard_viewport_scroll_preserves_targets_and_reveals_on_motion() {
let directory = Directory::new();
let text = (0..80)
.map(|row| format!("row-{row:02} {}", "word ".repeat(24)))
.collect::<Vec<_>>()
.join("\n");
for diff in [false, true] {
for wrapped in [false, true] {
for visual in [false, true] {
let mut editor = if diff {
readonly_diff_editor(syntax_diff_file(
"rows.txt",
&text,
&text.replace("word", "text"),
))
} else {
Editor::open(Some(directory.file("rows.txt", &text)), vec![]).unwrap()
};
if wrapped {
editor.execute("word_wrap.toggle");
}
editor
.bind("normal", "alt+j", Some("core.scroll_down".into()))
.unwrap();
editor
.bind("normal", "alt+k", Some("core.scroll_up".into()))
.unwrap();
if diff {
editor.handle(Input::Char('\t'));
}
editor.handle(Input::Ctrl('e'));
if visual {
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
}
let buffer = editor.buffer_id();
let carets = editor.carets();
let selections = editor.selections();
let cursor = editor.cursor();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(80, 14)).unwrap();
let mut views = BufferViewports::default();
let render = |terminal: &mut Terminal<ratatui::backend::TestBackend>,
views: &mut BufferViewports,
editor: &mut Editor| {
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
if diff {
let (id, file, top, before, after) =
views.panels.diff.as_ref().unwrap().viewport.unwrap();
editor.set_diff_viewport(id, file, top, before, after);
}
};
let offset = |views: &BufferViewports| {
if diff {
let top = views.panels.diff_wrap.as_ref().unwrap().top;
(top.display, top.continuation)
} else {
(
views.offsets[&buffer].0,
*views.document.continuations.get(&buffer).unwrap_or(&0),
)
}
};
render(&mut terminal, &mut views, &mut editor);
let initial = offset(&views);
let height = if diff {
views.panels.diff_wrap.as_ref().unwrap().heights[0]
} else {
usize::from(views.document.geometry.unwrap().1.height)
};
editor.handle(Input::Ctrl('d'));
render(&mut terminal, &mut views, &mut editor);
let scrolled = offset(&views);
assert!(
scrolled > initial,
"diff={diff}, wrap={wrapped}, visual={visual}"
);
if !wrapped {
assert_eq!(scrolled, (initial.0 + height / 2, 0));
}
assert_eq!(editor.carets(), carets);
assert_eq!(editor.selections(), selections);
render(&mut terminal, &mut views, &mut editor);
assert_eq!(offset(&views), scrolled);
editor.handle(Input::Ctrl('u'));
render(&mut terminal, &mut views, &mut editor);
assert_eq!(offset(&views), initial);
editor.handle(Input::Ctrl('u'));
render(&mut terminal, &mut views, &mut editor);
assert_eq!(offset(&views), initial);
if !visual {
editor.handle(Input::Char('3'));
editor.handle(Input::Alt('j'));
render(&mut terminal, &mut views, &mut editor);
assert!(offset(&views) > initial);
if !wrapped {
assert_eq!(offset(&views), (initial.0 + 3, 0));
}
editor.handle(Input::Char('3'));
editor.handle(Input::Alt('k'));
render(&mut terminal, &mut views, &mut editor);
assert_eq!(offset(&views), initial);
}
editor.handle(Input::Ctrl('d'));
render(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Home);
render(&mut terminal, &mut views, &mut editor);
assert!(views.panels.caret.is_some());
assert_eq!(editor.cursor().row, cursor.row);
}
}
}
}
#[test]
fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() {
let directory = Directory::new();