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..519c718 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,15 @@ 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. Vim emulation is implemented as a state machine. + +Vim emulation example ### [`popup_placeholder`](./examples/popup_placeholder.rs) @@ -513,49 +515,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/modal.rs deleted file mode 100644 index 75a5068..0000000 --- a/examples/modal.rs +++ /dev/null @@ -1,393 +0,0 @@ -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::backend::CrosstermBackend; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders}; -use ratatui::Terminal; -use std::env; -use std::fmt; -use std::fs; -use std::io; -use std::io::BufRead; -use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; - -enum Mode { - Normal, - Insert, -} - -impl Mode { - fn help_message(&self) -> &'static str { - match self { - Self::Normal => "type q to quit, type i to enter insert mode", - Self::Insert => "type Esc to back to normal mode", - } - } - - fn cursor_color(&self) -> Color { - match self { - Self::Normal => Color::Reset, - Self::Insert => Color::LightBlue, - } - } -} - -impl fmt::Display for Mode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { - match self { - Self::Normal => write!(f, "NORMAL"), - Self::Insert => write!(f, "INSERT"), - } - } -} - -// 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) -> 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 => Some(Self::D), - Input { - key: Key::Char('y'), - ctrl: false, - .. - } if self != Self::Y => Some(Self::Y), - Input { - key: Key::Char('c'), - ctrl: false, - .. - } if self != Self::C => Some(Self::C), - _ => Some(Self::None), - } - } -} - -fn main() -> io::Result<()> { - let stdout = io::stdout(); - let mut stdout = stdout.lock(); - - enable_raw_mode()?; - crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut term = Terminal::new(backend)?; - - let mut textarea = if let Some(path) = env::args().nth(1) { - let file = fs::File::open(path)?; - io::BufReader::new(file) - .lines() - .collect::>()? - } else { - TextArea::default() - }; - - let mut mode = Mode::Normal; - let mut pending = PendingState::None; - - loop { - // Show help message and current mode in title of the block - let title = format!("{} MODE ({})", mode, 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 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 = crossterm::event::read()?.into(); - - if pending.operator().is_some() && !textarea.is_selecting() { - textarea.start_selection(); - } - let next_pending = pending.transition(&input); // Calculate next state before moving `input` - - match mode { - Mode::Normal => match input { - // Mappings in normal mode - 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(); - } - Input { - key: Key::Char('C'), - .. - } => { - textarea.delete_line_by_end(); - mode = Mode::Insert; - } - Input { - key: Key::Char('p'), - .. - } => { - textarea.paste(); - } - Input { - key: Key::Char('u'), - ctrl: false, - .. - } => { - textarea.undo(); - } - Input { - key: Key::Char('r'), - ctrl: true, - .. - } => { - textarea.redo(); - } - Input { - key: Key::Char('x'), - .. - } => { - textarea.delete_next_char(); - } - Input { - key: Key::Char('i'), - .. - } => mode = Mode::Insert, - Input { - key: Key::Char('a'), - .. - } => { - textarea.move_cursor(CursorMove::Forward); - mode = Mode::Insert; - } - Input { - key: Key::Char('A'), - .. - } => { - textarea.move_cursor(CursorMove::End); - mode = Mode::Insert; - } - Input { - key: Key::Char('o'), - .. - } => { - textarea.move_cursor(CursorMove::End); - textarea.insert_newline(); - mode = Mode::Insert; - } - Input { - key: Key::Char('O'), - .. - } => { - textarea.move_cursor(CursorMove::Head); - textarea.insert_newline(); - textarea.move_cursor(CursorMove::Up); - mode = Mode::Insert; - } - Input { - key: Key::Char('I'), - .. - } => { - textarea.move_cursor(CursorMove::Head); - mode = Mode::Insert; - } - Input { - key: Key::Char('q'), - .. - } => break, - 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, - .. - } => textarea.start_selection(), - Input { - key: Key::Char('V'), - ctrl: false, - .. - } => { - textarea.move_cursor(CursorMove::Head); - textarea.start_selection(); - textarea.move_cursor(CursorMove::End); - } - Input { key: Key::Esc, .. } => textarea.cancel_selection(), - Input { - key: Key::Char('g'), - ctrl: false, - .. - } if pending == PendingState::G => { - 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 pending.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 - } - } - Input { - key: Key::Char('y'), - ctrl: false, - .. - } if textarea.is_selecting() => textarea.copy(), - Input { - key: Key::Char('d'), - ctrl: false, - .. - } if textarea.is_selecting() => { - textarea.cut(); - } - Input { - key: Key::Char('c'), - ctrl: false, - .. - } if textarea.is_selecting() => { - textarea.cut(); - mode = Mode::Insert; - } - _ => {} - }, - Mode::Insert => match input { - Input { key: Key::Esc, .. } - | Input { - key: Key::Char('c'), - ctrl: true, - .. - } => { - mode = Mode::Normal; // Back to normal mode with Esc or Ctrl+C - } - input => { - textarea.input(input); // Use default key mappings in insert mode - } - }, - } - - if let Some(next_pending) = next_pending { - match pending { - PendingState::D => { - textarea.cut(); - } - PendingState::Y => textarea.copy(), - PendingState::C => { - textarea.cut(); - mode = Mode::Insert; - } - _ => {} - } - pending = next_pending; - } - } - - disable_raw_mode()?; - crossterm::execute!( - term.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - term.show_cursor()?; - - println!("Lines: {:?}", textarea.lines()); - - Ok(()) -} diff --git a/examples/vim.rs b/examples/vim.rs new file mode 100644 index 0000000..e49366f --- /dev/null +++ b/examples/vim.rs @@ -0,0 +1,432 @@ +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders}; +use ratatui::Terminal; +use std::env; +use std::fmt; +use std::fs; +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, + Operator(char), +} + +impl Mode { + fn help_message(&self) -> &'static str { + 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", + Self::Operator(_) => "move cursor to apply operator", + } + } + + fn cursor_color(&self) -> Color { + match self { + Self::Normal => Color::Reset, + Self::Insert => Color::LightBlue, + Self::Visual => Color::LightYellow, + Self::Operator(_) => Color::LightGreen, + } + } +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + Self::Normal => write!(f, "NORMAL"), + Self::Insert => write!(f, "INSERT"), + Self::Visual => write!(f, "VISUAL"), + Self::Operator(c) => write!(f, "OPERATOR({})", c), + } + } +} + +// 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(); + + enable_raw_mode()?; + crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut term = Terminal::new(backend)?; + + let mut textarea = if let Some(path) = env::args().nth(1) { + let file = fs::File::open(path)?; + io::BufReader::new(file) + .lines() + .collect::>()? + } else { + TextArea::default() + }; + + let mut vim = Vim::new(Mode::Normal); + loop { + // Show help message and current mode in title of the block + 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 = 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()))?; + + 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, + } + } + + disable_raw_mode()?; + crossterm::execute!( + term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + term.show_cursor()?; + + println!("Lines: {:?}", textarea.lines()); + + Ok(()) +}