[go: up one dir, main page]

table/
table.rs

1//! # [Ratatui] Table example
2//!
3//! The latest version of this example is available in the [examples] folder in the repository.
4//!
5//! Please note that the examples are designed to be run against the `main` branch of the Github
6//! repository. This means that you may not be able to compile with the latest release version on
7//! crates.io, or the one that you have installed locally.
8//!
9//! See the [examples readme] for more information on finding examples that match the version of the
10//! library you are using.
11//!
12//! [Ratatui]: https://github.com/ratatui/ratatui
13//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
14//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
15
16use 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), // order is (name, address, email)
111    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                // + 1 is for padding.
249                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}