use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::{LastFind, MotionKind, Operator};
use crate::app::eval::handle::resolve_motion_pure;
use crate::app::{App, Selection, Toast};
use crate::editor::Cursor;
use crate::mode::Mode;
impl App {
pub(super) fn handle_visual_key(&mut self, key: KeyEvent) -> Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if let Some((forward, till)) = self.visual_find_pending.take() {
let count = self.take_visual_count();
if let KeyCode::Char(ch) = key.code {
self.last_find = Some(LastFind { ch, forward, till });
self.apply_visual_motion(MotionKind::FindChar { ch, forward, till }, count);
}
return Ok(());
}
if std::mem::take(&mut self.visual_g_pending) {
let had_count = self.visual_count.is_some();
let count = self.take_visual_count();
match key.code {
KeyCode::Char('g') => {
if had_count {
self.goto_line_n_pure(count as usize);
} else {
self.editor.move_file_start();
}
}
KeyCode::Char('e') => self.apply_visual_motion(MotionKind::WordEndBack, count),
KeyCode::Char('E') => self.apply_visual_motion(MotionKind::BigWordEndBack, count),
KeyCode::Char('_') => self.apply_visual_motion(MotionKind::LineLastNonBlank, count),
KeyCode::Char('s') => {
self.apply_visual_motion(MotionKind::LineFirstNonBlank, count)
}
KeyCode::Char('l') => self.apply_visual_motion(MotionKind::LineEnd, count),
KeyCode::Char('c') => self.apply_visual_motion(MotionKind::ViewportMiddle, count),
KeyCode::Char('b') => self.apply_visual_motion(MotionKind::ViewportBottom, count),
KeyCode::Char('n') => self.run_search_select(self.search.last_forward),
KeyCode::Char('N') => self.run_search_select(!self.search.last_forward),
_ => {}
}
return Ok(());
}
if let KeyCode::Char(c @ '0'..='9') = key.code
&& !ctrl
&& !(c == '0' && self.visual_count.is_none())
{
let digit = c as u32 - '0' as u32;
let next = self
.visual_count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(digit);
self.visual_count = Some(next);
return Ok(());
}
match key.code {
KeyCode::Char('g') => {
self.visual_g_pending = true;
return Ok(());
}
KeyCode::Char('f') if !ctrl => {
self.visual_find_pending = Some((true, false));
return Ok(());
}
KeyCode::Char('F') => {
self.visual_find_pending = Some((false, false));
return Ok(());
}
KeyCode::Char('t') => {
self.visual_find_pending = Some((true, true));
return Ok(());
}
KeyCode::Char('T') => {
self.visual_find_pending = Some((false, true));
return Ok(());
}
_ => {}
}
if let Some(motion) = visual_motion_for(key) {
let count = self.take_visual_count();
self.apply_visual_motion(motion, count);
return Ok(());
}
let had_count = self.visual_count.is_some();
let count = self.take_visual_count();
match key.code {
KeyCode::Esc => self.enter_mode(Mode::Normal),
KeyCode::Char('h') | KeyCode::Left => {
for _ in 0..count {
self.editor.move_left();
}
}
KeyCode::Char('l') | KeyCode::Right => {
for _ in 0..count {
ed_op_ref!(self, move_right(false));
}
}
KeyCode::Char('j') | KeyCode::Down => {
for _ in 0..count {
ed_op_ref!(self, move_down());
}
}
KeyCode::Char('k') | KeyCode::Up => {
for _ in 0..count {
ed_op_ref!(self, move_up());
}
}
KeyCode::Char('0') | KeyCode::Home => self.editor.move_line_start(),
KeyCode::Char('G') => {
if had_count {
self.goto_line_n_pure(count as usize);
} else {
ed_op_ref!(self, move_file_end());
}
}
KeyCode::Char(';') => self.repeat_visual_find(false, count),
KeyCode::Char(',') => self.repeat_visual_find(true, count),
KeyCode::Char('n') => {
for _ in 0..count {
self.run_jump_search(self.search.last_forward);
}
}
KeyCode::Char('N') => {
for _ in 0..count {
self.run_jump_search(!self.search.last_forward);
}
}
KeyCode::Char('*') => self.search_word_under_cursor(true),
KeyCode::Char('#') => self.search_word_under_cursor(false),
KeyCode::Char('o') => self.swap_visual_endpoints(),
KeyCode::Char('v') if !ctrl => self.toggle_visual(Mode::Visual),
KeyCode::Char('v') if ctrl => self.toggle_visual(Mode::VisualBlock),
KeyCode::Char('V') => self.toggle_visual(Mode::VisualLine),
KeyCode::Char('y') => {
self.apply_visual_op(Operator::Yank);
self.enter_mode(Mode::Normal);
}
KeyCode::Char('d') | KeyCode::Char('x') => {
ed_op!(self, snapshot());
self.apply_visual_op(Operator::Delete);
self.enter_mode(Mode::Normal);
}
KeyCode::Char('c') | KeyCode::Char('s') => {
ed_op!(self, snapshot());
self.apply_visual_op(Operator::Change);
}
KeyCode::Char('I') => self.visual_insert_at_start(),
KeyCode::Char('A') => self.visual_append_at_end(),
KeyCode::Char('S') | KeyCode::Char('R') => self.visual_change_lines(),
KeyCode::Char('C') => self.visual_change_to_eol(),
KeyCode::Char('D') => self.visual_delete_to_eol(),
KeyCode::Char('X') => self.visual_delete_lines(),
KeyCode::Char('Y') => self.visual_yank_lines(),
KeyCode::Char('u') => self.transform_case_selection(crate::editor::to_lower_keep_width),
KeyCode::Char('U') => self.transform_case_selection(crate::editor::to_upper_keep_width),
KeyCode::Char('~') => self.toggle_case_selection(),
KeyCode::Char('J') => self.join_selection_lines(),
KeyCode::Char('>') => self.indent_selection(true),
KeyCode::Char('<') => self.indent_selection(false),
KeyCode::Char(':') => self.open_command_from_visual(),
_ => {}
}
Ok(())
}
fn apply_visual_motion(&mut self, motion: MotionKind, count: u32) {
let target = self
.active_doc()
.motion_target(self.editor.cursor, motion, count.max(1));
self.editor.cursor = target;
}
fn take_visual_count(&mut self) -> u32 {
self.visual_count.take().unwrap_or(1).max(1)
}
fn repeat_visual_find(&mut self, reverse: bool, count: u32) {
let (resolved, _) = resolve_motion_pure(MotionKind::RepeatFind { reverse }, self.last_find);
match resolved {
Some(motion) => self.apply_visual_motion(motion, count),
None => self.push_toast(Toast::error("no previous find")),
}
}
fn swap_visual_endpoints(&mut self) {
if let Some(anchor) = self.visual_anchor {
let cur = self.editor.cursor;
self.editor.cursor = anchor;
self.visual_anchor = Some(cur);
}
}
fn toggle_case_selection(&mut self) {
self.transform_case_selection(crate::editor::flip_case_char_keep_width);
}
fn transform_case_selection(&mut self, f: fn(char) -> char) {
let Some(sel) = self.selection() else { return };
ed_op!(self, snapshot());
let doc = self.active_doc_mut();
match sel {
Selection::Char { from, to } => {
let end = doc.advance_one(to);
doc.transform_case_range(from, end, f);
}
Selection::Line { from_row, to_row } => {
doc.transform_case_lines(from_row, to_row, f);
}
Selection::Block { r0, c0, r1, c1 } => {
doc.transform_case_block(r0, c0, r1, c1, f);
}
}
self.enter_mode(Mode::Normal);
}
fn join_selection_lines(&mut self) {
let Some(sel) = self.selection() else { return };
let (from_row, to_row) = match sel {
Selection::Char { from, to } => (from.row, to.row),
Selection::Line { from_row, to_row } => (from_row, to_row),
Selection::Block { r0, r1, .. } => (r0, r1),
};
if from_row == to_row {
return;
}
ed_op!(self, snapshot());
self.editor.cursor.row = from_row;
self.editor.cursor.col = 0;
let doc_ref = self.editor.doc.clone();
let doc = self
.documents
.get_mut(&doc_ref)
.expect("active doc present");
for _ in 0..(to_row - from_row) {
self.editor.join_next_line(doc);
}
self.enter_mode(Mode::Normal);
}
fn indent_selection(&mut self, indent: bool) {
let Some(sel) = self.selection() else { return };
let (from_row, to_row) = match sel {
Selection::Char { from, to } => (from.row, to.row),
Selection::Line { from_row, to_row } => (from_row, to_row),
Selection::Block { r0, r1, .. } => (r0, r1),
};
ed_op!(self, snapshot());
let indent_settings = self.indent_settings();
let doc_ref = self.editor.doc.clone();
let doc = self
.documents
.get_mut(&doc_ref)
.expect("active doc present");
for r in from_row..=to_row {
if indent {
self.editor.indent_line(doc, r, indent_settings);
} else {
self.editor.dedent_line(doc, r, indent_settings);
}
}
self.editor.cursor.row = from_row;
let col = {
let line = self.editor.current_line(doc);
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0)
};
self.editor.cursor.col = col;
self.enter_mode(Mode::Normal);
}
fn visual_insert_at_start(&mut self) {
let Some(sel) = self.selection() else { return };
ed_op!(self, snapshot());
self.editor.extra_cursors.clear();
match sel {
Selection::Char { from, .. } => {
self.editor.cursor = from;
}
Selection::Line { from_row, to_row } => {
self.editor.cursor = Cursor {
row: from_row,
col: first_non_blank(&self.active_doc().lines[from_row]),
};
for r in (from_row + 1)..=to_row {
self.editor.extra_cursors.push(Cursor {
row: r,
col: first_non_blank(&self.active_doc().lines[r]),
});
}
}
Selection::Block { r0, c0, r1, .. } => {
self.editor.cursor = Cursor { row: r0, col: c0 };
for r in (r0 + 1)..=r1 {
let len = self.active_doc().lines[r].chars().count();
self.editor.extra_cursors.push(Cursor {
row: r,
col: c0.min(len),
});
}
}
}
self.enter_mode(Mode::Insert);
}
fn visual_append_at_end(&mut self) {
let Some(sel) = self.selection() else { return };
ed_op!(self, snapshot());
self.editor.extra_cursors.clear();
match sel {
Selection::Char { to, .. } => {
let len = self.active_doc().lines[to.row].chars().count();
self.editor.cursor = Cursor {
row: to.row,
col: (to.col + 1).min(len),
};
}
Selection::Line { from_row, to_row } => {
self.editor.cursor = Cursor {
row: from_row,
col: self.active_doc().lines[from_row].chars().count(),
};
for r in (from_row + 1)..=to_row {
self.editor.extra_cursors.push(Cursor {
row: r,
col: self.active_doc().lines[r].chars().count(),
});
}
}
Selection::Block { r0, r1, c1, .. } => {
let primary_len = self.active_doc().lines[r0].chars().count();
self.editor.cursor = Cursor {
row: r0,
col: (c1 + 1).min(primary_len),
};
for r in (r0 + 1)..=r1 {
let len = self.active_doc().lines[r].chars().count();
self.editor.extra_cursors.push(Cursor {
row: r,
col: (c1 + 1).min(len),
});
}
}
}
self.enter_mode(Mode::Insert);
}
fn visual_change_lines(&mut self) {
let Some((from_row, to_row)) = self.selection_row_span() else {
return;
};
ed_op!(self, snapshot());
self.editor.extra_cursors.clear();
ed_op!(self, delete_lines(from_row, to_row));
self.editor.cursor.col = 0;
self.enter_mode(Mode::Insert);
}
fn visual_change_to_eol(&mut self) {
let Some(sel) = self.selection() else { return };
match sel {
Selection::Block { r0, c0, r1, .. } => {
ed_op!(self, snapshot());
self.editor.extra_cursors.clear();
truncate_block_to_eol(self, r0, c0, r1);
self.editor.cursor = Cursor { row: r0, col: c0 };
for r in (r0 + 1)..=r1 {
let len = self.active_doc().lines[r].chars().count();
self.editor.extra_cursors.push(Cursor {
row: r,
col: c0.min(len),
});
}
self.enter_mode(Mode::Insert);
}
_ => self.visual_change_lines(),
}
}
fn visual_delete_to_eol(&mut self) {
let Some(sel) = self.selection() else { return };
match sel {
Selection::Block { r0, c0, r1, .. } => {
ed_op!(self, snapshot());
truncate_block_to_eol(self, r0, c0, r1);
self.editor.cursor = Cursor { row: r0, col: c0 };
ed_op_ref!(self, clamp_col(false));
self.enter_mode(Mode::Normal);
}
_ => self.visual_delete_lines(),
}
}
fn visual_delete_lines(&mut self) {
let Some((from_row, to_row)) = self.selection_row_span() else {
return;
};
ed_op!(self, snapshot());
ed_op!(self, delete_lines(from_row, to_row));
self.enter_mode(Mode::Normal);
}
fn visual_yank_lines(&mut self) {
let Some((from_row, to_row)) = self.selection_row_span() else {
return;
};
self.active_doc_mut().yank_lines(from_row, to_row);
self.sync_yank_to_clipboard();
self.push_toast(Toast::info("yanked"));
self.editor.cursor.row = from_row;
self.editor.cursor.col = 0;
self.enter_mode(Mode::Normal);
}
fn selection_row_span(&self) -> Option<(usize, usize)> {
Some(match self.selection()? {
Selection::Char { from, to } => (from.row, to.row),
Selection::Line { from_row, to_row } => (from_row, to_row),
Selection::Block { r0, r1, .. } => (r0, r1),
})
}
fn toggle_visual(&mut self, target: Mode) {
if self.editor.mode == target {
self.enter_mode(Mode::Normal);
} else {
self.editor.mode = target;
}
}
fn apply_visual_op(&mut self, op: Operator) {
let Some(sel) = self.selection() else { return };
match sel {
Selection::Char { from, to } => {
let end = self.active_doc().advance_one(to);
match op {
Operator::Yank => {
self.active_doc_mut().yank_range(from, end);
self.sync_yank_to_clipboard();
self.push_toast(Toast::info("yanked"));
self.editor.cursor = from;
}
Operator::Delete => ed_op!(self, delete_range(from, end)),
Operator::Change => {
ed_op!(self, delete_range(from, end));
self.enter_mode(Mode::Insert);
}
Operator::Indent | Operator::Dedent => {
unreachable!("indent/dedent dispatched via indent_selection")
}
Operator::Comment => self.apply_visual_comment(from.row, end.row),
Operator::BlockComment => {
self.apply_visual_block_comment(from, end, from.row, end.row)
}
}
}
Selection::Line { from_row, to_row } => match op {
Operator::Yank => {
self.active_doc_mut().yank_lines(from_row, to_row);
self.sync_yank_to_clipboard();
self.push_toast(Toast::info("yanked"));
self.editor.cursor.row = from_row;
self.editor.cursor.col = 0;
}
Operator::Delete => ed_op!(self, delete_lines(from_row, to_row)),
Operator::Change => {
ed_op!(self, delete_lines(from_row, to_row));
self.enter_mode(Mode::Insert);
}
Operator::Indent | Operator::Dedent => {
unreachable!("indent/dedent dispatched via indent_selection")
}
Operator::Comment => self.apply_visual_comment(from_row, to_row),
Operator::BlockComment => {
let (lo_row, hi_row) = (from_row.min(to_row), from_row.max(to_row));
let hi_col = self.active_doc().lines[hi_row].chars().count();
self.apply_visual_block_comment(
Cursor {
row: lo_row,
col: 0,
},
Cursor {
row: hi_row,
col: hi_col,
},
lo_row,
hi_row,
);
}
},
Selection::Block { r0, c0, r1, c1 } => match op {
Operator::Yank => {
self.active_doc_mut().yank_block(r0, c0, r1, c1);
self.sync_yank_to_clipboard();
self.push_toast(Toast::info("yanked"));
self.editor.cursor = Cursor { row: r0, col: c0 };
}
Operator::Delete => ed_op!(self, delete_block(r0, c0, r1, c1)),
Operator::Change => {
self.editor.extra_cursors.clear();
ed_op!(self, delete_block(r0, c0, r1, c1));
self.editor.cursor = Cursor { row: r0, col: c0 };
for r in (r0 + 1)..=r1 {
let len = self.active_doc().lines[r].chars().count();
self.editor.extra_cursors.push(Cursor {
row: r,
col: c0.min(len),
});
}
self.enter_mode(Mode::Insert);
}
Operator::Indent | Operator::Dedent => {
unreachable!("indent/dedent dispatched via indent_selection")
}
Operator::Comment | Operator::BlockComment => self.apply_visual_comment(r0, r1),
},
}
}
fn apply_visual_comment(&mut self, row_a: usize, row_b: usize) {
let (lo, hi) = (row_a.min(row_b), row_a.max(row_b));
let rows: Vec<usize> = (lo..=hi).collect();
if !self.apply_line_comment(&rows) {
self.push_toast(Toast::error("no comment token for this buffer"));
}
}
fn apply_visual_block_comment(&mut self, from: Cursor, to: Cursor, row_a: usize, row_b: usize) {
let (lo, hi) = (row_a.min(row_b), row_a.max(row_b));
let rows: Vec<usize> = (lo..=hi).collect();
if !self.apply_block_comment(&rows, Some((from, to))) {
self.push_toast(Toast::error(
"no block- or line-comment tokens for this buffer",
));
}
}
}
fn first_non_blank(line: &str) -> usize {
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0)
}
fn truncate_block_to_eol(app: &mut App, r0: usize, c0: usize, r1: usize) {
let r1 = r1.min(app.active_doc().lines.len().saturating_sub(1));
let max_len = (r0..=r1)
.map(|r| app.active_doc().lines[r].chars().count())
.max()
.unwrap_or(0);
if max_len <= c0 {
return;
}
ed_op!(app, delete_block(r0, c0, r1, max_len.saturating_sub(1)));
}
fn visual_motion_for(key: KeyEvent) -> Option<MotionKind> {
use MotionKind as M;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl {
return match key.code {
KeyCode::Char('d') => Some(M::HalfPageDown),
KeyCode::Char('u') => Some(M::HalfPageUp),
KeyCode::Char('f') => Some(M::PageDown),
KeyCode::Char('b') => Some(M::PageUp),
_ => None,
};
}
Some(match key.code {
KeyCode::Char('w') => M::WordForward,
KeyCode::Char('b') => M::WordBack,
KeyCode::Char('e') => M::WordEnd,
KeyCode::Char('W') => M::BigWordForward,
KeyCode::Char('B') => M::BigWordBack,
KeyCode::Char('E') => M::BigWordEnd,
KeyCode::Char('^') => M::LineFirstNonBlank,
KeyCode::Char('$') | KeyCode::End => M::LineEnd,
KeyCode::Char('%') => M::BracketMatch,
KeyCode::Char('H') => M::ViewportTop,
KeyCode::Char('M') => M::ViewportMiddle,
KeyCode::Char('L') => M::ViewportBottom,
KeyCode::Char('{') => M::ParagraphBack,
KeyCode::Char('}') => M::ParagraphForward,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::editor::Cursor;
use crate::mode::Mode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn app_with_lines(src: &[&str]) -> App {
let config = crate::config::Config::load(None).expect("default config loads");
let loader =
crate::syntax::Loader::new(std::path::PathBuf::new(), std::path::PathBuf::new());
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new(config, loader, tx, cwd);
app.active_doc_mut().lines = src.iter().map(|s| s.to_string()).collect();
app
}
fn enter_visual_at(app: &mut App, row: usize, col: usize) {
app.editor.cursor = Cursor { row, col };
app.enter_mode(Mode::Visual);
}
fn press(app: &mut App, ch: char) {
app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
.expect("key handled");
}
fn type_keys(app: &mut App, keys: &str) {
for ch in keys.chars() {
press(app, ch);
}
}
#[test]
fn count_repeats_vertical_motion() {
let mut app = app_with_lines(&["a", "b", "c", "d", "e"]);
enter_visual_at(&mut app, 0, 0);
type_keys(&mut app, "3j");
assert_eq!(app.editor.cursor.row, 3);
assert_eq!(app.visual_anchor, Some(Cursor { row: 0, col: 0 }));
}
#[test]
fn count_with_g_prefix_jumps_to_line() {
let mut app = app_with_lines(&["1", "2", "3", "4", "5", "6", "7"]);
enter_visual_at(&mut app, 6, 0);
type_keys(&mut app, "3gg");
assert_eq!(app.editor.cursor.row, 2);
enter_visual_at(&mut app, 6, 0);
type_keys(&mut app, "1gg");
assert_eq!(app.editor.cursor.row, 0);
}
#[test]
fn capital_g_with_count_jumps_else_file_end() {
let mut app = app_with_lines(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]);
enter_visual_at(&mut app, 0, 0);
type_keys(&mut app, "10G");
assert_eq!(app.editor.cursor.row, 9);
press(&mut app, 'G');
assert_eq!(app.editor.cursor.row, 10);
type_keys(&mut app, "1G");
assert_eq!(app.editor.cursor.row, 0);
}
#[test]
fn find_char_and_repeat_with_count() {
let mut app = app_with_lines(&["a.b.c.d"]);
enter_visual_at(&mut app, 0, 0);
press(&mut app, 'f');
press(&mut app, '.');
assert_eq!(app.editor.cursor.col, 1);
press(&mut app, ';');
assert_eq!(app.editor.cursor.col, 3);
press(&mut app, ',');
assert_eq!(app.editor.cursor.col, 1);
}
#[test]
fn count_applies_to_find_char() {
let mut app = app_with_lines(&["a.b.c.d"]);
enter_visual_at(&mut app, 0, 0);
type_keys(&mut app, "3f.");
assert_eq!(app.editor.cursor.col, 5);
}
#[test]
fn bare_zero_is_line_start_not_a_count() {
let mut app = app_with_lines(&["hello world"]);
enter_visual_at(&mut app, 0, 6);
press(&mut app, '0');
assert_eq!(app.editor.cursor.col, 0);
}
#[test]
fn n_and_capital_n_walk_search_matches() {
let mut app = app_with_lines(&["foo", "bar", "foo", "baz", "foo"]);
app.search.set("foo".into(), true);
enter_visual_at(&mut app, 0, 0);
press(&mut app, 'n');
assert_eq!(app.editor.cursor.row, 2);
press(&mut app, 'n');
assert_eq!(app.editor.cursor.row, 4);
press(&mut app, 'N');
assert_eq!(app.editor.cursor.row, 2);
assert_eq!(app.visual_anchor, Some(Cursor { row: 0, col: 0 }));
}
}