mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-22 04:33:13 +00:00
84cb16483a
This is a simplification of the public API that is helpful for new users that are not familiar with how rust re-exports work, and helps avoid clashes with other modules in the backends that are named terminal. BREAKING CHANGE: The `terminal` module is now private and can not be used directly. The types under this module are exported from the root of the crate. ```diff - use ratatui::terminal::{CompletedFrame, Frame, Terminal, TerminalOptions, ViewPort}; + use ratatui::{CompletedFrame, Frame, Terminal, TerminalOptions, ViewPort}; ``` Fixes: https://github.com/ratatui-org/ratatui/issues/1210
374 lines
11 KiB
Rust
374 lines
11 KiB
Rust
//! # [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
|
|
|
|
use std::{error::Error, io};
|
|
|
|
use itertools::Itertools;
|
|
use ratatui::{
|
|
backend::{Backend, CrosstermBackend},
|
|
crossterm::{
|
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
},
|
|
layout::{Constraint, Layout, Margin, Rect},
|
|
style::{self, Color, Modifier, Style, Stylize},
|
|
text::{Line, Text},
|
|
widgets::{
|
|
Block, BorderType, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation,
|
|
ScrollbarState, Table, TableState,
|
|
},
|
|
Frame, Terminal,
|
|
};
|
|
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<Data>,
|
|
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<Data> {
|
|
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<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::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<B: Backend>(terminal: &mut Terminal<B>, 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 {
|
|
match key.code {
|
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
|
KeyCode::Char('j') | KeyCode::Down => app.next(),
|
|
KeyCode::Char('k') | KeyCode::Up => app.previous(),
|
|
KeyCode::Char('l') | KeyCode::Right => app.next_color(),
|
|
KeyCode::Char('h') | KeyCode::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::<Row>()
|
|
.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::<Row>()
|
|
.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::bordered()
|
|
.border_type(BorderType::Double)
|
|
.border_style(Style::new().fg(app.colors.footer_border_color)),
|
|
);
|
|
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);
|
|
}
|
|
}
|