1use color_eyre::Result;
17use crossterm::event::KeyModifiers;
18use itertools::Itertools;
19use ratatui::{
20 crossterm::event::{self, Event, KeyCode, KeyEventKind},
21 layout::{Constraint, Layout, Margin, Rect},
22 style::{self, Color, Modifier, Style, Stylize},
23 text::Text,
24 widgets::{
25 Block, BorderType, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation,
26 ScrollbarState, Table, TableState,
27 },
28 DefaultTerminal, Frame,
29};
30use style::palette::tailwind;
31use unicode_width::UnicodeWidthStr;
32
33const PALETTES: [tailwind::Palette; 4] = [
34 tailwind::BLUE,
35 tailwind::EMERALD,
36 tailwind::INDIGO,
37 tailwind::RED,
38];
39const INFO_TEXT: [&str; 2] = [
40 "(Esc) quit | (↑) move up | (↓) move down | (←) move left | (→) move right",
41 "(Shift + →) next color | (Shift + ←) previous color",
42];
43
44const ITEM_HEIGHT: usize = 4;
45
46fn main() -> Result<()> {
47 color_eyre::install()?;
48 let terminal = ratatui::init();
49 let app_result = App::new().run(terminal);
50 ratatui::restore();
51 app_result
52}
53struct TableColors {
54 buffer_bg: Color,
55 header_bg: Color,
56 header_fg: Color,
57 row_fg: Color,
58 selected_row_style_fg: Color,
59 selected_column_style_fg: Color,
60 selected_cell_style_fg: Color,
61 normal_row_color: Color,
62 alt_row_color: Color,
63 footer_border_color: Color,
64}
65
66impl TableColors {
67 const fn new(color: &tailwind::Palette) -> Self {
68 Self {
69 buffer_bg: tailwind::SLATE.c950,
70 header_bg: color.c900,
71 header_fg: tailwind::SLATE.c200,
72 row_fg: tailwind::SLATE.c200,
73 selected_row_style_fg: color.c400,
74 selected_column_style_fg: color.c400,
75 selected_cell_style_fg: color.c600,
76 normal_row_color: tailwind::SLATE.c950,
77 alt_row_color: tailwind::SLATE.c900,
78 footer_border_color: color.c400,
79 }
80 }
81}
82
83struct Data {
84 name: String,
85 address: String,
86 email: String,
87}
88
89impl Data {
90 const fn ref_array(&self) -> [&String; 3] {
91 [&self.name, &self.address, &self.email]
92 }
93
94 fn name(&self) -> &str {
95 &self.name
96 }
97
98 fn address(&self) -> &str {
99 &self.address
100 }
101
102 fn email(&self) -> &str {
103 &self.email
104 }
105}
106
107struct App {
108 state: TableState,
109 items: Vec<Data>,
110 longest_item_lens: (u16, u16, u16), scroll_state: ScrollbarState,
112 colors: TableColors,
113 color_index: usize,
114}
115
116impl App {
117 fn new() -> Self {
118 let data_vec = generate_fake_names();
119 Self {
120 state: TableState::default().with_selected(0),
121 longest_item_lens: constraint_len_calculator(&data_vec),
122 scroll_state: ScrollbarState::new((data_vec.len() - 1) * ITEM_HEIGHT),
123 colors: TableColors::new(&PALETTES[0]),
124 color_index: 0,
125 items: data_vec,
126 }
127 }
128 pub fn next_row(&mut self) {
129 let i = match self.state.selected() {
130 Some(i) => {
131 if i >= self.items.len() - 1 {
132 0
133 } else {
134 i + 1
135 }
136 }
137 None => 0,
138 };
139 self.state.select(Some(i));
140 self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT);
141 }
142
143 pub fn previous_row(&mut self) {
144 let i = match self.state.selected() {
145 Some(i) => {
146 if i == 0 {
147 self.items.len() - 1
148 } else {
149 i - 1
150 }
151 }
152 None => 0,
153 };
154 self.state.select(Some(i));
155 self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT);
156 }
157
158 pub fn next_column(&mut self) {
159 self.state.select_next_column();
160 }
161
162 pub fn previous_column(&mut self) {
163 self.state.select_previous_column();
164 }
165
166 pub fn next_color(&mut self) {
167 self.color_index = (self.color_index + 1) % PALETTES.len();
168 }
169
170 pub fn previous_color(&mut self) {
171 let count = PALETTES.len();
172 self.color_index = (self.color_index + count - 1) % count;
173 }
174
175 pub fn set_colors(&mut self) {
176 self.colors = TableColors::new(&PALETTES[self.color_index]);
177 }
178
179 fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
180 loop {
181 terminal.draw(|frame| self.draw(frame))?;
182
183 if let Event::Key(key) = event::read()? {
184 if key.kind == KeyEventKind::Press {
185 let shift_pressed = key.modifiers.contains(KeyModifiers::SHIFT);
186 match key.code {
187 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
188 KeyCode::Char('j') | KeyCode::Down => self.next_row(),
189 KeyCode::Char('k') | KeyCode::Up => self.previous_row(),
190 KeyCode::Char('l') | KeyCode::Right if shift_pressed => self.next_color(),
191 KeyCode::Char('h') | KeyCode::Left if shift_pressed => {
192 self.previous_color();
193 }
194 KeyCode::Char('l') | KeyCode::Right => self.next_column(),
195 KeyCode::Char('h') | KeyCode::Left => self.previous_column(),
196 _ => {}
197 }
198 }
199 }
200 }
201 }
202
203 fn draw(&mut self, frame: &mut Frame) {
204 let vertical = &Layout::vertical([Constraint::Min(5), Constraint::Length(4)]);
205 let rects = vertical.split(frame.area());
206
207 self.set_colors();
208
209 self.render_table(frame, rects[0]);
210 self.render_scrollbar(frame, rects[0]);
211 self.render_footer(frame, rects[1]);
212 }
213
214 fn render_table(&mut self, frame: &mut Frame, area: Rect) {
215 let header_style = Style::default()
216 .fg(self.colors.header_fg)
217 .bg(self.colors.header_bg);
218 let selected_row_style = Style::default()
219 .add_modifier(Modifier::REVERSED)
220 .fg(self.colors.selected_row_style_fg);
221 let selected_col_style = Style::default().fg(self.colors.selected_column_style_fg);
222 let selected_cell_style = Style::default()
223 .add_modifier(Modifier::REVERSED)
224 .fg(self.colors.selected_cell_style_fg);
225
226 let header = ["Name", "Address", "Email"]
227 .into_iter()
228 .map(Cell::from)
229 .collect::<Row>()
230 .style(header_style)
231 .height(1);
232 let rows = self.items.iter().enumerate().map(|(i, data)| {
233 let color = match i % 2 {
234 0 => self.colors.normal_row_color,
235 _ => self.colors.alt_row_color,
236 };
237 let item = data.ref_array();
238 item.into_iter()
239 .map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
240 .collect::<Row>()
241 .style(Style::new().fg(self.colors.row_fg).bg(color))
242 .height(4)
243 });
244 let bar = " █ ";
245 let t = Table::new(
246 rows,
247 [
248 Constraint::Length(self.longest_item_lens.0 + 1),
250 Constraint::Min(self.longest_item_lens.1 + 1),
251 Constraint::Min(self.longest_item_lens.2),
252 ],
253 )
254 .header(header)
255 .row_highlight_style(selected_row_style)
256 .column_highlight_style(selected_col_style)
257 .cell_highlight_style(selected_cell_style)
258 .highlight_symbol(Text::from(vec![
259 "".into(),
260 bar.into(),
261 bar.into(),
262 "".into(),
263 ]))
264 .bg(self.colors.buffer_bg)
265 .highlight_spacing(HighlightSpacing::Always);
266 frame.render_stateful_widget(t, area, &mut self.state);
267 }
268
269 fn render_scrollbar(&mut self, frame: &mut Frame, area: Rect) {
270 frame.render_stateful_widget(
271 Scrollbar::default()
272 .orientation(ScrollbarOrientation::VerticalRight)
273 .begin_symbol(None)
274 .end_symbol(None),
275 area.inner(Margin {
276 vertical: 1,
277 horizontal: 1,
278 }),
279 &mut self.scroll_state,
280 );
281 }
282
283 fn render_footer(&self, frame: &mut Frame, area: Rect) {
284 let info_footer = Paragraph::new(Text::from_iter(INFO_TEXT))
285 .style(
286 Style::new()
287 .fg(self.colors.row_fg)
288 .bg(self.colors.buffer_bg),
289 )
290 .centered()
291 .block(
292 Block::bordered()
293 .border_type(BorderType::Double)
294 .border_style(Style::new().fg(self.colors.footer_border_color)),
295 );
296 frame.render_widget(info_footer, area);
297 }
298}
299
300fn generate_fake_names() -> Vec<Data> {
301 use fakeit::{address, contact, name};
302
303 (0..20)
304 .map(|_| {
305 let name = name::full();
306 let address = format!(
307 "{}\n{}, {} {}",
308 address::street(),
309 address::city(),
310 address::state(),
311 address::zip()
312 );
313 let email = contact::email();
314
315 Data {
316 name,
317 address,
318 email,
319 }
320 })
321 .sorted_by(|a, b| a.name.cmp(&b.name))
322 .collect()
323}
324
325fn constraint_len_calculator(items: &[Data]) -> (u16, u16, u16) {
326 let name_len = items
327 .iter()
328 .map(Data::name)
329 .map(UnicodeWidthStr::width)
330 .max()
331 .unwrap_or(0);
332 let address_len = items
333 .iter()
334 .map(Data::address)
335 .flat_map(str::lines)
336 .map(UnicodeWidthStr::width)
337 .max()
338 .unwrap_or(0);
339 let email_len = items
340 .iter()
341 .map(Data::email)
342 .map(UnicodeWidthStr::width)
343 .max()
344 .unwrap_or(0);
345
346 #[allow(clippy::cast_possible_truncation)]
347 (name_len as u16, address_len as u16, email_len as u16)
348}
349
350#[cfg(test)]
351mod tests {
352 use crate::Data;
353
354 #[test]
355 fn constraint_len_calculator() {
356 let test_data = vec![
357 Data {
358 name: "Emirhan Tala".to_string(),
359 address: "Cambridgelaan 6XX\n3584 XX Utrecht".to_string(),
360 email: "tala.emirhan@gmail.com".to_string(),
361 },
362 Data {
363 name: "thistextis26characterslong".to_string(),
364 address: "this line is 31 characters long\nbottom line is 33 characters long"
365 .to_string(),
366 email: "thisemailis40caharacterslong@ratatui.com".to_string(),
367 },
368 ];
369 let (longest_name_len, longest_address_len, longest_email_len) =
370 crate::constraint_len_calculator(&test_data);
371
372 assert_eq!(26, longest_name_len);
373 assert_eq!(33, longest_address_len);
374 assert_eq!(40, longest_email_len);
375 }
376}