Skip to main content

altui_core/widgets/
table.rs

1use crate::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::Style,
5    text::Text,
6    widgets::{Block, 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 altui_core::widgets::Cell;
15/// # use altui_core::style::{Style, Modifier};
16/// # use altui_core::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, Eq, 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 altui_core::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 altui_core::widgets::{Row, Cell};
71/// # use altui_core::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 altui_core::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, Eq, 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 altui_core::widgets::{Block, Borders, Table, Row, Cell};
143/// # use altui_core::layout::Constraint;
144/// # use altui_core::style::{Style, Color, Modifier};
145/// # use altui_core::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, Eq)]
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    offset: usize,
208    selected: Option<usize>,
209}
210
211impl<'a> Table<'a> {
212    pub fn new<T>(rows: T) -> Self
213    where
214        T: IntoIterator<Item = Row<'a>>,
215    {
216        Self {
217            block: None,
218            style: Style::default(),
219            widths: &[],
220            column_spacing: 1,
221            highlight_style: Style::default(),
222            highlight_symbol: None,
223            header: None,
224            rows: rows.into_iter().collect(),
225            offset: 0,
226            selected: None,
227        }
228    }
229
230    pub fn selected(&self) -> Option<usize> {
231        self.selected
232    }
233
234    pub fn select(&mut self, index: Option<usize>) {
235        self.selected = index;
236        if index.is_none() {
237            self.offset = 0;
238        }
239    }
240
241    pub fn block(mut self, block: Block<'a>) -> Self {
242        self.block = Some(block);
243        self
244    }
245
246    pub fn header(mut self, header: Row<'a>) -> Self {
247        self.header = Some(header);
248        self
249    }
250
251    pub fn widths(mut self, widths: &'a [Constraint]) -> Self {
252        let between_0_and_100 = |&w| match w {
253            Constraint::Percentage(p) => p <= 100,
254            _ => true,
255        };
256        assert!(
257            widths.iter().all(between_0_and_100),
258            "Percentages should be between 0 and 100 inclusively."
259        );
260        self.widths = widths;
261        self
262    }
263
264    pub fn style(mut self, style: Style) -> Self {
265        self.style = style;
266        self
267    }
268
269    pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
270        self.highlight_symbol = Some(highlight_symbol);
271        self
272    }
273
274    pub fn highlight_style(mut self, highlight_style: Style) -> Self {
275        self.highlight_style = highlight_style;
276        self
277    }
278
279    pub fn column_spacing(mut self, spacing: u16) -> Self {
280        self.column_spacing = spacing;
281        self
282    }
283
284    fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
285        let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1);
286        if has_selection {
287            let highlight_symbol_width =
288                self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
289            constraints.push(Constraint::Length(highlight_symbol_width));
290        }
291        for constraint in self.widths {
292            constraints.push(*constraint);
293            constraints.push(Constraint::Length(self.column_spacing));
294        }
295        if !self.widths.is_empty() {
296            constraints.pop();
297        }
298        let mut chunks = Layout::horizontal(constraints).split(Rect {
299            x: 0,
300            y: 0,
301            width: max_width,
302            height: 1,
303        });
304        if has_selection {
305            chunks.remove(0);
306        }
307        chunks.iter().step_by(2).map(|c| c.width).collect()
308    }
309
310    fn get_row_bounds(
311        &self,
312        selected: Option<usize>,
313        offset: usize,
314        max_height: u16,
315    ) -> (usize, usize) {
316        let offset = offset.min(self.rows.len().saturating_sub(1));
317        let mut start = offset;
318        let mut end = offset;
319        let mut height = 0;
320        for item in self.rows.iter().skip(offset) {
321            if height + item.height > max_height {
322                break;
323            }
324            height += item.total_height();
325            end += 1;
326        }
327
328        let selected = selected.unwrap_or(0).min(self.rows.len() - 1);
329        while selected >= end {
330            height = height.saturating_add(self.rows[end].total_height());
331            end += 1;
332            while height > max_height {
333                height = height.saturating_sub(self.rows[start].total_height());
334                start += 1;
335            }
336        }
337        while selected < start {
338            start -= 1;
339            height = height.saturating_add(self.rows[start].total_height());
340            while height > max_height {
341                end -= 1;
342                height = height.saturating_sub(self.rows[end].total_height());
343            }
344        }
345        (start, end)
346    }
347}
348
349impl<'a> Widget for Table<'a> {
350    fn render(&mut self, area: Rect, buf: &mut Buffer) {
351        if area.area() == 0 {
352            return;
353        }
354        buf.set_style(area, self.style);
355        let table_area = match self.block.as_mut() {
356            Some(b) => {
357                let inner_area = b.inner(area);
358                b.render(area, buf);
359                inner_area
360            }
361            None => area,
362        };
363
364        let has_selection = self.selected.is_some();
365        let columns_widths = self.get_columns_widths(table_area.width, has_selection);
366        let highlight_symbol = self.highlight_symbol.unwrap_or("");
367        let blank_symbol = " ".repeat(highlight_symbol.width());
368        let mut current_height = 0;
369        let mut rows_height = table_area.height;
370
371        // Draw header
372        if let Some(ref header) = self.header {
373            let max_header_height = table_area.height.min(header.total_height());
374            buf.set_style(
375                Rect {
376                    x: table_area.left(),
377                    y: table_area.top(),
378                    width: table_area.width,
379                    height: table_area.height.min(header.height),
380                },
381                header.style,
382            );
383            let mut col = table_area.left();
384            if has_selection {
385                col += (highlight_symbol.width() as u16).min(table_area.width);
386            }
387            for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
388                render_cell(
389                    buf,
390                    cell,
391                    Rect {
392                        x: col,
393                        y: table_area.top(),
394                        width: *width,
395                        height: max_header_height,
396                    },
397                );
398                col += *width + self.column_spacing;
399            }
400            current_height += max_header_height;
401            rows_height = rows_height.saturating_sub(max_header_height);
402        }
403
404        // Draw rows
405        if self.rows.is_empty() {
406            return;
407        }
408        let (start, end) = self.get_row_bounds(self.selected, self.offset, rows_height);
409        self.offset = start;
410        for (i, table_row) in self
411            .rows
412            .iter_mut()
413            .enumerate()
414            .skip(self.offset)
415            .take(end - start)
416        {
417            let (row, col) = (table_area.top() + current_height, table_area.left());
418            current_height += table_row.total_height();
419            let table_row_area = Rect {
420                x: col,
421                y: row,
422                width: table_area.width,
423                height: table_row.height,
424            };
425            buf.set_style(table_row_area, table_row.style);
426            let is_selected = self.selected.map(|s| s == i).unwrap_or(false);
427            let table_row_start_col = if has_selection {
428                let symbol = if is_selected {
429                    highlight_symbol
430                } else {
431                    &blank_symbol
432                };
433                let (col, _) =
434                    buf.set_stringn(col, row, symbol, table_area.width as usize, table_row.style);
435                col
436            } else {
437                col
438            };
439            let mut col = table_row_start_col;
440            for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
441                render_cell(
442                    buf,
443                    cell,
444                    Rect {
445                        x: col,
446                        y: row,
447                        width: *width,
448                        height: table_row.height,
449                    },
450                );
451                col += *width + self.column_spacing;
452            }
453            if is_selected {
454                buf.set_style(table_row_area, self.highlight_style);
455            }
456        }
457    }
458}
459
460fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
461    buf.set_style(area, cell.style);
462    for (i, spans) in cell.content.lines.iter().enumerate() {
463        if i as u16 >= area.height {
464            break;
465        }
466        buf.set_spans(area.x, area.y + i as u16, spans, area.width);
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    #[should_panic]
476    fn table_invalid_percentages() {
477        Table::new(vec![]).widths(&[Constraint::Percentage(110)]);
478    }
479}