Lines
86.92 %
Functions
57.38 %
Branches
100 %
//! Text-input editor supporting both emacs-style readline bindings and
//! a minimal vim-motion mode. The editor is pure state — it does not
//! own any terminal or ratatui types, so it can be tested without I/O.
/// Edit mode for text-input widgets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditMode {
Emacs,
Vim,
}
/// Vim sub-mode. Only relevant when [`EditMode::Vim`] is active.
pub enum VimMode {
Normal,
Insert,
/// A single-line text buffer with a cursor. Multi-line editing is not
/// needed for any current form field (transaction notes are one-line
/// memos today) but could grow in-place later.
#[derive(Debug, Clone)]
pub struct Editor {
buffer: String,
cursor: usize,
mode: EditMode,
vim: VimMode,
impl Editor {
#[must_use]
pub fn new(mode: EditMode) -> Self {
Self {
buffer: String::new(),
cursor: 0,
mode,
vim: VimMode::Insert,
pub fn with_buffer(mode: EditMode, buffer: impl Into<String>) -> Self {
let buf: String = buffer.into();
let cursor = buf.chars().count();
buffer: buf,
cursor,
pub fn buffer(&self) -> &str {
&self.buffer
pub fn cursor(&self) -> usize {
self.cursor
pub fn mode(&self) -> EditMode {
self.mode
pub fn vim_mode(&self) -> VimMode {
self.vim
pub fn set_mode(&mut self, mode: EditMode) {
self.mode = mode;
self.vim = VimMode::Insert;
fn chars(&self) -> Vec<char> {
self.buffer.chars().collect()
fn rebuild(&mut self, chars: &[char]) {
self.buffer = chars.iter().collect();
if self.cursor > chars.len() {
self.cursor = chars.len();
/// Insert a single character at the cursor. In vim mode, insertion
/// only happens in `Insert` sub-mode; in emacs mode always.
pub fn insert_char(&mut self, c: char) {
if self.mode == EditMode::Vim && self.vim == VimMode::Normal {
return;
let mut chars = self.chars();
chars.insert(self.cursor, c);
self.cursor += 1;
self.rebuild(&chars);
pub fn delete_backward(&mut self) {
if self.cursor == 0 {
chars.remove(self.cursor - 1);
self.cursor -= 1;
pub fn delete_forward(&mut self) {
if self.cursor < chars.len() {
chars.remove(self.cursor);
pub fn move_left(&mut self) {
if self.cursor > 0 {
pub fn move_right(&mut self) {
let len = self.chars().len();
if self.cursor < len {
pub fn move_home(&mut self) {
self.cursor = 0;
pub fn move_end(&mut self) {
self.cursor = self.chars().len();
/// Delete from the cursor to end-of-line (emacs C-k).
pub fn kill_to_end(&mut self) {
chars.truncate(self.cursor);
/// Delete the word before the cursor (emacs C-w).
pub fn kill_word_backward(&mut self) {
let mut i = self.cursor;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
while i > 0 && !chars[i - 1].is_whitespace() {
chars.drain(i..self.cursor);
self.cursor = i;
pub fn enter_insert_mode(&mut self) {
pub fn enter_normal_mode(&mut self) {
self.vim = VimMode::Normal;
/// Execute a vim normal-mode motion or edit by name. Keeping this
/// symbolic rather than key-driven means the event layer can
/// translate key events into these names, and the pure engine is
/// trivial to unit-test.
pub fn vim_action(&mut self, action: VimAction) {
match action {
VimAction::MoveLeft => self.move_left(),
VimAction::MoveRight => self.move_right(),
VimAction::MoveHome => self.move_home(),
VimAction::MoveEnd => self.move_end(),
VimAction::WordForward => self.vim_word_forward(),
VimAction::WordBackward => self.vim_word_backward(),
VimAction::DeleteChar => self.delete_forward(),
VimAction::DeleteWordForward => self.vim_delete_word_forward(),
VimAction::DeleteWordBackward => self.kill_word_backward(),
VimAction::InsertAtCursor => self.enter_insert_mode(),
VimAction::InsertAfterCursor => {
self.move_right();
self.enter_insert_mode();
VimAction::InsertAtLineStart => {
self.move_home();
VimAction::InsertAtLineEnd => {
self.move_end();
fn vim_word_forward(&mut self) {
let chars = self.chars();
while i < chars.len() && !chars[i].is_whitespace() {
i += 1;
while i < chars.len() && chars[i].is_whitespace() {
fn vim_word_backward(&mut self) {
fn vim_delete_word_forward(&mut self) {
let start = self.cursor;
let mut i = start;
chars.drain(start..i);
/// Named vim-mode actions the event layer can invoke.
pub enum VimAction {
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
WordForward,
WordBackward,
DeleteChar,
DeleteWordForward,
DeleteWordBackward,
InsertAtCursor,
InsertAfterCursor,
InsertAtLineStart,
InsertAtLineEnd,
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emacs_insert_appends_at_cursor() {
let mut e = Editor::new(EditMode::Emacs);
e.insert_char('a');
e.insert_char('b');
e.insert_char('c');
assert_eq!(e.buffer(), "abc");
assert_eq!(e.cursor(), 3);
fn emacs_kill_to_end_truncates() {
let mut e = Editor::with_buffer(EditMode::Emacs, "hello world");
e.move_home();
for _ in 0..5 {
e.move_right();
e.kill_to_end();
assert_eq!(e.buffer(), "hello");
assert_eq!(e.cursor(), 5);
fn emacs_kill_word_backward_drops_word_and_spaces() {
e.move_end();
e.kill_word_backward();
assert_eq!(e.buffer(), "hello ");
fn delete_backward_at_start_is_noop() {
e.delete_backward();
assert_eq!(e.buffer(), "");
assert_eq!(e.cursor(), 0);
fn move_right_saturates_at_end() {
let mut e = Editor::with_buffer(EditMode::Emacs, "ab");
assert_eq!(e.cursor(), 2);
fn vim_normal_mode_ignores_insert_char() {
let mut e = Editor::new(EditMode::Vim);
e.enter_normal_mode();
fn vim_i_enters_insert_mode() {
e.vim_action(VimAction::InsertAtCursor);
assert_eq!(e.vim_mode(), VimMode::Insert);
e.insert_char('x');
assert_eq!(e.buffer(), "x");
fn vim_word_forward_skips_to_next_token() {
let mut e = Editor::with_buffer(EditMode::Vim, "hello world foo");
e.vim_action(VimAction::WordForward);
assert_eq!(e.cursor(), 6);
assert_eq!(e.cursor(), 12);
fn vim_word_backward_reverses_word_forward() {
e.vim_action(VimAction::WordBackward);
fn vim_dw_deletes_word_forward() {
e.vim_action(VimAction::DeleteWordForward);
assert_eq!(e.buffer(), "world foo");
fn vim_capital_a_appends_at_eol_in_insert_mode() {
let mut e = Editor::with_buffer(EditMode::Vim, "ab");
e.vim_action(VimAction::InsertAtLineEnd);
fn switching_mode_resets_to_insert() {
e.set_mode(EditMode::Emacs);