feat: add counted forward and backward character seek motions
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{DisplayRow, Focus, Phase},
|
||||
editor::{Cursor, Editor, Input, Mode, Selection},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-navigation-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let mut editor =
|
||||
Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
|
||||
fn session(&self, text: &str, bindings: &str) -> EditorSession {
|
||||
let source = self.file("buffer.txt", text);
|
||||
let config = self.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n"
|
||||
),
|
||||
);
|
||||
let session =
|
||||
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.0.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Navigation Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Navigation Fixture")
|
||||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn session_keys(session: &mut EditorSession, text: &str) {
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn palette_seek(session: &mut EditorSession, action: &str, target: char) {
|
||||
session.handle(Input::Ctrl('p'));
|
||||
session_keys(session, action);
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor().palette().is_none());
|
||||
session.handle(Input::Char(target));
|
||||
}
|
||||
|
||||
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready(session) {
|
||||
session.poll();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"diff state: {:?}",
|
||||
session.editor().diff()
|
||||
);
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_character_seeks_move_every_native_caret_independently() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "xaxbx tail\nx---x----x tail\nx only\n";
|
||||
let mut editor = fixture.editor(text);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.selection_count(), 3);
|
||||
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 4 },
|
||||
Cursor { row: 1, column: 9 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
keys(&mut editor, "2Fx");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
[
|
||||
Cursor { row: 0, column: 0 },
|
||||
Cursor { row: 1, column: 0 },
|
||||
Cursor { row: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() {
|
||||
for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("a x xx tail\n");
|
||||
editor.handle(Input::Char(mode));
|
||||
keys(&mut editor, "2fx");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
|
||||
assert_eq!(editor.visual_linewise(), mode == 'V');
|
||||
assert_eq!(
|
||||
editor.selections(),
|
||||
[Selection {
|
||||
start: Cursor { row: 0, column: 0 },
|
||||
end: if mode == 'V' {
|
||||
Cursor { row: 1, column: 0 }
|
||||
} else {
|
||||
Cursor { row: 0, column: 5 }
|
||||
},
|
||||
}]
|
||||
);
|
||||
editor.handle(Input::Char('y'));
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
|
||||
let destination = fixture.file("destination.txt", "");
|
||||
editor.execute(&format!("edit {}", destination.display()));
|
||||
editor.handle(Input::Char('P'));
|
||||
assert_eq!(editor.context().text, expected, "{mode}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"a x xx tail\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "a x x tail\n";
|
||||
let mut session = fixture.session(
|
||||
text,
|
||||
r#" config.bind 'leader s', config.core.seek_forward
|
||||
config.bind 'leader r', config.core.seek_backward
|
||||
config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual
|
||||
config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#,
|
||||
);
|
||||
session_keys(&mut session, "2 sx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
session_keys(&mut session, " rx");
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
|
||||
session_keys(&mut session, "v sx");
|
||||
let expanded = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 5 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
session_keys(&mut session, " rx");
|
||||
let contracted = [Selection {
|
||||
start: Cursor { row: 0, column: 2 },
|
||||
end: Cursor { row: 0, column: 3 },
|
||||
}];
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().mode(), Mode::Visual);
|
||||
assert_eq!(session.editor().selections(), expanded);
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().selections(), contracted);
|
||||
|
||||
session.handle(Input::Escape);
|
||||
palette_seek(&mut session, "core.seek_forward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 });
|
||||
palette_seek(&mut session, "core.seek_backward", 'x');
|
||||
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 });
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
assert_eq!(session.editor().context().text, text);
|
||||
assert!(!session.editor().dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session(
|
||||
"a x x tail\nlong x gap x tail\n",
|
||||
" config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual",
|
||||
);
|
||||
session_keys(&mut session, "v2 sxc!");
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
|
||||
session_keys(&mut session, "j0.");
|
||||
assert_eq!(session.editor().context().text, "! tail\n! tail\n");
|
||||
assert_eq!(session.editor().mode(), Mode::Normal);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
"! tail\nlong x gap x tail\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() {
|
||||
let fixture = Fixture::new();
|
||||
fs::create_dir_all(fixture.0.join("repo")).unwrap();
|
||||
fixture.git(&["init", "-q"]);
|
||||
let source = fixture.0.join("repo/context.txt");
|
||||
let mut lines = (1..=40)
|
||||
.map(|row| format!("stable_{row:03} [x] [x]"))
|
||||
.collect::<Vec<_>>();
|
||||
lines[19] = "before x x tail".into();
|
||||
let before_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &before_text).unwrap();
|
||||
fixture.git(&["add", "context.txt"]);
|
||||
fixture.git(&["commit", "-qm", "before"]);
|
||||
let before = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "after x x tail".into();
|
||||
let after_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &after_text).unwrap();
|
||||
fixture.git(&["commit", "-qam", "after"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
lines[19] = "working x x tail".into();
|
||||
let working_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &working_text).unwrap();
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n",
|
||||
);
|
||||
let mut session =
|
||||
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session_keys(&mut session, "iunsaved ");
|
||||
session.handle(Input::Escape);
|
||||
let unsaved_text = session.editor().context().text;
|
||||
let original_cursor = session.editor().cursor();
|
||||
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
for revision in [&before, &after] {
|
||||
session_keys(&mut session, revision);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
});
|
||||
let excerpts = session.editor().diff().unwrap().display_rows.clone();
|
||||
assert!(
|
||||
excerpts
|
||||
.iter()
|
||||
.any(|row| matches!(row, DisplayRow::Fold { .. }))
|
||||
);
|
||||
|
||||
for (focus, text, first_x, second_x) in [
|
||||
(Focus::Before, &before_text, 7, 9),
|
||||
(Focus::After, &after_text, 6, 8),
|
||||
] {
|
||||
for _ in 0..3 {
|
||||
if session.editor().diff().unwrap().focus == focus {
|
||||
break;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
assert_eq!(session.editor().diff().unwrap().focus, focus);
|
||||
let revision = session.editor().text_revision();
|
||||
session_keys(&mut session, "gt02f[");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 15
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "F[f]");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 18,
|
||||
column: 13
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "j02fx");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "Fxvfx");
|
||||
assert_eq!(
|
||||
session.editor().selections(),
|
||||
[Selection {
|
||||
start: Cursor {
|
||||
row: 19,
|
||||
column: first_x
|
||||
},
|
||||
end: Cursor {
|
||||
row: 19,
|
||||
column: second_x + 1
|
||||
},
|
||||
}]
|
||||
);
|
||||
session_keys(&mut session, "yVfx");
|
||||
assert!(session.editor().visual_linewise());
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().context().text, *text);
|
||||
assert_eq!(session.editor().text_revision(), revision);
|
||||
assert!(!session.editor().dirty());
|
||||
assert_eq!(session.editor().diff().unwrap().display_rows, excerpts);
|
||||
}
|
||||
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().context().text, unsaved_text);
|
||||
assert_eq!(session.editor().cursor(), original_cursor);
|
||||
assert!(session.editor().dirty());
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after);
|
||||
}
|
||||
Reference in New Issue
Block a user