#![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<Data>,
longest_item_lens: (u16, u16, u16), 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>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let app = App::new();
let res = run_app(&mut terminal, app);
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 {
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::<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,
[
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);
}
}