From 9f206200e54914662320c371d156ace566ba600a Mon Sep 17 00:00:00 2001 From: rhysd Date: Fri, 17 Nov 2023 23:21:57 +0900 Subject: [PATCH] refactor Vim emulation as state machine --- README.md | 2 +- examples/vim.rs | 691 +++++++++++++++++++++++------------------------- 2 files changed, 335 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index c09f8cd..cbf5c6f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Simple textarea with variable height following the number of lines. cargo run --example vim ``` -Vim-like modal text editor. +Vim-like modal text editor. Vim emulation is implemented as a state machine. ### [`popup_placeholder`](./examples/popup_placeholder.rs) diff --git a/examples/vim.rs b/examples/vim.rs index 058c08d..e49366f 100644 --- a/examples/vim.rs +++ b/examples/vim.rs @@ -52,6 +52,332 @@ impl fmt::Display for Mode { } } +// State of Vim emulation +struct Vim { + mode: Mode, + pending: Input, // Pending input to handle a sequence with two keys like gg +} + +// How the Vim emulation state transitions +enum Transition { + Nop, + Mode(Mode), + Pending(Input), + Quit, +} + +impl Vim { + fn new(mode: Mode) -> Self { + Self { + mode, + pending: Input::default(), + } + } + + fn with_pending(self, pending: Input) -> Self { + Self { + mode: self.mode, + pending, + } + } + + fn input(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition { + if input.key == Key::Null { + return Transition::Nop; + } + + match self.mode { + Mode::Normal | Mode::Visual | Mode::Operator(_) => { + match input { + Input { + key: Key::Char('h'), + .. + } => textarea.move_cursor(CursorMove::Back), + Input { + key: Key::Char('j'), + .. + } => textarea.move_cursor(CursorMove::Down), + Input { + key: Key::Char('k'), + .. + } => textarea.move_cursor(CursorMove::Up), + Input { + key: Key::Char('l'), + .. + } => textarea.move_cursor(CursorMove::Forward), + Input { + key: Key::Char('w'), + .. + } => textarea.move_cursor(CursorMove::WordForward), + Input { + key: Key::Char('b'), + ctrl: false, + .. + } => textarea.move_cursor(CursorMove::WordBack), + Input { + key: Key::Char('^'), + .. + } => textarea.move_cursor(CursorMove::Head), + Input { + key: Key::Char('$'), + .. + } => textarea.move_cursor(CursorMove::End), + Input { + key: Key::Char('D'), + .. + } => { + textarea.delete_line_by_end(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('C'), + .. + } => { + textarea.delete_line_by_end(); + textarea.cancel_selection(); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('p'), + .. + } => { + textarea.paste(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('u'), + ctrl: false, + .. + } => { + textarea.undo(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('r'), + ctrl: true, + .. + } => { + textarea.redo(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('x'), + .. + } => { + textarea.delete_next_char(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('i'), + .. + } => { + textarea.cancel_selection(); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('a'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::Forward); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('A'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::End); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('o'), + .. + } => { + textarea.move_cursor(CursorMove::End); + textarea.insert_newline(); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('O'), + .. + } => { + textarea.move_cursor(CursorMove::Head); + textarea.insert_newline(); + textarea.move_cursor(CursorMove::Up); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('I'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::Head); + return Transition::Mode(Mode::Insert); + } + Input { + key: Key::Char('q'), + .. + } => return Transition::Quit, + Input { + key: Key::Char('e'), + ctrl: true, + .. + } => textarea.scroll((1, 0)), + Input { + key: Key::Char('y'), + ctrl: true, + .. + } => textarea.scroll((-1, 0)), + Input { + key: Key::Char('d'), + ctrl: true, + .. + } => textarea.scroll(Scrolling::HalfPageDown), + Input { + key: Key::Char('u'), + ctrl: true, + .. + } => textarea.scroll(Scrolling::HalfPageUp), + Input { + key: Key::Char('f'), + ctrl: true, + .. + } => textarea.scroll(Scrolling::PageDown), + Input { + key: Key::Char('b'), + ctrl: true, + .. + } => textarea.scroll(Scrolling::PageUp), + Input { + key: Key::Char('v'), + ctrl: false, + .. + } if self.mode == Mode::Normal => { + textarea.start_selection(); + return Transition::Mode(Mode::Visual); + } + Input { + key: Key::Char('V'), + ctrl: false, + .. + } if self.mode == Mode::Normal => { + textarea.move_cursor(CursorMove::Head); + textarea.start_selection(); + textarea.move_cursor(CursorMove::End); + return Transition::Mode(Mode::Visual); + } + Input { key: Key::Esc, .. } + | Input { + key: Key::Char('v'), + ctrl: false, + .. + } if self.mode == Mode::Visual => { + textarea.cancel_selection(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('g'), + ctrl: false, + .. + } if matches!( + self.pending, + Input { + key: Key::Char('g'), + ctrl: false, + .. + } + ) => + { + textarea.move_cursor(CursorMove::Top) + } + Input { + key: Key::Char('G'), + ctrl: false, + .. + } => textarea.move_cursor(CursorMove::Bottom), + Input { + key: Key::Char(c), + ctrl: false, + .. + } if self.mode == Mode::Operator(c) => { + // Handle yy, dd, cc. (This is not strictly the same behavior as Vim) + textarea.move_cursor(CursorMove::Head); + textarea.start_selection(); + let cursor = textarea.cursor(); + textarea.move_cursor(CursorMove::Down); + if cursor == textarea.cursor() { + textarea.move_cursor(CursorMove::End); // At the last line, move to end of the line instead + } + } + Input { + key: Key::Char(op @ ('y' | 'd' | 'c')), + ctrl: false, + .. + } if self.mode == Mode::Normal => { + textarea.start_selection(); + return Transition::Mode(Mode::Operator(op)); + } + Input { + key: Key::Char('y'), + ctrl: false, + .. + } if self.mode == Mode::Visual => { + textarea.copy(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('d'), + ctrl: false, + .. + } if self.mode == Mode::Visual => { + textarea.cut(); + return Transition::Mode(Mode::Normal); + } + Input { + key: Key::Char('c'), + ctrl: false, + .. + } if self.mode == Mode::Visual => { + textarea.cut(); + return Transition::Mode(Mode::Insert); + } + input => return Transition::Pending(input), + } + + // Handle the pending operator + match self.mode { + Mode::Operator('y') => { + textarea.copy(); + Transition::Mode(Mode::Normal) + } + Mode::Operator('d') => { + textarea.cut(); + Transition::Mode(Mode::Normal) + } + Mode::Operator('c') => { + textarea.cut(); + Transition::Mode(Mode::Insert) + } + _ => Transition::Nop, + } + } + Mode::Insert => match input { + Input { key: Key::Esc, .. } + | Input { + key: Key::Char('c'), + ctrl: true, + .. + } => Transition::Mode(Mode::Normal), + input => { + textarea.input(input); // Use default key mappings in insert mode + Transition::Mode(Mode::Insert) + } + }, + } + } +} + fn main() -> io::Result<()> { let stdout = io::stdout(); let mut stdout = stdout.lock(); @@ -70,374 +396,25 @@ fn main() -> io::Result<()> { TextArea::default() }; - let mut mode = Mode::Normal; - let mut pending = Input::default(); - + let mut vim = Vim::new(Mode::Normal); loop { // Show help message and current mode in title of the block - let title = format!("{} MODE ({})", mode, mode.help_message()); + let title = format!("{} MODE ({})", vim.mode, vim.mode.help_message()); let block = Block::default().borders(Borders::ALL).title(title); textarea.set_block(block); // Change the cursor color looking at current mode - let color = mode.cursor_color(); + let color = vim.mode.cursor_color(); let style = Style::default().fg(color).add_modifier(Modifier::REVERSED); textarea.set_cursor_style(style); term.draw(|f| f.render_widget(textarea.widget(), f.size()))?; - let input: Input = crossterm::event::read()?.into(); - if input.key == Key::Null { - continue; - } - - let operator = if let Mode::Operator(op) = mode { - textarea.start_selection(); - Some(op) - } else { - None - }; - - mode = match mode { - Mode::Normal | Mode::Visual | Mode::Operator(_) => match input { - // Mappings in normal mode - Input { - key: Key::Char('h'), - .. - } => { - textarea.move_cursor(CursorMove::Back); - mode - } - Input { - key: Key::Char('j'), - .. - } => { - textarea.move_cursor(CursorMove::Down); - mode - } - Input { - key: Key::Char('k'), - .. - } => { - textarea.move_cursor(CursorMove::Up); - mode - } - Input { - key: Key::Char('l'), - .. - } => { - textarea.move_cursor(CursorMove::Forward); - mode - } - Input { - key: Key::Char('w'), - .. - } => { - textarea.move_cursor(CursorMove::WordForward); - mode - } - Input { - key: Key::Char('b'), - ctrl: false, - .. - } => { - textarea.move_cursor(CursorMove::WordBack); - mode - } - Input { - key: Key::Char('^'), - .. - } => { - textarea.move_cursor(CursorMove::Head); - mode - } - Input { - key: Key::Char('$'), - .. - } => { - textarea.move_cursor(CursorMove::End); - mode - } - Input { - key: Key::Char('D'), - .. - } => { - textarea.delete_line_by_end(); - Mode::Normal - } - Input { - key: Key::Char('C'), - .. - } => { - textarea.delete_line_by_end(); - textarea.cancel_selection(); - Mode::Insert - } - Input { - key: Key::Char('p'), - .. - } => { - textarea.paste(); - Mode::Normal - } - Input { - key: Key::Char('u'), - ctrl: false, - .. - } => { - textarea.undo(); - Mode::Normal - } - Input { - key: Key::Char('r'), - ctrl: true, - .. - } => { - textarea.redo(); - Mode::Normal - } - Input { - key: Key::Char('x'), - .. - } => { - textarea.delete_next_char(); - Mode::Normal - } - Input { - key: Key::Char('i'), - .. - } => { - textarea.cancel_selection(); - Mode::Insert - } - Input { - key: Key::Char('a'), - .. - } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::Forward); - Mode::Insert - } - Input { - key: Key::Char('A'), - .. - } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::End); - Mode::Insert - } - Input { - key: Key::Char('o'), - .. - } => { - textarea.move_cursor(CursorMove::End); - textarea.insert_newline(); - Mode::Insert - } - Input { - key: Key::Char('O'), - .. - } => { - textarea.move_cursor(CursorMove::Head); - textarea.insert_newline(); - textarea.move_cursor(CursorMove::Up); - Mode::Insert - } - Input { - key: Key::Char('I'), - .. - } => { - textarea.cancel_selection(); - textarea.move_cursor(CursorMove::Head); - Mode::Insert - } - Input { - key: Key::Char('q'), - .. - } => break, - Input { - key: Key::Char('e'), - ctrl: true, - .. - } => { - textarea.scroll((1, 0)); - mode - } - Input { - key: Key::Char('y'), - ctrl: true, - .. - } => { - textarea.scroll((-1, 0)); - mode - } - Input { - key: Key::Char('d'), - ctrl: true, - .. - } => { - textarea.scroll(Scrolling::HalfPageDown); - mode - } - Input { - key: Key::Char('u'), - ctrl: true, - .. - } => { - textarea.scroll(Scrolling::HalfPageUp); - mode - } - Input { - key: Key::Char('f'), - ctrl: true, - .. - } => { - textarea.scroll(Scrolling::PageDown); - mode - } - Input { - key: Key::Char('b'), - ctrl: true, - .. - } => { - textarea.scroll(Scrolling::PageUp); - mode - } - Input { - key: Key::Char('v'), - ctrl: false, - .. - } if mode == Mode::Normal => { - textarea.start_selection(); - Mode::Visual - } - Input { - key: Key::Char('V'), - ctrl: false, - .. - } if mode == Mode::Normal => { - textarea.move_cursor(CursorMove::Head); - textarea.start_selection(); - textarea.move_cursor(CursorMove::End); - Mode::Visual - } - Input { key: Key::Esc, .. } - | Input { - key: Key::Char('v'), - ctrl: false, - .. - } if mode == Mode::Visual => { - textarea.cancel_selection(); - Mode::Normal - } - Input { - key: Key::Char('g'), - ctrl: false, - .. - } if matches!( - pending, - Input { - key: Key::Char('g'), - ctrl: false, - .. - } - ) => - { - textarea.move_cursor(CursorMove::Top); - pending = Input::default(); - mode - } - Input { - key: Key::Char('G'), - ctrl: false, - .. - } => { - textarea.move_cursor(CursorMove::Bottom); - mode - } - Input { - key: Key::Char(c), - ctrl: false, - .. - } if operator == Some(c) => { - // Handle yy, dd, cc. (This is not strictly the same behavior as Vim) - textarea.move_cursor(CursorMove::Head); - textarea.start_selection(); - let cursor = textarea.cursor(); - textarea.move_cursor(CursorMove::Down); - if cursor == textarea.cursor() { - textarea.move_cursor(CursorMove::End); // At the last line, move to end of the line instead - } - mode - } - Input { - key: Key::Char(op @ ('y' | 'd' | 'c')), - ctrl: false, - .. - } if mode == Mode::Normal => Mode::Operator(op), - Input { - key: Key::Char('y'), - ctrl: false, - .. - } if mode == Mode::Visual => { - textarea.copy(); - Mode::Normal - } - Input { - key: Key::Char('d'), - ctrl: false, - .. - } if mode == Mode::Visual => { - textarea.cut(); - Mode::Normal - } - Input { - key: Key::Char('c'), - ctrl: false, - .. - } if mode == Mode::Visual => { - textarea.cut(); - Mode::Insert - } - input => { - pending = input; - mode - } - }, - Mode::Insert => match input { - Input { key: Key::Esc, .. } - | Input { - key: Key::Char('c'), - ctrl: true, - .. - } => { - Mode::Normal // Back to normal mode with Esc or Ctrl+C - } - input => { - textarea.input(input); // Use default key mappings in insert mode - mode - } - }, - }; - - if let Some(op) = operator { - if mode != Mode::Normal && mode != Mode::Visual { - mode = match op { - 'y' => { - textarea.copy(); - Mode::Normal - } - 'd' => { - textarea.cut(); - Mode::Normal - } - 'c' => { - textarea.cut(); - Mode::Insert - } - _ => mode, - } - } + vim = match vim.input(crossterm::event::read()?.into(), &mut textarea) { + Transition::Nop => vim, + Transition::Mode(mode) => Vim::new(mode), + Transition::Pending(input) => vim.with_pending(input), + Transition::Quit => break, } }