use std::ops::Range;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::config::Action;
use crate::document::{prev_grapheme_offset, EditDelta, Selection};
use crate::editor::vim_ops::{
cell_scope, clear_substitute_preview, clear_table_cell, delete_table_row, doubled_line_range,
end_incsearch, execute_operator, execute_substitute, first_non_blank, indent_lines,
indent_list_item, insert_table_rows, join_lines, line_end_offset, lines_touch_a_table,
op_range_breaks_a_table, open_list_continue, open_table_row, parse_ex, paste,
range_breaks_a_table, renumber_list_at_cursor, replace_char, replace_char_range,
replace_range_with, resolve_find_repeat, resolve_scoped_motion, resolve_scoped_op_range,
resolve_text_object_range, scope_offset, set_case_range, table_paste_plan, toggle_case,
toggle_case_range, update_incsearch, update_substitute_preview, vertical_line_range,
visual_cell_step, visual_charwise_range, visual_endpoint_in_cell, visual_line_bounds,
visual_line_char_range, word_under_cursor_at, CellLimit, ExCommand, FindKind, Motion, OpRange,
OpResult, Operator, TableBreak, TableOpOutcome, TablePaste, TextObject,
};
use crate::editor::{edit_ops, EditorState, Mode};
use crate::input::mode_handler::default::{is_ctrl_backspace, is_ctrl_delete};
use super::cmdline::{self, CmdLineStep};
use super::state::{
CmdLineKind, CmdLineState, PendingOp, VimRegister, VimState, VimSubMode, COUNT_CAP,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VimOutcome {
Pending,
Consumed,
Passthrough,
EnterSearch { forward: bool, query: String },
Save,
Quit { save_first: bool },
SaveAs {
path: Option<PathBuf>,
then_quit: bool,
force: bool,
},
SaveCopy {
path: PathBuf,
then_quit: bool,
force: bool,
},
Flash(String),
}
pub fn vim_feed(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
viewport_height: usize,
viewport_width: usize,
) -> VimOutcome {
if vim.cmdline.is_some() {
return feed_cmdline(vim, editor, key, viewport_height, viewport_width);
}
match vim.sub_mode {
VimSubMode::Insert => feed_insert(vim, editor, key, viewport_height, viewport_width),
VimSubMode::Visual | VimSubMode::VisualLine => {
feed_visual(vim, editor, key, viewport_height, viewport_width)
}
_ => feed_normal(vim, editor, key, viewport_height, viewport_width),
}
}
fn feed_insert(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
match key.code {
KeyCode::Esc => {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
let (_, col) = editor.cursor.line_col(&editor.buffer);
if col > 0 {
editor.cursor.move_left(&editor.buffer);
after_move(editor, vh, vw);
}
VimOutcome::Consumed
}
_ => VimOutcome::Passthrough,
}
}
fn feed_cmdline(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
if vim.cmdline.is_none() {
return VimOutcome::Passthrough;
}
if matches!(key.code, KeyCode::Up | KeyCode::Down) {
let kind = vim.cmdline.as_ref().expect("cmdline is Some").kind;
let history = match kind {
CmdLineKind::Ex => &vim.ex_history,
CmdLineKind::SearchForward | CmdLineKind::SearchBackward => &vim.search_history,
};
let cl = vim.cmdline.as_mut().expect("cmdline is Some");
let input_before = cl.input.clone();
if key.code == KeyCode::Up {
cmdline::history_prev(cl, history);
} else {
cmdline::history_next(cl, history);
}
cmdline_live_update(vim, editor, &input_before, vh, vw);
return VimOutcome::Consumed;
}
let cl = vim.cmdline.as_mut().expect("cmdline is Some");
let kind = cl.kind;
let input_before = cl.input.clone();
match cmdline::feed_key(cl, key) {
CmdLineStep::Editing => {
cmdline_live_update(vim, editor, &input_before, vh, vw);
VimOutcome::Consumed
}
CmdLineStep::Cancel => {
vim.cmdline = None;
match kind {
CmdLineKind::Ex => {
clear_substitute_preview(editor, true);
}
CmdLineKind::SearchForward | CmdLineKind::SearchBackward => {
end_incsearch(editor, &mut vim.incsearch);
}
}
VimOutcome::Consumed
}
CmdLineStep::Submit(input) => {
vim.cmdline = None;
match kind {
CmdLineKind::Ex => {
clear_substitute_preview(editor, false);
}
CmdLineKind::SearchForward | CmdLineKind::SearchBackward => {
end_incsearch(editor, &mut vim.incsearch);
}
}
if !input.is_empty() {
vim.record_command(kind, &input);
}
match kind {
CmdLineKind::SearchForward if !input.is_empty() => VimOutcome::EnterSearch {
forward: true,
query: input,
},
CmdLineKind::SearchBackward if !input.is_empty() => VimOutcome::EnterSearch {
forward: false,
query: input,
},
CmdLineKind::Ex => submit_ex(editor, &input, vim.last_visual_range, vh, vw),
_ => VimOutcome::Consumed,
}
}
}
}
pub fn cmdline_live_update(
vim: &mut VimState,
editor: &mut EditorState,
before: &str,
vh: usize,
vw: usize,
) {
let (kind, input) = match vim.cmdline.as_ref() {
Some(cl) if cl.input != before => (cl.kind, cl.input.clone()),
_ => return,
};
match kind {
CmdLineKind::Ex => update_substitute_preview(editor, &input, vim.last_visual_range, vh, vw),
CmdLineKind::SearchForward => {
update_incsearch(editor, &mut vim.incsearch, &input, true, vh, vw)
}
CmdLineKind::SearchBackward => {
update_incsearch(editor, &mut vim.incsearch, &input, false, vh, vw)
}
}
}
fn submit_ex(
editor: &mut EditorState,
input: &str,
visual_range: Option<(usize, usize)>,
vh: usize,
vw: usize,
) -> VimOutcome {
if input.trim().is_empty() {
return VimOutcome::Consumed;
}
match parse_ex(input) {
Ok(ExCommand::Write) => {
if editor.buffer.path().is_some() {
VimOutcome::Save
} else {
VimOutcome::SaveAs {
path: None,
then_quit: false,
force: false,
}
}
}
Ok(ExCommand::WriteCopy { path, force }) => VimOutcome::SaveCopy {
path: PathBuf::from(path),
then_quit: false,
force,
},
Ok(ExCommand::WriteAs { path, force }) => VimOutcome::SaveAs {
path: Some(PathBuf::from(path)),
then_quit: false,
force,
},
Ok(ExCommand::SaveAsPrompt) => VimOutcome::SaveAs {
path: None,
then_quit: false,
force: false,
},
Ok(ExCommand::Quit) => VimOutcome::Quit { save_first: false },
Ok(ExCommand::WriteQuit) => {
if editor.buffer.path().is_some() {
VimOutcome::Quit { save_first: true }
} else {
VimOutcome::SaveAs {
path: None,
then_quit: true,
force: false,
}
}
}
Ok(ExCommand::WriteQuitCopy { path, force }) => VimOutcome::SaveCopy {
path: PathBuf::from(path),
then_quit: true,
force,
},
Ok(ExCommand::WriteQuitIfModified) => {
if !editor.dirty {
VimOutcome::Quit { save_first: false }
} else if editor.buffer.path().is_some() {
VimOutcome::Quit { save_first: true }
} else {
VimOutcome::SaveAs {
path: None,
then_quit: true,
force: false,
}
}
}
Ok(ExCommand::GoToLine(n)) => {
let dest = resolve_scoped_motion(editor, Motion::GoToLine(n), 1, CellLimit::Append);
move_to_offset(editor, dest, vh, vw, false);
VimOutcome::Consumed
}
Ok(ExCommand::Substitute(sub)) => match execute_substitute(editor, &sub, visual_range) {
Ok(0) => VimOutcome::Flash(format!("Pattern not found: {}", sub.pattern)),
Ok(n) => {
after_edit(editor, vh, vw);
let plural = if n == 1 { "" } else { "s" };
VimOutcome::Flash(format!("{n} substitution{plural}"))
}
Err(e) => VimOutcome::Flash(e.to_string()),
},
Err(e) => VimOutcome::Flash(e.to_string()),
}
}
fn feed_normal(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
if vim.pending_replace {
return feed_replace_char(vim, editor, key, vh, vw);
}
if let Some(kind) = vim.pending_find {
return feed_find_char(vim, editor, kind, key, vh, vw, false);
}
if let Some(inner) = vim.pending_text_object {
return feed_text_object(vim, editor, inner, key, vh, vw, false);
}
if is_ctrl_backspace(&key) || is_ctrl_delete(&key) {
vim.sub_mode = VimSubMode::Normal;
let dir = if is_ctrl_delete(&key) { 'l' } else { 'h' };
for _ in 0..count_of(vim) {
feed_hjkl(editor, dir, vh, vw, false);
}
vim.reset_pending();
return VimOutcome::Consumed;
}
if is_passthrough_chord(&key) {
if vim.sub_mode == VimSubMode::OperatorPending {
vim.sub_mode = VimSubMode::Normal;
}
vim.reset_pending();
return VimOutcome::Passthrough;
}
match key.code {
KeyCode::Esc => {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
editor.exit_search();
editor.selection = None;
VimOutcome::Consumed
}
KeyCode::Char(c) => feed_command_char(vim, editor, c, vh, vw, false),
KeyCode::Tab if editor.search.is_some() => {
search_repeat(editor, true, count_of(vim), vh, vw);
vim.reset_pending();
VimOutcome::Consumed
}
KeyCode::BackTab if editor.search.is_some() => {
search_repeat(editor, false, count_of(vim), vh, vw);
vim.reset_pending();
VimOutcome::Consumed
}
KeyCode::Tab if editor.cursor_in_table() => {
vim.reset_pending();
VimOutcome::Passthrough
}
KeyCode::Backspace | KeyCode::Delete | KeyCode::Enter | KeyCode::Tab => {
vim.sub_mode = VimSubMode::Normal;
let n = count_of(vim);
match key.code {
KeyCode::Backspace => {
for _ in 0..n {
feed_hjkl(editor, 'h', vh, vw, false);
}
}
KeyCode::Delete => {
for _ in 0..n {
feed_hjkl(editor, 'l', vh, vw, false);
}
}
KeyCode::Enter => {
for _ in 0..n {
feed_hjkl(editor, 'j', vh, vw, false);
}
move_first_non_blank(editor);
after_move(editor, vh, vw);
}
_ => {} }
vim.reset_pending();
VimOutcome::Consumed
}
_ => VimOutcome::Passthrough,
}
}
fn feed_visual(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
if vim.pending_replace {
return feed_visual_replace_char(vim, editor, key, vh, vw);
}
if let Some(kind) = vim.pending_find {
return feed_find_char(vim, editor, kind, key, vh, vw, true);
}
if let Some(inner) = vim.pending_text_object {
return feed_text_object(vim, editor, inner, key, vh, vw, true);
}
if is_ctrl_backspace(&key) || is_ctrl_delete(&key) {
let dir = if is_ctrl_delete(&key) { 'l' } else { 'h' };
feed_hjkl(editor, dir, vh, vw, is_charwise_visual(vim));
extend_selection(editor);
vim.reset_pending();
return VimOutcome::Consumed;
}
if is_passthrough_chord(&key) {
return VimOutcome::Passthrough;
}
match key.code {
KeyCode::Esc => {
exit_visual(vim, editor);
VimOutcome::Consumed
}
KeyCode::Char(c) => match feed_visual_command(vim, editor, c, vh, vw) {
Some(out) => out,
None => feed_command_char(vim, editor, c, vh, vw, true),
},
KeyCode::Left
| KeyCode::Right
| KeyCode::Up
| KeyCode::Down
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Enter => {
let dir = match key.code {
KeyCode::Left | KeyCode::Backspace => 'h',
KeyCode::Right | KeyCode::Delete => 'l',
KeyCode::Up => 'k',
_ => 'j', };
feed_hjkl(editor, dir, vh, vw, is_charwise_visual(vim));
extend_selection(editor);
vim.reset_pending();
VimOutcome::Consumed
}
KeyCode::Tab | KeyCode::BackTab => {
vim.reset_pending();
VimOutcome::Consumed
}
_ => VimOutcome::Passthrough,
}
}
fn exit_visual(vim: &mut VimState, editor: &mut EditorState) {
vim.sub_mode = VimSubMode::Normal;
vim.visual_anchor = None;
vim.reset_pending();
editor.selection = None;
}
fn feed_visual_command(
vim: &mut VimState,
editor: &mut EditorState,
c: char,
vh: usize,
vw: usize,
) -> Option<VimOutcome> {
if matches!(c, 'J' | '>' | '<') && visual_selection_touches_a_table(editor) {
leave_visual_to_normal(vim, editor, vh, vw);
return Some(VimOutcome::Flash(TABLE_STRUCTURAL_REFUSAL.to_owned()));
}
match c {
'd' | 'x' => return Some(run_visual_operator(vim, editor, Operator::Delete, vh, vw)),
'y' => return Some(run_visual_operator(vim, editor, Operator::Yank, vh, vw)),
'c' | 's' => return Some(run_visual_operator(vim, editor, Operator::Change, vh, vw)),
'>' => run_visual_indent(vim, editor, true, vh, vw),
'<' => run_visual_indent(vim, editor, false, vh, vw),
'~' => run_visual_toggle_case(vim, editor, vh, vw),
'u' => run_visual_set_case(vim, editor, false, vh, vw),
'U' => run_visual_set_case(vim, editor, true, vh, vw),
'J' => run_visual_join(vim, editor, vh, vw),
'p' | 'P' => return Some(run_visual_paste(vim, editor, vh, vw)),
'r' => {
vim.pending_replace = true;
return Some(VimOutcome::Pending);
}
'i' | 'a' => {
vim.pending_text_object = Some(c == 'i');
return Some(VimOutcome::Pending);
}
':' => {
if let Some(sel) = editor.selection {
vim.last_visual_range = Some(visual_line_bounds(&sel, &editor.buffer));
}
exit_visual(vim, editor);
vim.cmdline = Some(CmdLineState::with_input(
CmdLineKind::Ex,
"'<,'>".to_owned(),
));
return Some(VimOutcome::Pending);
}
'o' => swap_visual_ends(vim, editor, vh, vw),
'v' => toggle_visual_mode(vim, editor, false),
'V' => toggle_visual_mode(vim, editor, true),
_ => return None,
}
Some(VimOutcome::Consumed)
}
fn with_selection(
vim: &mut VimState,
editor: &mut EditorState,
f: impl FnOnce(&mut VimState, &mut EditorState, Selection),
) {
let Some(sel) = editor.selection else {
exit_visual(vim, editor);
return;
};
f(vim, editor, sel);
}
fn run_visual_operator(
vim: &mut VimState,
editor: &mut EditorState,
op: Operator,
vh: usize,
vw: usize,
) -> VimOutcome {
let mut outcome = VimOutcome::Consumed;
with_selection(vim, editor, |vim, editor, sel| {
let range = visual_op_range(vim, editor, &sel);
if let Some(reason) = mutating_table_break(editor, op, &range) {
leave_visual_to_normal(vim, editor, vh, vw);
outcome = VimOutcome::Flash(reason.message().to_owned());
return;
}
let res = execute_operator(editor, op, range);
vim.visual_anchor = None;
editor.selection = None;
fold_op_result(vim, editor, res, vh, vw);
});
outcome
}
fn visual_op_range(vim: &VimState, editor: &EditorState, sel: &Selection) -> OpRange {
if vim.sub_mode == VimSubMode::VisualLine {
let (first, last) = visual_line_bounds(sel, &editor.buffer);
OpRange::Lines { first, last }
} else {
OpRange::Chars(visual_charwise_range(sel, &editor.buffer))
}
}
fn visual_selection_touches_a_table(editor: &EditorState) -> bool {
match editor.selection {
Some(sel) => {
let (first, last) = visual_line_bounds(&sel, &editor.buffer);
lines_touch_a_table(editor, first, last)
}
None => editor.cursor_in_table(),
}
}
fn run_visual_indent(
vim: &mut VimState,
editor: &mut EditorState,
right: bool,
vh: usize,
vw: usize,
) {
with_selection(vim, editor, |vim, editor, sel| {
let (first, last) = visual_line_bounds(&sel, &editor.buffer);
ensure_editing(editor);
indent_lines(editor, first, last, right, crate::constants::INDENT_WIDTH);
leave_visual_to_normal(vim, editor, vh, vw);
});
}
fn visual_edit_range(vim: &VimState, editor: &EditorState, sel: &Selection) -> Range<usize> {
if vim.sub_mode == VimSubMode::VisualLine {
visual_line_char_range(sel, &editor.buffer)
} else {
visual_charwise_range(sel, &editor.buffer)
}
}
fn run_visual_toggle_case(vim: &mut VimState, editor: &mut EditorState, vh: usize, vw: usize) {
with_selection(vim, editor, |vim, editor, sel| {
let range = visual_edit_range(vim, editor, &sel);
ensure_editing(editor);
toggle_case_range(editor, range.start, range.end);
leave_visual_to_normal(vim, editor, vh, vw);
});
}
fn run_visual_set_case(
vim: &mut VimState,
editor: &mut EditorState,
upper: bool,
vh: usize,
vw: usize,
) {
with_selection(vim, editor, |vim, editor, sel| {
let range = visual_edit_range(vim, editor, &sel);
ensure_editing(editor);
set_case_range(editor, range.start, range.end, upper);
leave_visual_to_normal(vim, editor, vh, vw);
});
}
fn run_visual_paste(
vim: &mut VimState,
editor: &mut EditorState,
vh: usize,
vw: usize,
) -> VimOutcome {
let mut outcome = VimOutcome::Consumed;
with_selection(vim, editor, |vim, editor, sel| {
if vim.register.text.is_empty() {
leave_visual_to_normal(vim, editor, vh, vw);
return;
}
let line_mode = vim.sub_mode == VimSubMode::VisualLine;
let range = visual_edit_range(vim, editor, &sel);
let text = if line_mode && !vim.register.linewise {
format!("{}\n", vim.register.text)
} else {
vim.register.text.clone()
};
if let Some(message) = paste_over_range_refusal(editor, &range, &text, line_mode) {
leave_visual_to_normal(vim, editor, vh, vw);
outcome = VimOutcome::Flash(message);
return;
}
ensure_editing(editor);
replace_range_with(editor, range.start, range.end, &text);
leave_visual_to_normal(vim, editor, vh, vw);
});
outcome
}
fn paste_over_range_refusal(
editor: &EditorState,
range: &Range<usize>,
text: &str,
linewise: bool,
) -> Option<String> {
let rope = editor.buffer.rope();
let len = rope.len_chars();
let start = rope.char_to_byte(range.start.min(len));
let end = rope.char_to_byte(range.end.min(len));
if let Some(reason) = range_breaks_a_table(editor, start, end) {
return Some(reason.message().to_owned());
}
match table_paste_plan(editor, text, linewise, true) {
TablePaste::Refused => Some(TABLE_PASTE_REFUSAL.to_owned()),
_ => None,
}
}
fn feed_visual_replace_char(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
match key.code {
KeyCode::Char(c) if !is_passthrough_chord(&key) => {
if let Some(sel) = editor.selection {
let range = visual_edit_range(vim, editor, &sel);
if let Some(reason) =
op_range_breaks_a_table(editor, &OpRange::Chars(range.start..range.end))
{
leave_visual_to_normal(vim, editor, vh, vw);
return VimOutcome::Flash(reason.message().to_owned());
}
ensure_editing(editor);
replace_char_range(editor, range.start, range.end, c);
}
leave_visual_to_normal(vim, editor, vh, vw);
}
_ => vim.pending_replace = false,
}
VimOutcome::Consumed
}
fn run_visual_join(vim: &mut VimState, editor: &mut EditorState, vh: usize, vw: usize) {
with_selection(vim, editor, |vim, editor, sel| {
let (first, last) = visual_line_bounds(&sel, &editor.buffer);
ensure_editing(editor);
editor.cursor.offset = editor.buffer.line_to_char(first);
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
let count = (last - first + 1) as u32;
join_lines(editor, count);
leave_visual_to_normal(vim, editor, vh, vw);
});
}
fn swap_visual_ends(vim: &mut VimState, editor: &mut EditorState, vh: usize, vw: usize) {
if let Some(sel) = editor.selection.as_mut() {
std::mem::swap(&mut sel.anchor, &mut sel.active);
let active = sel.active;
vim.visual_anchor = Some(sel.anchor);
editor.cursor.offset = active.min(editor.buffer.len_chars());
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
after_move(editor, vh, vw);
}
vim.reset_pending();
}
fn toggle_visual_mode(vim: &mut VimState, editor: &mut EditorState, line: bool) {
let target = if line {
VimSubMode::VisualLine
} else {
VimSubMode::Visual
};
if vim.sub_mode == target {
exit_visual(vim, editor);
} else {
vim.sub_mode = target;
if !line {
pull_cursor_into_cell(editor);
pull_anchor_into_cell(vim, editor);
extend_selection(editor);
}
vim.reset_pending();
}
}
fn leave_visual_to_normal(vim: &mut VimState, editor: &mut EditorState, vh: usize, vw: usize) {
vim.visual_anchor = None;
editor.selection = None;
vim.reset_pending();
vim.sub_mode = VimSubMode::Normal;
after_edit(editor, vh, vw);
}
fn feed_command_char(
vim: &mut VimState,
editor: &mut EditorState,
c: char,
vh: usize,
vw: usize,
visual: bool,
) -> VimOutcome {
if vim.pending_g {
vim.pending_g = false;
if c == 'g' {
let motion = line_jump(Motion::DocStart, operand_count(vim));
if let Some(operator) = vim.pending_op.and_then(operator_kind) {
let range = resolve_scoped_op_range(editor, motion, 1);
return run_operator(vim, editor, operator, range, vh, vw);
}
apply_motion(
editor,
motion,
count_of(vim),
vh,
vw,
visual,
cell_limit(vim),
);
}
if !visual {
vim.sub_mode = VimSubMode::Normal;
}
vim.reset_pending();
return VimOutcome::Consumed;
}
if let Some(op) = vim.pending_op {
return feed_operator_pending(vim, editor, op, c, vh, vw);
}
if is_count_digit(c, vim.count) {
vim.count = Some(accumulate(vim.count, c));
return VimOutcome::Pending;
}
if c == 'g' {
vim.pending_g = true;
return VimOutcome::Pending;
}
if !visual {
if let Some(op) = operator_for(c) {
vim.pending_op = Some(op);
vim.sub_mode = VimSubMode::OperatorPending;
return VimOutcome::Pending;
}
}
let count = count_of(vim);
if let Some(kind) = find_kind_for(c) {
vim.pending_find = Some(kind);
return VimOutcome::Pending;
}
if c == ';' || c == ',' {
if let Some((kind, target)) = vim.last_find {
let kind = if c == ',' { reverse_find(kind) } else { kind };
let dest =
resolve_find_repeat(&editor.buffer, editor.cursor.offset, target, kind, count);
let dest = scope_offset(
editor,
Motion::FindChar(target, kind),
dest,
cell_limit(vim),
);
move_to_offset(editor, dest, vh, vw, visual);
}
vim.reset_pending();
return VimOutcome::Consumed;
}
if let Some(motion) = motion_for(c) {
let motion = line_jump(motion, operand_count(vim));
apply_motion(editor, motion, count, vh, vw, visual, cell_limit(vim));
vim.reset_pending();
return VimOutcome::Consumed;
}
if matches!(c, 'h' | 'l' | 'j' | 'k') {
if !visual {
clear_selection(editor);
}
let charwise_visual = is_charwise_visual(vim);
for _ in 0..count {
feed_hjkl(editor, c, vh, vw, charwise_visual);
}
if visual {
extend_selection(editor);
}
vim.reset_pending();
return VimOutcome::Consumed;
}
if !visual {
match c {
'x' => {
let range = resolve_scoped_op_range(editor, Motion::Right, count);
return run_operator(vim, editor, Operator::Delete, range, vh, vw);
}
'X' => {
let range = resolve_scoped_op_range(editor, Motion::Left, count);
return run_operator(vim, editor, Operator::Delete, range, vh, vw);
}
'D' => {
let range = resolve_scoped_op_range(editor, Motion::LineEnd, count);
return run_operator(vim, editor, Operator::Delete, range, vh, vw);
}
'C' => {
let range = resolve_scoped_op_range(editor, Motion::LineEnd, count);
return run_operator(vim, editor, Operator::Change, range, vh, vw);
}
'Y' => {
let range = doubled_line_range(&editor.buffer, editor.cursor.offset, count);
return run_operator(vim, editor, Operator::Yank, range, vh, vw);
}
'p' => {
let outcome = paste_register(vim, editor, count, true, vh, vw);
vim.reset_pending();
return outcome;
}
'P' => {
let outcome = paste_register(vim, editor, count, false, vh, vw);
vim.reset_pending();
return outcome;
}
'r' => {
vim.pending_replace = true;
return VimOutcome::Pending;
}
'~' => {
toggle_case(editor, count);
after_edit(editor, vh, vw);
vim.reset_pending();
}
'J' => {
let line = editor.buffer.char_to_line(editor.cursor.offset);
let last = line + count.max(2) as usize - 1;
if lines_touch_a_table(editor, line, last) {
vim.reset_pending();
return VimOutcome::Flash(TABLE_STRUCTURAL_REFUSAL.to_owned());
}
join_lines(editor, count);
after_edit(editor, vh, vw);
vim.reset_pending();
}
'u' => {
for _ in 0..count {
edit_ops::apply(editor, Action::Undo, vh, vw);
}
vim.reset_pending();
}
'i' => {
enter_insert(vim, editor);
vim.reset_pending();
}
'a' => {
let line = editor.buffer.char_to_line(editor.cursor.offset);
if editor.cursor.offset < line_end_offset(&editor.buffer, line) {
editor.cursor.move_right(&editor.buffer);
}
enter_insert(vim, editor);
after_move(editor, vh, vw);
vim.reset_pending();
}
'I' => {
match cell_scope(editor) {
Some(scope) => editor.place_cursor(scope.start),
None => move_first_non_blank(editor),
}
enter_insert(vim, editor);
after_move(editor, vh, vw);
vim.reset_pending();
}
'A' => {
match cell_scope(editor) {
Some(scope) => editor.place_cursor(scope.end),
None => editor.cursor.move_line_end(&editor.buffer),
}
enter_insert(vim, editor);
after_move(editor, vh, vw);
vim.reset_pending();
}
'o' => {
open_line(vim, editor, true, vh, vw);
vim.reset_pending();
}
'O' => {
open_line(vim, editor, false, vh, vw);
vim.reset_pending();
}
'v' => {
enter_visual(vim, editor, false);
vim.reset_pending();
}
'V' => {
enter_visual(vim, editor, true);
vim.reset_pending();
}
'/' => {
start_cmdline(vim, CmdLineKind::SearchForward);
return VimOutcome::Pending;
}
'?' => {
start_cmdline(vim, CmdLineKind::SearchBackward);
return VimOutcome::Pending;
}
':' => {
start_cmdline(vim, CmdLineKind::Ex);
return VimOutcome::Pending;
}
'n' => {
search_repeat(editor, true, count, vh, vw);
vim.reset_pending();
}
'N' => {
search_repeat(editor, false, count, vh, vw);
vim.reset_pending();
}
'*' => {
let outcome = search_word_outcome(editor, true);
vim.reset_pending();
return outcome;
}
'#' => {
let outcome = search_word_outcome(editor, false);
vim.reset_pending();
return outcome;
}
_ => vim.reset_pending(),
}
} else {
vim.reset_pending();
}
VimOutcome::Consumed
}
fn feed_operator_pending(
vim: &mut VimState,
editor: &mut EditorState,
op: PendingOp,
c: char,
vh: usize,
vw: usize,
) -> VimOutcome {
if is_count_digit(c, vim.motion_count) {
vim.motion_count = Some(accumulate(vim.motion_count, c));
return VimOutcome::Pending;
}
if matches!(op, PendingOp::IndentRight | PendingOp::IndentLeft) {
return feed_indent_pending(vim, editor, op, c, vh, vw);
}
if c == 'g' {
vim.pending_g = true;
return VimOutcome::Pending;
}
let operator = operator_kind(op).expect("indent operators handled before this point");
let count = vim
.count
.unwrap_or(1)
.saturating_mul(vim.motion_count.unwrap_or(1))
.clamp(1, COUNT_CAP);
if operator_for(c) == Some(op) {
if count == 1 {
if let Some(out) = table_doubled_operator(vim, editor, operator, vh, vw) {
return out;
}
}
let range = doubled_line_range(&editor.buffer, editor.cursor.offset, count);
return run_operator(vim, editor, operator, range, vh, vw);
}
if c == 'j' || c == 'k' {
let range = vertical_line_range(&editor.buffer, editor.cursor.offset, count, c == 'j');
return run_operator(vim, editor, operator, range, vh, vw);
}
if let Some(kind) = find_kind_for(c) {
vim.pending_find = Some(kind);
return VimOutcome::Pending;
}
if let Some(motion) = operator_motion_for(c) {
let motion = line_jump(motion, operand_count(vim));
let motion = change_word_to_word_end(operator, motion, editor);
let range = resolve_scoped_op_range(editor, motion, count);
return run_operator(vim, editor, operator, range, vh, vw);
}
if c == 'i' || c == 'a' {
vim.pending_text_object = Some(c == 'i');
return VimOutcome::Pending;
}
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
VimOutcome::Consumed
}
const TABLE_STRUCTURAL_REFUSAL: &str = "Not available in a table";
const TABLE_PASTE_REFUSAL: &str = "Can't paste that into a table";
fn table_doubled_operator(
vim: &mut VimState,
editor: &mut EditorState,
operator: Operator,
vh: usize,
vw: usize,
) -> Option<VimOutcome> {
let outcome = match operator {
Operator::Delete => delete_table_row(editor, vh, vw),
Operator::Change => clear_table_cell(editor),
Operator::Yank => return None,
};
match outcome {
TableOpOutcome::Applied(res) => {
fold_op_result(vim, editor, res, vh, vw);
Some(VimOutcome::Consumed)
}
TableOpOutcome::Refused(reason) => {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
Some(VimOutcome::Flash(reason.message().to_owned()))
}
TableOpOutcome::NotATable => None,
}
}
fn run_operator(
vim: &mut VimState,
editor: &mut EditorState,
op: Operator,
range: OpRange,
vh: usize,
vw: usize,
) -> VimOutcome {
if let Some(reason) = mutating_table_break(editor, op, &range) {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
return VimOutcome::Flash(reason.message().to_owned());
}
let res = execute_operator(editor, op, range);
fold_op_result(vim, editor, res, vh, vw);
VimOutcome::Consumed
}
fn mutating_table_break(editor: &EditorState, op: Operator, range: &OpRange) -> Option<TableBreak> {
if op == Operator::Yank {
return None;
}
op_range_breaks_a_table(editor, range)
}
fn fold_op_result(
vim: &mut VimState,
editor: &mut EditorState,
res: OpResult,
vh: usize,
vw: usize,
) {
if res.linewise {
renumber_list_at_cursor(editor);
}
if !res.register_text.is_empty() {
vim.register = VimRegister {
text: res.register_text,
linewise: res.linewise,
};
}
vim.reset_pending();
if res.enter_insert {
ensure_editing(editor);
vim.sub_mode = VimSubMode::Insert;
} else {
vim.sub_mode = VimSubMode::Normal;
}
after_edit(editor, vh, vw);
}
fn paste_register(
vim: &mut VimState,
editor: &mut EditorState,
count: u32,
after: bool,
vh: usize,
vw: usize,
) -> VimOutcome {
if vim.register.text.is_empty() {
return VimOutcome::Consumed;
}
match table_paste_plan(editor, &vim.register.text, vim.register.linewise, after) {
TablePaste::Refused => return VimOutcome::Flash(TABLE_PASTE_REFUSAL.to_owned()),
TablePaste::RowsAt(at) => {
ensure_editing(editor);
let text = vim.register.text.repeat(count.max(1) as usize);
insert_table_rows(editor, at, &text);
after_edit(editor, vh, vw);
return VimOutcome::Consumed;
}
TablePaste::NotATable => {}
}
ensure_editing(editor);
paste(
editor,
&vim.register.text,
vim.register.linewise,
count,
after,
);
after_edit(editor, vh, vw);
VimOutcome::Consumed
}
fn feed_indent_pending(
vim: &mut VimState,
editor: &mut EditorState,
op: PendingOp,
c: char,
vh: usize,
vw: usize,
) -> VimOutcome {
let right = op == PendingOp::IndentRight;
if operator_for(c) == Some(op) {
let count = vim
.count
.unwrap_or(1)
.saturating_mul(vim.motion_count.unwrap_or(1))
.clamp(1, COUNT_CAP);
let line = editor.buffer.char_to_line(editor.cursor.offset);
if lines_touch_a_table(editor, line, line + count as usize - 1) {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
return VimOutcome::Flash(TABLE_STRUCTURAL_REFUSAL.to_owned());
}
ensure_editing(editor);
if count == 1 && indent_list_item(editor, right) {
after_edit(editor, vh, vw);
} else if let OpRange::Lines { first, last } =
doubled_line_range(&editor.buffer, editor.cursor.offset, count)
{
indent_lines(editor, first, last, right, crate::constants::INDENT_WIDTH);
after_edit(editor, vh, vw);
}
}
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
VimOutcome::Consumed
}
fn cancel_pending(vim: &mut VimState, key: &KeyEvent) -> VimOutcome {
if vim.sub_mode == VimSubMode::OperatorPending {
vim.sub_mode = VimSubMode::Normal;
}
vim.reset_pending();
if is_passthrough_chord(key) {
VimOutcome::Passthrough
} else {
VimOutcome::Consumed
}
}
fn feed_replace_char(
vim: &mut VimState,
editor: &mut EditorState,
key: KeyEvent,
vh: usize,
vw: usize,
) -> VimOutcome {
match key.code {
KeyCode::Char(c) if !is_passthrough_chord(&key) => {
let count = count_of(vim);
let span = editor.cursor.offset..editor.cursor.offset + count as usize;
if let Some(reason) = op_range_breaks_a_table(editor, &OpRange::Chars(span)) {
vim.reset_pending();
return VimOutcome::Flash(reason.message().to_owned());
}
ensure_editing(editor);
replace_char(editor, c, count);
after_edit(editor, vh, vw);
vim.reset_pending();
VimOutcome::Consumed
}
_ => cancel_pending(vim, &key),
}
}
fn feed_find_char(
vim: &mut VimState,
editor: &mut EditorState,
kind: FindKind,
key: KeyEvent,
vh: usize,
vw: usize,
visual: bool,
) -> VimOutcome {
let target = match key.code {
KeyCode::Char(c) if !is_passthrough_chord(&key) => c,
_ => return cancel_pending(vim, &key),
};
vim.last_find = Some((kind, target));
let motion = Motion::FindChar(target, kind);
if let Some(operator) = vim.pending_op.and_then(operator_kind) {
let count = vim
.count
.unwrap_or(1)
.saturating_mul(vim.motion_count.unwrap_or(1))
.clamp(1, COUNT_CAP);
let range = resolve_scoped_op_range(editor, motion, count);
return run_operator(vim, editor, operator, range, vh, vw);
}
if vim.sub_mode == VimSubMode::OperatorPending {
vim.sub_mode = VimSubMode::Normal;
}
apply_motion(
editor,
motion,
count_of(vim),
vh,
vw,
visual,
cell_limit(vim),
);
vim.reset_pending();
VimOutcome::Consumed
}
fn feed_text_object(
vim: &mut VimState,
editor: &mut EditorState,
inner: bool,
key: KeyEvent,
vh: usize,
vw: usize,
visual: bool,
) -> VimOutcome {
let obj = match key.code {
KeyCode::Char(c) if !is_passthrough_chord(&key) => text_object_for(c, inner),
_ => None,
};
let Some(obj) = obj else {
return cancel_pending(vim, &key);
};
let range = resolve_text_object_range(obj, editor.cursor.offset, &editor.buffer);
if visual {
if let Some(r) = range {
select_text_object(vim, editor, r, vh, vw);
}
vim.reset_pending();
return VimOutcome::Consumed;
}
if let Some(operator) = vim.pending_op.and_then(operator_kind) {
if let Some(r) = range {
return run_operator(vim, editor, operator, OpRange::Chars(r), vh, vw);
} else {
vim.sub_mode = VimSubMode::Normal;
vim.reset_pending();
}
return VimOutcome::Consumed;
}
if vim.sub_mode == VimSubMode::OperatorPending {
vim.sub_mode = VimSubMode::Normal;
}
vim.reset_pending();
VimOutcome::Consumed
}
fn select_text_object(
vim: &mut VimState,
editor: &mut EditorState,
range: Range<usize>,
vh: usize,
vw: usize,
) {
ensure_editing(editor);
let len = editor.buffer.len_chars();
let start = range.start.min(len);
let end = range.end.min(len);
let active = if end > start {
prev_grapheme_offset(&editor.buffer, end)
} else {
start
};
vim.visual_anchor = Some(start);
editor.selection = Some(Selection {
anchor: start,
active,
});
editor.cursor.offset = active;
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
after_move(editor, vh, vw);
}
fn text_object_for(c: char, inner: bool) -> Option<TextObject> {
Some(match c {
'w' => TextObject::Word { inner, big: false },
'W' => TextObject::Word { inner, big: true },
'"' => TextObject::Quote { inner, quote: '"' },
'\'' => TextObject::Quote { inner, quote: '\'' },
'`' => TextObject::Quote { inner, quote: '`' },
'(' | ')' | 'b' => TextObject::Pair {
inner,
open: '(',
close: ')',
},
'[' | ']' => TextObject::Pair {
inner,
open: '[',
close: ']',
},
'{' | '}' | 'B' => TextObject::Pair {
inner,
open: '{',
close: '}',
},
_ => return None,
})
}
fn motion_for(c: char) -> Option<Motion> {
Some(match c {
'w' => Motion::WordForward,
'e' => Motion::WordEnd,
'b' => Motion::WordBackward,
'W' => Motion::BigWordForward,
'E' => Motion::BigWordEnd,
'B' => Motion::BigWordBackward,
'0' => Motion::LineStart,
'^' => Motion::LineFirstNonBlank,
'$' => Motion::LineEnd,
'G' => Motion::DocEnd,
'{' => Motion::ParagraphBackward,
'}' => Motion::ParagraphForward,
'%' => Motion::MatchingPair,
_ => return None,
})
}
fn operator_motion_for(c: char) -> Option<Motion> {
match c {
'h' => Some(Motion::Left),
'l' => Some(Motion::Right),
_ => motion_for(c),
}
}
fn find_kind_for(c: char) -> Option<FindKind> {
Some(match c {
'f' => FindKind::Forward,
'F' => FindKind::Backward,
't' => FindKind::ForwardTill,
'T' => FindKind::BackwardTill,
_ => return None,
})
}
fn reverse_find(kind: FindKind) -> FindKind {
match kind {
FindKind::Forward => FindKind::Backward,
FindKind::Backward => FindKind::Forward,
FindKind::ForwardTill => FindKind::BackwardTill,
FindKind::BackwardTill => FindKind::ForwardTill,
}
}
fn operator_for(c: char) -> Option<PendingOp> {
match c {
'd' => Some(PendingOp::Delete),
'c' => Some(PendingOp::Change),
'y' => Some(PendingOp::Yank),
'>' => Some(PendingOp::IndentRight),
'<' => Some(PendingOp::IndentLeft),
_ => None,
}
}
fn operator_kind(op: PendingOp) -> Option<Operator> {
match op {
PendingOp::Delete => Some(Operator::Delete),
PendingOp::Change => Some(Operator::Change),
PendingOp::Yank => Some(Operator::Yank),
PendingOp::IndentRight | PendingOp::IndentLeft => None,
}
}
fn change_word_to_word_end(op: Operator, motion: Motion, editor: &EditorState) -> Motion {
if op != Operator::Change {
return motion;
}
let cursor = editor.cursor.offset;
let on_blank =
cursor < editor.buffer.len_chars() && editor.buffer.rope().char(cursor).is_whitespace();
if on_blank {
return motion;
}
match motion {
Motion::WordForward => Motion::CurrentWordEnd,
Motion::BigWordForward => Motion::CurrentBigWordEnd,
_ => motion,
}
}
fn is_count_digit(c: char, acc: Option<u32>) -> bool {
c.is_ascii_digit() && !(c == '0' && acc.is_none())
}
fn accumulate(acc: Option<u32>, c: char) -> u32 {
let digit = c.to_digit(10).unwrap_or(0);
acc.unwrap_or(0).saturating_mul(10).saturating_add(digit)
}
fn count_of(vim: &VimState) -> u32 {
vim.count.unwrap_or(1).clamp(1, COUNT_CAP)
}
fn line_jump(motion: Motion, count: Option<u32>) -> Motion {
match (motion, count) {
(Motion::DocStart | Motion::DocEnd, Some(n)) => Motion::GoToLine(n),
_ => motion,
}
}
fn operand_count(vim: &VimState) -> Option<u32> {
if vim.count.is_none() && vim.motion_count.is_none() {
return None;
}
Some(
vim.count
.unwrap_or(1)
.saturating_mul(vim.motion_count.unwrap_or(1))
.max(1),
)
}
fn is_charwise_visual(vim: &VimState) -> bool {
vim.sub_mode == VimSubMode::Visual
}
fn cell_limit(vim: &VimState) -> CellLimit {
if is_charwise_visual(vim) {
CellLimit::LastChar
} else {
CellLimit::Append
}
}
fn apply_motion(
editor: &mut EditorState,
motion: Motion,
count: u32,
vh: usize,
vw: usize,
visual: bool,
limit: CellLimit,
) {
let target = resolve_scoped_motion(editor, motion, count, limit);
move_to_offset(editor, target, vh, vw, visual);
}
fn move_to_offset(editor: &mut EditorState, target: usize, vh: usize, vw: usize, visual: bool) {
ensure_editing(editor);
if !visual {
clear_selection(editor);
}
editor.cursor.offset = target.min(editor.buffer.len_chars());
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
after_move(editor, vh, vw);
if visual {
extend_selection(editor);
}
}
fn feed_hjkl(editor: &mut EditorState, c: char, vh: usize, vw: usize, charwise_visual: bool) {
ensure_editing(editor);
let mut moved = true;
match c {
'h' => {
let held = charwise_visual && visual_cell_step(editor, false);
if !held && !editor.try_table_move_horizontal( false) {
editor.cursor.move_left(&editor.buffer);
}
}
'l' => {
let held = charwise_visual && visual_cell_step(editor, true);
if !held && !editor.try_table_move_horizontal( true) {
editor.cursor.move_right(&editor.buffer);
}
}
'j' => {
if editor.try_table_move_vertical( true, vh, vw) {
moved = false;
} else {
editor.move_cursor_line( true, false, vw);
}
}
'k' => {
if editor.try_table_move_vertical( false, vh, vw) {
moved = false;
} else {
editor.move_cursor_line( false, false, vw);
}
}
_ => unreachable!("feed_hjkl only handles h/j/k/l"),
}
if moved {
after_move(editor, vh, vw);
}
}
fn is_passthrough_chord(key: &KeyEvent) -> bool {
key.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
}
fn ensure_editing(editor: &mut EditorState) {
if editor.mode == Mode::Preview {
editor.mode = Mode::Rendered;
}
}
fn enter_insert(vim: &mut VimState, editor: &mut EditorState) {
ensure_editing(editor);
vim.sub_mode = VimSubMode::Insert;
}
fn enter_visual(vim: &mut VimState, editor: &mut EditorState, line: bool) {
ensure_editing(editor);
vim.sub_mode = if line {
VimSubMode::VisualLine
} else {
VimSubMode::Visual
};
if !line {
pull_cursor_into_cell(editor);
}
let offset = editor.cursor.offset;
vim.visual_anchor = Some(offset);
editor.selection = Some(Selection {
anchor: offset,
active: offset,
});
}
fn pull_cursor_into_cell(editor: &mut EditorState) {
if let Some(offset) = visual_endpoint_in_cell(editor, editor.cursor.offset) {
editor.cursor.offset = offset;
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
}
}
fn pull_anchor_into_cell(vim: &mut VimState, editor: &mut EditorState) {
let Some(anchor) = vim.visual_anchor else {
return;
};
let Some(pulled) = visual_endpoint_in_cell(editor, anchor) else {
return;
};
vim.visual_anchor = Some(pulled);
if let Some(sel) = editor.selection.as_mut() {
sel.anchor = pulled;
}
}
fn extend_selection(editor: &mut EditorState) {
let active = editor.cursor.offset;
match editor.selection.as_mut() {
Some(sel) => sel.active = active,
None => {
editor.selection = Some(Selection {
anchor: active,
active,
})
}
}
}
fn open_line(vim: &mut VimState, editor: &mut EditorState, below: bool, vh: usize, vw: usize) {
ensure_editing(editor);
if open_table_row(editor, below, vh, vw) {
after_edit(editor, vh, vw);
vim.sub_mode = VimSubMode::Insert;
return;
}
if open_list_continue(editor, below) {
after_edit(editor, vh, vw);
vim.sub_mode = VimSubMode::Insert;
return;
}
let (line, _) = editor.cursor.line_col(&editor.buffer);
let line_start = editor.buffer.line_to_char(line);
if below {
let mut probe = editor.cursor;
probe.move_line_end(&editor.buffer);
editor.apply_delta(EditDelta {
offset: probe.offset,
removed: String::new(),
inserted: "\n".to_string(),
});
} else {
editor.apply_delta(EditDelta {
offset: line_start,
removed: String::new(),
inserted: "\n".to_string(),
});
editor.cursor.offset = line_start;
editor.update_cursor_block();
}
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
after_move(editor, vh, vw);
vim.sub_mode = VimSubMode::Insert;
}
fn clear_selection(editor: &mut EditorState) {
editor.selection = None;
}
fn after_move(editor: &mut EditorState, vh: usize, vw: usize) {
editor.update_cursor_block();
editor.ensure_cursor_visible(vh, vw);
}
fn after_edit(editor: &mut EditorState, vh: usize, vw: usize) {
if editor.mode != Mode::Raw {
editor.flush_parsed_if_dirty();
}
editor.update_cursor_block();
editor.ensure_cursor_visible(vh, vw);
}
fn start_cmdline(vim: &mut VimState, kind: CmdLineKind) {
vim.reset_pending();
vim.cmdline = Some(CmdLineState::new(kind));
}
fn search_repeat(editor: &mut EditorState, forward: bool, count: u32, vh: usize, vw: usize) {
if editor.search.is_none() {
return;
}
editor.ensure_search_fresh();
for _ in 0..count.max(1) {
if let Some(s) = editor.search.as_mut() {
if forward {
s.advance_focus();
} else {
s.retreat_focus();
}
}
}
editor.sync_cursor_to_search_focus();
editor.scroll_focused_match_into_view(vh, vw);
}
fn search_word_outcome(editor: &mut EditorState, forward: bool) -> VimOutcome {
match word_under_cursor_at(&editor.buffer, editor.cursor.offset) {
Some((start, keyword)) => {
editor.cursor.offset = start;
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
editor.update_cursor_block();
VimOutcome::EnterSearch {
forward,
query: crate::search::escape::escape(&keyword),
}
}
None => VimOutcome::Consumed,
}
}
fn move_first_non_blank(editor: &mut EditorState) {
let line = editor.buffer.char_to_line(editor.cursor.offset);
editor.cursor.offset = first_non_blank(&editor.buffer, line);
editor.cursor.preferred_col = editor.cursor.cell_col(&editor.buffer);
editor.update_cursor_block();
}