From 409185f6530611a6c394318895f557c69923564d Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 16 Nov 2023 15:59:06 +0900 Subject: [PATCH 1/5] rename `modal` example to `vim` since operator-pending mode is Vim-specific --- Cargo.toml | 2 +- README.md | 50 ++++------------------------------- examples/{modal.rs => vim.rs} | 0 3 files changed, 6 insertions(+), 46 deletions(-) rename examples/{modal.rs => vim.rs} (100%) diff --git a/Cargo.toml b/Cargo.toml index 0701def..0ff0735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ name = "variable" required-features = ["crossterm"] [[example]] -name = "modal" +name = "vim" required-features = ["crossterm"] [[example]] diff --git a/README.md b/README.md index 0a21d2f..c09f8cd 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,13 @@ cargo run --example variable Simple textarea with variable height following the number of lines. -### [`modal`](./examples/modal.rs) +### [`vim`](./examples/vim.rs) ```sh -cargo run --example modal +cargo run --example vim ``` -Simple modal text editor like `vi`. +Vim-like modal text editor. ### [`popup_placeholder`](./examples/popup_placeholder.rs) @@ -513,49 +513,9 @@ notify how to move the cursor. | `textarea.scroll(Scrolling::HalfPageUp)` | Scroll up the viewport by half-page | | `textarea.scroll((row, col))` | Scroll down the viewport to (row, col) position | -To define your own key mappings, simply call the above methods in your code instead of `TextArea::input()` method. The -following example defines modal key mappings like Vim. +To define your own key mappings, simply call the above methods in your code instead of `TextArea::input()` method. -```rust,ignore -use crossterm::event::{Event, read}; -use tui_textarea::{Input, Key, CursorMove, Scrolling}; - -let mut textarea = ...; - -enum Mode { - Normal, - Insert, -} - -let mut mode = Mode::Normal; - -// Event loop -loop { - // ... - - match mode { - Mode::Normal => match read()?.into() { - 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('i'), .. } => mode = Mode::Insert, // Enter insert mode - // ...Add more mappings - _ => {}, - }, - Mode::Insert => match read()?.into() { - Input { key: Key::Esc, .. } => { - mode = Mode::Normal; // Back to normal mode with Esc or Ctrl+C - } - input => { - textarea.input(input); // Use default key mappings in insert mode - } - }, - } -} -``` - -See [`modal` example](./examples/modal.rs) for working example. It implements more Vim-like key mappings. +See the [`vim` example](./examples/vim.rs) for working example. It implements more Vim-like key modal mappings. If you don't want to use default key mappings, `TextArea::input_without_shortcuts()` method can be used instead of `TextArea::input()`. The method only handles very basic operations such as inserting/deleting single characters, tabs, diff --git a/examples/modal.rs b/examples/vim.rs similarity index 100% rename from examples/modal.rs rename to examples/vim.rs From ad8ad19e832d93eac8746dc2c3b8cfa4bb6c1bca Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 16 Nov 2023 18:15:56 +0900 Subject: [PATCH 2/5] implement visual mode for `vim` example --- examples/vim.rs | 172 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 128 insertions(+), 44 deletions(-) diff --git a/examples/vim.rs b/examples/vim.rs index 75a5068..fb3513b 100644 --- a/examples/vim.rs +++ b/examples/vim.rs @@ -13,9 +13,11 @@ use std::io; use std::io::BufRead; use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { Normal, Insert, + Visual, } impl Mode { @@ -23,6 +25,7 @@ impl Mode { match self { Self::Normal => "type q to quit, type i to enter insert mode", Self::Insert => "type Esc to back to normal mode", + Self::Visual => "type y to yank, type d to delete, type Esc to back to normal mode", } } @@ -30,6 +33,7 @@ impl Mode { match self { Self::Normal => Color::Reset, Self::Insert => Color::LightBlue, + Self::Visual => Color::LightYellow, } } } @@ -39,6 +43,7 @@ impl fmt::Display for Mode { match self { Self::Normal => write!(f, "NORMAL"), Self::Insert => write!(f, "INSERT"), + Self::Visual => write!(f, "VISUAL"), } } } @@ -63,7 +68,7 @@ impl PendingState { } } - fn transition(self, input: &Input) -> Option { + fn transition(self, input: &Input, mode: Mode) -> Option { match input { Input { key: Key::Null, .. } => None, Input { @@ -75,17 +80,17 @@ impl PendingState { key: Key::Char('d'), ctrl: false, .. - } if self != Self::D => Some(Self::D), + } if self != Self::D && mode == Mode::Normal => Some(Self::D), Input { key: Key::Char('y'), ctrl: false, .. - } if self != Self::Y => Some(Self::Y), + } if self != Self::Y && mode == Mode::Normal => Some(Self::Y), Input { key: Key::Char('c'), ctrl: false, .. - } if self != Self::C => Some(Self::C), + } if self != Self::C && mode == Mode::Normal => Some(Self::C), _ => Some(Self::None), } } @@ -127,65 +132,93 @@ fn main() -> io::Result<()> { let input = crossterm::event::read()?.into(); - if pending.operator().is_some() && !textarea.is_selecting() { + if pending.operator().is_some() && mode != Mode::Visual { textarea.start_selection(); } - let next_pending = pending.transition(&input); // Calculate next state before moving `input` + // Calculate next pending key state before moving `input` + let next_pending = pending.transition(&input, mode); - match mode { - Mode::Normal => match input { + mode = match mode { + Mode::Normal | Mode::Visual => match input { // Mappings in normal mode Input { key: Key::Char('h'), .. - } => textarea.move_cursor(CursorMove::Back), + } => { + textarea.move_cursor(CursorMove::Back); + mode + } Input { key: Key::Char('j'), .. - } => textarea.move_cursor(CursorMove::Down), + } => { + textarea.move_cursor(CursorMove::Down); + mode + } Input { key: Key::Char('k'), .. - } => textarea.move_cursor(CursorMove::Up), + } => { + textarea.move_cursor(CursorMove::Up); + mode + } Input { key: Key::Char('l'), .. - } => textarea.move_cursor(CursorMove::Forward), + } => { + textarea.move_cursor(CursorMove::Forward); + mode + } Input { key: Key::Char('w'), .. - } => textarea.move_cursor(CursorMove::WordForward), + } => { + textarea.move_cursor(CursorMove::WordForward); + mode + } Input { key: Key::Char('b'), ctrl: false, .. - } => textarea.move_cursor(CursorMove::WordBack), + } => { + textarea.move_cursor(CursorMove::WordBack); + mode + } Input { key: Key::Char('^'), .. - } => textarea.move_cursor(CursorMove::Head), + } => { + textarea.move_cursor(CursorMove::Head); + mode + } Input { key: Key::Char('$'), .. - } => textarea.move_cursor(CursorMove::End), + } => { + 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(); - mode = Mode::Insert; + textarea.cancel_selection(); + Mode::Insert } Input { key: Key::Char('p'), .. } => { textarea.paste(); + Mode::Normal } Input { key: Key::Char('u'), @@ -193,6 +226,7 @@ fn main() -> io::Result<()> { .. } => { textarea.undo(); + Mode::Normal } Input { key: Key::Char('r'), @@ -200,30 +234,37 @@ fn main() -> io::Result<()> { .. } => { textarea.redo(); + Mode::Normal } Input { key: Key::Char('x'), .. } => { textarea.delete_next_char(); + Mode::Normal } Input { key: Key::Char('i'), .. - } => mode = Mode::Insert, + } => { + textarea.cancel_selection(); + Mode::Insert + } Input { key: Key::Char('a'), .. } => { + textarea.cancel_selection(); textarea.move_cursor(CursorMove::Forward); - mode = Mode::Insert; + Mode::Insert } Input { key: Key::Char('A'), .. } => { + textarea.cancel_selection(); textarea.move_cursor(CursorMove::End); - mode = Mode::Insert; + Mode::Insert } Input { key: Key::Char('o'), @@ -231,7 +272,7 @@ fn main() -> io::Result<()> { } => { textarea.move_cursor(CursorMove::End); textarea.insert_newline(); - mode = Mode::Insert; + Mode::Insert } Input { key: Key::Char('O'), @@ -240,14 +281,15 @@ fn main() -> io::Result<()> { textarea.move_cursor(CursorMove::Head); textarea.insert_newline(); textarea.move_cursor(CursorMove::Up); - mode = Mode::Insert; + Mode::Insert } Input { key: Key::Char('I'), .. } => { + textarea.cancel_selection(); textarea.move_cursor(CursorMove::Head); - mode = Mode::Insert; + Mode::Insert } Input { key: Key::Char('q'), @@ -257,53 +299,84 @@ fn main() -> io::Result<()> { key: Key::Char('e'), ctrl: true, .. - } => textarea.scroll((1, 0)), + } => { + textarea.scroll((1, 0)); + mode + } Input { key: Key::Char('y'), ctrl: true, .. - } => textarea.scroll((-1, 0)), + } => { + textarea.scroll((-1, 0)); + mode + } Input { key: Key::Char('d'), ctrl: true, .. - } => textarea.scroll(Scrolling::HalfPageDown), + } => { + textarea.scroll(Scrolling::HalfPageDown); + mode + } Input { key: Key::Char('u'), ctrl: true, .. - } => textarea.scroll(Scrolling::HalfPageUp), + } => { + textarea.scroll(Scrolling::HalfPageUp); + mode + } Input { key: Key::Char('f'), ctrl: true, .. - } => textarea.scroll(Scrolling::PageDown), + } => { + textarea.scroll(Scrolling::PageDown); + mode + } Input { key: Key::Char('b'), ctrl: true, .. - } => textarea.scroll(Scrolling::PageUp), + } => { + textarea.scroll(Scrolling::PageUp); + mode + } Input { key: Key::Char('v'), ctrl: false, .. - } => textarea.start_selection(), + } 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::Esc, .. } => textarea.cancel_selection(), Input { key: Key::Char('g'), ctrl: false, .. } if pending == PendingState::G => { textarea.move_cursor(CursorMove::Top); + mode } Input { key: Key::Char('G'), @@ -311,6 +384,7 @@ fn main() -> io::Result<()> { .. } => { textarea.move_cursor(CursorMove::Bottom); + mode } Input { key: Key::Char(c), @@ -325,28 +399,33 @@ fn main() -> io::Result<()> { 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('y'), ctrl: false, .. - } if textarea.is_selecting() => textarea.copy(), + } if mode == Mode::Visual => { + textarea.copy(); + Mode::Normal + } Input { key: Key::Char('d'), ctrl: false, .. - } if textarea.is_selecting() => { + } if mode == Mode::Visual => { textarea.cut(); + Mode::Normal } Input { key: Key::Char('c'), ctrl: false, .. - } if textarea.is_selecting() => { + } if mode == Mode::Visual => { textarea.cut(); - mode = Mode::Insert; + Mode::Insert } - _ => {} + _ => mode, }, Mode::Insert => match input { Input { key: Key::Esc, .. } @@ -355,26 +434,31 @@ fn main() -> io::Result<()> { ctrl: true, .. } => { - mode = Mode::Normal; // Back to normal mode with Esc or Ctrl+C + 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(next_pending) = next_pending { - match pending { + mode = match pending { PendingState::D => { textarea.cut(); + Mode::Normal + } + PendingState::Y => { + textarea.copy(); + Mode::Normal } - PendingState::Y => textarea.copy(), PendingState::C => { textarea.cut(); - mode = Mode::Insert; + Mode::Insert } - _ => {} - } + _ => mode, + }; pending = next_pending; } } From 7c66f3ad5afc3805d948f351cde9378e0c4480f3 Mon Sep 17 00:00:00 2001 From: rhysd Date: Fri, 17 Nov 2023 21:22:36 +0900 Subject: [PATCH 3/5] make operator-pending mode variant of `Mode` --- examples/vim.rs | 128 ++++++++++++++++++++---------------------------- 1 file changed, 53 insertions(+), 75 deletions(-) diff --git a/examples/vim.rs b/examples/vim.rs index fb3513b..058c08d 100644 --- a/examples/vim.rs +++ b/examples/vim.rs @@ -18,6 +18,7 @@ enum Mode { Normal, Insert, Visual, + Operator(char), } impl Mode { @@ -26,6 +27,7 @@ impl Mode { Self::Normal => "type q to quit, type i to enter insert mode", Self::Insert => "type Esc to back to normal mode", Self::Visual => "type y to yank, type d to delete, type Esc to back to normal mode", + Self::Operator(_) => "move cursor to apply operator", } } @@ -34,6 +36,7 @@ impl Mode { Self::Normal => Color::Reset, Self::Insert => Color::LightBlue, Self::Visual => Color::LightYellow, + Self::Operator(_) => Color::LightGreen, } } } @@ -44,54 +47,7 @@ impl fmt::Display for Mode { Self::Normal => write!(f, "NORMAL"), Self::Insert => write!(f, "INSERT"), Self::Visual => write!(f, "VISUAL"), - } - } -} - -// State machine to handle pending key inputs -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PendingState { - None, - G, - D, // 'Delete' operator - Y, // 'Yank' operator - C, // 'Change' operator -} - -impl PendingState { - fn operator(&self) -> Option { - match self { - Self::D => Some('d'), - Self::Y => Some('y'), - Self::C => Some('c'), - _ => None, - } - } - - fn transition(self, input: &Input, mode: Mode) -> Option { - match input { - Input { key: Key::Null, .. } => None, - Input { - key: Key::Char('g'), - ctrl: false, - .. - } if self != Self::G => Some(Self::G), - Input { - key: Key::Char('d'), - ctrl: false, - .. - } if self != Self::D && mode == Mode::Normal => Some(Self::D), - Input { - key: Key::Char('y'), - ctrl: false, - .. - } if self != Self::Y && mode == Mode::Normal => Some(Self::Y), - Input { - key: Key::Char('c'), - ctrl: false, - .. - } if self != Self::C && mode == Mode::Normal => Some(Self::C), - _ => Some(Self::None), + Self::Operator(c) => write!(f, "OPERATOR({})", c), } } } @@ -115,7 +71,7 @@ fn main() -> io::Result<()> { }; let mut mode = Mode::Normal; - let mut pending = PendingState::None; + let mut pending = Input::default(); loop { // Show help message and current mode in title of the block @@ -130,16 +86,20 @@ fn main() -> io::Result<()> { term.draw(|f| f.render_widget(textarea.widget(), f.size()))?; - let input = crossterm::event::read()?.into(); - - if pending.operator().is_some() && mode != Mode::Visual { - textarea.start_selection(); + let input: Input = crossterm::event::read()?.into(); + if input.key == Key::Null { + continue; } - // Calculate next pending key state before moving `input` - let next_pending = pending.transition(&input, mode); + + let operator = if let Mode::Operator(op) = mode { + textarea.start_selection(); + Some(op) + } else { + None + }; mode = match mode { - Mode::Normal | Mode::Visual => match input { + Mode::Normal | Mode::Visual | Mode::Operator(_) => match input { // Mappings in normal mode Input { key: Key::Char('h'), @@ -374,8 +334,17 @@ fn main() -> io::Result<()> { key: Key::Char('g'), ctrl: false, .. - } if pending == PendingState::G => { + } if matches!( + pending, + Input { + key: Key::Char('g'), + ctrl: false, + .. + } + ) => + { textarea.move_cursor(CursorMove::Top); + pending = Input::default(); mode } Input { @@ -390,7 +359,7 @@ fn main() -> io::Result<()> { key: Key::Char(c), ctrl: false, .. - } if pending.operator() == Some(c) => { + } 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(); @@ -401,6 +370,11 @@ fn main() -> io::Result<()> { } 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, @@ -425,7 +399,10 @@ fn main() -> io::Result<()> { textarea.cut(); Mode::Insert } - _ => mode, + input => { + pending = input; + mode + } }, Mode::Insert => match input { Input { key: Key::Esc, .. } @@ -443,23 +420,24 @@ fn main() -> io::Result<()> { }, }; - if let Some(next_pending) = next_pending { - mode = match pending { - PendingState::D => { - textarea.cut(); - Mode::Normal + 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, } - PendingState::Y => { - textarea.copy(); - Mode::Normal - } - PendingState::C => { - textarea.cut(); - Mode::Insert - } - _ => mode, - }; - pending = next_pending; + } } } From 9f206200e54914662320c371d156ace566ba600a Mon Sep 17 00:00:00 2001 From: rhysd Date: Fri, 17 Nov 2023 23:21:57 +0900 Subject: [PATCH 4/5] 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, } } From fc08babaaf95102b4b9e5eafbb9567fb8da0c321 Mon Sep 17 00:00:00 2001 From: rhysd Date: Fri, 17 Nov 2023 23:54:23 +0900 Subject: [PATCH 5/5] add screencast of `vim` example --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cbf5c6f..519c718 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ cargo run --example vim Vim-like modal text editor. Vim emulation is implemented as a state machine. +Vim emulation example + ### [`popup_placeholder`](./examples/popup_placeholder.rs) ```sh