mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-14 17:07:13 +00:00
082cbcbc50
This change simplifys UI code that uses the Frame type. E.g.: ```rust fn draw<B: Backend>(frame: &mut Frame<B>) { // ... } ``` Frame was generic over Backend because it stored a reference to the terminal in the field. Instead it now directly stores the viewport area and current buffer. These are provided at creation time and are valid for the duration of the frame. BREAKING CHANGE: Frame is no longer generic over Backend. Code that accepted `Frame<Backend>` will now need to accept `Frame`. To migrate existing code, remove any generic parameters from code that uses an instance of a Frame. E.g. the above code becomes: ```rust fn draw(frame: &mut Frame) { // ... } ```
251 lines
8 KiB
Rust
251 lines
8 KiB
Rust
use std::{error::Error, io};
|
|
|
|
/// A simple example demonstrating how to handle user input. This is
|
|
/// a bit out of the scope of the library as it does not provide any
|
|
/// input handling out of the box. However, it may helps some to get
|
|
/// started.
|
|
///
|
|
/// This is a very simple example:
|
|
/// * An input box always focused. Every character you type is registered
|
|
/// here.
|
|
/// * An entered character is inserted at the cursor position.
|
|
/// * Pressing Backspace erases the left character before the cursor position
|
|
/// * Pressing Enter pushes the current input in the history of previous
|
|
/// messages.
|
|
/// **Note: ** as this is a relatively simple example unicode characters are unsupported and
|
|
/// their use will result in undefined behaviour.
|
|
use crossterm::{
|
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{prelude::*, widgets::*};
|
|
|
|
enum InputMode {
|
|
Normal,
|
|
Editing,
|
|
}
|
|
|
|
/// App holds the state of the application
|
|
struct App {
|
|
/// Current value of the input box
|
|
input: String,
|
|
/// Position of cursor in the editor area.
|
|
cursor_position: usize,
|
|
/// Current input mode
|
|
input_mode: InputMode,
|
|
/// History of recorded messages
|
|
messages: Vec<String>,
|
|
}
|
|
|
|
impl Default for App {
|
|
fn default() -> App {
|
|
App {
|
|
input: String::new(),
|
|
input_mode: InputMode::Normal,
|
|
messages: Vec::new(),
|
|
cursor_position: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
fn move_cursor_left(&mut self) {
|
|
let cursor_moved_left = self.cursor_position.saturating_sub(1);
|
|
self.cursor_position = self.clamp_cursor(cursor_moved_left);
|
|
}
|
|
|
|
fn move_cursor_right(&mut self) {
|
|
let cursor_moved_right = self.cursor_position.saturating_add(1);
|
|
self.cursor_position = self.clamp_cursor(cursor_moved_right);
|
|
}
|
|
|
|
fn enter_char(&mut self, new_char: char) {
|
|
self.input.insert(self.cursor_position, new_char);
|
|
|
|
self.move_cursor_right();
|
|
}
|
|
|
|
fn delete_char(&mut self) {
|
|
let is_not_cursor_leftmost = self.cursor_position != 0;
|
|
if is_not_cursor_leftmost {
|
|
// Method "remove" is not used on the saved text for deleting the selected char.
|
|
// Reason: Using remove on String works on bytes instead of the chars.
|
|
// Using remove would require special care because of char boundaries.
|
|
|
|
let current_index = self.cursor_position;
|
|
let from_left_to_current_index = current_index - 1;
|
|
|
|
// Getting all characters before the selected character.
|
|
let before_char_to_delete = self.input.chars().take(from_left_to_current_index);
|
|
// Getting all characters after selected character.
|
|
let after_char_to_delete = self.input.chars().skip(current_index);
|
|
|
|
// Put all characters together except the selected one.
|
|
// By leaving the selected one out, it is forgotten and therefore deleted.
|
|
self.input = before_char_to_delete.chain(after_char_to_delete).collect();
|
|
self.move_cursor_left();
|
|
}
|
|
}
|
|
|
|
fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
|
|
new_cursor_pos.clamp(0, self.input.len())
|
|
}
|
|
|
|
fn reset_cursor(&mut self) {
|
|
self.cursor_position = 0;
|
|
}
|
|
|
|
fn submit_message(&mut self) {
|
|
self.messages.push(self.input.clone());
|
|
self.input.clear();
|
|
self.reset_cursor();
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn Error>> {
|
|
// setup terminal
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
// create app and run it
|
|
let app = App::default();
|
|
let res = run_app(&mut terminal, app);
|
|
|
|
// restore terminal
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
if let Err(err) = res {
|
|
println!("{err:?}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
|
|
loop {
|
|
terminal.draw(|f| ui(f, &app))?;
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
match app.input_mode {
|
|
InputMode::Normal => match key.code {
|
|
KeyCode::Char('e') => {
|
|
app.input_mode = InputMode::Editing;
|
|
}
|
|
KeyCode::Char('q') => {
|
|
return Ok(());
|
|
}
|
|
_ => {}
|
|
},
|
|
InputMode::Editing if key.kind == KeyEventKind::Press => match key.code {
|
|
KeyCode::Enter => app.submit_message(),
|
|
KeyCode::Char(to_insert) => {
|
|
app.enter_char(to_insert);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.delete_char();
|
|
}
|
|
KeyCode::Left => {
|
|
app.move_cursor_left();
|
|
}
|
|
KeyCode::Right => {
|
|
app.move_cursor_right();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ui(f: &mut Frame, app: &App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[
|
|
Constraint::Length(1),
|
|
Constraint::Length(3),
|
|
Constraint::Min(1),
|
|
]
|
|
.as_ref(),
|
|
)
|
|
.split(f.size());
|
|
|
|
let (msg, style) = match app.input_mode {
|
|
InputMode::Normal => (
|
|
vec![
|
|
"Press ".into(),
|
|
"q".bold(),
|
|
" to exit, ".into(),
|
|
"e".bold(),
|
|
" to start editing.".bold(),
|
|
],
|
|
Style::default().add_modifier(Modifier::RAPID_BLINK),
|
|
),
|
|
InputMode::Editing => (
|
|
vec![
|
|
"Press ".into(),
|
|
"Esc".bold(),
|
|
" to stop editing, ".into(),
|
|
"Enter".bold(),
|
|
" to record the message".into(),
|
|
],
|
|
Style::default(),
|
|
),
|
|
};
|
|
let mut text = Text::from(Line::from(msg));
|
|
text.patch_style(style);
|
|
let help_message = Paragraph::new(text);
|
|
f.render_widget(help_message, chunks[0]);
|
|
|
|
let input = Paragraph::new(app.input.as_str())
|
|
.style(match app.input_mode {
|
|
InputMode::Normal => Style::default(),
|
|
InputMode::Editing => Style::default().fg(Color::Yellow),
|
|
})
|
|
.block(Block::default().borders(Borders::ALL).title("Input"));
|
|
f.render_widget(input, chunks[1]);
|
|
match app.input_mode {
|
|
InputMode::Normal =>
|
|
// Hide the cursor. `Frame` does this by default, so we don't need to do anything here
|
|
{}
|
|
|
|
InputMode::Editing => {
|
|
// Make the cursor visible and ask ratatui to put it at the specified coordinates after
|
|
// rendering
|
|
f.set_cursor(
|
|
// Draw the cursor at the current position in the input field.
|
|
// This position is can be controlled via the left and right arrow key
|
|
chunks[1].x + app.cursor_position as u16 + 1,
|
|
// Move one line down, from the border to the input line
|
|
chunks[1].y + 1,
|
|
)
|
|
}
|
|
}
|
|
|
|
let messages: Vec<ListItem> = app
|
|
.messages
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, m)| {
|
|
let content = Line::from(Span::raw(format!("{i}: {m}")));
|
|
ListItem::new(content)
|
|
})
|
|
.collect();
|
|
let messages =
|
|
List::new(messages).block(Block::default().borders(Borders::ALL).title("Messages"));
|
|
f.render_widget(messages, chunks[2]);
|
|
}
|