vim: Add support for moving to first, middle and last visible lines (H, L, M) (#6919)

This change implements the vim
[motion](https://github.com/vim/vim/blob/master/runtime/doc/motion.txt)
commands to move the cursor to the top, middle and bottom of the visible
view. This feature is requested in
https://github.com/zed-industries/zed/issues/4941.

This change takes inspiration from
[crates/vim/src/normal/scroll.rs](https://github.com/zed-industries/zed/blob/main/crates/vim/src/normal/scroll.rs).

A note on the behavior of these commands: Because
`NeovimBackedTestContext` requires compatibility with nvim, the current
implementation causes slightly non-standard behavior: it causes the
editor to scroll a few lines. The standard behavior causes no scrolling.
It is easy enough to account for the margin by adding
`VERTICAL_SCROLL_MARGIN`. However, doing so will cause test failures due
to the disparity between nvim and zed states. Perhaps
`NeovimBackedTestContext` should have a switch to be more tolerant for
such cases.

Release Notes:

- Added support for moving to top, middle and bottom of the screen in
vim mode (`H`, `M`, and `L`)
([#4941](https://github.com/zed-industries/zed/issues/4941)).
This commit is contained in:
Vishal Bhavsar 2024-01-29 22:58:24 -05:00 committed by GitHub
parent 1ab49fdbe6
commit 31e9526544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 337 additions and 1 deletions

View file

@ -341,6 +341,9 @@
"shift-s": "vim::SubstituteLine",
"> >": "editor::Indent",
"< <": "editor::Outdent",
"shift-h": "vim::WindowTop",
"shift-m": "vim::WindowMiddle",
"shift-l": "vim::WindowBottom",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePrevItem"
}

View file

@ -586,6 +586,8 @@ impl DisplaySnapshot {
text_system,
editor_style,
rem_size,
anchor: _,
visible_rows: _,
}: &TextLayoutDetails,
) -> Arc<LineLayout> {
let mut runs = Vec::new();

View file

@ -3052,6 +3052,8 @@ impl Editor {
text_system: cx.text_system().clone(),
editor_style: self.style.clone().unwrap(),
rem_size: cx.rem_size(),
anchor: self.scroll_manager.anchor().anchor,
visible_rows: self.visible_line_count(),
}
}

View file

@ -8,6 +8,8 @@ use language::Point;
use std::{ops::Range, sync::Arc};
use multi_buffer::Anchor;
/// Defines search strategy for items in `movement` module.
/// `FindRange::SingeLine` only looks for a match on a single line at a time, whereas
/// `FindRange::MultiLine` keeps going until the end of a string.
@ -23,6 +25,8 @@ pub struct TextLayoutDetails {
pub(crate) text_system: Arc<TextSystem>,
pub(crate) editor_style: EditorStyle,
pub(crate) rem_size: Pixels,
pub anchor: Anchor,
pub visible_rows: Option<f32>,
}
/// Returns a column to the left of the current point, wrapping

View file

@ -299,7 +299,7 @@ impl ScrollManager {
}
impl Editor {
pub fn vertical_scroll_margin(&mut self) -> usize {
pub fn vertical_scroll_margin(&self) -> usize {
self.scroll_manager.vertical_scroll_margin as usize
}

View file

@ -41,6 +41,9 @@ pub enum Motion {
StartOfLineDownward,
EndOfLineDownward,
GoToColumn,
WindowTop,
WindowMiddle,
WindowBottom,
}
#[derive(Clone, Deserialize, PartialEq)]
@ -136,6 +139,9 @@ actions!(
StartOfLineDownward,
EndOfLineDownward,
GoToColumn,
WindowTop,
WindowMiddle,
WindowBottom,
]
);
@ -231,6 +237,13 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(|_: &mut Workspace, action: &RepeatFind, cx: _| {
repeat_motion(action.backwards, cx)
});
workspace.register_action(|_: &mut Workspace, &WindowTop, cx: _| motion(Motion::WindowTop, cx));
workspace.register_action(|_: &mut Workspace, &WindowMiddle, cx: _| {
motion(Motion::WindowMiddle, cx)
});
workspace.register_action(|_: &mut Workspace, &WindowBottom, cx: _| {
motion(Motion::WindowBottom, cx)
});
}
pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
@ -295,6 +308,9 @@ impl Motion {
| NextLineStart
| StartOfLineDownward
| StartOfParagraph
| WindowTop
| WindowMiddle
| WindowBottom
| EndOfParagraph => true,
EndOfLine { .. }
| NextWordEnd { .. }
@ -336,6 +352,9 @@ impl Motion {
| PreviousWordStart { .. }
| FirstNonWhitespace { .. }
| FindBackward { .. }
| WindowTop
| WindowMiddle
| WindowBottom
| NextLineStart => false,
}
}
@ -353,6 +372,9 @@ impl Motion {
| NextWordEnd { .. }
| Matching
| FindForward { .. }
| WindowTop
| WindowMiddle
| WindowBottom
| NextLineStart => true,
Left
| Backspace
@ -449,6 +471,9 @@ impl Motion {
StartOfLineDownward => (next_line_start(map, point, times - 1), SelectionGoal::None),
EndOfLineDownward => (next_line_end(map, point, times), SelectionGoal::None),
GoToColumn => (go_to_column(map, point, times), SelectionGoal::None),
WindowTop => window_top(map, point, &text_layout_details),
WindowMiddle => window_middle(map, point, &text_layout_details),
WindowBottom => window_bottom(map, point, &text_layout_details),
};
(new_point != point || infallible).then_some((new_point, goal))
@ -955,6 +980,51 @@ pub(crate) fn next_line_end(
end_of_line(map, false, point)
}
fn window_top(
map: &DisplaySnapshot,
point: DisplayPoint,
text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
let first_visible_line = text_layout_details.anchor.to_display_point(map);
let new_col = point.column().min(map.line_len(first_visible_line.row()));
let new_point = DisplayPoint::new(first_visible_line.row(), new_col);
(map.clip_point(new_point, Bias::Left), SelectionGoal::None)
}
fn window_middle(
map: &DisplaySnapshot,
point: DisplayPoint,
text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
if let Some(visible_rows) = text_layout_details.visible_rows {
let first_visible_line = text_layout_details.anchor.to_display_point(map);
let max_rows = (visible_rows as u32).min(map.max_buffer_row());
let new_row = first_visible_line.row() + (max_rows.div_euclid(2));
let new_col = point.column().min(map.line_len(new_row));
let new_point = DisplayPoint::new(new_row, new_col);
(map.clip_point(new_point, Bias::Left), SelectionGoal::None)
} else {
(point, SelectionGoal::None)
}
}
fn window_bottom(
map: &DisplaySnapshot,
point: DisplayPoint,
text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
if let Some(visible_rows) = text_layout_details.visible_rows {
let first_visible_line = text_layout_details.anchor.to_display_point(map);
let bottom_row = first_visible_line.row() + (visible_rows) as u32;
let bottom_row_capped = bottom_row.min(map.max_buffer_row());
let new_col = point.column().min(map.line_len(bottom_row_capped));
let new_point = DisplayPoint::new(bottom_row_capped, new_col);
(map.clip_point(new_point, Bias::Left), SelectionGoal::None)
} else {
(point, SelectionGoal::None)
}
}
#[cfg(test)]
mod test {
@ -1107,4 +1177,218 @@ mod test {
cx.simulate_shared_keystrokes(["enter"]).await;
cx.assert_shared_state("one\n ˇtwo\nthree").await;
}
#[gpui::test]
async fn test_window_top(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {r"abc
def
paragraph
the second
third ˇand
final"};
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes(["shift-h"]).await;
cx.assert_shared_state(indoc! {r"abˇc
def
paragraph
the second
third and
final"})
.await;
// clip point
cx.set_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 ˇ9
"})
.await;
cx.simulate_shared_keystrokes(["shift-h"]).await;
cx.assert_shared_state(indoc! {r"
1 2 ˇ3
4 5 6
7 8 9
"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
4 5 6
ˇ7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-h"]).await;
cx.assert_shared_state(indoc! {r"
ˇ1 2 3
4 5 6
7 8 9
"})
.await;
}
#[gpui::test]
async fn test_window_middle(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {r"abˇc
def
paragraph
the second
third and
final"};
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"abc
def
paˇragraph
the second
third and
final"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 ˇ9
"})
.await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 ˇ6
7 8 9
"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
4 5 6
ˇ7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
ˇ4 5 6
7 8 9
"})
.await;
cx.set_shared_state(indoc! {r"
ˇ1 2 3
4 5 6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
ˇ4 5 6
7 8 9
"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
ˇ4 5 6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
ˇ4 5 6
7 8 9
"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
4 5 ˇ6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-m"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 ˇ6
7 8 9
"})
.await;
}
#[gpui::test]
async fn test_window_bottom(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
let initial_state = indoc! {r"abc
deˇf
paragraph
the second
third and
final"};
cx.set_shared_state(initial_state).await;
cx.simulate_shared_keystrokes(["shift-l"]).await;
cx.assert_shared_state(indoc! {r"abc
def
paragraph
the second
third and
fiˇnal"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
4 5 ˇ6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-l"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 9
ˇ"})
.await;
cx.set_shared_state(indoc! {r"
1 2 3
ˇ4 5 6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-l"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 9
ˇ"})
.await;
cx.set_shared_state(indoc! {r"
1 2 ˇ3
4 5 6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-l"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 9
ˇ"})
.await;
cx.set_shared_state(indoc! {r"
ˇ1 2 3
4 5 6
7 8 9
"})
.await;
cx.simulate_shared_keystrokes(["shift-l"]).await;
cx.assert_shared_state(indoc! {r"
1 2 3
4 5 6
7 8 9
ˇ"})
.await;
}
}

View file

@ -0,0 +1,15 @@
{"Put":{"state":"abc\ndeˇf\nparagraph\nthe second\nthird and\nfinal"}}
{"Key":"shift-l"}
{"Get":{"state":"abc\ndef\nparagraph\nthe second\nthird and\nfiˇnal","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n"}}
{"Key":"shift-l"}
{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
{"Put":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n"}}
{"Key":"shift-l"}
{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
{"Put":{"state":"1 2 ˇ3\n4 5 6\n7 8 9\n"}}
{"Key":"shift-l"}
{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}
{"Put":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n"}}
{"Key":"shift-l"}
{"Get":{"state":"1 2 3\n4 5 6\n7 8 9\nˇ","mode":"Normal"}}

View file

@ -0,0 +1,17 @@
{"Put":{"state":"abˇc\ndef\nparagraph\nthe second\nthird and\nfinal"}}
{"Key":"shift-m"}
{"Get":{"state":"abc\ndef\npaˇragraph\nthe second\nthird and\nfinal","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 6\n7 8 ˇ9\n"}}
{"Key":"shift-m"}
{"Get":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 6\nˇ7 8 9\n"}}
{"Key":"shift-m"}
{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
{"Put":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n"}}
{"Key":"shift-m"}
{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
{"Key":"shift-m"}
{"Get":{"state":"1 2 3\nˇ4 5 6\n7 8 9\n","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n"}}
{"Key":"shift-m"}
{"Get":{"state":"1 2 3\n4 5 ˇ6\n7 8 9\n","mode":"Normal"}}

View file

@ -0,0 +1,9 @@
{"Put":{"state":"abc\ndef\nparagraph\nthe second\nthird ˇand\nfinal"}}
{"Key":"shift-h"}
{"Get":{"state":"abˇc\ndef\nparagraph\nthe second\nthird and\nfinal","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 6\n7 8 ˇ9\n"}}
{"Key":"shift-h"}
{"Get":{"state":"1 2 ˇ3\n4 5 6\n7 8 9\n","mode":"Normal"}}
{"Put":{"state":"1 2 3\n4 5 6\nˇ7 8 9\n"}}
{"Key":"shift-h"}
{"Get":{"state":"ˇ1 2 3\n4 5 6\n7 8 9\n","mode":"Normal"}}