1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-09-04 14:55:51 +03:00

refactor Vim emulation as state machine

Этот коммит содержится в:
rhysd
2023-11-17 23:21:57 +09:00
родитель 7c66f3ad5a
Коммит 9f206200e5
2 изменённых файлов: 335 добавлений и 358 удалений

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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,
}
}