vim: Fix and improve horizontal scrolling (#33590)

This Pull Request introduces various changes to the editor's horizontal
scrolling, mostly focused on vim mode's horizontal scroll motions (`z
l`, `z h`, `z shift-l`, `z shift-h`). In order to make it easier to
review, the logical changes have been split into different sections.

## Cursor Position Update

Changes introduced on https://github.com/zed-industries/zed/pull/32558
added both `z l` and `z h` to vim mode but it only scrolled the editor's
content, without changing the cursor position. This doesn't reflect the
actual behavior of those motions in vim, so these two commits tackled
that, ensuring that the cursor position is updated, only when the cursor
is on the left or right edges of the editor:

-
ea3b866a76
-
805f41a913

## Horizontal Autoscroll Fix

After introducing the cursor position update to both `z l` and `z h` it
was noted that there was a bug with using `z l`, followed by `0` and
then `z l` again, as on the second use `z l` the cursor would not be
updated. This would only happen on the first line in the editor, and it
was concluded that it was because the
`editor:📜:autoscroll::Editor.autoscroll_horizontally` method was
directly updating the scroll manager's anchor offset, instead of using
the `editor:📜:Editor.set_scroll_position_internal` method, like
is being done by the vertical autoscroll
(`editor:📜:autoscroll::Editor.autoscroll_vertically`).

This wouldn't update the scroll manager's anchor, which would still
think it was at `(0, 1)` so the cursor position would not be updated.
The changes in [this
commit](3957f02e18)
updated the horizontal autoscrolling method to also leverage
`set_scroll_position_internal`.

## Visible Column Count & Page Width Scroll Amount

The changes in
d83652c3ae
add a `visible_column_count` field to `editor:📜:ScrollManager`
struct, which allowed the introduction of the `ScrollAmount::PageWidth`
enum.

With these changes, two new actions are introduced,
`vim::normal:📜:HalfPageRight` and
`vim::normal:📜:HalfPageLeft` (in
7f344304d5),
which move the editor half page to the right and half page to the left,
as well as the cursor position, which have also been mapped to `z
shift-l` and `z shift-h`, respectively.

Closes #17219 

Release Notes:

- Improved `z l` and `z h` to actually move the cursor position, similar
to vim's behavior
- Added `z shift-l` and `z shift-h` to scroll half of the page width's
to the right or to the left, respectively

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Dino 2025-07-08 21:48:48 +01:00 committed by GitHub
parent 6b7c30d7ad
commit 139af02737
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 209 additions and 27 deletions

View file

@ -7,6 +7,7 @@ use editor::{
use gpui::{Context, Window, actions};
use language::Bias;
use settings::Settings;
use text::SelectionGoal;
actions!(
vim,
@ -26,7 +27,11 @@ actions!(
/// Scrolls up by one page.
PageUp,
/// Scrolls down by one page.
PageDown
PageDown,
/// Scrolls right by half a page's width.
HalfPageRight,
/// Scrolls left by half a page's width.
HalfPageLeft,
]
);
@ -51,6 +56,16 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, |vim, _: &PageUp, window, cx| {
vim.scroll(false, window, cx, |c| ScrollAmount::Page(-c.unwrap_or(1.)))
});
Vim::action(editor, cx, |vim, _: &HalfPageRight, window, cx| {
vim.scroll(false, window, cx, |c| {
ScrollAmount::PageWidth(c.unwrap_or(0.5))
})
});
Vim::action(editor, cx, |vim, _: &HalfPageLeft, window, cx| {
vim.scroll(false, window, cx, |c| {
ScrollAmount::PageWidth(-c.unwrap_or(0.5))
})
});
Vim::action(editor, cx, |vim, _: &ScrollDown, window, cx| {
vim.scroll(true, window, cx, |c| {
if let Some(c) = c {
@ -123,6 +138,10 @@ fn scroll_editor(
return;
};
let Some(visible_column_count) = editor.visible_column_count() else {
return;
};
let top_anchor = editor.scroll_manager.anchor().anchor;
let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin;
@ -132,8 +151,14 @@ fn scroll_editor(
cx,
|s| {
s.move_with(|map, selection| {
// TODO: Improve the logic and function calls below to be dependent on
// the `amount`. If the amount is vertical, we don't care about
// columns, while if it's horizontal, we don't care about rows,
// so we don't need to calculate both and deal with logic for
// both.
let mut head = selection.head();
let top = top_anchor.to_display_point(map);
let max_point = map.max_point();
let starting_column = head.column();
let vertical_scroll_margin =
@ -163,9 +188,8 @@ fn scroll_editor(
(visible_line_count as u32).saturating_sub(1 + vertical_scroll_margin),
);
// scroll off the end.
let max_row = if top.row().0 + visible_line_count as u32 >= map.max_point().row().0
{
map.max_point().row()
let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
max_point.row()
} else {
DisplayRow(
(top.row().0 + visible_line_count as u32)
@ -185,13 +209,52 @@ fn scroll_editor(
} else {
head.row()
};
let new_head =
map.clip_point(DisplayPoint::new(new_row, starting_column), Bias::Left);
// The minimum column position that the cursor position can be
// at is either the scroll manager's anchor column, which is the
// left-most column in the visible area, or the scroll manager's
// old anchor column, in case the cursor position is being
// preserved. This is necessary for motions like `ctrl-d` in
// case there's not enough content to scroll half page down, in
// which case the scroll manager's anchor column will be the
// maximum column for the current line, so the minimum column
// would end up being the same as the maximum column.
let min_column = match preserve_cursor_position {
true => old_top_anchor.to_display_point(map).column(),
false => top.column(),
};
// As for the maximum column position, that should be either the
// right-most column in the visible area, which we can easily
// calculate by adding the visible column count to the minimum
// column position, or the right-most column in the current
// line, seeing as the cursor might be in a short line, in which
// case we don't want to go past its last column.
let max_row_column = map.line_len(new_row);
let max_column = match min_column + visible_column_count as u32 {
max_column if max_column >= max_row_column => max_row_column,
max_column => max_column,
};
// Ensure that the cursor's column stays within the visible
// area, otherwise clip it at either the left or right edge of
// the visible area.
let new_column = match (min_column, max_column) {
(min_column, _) if starting_column < min_column => min_column,
(_, max_column) if starting_column > max_column => max_column,
_ => starting_column,
};
let new_head = map.clip_point(DisplayPoint::new(new_row, new_column), Bias::Left);
let goal = match amount {
ScrollAmount::Column(_) | ScrollAmount::PageWidth(_) => SelectionGoal::None,
_ => selection.goal,
};
if selection.is_empty() {
selection.collapse_to(new_head, selection.goal)
selection.collapse_to(new_head, goal)
} else {
selection.set_head(new_head, selection.goal)
selection.set_head(new_head, goal)
};
})
},
@ -472,4 +535,30 @@ mod test {
cx.simulate_shared_keystrokes("ctrl-o").await;
cx.shared_state().await.assert_matches();
}
#[gpui::test]
async fn test_horizontal_scroll(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_scroll_height(20).await;
cx.set_shared_wrap(12).await;
cx.set_neovim_option("nowrap").await;
let content = "ˇ01234567890123456789";
cx.set_shared_state(&content).await;
cx.simulate_shared_keystrokes("z shift-l").await;
cx.shared_state().await.assert_eq("012345ˇ67890123456789");
// At this point, `z h` should not move the cursor as it should still be
// visible within the 12 column width.
cx.simulate_shared_keystrokes("z h").await;
cx.shared_state().await.assert_eq("012345ˇ67890123456789");
let content = "ˇ01234567890123456789";
cx.set_shared_state(&content).await;
cx.simulate_shared_keystrokes("z l").await;
cx.shared_state().await.assert_eq("0ˇ1234567890123456789");
}
}

View file

@ -0,0 +1,16 @@
{"SetOption":{"value":"scrolloff=3"}}
{"SetOption":{"value":"lines=22"}}
{"SetOption":{"value":"wrap"}}
{"SetOption":{"value":"columns=12"}}
{"SetOption":{"value":"nowrap"}}
{"Put":{"state":"ˇ01234567890123456789"}}
{"Key":"z"}
{"Key":"shift-l"}
{"Get":{"state":"012345ˇ67890123456789","mode":"Normal"}}
{"Key":"z"}
{"Key":"h"}
{"Get":{"state":"012345ˇ67890123456789","mode":"Normal"}}
{"Put":{"state":"ˇ01234567890123456789"}}
{"Key":"z"}
{"Key":"l"}
{"Get":{"state":"0ˇ1234567890123456789","mode":"Normal"}}