zui_widgets/widgets/
table.rs

1use crate::{
2    buffer::Buffer,
3    layout::{Constraint, Direction, Layout, Rect},
4    style::Style,
5    text::Text,
6    widgets::{Block, StatefulWidget, Widget},
7};
8use unicode_width::UnicodeWidthStr;
9
10/// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`].
11///
12/// It can be created from anything that can be converted to a [`Text`].
13/// ```rust
14/// # use zui_widgets::widgets::Cell;
15/// # use zui_widgets::style::{Style, Modifier};
16/// # use zui_widgets::text::{Span, Spans, Text};
17/// # use std::borrow::Cow;
18/// Cell::from("simple string");
19///
20/// Cell::from(Span::from("span"));
21///
22/// Cell::from(Spans::from(vec![
23///     Span::raw("a vec of "),
24///     Span::styled("spans", Style::default().add_modifier(Modifier::BOLD))
25/// ]));
26///
27/// Cell::from(Text::from("a text"));
28///
29/// Cell::from(Text::from(Cow::Borrowed("hello")));
30/// ```
31///
32/// You can apply a [`Style`] on the entire [`Cell`] using [`Cell::style`] or rely on the styling
33/// capabilities of [`Text`].
34#[derive(Debug, Clone, PartialEq, Default)]
35pub struct Cell<'a> {
36    content: Text<'a>,
37    style: Style,
38}
39
40impl<'a> Cell<'a> {
41    /// Set the `Style` of this cell.
42    pub fn style(mut self, style: Style) -> Self {
43        self.style = style;
44        self
45    }
46}
47
48impl<'a, T> From<T> for Cell<'a>
49where
50    T: Into<Text<'a>>,
51{
52    fn from(content: T) -> Cell<'a> {
53        Cell {
54            content: content.into(),
55            style: Style::default(),
56        }
57    }
58}
59
60/// Holds data to be displayed in a [`Table`] widget.
61///
62/// A [`Row`] is a collection of cells. It can be created from simple strings:
63/// ```rust
64/// # use zui_widgets::widgets::Row;
65/// Row::new(vec!["Cell1", "Cell2", "Cell3"]);
66/// ```
67///
68/// But if you need a bit more control over individual cells, you can explicity create [`Cell`]s:
69/// ```rust
70/// # use zui_widgets::widgets::{Row, Cell};
71/// # use zui_widgets::style::{Style, Color};
72/// Row::new(vec![
73///     Cell::from("Cell1"),
74///     Cell::from("Cell2").style(Style::default().fg(Color::Yellow)),
75/// ]);
76/// ```
77///
78/// You can also construct a row from any type that can be converted into [`Text`]:
79/// ```rust
80/// # use std::borrow::Cow;
81/// # use zui_widgets::widgets::Row;
82/// Row::new(vec![
83///     Cow::Borrowed("hello"),
84///     Cow::Owned("world".to_uppercase()),
85/// ]);
86/// ```
87///
88/// By default, a row has a height of 1 but you can change this using [`Row::height`].
89#[derive(Debug, Clone, PartialEq, Default)]
90pub struct Row<'a> {
91    cells: Vec<Cell<'a>>,
92    height: u16,
93    style: Style,
94    bottom_margin: u16,
95}
96
97impl<'a> Row<'a> {
98    /// Creates a new [`Row`] from an iterator where items can be converted to a [`Cell`].
99    pub fn new<T>(cells: T) -> Self
100    where
101        T: IntoIterator,
102        T::Item: Into<Cell<'a>>,
103    {
104        Self {
105            height: 1,
106            cells: cells.into_iter().map(|c| c.into()).collect(),
107            style: Style::default(),
108            bottom_margin: 0,
109        }
110    }
111
112    /// Set the fixed height of the [`Row`]. Any [`Cell`] whose content has more lines than this
113    /// height will see its content truncated.
114    pub fn height(mut self, height: u16) -> Self {
115        self.height = height;
116        self
117    }
118
119    /// Set the [`Style`] of the entire row. This [`Style`] can be overriden by the [`Style`] of a
120    /// any individual [`Cell`] or event by their [`Text`] content.
121    pub fn style(mut self, style: Style) -> Self {
122        self.style = style;
123        self
124    }
125
126    /// Set the bottom margin. By default, the bottom margin is `0`.
127    pub fn bottom_margin(mut self, margin: u16) -> Self {
128        self.bottom_margin = margin;
129        self
130    }
131
132    /// Returns the total height of the row.
133    fn total_height(&self) -> u16 {
134        self.height.saturating_add(self.bottom_margin)
135    }
136}
137
138/// A widget to display data in formatted columns.
139///
140/// It is a collection of [`Row`]s, themselves composed of [`Cell`]s:
141/// ```rust
142/// # use zui_widgets::widgets::{Block, Borders, Table, Row, Cell};
143/// # use zui_widgets::layout::Constraint;
144/// # use zui_widgets::style::{Style, Color, Modifier};
145/// # use zui_widgets::text::{Text, Spans, Span};
146/// Table::new(vec![
147///     // Row can be created from simple strings.
148///     Row::new(vec!["Row11", "Row12", "Row13"]),
149///     // You can style the entire row.
150///     Row::new(vec!["Row21", "Row22", "Row23"]).style(Style::default().fg(Color::Blue)),
151///     // If you need more control over the styling you may need to create Cells directly
152///     Row::new(vec![
153///         Cell::from("Row31"),
154///         Cell::from("Row32").style(Style::default().fg(Color::Yellow)),
155///         Cell::from(Spans::from(vec![
156///             Span::raw("Row"),
157///             Span::styled("33", Style::default().fg(Color::Green))
158///         ])),
159///     ]),
160///     // If a Row need to display some content over multiple lines, you just have to change
161///     // its height.
162///     Row::new(vec![
163///         Cell::from("Row\n41"),
164///         Cell::from("Row\n42"),
165///         Cell::from("Row\n43"),
166///     ]).height(2),
167/// ])
168/// // You can set the style of the entire Table.
169/// .style(Style::default().fg(Color::White))
170/// // It has an optional header, which is simply a Row always visible at the top.
171/// .header(
172///     Row::new(vec!["Col1", "Col2", "Col3"])
173///         .style(Style::default().fg(Color::Yellow))
174///         // If you want some space between the header and the rest of the rows, you can always
175///         // specify some margin at the bottom.
176///         .bottom_margin(1)
177/// )
178/// // As any other widget, a Table can be wrapped in a Block.
179/// .block(Block::default().title("Table"))
180/// // Columns widths are constrained in the same way as Layout...
181/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
182/// // ...and they can be separated by a fixed spacing.
183/// .column_spacing(1)
184/// // If you wish to highlight a row in any specific way when it is selected...
185/// .highlight_style(Style::default().add_modifier(Modifier::BOLD))
186/// // ...and potentially show a symbol in front of the selection.
187/// .highlight_symbol(">>");
188/// ```
189#[derive(Debug, Clone, PartialEq)]
190pub struct Table<'a> {
191    /// A block to wrap the widget in
192    block: Option<Block<'a>>,
193    /// Base style for the widget
194    style: Style,
195    /// Width constraints for each column
196    widths: &'a [Constraint],
197    /// Space between each column
198    column_spacing: u16,
199    /// Style used to render the selected row
200    highlight_style: Style,
201    /// Symbol in front of the selected rom
202    highlight_symbol: Option<&'a str>,
203    /// Optional header
204    header: Option<Row<'a>>,
205    /// Data to display in each row
206    rows: Vec<Row<'a>>,
207}
208
209impl<'a> Table<'a> {
210    pub fn new<T>(rows: T) -> Self
211    where
212        T: IntoIterator<Item = Row<'a>>,
213    {
214        Self {
215            block: None,
216            style: Style::default(),
217            widths: &[],
218            column_spacing: 1,
219            highlight_style: Style::default(),
220            highlight_symbol: None,
221            header: None,
222            rows: rows.into_iter().collect(),
223        }
224    }
225
226    pub fn block(mut self, block: Block<'a>) -> Self {
227        self.block = Some(block);
228        self
229    }
230
231    pub fn header(mut self, header: Row<'a>) -> Self {
232        self.header = Some(header);
233        self
234    }
235
236    pub fn widths(mut self, widths: &'a [Constraint]) -> Self {
237        let between_0_and_100 = |&w| match w {
238            Constraint::Percentage(p) => p <= 100,
239            _ => true,
240        };
241        assert!(
242            widths.iter().all(between_0_and_100),
243            "Percentages should be between 0 and 100 inclusively."
244        );
245        self.widths = widths;
246        self
247    }
248
249    pub fn style(mut self, style: Style) -> Self {
250        self.style = style;
251        self
252    }
253
254    pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
255        self.highlight_symbol = Some(highlight_symbol);
256        self
257    }
258
259    pub fn highlight_style(mut self, highlight_style: Style) -> Self {
260        self.highlight_style = highlight_style;
261        self
262    }
263
264    pub fn column_spacing(mut self, spacing: u16) -> Self {
265        self.column_spacing = spacing;
266        self
267    }
268
269    fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
270        let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1);
271        if has_selection {
272            let highlight_symbol_width =
273                self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
274            constraints.push(Constraint::Length(highlight_symbol_width));
275        }
276        for constraint in self.widths {
277            constraints.push(*constraint);
278            constraints.push(Constraint::Length(self.column_spacing));
279        }
280        if !self.widths.is_empty() {
281            constraints.pop();
282        }
283        let mut chunks = Layout::default()
284            .direction(Direction::Horizontal)
285            .constraints(constraints)
286            .expand_to_fill(false)
287            .split(Rect {
288                x: 0,
289                y: 0,
290                width: max_width,
291                height: 1,
292            });
293        if has_selection {
294            chunks.remove(0);
295        }
296        chunks.iter().step_by(2).map(|c| c.width).collect()
297    }
298
299    fn get_row_bounds(
300        &self,
301        selected: Option<usize>,
302        offset: usize,
303        max_height: u16,
304    ) -> (usize, usize) {
305        let offset = offset.min(self.rows.len().saturating_sub(1));
306        let mut start = offset;
307        let mut end = offset;
308        let mut height = 0;
309        for item in self.rows.iter().skip(offset) {
310            if height + item.height > max_height {
311                break;
312            }
313            height += item.total_height();
314            end += 1;
315        }
316
317        let selected = selected.unwrap_or(0).min(self.rows.len() - 1);
318        while selected >= end {
319            height = height.saturating_add(self.rows[end].total_height());
320            end += 1;
321            while height > max_height {
322                height = height.saturating_sub(self.rows[start].total_height());
323                start += 1;
324            }
325        }
326        while selected < start {
327            start -= 1;
328            height = height.saturating_add(self.rows[start].total_height());
329            while height > max_height {
330                end -= 1;
331                height = height.saturating_sub(self.rows[end].total_height());
332            }
333        }
334        (start, end)
335    }
336}
337
338#[derive(Debug, Clone)]
339pub struct TableState {
340    offset: usize,
341    selected: Option<usize>,
342}
343
344impl Default for TableState {
345    fn default() -> TableState {
346        TableState {
347            offset: 0,
348            selected: None,
349        }
350    }
351}
352
353impl TableState {
354    pub fn selected(&self) -> Option<usize> {
355        self.selected
356    }
357
358    pub fn select(&mut self, index: Option<usize>) {
359        self.selected = index;
360        if index.is_none() {
361            self.offset = 0;
362        }
363    }
364}
365
366impl<'a> StatefulWidget for Table<'a> {
367    type State = TableState;
368
369    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
370        if area.area() == 0 {
371            return;
372        }
373        buf.set_style(area, self.style);
374        let table_area = match self.block.take() {
375            Some(b) => {
376                let inner_area = b.inner(area);
377                b.render(area, buf);
378                inner_area
379            }
380            None => area,
381        };
382
383        let has_selection = state.selected.is_some();
384        let columns_widths = self.get_columns_widths(table_area.width, has_selection);
385        let highlight_symbol = self.highlight_symbol.unwrap_or("");
386        let blank_symbol = " ".repeat(highlight_symbol.width());
387        let mut current_height = 0;
388        let mut rows_height = table_area.height;
389
390        // Draw header
391        if let Some(ref header) = self.header {
392            let max_header_height = table_area.height.min(header.total_height());
393            buf.set_style(
394                Rect {
395                    x: table_area.left(),
396                    y: table_area.top(),
397                    width: table_area.width,
398                    height: table_area.height.min(header.height),
399                },
400                header.style,
401            );
402            let mut col = table_area.left();
403            if has_selection {
404                col += (highlight_symbol.width() as u16).min(table_area.width);
405            }
406            for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
407                render_cell(
408                    buf,
409                    cell,
410                    Rect {
411                        x: col,
412                        y: table_area.top(),
413                        width: *width,
414                        height: max_header_height,
415                    },
416                );
417                col += *width + self.column_spacing;
418            }
419            current_height += max_header_height;
420            rows_height = rows_height.saturating_sub(max_header_height);
421        }
422
423        // Draw rows
424        if self.rows.is_empty() {
425            return;
426        }
427        let (start, end) = self.get_row_bounds(state.selected, state.offset, rows_height);
428        state.offset = start;
429        for (i, table_row) in self
430            .rows
431            .iter_mut()
432            .enumerate()
433            .skip(state.offset)
434            .take(end - start)
435        {
436            let (row, col) = (table_area.top() + current_height, table_area.left());
437            current_height += table_row.total_height();
438            let table_row_area = Rect {
439                x: col,
440                y: row,
441                width: table_area.width,
442                height: table_row.height,
443            };
444            buf.set_style(table_row_area, table_row.style);
445            let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
446            let table_row_start_col = if has_selection {
447                let symbol = if is_selected {
448                    highlight_symbol
449                } else {
450                    &blank_symbol
451                };
452                let (col, _) =
453                    buf.set_stringn(col, row, symbol, table_area.width as usize, table_row.style);
454                col
455            } else {
456                col
457            };
458            let mut col = table_row_start_col;
459            for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
460                render_cell(
461                    buf,
462                    cell,
463                    Rect {
464                        x: col,
465                        y: row,
466                        width: *width,
467                        height: table_row.height,
468                    },
469                );
470                col += *width + self.column_spacing;
471            }
472            if is_selected {
473                buf.set_style(table_row_area, self.highlight_style);
474            }
475        }
476    }
477}
478
479fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
480    buf.set_style(area, cell.style);
481    for (i, spans) in cell.content.lines.iter().enumerate() {
482        if i as u16 >= area.height {
483            break;
484        }
485        buf.set_spans(area.x, area.y + i as u16, spans, area.width);
486    }
487}
488
489impl<'a> Widget for Table<'a> {
490    fn render(self, area: Rect, buf: &mut Buffer) {
491        let mut state = TableState::default();
492        StatefulWidget::render(self, area, buf, &mut state);
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    #[should_panic]
502    fn table_invalid_percentages() {
503        Table::new(vec![]).widths(&[Constraint::Percentage(110)]);
504    }
505}