mirror of
https://github.com/ratatui/ratatui.git
synced 2025-12-30 05:11:46 +00:00
This commit introduces new methods for the `Rect` struct that simplify
the process of splitting a `Rect` into sub-rects according to a given
`Layout`. By putting these methods on the `Rect` struct, we make it a
bit more natural that a layout is applied to the `Rect` itself, rather
than passing a `Rect` to the `Layout` struct to be split.
Adds:
- `Rect::layout` and `Rect::try_layout` methods that allow splitting a
`Rect` into an array of sub-rects according to a given `Layout`.
- `Rect::layout_vec` method that returns a `Vec` of sub-rects.
- `Layout::try_areas` method that returns an array of sub-rects, with
compile-time checks for the number of constraints. This is added
mainly for consistency with the new `Rect` methods.
```rust
use ratatui_core::layout::{Layout, Constraint, Rect};
let area = Rect::new(0, 0, 10, 10);
let layout = Layout::vertical([Constraint::Fill(1); 2]);
// Rect::layout() infers the number of constraints at compile time:
let [top, main] = area.layout(&layout);
// Rect::try_layout() and Layout::try_areas() do the same, but return a
// Result:
let [top, main] = area.try_layout(&layout)?;
let [top, main] = layout.try_areas(area)?;
// Rect::layout_vec() returns a Vec of sub-rects:
let areas_vec = area.layout_vec(&layout);
// you can also explicitly specify the number of constraints:
let areas = area.layout::<2>(&layout);
let areas = area.try_layout::<2>(&layout)?;
let areas = layout.try_areas::<2>(area)?;
```
362 lines
11 KiB
Rust
362 lines
11 KiB
Rust
/// A Ratatui example that demonstrates how to create an interactive table with a scrollbar.
|
|
///
|
|
/// This example runs with the Ratatui library code in the branch that you are currently
|
|
/// reading. See the [`latest`] branch for the code which works with the most recent Ratatui
|
|
/// release.
|
|
///
|
|
/// [`latest`]: https://github.com/ratatui/ratatui/tree/latest
|
|
use color_eyre::Result;
|
|
use crossterm::event::{self, KeyCode, KeyModifiers};
|
|
use itertools::Itertools;
|
|
use ratatui::layout::{Constraint, Layout, Margin, Rect};
|
|
use ratatui::style::{self, Color, Modifier, Style, Stylize};
|
|
use ratatui::text::Text;
|
|
use ratatui::widgets::{
|
|
Block, BorderType, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation,
|
|
ScrollbarState, Table, TableState,
|
|
};
|
|
use ratatui::{DefaultTerminal, Frame};
|
|
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; 2] = [
|
|
"(Esc) quit | (↑) move up | (↓) move down | (←) move left | (→) move right",
|
|
"(Shift + →) next color | (Shift + ←) previous color",
|
|
];
|
|
|
|
const ITEM_HEIGHT: usize = 4;
|
|
|
|
fn main() -> Result<()> {
|
|
color_eyre::install()?;
|
|
ratatui::run(|terminal| App::new().run(terminal))
|
|
}
|
|
struct TableColors {
|
|
buffer_bg: Color,
|
|
header_bg: Color,
|
|
header_fg: Color,
|
|
row_fg: Color,
|
|
selected_row_style_fg: Color,
|
|
selected_column_style_fg: Color,
|
|
selected_cell_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_row_style_fg: color.c400,
|
|
selected_column_style_fg: color.c400,
|
|
selected_cell_style_fg: color.c600,
|
|
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_row(&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_row(&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_column(&mut self) {
|
|
self.state.select_next_column();
|
|
}
|
|
|
|
pub fn previous_column(&mut self) {
|
|
self.state.select_previous_column();
|
|
}
|
|
|
|
pub const fn next_color(&mut self) {
|
|
self.color_index = (self.color_index + 1) % PALETTES.len();
|
|
}
|
|
|
|
pub const fn previous_color(&mut self) {
|
|
let count = PALETTES.len();
|
|
self.color_index = (self.color_index + count - 1) % count;
|
|
}
|
|
|
|
pub const fn set_colors(&mut self) {
|
|
self.colors = TableColors::new(&PALETTES[self.color_index]);
|
|
}
|
|
|
|
fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
|
loop {
|
|
terminal.draw(|frame| self.render(frame))?;
|
|
|
|
if let Some(key) = event::read()?.as_key_press_event() {
|
|
let shift_pressed = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
match key.code {
|
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
|
KeyCode::Char('j') | KeyCode::Down => self.next_row(),
|
|
KeyCode::Char('k') | KeyCode::Up => self.previous_row(),
|
|
KeyCode::Char('l') | KeyCode::Right if shift_pressed => self.next_color(),
|
|
KeyCode::Char('h') | KeyCode::Left if shift_pressed => {
|
|
self.previous_color();
|
|
}
|
|
KeyCode::Char('l') | KeyCode::Right => self.next_column(),
|
|
KeyCode::Char('h') | KeyCode::Left => self.previous_column(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render(&mut self, frame: &mut Frame) {
|
|
let layout = Layout::vertical([Constraint::Min(5), Constraint::Length(4)]);
|
|
let rects = frame.area().layout_vec(&layout);
|
|
|
|
self.set_colors();
|
|
|
|
self.render_table(frame, rects[0]);
|
|
self.render_scrollbar(frame, rects[0]);
|
|
self.render_footer(frame, rects[1]);
|
|
}
|
|
|
|
fn render_table(&mut self, frame: &mut Frame, area: Rect) {
|
|
let header_style = Style::default()
|
|
.fg(self.colors.header_fg)
|
|
.bg(self.colors.header_bg);
|
|
let selected_row_style = Style::default()
|
|
.add_modifier(Modifier::REVERSED)
|
|
.fg(self.colors.selected_row_style_fg);
|
|
let selected_col_style = Style::default().fg(self.colors.selected_column_style_fg);
|
|
let selected_cell_style = Style::default()
|
|
.add_modifier(Modifier::REVERSED)
|
|
.fg(self.colors.selected_cell_style_fg);
|
|
|
|
let header = ["Name", "Address", "Email"]
|
|
.into_iter()
|
|
.map(Cell::from)
|
|
.collect::<Row>()
|
|
.style(header_style)
|
|
.height(1);
|
|
let rows = self.items.iter().enumerate().map(|(i, data)| {
|
|
let color = match i % 2 {
|
|
0 => self.colors.normal_row_color,
|
|
_ => self.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(self.colors.row_fg).bg(color))
|
|
.height(4)
|
|
});
|
|
let bar = " █ ";
|
|
let t = Table::new(
|
|
rows,
|
|
[
|
|
// + 1 is for padding.
|
|
Constraint::Length(self.longest_item_lens.0 + 1),
|
|
Constraint::Min(self.longest_item_lens.1 + 1),
|
|
Constraint::Min(self.longest_item_lens.2),
|
|
],
|
|
)
|
|
.header(header)
|
|
.row_highlight_style(selected_row_style)
|
|
.column_highlight_style(selected_col_style)
|
|
.cell_highlight_style(selected_cell_style)
|
|
.highlight_symbol(Text::from(vec![
|
|
"".into(),
|
|
bar.into(),
|
|
bar.into(),
|
|
"".into(),
|
|
]))
|
|
.bg(self.colors.buffer_bg)
|
|
.highlight_spacing(HighlightSpacing::Always);
|
|
frame.render_stateful_widget(t, area, &mut self.state);
|
|
}
|
|
|
|
fn render_scrollbar(&mut self, frame: &mut Frame, area: Rect) {
|
|
frame.render_stateful_widget(
|
|
Scrollbar::default()
|
|
.orientation(ScrollbarOrientation::VerticalRight)
|
|
.begin_symbol(None)
|
|
.end_symbol(None),
|
|
area.inner(Margin {
|
|
vertical: 1,
|
|
horizontal: 1,
|
|
}),
|
|
&mut self.scroll_state,
|
|
);
|
|
}
|
|
|
|
fn render_footer(&self, frame: &mut Frame, area: Rect) {
|
|
let info_footer = Paragraph::new(Text::from_iter(INFO_TEXT))
|
|
.style(
|
|
Style::new()
|
|
.fg(self.colors.row_fg)
|
|
.bg(self.colors.buffer_bg),
|
|
)
|
|
.centered()
|
|
.block(
|
|
Block::bordered()
|
|
.border_type(BorderType::Double)
|
|
.border_style(Style::new().fg(self.colors.footer_border_color)),
|
|
);
|
|
frame.render_widget(info_footer, area);
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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);
|
|
|
|
#[expect(clippy::cast_possible_truncation)]
|
|
(name_len as u16, address_len as u16, email_len as u16)
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|