use crate::VimMode;
use crate::input::{Input, Key};
use crate::editor::Editor;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Normal,
Insert,
Visual,
VisualLine,
VisualBlock,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
enum Pending {
#[default]
None,
Op { op: Operator, count1: usize },
OpTextObj {
op: Operator,
count1: usize,
inner: bool,
},
OpG { op: Operator, count1: usize },
G,
Find { forward: bool, till: bool },
OpFind {
op: Operator,
count1: usize,
forward: bool,
till: bool,
},
Replace,
VisualTextObj { inner: bool },
Z,
SetMark,
GotoMarkLine,
GotoMarkChar,
SelectRegister,
RecordMacroTarget,
PlayMacroTarget { count: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operator {
Delete,
Change,
Yank,
Uppercase,
Lowercase,
ToggleCase,
Indent,
Outdent,
Fold,
Reflow,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Motion {
Left,
Right,
Up,
Down,
WordFwd,
BigWordFwd,
WordBack,
BigWordBack,
WordEnd,
BigWordEnd,
WordEndBack,
BigWordEndBack,
LineStart,
FirstNonBlank,
LineEnd,
FileTop,
FileBottom,
Find {
ch: char,
forward: bool,
till: bool,
},
FindRepeat {
reverse: bool,
},
MatchBracket,
WordAtCursor {
forward: bool,
whole_word: bool,
},
SearchNext {
reverse: bool,
},
ViewportTop,
ViewportMiddle,
ViewportBottom,
LastNonBlank,
LineMiddle,
ParagraphPrev,
ParagraphNext,
SentencePrev,
SentenceNext,
ScreenDown,
ScreenUp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextObject {
Word {
big: bool,
},
Quote(char),
Bracket(char),
Paragraph,
XmlTag,
Sentence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionKind {
Exclusive,
Inclusive,
Linewise,
}
#[derive(Debug, Clone)]
enum LastChange {
OpMotion {
op: Operator,
motion: Motion,
count: usize,
inserted: Option<String>,
},
OpTextObj {
op: Operator,
obj: TextObject,
inner: bool,
inserted: Option<String>,
},
LineOp {
op: Operator,
count: usize,
inserted: Option<String>,
},
CharDel { forward: bool, count: usize },
ReplaceChar { ch: char, count: usize },
ToggleCase { count: usize },
JoinLine { count: usize },
Paste { before: bool, count: usize },
DeleteToEol { inserted: Option<String> },
OpenLine { above: bool, inserted: String },
InsertAt {
entry: InsertEntry,
inserted: String,
count: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InsertEntry {
I,
A,
ShiftI,
ShiftA,
}
#[derive(Default)]
pub struct VimState {
mode: Mode,
pending: Pending,
count: usize,
last_find: Option<(char, bool, bool)>,
last_change: Option<LastChange>,
insert_session: Option<InsertSession>,
pub(super) visual_anchor: (usize, usize),
pub(super) visual_line_anchor: usize,
pub(super) block_anchor: (usize, usize),
pub(super) block_vcol: usize,
pub(super) sticky_col: Option<usize>,
pub(super) yank_linewise: bool,
pub(super) pending_register: Option<char>,
pub(super) recording_macro: Option<char>,
pub(super) recording_keys: Vec<crate::input::Input>,
pub(super) replaying_macro: bool,
pub(super) last_macro: Option<char>,
#[doc(hidden)]
pub last_edit_pos: Option<(usize, usize)>,
pub(super) change_list: Vec<(usize, usize)>,
pub(super) change_list_cursor: Option<usize>,
pub(super) last_visual: Option<LastVisual>,
pub(super) viewport_pinned: bool,
replaying: bool,
one_shot_normal: bool,
pub(super) search_prompt: Option<SearchPrompt>,
pub(super) last_search: Option<String>,
pub(super) last_search_forward: bool,
#[doc(hidden)]
pub jump_back: Vec<(usize, usize)>,
pub(super) jump_fwd: Vec<(usize, usize)>,
#[doc(hidden)]
pub marks: std::collections::HashMap<char, (usize, usize)>,
pub(super) insert_pending_register: bool,
pub(super) search_history: Vec<String>,
pub(super) search_history_cursor: Option<usize>,
}
const SEARCH_HISTORY_MAX: usize = 100;
pub(crate) const CHANGE_LIST_MAX: usize = 100;
#[derive(Debug, Clone)]
pub struct SearchPrompt {
pub text: String,
pub cursor: usize,
pub forward: bool,
}
#[derive(Debug, Clone)]
struct InsertSession {
count: usize,
row_min: usize,
row_max: usize,
before_lines: Vec<String>,
reason: InsertReason,
}
#[derive(Debug, Clone)]
enum InsertReason {
Enter(InsertEntry),
Open { above: bool },
AfterChange,
DeleteToEol,
ReplayOnly,
BlockEdge { top: usize, bot: usize, col: usize },
Replace,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct LastVisual {
pub mode: Mode,
pub anchor: (usize, usize),
pub cursor: (usize, usize),
pub block_vcol: usize,
}
impl VimState {
pub fn public_mode(&self) -> VimMode {
match self.mode {
Mode::Normal => VimMode::Normal,
Mode::Insert => VimMode::Insert,
Mode::Visual => VimMode::Visual,
Mode::VisualLine => VimMode::VisualLine,
Mode::VisualBlock => VimMode::VisualBlock,
}
}
pub fn force_normal(&mut self) {
self.mode = Mode::Normal;
self.pending = Pending::None;
self.count = 0;
self.insert_session = None;
}
pub fn is_visual(&self) -> bool {
matches!(
self.mode,
Mode::Visual | Mode::VisualLine | Mode::VisualBlock
)
}
pub fn is_visual_char(&self) -> bool {
self.mode == Mode::Visual
}
pub fn enter_visual(&mut self, anchor: (usize, usize)) {
self.visual_anchor = anchor;
self.mode = Mode::Visual;
}
}
fn enter_search(ed: &mut Editor<'_>, forward: bool) {
ed.vim.search_prompt = Some(SearchPrompt {
text: String::new(),
cursor: 0,
forward,
});
ed.vim.search_history_cursor = None;
ed.buffer_mut().set_search_pattern(None);
}
fn push_search_pattern(ed: &mut Editor<'_>, pattern: &str) {
let compiled = if pattern.is_empty() {
None
} else {
let effective: std::borrow::Cow<'_, str> = if ed.settings().ignore_case {
std::borrow::Cow::Owned(format!("(?i){pattern}"))
} else {
std::borrow::Cow::Borrowed(pattern)
};
regex::Regex::new(&effective).ok()
};
ed.buffer_mut().set_search_pattern(compiled);
}
fn step_search_prompt(ed: &mut Editor<'_>, input: Input) -> bool {
let history_dir = match (input.key, input.ctrl) {
(Key::Char('p'), true) | (Key::Up, _) => Some(-1),
(Key::Char('n'), true) | (Key::Down, _) => Some(1),
_ => None,
};
if let Some(dir) = history_dir {
walk_search_history(ed, dir);
return true;
}
match input.key {
Key::Esc => {
let text = ed
.vim
.search_prompt
.take()
.map(|p| p.text)
.unwrap_or_default();
if !text.is_empty() {
ed.vim.last_search = Some(text);
}
ed.vim.search_history_cursor = None;
}
Key::Enter => {
let prompt = ed.vim.search_prompt.take();
if let Some(p) = prompt {
let pattern = if p.text.is_empty() {
ed.vim.last_search.clone()
} else {
Some(p.text.clone())
};
if let Some(pattern) = pattern {
push_search_pattern(ed, &pattern);
let pre = ed.cursor();
if p.forward {
ed.buffer_mut().search_forward(true);
} else {
ed.buffer_mut().search_backward(true);
}
ed.push_buffer_cursor_to_textarea();
if ed.cursor() != pre {
push_jump(ed, pre);
}
record_search_history(ed, &pattern);
ed.vim.last_search = Some(pattern);
ed.vim.last_search_forward = p.forward;
}
}
ed.vim.search_history_cursor = None;
}
Key::Backspace => {
ed.vim.search_history_cursor = None;
let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
if p.text.pop().is_some() {
p.cursor = p.text.chars().count();
Some(p.text.clone())
} else {
None
}
});
if let Some(text) = new_text {
push_search_pattern(ed, &text);
}
}
Key::Char(c) => {
ed.vim.search_history_cursor = None;
let new_text = ed.vim.search_prompt.as_mut().map(|p| {
p.text.push(c);
p.cursor = p.text.chars().count();
p.text.clone()
});
if let Some(text) = new_text {
push_search_pattern(ed, &text);
}
}
_ => {}
}
true
}
fn walk_change_list(ed: &mut Editor<'_>, dir: isize, count: usize) {
if ed.vim.change_list.is_empty() {
return;
}
let len = ed.vim.change_list.len();
let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
(None, -1) => len as isize - 1,
(None, 1) => return, (Some(i), -1) => i as isize - 1,
(Some(i), 1) => i as isize + 1,
_ => return,
};
for _ in 1..count {
let next = idx + dir;
if next < 0 || next >= len as isize {
break;
}
idx = next;
}
if idx < 0 || idx >= len as isize {
return;
}
let idx = idx as usize;
ed.vim.change_list_cursor = Some(idx);
let (row, col) = ed.vim.change_list[idx];
ed.jump_cursor(row, col);
}
fn record_search_history(ed: &mut Editor<'_>, pattern: &str) {
if pattern.is_empty() {
return;
}
if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
return;
}
ed.vim.search_history.push(pattern.to_string());
let len = ed.vim.search_history.len();
if len > SEARCH_HISTORY_MAX {
ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
}
}
fn walk_search_history(ed: &mut Editor<'_>, dir: isize) {
if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
return;
}
let len = ed.vim.search_history.len();
let next_idx = match (ed.vim.search_history_cursor, dir) {
(None, -1) => Some(len - 1),
(None, 1) => return, (Some(i), -1) => i.checked_sub(1),
(Some(i), 1) if i + 1 < len => Some(i + 1),
_ => None,
};
let Some(idx) = next_idx else {
return;
};
ed.vim.search_history_cursor = Some(idx);
let text = ed.vim.search_history[idx].clone();
if let Some(prompt) = ed.vim.search_prompt.as_mut() {
prompt.cursor = text.chars().count();
prompt.text = text.clone();
}
push_search_pattern(ed, &text);
}
pub fn step(ed: &mut Editor<'_>, input: Input) -> bool {
ed.sync_buffer_content_from_textarea();
if ed.vim.recording_macro.is_some()
&& !ed.vim.replaying_macro
&& matches!(ed.vim.pending, Pending::None)
&& ed.vim.mode != Mode::Insert
&& input.key == Key::Char('q')
&& !input.ctrl
&& !input.alt
{
let reg = ed.vim.recording_macro.take().unwrap();
let keys = std::mem::take(&mut ed.vim.recording_keys);
let text = crate::input::encode_macro(&keys);
ed.set_named_register_text(reg.to_ascii_lowercase(), text);
return true;
}
if ed.vim.search_prompt.is_some() {
return step_search_prompt(ed, input);
}
let pending_was_macro_chord = matches!(
ed.vim.pending,
Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
);
let was_insert = ed.vim.mode == Mode::Insert;
let pre_visual_snapshot = match ed.vim.mode {
Mode::Visual => Some(LastVisual {
mode: Mode::Visual,
anchor: ed.vim.visual_anchor,
cursor: ed.cursor(),
block_vcol: 0,
}),
Mode::VisualLine => Some(LastVisual {
mode: Mode::VisualLine,
anchor: (ed.vim.visual_line_anchor, 0),
cursor: ed.cursor(),
block_vcol: 0,
}),
Mode::VisualBlock => Some(LastVisual {
mode: Mode::VisualBlock,
anchor: ed.vim.block_anchor,
cursor: ed.cursor(),
block_vcol: ed.vim.block_vcol,
}),
_ => None,
};
let consumed = match ed.vim.mode {
Mode::Insert => step_insert(ed, input),
_ => step_normal(ed, input),
};
if let Some(snap) = pre_visual_snapshot
&& !matches!(
ed.vim.mode,
Mode::Visual | Mode::VisualLine | Mode::VisualBlock
)
{
ed.vim.last_visual = Some(snap);
}
if !was_insert
&& ed.vim.one_shot_normal
&& ed.vim.mode == Mode::Normal
&& matches!(ed.vim.pending, Pending::None)
{
ed.vim.one_shot_normal = false;
ed.vim.mode = Mode::Insert;
}
ed.sync_buffer_content_from_textarea();
if !ed.vim.viewport_pinned {
ed.ensure_cursor_in_scrolloff();
}
ed.vim.viewport_pinned = false;
if ed.vim.recording_macro.is_some()
&& !ed.vim.replaying_macro
&& input.key != Key::Char('q')
&& !pending_was_macro_chord
{
ed.vim.recording_keys.push(input);
}
consumed
}
fn step_insert(ed: &mut Editor<'_>, input: Input) -> bool {
if ed.vim.insert_pending_register {
ed.vim.insert_pending_register = false;
if let Key::Char(c) = input.key
&& !input.ctrl
{
insert_register_text(ed, c);
}
return true;
}
if input.key == Key::Esc {
finish_insert_session(ed);
ed.vim.mode = Mode::Normal;
let col = ed.cursor().1;
if col > 0 {
ed.buffer_mut().move_left(1);
ed.push_buffer_cursor_to_textarea();
}
ed.vim.sticky_col = Some(ed.cursor().1);
return true;
}
if input.ctrl {
match input.key {
Key::Char('w') => {
use hjkl_buffer::{Edit, MotionKind};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
if cursor.row == 0 && cursor.col == 0 {
return true;
}
ed.buffer_mut().move_word_back(false, 1);
let word_start = ed.buffer().cursor();
if word_start == cursor {
return true;
}
ed.buffer_mut().set_cursor(cursor);
ed.mutate_edit(Edit::DeleteRange {
start: word_start,
end: cursor,
kind: MotionKind::Char,
});
ed.push_buffer_cursor_to_textarea();
return true;
}
Key::Char('u') => {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
if cursor.col > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(cursor.row, 0),
end: cursor,
kind: MotionKind::Char,
});
ed.push_buffer_cursor_to_textarea();
}
return true;
}
Key::Char('h') => {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
if cursor.col > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(cursor.row, cursor.col - 1),
end: cursor,
kind: MotionKind::Char,
});
} else if cursor.row > 0 {
let prev_row = cursor.row - 1;
let prev_chars = ed
.buffer()
.line(prev_row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::JoinLines {
row: prev_row,
count: 1,
with_space: false,
});
ed.buffer_mut()
.set_cursor(Position::new(prev_row, prev_chars));
}
ed.push_buffer_cursor_to_textarea();
return true;
}
Key::Char('o') => {
ed.vim.one_shot_normal = true;
ed.vim.mode = Mode::Normal;
return true;
}
Key::Char('r') => {
ed.vim.insert_pending_register = true;
return true;
}
Key::Char('t') => {
let (row, col) = ed.cursor();
let sw = ed.settings().shiftwidth;
indent_rows(ed, row, row, 1);
ed.jump_cursor(row, col + sw);
return true;
}
Key::Char('d') => {
let (row, col) = ed.cursor();
let before_len = ed.buffer().lines()[row].len();
outdent_rows(ed, row, row, 1);
let after_len = ed.buffer().lines()[row].len();
let stripped = before_len.saturating_sub(after_len);
let new_col = col.saturating_sub(stripped);
ed.jump_cursor(row, new_col);
return true;
}
_ => {}
}
}
let (row, _) = ed.cursor();
if let Some(ref mut session) = ed.vim.insert_session {
session.row_min = session.row_min.min(row);
session.row_max = session.row_max.max(row);
}
let mutated = handle_insert_key(ed, input);
if mutated {
ed.mark_content_dirty();
let (row, _) = ed.cursor();
if let Some(ref mut session) = ed.vim.insert_session {
session.row_min = session.row_min.min(row);
session.row_max = session.row_max.max(row);
}
}
true
}
fn insert_register_text(ed: &mut Editor<'_>, selector: char) {
use hjkl_buffer::{Edit, Position};
let text = match ed.registers().read(selector) {
Some(slot) if !slot.text.is_empty() => slot.text.clone(),
_ => return,
};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
ed.mutate_edit(Edit::InsertStr {
at: cursor,
text: text.clone(),
});
let mut row = cursor.row;
let mut col = cursor.col;
for ch in text.chars() {
if ch == '\n' {
row += 1;
col = 0;
} else {
col += 1;
}
}
ed.buffer_mut().set_cursor(Position::new(row, col));
ed.push_buffer_cursor_to_textarea();
ed.mark_content_dirty();
if let Some(ref mut session) = ed.vim.insert_session {
session.row_min = session.row_min.min(row);
session.row_max = session.row_max.max(row);
}
}
fn handle_insert_key(ed: &mut Editor<'_>, input: Input) -> bool {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
let in_replace = matches!(
ed.vim.insert_session.as_ref().map(|s| &s.reason),
Some(InsertReason::Replace)
);
let mutated = match input.key {
Key::Char(c) if in_replace && cursor.col < line_chars => {
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
true
}
Key::Char(c) => {
ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
true
}
Key::Enter => {
ed.mutate_edit(Edit::InsertStr {
at: cursor,
text: "\n".into(),
});
true
}
Key::Tab => {
ed.mutate_edit(Edit::InsertChar {
at: cursor,
ch: '\t',
});
true
}
Key::Backspace => {
if cursor.col > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(cursor.row, cursor.col - 1),
end: cursor,
kind: MotionKind::Char,
});
true
} else if cursor.row > 0 {
let prev_row = cursor.row - 1;
let prev_chars = ed
.buffer()
.line(prev_row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::JoinLines {
row: prev_row,
count: 1,
with_space: false,
});
ed.buffer_mut()
.set_cursor(Position::new(prev_row, prev_chars));
true
} else {
false
}
}
Key::Delete => {
if cursor.col < line_chars {
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
true
} else if cursor.row + 1 < ed.buffer().row_count() {
ed.mutate_edit(Edit::JoinLines {
row: cursor.row,
count: 1,
with_space: false,
});
ed.buffer_mut().set_cursor(cursor);
true
} else {
false
}
}
Key::Left => {
ed.buffer_mut().move_left(1);
false
}
Key::Right => {
ed.buffer_mut().move_right_to_end(1);
false
}
Key::Up => {
ed.buffer_mut().move_up(1);
false
}
Key::Down => {
ed.buffer_mut().move_down(1);
false
}
Key::Home => {
ed.buffer_mut().move_line_start();
false
}
Key::End => {
ed.buffer_mut().move_line_end();
false
}
Key::PageUp => {
let rows = viewport_full_rows(ed, 1) as isize;
scroll_cursor_rows(ed, -rows);
return false;
}
Key::PageDown => {
let rows = viewport_full_rows(ed, 1) as isize;
scroll_cursor_rows(ed, rows);
return false;
}
_ => false,
};
ed.push_buffer_cursor_to_textarea();
mutated
}
fn finish_insert_session(ed: &mut Editor<'_>) {
let Some(session) = ed.vim.insert_session.take() else {
return;
};
let lines = ed.buffer().lines();
let after_end = session.row_max.min(lines.len().saturating_sub(1));
let before_end = session
.row_max
.min(session.before_lines.len().saturating_sub(1));
let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
session.before_lines[session.row_min..=before_end].join("\n")
} else {
String::new()
};
let after = if after_end >= session.row_min && session.row_min < lines.len() {
lines[session.row_min..=after_end].join("\n")
} else {
String::new()
};
let inserted = extract_inserted(&before, &after);
if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
use hjkl_buffer::{Edit, Position};
for _ in 0..session.count - 1 {
let (row, col) = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, col),
text: inserted.clone(),
});
}
}
if let InsertReason::BlockEdge { top, bot, col } = session.reason {
if !inserted.is_empty() && top < bot && !ed.vim.replaying {
use hjkl_buffer::{Edit, Position};
for r in (top + 1)..=bot {
let line_len = ed.buffer().line(r).map(|l| l.chars().count()).unwrap_or(0);
if col > line_len {
let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(r, line_len),
text: pad,
});
}
ed.mutate_edit(Edit::InsertStr {
at: Position::new(r, col),
text: inserted.clone(),
});
}
ed.buffer_mut().set_cursor(Position::new(top, col));
ed.push_buffer_cursor_to_textarea();
}
return;
}
if ed.vim.replaying {
return;
}
match session.reason {
InsertReason::Enter(entry) => {
ed.vim.last_change = Some(LastChange::InsertAt {
entry,
inserted,
count: session.count,
});
}
InsertReason::Open { above } => {
ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
}
InsertReason::AfterChange => {
if let Some(
LastChange::OpMotion { inserted: ins, .. }
| LastChange::OpTextObj { inserted: ins, .. }
| LastChange::LineOp { inserted: ins, .. },
) = ed.vim.last_change.as_mut()
{
*ins = Some(inserted);
}
}
InsertReason::DeleteToEol => {
ed.vim.last_change = Some(LastChange::DeleteToEol {
inserted: Some(inserted),
});
}
InsertReason::ReplayOnly => {}
InsertReason::BlockEdge { .. } => unreachable!("handled above"),
InsertReason::Replace => {
ed.vim.last_change = Some(LastChange::DeleteToEol {
inserted: Some(inserted),
});
}
}
}
fn begin_insert(ed: &mut Editor<'_>, count: usize, reason: InsertReason) {
let record = !matches!(reason, InsertReason::ReplayOnly);
if record {
ed.push_undo();
}
let reason = if ed.vim.replaying {
InsertReason::ReplayOnly
} else {
reason
};
let (row, _) = ed.cursor();
ed.vim.insert_session = Some(InsertSession {
count,
row_min: row,
row_max: row,
before_lines: ed.buffer().lines().to_vec(),
reason,
});
ed.vim.mode = Mode::Insert;
}
fn step_normal(ed: &mut Editor<'_>, input: Input) -> bool {
if let Key::Char(d @ '0'..='9') = input.key
&& !input.ctrl
&& !input.alt
&& !matches!(
ed.vim.pending,
Pending::Replace
| Pending::Find { .. }
| Pending::OpFind { .. }
| Pending::VisualTextObj { .. }
)
&& (d != '0' || ed.vim.count > 0)
{
ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
return true;
}
match std::mem::take(&mut ed.vim.pending) {
Pending::Replace => return handle_replace(ed, input),
Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
Pending::OpFind {
op,
count1,
forward,
till,
} => return handle_op_find_target(ed, input, op, count1, forward, till),
Pending::G => return handle_after_g(ed, input),
Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
Pending::OpTextObj { op, count1, inner } => {
return handle_text_object(ed, input, op, count1, inner);
}
Pending::VisualTextObj { inner } => {
return handle_visual_text_obj(ed, input, inner);
}
Pending::Z => return handle_after_z(ed, input),
Pending::SetMark => return handle_set_mark(ed, input),
Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
Pending::SelectRegister => return handle_select_register(ed, input),
Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
Pending::None => {}
}
let count = take_count(&mut ed.vim);
match input.key {
Key::Esc => {
ed.vim.force_normal();
return true;
}
Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
ed.vim.visual_anchor = ed.cursor();
ed.vim.mode = Mode::Visual;
return true;
}
Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
let (row, _) = ed.cursor();
ed.vim.visual_line_anchor = row;
ed.vim.mode = Mode::VisualLine;
return true;
}
Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
ed.vim.visual_anchor = ed.cursor();
ed.vim.mode = Mode::Visual;
return true;
}
Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
let (row, _) = ed.cursor();
ed.vim.visual_line_anchor = row;
ed.vim.mode = Mode::VisualLine;
return true;
}
Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
let cur = ed.cursor();
ed.vim.block_anchor = cur;
ed.vim.block_vcol = cur.1;
ed.vim.mode = Mode::VisualBlock;
return true;
}
Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
ed.vim.mode = Mode::Normal;
return true;
}
Key::Char('o') if !input.ctrl => match ed.vim.mode {
Mode::Visual => {
let cur = ed.cursor();
let anchor = ed.vim.visual_anchor;
ed.vim.visual_anchor = cur;
ed.jump_cursor(anchor.0, anchor.1);
return true;
}
Mode::VisualLine => {
let cur_row = ed.cursor().0;
let anchor_row = ed.vim.visual_line_anchor;
ed.vim.visual_line_anchor = cur_row;
ed.jump_cursor(anchor_row, 0);
return true;
}
Mode::VisualBlock => {
let cur = ed.cursor();
let anchor = ed.vim.block_anchor;
ed.vim.block_anchor = cur;
ed.vim.block_vcol = anchor.1;
ed.jump_cursor(anchor.0, anchor.1);
return true;
}
_ => {}
},
_ => {}
}
if ed.vim.is_visual()
&& let Some(op) = visual_operator(&input)
{
apply_visual_operator(ed, op);
return true;
}
if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
match input.key {
Key::Char('r') => {
ed.vim.pending = Pending::Replace;
return true;
}
Key::Char('I') => {
let (top, bot, left, _right) = block_bounds(ed);
ed.jump_cursor(top, left);
ed.vim.mode = Mode::Normal;
begin_insert(
ed,
1,
InsertReason::BlockEdge {
top,
bot,
col: left,
},
);
return true;
}
Key::Char('A') => {
let (top, bot, _left, right) = block_bounds(ed);
let line_len = ed.buffer().lines()[top].chars().count();
let col = (right + 1).min(line_len);
ed.jump_cursor(top, col);
ed.vim.mode = Mode::Normal;
begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
return true;
}
_ => {}
}
}
if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
&& !input.ctrl
&& matches!(input.key, Key::Char('i') | Key::Char('a'))
{
let inner = matches!(input.key, Key::Char('i'));
ed.vim.pending = Pending::VisualTextObj { inner };
return true;
}
if input.ctrl
&& let Key::Char(c) = input.key
{
match c {
'd' => {
scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
return true;
}
'u' => {
scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
return true;
}
'f' => {
scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
return true;
}
'b' => {
scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
return true;
}
'r' => {
do_redo(ed);
return true;
}
'a' if ed.vim.mode == Mode::Normal => {
adjust_number(ed, count.max(1) as i64);
return true;
}
'x' if ed.vim.mode == Mode::Normal => {
adjust_number(ed, -(count.max(1) as i64));
return true;
}
'o' if ed.vim.mode == Mode::Normal => {
for _ in 0..count.max(1) {
jump_back(ed);
}
return true;
}
'i' if ed.vim.mode == Mode::Normal => {
for _ in 0..count.max(1) {
jump_forward(ed);
}
return true;
}
_ => {}
}
}
if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
for _ in 0..count.max(1) {
jump_forward(ed);
}
return true;
}
if let Some(motion) = parse_motion(&input) {
execute_motion(ed, motion.clone(), count);
if ed.vim.mode == Mode::VisualBlock {
update_block_vcol(ed, &motion);
}
if let Motion::Find { ch, forward, till } = motion {
ed.vim.last_find = Some((ch, forward, till));
}
return true;
}
if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
return true;
}
if ed.vim.mode == Mode::Normal
&& let Key::Char(op_ch) = input.key
&& !input.ctrl
&& let Some(op) = char_to_operator(op_ch)
{
ed.vim.pending = Pending::Op { op, count1: count };
return true;
}
if ed.vim.mode == Mode::Normal
&& let Some((forward, till)) = find_entry(&input)
{
ed.vim.count = count;
ed.vim.pending = Pending::Find { forward, till };
return true;
}
if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
ed.vim.count = count;
ed.vim.pending = Pending::G;
return true;
}
if !input.ctrl
&& input.key == Key::Char('z')
&& matches!(
ed.vim.mode,
Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
)
{
ed.vim.pending = Pending::Z;
return true;
}
if !input.ctrl && ed.vim.mode == Mode::Normal {
match input.key {
Key::Char('m') => {
ed.vim.pending = Pending::SetMark;
return true;
}
Key::Char('\'') => {
ed.vim.pending = Pending::GotoMarkLine;
return true;
}
Key::Char('`') => {
ed.vim.pending = Pending::GotoMarkChar;
return true;
}
Key::Char('"') => {
ed.vim.pending = Pending::SelectRegister;
return true;
}
Key::Char('@') => {
ed.vim.pending = Pending::PlayMacroTarget { count };
return true;
}
Key::Char('q') if ed.vim.recording_macro.is_none() => {
ed.vim.pending = Pending::RecordMacroTarget;
return true;
}
_ => {}
}
}
true
}
fn handle_set_mark(ed: &mut Editor<'_>, input: Input) -> bool {
if let Key::Char(c) = input.key {
let pos = ed.cursor();
if c.is_ascii_lowercase() {
ed.vim.marks.insert(c, pos);
} else if c.is_ascii_uppercase() {
ed.file_marks.insert(c, pos);
}
}
true
}
fn handle_select_register(ed: &mut Editor<'_>, input: Input) -> bool {
if let Key::Char(c) = input.key
&& (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
{
ed.vim.pending_register = Some(c);
}
true
}
fn handle_record_macro_target(ed: &mut Editor<'_>, input: Input) -> bool {
if let Key::Char(c) = input.key
&& (c.is_ascii_alphabetic() || c.is_ascii_digit())
{
ed.vim.recording_macro = Some(c);
if c.is_ascii_uppercase() {
let lower = c.to_ascii_lowercase();
let text = ed
.registers()
.read(lower)
.map(|s| s.text.clone())
.unwrap_or_default();
ed.vim.recording_keys = crate::input::decode_macro(&text);
} else {
ed.vim.recording_keys.clear();
}
}
true
}
fn handle_play_macro_target(ed: &mut Editor<'_>, input: Input, count: usize) -> bool {
let reg = match input.key {
Key::Char('@') => ed.vim.last_macro,
Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
Some(c.to_ascii_lowercase())
}
_ => None,
};
let Some(reg) = reg else {
return true;
};
let text = match ed.registers().read(reg) {
Some(slot) if !slot.text.is_empty() => slot.text.clone(),
_ => return true,
};
let keys = crate::input::decode_macro(&text);
ed.vim.last_macro = Some(reg);
let times = count.max(1);
let was_replaying = ed.vim.replaying_macro;
ed.vim.replaying_macro = true;
for _ in 0..times {
for k in keys.iter().copied() {
step(ed, k);
}
}
ed.vim.replaying_macro = was_replaying;
true
}
fn handle_goto_mark(ed: &mut Editor<'_>, input: Input, linewise: bool) -> bool {
let Key::Char(c) = input.key else {
return true;
};
let target = match c {
'a'..='z' => ed.vim.marks.get(&c).copied(),
'A'..='Z' => ed.file_marks.get(&c).copied(),
'\'' | '`' => ed.vim.jump_back.last().copied(),
'.' => ed.vim.last_edit_pos,
_ => None,
};
let Some((row, col)) = target else {
return true;
};
let pre = ed.cursor();
let (r, c_clamped) = clamp_pos(ed, (row, col));
if linewise {
ed.buffer_mut().set_cursor(hjkl_buffer::Position::new(r, 0));
ed.push_buffer_cursor_to_textarea();
move_first_non_whitespace(ed);
} else {
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(r, c_clamped));
ed.push_buffer_cursor_to_textarea();
}
if ed.cursor() != pre {
push_jump(ed, pre);
}
ed.vim.sticky_col = Some(ed.cursor().1);
true
}
fn take_count(vim: &mut VimState) -> usize {
if vim.count > 0 {
let n = vim.count;
vim.count = 0;
n
} else {
1
}
}
fn char_to_operator(c: char) -> Option<Operator> {
match c {
'd' => Some(Operator::Delete),
'c' => Some(Operator::Change),
'y' => Some(Operator::Yank),
'>' => Some(Operator::Indent),
'<' => Some(Operator::Outdent),
_ => None,
}
}
fn visual_operator(input: &Input) -> Option<Operator> {
if input.ctrl {
return None;
}
match input.key {
Key::Char('y') => Some(Operator::Yank),
Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
Key::Char('c') | Key::Char('s') => Some(Operator::Change),
Key::Char('U') => Some(Operator::Uppercase),
Key::Char('u') => Some(Operator::Lowercase),
Key::Char('~') => Some(Operator::ToggleCase),
Key::Char('>') => Some(Operator::Indent),
Key::Char('<') => Some(Operator::Outdent),
_ => None,
}
}
fn find_entry(input: &Input) -> Option<(bool, bool)> {
if input.ctrl {
return None;
}
match input.key {
Key::Char('f') => Some((true, false)),
Key::Char('F') => Some((false, false)),
Key::Char('t') => Some((true, true)),
Key::Char('T') => Some((false, true)),
_ => None,
}
}
const JUMPLIST_MAX: usize = 100;
fn push_jump(ed: &mut Editor<'_>, from: (usize, usize)) {
ed.vim.jump_back.push(from);
if ed.vim.jump_back.len() > JUMPLIST_MAX {
ed.vim.jump_back.remove(0);
}
ed.vim.jump_fwd.clear();
}
fn jump_back(ed: &mut Editor<'_>) {
let Some(target) = ed.vim.jump_back.pop() else {
return;
};
let cur = ed.cursor();
ed.vim.jump_fwd.push(cur);
let (r, c) = clamp_pos(ed, target);
ed.jump_cursor(r, c);
ed.vim.sticky_col = Some(c);
}
fn jump_forward(ed: &mut Editor<'_>) {
let Some(target) = ed.vim.jump_fwd.pop() else {
return;
};
let cur = ed.cursor();
ed.vim.jump_back.push(cur);
if ed.vim.jump_back.len() > JUMPLIST_MAX {
ed.vim.jump_back.remove(0);
}
let (r, c) = clamp_pos(ed, target);
ed.jump_cursor(r, c);
ed.vim.sticky_col = Some(c);
}
fn clamp_pos(ed: &Editor<'_>, pos: (usize, usize)) -> (usize, usize) {
let last_row = ed.buffer().lines().len().saturating_sub(1);
let r = pos.0.min(last_row);
let line_len = ed.buffer().line(r).map(|l| l.chars().count()).unwrap_or(0);
let c = pos.1.min(line_len.saturating_sub(1));
(r, c)
}
fn is_big_jump(motion: &Motion) -> bool {
matches!(
motion,
Motion::FileTop
| Motion::FileBottom
| Motion::MatchBracket
| Motion::WordAtCursor { .. }
| Motion::SearchNext { .. }
| Motion::ViewportTop
| Motion::ViewportMiddle
| Motion::ViewportBottom
)
}
fn viewport_half_rows(ed: &Editor<'_>, count: usize) -> usize {
let h = ed.viewport_height_value() as usize;
(h / 2).max(1).saturating_mul(count.max(1))
}
fn viewport_full_rows(ed: &Editor<'_>, count: usize) -> usize {
let h = ed.viewport_height_value() as usize;
h.saturating_sub(2).max(1).saturating_mul(count.max(1))
}
fn scroll_cursor_rows(ed: &mut Editor<'_>, delta: isize) {
if delta == 0 {
return;
}
ed.sync_buffer_content_from_textarea();
let (row, _) = ed.cursor();
let last_row = ed.buffer().row_count().saturating_sub(1);
let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(target, 0));
ed.buffer_mut().move_first_non_blank();
ed.push_buffer_cursor_to_textarea();
ed.vim.sticky_col = Some(ed.buffer().cursor().col);
}
fn parse_motion(input: &Input) -> Option<Motion> {
if input.ctrl {
return None;
}
match input.key {
Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
Key::Char('l') | Key::Right => Some(Motion::Right),
Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
Key::Char('k') | Key::Up => Some(Motion::Up),
Key::Char('w') => Some(Motion::WordFwd),
Key::Char('W') => Some(Motion::BigWordFwd),
Key::Char('b') => Some(Motion::WordBack),
Key::Char('B') => Some(Motion::BigWordBack),
Key::Char('e') => Some(Motion::WordEnd),
Key::Char('E') => Some(Motion::BigWordEnd),
Key::Char('0') | Key::Home => Some(Motion::LineStart),
Key::Char('^') => Some(Motion::FirstNonBlank),
Key::Char('$') | Key::End => Some(Motion::LineEnd),
Key::Char('G') => Some(Motion::FileBottom),
Key::Char('%') => Some(Motion::MatchBracket),
Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
Key::Char('*') => Some(Motion::WordAtCursor {
forward: true,
whole_word: true,
}),
Key::Char('#') => Some(Motion::WordAtCursor {
forward: false,
whole_word: true,
}),
Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
Key::Char('H') => Some(Motion::ViewportTop),
Key::Char('M') => Some(Motion::ViewportMiddle),
Key::Char('L') => Some(Motion::ViewportBottom),
Key::Char('{') => Some(Motion::ParagraphPrev),
Key::Char('}') => Some(Motion::ParagraphNext),
Key::Char('(') => Some(Motion::SentencePrev),
Key::Char(')') => Some(Motion::SentenceNext),
_ => None,
}
}
fn execute_motion(ed: &mut Editor<'_>, motion: Motion, count: usize) {
let count = count.max(1);
let motion = match motion {
Motion::FindRepeat { reverse } => match ed.vim.last_find {
Some((ch, forward, till)) => Motion::Find {
ch,
forward: if reverse { !forward } else { forward },
till,
},
None => return,
},
other => other,
};
let pre_pos = ed.cursor();
let pre_col = pre_pos.1;
apply_motion_cursor(ed, &motion, count);
let post_pos = ed.cursor();
if is_big_jump(&motion) && pre_pos != post_pos {
push_jump(ed, pre_pos);
}
apply_sticky_col(ed, &motion, pre_col);
ed.sync_buffer_from_textarea();
}
fn apply_sticky_col(ed: &mut Editor<'_>, motion: &Motion, pre_col: usize) {
if is_vertical_motion(motion) {
let want = ed.vim.sticky_col.unwrap_or(pre_col);
ed.vim.sticky_col = Some(want);
let (row, _) = ed.cursor();
let line_len = ed.buffer().lines()[row].chars().count();
let max_col = line_len.saturating_sub(1);
let target = want.min(max_col);
ed.jump_cursor(row, target);
} else {
ed.vim.sticky_col = Some(ed.cursor().1);
}
}
fn is_vertical_motion(motion: &Motion) -> bool {
matches!(
motion,
Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
)
}
fn apply_motion_cursor(ed: &mut Editor<'_>, motion: &Motion, count: usize) {
apply_motion_cursor_ctx(ed, motion, count, false)
}
fn apply_motion_cursor_ctx(ed: &mut Editor<'_>, motion: &Motion, count: usize, as_operator: bool) {
match motion {
Motion::Left => {
ed.buffer_mut().move_left(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::Right => {
if as_operator {
ed.buffer_mut().move_right_to_end(count);
} else {
ed.buffer_mut().move_right_in_line(count);
}
ed.push_buffer_cursor_to_textarea();
}
Motion::Up => {
ed.buffer_mut().move_up(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::Down => {
ed.buffer_mut().move_down(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::ScreenUp => {
ed.buffer_mut().move_screen_up(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::ScreenDown => {
ed.buffer_mut().move_screen_down(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::WordFwd => {
ed.buffer_mut().move_word_fwd(false, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::WordBack => {
ed.buffer_mut().move_word_back(false, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::WordEnd => {
ed.buffer_mut().move_word_end(false, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::BigWordFwd => {
ed.buffer_mut().move_word_fwd(true, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::BigWordBack => {
ed.buffer_mut().move_word_back(true, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::BigWordEnd => {
ed.buffer_mut().move_word_end(true, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::WordEndBack => {
ed.buffer_mut().move_word_end_back(false, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::BigWordEndBack => {
ed.buffer_mut().move_word_end_back(true, count);
ed.push_buffer_cursor_to_textarea();
}
Motion::LineStart => {
ed.buffer_mut().move_line_start();
ed.push_buffer_cursor_to_textarea();
}
Motion::FirstNonBlank => {
ed.buffer_mut().move_first_non_blank();
ed.push_buffer_cursor_to_textarea();
}
Motion::LineEnd => {
ed.buffer_mut().move_line_end();
ed.push_buffer_cursor_to_textarea();
}
Motion::FileTop => {
if count > 1 {
ed.buffer_mut().move_bottom(count);
} else {
ed.buffer_mut().move_top();
}
ed.push_buffer_cursor_to_textarea();
}
Motion::FileBottom => {
if count > 1 {
ed.buffer_mut().move_bottom(count);
} else {
ed.buffer_mut().move_bottom(0);
}
ed.push_buffer_cursor_to_textarea();
}
Motion::Find { ch, forward, till } => {
for _ in 0..count {
if !find_char_on_line(ed, *ch, *forward, *till) {
break;
}
}
}
Motion::FindRepeat { .. } => {} Motion::MatchBracket => {
let _ = matching_bracket(ed);
}
Motion::WordAtCursor {
forward,
whole_word,
} => {
word_at_cursor_search(ed, *forward, *whole_word, count);
}
Motion::SearchNext { reverse } => {
if let Some(pattern) = ed.vim.last_search.clone() {
push_search_pattern(ed, &pattern);
}
if ed.buffer().search_pattern().is_none() {
return;
}
let forward = ed.vim.last_search_forward != *reverse;
for _ in 0..count.max(1) {
if forward {
ed.buffer_mut().search_forward(true);
} else {
ed.buffer_mut().search_backward(true);
}
}
ed.push_buffer_cursor_to_textarea();
}
Motion::ViewportTop => {
ed.buffer_mut().move_viewport_top(count.saturating_sub(1));
ed.push_buffer_cursor_to_textarea();
}
Motion::ViewportMiddle => {
ed.buffer_mut().move_viewport_middle();
ed.push_buffer_cursor_to_textarea();
}
Motion::ViewportBottom => {
ed.buffer_mut()
.move_viewport_bottom(count.saturating_sub(1));
ed.push_buffer_cursor_to_textarea();
}
Motion::LastNonBlank => {
ed.buffer_mut().move_last_non_blank();
ed.push_buffer_cursor_to_textarea();
}
Motion::LineMiddle => {
let row = ed.cursor().0;
let line_chars = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
let target = line_chars / 2;
ed.jump_cursor(row, target);
}
Motion::ParagraphPrev => {
ed.buffer_mut().move_paragraph_prev(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::ParagraphNext => {
ed.buffer_mut().move_paragraph_next(count);
ed.push_buffer_cursor_to_textarea();
}
Motion::SentencePrev => {
for _ in 0..count.max(1) {
if let Some((row, col)) = sentence_boundary(ed, false) {
ed.jump_cursor(row, col);
}
}
}
Motion::SentenceNext => {
for _ in 0..count.max(1) {
if let Some((row, col)) = sentence_boundary(ed, true) {
ed.jump_cursor(row, col);
}
}
}
}
}
fn move_first_non_whitespace(ed: &mut Editor<'_>) {
ed.sync_buffer_content_from_textarea();
ed.buffer_mut().move_first_non_blank();
ed.push_buffer_cursor_to_textarea();
}
fn find_char_on_line(ed: &mut Editor<'_>, ch: char, forward: bool, till: bool) -> bool {
let moved = ed.buffer_mut().find_char_on_line(ch, forward, till);
if moved {
ed.push_buffer_cursor_to_textarea();
}
moved
}
fn matching_bracket(ed: &mut Editor<'_>) -> bool {
let moved = ed.buffer_mut().match_bracket();
if moved {
ed.push_buffer_cursor_to_textarea();
}
moved
}
fn word_at_cursor_search(ed: &mut Editor<'_>, forward: bool, whole_word: bool, count: usize) {
let (row, col) = ed.cursor();
let line: String = ed.buffer().line(row).unwrap_or("").to_string();
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return;
}
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut start = col.min(chars.len().saturating_sub(1));
while start > 0 && is_word(chars[start - 1]) {
start -= 1;
}
let mut end = start;
while end < chars.len() && is_word(chars[end]) {
end += 1;
}
if end <= start {
return;
}
let word: String = chars[start..end].iter().collect();
let escaped = regex_escape(&word);
let pattern = if whole_word {
format!(r"\b{escaped}\b")
} else {
escaped
};
push_search_pattern(ed, &pattern);
if ed.buffer().search_pattern().is_none() {
return;
}
ed.vim.last_search = Some(pattern);
ed.vim.last_search_forward = forward;
for _ in 0..count.max(1) {
if forward {
ed.buffer_mut().search_forward(true);
} else {
ed.buffer_mut().search_backward(true);
}
}
ed.push_buffer_cursor_to_textarea();
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(
c,
'.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
) {
out.push('\\');
}
out.push(c);
}
out
}
fn handle_after_op(ed: &mut Editor<'_>, input: Input, op: Operator, count1: usize) -> bool {
if let Key::Char(d @ '0'..='9') = input.key
&& !input.ctrl
&& (d != '0' || ed.vim.count > 0)
{
ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
ed.vim.pending = Pending::Op { op, count1 };
return true;
}
if input.key == Key::Esc {
ed.vim.count = 0;
return true;
}
let double_ch = match op {
Operator::Delete => Some('d'),
Operator::Change => Some('c'),
Operator::Yank => Some('y'),
Operator::Indent => Some('>'),
Operator::Outdent => Some('<'),
Operator::Uppercase => Some('U'),
Operator::Lowercase => Some('u'),
Operator::ToggleCase => Some('~'),
Operator::Fold => None,
Operator::Reflow => Some('q'),
};
if let Key::Char(c) = input.key
&& !input.ctrl
&& Some(c) == double_ch
{
let count2 = take_count(&mut ed.vim);
let total = count1.max(1) * count2.max(1);
execute_line_op(ed, op, total);
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::LineOp {
op,
count: total,
inserted: None,
});
}
return true;
}
if let Key::Char('i') | Key::Char('a') = input.key
&& !input.ctrl
{
let inner = matches!(input.key, Key::Char('i'));
ed.vim.pending = Pending::OpTextObj { op, count1, inner };
return true;
}
if input.key == Key::Char('g') && !input.ctrl {
ed.vim.pending = Pending::OpG { op, count1 };
return true;
}
if let Some((forward, till)) = find_entry(&input) {
ed.vim.pending = Pending::OpFind {
op,
count1,
forward,
till,
};
return true;
}
let count2 = take_count(&mut ed.vim);
let total = count1.max(1) * count2.max(1);
if let Some(motion) = parse_motion(&input) {
let motion = match motion {
Motion::FindRepeat { reverse } => match ed.vim.last_find {
Some((ch, forward, till)) => Motion::Find {
ch,
forward: if reverse { !forward } else { forward },
till,
},
None => return true,
},
Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
m => m,
};
apply_op_with_motion(ed, op, &motion, total);
if let Motion::Find { ch, forward, till } = &motion {
ed.vim.last_find = Some((*ch, *forward, *till));
}
if !ed.vim.replaying && op_is_change(op) {
ed.vim.last_change = Some(LastChange::OpMotion {
op,
motion,
count: total,
inserted: None,
});
}
return true;
}
true
}
fn handle_op_after_g(ed: &mut Editor<'_>, input: Input, op: Operator, count1: usize) -> bool {
if input.ctrl {
return true;
}
let count2 = take_count(&mut ed.vim);
let total = count1.max(1) * count2.max(1);
if matches!(
op,
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
) {
let op_char = match op {
Operator::Uppercase => 'U',
Operator::Lowercase => 'u',
Operator::ToggleCase => '~',
_ => unreachable!(),
};
if input.key == Key::Char(op_char) {
execute_line_op(ed, op, total);
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::LineOp {
op,
count: total,
inserted: None,
});
}
return true;
}
}
let motion = match input.key {
Key::Char('g') => Motion::FileTop,
Key::Char('e') => Motion::WordEndBack,
Key::Char('E') => Motion::BigWordEndBack,
Key::Char('j') => Motion::ScreenDown,
Key::Char('k') => Motion::ScreenUp,
_ => return true,
};
apply_op_with_motion(ed, op, &motion, total);
if !ed.vim.replaying && op_is_change(op) {
ed.vim.last_change = Some(LastChange::OpMotion {
op,
motion,
count: total,
inserted: None,
});
}
true
}
fn handle_after_g(ed: &mut Editor<'_>, input: Input) -> bool {
let count = take_count(&mut ed.vim);
match input.key {
Key::Char('g') => {
let pre = ed.cursor();
if count > 1 {
ed.jump_cursor(count - 1, 0);
} else {
ed.jump_cursor(0, 0);
}
move_first_non_whitespace(ed);
if ed.cursor() != pre {
push_jump(ed, pre);
}
}
Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
Key::Char('v') => {
if let Some(snap) = ed.vim.last_visual {
match snap.mode {
Mode::Visual => {
ed.vim.visual_anchor = snap.anchor;
ed.vim.mode = Mode::Visual;
}
Mode::VisualLine => {
ed.vim.visual_line_anchor = snap.anchor.0;
ed.vim.mode = Mode::VisualLine;
}
Mode::VisualBlock => {
ed.vim.block_anchor = snap.anchor;
ed.vim.block_vcol = snap.block_vcol;
ed.vim.mode = Mode::VisualBlock;
}
_ => {}
}
ed.jump_cursor(snap.cursor.0, snap.cursor.1);
}
}
Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
Key::Char('U') => {
ed.vim.pending = Pending::Op {
op: Operator::Uppercase,
count1: count,
};
}
Key::Char('u') => {
ed.vim.pending = Pending::Op {
op: Operator::Lowercase,
count1: count,
};
}
Key::Char('~') => {
ed.vim.pending = Pending::Op {
op: Operator::ToggleCase,
count1: count,
};
}
Key::Char('q') => {
ed.vim.pending = Pending::Op {
op: Operator::Reflow,
count1: count,
};
}
Key::Char('J') => {
for _ in 0..count.max(1) {
ed.push_undo();
join_line_raw(ed);
}
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::JoinLine {
count: count.max(1),
});
}
}
Key::Char('d') => {
ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
}
Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
Key::Char('*') => execute_motion(
ed,
Motion::WordAtCursor {
forward: true,
whole_word: false,
},
count,
),
Key::Char('#') => execute_motion(
ed,
Motion::WordAtCursor {
forward: false,
whole_word: false,
},
count,
),
_ => {}
}
true
}
fn handle_after_z(ed: &mut Editor<'_>, input: Input) -> bool {
use crate::editor::CursorScrollTarget;
let row = ed.cursor().0;
match input.key {
Key::Char('z') => {
ed.scroll_cursor_to(CursorScrollTarget::Center);
ed.vim.viewport_pinned = true;
}
Key::Char('t') => {
ed.scroll_cursor_to(CursorScrollTarget::Top);
ed.vim.viewport_pinned = true;
}
Key::Char('b') => {
ed.scroll_cursor_to(CursorScrollTarget::Bottom);
ed.vim.viewport_pinned = true;
}
Key::Char('o') => {
ed.buffer_mut().open_fold_at(row);
}
Key::Char('c') => {
ed.buffer_mut().close_fold_at(row);
}
Key::Char('a') => {
ed.buffer_mut().toggle_fold_at(row);
}
Key::Char('R') => {
ed.buffer_mut().open_all_folds();
}
Key::Char('M') => {
ed.buffer_mut().close_all_folds();
}
Key::Char('E') => {
ed.buffer_mut().clear_all_folds();
}
Key::Char('d') => {
ed.buffer_mut().remove_fold_at(row);
}
Key::Char('f') => {
if matches!(
ed.vim.mode,
Mode::Visual | Mode::VisualLine | Mode::VisualBlock
) {
let anchor_row = match ed.vim.mode {
Mode::VisualLine => ed.vim.visual_line_anchor,
Mode::VisualBlock => ed.vim.block_anchor.0,
_ => ed.vim.visual_anchor.0,
};
let cur = ed.cursor().0;
let top = anchor_row.min(cur);
let bot = anchor_row.max(cur);
ed.buffer_mut().add_fold(top, bot, true);
ed.vim.mode = Mode::Normal;
} else {
let count = take_count(&mut ed.vim);
ed.vim.pending = Pending::Op {
op: Operator::Fold,
count1: count,
};
}
}
_ => {}
}
true
}
fn handle_replace(ed: &mut Editor<'_>, input: Input) -> bool {
if let Key::Char(ch) = input.key {
if ed.vim.mode == Mode::VisualBlock {
block_replace(ed, ch);
return true;
}
let count = take_count(&mut ed.vim);
replace_char(ed, ch, count.max(1));
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::ReplaceChar {
ch,
count: count.max(1),
});
}
}
true
}
fn handle_find_target(ed: &mut Editor<'_>, input: Input, forward: bool, till: bool) -> bool {
let Key::Char(ch) = input.key else {
return true;
};
let count = take_count(&mut ed.vim);
execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
ed.vim.last_find = Some((ch, forward, till));
true
}
fn handle_op_find_target(
ed: &mut Editor<'_>,
input: Input,
op: Operator,
count1: usize,
forward: bool,
till: bool,
) -> bool {
let Key::Char(ch) = input.key else {
return true;
};
let count2 = take_count(&mut ed.vim);
let total = count1.max(1) * count2.max(1);
let motion = Motion::Find { ch, forward, till };
apply_op_with_motion(ed, op, &motion, total);
ed.vim.last_find = Some((ch, forward, till));
if !ed.vim.replaying && op_is_change(op) {
ed.vim.last_change = Some(LastChange::OpMotion {
op,
motion,
count: total,
inserted: None,
});
}
true
}
fn handle_text_object(
ed: &mut Editor<'_>,
input: Input,
op: Operator,
_count1: usize,
inner: bool,
) -> bool {
let Key::Char(ch) = input.key else {
return true;
};
let obj = match ch {
'w' => TextObject::Word { big: false },
'W' => TextObject::Word { big: true },
'"' | '\'' | '`' => TextObject::Quote(ch),
'(' | ')' | 'b' => TextObject::Bracket('('),
'[' | ']' => TextObject::Bracket('['),
'{' | '}' | 'B' => TextObject::Bracket('{'),
'<' | '>' => TextObject::Bracket('<'),
'p' => TextObject::Paragraph,
't' => TextObject::XmlTag,
's' => TextObject::Sentence,
_ => return true,
};
apply_op_with_text_object(ed, op, obj, inner);
if !ed.vim.replaying && op_is_change(op) {
ed.vim.last_change = Some(LastChange::OpTextObj {
op,
obj,
inner,
inserted: None,
});
}
true
}
fn handle_visual_text_obj(ed: &mut Editor<'_>, input: Input, inner: bool) -> bool {
let Key::Char(ch) = input.key else {
return true;
};
let obj = match ch {
'w' => TextObject::Word { big: false },
'W' => TextObject::Word { big: true },
'"' | '\'' | '`' => TextObject::Quote(ch),
'(' | ')' | 'b' => TextObject::Bracket('('),
'[' | ']' => TextObject::Bracket('['),
'{' | '}' | 'B' => TextObject::Bracket('{'),
'<' | '>' => TextObject::Bracket('<'),
'p' => TextObject::Paragraph,
't' => TextObject::XmlTag,
's' => TextObject::Sentence,
_ => return true,
};
let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
return true;
};
match kind {
MotionKind::Linewise => {
ed.vim.visual_line_anchor = start.0;
ed.vim.mode = Mode::VisualLine;
ed.jump_cursor(end.0, 0);
}
_ => {
ed.vim.mode = Mode::Visual;
ed.vim.visual_anchor = (start.0, start.1);
let (er, ec) = retreat_one(ed, end);
ed.jump_cursor(er, ec);
}
}
true
}
fn retreat_one(ed: &Editor<'_>, pos: (usize, usize)) -> (usize, usize) {
let (r, c) = pos;
if c > 0 {
(r, c - 1)
} else if r > 0 {
let prev_len = ed.buffer().lines()[r - 1].len();
(r - 1, prev_len)
} else {
(0, 0)
}
}
fn op_is_change(op: Operator) -> bool {
matches!(op, Operator::Delete | Operator::Change)
}
fn handle_normal_only(ed: &mut Editor<'_>, input: &Input, count: usize) -> bool {
if input.ctrl {
return false;
}
match input.key {
Key::Char('i') => {
begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
true
}
Key::Char('I') => {
move_first_non_whitespace(ed);
begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
true
}
Key::Char('a') => {
ed.buffer_mut().move_right_to_end(1);
ed.push_buffer_cursor_to_textarea();
begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
true
}
Key::Char('A') => {
ed.buffer_mut().move_line_end();
ed.buffer_mut().move_right_to_end(1);
ed.push_buffer_cursor_to_textarea();
begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
true
}
Key::Char('R') => {
begin_insert(ed, count.max(1), InsertReason::Replace);
true
}
Key::Char('o') => {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
ed.sync_buffer_content_from_textarea();
let row = ed.buffer().cursor().row;
let line_chars = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, line_chars),
text: "\n".to_string(),
});
ed.push_buffer_cursor_to_textarea();
true
}
Key::Char('O') => {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
ed.sync_buffer_content_from_textarea();
let row = ed.buffer().cursor().row;
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, 0),
text: "\n".to_string(),
});
ed.buffer_mut().move_up(1);
ed.push_buffer_cursor_to_textarea();
true
}
Key::Char('x') => {
do_char_delete(ed, true, count.max(1));
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::CharDel {
forward: true,
count: count.max(1),
});
}
true
}
Key::Char('X') => {
do_char_delete(ed, false, count.max(1));
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::CharDel {
forward: false,
count: count.max(1),
});
}
true
}
Key::Char('~') => {
for _ in 0..count.max(1) {
ed.push_undo();
toggle_case_at_cursor(ed);
}
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::ToggleCase {
count: count.max(1),
});
}
true
}
Key::Char('J') => {
for _ in 0..count.max(1) {
ed.push_undo();
join_line(ed);
}
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::JoinLine {
count: count.max(1),
});
}
true
}
Key::Char('D') => {
ed.push_undo();
delete_to_eol(ed);
ed.buffer_mut().move_left(1);
ed.push_buffer_cursor_to_textarea();
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
}
true
}
Key::Char('Y') => {
apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
true
}
Key::Char('C') => {
ed.push_undo();
delete_to_eol(ed);
begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
true
}
Key::Char('s') => {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
for _ in 0..count.max(1) {
let cursor = ed.buffer().cursor();
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
if cursor.col >= line_chars {
break;
}
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
}
ed.push_buffer_cursor_to_textarea();
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::OpMotion {
op: Operator::Change,
motion: Motion::Right,
count: count.max(1),
inserted: None,
});
}
true
}
Key::Char('p') => {
do_paste(ed, false, count.max(1));
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::Paste {
before: false,
count: count.max(1),
});
}
true
}
Key::Char('P') => {
do_paste(ed, true, count.max(1));
if !ed.vim.replaying {
ed.vim.last_change = Some(LastChange::Paste {
before: true,
count: count.max(1),
});
}
true
}
Key::Char('u') => {
do_undo(ed);
true
}
Key::Char('r') => {
ed.vim.count = count;
ed.vim.pending = Pending::Replace;
true
}
Key::Char('/') => {
enter_search(ed, true);
true
}
Key::Char('?') => {
enter_search(ed, false);
true
}
Key::Char('.') => {
replay_last_change(ed, count);
true
}
_ => false,
}
}
fn begin_insert_noundo(ed: &mut Editor<'_>, count: usize, reason: InsertReason) {
let reason = if ed.vim.replaying {
InsertReason::ReplayOnly
} else {
reason
};
let (row, _) = ed.cursor();
ed.vim.insert_session = Some(InsertSession {
count,
row_min: row,
row_max: row,
before_lines: ed.buffer().lines().to_vec(),
reason,
});
ed.vim.mode = Mode::Insert;
}
fn apply_op_with_motion(ed: &mut Editor<'_>, op: Operator, motion: &Motion, count: usize) {
let start = ed.cursor();
apply_motion_cursor_ctx(ed, motion, count, true);
let end = ed.cursor();
let kind = motion_kind(motion);
ed.jump_cursor(start.0, start.1);
run_operator_over_range(ed, op, start, end, kind);
}
fn apply_op_with_text_object(ed: &mut Editor<'_>, op: Operator, obj: TextObject, inner: bool) {
let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
return;
};
ed.jump_cursor(start.0, start.1);
run_operator_over_range(ed, op, start, end, kind);
}
fn motion_kind(motion: &Motion) -> MotionKind {
match motion {
Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
MotionKind::Linewise
}
Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
MotionKind::Inclusive
}
Motion::Find { .. } => MotionKind::Inclusive,
Motion::MatchBracket => MotionKind::Inclusive,
Motion::LineEnd => MotionKind::Inclusive,
_ => MotionKind::Exclusive,
}
}
fn run_operator_over_range(
ed: &mut Editor<'_>,
op: Operator,
start: (usize, usize),
end: (usize, usize),
kind: MotionKind,
) {
let (top, bot) = order(start, end);
if top == bot {
return;
}
match op {
Operator::Yank => {
let text = read_vim_range(ed, top, bot, kind);
if !text.is_empty() {
ed.last_yank = Some(text.clone());
ed.record_yank(text, matches!(kind, MotionKind::Linewise));
}
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(top.0, top.1));
ed.push_buffer_cursor_to_textarea();
}
Operator::Delete => {
ed.push_undo();
cut_vim_range(ed, top, bot, kind);
ed.vim.mode = Mode::Normal;
}
Operator::Change => {
ed.push_undo();
cut_vim_range(ed, top, bot, kind);
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
apply_case_op_to_selection(ed, op, top, bot, kind);
}
Operator::Indent | Operator::Outdent => {
ed.push_undo();
if op == Operator::Indent {
indent_rows(ed, top.0, bot.0, 1);
} else {
outdent_rows(ed, top.0, bot.0, 1);
}
ed.vim.mode = Mode::Normal;
}
Operator::Fold => {
if bot.0 >= top.0 {
ed.buffer_mut().add_fold(top.0, bot.0, true);
}
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(top.0, top.1));
ed.push_buffer_cursor_to_textarea();
ed.vim.mode = Mode::Normal;
}
Operator::Reflow => {
ed.push_undo();
reflow_rows(ed, top.0, bot.0);
ed.vim.mode = Mode::Normal;
}
}
}
fn reflow_rows(ed: &mut Editor<'_>, top: usize, bot: usize) {
let width = ed.settings().textwidth.max(1);
let mut lines: Vec<String> = ed.buffer().lines().to_vec();
let bot = bot.min(lines.len().saturating_sub(1));
if top > bot {
return;
}
let original = lines[top..=bot].to_vec();
let mut wrapped: Vec<String> = Vec::new();
let mut paragraph: Vec<String> = Vec::new();
let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
if para.is_empty() {
return;
}
let words = para.join(" ");
let mut current = String::new();
for word in words.split_whitespace() {
let extra = if current.is_empty() {
word.chars().count()
} else {
current.chars().count() + 1 + word.chars().count()
};
if extra > width && !current.is_empty() {
out.push(std::mem::take(&mut current));
current.push_str(word);
} else if current.is_empty() {
current.push_str(word);
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
out.push(current);
}
para.clear();
};
for line in &original {
if line.trim().is_empty() {
flush(&mut paragraph, &mut wrapped, width);
wrapped.push(String::new());
} else {
paragraph.push(line.clone());
}
}
flush(&mut paragraph, &mut wrapped, width);
let after: Vec<String> = lines.split_off(bot + 1);
lines.truncate(top);
lines.extend(wrapped);
lines.extend(after);
ed.restore(lines, (top, 0));
ed.mark_content_dirty();
}
fn apply_case_op_to_selection(
ed: &mut Editor<'_>,
op: Operator,
top: (usize, usize),
bot: (usize, usize),
kind: MotionKind,
) {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
let saved_yank = ed.yank().to_string();
let saved_yank_linewise = ed.vim.yank_linewise;
let selection = cut_vim_range(ed, top, bot, kind);
let transformed = match op {
Operator::Uppercase => selection.to_uppercase(),
Operator::Lowercase => selection.to_lowercase(),
Operator::ToggleCase => toggle_case_str(&selection),
_ => unreachable!(),
};
if !transformed.is_empty() {
let cursor = ed.buffer().cursor();
ed.mutate_edit(Edit::InsertStr {
at: cursor,
text: transformed,
});
}
ed.buffer_mut().set_cursor(Position::new(top.0, top.1));
ed.push_buffer_cursor_to_textarea();
ed.set_yank(saved_yank);
ed.vim.yank_linewise = saved_yank_linewise;
ed.vim.mode = Mode::Normal;
}
fn indent_rows(ed: &mut Editor<'_>, top: usize, bot: usize, count: usize) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth * count.max(1);
let pad: String = " ".repeat(width);
let mut lines: Vec<String> = ed.buffer().lines().to_vec();
let bot = bot.min(lines.len().saturating_sub(1));
for line in lines.iter_mut().take(bot + 1).skip(top) {
if !line.is_empty() {
line.insert_str(0, &pad);
}
}
ed.restore(lines, (top, 0));
move_first_non_whitespace(ed);
}
fn outdent_rows(ed: &mut Editor<'_>, top: usize, bot: usize, count: usize) {
ed.sync_buffer_content_from_textarea();
let width = ed.settings().shiftwidth * count.max(1);
let mut lines: Vec<String> = ed.buffer().lines().to_vec();
let bot = bot.min(lines.len().saturating_sub(1));
for line in lines.iter_mut().take(bot + 1).skip(top) {
let strip: usize = line
.chars()
.take(width)
.take_while(|c| *c == ' ' || *c == '\t')
.count();
if strip > 0 {
let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
line.drain(..byte_len);
}
}
ed.restore(lines, (top, 0));
move_first_non_whitespace(ed);
}
fn toggle_case_str(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_lowercase() {
c.to_uppercase().next().unwrap_or(c)
} else if c.is_uppercase() {
c.to_lowercase().next().unwrap_or(c)
} else {
c
}
})
.collect()
}
fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
if a <= b { (a, b) } else { (b, a) }
}
fn execute_line_op(ed: &mut Editor<'_>, op: Operator, count: usize) {
let (row, col) = ed.cursor();
let total = ed.buffer().lines().len();
let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
match op {
Operator::Yank => {
let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
if !text.is_empty() {
ed.last_yank = Some(text.clone());
ed.record_yank(text, true);
}
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(row, col));
ed.push_buffer_cursor_to_textarea();
ed.vim.mode = Mode::Normal;
}
Operator::Delete => {
ed.push_undo();
let deleted_through_last = end_row + 1 >= total;
cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
let total_after = ed.buffer().row_count();
let target_row = if deleted_through_last {
row.saturating_sub(1).min(total_after.saturating_sub(1))
} else {
row.min(total_after.saturating_sub(1))
};
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(target_row, 0));
ed.push_buffer_cursor_to_textarea();
move_first_non_whitespace(ed);
ed.vim.mode = Mode::Normal;
}
Operator::Change => {
use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
if end_row > row {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(row + 1, 0),
end: Position::new(end_row, 0),
kind: BufKind::Line,
});
}
let line_chars = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
if line_chars > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(row, 0),
end: Position::new(row, line_chars),
kind: BufKind::Char,
});
}
if !payload.is_empty() {
ed.last_yank = Some(payload.clone());
ed.record_delete(payload, true);
}
ed.buffer_mut().set_cursor(Position::new(row, 0));
ed.push_buffer_cursor_to_textarea();
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
move_first_non_whitespace(ed);
}
Operator::Indent | Operator::Outdent => {
ed.push_undo();
if op == Operator::Indent {
indent_rows(ed, row, end_row, 1);
} else {
outdent_rows(ed, row, end_row, 1);
}
ed.vim.mode = Mode::Normal;
}
Operator::Fold => unreachable!("Fold has no line-op double"),
Operator::Reflow => {
ed.push_undo();
reflow_rows(ed, row, end_row);
ed.vim.mode = Mode::Normal;
}
}
}
fn apply_visual_operator(ed: &mut Editor<'_>, op: Operator) {
match ed.vim.mode {
Mode::VisualLine => {
let cursor_row = ed.buffer().cursor().row;
let top = cursor_row.min(ed.vim.visual_line_anchor);
let bot = cursor_row.max(ed.vim.visual_line_anchor);
ed.vim.yank_linewise = true;
match op {
Operator::Yank => {
let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
if !text.is_empty() {
ed.last_yank = Some(text.clone());
ed.record_yank(text, true);
}
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(top, 0));
ed.push_buffer_cursor_to_textarea();
ed.vim.mode = Mode::Normal;
}
Operator::Delete => {
ed.push_undo();
cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
ed.vim.mode = Mode::Normal;
}
Operator::Change => {
use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
if bot > top {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(top + 1, 0),
end: Position::new(bot, 0),
kind: BufKind::Line,
});
}
let line_chars = ed
.buffer()
.line(top)
.map(|l| l.chars().count())
.unwrap_or(0);
if line_chars > 0 {
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(top, 0),
end: Position::new(top, line_chars),
kind: BufKind::Char,
});
}
if !payload.is_empty() {
ed.last_yank = Some(payload.clone());
ed.record_delete(payload, true);
}
ed.buffer_mut().set_cursor(Position::new(top, 0));
ed.push_buffer_cursor_to_textarea();
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
let bot = ed.buffer().cursor().row.max(ed.vim.visual_line_anchor);
apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
move_first_non_whitespace(ed);
}
Operator::Indent | Operator::Outdent => {
ed.push_undo();
let (cursor_row, _) = ed.cursor();
let bot = cursor_row.max(ed.vim.visual_line_anchor);
if op == Operator::Indent {
indent_rows(ed, top, bot, 1);
} else {
outdent_rows(ed, top, bot, 1);
}
ed.vim.mode = Mode::Normal;
}
Operator::Reflow => {
ed.push_undo();
let (cursor_row, _) = ed.cursor();
let bot = cursor_row.max(ed.vim.visual_line_anchor);
reflow_rows(ed, top, bot);
ed.vim.mode = Mode::Normal;
}
Operator::Fold => unreachable!("Visual zf takes its own path"),
}
}
Mode::Visual => {
ed.vim.yank_linewise = false;
let anchor = ed.vim.visual_anchor;
let cursor = ed.cursor();
let (top, bot) = order(anchor, cursor);
match op {
Operator::Yank => {
let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
if !text.is_empty() {
ed.last_yank = Some(text.clone());
ed.record_yank(text, false);
}
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(top.0, top.1));
ed.push_buffer_cursor_to_textarea();
ed.vim.mode = Mode::Normal;
}
Operator::Delete => {
ed.push_undo();
cut_vim_range(ed, top, bot, MotionKind::Inclusive);
ed.vim.mode = Mode::Normal;
}
Operator::Change => {
ed.push_undo();
cut_vim_range(ed, top, bot, MotionKind::Inclusive);
begin_insert_noundo(ed, 1, InsertReason::AfterChange);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
let anchor = ed.vim.visual_anchor;
let cursor = ed.cursor();
let (top, bot) = order(anchor, cursor);
apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
}
Operator::Indent | Operator::Outdent => {
ed.push_undo();
let anchor = ed.vim.visual_anchor;
let cursor = ed.cursor();
let (top, bot) = order(anchor, cursor);
if op == Operator::Indent {
indent_rows(ed, top.0, bot.0, 1);
} else {
outdent_rows(ed, top.0, bot.0, 1);
}
ed.vim.mode = Mode::Normal;
}
Operator::Reflow => {
ed.push_undo();
let anchor = ed.vim.visual_anchor;
let cursor = ed.cursor();
let (top, bot) = order(anchor, cursor);
reflow_rows(ed, top.0, bot.0);
ed.vim.mode = Mode::Normal;
}
Operator::Fold => unreachable!("Visual zf takes its own path"),
}
}
Mode::VisualBlock => apply_block_operator(ed, op),
_ => {}
}
}
fn block_bounds(ed: &Editor<'_>) -> (usize, usize, usize, usize) {
let (ar, ac) = ed.vim.block_anchor;
let (cr, _) = ed.cursor();
let cc = ed.vim.block_vcol;
let top = ar.min(cr);
let bot = ar.max(cr);
let left = ac.min(cc);
let right = ac.max(cc);
(top, bot, left, right)
}
fn update_block_vcol(ed: &mut Editor<'_>, motion: &Motion) {
match motion {
Motion::Left
| Motion::Right
| Motion::WordFwd
| Motion::BigWordFwd
| Motion::WordBack
| Motion::BigWordBack
| Motion::WordEnd
| Motion::BigWordEnd
| Motion::WordEndBack
| Motion::BigWordEndBack
| Motion::LineStart
| Motion::FirstNonBlank
| Motion::LineEnd
| Motion::Find { .. }
| Motion::FindRepeat { .. }
| Motion::MatchBracket => {
ed.vim.block_vcol = ed.cursor().1;
}
_ => {}
}
}
fn apply_block_operator(ed: &mut Editor<'_>, op: Operator) {
let (top, bot, left, right) = block_bounds(ed);
let yank = block_yank(ed, top, bot, left, right);
match op {
Operator::Yank => {
if !yank.is_empty() {
ed.last_yank = Some(yank.clone());
ed.record_yank(yank, false);
}
ed.vim.mode = Mode::Normal;
ed.jump_cursor(top, left);
}
Operator::Delete => {
ed.push_undo();
delete_block_contents(ed, top, bot, left, right);
if !yank.is_empty() {
ed.last_yank = Some(yank.clone());
ed.record_delete(yank, false);
}
ed.vim.mode = Mode::Normal;
ed.jump_cursor(top, left);
}
Operator::Change => {
ed.push_undo();
delete_block_contents(ed, top, bot, left, right);
if !yank.is_empty() {
ed.last_yank = Some(yank.clone());
ed.record_delete(yank, false);
}
ed.jump_cursor(top, left);
begin_insert_noundo(
ed,
1,
InsertReason::BlockEdge {
top,
bot,
col: left,
},
);
}
Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
ed.push_undo();
transform_block_case(ed, op, top, bot, left, right);
ed.vim.mode = Mode::Normal;
ed.jump_cursor(top, left);
}
Operator::Indent | Operator::Outdent => {
ed.push_undo();
if op == Operator::Indent {
indent_rows(ed, top, bot, 1);
} else {
outdent_rows(ed, top, bot, 1);
}
ed.vim.mode = Mode::Normal;
}
Operator::Fold => unreachable!("Visual zf takes its own path"),
Operator::Reflow => {
ed.push_undo();
reflow_rows(ed, top, bot);
ed.vim.mode = Mode::Normal;
}
}
}
fn transform_block_case(
ed: &mut Editor<'_>,
op: Operator,
top: usize,
bot: usize,
left: usize,
right: usize,
) {
let mut lines: Vec<String> = ed.buffer().lines().to_vec();
for r in top..=bot.min(lines.len().saturating_sub(1)) {
let chars: Vec<char> = lines[r].chars().collect();
if left >= chars.len() {
continue;
}
let end = (right + 1).min(chars.len());
let head: String = chars[..left].iter().collect();
let mid: String = chars[left..end].iter().collect();
let tail: String = chars[end..].iter().collect();
let transformed = match op {
Operator::Uppercase => mid.to_uppercase(),
Operator::Lowercase => mid.to_lowercase(),
Operator::ToggleCase => toggle_case_str(&mid),
_ => mid,
};
lines[r] = format!("{head}{transformed}{tail}");
}
let saved_yank = ed.yank().to_string();
let saved_linewise = ed.vim.yank_linewise;
ed.restore(lines, (top, left));
ed.set_yank(saved_yank);
ed.vim.yank_linewise = saved_linewise;
}
fn block_yank(ed: &Editor<'_>, top: usize, bot: usize, left: usize, right: usize) -> String {
let lines = ed.buffer().lines();
let mut rows: Vec<String> = Vec::new();
for r in top..=bot {
let line = match lines.get(r) {
Some(l) => l,
None => break,
};
let chars: Vec<char> = line.chars().collect();
let end = (right + 1).min(chars.len());
if left >= chars.len() {
rows.push(String::new());
} else {
rows.push(chars[left..end].iter().collect());
}
}
rows.join("\n")
}
fn delete_block_contents(ed: &mut Editor<'_>, top: usize, bot: usize, left: usize, right: usize) {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let last_row = bot.min(ed.buffer().row_count().saturating_sub(1));
if last_row < top {
return;
}
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(top, left),
end: Position::new(last_row, right),
kind: MotionKind::Block,
});
ed.push_buffer_cursor_to_textarea();
}
fn block_replace(ed: &mut Editor<'_>, ch: char) {
let (top, bot, left, right) = block_bounds(ed);
ed.push_undo();
ed.sync_buffer_content_from_textarea();
let mut lines: Vec<String> = ed.buffer().lines().to_vec();
for r in top..=bot.min(lines.len().saturating_sub(1)) {
let chars: Vec<char> = lines[r].chars().collect();
if left >= chars.len() {
continue;
}
let end = (right + 1).min(chars.len());
let before: String = chars[..left].iter().collect();
let middle: String = std::iter::repeat_n(ch, end - left).collect();
let after: String = chars[end..].iter().collect();
lines[r] = format!("{before}{middle}{after}");
}
reset_textarea_lines(ed, lines);
ed.vim.mode = Mode::Normal;
ed.jump_cursor(top, left);
}
fn reset_textarea_lines(ed: &mut Editor<'_>, lines: Vec<String>) {
let cursor = ed.cursor();
ed.buffer_mut().replace_all(&lines.join("\n"));
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(cursor.0, cursor.1));
ed.mark_content_dirty();
}
type Pos = (usize, usize);
fn text_object_range(
ed: &Editor<'_>,
obj: TextObject,
inner: bool,
) -> Option<(Pos, Pos, MotionKind)> {
match obj {
TextObject::Word { big } => {
word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
}
TextObject::Quote(q) => {
quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
}
TextObject::Bracket(open) => {
bracket_text_object(ed, open, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
}
TextObject::Paragraph => {
paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
}
TextObject::XmlTag => {
tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
}
TextObject::Sentence => {
sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
}
}
}
fn sentence_boundary(ed: &Editor<'_>, forward: bool) -> Option<(usize, usize)> {
let lines = ed.buffer().lines();
if lines.is_empty() {
return None;
}
let pos_to_idx = |pos: (usize, usize)| -> usize {
let mut idx = 0;
for line in lines.iter().take(pos.0) {
idx += line.chars().count() + 1;
}
idx + pos.1
};
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (r, line) in lines.iter().enumerate() {
let len = line.chars().count();
if idx <= len {
return (r, idx);
}
idx -= len + 1;
}
let last = lines.len().saturating_sub(1);
(last, lines[last].chars().count())
};
let mut chars: Vec<char> = Vec::new();
for (r, line) in lines.iter().enumerate() {
chars.extend(line.chars());
if r + 1 < lines.len() {
chars.push('\n');
}
}
if chars.is_empty() {
return None;
}
let total = chars.len();
let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
if forward {
let mut i = cursor_idx + 1;
while i < total {
if is_terminator(chars[i]) {
while i + 1 < total && is_terminator(chars[i + 1]) {
i += 1;
}
if i + 1 >= total {
return None;
}
if chars[i + 1].is_whitespace() {
let mut j = i + 1;
while j < total && chars[j].is_whitespace() {
j += 1;
}
if j >= total {
return None;
}
return Some(idx_to_pos(j));
}
}
i += 1;
}
None
} else {
let find_start = |from: usize| -> Option<usize> {
let mut start = from;
while start > 0 {
let prev = chars[start - 1];
if prev.is_whitespace() {
let mut k = start - 1;
while k > 0 && chars[k - 1].is_whitespace() {
k -= 1;
}
if k > 0 && is_terminator(chars[k - 1]) {
break;
}
}
start -= 1;
}
while start < total && chars[start].is_whitespace() {
start += 1;
}
(start < total).then_some(start)
};
let current_start = find_start(cursor_idx)?;
if current_start < cursor_idx {
return Some(idx_to_pos(current_start));
}
let mut k = current_start;
while k > 0 && chars[k - 1].is_whitespace() {
k -= 1;
}
if k == 0 {
return None;
}
let prev_start = find_start(k - 1)?;
Some(idx_to_pos(prev_start))
}
}
fn sentence_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
let lines = ed.buffer().lines();
if lines.is_empty() {
return None;
}
let pos_to_idx = |pos: (usize, usize)| -> usize {
let mut idx = 0;
for line in lines.iter().take(pos.0) {
idx += line.chars().count() + 1;
}
idx + pos.1
};
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (r, line) in lines.iter().enumerate() {
let len = line.chars().count();
if idx <= len {
return (r, idx);
}
idx -= len + 1;
}
let last = lines.len().saturating_sub(1);
(last, lines[last].chars().count())
};
let mut chars: Vec<char> = Vec::new();
for (r, line) in lines.iter().enumerate() {
chars.extend(line.chars());
if r + 1 < lines.len() {
chars.push('\n');
}
}
if chars.is_empty() {
return None;
}
let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
let mut start = cursor_idx;
while start > 0 {
let prev = chars[start - 1];
if prev.is_whitespace() {
let mut k = start - 1;
while k > 0 && chars[k - 1].is_whitespace() {
k -= 1;
}
if k > 0 && is_terminator(chars[k - 1]) {
break;
}
}
start -= 1;
}
while start < chars.len() && chars[start].is_whitespace() {
start += 1;
}
if start >= chars.len() {
return None;
}
let mut end = start;
while end < chars.len() {
if is_terminator(chars[end]) {
while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
end += 1;
}
if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
break;
}
}
end += 1;
}
let end_idx = (end + 1).min(chars.len());
let final_end = if inner {
end_idx
} else {
let mut e = end_idx;
while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
e += 1;
}
e
};
Some((idx_to_pos(start), idx_to_pos(final_end)))
}
fn tag_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
let lines = ed.buffer().lines();
if lines.is_empty() {
return None;
}
let pos_to_idx = |pos: (usize, usize)| -> usize {
let mut idx = 0;
for line in lines.iter().take(pos.0) {
idx += line.chars().count() + 1;
}
idx + pos.1
};
let idx_to_pos = |mut idx: usize| -> (usize, usize) {
for (r, line) in lines.iter().enumerate() {
let len = line.chars().count();
if idx <= len {
return (r, idx);
}
idx -= len + 1;
}
let last = lines.len().saturating_sub(1);
(last, lines[last].chars().count())
};
let mut chars: Vec<char> = Vec::new();
for (r, line) in lines.iter().enumerate() {
chars.extend(line.chars());
if r + 1 < lines.len() {
chars.push('\n');
}
}
let cursor_idx = pos_to_idx(ed.cursor());
let mut stack: Vec<(usize, usize, String)> = Vec::new(); let mut innermost: Option<(usize, usize, usize, usize)> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] != '<' {
i += 1;
continue;
}
let mut j = i + 1;
while j < chars.len() && chars[j] != '>' {
j += 1;
}
if j >= chars.len() {
break;
}
let inside: String = chars[i + 1..j].iter().collect();
let close_end = j + 1;
let trimmed = inside.trim();
if trimmed.starts_with('!') || trimmed.starts_with('?') {
i = close_end;
continue;
}
if let Some(rest) = trimmed.strip_prefix('/') {
let name = rest.split_whitespace().next().unwrap_or("").to_string();
if !name.is_empty()
&& let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
{
let (open_start, content_start, _) = stack[stack_idx].clone();
stack.truncate(stack_idx);
let content_end = i;
if cursor_idx >= content_start && cursor_idx <= content_end {
let candidate = (open_start, content_start, content_end, close_end);
innermost = match innermost {
Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
Some(candidate)
}
None => Some(candidate),
existing => existing,
};
}
}
} else if !trimmed.ends_with('/') {
let name: String = trimmed
.split(|c: char| c.is_whitespace() || c == '/')
.next()
.unwrap_or("")
.to_string();
if !name.is_empty() {
stack.push((i, close_end, name));
}
}
i = close_end;
}
let (open_start, content_start, content_end, close_end) = innermost?;
if inner {
Some((idx_to_pos(content_start), idx_to_pos(content_end)))
} else {
Some((idx_to_pos(open_start), idx_to_pos(close_end)))
}
}
fn is_wordchar(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn word_text_object(
ed: &Editor<'_>,
inner: bool,
big: bool,
) -> Option<((usize, usize), (usize, usize))> {
let (row, col) = ed.cursor();
let line = ed.buffer().lines().get(row)?;
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return None;
}
let at = col.min(chars.len().saturating_sub(1));
let classify = |c: char| -> u8 {
if c.is_whitespace() {
0
} else if big || is_wordchar(c) {
1
} else {
2
}
};
let cls = classify(chars[at]);
let mut start = at;
while start > 0 && classify(chars[start - 1]) == cls {
start -= 1;
}
let mut end = at;
while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
end += 1;
}
let char_byte = |i: usize| {
if i >= chars.len() {
line.len()
} else {
line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
}
};
let mut start_col = char_byte(start);
let mut end_col = char_byte(end + 1);
if !inner {
let mut t = end + 1;
let mut included_trailing = false;
while t < chars.len() && chars[t].is_whitespace() {
included_trailing = true;
t += 1;
}
if included_trailing {
end_col = char_byte(t);
} else {
let mut s = start;
while s > 0 && chars[s - 1].is_whitespace() {
s -= 1;
}
start_col = char_byte(s);
}
}
Some(((row, start_col), (row, end_col)))
}
fn quote_text_object(
ed: &Editor<'_>,
q: char,
inner: bool,
) -> Option<((usize, usize), (usize, usize))> {
let (row, col) = ed.cursor();
let line = ed.buffer().lines().get(row)?;
let bytes = line.as_bytes();
let q_byte = q as u8;
let mut positions: Vec<usize> = Vec::new();
for (i, &b) in bytes.iter().enumerate() {
if b == q_byte {
positions.push(i);
}
}
if positions.len() < 2 {
return None;
}
let mut open_idx: Option<usize> = None;
let mut close_idx: Option<usize> = None;
for pair in positions.chunks(2) {
if pair.len() < 2 {
break;
}
if col >= pair[0] && col <= pair[1] {
open_idx = Some(pair[0]);
close_idx = Some(pair[1]);
break;
}
if col < pair[0] {
open_idx = Some(pair[0]);
close_idx = Some(pair[1]);
break;
}
}
let open = open_idx?;
let close = close_idx?;
if inner {
if close <= open + 1 {
return None;
}
Some(((row, open + 1), (row, close)))
} else {
Some(((row, open), (row, close + 1)))
}
}
fn bracket_text_object(
ed: &Editor<'_>,
open: char,
inner: bool,
) -> Option<((usize, usize), (usize, usize))> {
let close = match open {
'(' => ')',
'[' => ']',
'{' => '}',
'<' => '>',
_ => return None,
};
let (row, col) = ed.cursor();
let lines = ed.buffer().lines();
let open_pos = find_open_bracket(lines, row, col, open, close)?;
let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
if inner {
let inner_start = advance_pos(lines, open_pos);
if inner_start.0 > close_pos.0
|| (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
{
return None;
}
Some((inner_start, close_pos))
} else {
Some((open_pos, advance_pos(lines, close_pos)))
}
}
fn find_open_bracket(
lines: &[String],
row: usize,
col: usize,
open: char,
close: char,
) -> Option<(usize, usize)> {
let mut depth: i32 = 0;
let mut r = row;
let mut c = col as isize;
loop {
let cur = &lines[r];
let chars: Vec<char> = cur.chars().collect();
if (c as usize) >= chars.len() {
c = chars.len() as isize - 1;
}
while c >= 0 {
let ch = chars[c as usize];
if ch == close {
depth += 1;
} else if ch == open {
if depth == 0 {
return Some((r, c as usize));
}
depth -= 1;
}
c -= 1;
}
if r == 0 {
return None;
}
r -= 1;
c = lines[r].chars().count() as isize - 1;
}
}
fn find_close_bracket(
lines: &[String],
row: usize,
start_col: usize,
open: char,
close: char,
) -> Option<(usize, usize)> {
let mut depth: i32 = 0;
let mut r = row;
let mut c = start_col;
loop {
let cur = &lines[r];
let chars: Vec<char> = cur.chars().collect();
while c < chars.len() {
let ch = chars[c];
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
return Some((r, c));
}
depth -= 1;
}
c += 1;
}
if r + 1 >= lines.len() {
return None;
}
r += 1;
c = 0;
}
}
fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
let (r, c) = pos;
let line_len = lines[r].chars().count();
if c < line_len {
(r, c + 1)
} else if r + 1 < lines.len() {
(r + 1, 0)
} else {
pos
}
}
fn paragraph_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
let (row, _) = ed.cursor();
let lines = ed.buffer().lines();
if lines.is_empty() {
return None;
}
let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
if is_blank(row) {
return None;
}
let mut top = row;
while top > 0 && !is_blank(top - 1) {
top -= 1;
}
let mut bot = row;
while bot + 1 < lines.len() && !is_blank(bot + 1) {
bot += 1;
}
if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
bot += 1;
}
let end_col = lines[bot].chars().count();
Some(((top, 0), (bot, end_col)))
}
fn read_vim_range(
ed: &mut Editor<'_>,
start: (usize, usize),
end: (usize, usize),
kind: MotionKind,
) -> String {
let (top, bot) = order(start, end);
ed.sync_buffer_content_from_textarea();
let lines = ed.buffer().lines();
match kind {
MotionKind::Linewise => {
let lo = top.0;
let hi = bot.0.min(lines.len().saturating_sub(1));
let mut text = lines[lo..=hi].join("\n");
text.push('\n');
text
}
MotionKind::Inclusive | MotionKind::Exclusive => {
let inclusive = matches!(kind, MotionKind::Inclusive);
let mut out = String::new();
for row in top.0..=bot.0 {
let line = lines.get(row).map(String::as_str).unwrap_or("");
let lo = if row == top.0 { top.1 } else { 0 };
let hi_unclamped = if row == bot.0 {
if inclusive { bot.1 + 1 } else { bot.1 }
} else {
line.chars().count() + 1
};
let row_chars: Vec<char> = line.chars().collect();
let hi = hi_unclamped.min(row_chars.len());
if lo < hi {
out.push_str(&row_chars[lo..hi].iter().collect::<String>());
}
if row < bot.0 {
out.push('\n');
}
}
out
}
}
}
fn cut_vim_range(
ed: &mut Editor<'_>,
start: (usize, usize),
end: (usize, usize),
kind: MotionKind,
) -> String {
use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
let (top, bot) = order(start, end);
ed.sync_buffer_content_from_textarea();
let (buf_start, buf_end, buf_kind) = match kind {
MotionKind::Linewise => (
Position::new(top.0, 0),
Position::new(bot.0, 0),
BufKind::Line,
),
MotionKind::Inclusive => {
let line_chars = ed
.buffer()
.line(bot.0)
.map(|l| l.chars().count())
.unwrap_or(0);
let next = if bot.1 < line_chars {
Position::new(bot.0, bot.1 + 1)
} else if bot.0 + 1 < ed.buffer().row_count() {
Position::new(bot.0 + 1, 0)
} else {
Position::new(bot.0, line_chars)
};
(Position::new(top.0, top.1), next, BufKind::Char)
}
MotionKind::Exclusive => (
Position::new(top.0, top.1),
Position::new(bot.0, bot.1),
BufKind::Char,
),
};
let inverse = ed.mutate_edit(Edit::DeleteRange {
start: buf_start,
end: buf_end,
kind: buf_kind,
});
let text = match inverse {
Edit::InsertStr { text, .. } => text,
_ => String::new(),
};
if !text.is_empty() {
ed.last_yank = Some(text.clone());
ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
}
ed.push_buffer_cursor_to_textarea();
text
}
fn delete_to_eol(ed: &mut Editor<'_>) {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
if cursor.col >= line_chars {
return;
}
let inverse = ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, line_chars),
kind: MotionKind::Char,
});
if let Edit::InsertStr { text, .. } = inverse
&& !text.is_empty()
{
ed.last_yank = Some(text.clone());
ed.vim.yank_linewise = false;
ed.set_yank(text);
}
ed.buffer_mut().set_cursor(cursor);
ed.push_buffer_cursor_to_textarea();
}
fn do_char_delete(ed: &mut Editor<'_>, forward: bool, count: usize) {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
for _ in 0..count {
let cursor = ed.buffer().cursor();
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
if forward {
if cursor.col >= line_chars {
continue;
}
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
} else {
if cursor.col == 0 {
continue;
}
ed.mutate_edit(Edit::DeleteRange {
start: Position::new(cursor.row, cursor.col - 1),
end: cursor,
kind: MotionKind::Char,
});
}
}
ed.push_buffer_cursor_to_textarea();
}
fn adjust_number(ed: &mut Editor<'_>, delta: i64) -> bool {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
let row = cursor.row;
let chars: Vec<char> = match ed.buffer().line(row) {
Some(l) => l.chars().collect(),
None => return false,
};
let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
return false;
};
let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
digit_start - 1
} else {
digit_start
};
let mut span_end = digit_start;
while span_end < chars.len() && chars[span_end].is_ascii_digit() {
span_end += 1;
}
let s: String = chars[span_start..span_end].iter().collect();
let Ok(n) = s.parse::<i64>() else {
return false;
};
let new_s = n.saturating_add(delta).to_string();
ed.push_undo();
let span_start_pos = Position::new(row, span_start);
let span_end_pos = Position::new(row, span_end);
ed.mutate_edit(Edit::DeleteRange {
start: span_start_pos,
end: span_end_pos,
kind: MotionKind::Char,
});
ed.mutate_edit(Edit::InsertStr {
at: span_start_pos,
text: new_s.clone(),
});
let new_len = new_s.chars().count();
ed.buffer_mut()
.set_cursor(Position::new(row, span_start + new_len.saturating_sub(1)));
ed.push_buffer_cursor_to_textarea();
true
}
fn replace_char(ed: &mut Editor<'_>, ch: char, count: usize) {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
for _ in 0..count {
let cursor = ed.buffer().cursor();
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
if cursor.col >= line_chars {
break;
}
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
}
ed.buffer_mut().move_left(1);
ed.push_buffer_cursor_to_textarea();
}
fn toggle_case_at_cursor(ed: &mut Editor<'_>) {
use hjkl_buffer::{Edit, MotionKind, Position};
ed.sync_buffer_content_from_textarea();
let cursor = ed.buffer().cursor();
let Some(c) = ed
.buffer()
.line(cursor.row)
.and_then(|l| l.chars().nth(cursor.col))
else {
return;
};
let toggled = if c.is_uppercase() {
c.to_lowercase().next().unwrap_or(c)
} else {
c.to_uppercase().next().unwrap_or(c)
};
ed.mutate_edit(Edit::DeleteRange {
start: cursor,
end: Position::new(cursor.row, cursor.col + 1),
kind: MotionKind::Char,
});
ed.mutate_edit(Edit::InsertChar {
at: cursor,
ch: toggled,
});
}
fn join_line(ed: &mut Editor<'_>) {
use hjkl_buffer::{Edit, Position};
ed.sync_buffer_content_from_textarea();
let row = ed.buffer().cursor().row;
if row + 1 >= ed.buffer().row_count() {
return;
}
let cur_line = ed.buffer().line(row).unwrap_or("").to_string();
let next_raw = ed.buffer().line(row + 1).unwrap_or("").to_string();
let next_trimmed = next_raw.trim_start();
let cur_chars = cur_line.chars().count();
let next_chars = next_raw.chars().count();
let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
" "
} else {
""
};
let joined = format!("{cur_line}{separator}{next_trimmed}");
ed.mutate_edit(Edit::Replace {
start: Position::new(row, 0),
end: Position::new(row + 1, next_chars),
with: joined,
});
ed.buffer_mut().set_cursor(Position::new(row, cur_chars));
ed.push_buffer_cursor_to_textarea();
}
fn join_line_raw(ed: &mut Editor<'_>) {
use hjkl_buffer::{Edit, Position};
ed.sync_buffer_content_from_textarea();
let row = ed.buffer().cursor().row;
if row + 1 >= ed.buffer().row_count() {
return;
}
let join_col = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::JoinLines {
row,
count: 1,
with_space: false,
});
ed.buffer_mut().set_cursor(Position::new(row, join_col));
ed.push_buffer_cursor_to_textarea();
}
fn do_paste(ed: &mut Editor<'_>, before: bool, count: usize) {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
let selector = ed.vim.pending_register.take();
let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
Some(slot) => (slot.text.clone(), slot.linewise),
None => (ed.yank().to_string(), ed.vim.yank_linewise),
};
for _ in 0..count {
ed.sync_buffer_content_from_textarea();
let yank = yank.clone();
if yank.is_empty() {
continue;
}
if linewise {
let text = yank.trim_matches('\n').to_string();
let row = ed.buffer().cursor().row;
let target_row = if before {
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, 0),
text: format!("{text}\n"),
});
row
} else {
let line_chars = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, line_chars),
text: format!("\n{text}"),
});
row + 1
};
ed.buffer_mut().set_cursor(Position::new(target_row, 0));
ed.buffer_mut().move_first_non_blank();
ed.push_buffer_cursor_to_textarea();
} else {
let cursor = ed.buffer().cursor();
let at = if before {
cursor
} else {
let line_chars = ed
.buffer()
.line(cursor.row)
.map(|l| l.chars().count())
.unwrap_or(0);
Position::new(cursor.row, (cursor.col + 1).min(line_chars))
};
ed.mutate_edit(Edit::InsertStr {
at,
text: yank.clone(),
});
ed.buffer_mut().move_left(1);
ed.push_buffer_cursor_to_textarea();
}
}
ed.vim.sticky_col = Some(ed.buffer().cursor().col);
}
#[doc(hidden)]
pub fn do_undo(ed: &mut Editor<'_>) {
if let Some((lines, cursor)) = ed.undo_stack.pop() {
let current = ed.snapshot();
ed.redo_stack.push(current);
ed.restore(lines, cursor);
}
ed.vim.mode = Mode::Normal;
}
#[doc(hidden)]
pub fn do_redo(ed: &mut Editor<'_>) {
if let Some((lines, cursor)) = ed.redo_stack.pop() {
let current = ed.snapshot();
ed.undo_stack.push(current);
ed.restore(lines, cursor);
}
ed.vim.mode = Mode::Normal;
}
fn replay_insert_and_finish(ed: &mut Editor<'_>, text: &str) {
use hjkl_buffer::{Edit, Position};
let cursor = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(cursor.0, cursor.1),
text: text.to_string(),
});
if ed.vim.insert_session.take().is_some() {
if ed.cursor().1 > 0 {
ed.buffer_mut().move_left(1);
ed.push_buffer_cursor_to_textarea();
}
ed.vim.mode = Mode::Normal;
}
}
fn replay_last_change(ed: &mut Editor<'_>, outer_count: usize) {
let Some(change) = ed.vim.last_change.clone() else {
return;
};
ed.vim.replaying = true;
let scale = if outer_count > 0 { outer_count } else { 1 };
match change {
LastChange::OpMotion {
op,
motion,
count,
inserted,
} => {
let total = count.max(1) * scale;
apply_op_with_motion(ed, op, &motion, total);
if let Some(text) = inserted {
replay_insert_and_finish(ed, &text);
}
}
LastChange::OpTextObj {
op,
obj,
inner,
inserted,
} => {
apply_op_with_text_object(ed, op, obj, inner);
if let Some(text) = inserted {
replay_insert_and_finish(ed, &text);
}
}
LastChange::LineOp {
op,
count,
inserted,
} => {
let total = count.max(1) * scale;
execute_line_op(ed, op, total);
if let Some(text) = inserted {
replay_insert_and_finish(ed, &text);
}
}
LastChange::CharDel { forward, count } => {
do_char_delete(ed, forward, count * scale);
}
LastChange::ReplaceChar { ch, count } => {
replace_char(ed, ch, count * scale);
}
LastChange::ToggleCase { count } => {
for _ in 0..count * scale {
ed.push_undo();
toggle_case_at_cursor(ed);
}
}
LastChange::JoinLine { count } => {
for _ in 0..count * scale {
ed.push_undo();
join_line(ed);
}
}
LastChange::Paste { before, count } => {
do_paste(ed, before, count * scale);
}
LastChange::DeleteToEol { inserted } => {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
delete_to_eol(ed);
if let Some(text) = inserted {
let cursor = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(cursor.0, cursor.1),
text,
});
}
}
LastChange::OpenLine { above, inserted } => {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
ed.sync_buffer_content_from_textarea();
let row = ed.buffer().cursor().row;
if above {
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, 0),
text: "\n".to_string(),
});
ed.buffer_mut().move_up(1);
} else {
let line_chars = ed
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
ed.mutate_edit(Edit::InsertStr {
at: Position::new(row, line_chars),
text: "\n".to_string(),
});
}
ed.push_buffer_cursor_to_textarea();
let cursor = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(cursor.0, cursor.1),
text: inserted,
});
}
LastChange::InsertAt {
entry,
inserted,
count,
} => {
use hjkl_buffer::{Edit, Position};
ed.push_undo();
match entry {
InsertEntry::I => {}
InsertEntry::ShiftI => move_first_non_whitespace(ed),
InsertEntry::A => {
ed.buffer_mut().move_right_to_end(1);
ed.push_buffer_cursor_to_textarea();
}
InsertEntry::ShiftA => {
ed.buffer_mut().move_line_end();
ed.buffer_mut().move_right_to_end(1);
ed.push_buffer_cursor_to_textarea();
}
}
for _ in 0..count.max(1) {
let cursor = ed.cursor();
ed.mutate_edit(Edit::InsertStr {
at: Position::new(cursor.0, cursor.1),
text: inserted.clone(),
});
}
}
}
ed.vim.replaying = false;
}
fn extract_inserted(before: &str, after: &str) -> String {
let before_chars: Vec<char> = before.chars().collect();
let after_chars: Vec<char> = after.chars().collect();
if after_chars.len() <= before_chars.len() {
return String::new();
}
let prefix = before_chars
.iter()
.zip(after_chars.iter())
.take_while(|(a, b)| a == b)
.count();
let max_suffix = before_chars.len() - prefix;
let suffix = before_chars
.iter()
.rev()
.zip(after_chars.iter().rev())
.take(max_suffix)
.take_while(|(a, b)| a == b)
.count();
after_chars[prefix..after_chars.len() - suffix]
.iter()
.collect()
}
#[cfg(test)]
mod tests {
use crate::editor::Editor;
use crate::{KeybindingMode, VimMode};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn run_keys(e: &mut Editor<'_>, keys: &str) {
let mut iter = keys.chars().peekable();
while let Some(c) = iter.next() {
if c == '<' {
let mut tag = String::new();
for ch in iter.by_ref() {
if ch == '>' {
break;
}
tag.push(ch);
}
let ev = match tag.as_str() {
"Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
"CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
"BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
"Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
"Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
"Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
"Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
"Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
"lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
s if s.starts_with("C-") => {
let ch = s.chars().nth(2).unwrap();
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
}
_ => continue,
};
e.handle_key(ev);
} else {
let mods = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
}
}
}
fn editor_with(content: &str) -> Editor<'static> {
let mut e = Editor::new(KeybindingMode::Vim);
e.set_content(content);
e
}
#[test]
fn f_char_jumps_on_line() {
let mut e = editor_with("hello world");
run_keys(&mut e, "fw");
assert_eq!(e.cursor(), (0, 6));
}
#[test]
fn cap_f_jumps_backward() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 10);
run_keys(&mut e, "Fo");
assert_eq!(e.cursor().1, 7);
}
#[test]
fn t_stops_before_char() {
let mut e = editor_with("hello");
run_keys(&mut e, "tl");
assert_eq!(e.cursor(), (0, 1));
}
#[test]
fn semicolon_repeats_find() {
let mut e = editor_with("aa.bb.cc");
run_keys(&mut e, "f.");
assert_eq!(e.cursor().1, 2);
run_keys(&mut e, ";");
assert_eq!(e.cursor().1, 5);
}
#[test]
fn comma_repeats_find_reverse() {
let mut e = editor_with("aa.bb.cc");
run_keys(&mut e, "f.");
run_keys(&mut e, ";");
run_keys(&mut e, ",");
assert_eq!(e.cursor().1, 2);
}
#[test]
fn di_quote_deletes_content() {
let mut e = editor_with("foo \"bar\" baz");
e.jump_cursor(0, 6); run_keys(&mut e, "di\"");
assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
}
#[test]
fn da_quote_deletes_with_quotes() {
let mut e = editor_with("foo \"bar\" baz");
e.jump_cursor(0, 6);
run_keys(&mut e, "da\"");
assert_eq!(e.buffer().lines()[0], "foo baz");
}
#[test]
fn ci_paren_deletes_and_inserts() {
let mut e = editor_with("fn(a, b, c)");
e.jump_cursor(0, 5);
run_keys(&mut e, "ci(");
assert_eq!(e.vim_mode(), VimMode::Insert);
assert_eq!(e.buffer().lines()[0], "fn()");
}
#[test]
fn diw_deletes_inner_word() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 2);
run_keys(&mut e, "diw");
assert_eq!(e.buffer().lines()[0], " world");
}
#[test]
fn daw_deletes_word_with_trailing_space() {
let mut e = editor_with("hello world");
run_keys(&mut e, "daw");
assert_eq!(e.buffer().lines()[0], "world");
}
#[test]
fn percent_jumps_to_matching_bracket() {
let mut e = editor_with("foo(bar)");
e.jump_cursor(0, 3);
run_keys(&mut e, "%");
assert_eq!(e.cursor().1, 7);
run_keys(&mut e, "%");
assert_eq!(e.cursor().1, 3);
}
#[test]
fn dot_repeats_last_change() {
let mut e = editor_with("aaa bbb ccc");
run_keys(&mut e, "dw");
assert_eq!(e.buffer().lines()[0], "bbb ccc");
run_keys(&mut e, ".");
assert_eq!(e.buffer().lines()[0], "ccc");
}
#[test]
fn dot_repeats_change_operator_with_text() {
let mut e = editor_with("foo foo foo");
run_keys(&mut e, "cwbar<Esc>");
assert_eq!(e.buffer().lines()[0], "bar foo foo");
run_keys(&mut e, "w");
run_keys(&mut e, ".");
assert_eq!(e.buffer().lines()[0], "bar bar foo");
}
#[test]
fn dot_repeats_x() {
let mut e = editor_with("abcdef");
run_keys(&mut e, "x");
run_keys(&mut e, "..");
assert_eq!(e.buffer().lines()[0], "def");
}
#[test]
fn count_operator_motion_compose() {
let mut e = editor_with("one two three four five");
run_keys(&mut e, "d3w");
assert_eq!(e.buffer().lines()[0], "four five");
}
#[test]
fn two_dd_deletes_two_lines() {
let mut e = editor_with("a\nb\nc");
run_keys(&mut e, "2dd");
assert_eq!(e.buffer().lines().len(), 1);
assert_eq!(e.buffer().lines()[0], "c");
}
#[test]
fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
let mut e = editor_with("one\ntwo\n three\nfour");
e.jump_cursor(1, 2);
run_keys(&mut e, "dd");
assert_eq!(e.buffer().lines()[1], " three");
assert_eq!(e.cursor(), (1, 4));
}
#[test]
fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
let mut e = editor_with("one\n two\nthree");
e.jump_cursor(2, 0);
run_keys(&mut e, "dd");
assert_eq!(e.buffer().lines().len(), 2);
assert_eq!(e.cursor(), (1, 2));
}
#[test]
fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
let mut e = editor_with("lonely");
run_keys(&mut e, "dd");
assert_eq!(e.buffer().lines().len(), 1);
assert_eq!(e.buffer().lines()[0], "");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
let mut e = editor_with("a\nb\nc\n d\ne");
e.jump_cursor(1, 0);
run_keys(&mut e, "3dd");
assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn gu_lowercases_motion_range() {
let mut e = editor_with("HELLO WORLD");
run_keys(&mut e, "guw");
assert_eq!(e.buffer().lines()[0], "hello WORLD");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn g_u_uppercases_text_object() {
let mut e = editor_with("hello world");
run_keys(&mut e, "gUiw");
assert_eq!(e.buffer().lines()[0], "HELLO world");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn g_tilde_toggles_case_of_range() {
let mut e = editor_with("Hello World");
run_keys(&mut e, "g~iw");
assert_eq!(e.buffer().lines()[0], "hELLO World");
}
#[test]
fn g_uu_uppercases_current_line() {
let mut e = editor_with("select 1\nselect 2");
run_keys(&mut e, "gUU");
assert_eq!(e.buffer().lines()[0], "SELECT 1");
assert_eq!(e.buffer().lines()[1], "select 2");
}
#[test]
fn gugu_lowercases_current_line() {
let mut e = editor_with("FOO BAR\nBAZ");
run_keys(&mut e, "gugu");
assert_eq!(e.buffer().lines()[0], "foo bar");
}
#[test]
fn visual_u_uppercases_selection() {
let mut e = editor_with("hello world");
run_keys(&mut e, "veU");
assert_eq!(e.buffer().lines()[0], "HELLO world");
}
#[test]
fn visual_line_u_lowercases_line() {
let mut e = editor_with("HELLO WORLD\nOTHER");
run_keys(&mut e, "Vu");
assert_eq!(e.buffer().lines()[0], "hello world");
assert_eq!(e.buffer().lines()[1], "OTHER");
}
#[test]
fn g_uu_with_count_uppercases_multiple_lines() {
let mut e = editor_with("one\ntwo\nthree\nfour");
run_keys(&mut e, "3gUU");
assert_eq!(e.buffer().lines()[0], "ONE");
assert_eq!(e.buffer().lines()[1], "TWO");
assert_eq!(e.buffer().lines()[2], "THREE");
assert_eq!(e.buffer().lines()[3], "four");
}
#[test]
fn double_gt_indents_current_line() {
let mut e = editor_with("hello");
run_keys(&mut e, ">>");
assert_eq!(e.buffer().lines()[0], " hello");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn double_lt_outdents_current_line() {
let mut e = editor_with(" hello");
run_keys(&mut e, "<lt><lt>");
assert_eq!(e.buffer().lines()[0], " hello");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn count_double_gt_indents_multiple_lines() {
let mut e = editor_with("a\nb\nc\nd");
run_keys(&mut e, "3>>");
assert_eq!(e.buffer().lines()[0], " a");
assert_eq!(e.buffer().lines()[1], " b");
assert_eq!(e.buffer().lines()[2], " c");
assert_eq!(e.buffer().lines()[3], "d");
}
#[test]
fn outdent_clips_ragged_leading_whitespace() {
let mut e = editor_with(" x");
run_keys(&mut e, "<lt><lt>");
assert_eq!(e.buffer().lines()[0], "x");
}
#[test]
fn indent_motion_is_always_linewise() {
let mut e = editor_with("foo bar");
run_keys(&mut e, ">w");
assert_eq!(e.buffer().lines()[0], " foo bar");
}
#[test]
fn indent_text_object_extends_over_paragraph() {
let mut e = editor_with("a\nb\n\nc\nd");
run_keys(&mut e, ">ap");
assert_eq!(e.buffer().lines()[0], " a");
assert_eq!(e.buffer().lines()[1], " b");
assert_eq!(e.buffer().lines()[2], "");
assert_eq!(e.buffer().lines()[3], "c");
}
#[test]
fn visual_line_indent_shifts_selected_rows() {
let mut e = editor_with("x\ny\nz");
run_keys(&mut e, "Vj>");
assert_eq!(e.buffer().lines()[0], " x");
assert_eq!(e.buffer().lines()[1], " y");
assert_eq!(e.buffer().lines()[2], "z");
}
#[test]
fn outdent_empty_line_is_noop() {
let mut e = editor_with("\nfoo");
run_keys(&mut e, "<lt><lt>");
assert_eq!(e.buffer().lines()[0], "");
}
#[test]
fn indent_skips_empty_lines() {
let mut e = editor_with("");
run_keys(&mut e, ">>");
assert_eq!(e.buffer().lines()[0], "");
}
#[test]
fn insert_ctrl_t_indents_current_line() {
let mut e = editor_with("x");
run_keys(&mut e, "i<C-t>");
assert_eq!(e.buffer().lines()[0], " x");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn insert_ctrl_d_outdents_current_line() {
let mut e = editor_with(" x");
run_keys(&mut e, "A<C-d>");
assert_eq!(e.buffer().lines()[0], " x");
}
#[test]
fn h_at_col_zero_does_not_wrap_to_prev_line() {
let mut e = editor_with("first\nsecond");
e.jump_cursor(1, 0);
run_keys(&mut e, "h");
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn l_at_last_char_does_not_wrap_to_next_line() {
let mut e = editor_with("ab\ncd");
e.jump_cursor(0, 1);
run_keys(&mut e, "l");
assert_eq!(e.cursor(), (0, 1));
}
#[test]
fn count_l_clamps_at_line_end() {
let mut e = editor_with("abcde");
run_keys(&mut e, "20l");
assert_eq!(e.cursor(), (0, 4));
}
#[test]
fn count_h_clamps_at_col_zero() {
let mut e = editor_with("abcde");
e.jump_cursor(0, 3);
run_keys(&mut e, "20h");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn dl_on_last_char_still_deletes_it() {
let mut e = editor_with("ab");
e.jump_cursor(0, 1);
run_keys(&mut e, "dl");
assert_eq!(e.buffer().lines()[0], "a");
}
#[test]
fn case_op_preserves_yank_register() {
let mut e = editor_with("target");
run_keys(&mut e, "yy");
let yank_before = e.yank().to_string();
run_keys(&mut e, "gUU");
assert_eq!(e.buffer().lines()[0], "TARGET");
assert_eq!(
e.yank(),
yank_before,
"case ops must preserve the yank buffer"
);
}
#[test]
fn dap_deletes_paragraph() {
let mut e = editor_with("a\nb\n\nc\nd");
run_keys(&mut e, "dap");
assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
}
#[test]
fn dit_deletes_inner_tag_content() {
let mut e = editor_with("<b>hello</b>");
e.jump_cursor(0, 4);
run_keys(&mut e, "dit");
assert_eq!(e.buffer().lines()[0], "<b></b>");
}
#[test]
fn dat_deletes_around_tag() {
let mut e = editor_with("hi <b>foo</b> bye");
e.jump_cursor(0, 6);
run_keys(&mut e, "dat");
assert_eq!(e.buffer().lines()[0], "hi bye");
}
#[test]
fn dit_picks_innermost_tag() {
let mut e = editor_with("<a><b>x</b></a>");
e.jump_cursor(0, 6);
run_keys(&mut e, "dit");
assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
}
#[test]
fn dat_innermost_tag_pair() {
let mut e = editor_with("<a><b>x</b></a>");
e.jump_cursor(0, 6);
run_keys(&mut e, "dat");
assert_eq!(e.buffer().lines()[0], "<a></a>");
}
#[test]
fn dit_outside_any_tag_no_op() {
let mut e = editor_with("plain text");
e.jump_cursor(0, 3);
run_keys(&mut e, "dit");
assert_eq!(e.buffer().lines()[0], "plain text");
}
#[test]
fn cit_changes_inner_tag_content() {
let mut e = editor_with("<b>hello</b>");
e.jump_cursor(0, 4);
run_keys(&mut e, "citNEW<Esc>");
assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
}
#[test]
fn cat_changes_around_tag() {
let mut e = editor_with("hi <b>foo</b> bye");
e.jump_cursor(0, 6);
run_keys(&mut e, "catBAR<Esc>");
assert_eq!(e.buffer().lines()[0], "hi BAR bye");
}
#[test]
fn yit_yanks_inner_tag_content() {
let mut e = editor_with("<b>hello</b>");
e.jump_cursor(0, 4);
run_keys(&mut e, "yit");
assert_eq!(e.registers().read('"').unwrap().text, "hello");
}
#[test]
fn yat_yanks_full_tag_pair() {
let mut e = editor_with("hi <b>foo</b> bye");
e.jump_cursor(0, 6);
run_keys(&mut e, "yat");
assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
}
#[test]
fn vit_visually_selects_inner_tag() {
let mut e = editor_with("<b>hello</b>");
e.jump_cursor(0, 4);
run_keys(&mut e, "vit");
assert_eq!(e.vim_mode(), VimMode::Visual);
run_keys(&mut e, "y");
assert_eq!(e.registers().read('"').unwrap().text, "hello");
}
#[test]
fn vat_visually_selects_around_tag() {
let mut e = editor_with("x<b>foo</b>y");
e.jump_cursor(0, 5);
run_keys(&mut e, "vat");
assert_eq!(e.vim_mode(), VimMode::Visual);
run_keys(&mut e, "y");
assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
}
#[test]
#[allow(non_snake_case)]
fn diW_deletes_inner_big_word() {
let mut e = editor_with("foo.bar baz");
e.jump_cursor(0, 2);
run_keys(&mut e, "diW");
assert_eq!(e.buffer().lines()[0], " baz");
}
#[test]
#[allow(non_snake_case)]
fn daW_deletes_around_big_word() {
let mut e = editor_with("foo.bar baz");
e.jump_cursor(0, 2);
run_keys(&mut e, "daW");
assert_eq!(e.buffer().lines()[0], "baz");
}
#[test]
fn di_double_quote_deletes_inside() {
let mut e = editor_with("a \"hello\" b");
e.jump_cursor(0, 4);
run_keys(&mut e, "di\"");
assert_eq!(e.buffer().lines()[0], "a \"\" b");
}
#[test]
fn da_double_quote_deletes_around() {
let mut e = editor_with("a \"hello\" b");
e.jump_cursor(0, 4);
run_keys(&mut e, "da\"");
assert_eq!(e.buffer().lines()[0], "a b");
}
#[test]
fn di_single_quote_deletes_inside() {
let mut e = editor_with("x 'foo' y");
e.jump_cursor(0, 4);
run_keys(&mut e, "di'");
assert_eq!(e.buffer().lines()[0], "x '' y");
}
#[test]
fn da_single_quote_deletes_around() {
let mut e = editor_with("x 'foo' y");
e.jump_cursor(0, 4);
run_keys(&mut e, "da'");
assert_eq!(e.buffer().lines()[0], "x y");
}
#[test]
fn di_backtick_deletes_inside() {
let mut e = editor_with("p `q` r");
e.jump_cursor(0, 3);
run_keys(&mut e, "di`");
assert_eq!(e.buffer().lines()[0], "p `` r");
}
#[test]
fn da_backtick_deletes_around() {
let mut e = editor_with("p `q` r");
e.jump_cursor(0, 3);
run_keys(&mut e, "da`");
assert_eq!(e.buffer().lines()[0], "p r");
}
#[test]
fn di_paren_deletes_inside() {
let mut e = editor_with("f(arg)");
e.jump_cursor(0, 3);
run_keys(&mut e, "di(");
assert_eq!(e.buffer().lines()[0], "f()");
}
#[test]
fn di_paren_alias_b_works() {
let mut e = editor_with("f(arg)");
e.jump_cursor(0, 3);
run_keys(&mut e, "dib");
assert_eq!(e.buffer().lines()[0], "f()");
}
#[test]
fn di_bracket_deletes_inside() {
let mut e = editor_with("a[b,c]d");
e.jump_cursor(0, 3);
run_keys(&mut e, "di[");
assert_eq!(e.buffer().lines()[0], "a[]d");
}
#[test]
fn da_bracket_deletes_around() {
let mut e = editor_with("a[b,c]d");
e.jump_cursor(0, 3);
run_keys(&mut e, "da[");
assert_eq!(e.buffer().lines()[0], "ad");
}
#[test]
fn di_brace_deletes_inside() {
let mut e = editor_with("x{y}z");
e.jump_cursor(0, 2);
run_keys(&mut e, "di{");
assert_eq!(e.buffer().lines()[0], "x{}z");
}
#[test]
fn da_brace_deletes_around() {
let mut e = editor_with("x{y}z");
e.jump_cursor(0, 2);
run_keys(&mut e, "da{");
assert_eq!(e.buffer().lines()[0], "xz");
}
#[test]
fn di_brace_alias_capital_b_works() {
let mut e = editor_with("x{y}z");
e.jump_cursor(0, 2);
run_keys(&mut e, "diB");
assert_eq!(e.buffer().lines()[0], "x{}z");
}
#[test]
fn di_angle_deletes_inside() {
let mut e = editor_with("p<q>r");
e.jump_cursor(0, 2);
run_keys(&mut e, "di<lt>");
assert_eq!(e.buffer().lines()[0], "p<>r");
}
#[test]
fn da_angle_deletes_around() {
let mut e = editor_with("p<q>r");
e.jump_cursor(0, 2);
run_keys(&mut e, "da<lt>");
assert_eq!(e.buffer().lines()[0], "pr");
}
#[test]
fn dip_deletes_inner_paragraph() {
let mut e = editor_with("a\nb\nc\n\nd");
e.jump_cursor(1, 0);
run_keys(&mut e, "dip");
assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
}
#[test]
fn sentence_motion_close_paren_jumps_forward() {
let mut e = editor_with("Alpha. Beta. Gamma.");
e.jump_cursor(0, 0);
run_keys(&mut e, ")");
assert_eq!(e.cursor(), (0, 7));
run_keys(&mut e, ")");
assert_eq!(e.cursor(), (0, 13));
}
#[test]
fn sentence_motion_open_paren_jumps_backward() {
let mut e = editor_with("Alpha. Beta. Gamma.");
e.jump_cursor(0, 13);
run_keys(&mut e, "(");
assert_eq!(e.cursor(), (0, 7));
run_keys(&mut e, "(");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn sentence_motion_count() {
let mut e = editor_with("A. B. C. D.");
e.jump_cursor(0, 0);
run_keys(&mut e, "3)");
assert_eq!(e.cursor(), (0, 9));
}
#[test]
fn dis_deletes_inner_sentence() {
let mut e = editor_with("First one. Second one. Third one.");
e.jump_cursor(0, 13);
run_keys(&mut e, "dis");
assert_eq!(e.buffer().lines()[0], "First one. Third one.");
}
#[test]
fn das_deletes_around_sentence_with_trailing_space() {
let mut e = editor_with("Alpha. Beta. Gamma.");
e.jump_cursor(0, 8);
run_keys(&mut e, "das");
assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
}
#[test]
fn dis_handles_double_terminator() {
let mut e = editor_with("Wow!? Next.");
e.jump_cursor(0, 1);
run_keys(&mut e, "dis");
assert_eq!(e.buffer().lines()[0], " Next.");
}
#[test]
fn dis_first_sentence_from_cursor_at_zero() {
let mut e = editor_with("Alpha. Beta.");
e.jump_cursor(0, 0);
run_keys(&mut e, "dis");
assert_eq!(e.buffer().lines()[0], " Beta.");
}
#[test]
fn yis_yanks_inner_sentence() {
let mut e = editor_with("Hello world. Bye.");
e.jump_cursor(0, 5);
run_keys(&mut e, "yis");
assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
}
#[test]
fn vis_visually_selects_inner_sentence() {
let mut e = editor_with("First. Second.");
e.jump_cursor(0, 1);
run_keys(&mut e, "vis");
assert_eq!(e.vim_mode(), VimMode::Visual);
run_keys(&mut e, "y");
assert_eq!(e.registers().read('"').unwrap().text, "First.");
}
#[test]
fn ciw_changes_inner_word() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 1);
run_keys(&mut e, "ciwHEY<Esc>");
assert_eq!(e.buffer().lines()[0], "HEY world");
}
#[test]
fn yiw_yanks_inner_word() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 1);
run_keys(&mut e, "yiw");
assert_eq!(e.registers().read('"').unwrap().text, "hello");
}
#[test]
fn viw_selects_inner_word() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 2);
run_keys(&mut e, "viw");
assert_eq!(e.vim_mode(), VimMode::Visual);
run_keys(&mut e, "y");
assert_eq!(e.registers().read('"').unwrap().text, "hello");
}
#[test]
fn ci_paren_changes_inside() {
let mut e = editor_with("f(old)");
e.jump_cursor(0, 3);
run_keys(&mut e, "ci(NEW<Esc>");
assert_eq!(e.buffer().lines()[0], "f(NEW)");
}
#[test]
fn yi_double_quote_yanks_inside() {
let mut e = editor_with("say \"hi there\" then");
e.jump_cursor(0, 6);
run_keys(&mut e, "yi\"");
assert_eq!(e.registers().read('"').unwrap().text, "hi there");
}
#[test]
fn vap_visual_selects_around_paragraph() {
let mut e = editor_with("a\nb\n\nc");
e.jump_cursor(0, 0);
run_keys(&mut e, "vap");
assert_eq!(e.vim_mode(), VimMode::VisualLine);
run_keys(&mut e, "y");
let text = e.registers().read('"').unwrap().text.clone();
assert!(text.starts_with("a\nb"));
}
#[test]
fn star_finds_next_occurrence() {
let mut e = editor_with("foo bar foo baz");
run_keys(&mut e, "*");
assert_eq!(e.cursor().1, 8);
}
#[test]
fn star_skips_substring_match() {
let mut e = editor_with("foo foobar baz");
run_keys(&mut e, "*");
assert_eq!(e.cursor().1, 0);
}
#[test]
fn g_star_matches_substring() {
let mut e = editor_with("foo foobar baz");
run_keys(&mut e, "g*");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn g_pound_matches_substring_backward() {
let mut e = editor_with("foo foobar baz foo");
run_keys(&mut e, "$b");
assert_eq!(e.cursor().1, 15);
run_keys(&mut e, "g#");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn n_repeats_last_search_forward() {
let mut e = editor_with("foo bar foo baz foo");
run_keys(&mut e, "/foo<CR>");
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "n");
assert_eq!(e.cursor().1, 16);
}
#[test]
fn shift_n_reverses_search() {
let mut e = editor_with("foo bar foo baz foo");
run_keys(&mut e, "/foo<CR>");
run_keys(&mut e, "n");
assert_eq!(e.cursor().1, 16);
run_keys(&mut e, "N");
assert_eq!(e.cursor().1, 8);
}
#[test]
fn n_noop_without_pattern() {
let mut e = editor_with("foo bar");
run_keys(&mut e, "n");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn visual_line_preserves_cursor_column() {
let mut e = editor_with("hello world\nanother one\nbye");
run_keys(&mut e, "lllll"); run_keys(&mut e, "V");
assert_eq!(e.vim_mode(), VimMode::VisualLine);
assert_eq!(e.cursor(), (0, 5));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 5));
}
#[test]
fn visual_line_yank_includes_trailing_newline() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "Vjy");
assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
}
#[test]
fn visual_line_yank_last_line_trailing_newline() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "jj");
run_keys(&mut e, "Vy");
assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
}
#[test]
fn yy_on_last_line_has_trailing_newline() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "jj");
run_keys(&mut e, "yy");
assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
}
#[test]
fn yy_in_middle_has_trailing_newline() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "j");
run_keys(&mut e, "yy");
assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
}
#[test]
fn di_single_quote() {
let mut e = editor_with("say 'hello world' now");
e.jump_cursor(0, 7);
run_keys(&mut e, "di'");
assert_eq!(e.buffer().lines()[0], "say '' now");
}
#[test]
fn da_single_quote() {
let mut e = editor_with("say 'hello' now");
e.jump_cursor(0, 7);
run_keys(&mut e, "da'");
assert_eq!(e.buffer().lines()[0], "say now");
}
#[test]
fn di_backtick() {
let mut e = editor_with("say `hi` now");
e.jump_cursor(0, 5);
run_keys(&mut e, "di`");
assert_eq!(e.buffer().lines()[0], "say `` now");
}
#[test]
fn di_brace() {
let mut e = editor_with("fn { a; b; c }");
e.jump_cursor(0, 7);
run_keys(&mut e, "di{");
assert_eq!(e.buffer().lines()[0], "fn {}");
}
#[test]
fn di_bracket() {
let mut e = editor_with("arr[1, 2, 3]");
e.jump_cursor(0, 5);
run_keys(&mut e, "di[");
assert_eq!(e.buffer().lines()[0], "arr[]");
}
#[test]
fn dab_deletes_around_paren() {
let mut e = editor_with("fn(a, b) + 1");
e.jump_cursor(0, 4);
run_keys(&mut e, "dab");
assert_eq!(e.buffer().lines()[0], "fn + 1");
}
#[test]
fn da_big_b_deletes_around_brace() {
let mut e = editor_with("x = {a: 1}");
e.jump_cursor(0, 6);
run_keys(&mut e, "daB");
assert_eq!(e.buffer().lines()[0], "x = ");
}
#[test]
fn di_big_w_deletes_bigword() {
let mut e = editor_with("foo-bar baz");
e.jump_cursor(0, 2);
run_keys(&mut e, "diW");
assert_eq!(e.buffer().lines()[0], " baz");
}
#[test]
fn visual_select_inner_word() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 2);
run_keys(&mut e, "viw");
assert_eq!(e.vim_mode(), VimMode::Visual);
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("hello"));
}
#[test]
fn visual_select_inner_quote() {
let mut e = editor_with("foo \"bar\" baz");
e.jump_cursor(0, 6);
run_keys(&mut e, "vi\"");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("bar"));
}
#[test]
fn visual_select_inner_paren() {
let mut e = editor_with("fn(a, b)");
e.jump_cursor(0, 4);
run_keys(&mut e, "vi(");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("a, b"));
}
#[test]
fn visual_select_outer_brace() {
let mut e = editor_with("{x}");
e.jump_cursor(0, 1);
run_keys(&mut e, "va{");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("{x}"));
}
#[test]
fn caw_changes_word_with_trailing_space() {
let mut e = editor_with("hello world");
run_keys(&mut e, "cawfoo<Esc>");
assert_eq!(e.buffer().lines()[0], "fooworld");
}
#[test]
fn visual_char_yank_preserves_raw_text() {
let mut e = editor_with("hello world");
run_keys(&mut e, "vllly");
assert_eq!(e.last_yank.as_deref(), Some("hell"));
}
#[test]
fn single_line_visual_line_selects_full_line_on_yank() {
let mut e = editor_with("hello world\nbye");
run_keys(&mut e, "V");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
}
#[test]
fn visual_line_extends_both_directions() {
let mut e = editor_with("aaa\nbbb\nccc\nddd");
run_keys(&mut e, "jjj"); run_keys(&mut e, "V");
assert_eq!(e.cursor(), (3, 0));
run_keys(&mut e, "k");
assert_eq!(e.cursor(), (2, 0));
run_keys(&mut e, "k");
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn visual_char_preserves_cursor_column() {
let mut e = editor_with("hello world");
run_keys(&mut e, "lllll"); run_keys(&mut e, "v");
assert_eq!(e.cursor(), (0, 5));
run_keys(&mut e, "ll");
assert_eq!(e.cursor(), (0, 7));
}
#[test]
fn visual_char_highlight_bounds_order() {
let mut e = editor_with("abcdef");
run_keys(&mut e, "lll"); run_keys(&mut e, "v");
run_keys(&mut e, "hh"); assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
}
#[test]
fn visual_line_highlight_bounds() {
let mut e = editor_with("a\nb\nc");
run_keys(&mut e, "V");
assert_eq!(e.line_highlight(), Some((0, 0)));
run_keys(&mut e, "j");
assert_eq!(e.line_highlight(), Some((0, 1)));
run_keys(&mut e, "j");
assert_eq!(e.line_highlight(), Some((0, 2)));
}
#[test]
fn h_moves_left() {
let mut e = editor_with("hello");
e.jump_cursor(0, 3);
run_keys(&mut e, "h");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn l_moves_right() {
let mut e = editor_with("hello");
run_keys(&mut e, "l");
assert_eq!(e.cursor(), (0, 1));
}
#[test]
fn k_moves_up() {
let mut e = editor_with("a\nb\nc");
e.jump_cursor(2, 0);
run_keys(&mut e, "k");
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn zero_moves_to_line_start() {
let mut e = editor_with(" hello");
run_keys(&mut e, "$");
run_keys(&mut e, "0");
assert_eq!(e.cursor().1, 0);
}
#[test]
fn caret_moves_to_first_non_blank() {
let mut e = editor_with(" hello");
run_keys(&mut e, "0");
run_keys(&mut e, "^");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn dollar_moves_to_last_char() {
let mut e = editor_with("hello");
run_keys(&mut e, "$");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn dollar_on_empty_line_stays_at_col_zero() {
let mut e = editor_with("");
run_keys(&mut e, "$");
assert_eq!(e.cursor().1, 0);
}
#[test]
fn w_jumps_to_next_word() {
let mut e = editor_with("foo bar baz");
run_keys(&mut e, "w");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn b_jumps_back_a_word() {
let mut e = editor_with("foo bar");
e.jump_cursor(0, 6);
run_keys(&mut e, "b");
assert_eq!(e.cursor().1, 4);
}
#[test]
fn e_jumps_to_word_end() {
let mut e = editor_with("foo bar");
run_keys(&mut e, "e");
assert_eq!(e.cursor().1, 2);
}
#[test]
fn d_dollar_deletes_to_eol() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 5);
run_keys(&mut e, "d$");
assert_eq!(e.buffer().lines()[0], "hello");
}
#[test]
fn d_zero_deletes_to_line_start() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 6);
run_keys(&mut e, "d0");
assert_eq!(e.buffer().lines()[0], "world");
}
#[test]
fn d_caret_deletes_to_first_non_blank() {
let mut e = editor_with(" hello");
e.jump_cursor(0, 6);
run_keys(&mut e, "d^");
assert_eq!(e.buffer().lines()[0], " llo");
}
#[test]
fn d_capital_g_deletes_to_end_of_file() {
let mut e = editor_with("a\nb\nc\nd");
e.jump_cursor(1, 0);
run_keys(&mut e, "dG");
assert_eq!(e.buffer().lines(), &["a".to_string()]);
}
#[test]
fn d_gg_deletes_to_start_of_file() {
let mut e = editor_with("a\nb\nc\nd");
e.jump_cursor(2, 0);
run_keys(&mut e, "dgg");
assert_eq!(e.buffer().lines(), &["d".to_string()]);
}
#[test]
fn cw_is_ce_quirk() {
let mut e = editor_with("foo bar");
run_keys(&mut e, "cwxyz<Esc>");
assert_eq!(e.buffer().lines()[0], "xyz bar");
}
#[test]
fn big_d_deletes_to_eol() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 5);
run_keys(&mut e, "D");
assert_eq!(e.buffer().lines()[0], "hello");
}
#[test]
fn big_c_deletes_to_eol_and_inserts() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 5);
run_keys(&mut e, "C!<Esc>");
assert_eq!(e.buffer().lines()[0], "hello!");
}
#[test]
fn j_joins_next_line_with_space() {
let mut e = editor_with("hello\nworld");
run_keys(&mut e, "J");
assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
}
#[test]
fn j_strips_leading_whitespace_on_join() {
let mut e = editor_with("hello\n world");
run_keys(&mut e, "J");
assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
}
#[test]
fn big_x_deletes_char_before_cursor() {
let mut e = editor_with("hello");
e.jump_cursor(0, 3);
run_keys(&mut e, "X");
assert_eq!(e.buffer().lines()[0], "helo");
}
#[test]
fn s_substitutes_char_and_enters_insert() {
let mut e = editor_with("hello");
run_keys(&mut e, "sX<Esc>");
assert_eq!(e.buffer().lines()[0], "Xello");
}
#[test]
fn count_x_deletes_many() {
let mut e = editor_with("abcdef");
run_keys(&mut e, "3x");
assert_eq!(e.buffer().lines()[0], "def");
}
#[test]
fn p_pastes_charwise_after_cursor() {
let mut e = editor_with("hello");
run_keys(&mut e, "yw");
run_keys(&mut e, "$p");
assert_eq!(e.buffer().lines()[0], "hellohello");
}
#[test]
fn capital_p_pastes_charwise_before_cursor() {
let mut e = editor_with("hello");
run_keys(&mut e, "v");
run_keys(&mut e, "l");
run_keys(&mut e, "y");
run_keys(&mut e, "$P");
assert_eq!(e.buffer().lines()[0], "hellheo");
}
#[test]
fn p_pastes_linewise_below() {
let mut e = editor_with("one\ntwo\nthree");
run_keys(&mut e, "yy");
run_keys(&mut e, "p");
assert_eq!(
e.buffer().lines(),
&[
"one".to_string(),
"one".to_string(),
"two".to_string(),
"three".to_string()
]
);
}
#[test]
fn capital_p_pastes_linewise_above() {
let mut e = editor_with("one\ntwo");
e.jump_cursor(1, 0);
run_keys(&mut e, "yy");
run_keys(&mut e, "P");
assert_eq!(
e.buffer().lines(),
&["one".to_string(), "two".to_string(), "two".to_string()]
);
}
#[test]
fn hash_finds_previous_occurrence() {
let mut e = editor_with("foo bar foo baz foo");
e.jump_cursor(0, 16);
run_keys(&mut e, "#");
assert_eq!(e.cursor().1, 8);
}
#[test]
fn visual_line_delete_removes_full_lines() {
let mut e = editor_with("a\nb\nc\nd");
run_keys(&mut e, "Vjd");
assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
}
#[test]
fn visual_line_change_leaves_blank_line() {
let mut e = editor_with("a\nb\nc");
run_keys(&mut e, "Vjc");
assert_eq!(e.vim_mode(), VimMode::Insert);
run_keys(&mut e, "X<Esc>");
assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
}
#[test]
fn cc_leaves_blank_line() {
let mut e = editor_with("a\nb\nc");
e.jump_cursor(1, 0);
run_keys(&mut e, "ccX<Esc>");
assert_eq!(
e.buffer().lines(),
&["a".to_string(), "X".to_string(), "c".to_string()]
);
}
#[test]
fn big_w_skips_hyphens() {
let mut e = editor_with("foo-bar baz");
run_keys(&mut e, "W");
assert_eq!(e.cursor().1, 8);
}
#[test]
fn big_w_crosses_lines() {
let mut e = editor_with("foo-bar\nbaz-qux");
run_keys(&mut e, "W");
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn big_b_skips_hyphens() {
let mut e = editor_with("foo-bar baz");
e.jump_cursor(0, 9);
run_keys(&mut e, "B");
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "B");
assert_eq!(e.cursor().1, 0);
}
#[test]
fn big_e_jumps_to_big_word_end() {
let mut e = editor_with("foo-bar baz");
run_keys(&mut e, "E");
assert_eq!(e.cursor().1, 6);
run_keys(&mut e, "E");
assert_eq!(e.cursor().1, 10);
}
#[test]
fn dw_with_big_word_variant() {
let mut e = editor_with("foo-bar baz");
run_keys(&mut e, "dW");
assert_eq!(e.buffer().lines()[0], "baz");
}
#[test]
fn insert_ctrl_w_deletes_word_back() {
let mut e = editor_with("");
run_keys(&mut e, "i");
for c in "hello world".chars() {
e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
run_keys(&mut e, "<C-w>");
assert_eq!(e.buffer().lines()[0], "hello ");
}
#[test]
fn insert_ctrl_w_at_col0_joins_with_prev_word() {
let mut e = editor_with("hello\nworld");
e.jump_cursor(1, 0);
run_keys(&mut e, "i");
e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn insert_ctrl_w_at_col0_keeps_prefix_words() {
let mut e = editor_with("foo bar\nbaz");
e.jump_cursor(1, 0);
run_keys(&mut e, "i");
e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
assert_eq!(e.cursor(), (0, 4));
}
#[test]
fn insert_ctrl_u_deletes_to_line_start() {
let mut e = editor_with("");
run_keys(&mut e, "i");
for c in "hello world".chars() {
e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
run_keys(&mut e, "<C-u>");
assert_eq!(e.buffer().lines()[0], "");
}
#[test]
fn insert_ctrl_o_runs_one_normal_command() {
let mut e = editor_with("hello world");
run_keys(&mut e, "A");
assert_eq!(e.vim_mode(), VimMode::Insert);
e.jump_cursor(0, 0);
run_keys(&mut e, "<C-o>");
assert_eq!(e.vim_mode(), VimMode::Normal);
run_keys(&mut e, "dw");
assert_eq!(e.vim_mode(), VimMode::Insert);
assert_eq!(e.buffer().lines()[0], "world");
}
#[test]
fn j_through_empty_line_preserves_column() {
let mut e = editor_with("hello world\n\nanother line");
run_keys(&mut e, "llllll");
assert_eq!(e.cursor(), (0, 6));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 0));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (2, 6));
}
#[test]
fn j_through_shorter_line_preserves_column() {
let mut e = editor_with("hello world\nhi\nanother line");
run_keys(&mut e, "lllllll"); run_keys(&mut e, "j"); assert_eq!(e.cursor(), (1, 1));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (2, 7));
}
#[test]
fn esc_from_insert_sticky_matches_visible_cursor() {
let mut e = editor_with(" this is a line\n another one of a similar size");
e.jump_cursor(0, 12);
run_keys(&mut e, "I");
assert_eq!(e.cursor(), (0, 4));
run_keys(&mut e, "X<Esc>");
assert_eq!(e.cursor(), (0, 4));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 4));
}
#[test]
fn esc_from_insert_sticky_tracks_inserted_chars() {
let mut e = editor_with("xxxxxxx\nyyyyyyy");
run_keys(&mut e, "i");
run_keys(&mut e, "abc<Esc>");
assert_eq!(e.cursor(), (0, 2));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 2));
}
#[test]
fn esc_from_insert_sticky_tracks_arrow_nav() {
let mut e = editor_with("xxxxxx\nyyyyyy");
run_keys(&mut e, "i");
run_keys(&mut e, "abc");
for _ in 0..2 {
e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
}
run_keys(&mut e, "<Esc>");
assert_eq!(e.cursor(), (0, 0));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 0));
}
#[test]
fn esc_from_insert_at_col_14_followed_by_j() {
let line = "x".repeat(30);
let buf = format!("{line}\n{line}");
let mut e = editor_with(&buf);
e.jump_cursor(0, 14);
run_keys(&mut e, "i");
for c in "test ".chars() {
e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
run_keys(&mut e, "<Esc>");
assert_eq!(e.cursor(), (0, 18));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (1, 18));
}
#[test]
fn linewise_paste_resets_sticky_column() {
let mut e = editor_with(" hello\naaaaaaaa\nbye");
run_keys(&mut e, "llllll"); run_keys(&mut e, "yy");
run_keys(&mut e, "j"); run_keys(&mut e, "p"); assert_eq!(e.cursor(), (2, 4));
run_keys(&mut e, "j");
assert_eq!(e.cursor(), (3, 2));
}
#[test]
fn horizontal_motion_resyncs_sticky_column() {
let mut e = editor_with("hello world\n\nanother line");
run_keys(&mut e, "llllll"); run_keys(&mut e, "hhh"); run_keys(&mut e, "jj");
assert_eq!(e.cursor(), (2, 3));
}
#[test]
fn ctrl_v_enters_visual_block() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "<C-v>");
assert_eq!(e.vim_mode(), VimMode::VisualBlock);
}
#[test]
fn visual_block_esc_returns_to_normal() {
let mut e = editor_with("aaa\nbbb\nccc");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "<Esc>");
assert_eq!(e.vim_mode(), VimMode::Normal);
}
#[test]
fn visual_block_delete_removes_column_range() {
let mut e = editor_with("hello\nworld\nhappy");
run_keys(&mut e, "l");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "ll");
run_keys(&mut e, "d");
assert_eq!(
e.buffer().lines(),
&["ho".to_string(), "wd".to_string(), "hy".to_string()]
);
}
#[test]
fn visual_block_yank_joins_with_newlines() {
let mut e = editor_with("hello\nworld\nhappy");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "ll");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
}
#[test]
fn visual_block_replace_fills_block() {
let mut e = editor_with("hello\nworld\nhappy");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "ll");
run_keys(&mut e, "rx");
assert_eq!(
e.buffer().lines(),
&[
"xxxlo".to_string(),
"xxxld".to_string(),
"xxxpy".to_string()
]
);
}
#[test]
fn visual_block_insert_repeats_across_rows() {
let mut e = editor_with("hello\nworld\nhappy");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "I");
run_keys(&mut e, "# <Esc>");
assert_eq!(
e.buffer().lines(),
&[
"# hello".to_string(),
"# world".to_string(),
"# happy".to_string()
]
);
}
#[test]
fn block_highlight_returns_none_outside_block_mode() {
let mut e = editor_with("abc");
assert!(e.block_highlight().is_none());
run_keys(&mut e, "v");
assert!(e.block_highlight().is_none());
run_keys(&mut e, "<Esc>V");
assert!(e.block_highlight().is_none());
}
#[test]
fn block_highlight_bounds_track_anchor_and_cursor() {
let mut e = editor_with("aaaa\nbbbb\ncccc");
run_keys(&mut e, "ll"); run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jh"); assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
}
#[test]
fn visual_block_delete_handles_short_lines() {
let mut e = editor_with("hello\nhi\nworld");
run_keys(&mut e, "l"); run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jjll"); run_keys(&mut e, "d");
assert_eq!(
e.buffer().lines(),
&["ho".to_string(), "h".to_string(), "wd".to_string()]
);
}
#[test]
fn visual_block_yank_pads_short_lines_with_empties() {
let mut e = editor_with("hello\nhi\nworld");
run_keys(&mut e, "l");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jjll");
run_keys(&mut e, "y");
assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
}
#[test]
fn visual_block_replace_skips_past_eol() {
let mut e = editor_with("ab\ncd\nef");
run_keys(&mut e, "l");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jjllllll");
run_keys(&mut e, "rX");
assert_eq!(
e.buffer().lines(),
&["aX".to_string(), "cX".to_string(), "eX".to_string()]
);
}
#[test]
fn visual_block_with_empty_line_in_middle() {
let mut e = editor_with("abcd\n\nefgh");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jjll"); run_keys(&mut e, "d");
assert_eq!(
e.buffer().lines(),
&["d".to_string(), "".to_string(), "h".to_string()]
);
}
#[test]
fn block_insert_pads_empty_lines_to_block_column() {
let mut e = editor_with("this is a line\n\nthis is a line");
e.jump_cursor(0, 3);
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "I");
run_keys(&mut e, "XX<Esc>");
assert_eq!(
e.buffer().lines(),
&[
"thiXXs is a line".to_string(),
" XX".to_string(),
"thiXXs is a line".to_string()
]
);
}
#[test]
fn block_insert_pads_short_lines_to_block_column() {
let mut e = editor_with("aaaaa\nbb\naaaaa");
e.jump_cursor(0, 3);
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "I");
run_keys(&mut e, "Y<Esc>");
assert_eq!(
e.buffer().lines(),
&[
"aaaYaa".to_string(),
"bb Y".to_string(),
"aaaYaa".to_string()
]
);
}
#[test]
fn visual_block_append_repeats_across_rows() {
let mut e = editor_with("foo\nbar\nbaz");
run_keys(&mut e, "<C-v>");
run_keys(&mut e, "jj");
run_keys(&mut e, "A");
run_keys(&mut e, "!<Esc>");
assert_eq!(
e.buffer().lines(),
&["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
);
}
#[test]
fn slash_opens_forward_search_prompt() {
let mut e = editor_with("hello world");
run_keys(&mut e, "/");
let p = e.search_prompt().expect("prompt should be active");
assert!(p.text.is_empty());
assert!(p.forward);
}
#[test]
fn question_opens_backward_search_prompt() {
let mut e = editor_with("hello world");
run_keys(&mut e, "?");
let p = e.search_prompt().expect("prompt should be active");
assert!(!p.forward);
}
#[test]
fn search_prompt_typing_updates_pattern_live() {
let mut e = editor_with("foo bar\nbaz");
run_keys(&mut e, "/bar");
assert_eq!(e.search_prompt().unwrap().text, "bar");
assert!(e.buffer().search_pattern().is_some());
}
#[test]
fn search_prompt_backspace_and_enter() {
let mut e = editor_with("hello world\nagain");
run_keys(&mut e, "/worlx");
e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(e.search_prompt().unwrap().text, "worl");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(e.search_prompt().is_none());
assert_eq!(e.last_search(), Some("worl"));
assert_eq!(e.cursor(), (0, 6));
}
#[test]
fn empty_search_prompt_enter_repeats_last_search() {
let mut e = editor_with("foo bar foo baz foo");
run_keys(&mut e, "/foo");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "/");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor().1, 16);
assert_eq!(e.last_search(), Some("foo"));
}
#[test]
fn search_history_records_committed_patterns() {
let mut e = editor_with("alpha beta gamma");
run_keys(&mut e, "/alpha<CR>");
run_keys(&mut e, "/beta<CR>");
let history = e.vim.search_history.clone();
assert_eq!(history, vec!["alpha", "beta"]);
}
#[test]
fn search_history_dedupes_consecutive_repeats() {
let mut e = editor_with("foo bar foo");
run_keys(&mut e, "/foo<CR>");
run_keys(&mut e, "/foo<CR>");
run_keys(&mut e, "/bar<CR>");
run_keys(&mut e, "/bar<CR>");
assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
}
#[test]
fn ctrl_p_walks_history_backward() {
let mut e = editor_with("alpha beta gamma");
run_keys(&mut e, "/alpha<CR>");
run_keys(&mut e, "/beta<CR>");
run_keys(&mut e, "/");
assert_eq!(e.search_prompt().unwrap().text, "");
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "beta");
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "alpha");
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "alpha");
}
#[test]
fn ctrl_n_walks_history_forward_after_ctrl_p() {
let mut e = editor_with("a b c");
run_keys(&mut e, "/a<CR>");
run_keys(&mut e, "/b<CR>");
run_keys(&mut e, "/c<CR>");
run_keys(&mut e, "/");
for _ in 0..3 {
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
}
assert_eq!(e.search_prompt().unwrap().text, "a");
e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "b");
e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "c");
e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "c");
}
#[test]
fn typing_after_history_walk_resets_cursor() {
let mut e = editor_with("foo");
run_keys(&mut e, "/foo<CR>");
run_keys(&mut e, "/");
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "foo");
e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(e.search_prompt().unwrap().text, "foox");
e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(e.search_prompt().unwrap().text, "foo");
}
#[test]
fn empty_backward_search_prompt_enter_repeats_last_search() {
let mut e = editor_with("foo bar foo baz foo");
run_keys(&mut e, "/foo");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "?");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor().1, 0);
assert_eq!(e.last_search(), Some("foo"));
}
#[test]
fn search_prompt_esc_cancels_but_keeps_last_search() {
let mut e = editor_with("foo bar\nbaz");
run_keys(&mut e, "/bar");
e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(e.search_prompt().is_none());
assert_eq!(e.last_search(), Some("bar"));
}
#[test]
fn search_then_n_and_shift_n_navigate() {
let mut e = editor_with("foo bar foo baz foo");
run_keys(&mut e, "/foo");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "n");
assert_eq!(e.cursor().1, 16);
run_keys(&mut e, "N");
assert_eq!(e.cursor().1, 8);
}
#[test]
fn question_mark_searches_backward_on_enter() {
let mut e = editor_with("foo bar foo baz");
e.jump_cursor(0, 10);
run_keys(&mut e, "?foo");
e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(e.cursor(), (0, 8));
}
#[test]
fn big_y_yanks_to_end_of_line() {
let mut e = editor_with("hello world");
e.jump_cursor(0, 6);
run_keys(&mut e, "Y");
assert_eq!(e.last_yank.as_deref(), Some("world"));
}
#[test]
fn big_y_from_line_start_yanks_full_line() {
let mut e = editor_with("hello world");
run_keys(&mut e, "Y");
assert_eq!(e.last_yank.as_deref(), Some("hello world"));
}
#[test]
fn gj_joins_without_inserting_space() {
let mut e = editor_with("hello\n world");
run_keys(&mut e, "gJ");
assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
}
#[test]
fn gj_noop_on_last_line() {
let mut e = editor_with("only");
run_keys(&mut e, "gJ");
assert_eq!(e.buffer().lines(), &["only".to_string()]);
}
#[test]
fn ge_jumps_to_previous_word_end() {
let mut e = editor_with("foo bar baz");
e.jump_cursor(0, 5);
run_keys(&mut e, "ge");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn ge_respects_word_class() {
let mut e = editor_with("foo-bar baz");
e.jump_cursor(0, 5);
run_keys(&mut e, "ge");
assert_eq!(e.cursor(), (0, 3));
}
#[test]
fn big_ge_treats_hyphens_as_part_of_word() {
let mut e = editor_with("foo-bar baz");
e.jump_cursor(0, 10);
run_keys(&mut e, "gE");
assert_eq!(e.cursor(), (0, 6));
}
#[test]
fn ge_crosses_line_boundary() {
let mut e = editor_with("foo\nbar");
e.jump_cursor(1, 0);
run_keys(&mut e, "ge");
assert_eq!(e.cursor(), (0, 2));
}
#[test]
fn dge_deletes_to_end_of_previous_word() {
let mut e = editor_with("foo bar baz");
e.jump_cursor(0, 8);
run_keys(&mut e, "dge");
assert_eq!(e.buffer().lines()[0], "foo baaz");
}
#[test]
fn ctrl_scroll_keys_do_not_panic() {
let mut e = editor_with(
(0..50)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n")
.as_str(),
);
run_keys(&mut e, "<C-f>");
run_keys(&mut e, "<C-b>");
assert!(!e.buffer().lines().is_empty());
}
#[test]
fn count_insert_with_arrow_nav_does_not_leak_rows() {
let mut e = Editor::new(KeybindingMode::Vim);
e.set_content("row0\nrow1\nrow2");
run_keys(&mut e, "3iX<Down><Esc>");
assert!(e.buffer().lines()[0].contains('X'));
assert!(
!e.buffer().lines()[1].contains("row0"),
"row1 leaked row0 contents: {:?}",
e.buffer().lines()[1]
);
assert_eq!(e.buffer().lines().len(), 3);
}
fn editor_with_rows(n: usize, viewport: u16) -> Editor<'static> {
let mut e = Editor::new(KeybindingMode::Vim);
let body = (0..n)
.map(|i| format!(" line{}", i))
.collect::<Vec<_>>()
.join("\n");
e.set_content(&body);
e.set_viewport_height(viewport);
e
}
#[test]
fn ctrl_d_moves_cursor_half_page_down() {
let mut e = editor_with_rows(100, 20);
run_keys(&mut e, "<C-d>");
assert_eq!(e.cursor().0, 10);
}
fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor<'static> {
let mut e = Editor::new(KeybindingMode::Vim);
e.set_content(&lines.join("\n"));
e.set_viewport_height(viewport);
let v = e.buffer_mut().viewport_mut();
v.height = viewport;
v.width = text_width;
v.text_width = text_width;
v.wrap = hjkl_buffer::Wrap::Char;
e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
e
}
#[test]
fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
let lines = ["aaaabbbbcccc"; 10];
let mut e = editor_with_wrap_lines(&lines, 12, 4);
e.jump_cursor(4, 0);
e.ensure_cursor_in_scrolloff();
let csr = e.buffer().cursor_screen_row().unwrap();
assert!(csr <= 6, "csr={csr}");
}
#[test]
fn scrolloff_wrap_keeps_cursor_off_top_edge() {
let lines = ["aaaabbbbcccc"; 10];
let mut e = editor_with_wrap_lines(&lines, 12, 4);
e.jump_cursor(7, 0);
e.ensure_cursor_in_scrolloff();
e.jump_cursor(2, 0);
e.ensure_cursor_in_scrolloff();
let csr = e.buffer().cursor_screen_row().unwrap();
assert!(csr >= 5, "csr={csr}");
}
#[test]
fn scrolloff_wrap_clamps_top_at_buffer_end() {
let lines = ["aaaabbbbcccc"; 5];
let mut e = editor_with_wrap_lines(&lines, 12, 4);
e.jump_cursor(4, 11);
e.ensure_cursor_in_scrolloff();
let top = e.buffer().viewport().top_row;
assert_eq!(top, 1);
}
#[test]
fn ctrl_u_moves_cursor_half_page_up() {
let mut e = editor_with_rows(100, 20);
e.jump_cursor(50, 0);
run_keys(&mut e, "<C-u>");
assert_eq!(e.cursor().0, 40);
}
#[test]
fn ctrl_f_moves_cursor_full_page_down() {
let mut e = editor_with_rows(100, 20);
run_keys(&mut e, "<C-f>");
assert_eq!(e.cursor().0, 18);
}
#[test]
fn ctrl_b_moves_cursor_full_page_up() {
let mut e = editor_with_rows(100, 20);
e.jump_cursor(50, 0);
run_keys(&mut e, "<C-b>");
assert_eq!(e.cursor().0, 32);
}
#[test]
fn ctrl_d_lands_on_first_non_blank() {
let mut e = editor_with_rows(100, 20);
run_keys(&mut e, "<C-d>");
assert_eq!(e.cursor().1, 2);
}
#[test]
fn ctrl_d_clamps_at_end_of_buffer() {
let mut e = editor_with_rows(5, 20);
run_keys(&mut e, "<C-d>");
assert_eq!(e.cursor().0, 4);
}
#[test]
fn capital_h_jumps_to_viewport_top() {
let mut e = editor_with_rows(100, 10);
e.jump_cursor(50, 0);
e.set_viewport_top(45);
let top = e.buffer().viewport().top_row;
run_keys(&mut e, "H");
assert_eq!(e.cursor().0, top);
assert_eq!(e.cursor().1, 2);
}
#[test]
fn capital_l_jumps_to_viewport_bottom() {
let mut e = editor_with_rows(100, 10);
e.jump_cursor(50, 0);
e.set_viewport_top(45);
let top = e.buffer().viewport().top_row;
run_keys(&mut e, "L");
assert_eq!(e.cursor().0, top + 9);
}
#[test]
fn capital_m_jumps_to_viewport_middle() {
let mut e = editor_with_rows(100, 10);
e.jump_cursor(50, 0);
e.set_viewport_top(45);
let top = e.buffer().viewport().top_row;
run_keys(&mut e, "M");
assert_eq!(e.cursor().0, top + 4);
}
#[test]
fn g_capital_m_lands_at_line_midpoint() {
let mut e = editor_with("hello world!"); run_keys(&mut e, "gM");
assert_eq!(e.cursor(), (0, 6));
}
#[test]
fn g_capital_m_on_empty_line_stays_at_zero() {
let mut e = editor_with("");
run_keys(&mut e, "gM");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn g_capital_m_uses_current_line_only() {
let mut e = editor_with("a\nlonglongline"); e.jump_cursor(1, 0);
run_keys(&mut e, "gM");
assert_eq!(e.cursor(), (1, 6));
}
#[test]
fn capital_h_count_offsets_from_top() {
let mut e = editor_with_rows(100, 10);
e.jump_cursor(50, 0);
e.set_viewport_top(45);
let top = e.buffer().viewport().top_row;
run_keys(&mut e, "3H");
assert_eq!(e.cursor().0, top + 2);
}
#[test]
fn ctrl_o_returns_to_pre_g_position() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 2);
run_keys(&mut e, "G");
assert_eq!(e.cursor().0, 49);
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), (5, 2));
}
#[test]
fn ctrl_i_redoes_jump_after_ctrl_o() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 2);
run_keys(&mut e, "G");
let post = e.cursor();
run_keys(&mut e, "<C-o>");
run_keys(&mut e, "<C-i>");
assert_eq!(e.cursor(), post);
}
#[test]
fn new_jump_clears_forward_stack() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 2);
run_keys(&mut e, "G");
run_keys(&mut e, "<C-o>");
run_keys(&mut e, "gg");
run_keys(&mut e, "<C-i>");
assert_eq!(e.cursor().0, 0);
}
#[test]
fn ctrl_o_on_empty_stack_is_noop() {
let mut e = editor_with_rows(10, 20);
e.jump_cursor(3, 1);
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), (3, 1));
}
#[test]
fn asterisk_search_pushes_jump() {
let mut e = editor_with("foo bar\nbaz foo end");
e.jump_cursor(0, 0);
run_keys(&mut e, "*");
let after = e.cursor();
assert_ne!(after, (0, 0));
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn h_viewport_jump_is_recorded() {
let mut e = editor_with_rows(100, 10);
e.jump_cursor(50, 0);
e.set_viewport_top(45);
let pre = e.cursor();
run_keys(&mut e, "H");
assert_ne!(e.cursor(), pre);
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), pre);
}
#[test]
fn j_k_motion_does_not_push_jump() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 0);
run_keys(&mut e, "jjj");
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor().0, 8);
}
#[test]
fn jumplist_caps_at_100() {
let mut e = editor_with_rows(200, 20);
for i in 0..101 {
e.jump_cursor(i, 0);
run_keys(&mut e, "G");
}
assert!(e.vim.jump_back.len() <= 100);
}
#[test]
fn tab_acts_as_ctrl_i() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 2);
run_keys(&mut e, "G");
let post = e.cursor();
run_keys(&mut e, "<C-o>");
e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(e.cursor(), post);
}
#[test]
fn ma_then_backtick_a_jumps_exact() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 3);
run_keys(&mut e, "ma");
e.jump_cursor(20, 0);
run_keys(&mut e, "`a");
assert_eq!(e.cursor(), (5, 3));
}
#[test]
fn ma_then_apostrophe_a_lands_on_first_non_blank() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 6);
run_keys(&mut e, "ma");
e.jump_cursor(30, 4);
run_keys(&mut e, "'a");
assert_eq!(e.cursor(), (5, 2));
}
#[test]
fn goto_mark_pushes_jumplist() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(10, 2);
run_keys(&mut e, "mz");
e.jump_cursor(3, 0);
run_keys(&mut e, "`z");
assert_eq!(e.cursor(), (10, 2));
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), (3, 0));
}
#[test]
fn goto_missing_mark_is_noop() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(3, 1);
run_keys(&mut e, "`q");
assert_eq!(e.cursor(), (3, 1));
}
#[test]
fn uppercase_mark_letter_ignored() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(5, 3);
run_keys(&mut e, "mA");
assert!(e.vim.marks.is_empty());
}
#[test]
fn mark_survives_document_shrink_via_clamp() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(40, 4);
run_keys(&mut e, "mx");
e.set_content("a\nb\nc\nd\ne");
run_keys(&mut e, "`x");
let (r, _) = e.cursor();
assert!(r <= 4);
}
#[test]
fn g_semicolon_walks_back_through_edits() {
let mut e = editor_with("alpha\nbeta\ngamma");
e.jump_cursor(0, 0);
run_keys(&mut e, "iX<Esc>");
e.jump_cursor(2, 0);
run_keys(&mut e, "iY<Esc>");
run_keys(&mut e, "g;");
assert_eq!(e.cursor(), (2, 1));
run_keys(&mut e, "g;");
assert_eq!(e.cursor(), (0, 1));
run_keys(&mut e, "g;");
assert_eq!(e.cursor(), (0, 1));
}
#[test]
fn g_comma_walks_forward_after_g_semicolon() {
let mut e = editor_with("a\nb\nc");
e.jump_cursor(0, 0);
run_keys(&mut e, "iX<Esc>");
e.jump_cursor(2, 0);
run_keys(&mut e, "iY<Esc>");
run_keys(&mut e, "g;");
run_keys(&mut e, "g;");
assert_eq!(e.cursor(), (0, 1));
run_keys(&mut e, "g,");
assert_eq!(e.cursor(), (2, 1));
}
#[test]
fn new_edit_during_walk_trims_forward_entries() {
let mut e = editor_with("a\nb\nc\nd");
e.jump_cursor(0, 0);
run_keys(&mut e, "iX<Esc>"); e.jump_cursor(2, 0);
run_keys(&mut e, "iY<Esc>"); run_keys(&mut e, "g;");
run_keys(&mut e, "g;");
assert_eq!(e.cursor(), (0, 1));
run_keys(&mut e, "iZ<Esc>");
run_keys(&mut e, "g,");
assert_ne!(e.cursor(), (2, 1));
}
#[test]
fn capital_mark_set_and_jump() {
let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
e.jump_cursor(2, 1);
run_keys(&mut e, "mA");
e.jump_cursor(0, 0);
run_keys(&mut e, "'A");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn capital_mark_survives_set_content() {
let mut e = editor_with("first buffer line\nsecond");
e.jump_cursor(1, 3);
run_keys(&mut e, "mA");
e.set_content("totally different content\non many\nrows of text");
e.jump_cursor(0, 0);
run_keys(&mut e, "'A");
assert_eq!(e.cursor().0, 1);
}
#[test]
fn capital_mark_shifts_with_edit() {
let mut e = editor_with("a\nb\nc\nd");
e.jump_cursor(3, 0);
run_keys(&mut e, "mA");
e.jump_cursor(0, 0);
run_keys(&mut e, "dd");
e.jump_cursor(0, 0);
run_keys(&mut e, "'A");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn mark_below_delete_shifts_up() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(3, 0);
run_keys(&mut e, "ma");
e.jump_cursor(0, 0);
run_keys(&mut e, "dd");
e.jump_cursor(0, 0);
run_keys(&mut e, "'a");
assert_eq!(e.cursor().0, 2);
assert_eq!(e.buffer().line(2).unwrap(), "d");
}
#[test]
fn mark_on_deleted_row_is_dropped() {
let mut e = editor_with("a\nb\nc\nd");
e.jump_cursor(1, 0);
run_keys(&mut e, "ma");
run_keys(&mut e, "dd");
e.jump_cursor(2, 0);
run_keys(&mut e, "'a");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn mark_above_edit_unchanged() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(0, 0);
run_keys(&mut e, "ma");
e.jump_cursor(3, 0);
run_keys(&mut e, "dd");
e.jump_cursor(2, 0);
run_keys(&mut e, "'a");
assert_eq!(e.cursor().0, 0);
}
#[test]
fn mark_shifts_down_after_insert() {
let mut e = editor_with("a\nb\nc");
e.jump_cursor(2, 0);
run_keys(&mut e, "ma");
e.jump_cursor(0, 0);
run_keys(&mut e, "Onew<Esc>");
e.jump_cursor(0, 0);
run_keys(&mut e, "'a");
assert_eq!(e.cursor().0, 3);
assert_eq!(e.buffer().line(3).unwrap(), "c");
}
#[test]
fn forward_search_commit_pushes_jump() {
let mut e = editor_with("alpha beta\nfoo target end\nmore");
e.jump_cursor(0, 0);
run_keys(&mut e, "/target<CR>");
assert_ne!(e.cursor(), (0, 0));
run_keys(&mut e, "<C-o>");
assert_eq!(e.cursor(), (0, 0));
}
#[test]
fn search_commit_no_match_does_not_push_jump() {
let mut e = editor_with("alpha beta\nfoo end");
e.jump_cursor(0, 3);
let pre_len = e.vim.jump_back.len();
run_keys(&mut e, "/zzznotfound<CR>");
assert_eq!(e.vim.jump_back.len(), pre_len);
}
#[test]
fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
let mut e = editor_with("hello world");
run_keys(&mut e, "lll");
let (row, col) = e.cursor();
assert_eq!(e.buffer.cursor().row, row);
assert_eq!(e.buffer.cursor().col, col);
}
#[test]
fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
let mut e = editor_with("aaaa\nbbbb\ncccc");
run_keys(&mut e, "jj");
let (row, col) = e.cursor();
assert_eq!(e.buffer.cursor().row, row);
assert_eq!(e.buffer.cursor().col, col);
}
#[test]
fn buffer_cursor_mirrors_textarea_after_word_motion() {
let mut e = editor_with("foo bar baz");
run_keys(&mut e, "ww");
let (row, col) = e.cursor();
assert_eq!(e.buffer.cursor().row, row);
assert_eq!(e.buffer.cursor().col, col);
}
#[test]
fn buffer_cursor_mirrors_textarea_after_jump_motion() {
let mut e = editor_with("a\nb\nc\nd\ne");
run_keys(&mut e, "G");
let (row, col) = e.cursor();
assert_eq!(e.buffer.cursor().row, row);
assert_eq!(e.buffer.cursor().col, col);
}
#[test]
fn buffer_sticky_col_mirrors_vim_state() {
let mut e = editor_with("longline\nhi\nlongline");
run_keys(&mut e, "fl");
run_keys(&mut e, "j");
assert_eq!(e.buffer.sticky_col(), e.vim.sticky_col);
}
#[test]
fn buffer_content_mirrors_textarea_after_insert() {
let mut e = editor_with("hello");
run_keys(&mut e, "iXYZ<Esc>");
let text = e.buffer().lines().join("\n");
assert_eq!(e.buffer.as_string(), text);
}
#[test]
fn buffer_content_mirrors_textarea_after_delete() {
let mut e = editor_with("alpha bravo charlie");
run_keys(&mut e, "dw");
let text = e.buffer().lines().join("\n");
assert_eq!(e.buffer.as_string(), text);
}
#[test]
fn buffer_content_mirrors_textarea_after_dd() {
let mut e = editor_with("a\nb\nc\nd");
run_keys(&mut e, "jdd");
let text = e.buffer().lines().join("\n");
assert_eq!(e.buffer.as_string(), text);
}
#[test]
fn buffer_content_mirrors_textarea_after_open_line() {
let mut e = editor_with("foo\nbar");
run_keys(&mut e, "oNEW<Esc>");
let text = e.buffer().lines().join("\n");
assert_eq!(e.buffer.as_string(), text);
}
#[test]
fn buffer_content_mirrors_textarea_after_paste() {
let mut e = editor_with("hello");
run_keys(&mut e, "yy");
run_keys(&mut e, "p");
let text = e.buffer().lines().join("\n");
assert_eq!(e.buffer.as_string(), text);
}
#[test]
fn buffer_selection_none_in_normal_mode() {
let e = editor_with("foo bar");
assert!(e.buffer_selection().is_none());
}
#[test]
fn buffer_selection_char_in_visual_mode() {
use hjkl_buffer::{Position, Selection};
let mut e = editor_with("hello world");
run_keys(&mut e, "vlll");
assert_eq!(
e.buffer_selection(),
Some(Selection::Char {
anchor: Position::new(0, 0),
head: Position::new(0, 3),
})
);
}
#[test]
fn buffer_selection_line_in_visual_line_mode() {
use hjkl_buffer::Selection;
let mut e = editor_with("a\nb\nc\nd");
run_keys(&mut e, "Vj");
assert_eq!(
e.buffer_selection(),
Some(Selection::Line {
anchor_row: 0,
head_row: 1,
})
);
}
#[test]
fn intern_style_dedups_repeated_styles() {
use ratatui::style::{Color, Style};
let mut e = editor_with("");
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let id_r1 = e.intern_style(red);
let id_r2 = e.intern_style(red);
let id_b = e.intern_style(blue);
assert_eq!(id_r1, id_r2);
assert_ne!(id_r1, id_b);
assert_eq!(e.style_table().len(), 2);
}
#[test]
fn install_syntax_spans_translates_styled_spans() {
use ratatui::style::{Color, Style};
let mut e = editor_with("SELECT foo");
e.install_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
let by_row = e.buffer.spans();
assert_eq!(by_row.len(), 1);
assert_eq!(by_row[0].len(), 1);
assert_eq!(by_row[0][0].start_byte, 0);
assert_eq!(by_row[0][0].end_byte, 6);
let id = by_row[0][0].style;
assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
}
#[test]
fn install_syntax_spans_clamps_sentinel_end() {
use ratatui::style::{Color, Style};
let mut e = editor_with("hello");
e.install_syntax_spans(vec![vec![(
0,
usize::MAX,
Style::default().fg(Color::Blue),
)]]);
let by_row = e.buffer.spans();
assert_eq!(by_row[0][0].end_byte, 5);
}
#[test]
fn install_syntax_spans_drops_zero_width() {
use ratatui::style::{Color, Style};
let mut e = editor_with("abc");
e.install_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
assert!(e.buffer.spans()[0].is_empty());
}
#[test]
fn named_register_yank_into_a_then_paste_from_a() {
let mut e = editor_with("hello world\nsecond");
run_keys(&mut e, "\"ayw");
assert_eq!(e.registers().read('a').unwrap().text, "hello ");
run_keys(&mut e, "j0\"aP");
assert_eq!(e.buffer().lines()[1], "hello second");
}
#[test]
fn capital_r_overstrikes_chars() {
let mut e = editor_with("hello");
e.jump_cursor(0, 0);
run_keys(&mut e, "RXY<Esc>");
assert_eq!(e.buffer().lines()[0], "XYllo");
}
#[test]
fn capital_r_at_eol_appends() {
let mut e = editor_with("hi");
e.jump_cursor(0, 1);
run_keys(&mut e, "RXYZ<Esc>");
assert_eq!(e.buffer().lines()[0], "hXYZ");
}
#[test]
fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
let mut e = editor_with("abc");
e.jump_cursor(0, 0);
run_keys(&mut e, "RX<Esc>");
assert_eq!(e.buffer().lines()[0], "Xbc");
}
#[test]
fn ctrl_r_in_insert_pastes_named_register() {
let mut e = editor_with("hello world");
run_keys(&mut e, "\"ayw");
assert_eq!(e.registers().read('a').unwrap().text, "hello ");
run_keys(&mut e, "o");
assert_eq!(e.vim_mode(), VimMode::Insert);
e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(e.buffer().lines()[1], "hello ");
assert_eq!(e.cursor(), (1, 6));
assert_eq!(e.vim_mode(), VimMode::Insert);
e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(e.buffer().lines()[1], "hello X");
}
#[test]
fn ctrl_r_with_unnamed_register() {
let mut e = editor_with("foo");
run_keys(&mut e, "yiw");
run_keys(&mut e, "A ");
e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
assert_eq!(e.buffer().lines()[0], "foo foo");
}
#[test]
fn ctrl_r_unknown_selector_is_no_op() {
let mut e = editor_with("abc");
run_keys(&mut e, "A");
e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
assert_eq!(e.buffer().lines()[0], "abcZ");
}
#[test]
fn ctrl_r_multiline_register_pastes_with_newlines() {
let mut e = editor_with("alpha\nbeta\ngamma");
run_keys(&mut e, "\"byy");
run_keys(&mut e, "j\"byy");
run_keys(&mut e, "ggVj\"by");
let payload = e.registers().read('b').unwrap().text.clone();
assert!(payload.contains('\n'));
run_keys(&mut e, "Go");
e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
let total_lines = e.buffer().lines().len();
assert!(total_lines >= 5);
}
#[test]
fn yank_zero_holds_last_yank_after_delete() {
let mut e = editor_with("hello world");
run_keys(&mut e, "yw");
let yanked = e.registers().read('0').unwrap().text.clone();
assert!(!yanked.is_empty());
run_keys(&mut e, "dw");
assert_eq!(e.registers().read('0').unwrap().text, yanked);
assert!(!e.registers().read('1').unwrap().text.is_empty());
}
#[test]
fn delete_ring_rotates_through_one_through_nine() {
let mut e = editor_with("a b c d e f g h i j");
for _ in 0..3 {
run_keys(&mut e, "dw");
}
let r1 = e.registers().read('1').unwrap().text.clone();
let r2 = e.registers().read('2').unwrap().text.clone();
let r3 = e.registers().read('3').unwrap().text.clone();
assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
assert_ne!(r1, r2);
assert_ne!(r2, r3);
}
#[test]
fn capital_register_appends_to_lowercase() {
let mut e = editor_with("foo bar");
run_keys(&mut e, "\"ayw");
let first = e.registers().read('a').unwrap().text.clone();
assert!(first.contains("foo"));
run_keys(&mut e, "w\"Ayw");
let combined = e.registers().read('a').unwrap().text.clone();
assert!(combined.starts_with(&first));
assert!(combined.contains("bar"));
}
#[test]
fn zf_in_visual_line_creates_closed_fold() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(1, 0);
run_keys(&mut e, "Vjjzf");
assert_eq!(e.buffer().folds().len(), 1);
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 1);
assert_eq!(f.end_row, 3);
assert!(f.closed);
}
#[test]
fn zfj_in_normal_creates_two_row_fold() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(1, 0);
run_keys(&mut e, "zfj");
assert_eq!(e.buffer().folds().len(), 1);
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 1);
assert_eq!(f.end_row, 2);
assert!(f.closed);
assert_eq!(e.cursor().0, 1);
}
#[test]
fn zf_with_count_folds_count_rows() {
let mut e = editor_with("a\nb\nc\nd\ne\nf");
e.jump_cursor(0, 0);
run_keys(&mut e, "zf3j");
assert_eq!(e.buffer().folds().len(), 1);
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 0);
assert_eq!(f.end_row, 3);
}
#[test]
fn zfk_folds_upward_range() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(3, 0);
run_keys(&mut e, "zfk");
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 2);
assert_eq!(f.end_row, 3);
}
#[test]
fn zf_capital_g_folds_to_bottom() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(1, 0);
run_keys(&mut e, "zfG");
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 1);
assert_eq!(f.end_row, 4);
}
#[test]
fn zfgg_folds_to_top_via_operator_pipeline() {
let mut e = editor_with("a\nb\nc\nd\ne");
e.jump_cursor(3, 0);
run_keys(&mut e, "zfgg");
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 0);
assert_eq!(f.end_row, 3);
}
#[test]
fn zfip_folds_paragraph_via_text_object() {
let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
e.jump_cursor(1, 0);
run_keys(&mut e, "zfip");
assert_eq!(e.buffer().folds().len(), 1);
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 0);
assert_eq!(f.end_row, 2);
}
#[test]
fn zfap_folds_paragraph_with_trailing_blank() {
let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
e.jump_cursor(0, 0);
run_keys(&mut e, "zfap");
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 0);
assert_eq!(f.end_row, 3);
}
#[test]
fn zf_paragraph_motion_folds_to_blank() {
let mut e = editor_with("alpha\nbeta\n\ngamma");
e.jump_cursor(0, 0);
run_keys(&mut e, "zf}");
let f = e.buffer().folds()[0];
assert_eq!(f.start_row, 0);
assert_eq!(f.end_row, 2);
}
#[test]
fn za_toggles_fold_under_cursor() {
let mut e = editor_with("a\nb\nc\nd");
e.buffer_mut().add_fold(1, 2, true);
e.jump_cursor(1, 0);
run_keys(&mut e, "za");
assert!(!e.buffer().folds()[0].closed);
run_keys(&mut e, "za");
assert!(e.buffer().folds()[0].closed);
}
#[test]
fn zr_opens_all_folds_zm_closes_all() {
let mut e = editor_with("a\nb\nc\nd\ne\nf");
e.buffer_mut().add_fold(0, 1, true);
e.buffer_mut().add_fold(2, 3, true);
e.buffer_mut().add_fold(4, 5, true);
run_keys(&mut e, "zR");
assert!(e.buffer().folds().iter().all(|f| !f.closed));
run_keys(&mut e, "zM");
assert!(e.buffer().folds().iter().all(|f| f.closed));
}
#[test]
fn ze_clears_all_folds() {
let mut e = editor_with("a\nb\nc\nd");
e.buffer_mut().add_fold(0, 1, true);
e.buffer_mut().add_fold(2, 3, false);
run_keys(&mut e, "zE");
assert!(e.buffer().folds().is_empty());
}
#[test]
fn g_underscore_jumps_to_last_non_blank() {
let mut e = editor_with("hello world ");
run_keys(&mut e, "g_");
assert_eq!(e.cursor().1, 10);
}
#[test]
fn gj_and_gk_alias_j_and_k() {
let mut e = editor_with("a\nb\nc");
run_keys(&mut e, "gj");
assert_eq!(e.cursor().0, 1);
run_keys(&mut e, "gk");
assert_eq!(e.cursor().0, 0);
}
#[test]
fn paragraph_motions_walk_blank_lines() {
let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
run_keys(&mut e, "}");
assert_eq!(e.cursor().0, 2);
run_keys(&mut e, "}");
assert_eq!(e.cursor().0, 5);
run_keys(&mut e, "{");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn gv_reenters_last_visual_selection() {
let mut e = editor_with("alpha\nbeta\ngamma");
run_keys(&mut e, "Vj");
run_keys(&mut e, "<Esc>");
assert_eq!(e.vim_mode(), VimMode::Normal);
run_keys(&mut e, "gv");
assert_eq!(e.vim_mode(), VimMode::VisualLine);
}
#[test]
fn o_in_visual_swaps_anchor_and_cursor() {
let mut e = editor_with("hello world");
run_keys(&mut e, "vllll");
assert_eq!(e.cursor().1, 4);
run_keys(&mut e, "o");
assert_eq!(e.cursor().1, 0);
assert_eq!(e.vim.visual_anchor, (0, 4));
}
#[test]
fn editing_inside_fold_invalidates_it() {
let mut e = editor_with("a\nb\nc\nd");
e.buffer_mut().add_fold(1, 2, true);
e.jump_cursor(1, 0);
run_keys(&mut e, "iX<Esc>");
assert!(e.buffer().folds().is_empty());
}
#[test]
fn zd_removes_fold_under_cursor() {
let mut e = editor_with("a\nb\nc\nd");
e.buffer_mut().add_fold(1, 2, true);
e.jump_cursor(2, 0);
run_keys(&mut e, "zd");
assert!(e.buffer().folds().is_empty());
}
#[test]
fn dot_mark_jumps_to_last_edit_position() {
let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
e.jump_cursor(2, 0);
run_keys(&mut e, "iX<Esc>");
let after_edit = e.cursor();
run_keys(&mut e, "gg");
assert_eq!(e.cursor().0, 0);
run_keys(&mut e, "'.");
assert_eq!(e.cursor().0, after_edit.0);
}
#[test]
fn quote_quote_returns_to_pre_jump_position() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(10, 2);
let before = e.cursor();
run_keys(&mut e, "G");
assert_ne!(e.cursor(), before);
run_keys(&mut e, "''");
assert_eq!(e.cursor().0, before.0);
}
#[test]
fn backtick_backtick_restores_exact_pre_jump_pos() {
let mut e = editor_with_rows(50, 20);
e.jump_cursor(7, 3);
let before = e.cursor();
run_keys(&mut e, "G");
run_keys(&mut e, "``");
assert_eq!(e.cursor(), before);
}
#[test]
fn macro_record_and_replay_basic() {
let mut e = editor_with("foo\nbar\nbaz");
run_keys(&mut e, "qaIX<Esc>jq");
assert_eq!(e.buffer().lines()[0], "Xfoo");
run_keys(&mut e, "@a");
assert_eq!(e.buffer().lines()[1], "Xbar");
run_keys(&mut e, "j@@");
assert_eq!(e.buffer().lines()[2], "Xbaz");
}
#[test]
fn macro_count_replays_n_times() {
let mut e = editor_with("a\nb\nc\nd\ne");
run_keys(&mut e, "qajq");
assert_eq!(e.cursor().0, 1);
run_keys(&mut e, "3@a");
assert_eq!(e.cursor().0, 4);
}
#[test]
fn macro_capital_q_appends_to_lowercase_register() {
let mut e = editor_with("hello");
run_keys(&mut e, "qall<Esc>q");
run_keys(&mut e, "qAhh<Esc>q");
let text = e.registers().read('a').unwrap().text.clone();
assert!(text.contains("ll<Esc>"));
assert!(text.contains("hh<Esc>"));
}
#[test]
fn buffer_selection_block_in_visual_block_mode() {
use hjkl_buffer::{Position, Selection};
let mut e = editor_with("aaaa\nbbbb\ncccc");
run_keys(&mut e, "<C-v>jl");
assert_eq!(
e.buffer_selection(),
Some(Selection::Block {
anchor: Position::new(0, 0),
head: Position::new(1, 1),
})
);
}
#[test]
fn n_after_question_mark_keeps_walking_backward() {
let mut e = editor_with("foo bar foo baz foo end");
e.jump_cursor(0, 22);
run_keys(&mut e, "?foo<CR>");
assert_eq!(e.cursor().1, 16);
run_keys(&mut e, "n");
assert_eq!(e.cursor().1, 8);
run_keys(&mut e, "N");
assert_eq!(e.cursor().1, 16);
}
#[test]
fn nested_macro_chord_records_literal_keys() {
let mut e = editor_with("alpha\nbeta\ngamma");
run_keys(&mut e, "qblq");
run_keys(&mut e, "qaIX<Esc>q");
e.jump_cursor(1, 0);
run_keys(&mut e, "@a");
assert_eq!(e.buffer().lines()[1], "Xbeta");
}
#[test]
fn shift_gt_motion_indents_one_line() {
let mut e = editor_with("hello world");
run_keys(&mut e, ">w");
assert_eq!(e.buffer().lines()[0], " hello world");
}
#[test]
fn shift_lt_motion_outdents_one_line() {
let mut e = editor_with(" hello world");
run_keys(&mut e, "<lt>w");
assert_eq!(e.buffer().lines()[0], " hello world");
}
#[test]
fn shift_gt_text_object_indents_paragraph() {
let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
e.jump_cursor(0, 0);
run_keys(&mut e, ">ip");
assert_eq!(e.buffer().lines()[0], " alpha");
assert_eq!(e.buffer().lines()[1], " beta");
assert_eq!(e.buffer().lines()[2], " gamma");
assert_eq!(e.buffer().lines()[4], "rest");
}
#[test]
fn ctrl_o_runs_exactly_one_normal_command() {
let mut e = editor_with("alpha beta gamma");
e.jump_cursor(0, 0);
run_keys(&mut e, "i");
e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
run_keys(&mut e, "dw");
assert_eq!(e.vim_mode(), VimMode::Insert);
run_keys(&mut e, "X");
assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
}
#[test]
fn macro_replay_respects_mode_switching() {
let mut e = editor_with("hi");
run_keys(&mut e, "qaiX<Esc>0q");
assert_eq!(e.vim_mode(), VimMode::Normal);
e.set_content("yo");
run_keys(&mut e, "@a");
assert_eq!(e.vim_mode(), VimMode::Normal);
assert_eq!(e.cursor().1, 0);
assert_eq!(e.buffer().lines()[0], "Xyo");
}
#[test]
fn macro_recorded_text_round_trips_through_register() {
let mut e = editor_with("");
run_keys(&mut e, "qaiX<Esc>q");
let text = e.registers().read('a').unwrap().text.clone();
assert!(text.starts_with("iX"));
run_keys(&mut e, "@a");
assert_eq!(e.buffer().lines()[0], "XX");
}
#[test]
fn dot_after_macro_replays_macros_last_change() {
let mut e = editor_with("ab\ncd\nef");
run_keys(&mut e, "qaIX<Esc>jq");
assert_eq!(e.buffer().lines()[0], "Xab");
run_keys(&mut e, "@a");
assert_eq!(e.buffer().lines()[1], "Xcd");
let row_before_dot = e.cursor().0;
run_keys(&mut e, ".");
assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
}
}