feat: scroll viewport independently of the cursor
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
- each side uses its own syntax tree; scope changes within a hunk get separate labels
|
||||
- [x] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed.
|
||||
- [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
|
||||
- [ ] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`).
|
||||
- [x] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`).
|
||||
- [ ] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
|
||||
@@ -208,6 +208,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
||||
|
||||
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
||||
|
||||
Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_half_up` / `config.core.scroll_half_down` without moving carets or selection endpoints. Counts multiply the half-viewport distance. Bind `config.core.scroll_up` / `config.core.scroll_down` for one screen row per count, for example `config.bind "ctrl+e", config.core.scroll_down` to replace clone-below in Normal. Pass `mode: config.modes.visual` to rebind Visual too. These actions work in ordinary and diff text panes, use wrapped screen rows, and have no Ex commands. Cursor navigation or editing resumes caret following.
|
||||
|
||||
## Action reference
|
||||
|
||||
| Available modes | Methods on `config.core` |
|
||||
@@ -238,6 +240,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Visual | `document_start`, `document_end`, `go_to_line` |
|
||||
| Normal, Visual | `seek_forward`, `seek_backward` |
|
||||
| Normal, Visual | `scroll_up`, `scroll_down`, `scroll_half_up`, `scroll_half_down` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
| Normal, Insert, Visual, Selection, Tree | `down`, `up` |
|
||||
|
||||
@@ -31,6 +31,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
|
||||
| Normal, Visual | `<line>g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections |
|
||||
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal, Visual | Ctrl+u/Ctrl+d scroll half a viewport up/down without moving carets or selections; counts multiply the distance |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
|
||||
@@ -64,6 +65,8 @@ On macOS, these modified Backspace defaults also edit prompt and filter queries,
|
||||
|
||||
The terminal may deliver a native modifier or a conventional control sequence: Option may use legacy ESC+DEL, and Kitty CSI-u represents Command as **Super**, not Meta. Ghostty defaults to encoding Command+Backspace as Ctrl+u (`\x15`), which invokes the same backward line deletion without a Ghostty configuration override. Ctrl+u remains a separate, configurable binding; vamp cannot distinguish it from Command+Backspace when both send the same bytes. Vamp does not negotiate an enhanced keyboard protocol or support generic Command-modified character keys.
|
||||
|
||||
Viewport scrolling uses screen rows, including wrapped continuations and diff display rows. It affects the focused text pane (both sides share the diff's vertical offset), and the caret may leave the screen. The next cursor motion or edit resumes caret following. Ctrl+e retains its clone-below binding; single-row scrolling is available through `config.core.scroll_up` / `config.core.scroll_down`. Insert, Command, Search, and picker Ctrl+u behavior is unchanged.
|
||||
|
||||
## Manual LSP completion
|
||||
|
||||
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
|
||||
|
||||
@@ -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