feat: scroll viewport independently of the cursor
This commit is contained in:
@@ -633,6 +633,26 @@ core_actions![
|
||||
Action::Up,
|
||||
&[Mode::Normal, Mode::Insert, Mode::Visual],
|
||||
),
|
||||
(
|
||||
"core.scroll_up",
|
||||
Action::ScrollUp,
|
||||
&[Mode::Normal, Mode::Visual]
|
||||
),
|
||||
(
|
||||
"core.scroll_down",
|
||||
Action::ScrollDown,
|
||||
&[Mode::Normal, Mode::Visual]
|
||||
),
|
||||
(
|
||||
"core.scroll_half_up",
|
||||
Action::ScrollHalfUp,
|
||||
&[Mode::Normal, Mode::Visual]
|
||||
),
|
||||
(
|
||||
"core.scroll_half_down",
|
||||
Action::ScrollHalfDown,
|
||||
&[Mode::Normal, Mode::Visual]
|
||||
),
|
||||
(
|
||||
"core.line_start",
|
||||
Action::LineStart,
|
||||
@@ -1322,6 +1342,8 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
for (key, action) in [
|
||||
(Input::Ctrl('k'), CloneAbove),
|
||||
(Input::Ctrl('e'), CloneBelow),
|
||||
(Input::Ctrl('u'), ScrollHalfUp),
|
||||
(Input::Ctrl('d'), ScrollHalfDown),
|
||||
(Input::Escape, Normal),
|
||||
] {
|
||||
bindings
|
||||
@@ -1580,6 +1602,7 @@ struct ViewState {
|
||||
command_line: String,
|
||||
caret_reveal_revision: u64,
|
||||
lsp_center: Cell<Option<(u64, u64, Cursor)>>,
|
||||
viewport_scroll: Cell<(isize, isize)>,
|
||||
insert_start: Option<Snapshot>,
|
||||
pending_edit: Option<EditRecipe>,
|
||||
visual_motions: Option<Vec<Motion>>,
|
||||
@@ -1600,6 +1623,7 @@ impl ViewState {
|
||||
command_line: String::new(),
|
||||
caret_reveal_revision: 0,
|
||||
lsp_center: Cell::new(None),
|
||||
viewport_scroll: Cell::new((0, 0)),
|
||||
insert_start: None,
|
||||
pending_edit: None,
|
||||
visual_motions: None,
|
||||
@@ -5787,8 +5811,14 @@ impl Editor {
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn take_viewport_scroll(&self, height: usize) -> isize {
|
||||
let (rows, halves) = self.active.view.viewport_scroll.take();
|
||||
rows.saturating_add(halves.saturating_mul((height / 2).max(1) as isize))
|
||||
}
|
||||
|
||||
fn reveal_caret(&mut self) {
|
||||
self.active.view.lsp_center.set(None);
|
||||
self.active.view.viewport_scroll.set((0, 0));
|
||||
self.active.view.caret_reveal_revision =
|
||||
self.active.view.caret_reveal_revision.wrapping_add(1);
|
||||
}
|
||||
@@ -7678,6 +7708,7 @@ impl Editor {
|
||||
self.refresh_resolver_status();
|
||||
self.refresh_resolver_comparison();
|
||||
if self.diff_interacting()
|
||||
&& self.active.view.viewport_scroll.get() == (0, 0)
|
||||
&& let Some(diff) = self.interaction_diff_mut()
|
||||
{
|
||||
diff.follow_caret = true;
|
||||
@@ -8246,6 +8277,29 @@ impl Editor {
|
||||
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
|
||||
Action::JumpBack => self.traverse_jumps(false, count),
|
||||
Action::JumpForward => self.traverse_jumps(true, count),
|
||||
Action::ScrollUp
|
||||
| Action::ScrollDown
|
||||
| Action::ScrollHalfUp
|
||||
| Action::ScrollHalfDown => {
|
||||
if self.document_actions_available() {
|
||||
let amount = isize::try_from(count).unwrap_or(isize::MAX);
|
||||
let amount = if matches!(action, Action::ScrollUp | Action::ScrollHalfUp) {
|
||||
-amount
|
||||
} else {
|
||||
amount
|
||||
};
|
||||
let (mut rows, mut halves) = self.active.view.viewport_scroll.get();
|
||||
if matches!(action, Action::ScrollHalfUp | Action::ScrollHalfDown) {
|
||||
halves = halves.saturating_add(amount);
|
||||
} else {
|
||||
rows = rows.saturating_add(amount);
|
||||
}
|
||||
self.active.view.viewport_scroll.set((rows, halves));
|
||||
if let Some(diff) = self.interaction_diff_mut() {
|
||||
diff.follow_caret = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::SeekForward | Action::SeekBackward => {
|
||||
self.seek_pending = Some((action == Action::SeekForward, count));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
Normal/Visual: Ctrl+u/d scroll half a viewport up/down without moving carets; counts supported
|
||||
u undo, U redo, : command, / search, * search word, n/N next/previous match
|
||||
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
||||
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
|
||||
|
||||
+183
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user