diff --git a/examples/paragraph.rs b/examples/paragraph.rs index c9e421ff..4dd27cc2 100644 --- a/examples/paragraph.rs +++ b/examples/paragraph.rs @@ -14,154 +14,218 @@ //! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md use std::{ - error::Error, - io, + io::{self}, time::{Duration, Instant}, }; +use crossterm::event::KeyEventKind; use ratatui::{ - backend::{Backend, CrosstermBackend}, - crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - }, - layout::{Constraint, Layout}, - style::{Color, Modifier, Style, Stylize}, - terminal::{Frame, Terminal}, + buffer::Buffer, + crossterm::event::{self, Event, KeyCode}, + layout::{Constraint, Layout, Rect}, + style::{Color, Stylize}, text::{Line, Masked, Span}, - widgets::{Block, Paragraph, Wrap}, + widgets::{Block, Paragraph, Widget, Wrap}, }; -struct App { - scroll: u16, -} - -impl App { - const fn new() -> Self { - Self { scroll: 0 } - } - - fn on_tick(&mut self) { - self.scroll += 1; - self.scroll %= 10; - } -} - -fn main() -> Result<(), Box> { - // 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 tick_rate = Duration::from_millis(250); - let app = App::new(); - let res = run_app(&mut terminal, app, tick_rate); - - // restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - println!("{err:?}"); - } +use self::common::{init_terminal, install_hooks, restore_terminal, Tui}; +fn main() -> color_eyre::Result<()> { + install_hooks()?; + let mut terminal = init_terminal()?; + let mut app = App::new(); + app.run(&mut terminal)?; + restore_terminal()?; Ok(()) } -fn run_app( - terminal: &mut Terminal, - mut app: App, - tick_rate: Duration, -) -> io::Result<()> { - let mut last_tick = Instant::now(); - loop { - terminal.draw(|f| ui(f, &app))?; +#[derive(Debug)] +struct App { + should_exit: bool, + scroll: u16, + last_tick: Instant, +} - let timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if crossterm::event::poll(timeout)? { +impl App { + /// The duration between each tick. + const TICK_RATE: Duration = Duration::from_millis(250); + + /// Create a new instance of the app. + fn new() -> Self { + Self { + should_exit: false, + scroll: 0, + last_tick: Instant::now(), + } + } + + /// Run the app until the user exits. + fn run(&mut self, terminal: &mut Tui) -> io::Result<()> { + while !self.should_exit { + self.draw(terminal)?; + self.handle_events()?; + if self.last_tick.elapsed() >= Self::TICK_RATE { + self.on_tick(); + self.last_tick = Instant::now(); + } + } + Ok(()) + } + + /// Draw the app to the terminal. + fn draw(&mut self, terminal: &mut Tui) -> io::Result<()> { + terminal.draw(|frame| frame.render_widget(self, frame.size()))?; + Ok(()) + } + + /// Handle events from the terminal. + fn handle_events(&mut self) -> io::Result<()> { + let timeout = Self::TICK_RATE.saturating_sub(self.last_tick.elapsed()); + while event::poll(timeout)? { if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Char('q') { - return Ok(()); + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { + self.should_exit = true; } } } - if last_tick.elapsed() >= tick_rate { - app.on_tick(); - last_tick = Instant::now(); - } + Ok(()) + } + + /// Update the app state on each tick. + fn on_tick(&mut self) { + self.scroll = (self.scroll + 1) % 10; } } -fn ui(f: &mut Frame, app: &App) { - let size = f.size(); +impl Widget for &mut App { + fn render(self, area: Rect, buf: &mut Buffer) { + let areas = Layout::vertical([Constraint::Max(9); 4]).split(area); + Paragraph::new(create_lines(area)) + .block(title_block("Default alignment (Left), no wrap")) + .gray() + .render(areas[0], buf); + Paragraph::new(create_lines(area)) + .block(title_block("Default alignment (Left), with wrap")) + .gray() + .wrap(Wrap { trim: true }) + .render(areas[1], buf); + Paragraph::new(create_lines(area)) + .block(title_block("Right alignment, with wrap")) + .gray() + .right_aligned() + .wrap(Wrap { trim: true }) + .render(areas[2], buf); + Paragraph::new(create_lines(area)) + .block(title_block("Center alignment, with wrap, with scroll")) + .gray() + .centered() + .wrap(Wrap { trim: true }) + .scroll((self.scroll, 0)) + .render(areas[3], buf); + } +} - // Words made "loooong" to demonstrate line breaking. - let s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. "; - let mut long_line = s.repeat(usize::from(size.width) / s.len() + 4); - long_line.push('\n'); +/// Create a bordered block with a title. +fn title_block(title: &str) -> Block { + Block::bordered() + .gray() + .title(title.bold().into_centered_line()) +} - let block = Block::new().black(); - f.render_widget(block, size); - - let layout = Layout::vertical([Constraint::Ratio(1, 4); 4]).split(size); - - let text = vec![ - Line::from("This is a line "), - Line::from("This is a line ".red()), - Line::from("This is a line".on_blue()), - Line::from("This is a longer line".crossed_out()), - Line::from(long_line.on_green()), - Line::from("This is a line".green().italic()), - Line::from(vec![ +/// Create some lines to display in the paragraph. +fn create_lines(area: Rect) -> Vec> { + let short_line = "A long line to demonstrate line wrapping. "; + let long_line = short_line.repeat(usize::from(area.width) / short_line.len() + 4); + let mut styled_spans = vec![]; + for span in [ + "Styled".blue(), + "Spans".red().on_white(), + "Bold".bold(), + "Italic".italic(), + "Underlined".underlined(), + "Strikethrough".crossed_out(), + ] { + styled_spans.push(span); + styled_spans.push(" ".into()); + } + vec![ + Line::raw("Unstyled Line"), + Line::raw("Styled Line").black().on_red().bold().italic(), + Line::from(styled_spans), + Line::from(long_line.green().italic()), + Line::from_iter([ "Masked text: ".into(), - Span::styled( - Masked::new("password", '*'), - Style::default().fg(Color::Red), - ), + Span::styled(Masked::new("my secret password", '*'), Color::Red), ]), - ]; + ] +} - let create_block = |title| { - Block::bordered() - .style(Style::default().fg(Color::Gray)) - .title(Span::styled( - title, - Style::default().add_modifier(Modifier::BOLD), - )) +/// A module for common functionality used in the examples. +mod common { + use std::{ + io::{self, stdout, Stdout}, + panic, }; - let paragraph = Paragraph::new(text.clone()) - .style(Style::default().fg(Color::Gray)) - .block(create_block("Default alignment (Left), no wrap")); - f.render_widget(paragraph, layout[0]); + use color_eyre::{ + config::{EyreHook, HookBuilder, PanicHook}, + eyre, + }; + use crossterm::ExecutableCommand; + use ratatui::{ + backend::CrosstermBackend, + crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, + terminal::Terminal, + }; - let paragraph = Paragraph::new(text.clone()) - .style(Style::default().fg(Color::Gray)) - .block(create_block("Default alignment (Left), with wrap")) - .wrap(Wrap { trim: true }); - f.render_widget(paragraph, layout[1]); + // A simple alias for the terminal type used in this example. + pub type Tui = Terminal>; - let paragraph = Paragraph::new(text.clone()) - .style(Style::default().fg(Color::Gray)) - .block(create_block("Right alignment, with wrap")) - .right_aligned() - .wrap(Wrap { trim: true }); - f.render_widget(paragraph, layout[2]); + /// Initialize the terminal and enter alternate screen mode. + pub fn init_terminal() -> io::Result { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + Terminal::new(backend) + } - let paragraph = Paragraph::new(text) - .style(Style::default().fg(Color::Gray)) - .block(create_block("Center alignment, with wrap, with scroll")) - .centered() - .wrap(Wrap { trim: true }) - .scroll((app.scroll, 0)); - f.render_widget(paragraph, layout[3]); + /// Restore the terminal to its original state. + pub fn restore_terminal() -> io::Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) + } + + /// Installs hooks for panic and error handling. + /// + /// Makes the app resilient to panics and errors by restoring the terminal before printing the + /// panic or error message. This prevents error messages from being messed up by the terminal + /// state. + pub fn install_hooks() -> color_eyre::Result<()> { + let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); + install_panic_hook(panic_hook); + install_error_hook(eyre_hook)?; + Ok(()) + } + + /// Install a panic hook that restores the terminal before printing the panic. + fn install_panic_hook(panic_hook: PanicHook) { + let panic_hook = panic_hook.into_panic_hook(); + panic::set_hook(Box::new(move |panic_info| { + let _ = restore_terminal(); + panic_hook(panic_info); + })); + } + + /// Install an error hook that restores the terminal before printing the error. + fn install_error_hook(eyre_hook: EyreHook) -> color_eyre::Result<()> { + let eyre_hook = eyre_hook.into_eyre_hook(); + eyre::set_hook(Box::new(move |error| { + let _ = restore_terminal(); + eyre_hook(error) + }))?; + Ok(()) + } }