//! # [Ratatui] Table example //! //! The latest version of this example is available in the [examples] folder in the repository. //! //! Please note that the examples are designed to be run against the `main` branch of the Github //! repository. This means that you may not be able to compile with the latest release version on //! crates.io, or the one that you have installed locally. //! //! See the [examples readme] for more information on finding examples that match the version of the //! library you are using. //! //! [Ratatui]: https://github.com/ratatui-org/ratatui //! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples //! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md #![allow(clippy::enum_glob_use, clippy::wildcard_imports)] use std::{error::Error, io}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use itertools::Itertools; use ratatui::{prelude::*, widgets::*}; use style::palette::tailwind; use unicode_width::UnicodeWidthStr; const PALETTES: [tailwind::Palette; 4] = [ tailwind::BLUE, tailwind::EMERALD, tailwind::INDIGO, tailwind::RED, ]; const INFO_TEXT: &str = "(Esc) quit | (↑) move up | (↓) move down | (→) next color | (←) previous color"; const ITEM_HEIGHT: usize = 4; struct TableColors { buffer_bg: Color, header_bg: Color, header_fg: Color, row_fg: Color, selected_style_fg: Color, normal_row_color: Color, alt_row_color: Color, footer_border_color: Color, } impl TableColors { const fn new(color: &tailwind::Palette) -> Self { Self { buffer_bg: tailwind::SLATE.c950, header_bg: color.c900, header_fg: tailwind::SLATE.c200, row_fg: tailwind::SLATE.c200, selected_style_fg: color.c400, normal_row_color: tailwind::SLATE.c950, alt_row_color: tailwind::SLATE.c900, footer_border_color: color.c400, } } } struct Data { name: String, address: String, email: String, } impl Data { const fn ref_array(&self) -> [&String; 3] { [&self.name, &self.address, &self.email] } fn name(&self) -> &str { &self.name } fn address(&self) -> &str { &self.address } fn email(&self) -> &str { &self.email } } struct App { state: TableState, items: Vec, longest_item_lens: (u16, u16, u16), // order is (name, address, email) scroll_state: ScrollbarState, colors: TableColors, color_index: usize, } impl App { fn new() -> Self { let data_vec = generate_fake_names(); Self { state: TableState::default().with_selected(0), longest_item_lens: constraint_len_calculator(&data_vec), scroll_state: ScrollbarState::new((data_vec.len() - 1) * ITEM_HEIGHT), colors: TableColors::new(&PALETTES[0]), color_index: 0, items: data_vec, } } pub fn next(&mut self) { let i = match self.state.selected() { Some(i) => { if i >= self.items.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.state.select(Some(i)); self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); } pub fn previous(&mut self) { let i = match self.state.selected() { Some(i) => { if i == 0 { self.items.len() - 1 } else { i - 1 } } None => 0, }; self.state.select(Some(i)); self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); } pub fn next_color(&mut self) { self.color_index = (self.color_index + 1) % PALETTES.len(); } pub fn previous_color(&mut self) { let count = PALETTES.len(); self.color_index = (self.color_index + count - 1) % count; } pub fn set_colors(&mut self) { self.colors = TableColors::new(&PALETTES[self.color_index]); } } fn generate_fake_names() -> Vec { use fakeit::{address, contact, name}; (0..20) .map(|_| { let name = name::full(); let address = format!( "{}\n{}, {} {}", address::street(), address::city(), address::state(), address::zip() ); let email = contact::email(); Data { name, address, email, } }) .sorted_by(|a, b| a.name.cmp(&b.name)) .collect_vec() } 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 app = App::new(); 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(terminal: &mut Terminal, mut app: App) -> io::Result<()> { loop { terminal.draw(|f| ui(f, &mut app))?; if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { use KeyCode::*; match key.code { Char('q') | Esc => return Ok(()), Char('j') | Down => app.next(), Char('k') | Up => app.previous(), Char('l') | Right => app.next_color(), Char('h') | Left => app.previous_color(), _ => {} } } } } } fn ui(f: &mut Frame, app: &mut App) { let rects = Layout::vertical([Constraint::Min(5), Constraint::Length(3)]).split(f.size()); app.set_colors(); render_table(f, app, rects[0]); render_scrollbar(f, app, rects[0]); render_footer(f, app, rects[1]); } fn render_table(f: &mut Frame, app: &mut App, area: Rect) { let header_style = Style::default() .fg(app.colors.header_fg) .bg(app.colors.header_bg); let selected_style = Style::default() .add_modifier(Modifier::REVERSED) .fg(app.colors.selected_style_fg); let header = ["Name", "Address", "Email"] .into_iter() .map(Cell::from) .collect::() .style(header_style) .height(1); let rows = app.items.iter().enumerate().map(|(i, data)| { let color = match i % 2 { 0 => app.colors.normal_row_color, _ => app.colors.alt_row_color, }; let item = data.ref_array(); item.into_iter() .map(|content| Cell::from(Text::from(format!("\n{content}\n")))) .collect::() .style(Style::new().fg(app.colors.row_fg).bg(color)) .height(4) }); let bar = " █ "; let t = Table::new( rows, [ // + 1 is for padding. Constraint::Length(app.longest_item_lens.0 + 1), Constraint::Min(app.longest_item_lens.1 + 1), Constraint::Min(app.longest_item_lens.2), ], ) .header(header) .highlight_style(selected_style) .highlight_symbol(Text::from(vec![ "".into(), bar.into(), bar.into(), "".into(), ])) .bg(app.colors.buffer_bg) .highlight_spacing(HighlightSpacing::Always); f.render_stateful_widget(t, area, &mut app.state); } fn constraint_len_calculator(items: &[Data]) -> (u16, u16, u16) { let name_len = items .iter() .map(Data::name) .map(UnicodeWidthStr::width) .max() .unwrap_or(0); let address_len = items .iter() .map(Data::address) .flat_map(str::lines) .map(UnicodeWidthStr::width) .max() .unwrap_or(0); let email_len = items .iter() .map(Data::email) .map(UnicodeWidthStr::width) .max() .unwrap_or(0); #[allow(clippy::cast_possible_truncation)] (name_len as u16, address_len as u16, email_len as u16) } fn render_scrollbar(f: &mut Frame, app: &mut App, area: Rect) { f.render_stateful_widget( Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None), area.inner(&Margin { vertical: 1, horizontal: 1, }), &mut app.scroll_state, ); } fn render_footer(f: &mut Frame, app: &App, area: Rect) { let info_footer = Paragraph::new(Line::from(INFO_TEXT)) .style(Style::new().fg(app.colors.row_fg).bg(app.colors.buffer_bg)) .centered() .block( Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(app.colors.footer_border_color)) .border_type(BorderType::Double), ); f.render_widget(info_footer, area); } #[cfg(test)] mod tests { use crate::Data; #[test] fn constraint_len_calculator() { let test_data = vec![ Data { name: "Emirhan Tala".to_string(), address: "Cambridgelaan 6XX\n3584 XX Utrecht".to_string(), email: "tala.emirhan@gmail.com".to_string(), }, Data { name: "thistextis26characterslong".to_string(), address: "this line is 31 characters long\nbottom line is 33 characters long" .to_string(), email: "thisemailis40caharacterslong@ratatui.com".to_string(), }, ]; let (longest_name_len, longest_address_len, longest_email_len) = crate::constraint_len_calculator(&test_data); assert_eq!(26, longest_name_len); assert_eq!(33, longest_address_len); assert_eq!(40, longest_email_len); } }