From 526f5cc33419025f54e2f2b5ee3a5d137a537e41 Mon Sep 17 00:00:00 2001 From: rhysd Date: Thu, 13 Apr 2023 22:21:03 +0900 Subject: [PATCH] separate examples for `ratatui` --- Cargo.toml | 16 +- examples/editor.rs | 28 +-- examples/minimal.rs | 19 +- examples/ratatui_editor.rs | 372 ++++++++++++++++++++++++++++++++++++ examples/ratatui_minimal.rs | 51 +++++ examples/ratatui_termion.rs | 69 +++++++ examples/termion.rs | 19 +- 7 files changed, 519 insertions(+), 55 deletions(-) create mode 100644 examples/ratatui_editor.rs create mode 100644 examples/ratatui_minimal.rs create mode 100644 examples/ratatui_termion.rs diff --git a/Cargo.toml b/Cargo.toml index 88c8972..9b1b5b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,13 +40,15 @@ ratatui = { version = "0.20.1", default-features = false, optional = true } [[example]] name = "minimal" +required-features = ["crossterm"] [[example]] name = "termion" +required-features = ["termion"] [[example]] name = "editor" -required-features = ["search"] +required-features = ["crossterm", "search"] [[example]] name = "split" @@ -64,6 +66,18 @@ required-features = ["crossterm"] name = "modal" required-features = ["crossterm"] +[[example]] +name = "ratatui_minimal" +required-features = ["ratatui-crossterm"] + +[[example]] +name = "ratatui_editor" +required-features = ["ratatui-crossterm", "search"] + +[[example]] +name = "ratatui_termion" +required-features = ["ratatui-termion"] + [workspace] members = [ "bench", diff --git a/examples/editor.rs b/examples/editor.rs index 7de14ea..67a80e6 100644 --- a/examples/editor.rs +++ b/examples/editor.rs @@ -1,6 +1,3 @@ -#[cfg(feature = "ratatui-crossterm")] -use crossterm_026 as crossterm; - use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen, @@ -13,27 +10,14 @@ use std::fs; use std::io; use std::io::{BufRead, Write}; use std::path::PathBuf; +use tui::backend::CrosstermBackend; +use tui::layout::{Constraint, Direction, Layout}; +use tui::style::{Color, Modifier, Style}; +use tui::text::{Span, Spans}; +use tui::widgets::{Block, Borders, Paragraph}; +use tui::Terminal; use tui_textarea::{CursorMove, Input, Key, TextArea}; -#[cfg(feature = "ratatui-crossterm")] -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::{Block, Borders, Paragraph}, - Terminal, -}; -#[cfg(not(feature = "ratatui-crossterm"))] -use tui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::{Block, Borders, Paragraph}, - Terminal, -}; - macro_rules! error { ($fmt: expr $(, $args:tt)*) => {{ Err(io::Error::new(io::ErrorKind::Other, format!($fmt $(, $args)*))) diff --git a/examples/minimal.rs b/examples/minimal.rs index 04d925e..5d26a20 100644 --- a/examples/minimal.rs +++ b/examples/minimal.rs @@ -1,26 +1,13 @@ -#[cfg(feature = "ratatui-crossterm")] -use crossterm_026 as crossterm; - use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use std::io; +use tui::backend::CrosstermBackend; +use tui::widgets::{Block, Borders}; +use tui::Terminal; use tui_textarea::{Input, Key, TextArea}; -#[cfg(feature = "ratatui-crossterm")] -use ratatui::{ - backend::CrosstermBackend, - widgets::{Block, Borders}, - Terminal, -}; -#[cfg(feature = "crossterm")] -use tui::{ - backend::CrosstermBackend, - widgets::{Block, Borders}, - Terminal, -}; - fn main() -> io::Result<()> { let stdout = io::stdout(); let mut stdout = stdout.lock(); diff --git a/examples/ratatui_editor.rs b/examples/ratatui_editor.rs new file mode 100644 index 0000000..5a16c58 --- /dev/null +++ b/examples/ratatui_editor.rs @@ -0,0 +1,372 @@ +use crossterm_026 as crossterm; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen, + LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Span, Spans}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Terminal; +use std::borrow::Cow; +use std::env; +use std::fmt::Display; +use std::fs; +use std::io; +use std::io::{BufRead, Write}; +use std::path::PathBuf; +use tui_textarea::{CursorMove, Input, Key, TextArea}; + +macro_rules! error { + ($fmt: expr $(, $args:tt)*) => {{ + Err(io::Error::new(io::ErrorKind::Other, format!($fmt $(, $args)*))) + }}; +} + +struct SearchBox<'a> { + textarea: TextArea<'a>, + open: bool, +} + +impl<'a> Default for SearchBox<'a> { + fn default() -> Self { + let mut textarea = TextArea::default(); + textarea.set_block(Block::default().borders(Borders::ALL).title("Search")); + Self { + textarea, + open: false, + } + } +} + +impl<'a> SearchBox<'a> { + fn open(&mut self) { + self.open = true; + } + + fn close(&mut self) { + self.open = false; + // Remove input for next search. Do not recreate `self.textarea` instance to keep undo history so that users can + // restore previous input easily. + self.textarea.move_cursor(CursorMove::End); + self.textarea.delete_line_by_head(); + } + + fn height(&self) -> u16 { + if self.open { + 3 + } else { + 0 + } + } + + fn input(&mut self, input: Input) -> Option<&'_ str> { + match input { + Input { + key: Key::Enter, .. + } + | Input { + key: Key::Char('m'), + ctrl: true, + .. + } => None, // Disable shortcuts which inserts a newline. See `single_line` example + input => { + let modified = self.textarea.input(input); + modified.then(|| self.textarea.lines()[0].as_str()) + } + } + } + + fn set_error(&mut self, err: Option) { + let b = if let Some(err) = err { + Block::default() + .borders(Borders::ALL) + .title(format!("Search: {}", err)) + .style(Style::default().fg(Color::Red)) + } else { + Block::default().borders(Borders::ALL).title("Search") + }; + self.textarea.set_block(b); + } +} + +struct Buffer<'a> { + textarea: TextArea<'a>, + path: PathBuf, + modified: bool, +} + +impl<'a> Buffer<'a> { + fn new(path: PathBuf) -> io::Result { + let mut textarea = if let Ok(md) = path.metadata() { + if md.is_file() { + let mut textarea: TextArea = io::BufReader::new(fs::File::open(&path)?) + .lines() + .collect::>()?; + if textarea.lines().iter().any(|l| l.starts_with('\t')) { + textarea.set_hard_tab_indent(true); + } + textarea + } else { + return error!("{:?} is not a file", path); + } + } else { + TextArea::default() // File does not exist + }; + textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); + Ok(Self { + textarea, + path, + modified: false, + }) + } + + fn save(&mut self) -> io::Result<()> { + if !self.modified { + return Ok(()); + } + let mut f = io::BufWriter::new(fs::File::create(&self.path)?); + for line in self.textarea.lines() { + f.write_all(line.as_bytes())?; + f.write_all(b"\n")?; + } + self.modified = false; + Ok(()) + } +} + +struct Editor<'a> { + current: usize, + buffers: Vec>, + term: Terminal>, + message: Option>, + search: SearchBox<'a>, +} + +impl<'a> Editor<'a> { + fn new(paths: I) -> io::Result + where + I: Iterator, + I::Item: Into, + { + let buffers = paths + .map(|p| Buffer::new(p.into())) + .collect::>>()?; + if buffers.is_empty() { + return error!("USAGE: cargo run --example editor FILE1 [FILE2...]"); + } + let mut stdout = io::stdout(); + if !is_raw_mode_enabled()? { + enable_raw_mode()?; + crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + } + let backend = CrosstermBackend::new(stdout); + let term = Terminal::new(backend)?; + Ok(Self { + current: 0, + buffers, + term, + message: None, + search: SearchBox::default(), + }) + } + + fn run(&mut self) -> io::Result<()> { + loop { + let search_height = self.search.height(); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(search_height), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ] + .as_ref(), + ); + + self.term.draw(|f| { + let chunks = layout.split(f.size()); + + if search_height > 0 { + f.render_widget(self.search.textarea.widget(), chunks[0]); + } + + let buffer = &self.buffers[self.current]; + let textarea = &buffer.textarea; + let widget = textarea.widget(); + f.render_widget(widget, chunks[1]); + + // Render status line + let modified = if buffer.modified { " [modified]" } else { "" }; + let slot = format!("[{}/{}]", self.current + 1, self.buffers.len()); + let path = format!(" {}{} ", buffer.path.display(), modified); + let (row, col) = textarea.cursor(); + let cursor = format!("({},{})", row + 1, col + 1); + let status_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Length(slot.len() as u16), + Constraint::Min(1), + Constraint::Length(cursor.len() as u16), + ] + .as_ref(), + ) + .split(chunks[2]); + let status_style = Style::default().add_modifier(Modifier::REVERSED); + f.render_widget(Paragraph::new(slot).style(status_style), status_chunks[0]); + f.render_widget(Paragraph::new(path).style(status_style), status_chunks[1]); + f.render_widget(Paragraph::new(cursor).style(status_style), status_chunks[2]); + + // Render message at bottom + let message = if let Some(message) = self.message.take() { + Spans::from(Span::raw(message)) + } else if search_height > 0 { + Spans::from(vec![ + Span::raw("Press "), + Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to jump to first match and close, "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to close, "), + Span::styled( + "^G or ↓ or ^N", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" to search next, "), + Span::styled( + "M-G or ↑ or ^P", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" to search previous"), + ]) + } else { + Spans::from(vec![ + Span::raw("Press "), + Span::styled("^Q", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to quit, "), + Span::styled("^S", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to save, "), + Span::styled("^G", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to search, "), + Span::styled("^X", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to switch buffer"), + ]) + }; + f.render_widget(Paragraph::new(message), chunks[3]); + })?; + + if search_height > 0 { + let textarea = &mut self.buffers[self.current].textarea; + match crossterm::event::read()?.into() { + Input { + key: Key::Char('g' | 'n'), + ctrl: true, + alt: false, + } + | Input { key: Key::Down, .. } => { + if !textarea.search_forward(false) { + self.search.set_error(Some("Pattern not found")); + } + } + Input { + key: Key::Char('g'), + ctrl: false, + alt: true, + } + | Input { + key: Key::Char('p'), + ctrl: true, + alt: false, + } + | Input { key: Key::Up, .. } => { + if !textarea.search_back(false) { + self.search.set_error(Some("Pattern not found")); + } + } + Input { + key: Key::Enter, .. + } => { + if !textarea.search_forward(true) { + self.message = Some("Pattern not found".into()); + } + self.search.close(); + textarea.set_search_pattern("").unwrap(); + } + Input { key: Key::Esc, .. } => { + self.search.close(); + textarea.set_search_pattern("").unwrap(); + } + input => { + if let Some(query) = self.search.input(input) { + let maybe_err = textarea.set_search_pattern(query).err(); + self.search.set_error(maybe_err); + } + } + } + } else { + match crossterm::event::read()?.into() { + Input { + key: Key::Char('q'), + ctrl: true, + .. + } => break, + Input { + key: Key::Char('x'), + ctrl: true, + .. + } => { + self.current = (self.current + 1) % self.buffers.len(); + self.message = + Some(format!("Switched to buffer #{}", self.current + 1).into()); + } + Input { + key: Key::Char('s'), + ctrl: true, + .. + } => { + self.buffers[self.current].save()?; + self.message = Some("Saved!".into()); + } + Input { + key: Key::Char('g'), + ctrl: true, + .. + } => { + self.search.open(); + } + input => { + let buffer = &mut self.buffers[self.current]; + buffer.modified = buffer.textarea.input(input); + } + } + } + } + + Ok(()) + } +} + +impl<'a> Drop for Editor<'a> { + fn drop(&mut self) { + self.term.show_cursor().unwrap(); + if !is_raw_mode_enabled().unwrap() { + return; + } + disable_raw_mode().unwrap(); + crossterm::execute!( + self.term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .unwrap(); + } +} + +fn main() -> io::Result<()> { + Editor::new(env::args_os().skip(1))?.run() +} diff --git a/examples/ratatui_minimal.rs b/examples/ratatui_minimal.rs new file mode 100644 index 0000000..0cdd311 --- /dev/null +++ b/examples/ratatui_minimal.rs @@ -0,0 +1,51 @@ +use crossterm_026 as crossterm; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::widgets::{Block, Borders}; +use ratatui::Terminal; +use std::io; +use tui_textarea::{Input, Key, TextArea}; + +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 = TextArea::default(); + textarea.set_block( + Block::default() + .borders(Borders::ALL) + .title("Crossterm Minimal Example"), + ); + + loop { + term.draw(|f| { + f.render_widget(textarea.widget(), f.size()); + })?; + match crossterm::event::read()?.into() { + Input { key: Key::Esc, .. } => break, + input => { + textarea.input(input); + } + } + } + + disable_raw_mode()?; + crossterm::execute!( + term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + term.show_cursor()?; + + println!("Lines: {:?}", textarea.lines()); + Ok(()) +} diff --git a/examples/ratatui_termion.rs b/examples/ratatui_termion.rs new file mode 100644 index 0000000..cc6ecc0 --- /dev/null +++ b/examples/ratatui_termion.rs @@ -0,0 +1,69 @@ +use ratatui::backend::TermionBackend; +use ratatui::widgets::{Block, Borders}; +use ratatui::Terminal; +use std::error::Error; +use std::io; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use termion::event::Event as TermEvent; +use termion::input::{MouseTerminal, TermRead}; +use termion::raw::IntoRawMode; +use termion::screen::AlternateScreen; +use tui_textarea::{Input, Key, TextArea}; + +enum Event { + Term(TermEvent), + Tick, +} + +fn main() -> Result<(), Box> { + let stdout = io::stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut term = Terminal::new(backend)?; + + let events = { + let events = io::stdin().events(); + let (tx, rx) = mpsc::channel(); + let keys_tx = tx.clone(); + thread::spawn(move || { + for event in events.flatten() { + keys_tx.send(Event::Term(event)).unwrap(); + } + }); + thread::spawn(move || loop { + tx.send(Event::Tick).unwrap(); + thread::sleep(Duration::from_millis(100)); + }); + rx + }; + + let mut textarea = TextArea::default(); + textarea.set_block( + Block::default() + .borders(Borders::ALL) + .title("Termion Minimal Example"), + ); + + loop { + match events.recv()? { + Event::Term(event) => match event.into() { + Input { key: Key::Esc, .. } => break, + input => { + textarea.input(input); + } + }, + Event::Tick => {} + } + term.draw(|f| { + let widget = textarea.widget(); + f.render_widget(widget, f.size()); + })?; + } + + drop(term); // Leave terminal raw mode to print the following line + println!("Lines: {:?}", textarea.lines()); + Ok(()) +} diff --git a/examples/termion.rs b/examples/termion.rs index ac5ecef..3d7e2b4 100644 --- a/examples/termion.rs +++ b/examples/termion.rs @@ -1,6 +1,3 @@ -#[cfg(all(not(feature = "termion"), not(feature = "ratatui-termion")))] -compile_error!("termion example requires \"termion\" feature or \"ratatui-termion\" feature. see https://github.com/rhysd/tui-textarea"); - use std::error::Error; use std::io; use std::sync::mpsc; @@ -10,21 +7,11 @@ use termion::event::Event as TermEvent; use termion::input::{MouseTerminal, TermRead}; use termion::raw::IntoRawMode; use termion::screen::AlternateScreen; +use tui::backend::TermionBackend; +use tui::widgets::{Block, Borders}; +use tui::Terminal; use tui_textarea::{Input, Key, TextArea}; -#[cfg(feature = "ratatui-termion")] -use ratatui::{ - backend::TermionBackend, - widgets::{Block, Borders}, - Terminal, -}; -#[cfg(feature = "termion")] -use tui::{ - backend::TermionBackend, - widgets::{Block, Borders}, - Terminal, -}; - enum Event { Term(TermEvent), Tick,