summaryrefslogtreecommitdiff
path: root/helix-tui/src/widgets/table.rs
diff options
context:
space:
mode:
Diffstat (limited to 'helix-tui/src/widgets/table.rs')
-rw-r--r--helix-tui/src/widgets/table.rs538
1 files changed, 538 insertions, 0 deletions
diff --git a/helix-tui/src/widgets/table.rs b/helix-tui/src/widgets/table.rs
new file mode 100644
index 00000000..31624a8f
--- /dev/null
+++ b/helix-tui/src/widgets/table.rs
@@ -0,0 +1,538 @@
+use crate::{
+ buffer::Buffer,
+ layout::{Constraint, Rect},
+ style::Style,
+ text::Text,
+ widgets::{Block, StatefulWidget, Widget},
+};
+use cassowary::{
+ strength::{MEDIUM, REQUIRED, WEAK},
+ WeightedRelation::*,
+ {Expression, Solver},
+};
+use std::{
+ collections::HashMap,
+ iter::{self, Iterator},
+};
+use unicode_width::UnicodeWidthStr;
+
+/// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`].
+///
+/// It can be created from anything that can be converted to a [`Text`].
+/// ```rust
+/// # use helix_tui::widgets::Cell;
+/// # use helix_tui::style::{Style, Modifier};
+/// # use helix_tui::text::{Span, Spans, Text};
+/// Cell::from("simple string");
+///
+/// Cell::from(Span::from("span"));
+///
+/// Cell::from(Spans::from(vec![
+/// Span::raw("a vec of "),
+/// Span::styled("spans", Style::default().add_modifier(Modifier::BOLD))
+/// ]));
+///
+/// Cell::from(Text::from("a text"));
+/// ```
+///
+/// You can apply a [`Style`] on the entire [`Cell`] using [`Cell::style`] or rely on the styling
+/// capabilities of [`Text`].
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Cell<'a> {
+ content: Text<'a>,
+ style: Style,
+}
+
+impl<'a> Cell<'a> {
+ /// Set the `Style` of this cell.
+ pub fn style(mut self, style: Style) -> Self {
+ self.style = style;
+ self
+ }
+}
+
+impl<'a, T> From<T> for Cell<'a>
+where
+ T: Into<Text<'a>>,
+{
+ fn from(content: T) -> Cell<'a> {
+ Cell {
+ content: content.into(),
+ style: Style::default(),
+ }
+ }
+}
+
+/// Holds data to be displayed in a [`Table`] widget.
+///
+/// A [`Row`] is a collection of cells. It can be created from simple strings:
+/// ```rust
+/// # use helix_tui::widgets::Row;
+/// Row::new(vec!["Cell1", "Cell2", "Cell3"]);
+/// ```
+///
+/// But if you need a bit more control over individual cells, you can explicity create [`Cell`]s:
+/// ```rust
+/// # use helix_tui::widgets::{Row, Cell};
+/// # use helix_tui::style::{Style, Color};
+/// Row::new(vec![
+/// Cell::from("Cell1"),
+/// Cell::from("Cell2").style(Style::default().fg(Color::Yellow)),
+/// ]);
+/// ```
+///
+/// By default, a row has a height of 1 but you can change this using [`Row::height`].
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Row<'a> {
+ cells: Vec<Cell<'a>>,
+ height: u16,
+ style: Style,
+ bottom_margin: u16,
+}
+
+impl<'a> Row<'a> {
+ /// Creates a new [`Row`] from an iterator where items can be converted to a [`Cell`].
+ pub fn new<T>(cells: T) -> Self
+ where
+ T: IntoIterator,
+ T::Item: Into<Cell<'a>>,
+ {
+ Self {
+ height: 1,
+ cells: cells.into_iter().map(|c| c.into()).collect(),
+ style: Style::default(),
+ bottom_margin: 0,
+ }
+ }
+
+ /// Set the fixed height of the [`Row`]. Any [`Cell`] whose content has more lines than this
+ /// height will see its content truncated.
+ pub fn height(mut self, height: u16) -> Self {
+ self.height = height;
+ self
+ }
+
+ /// Set the [`Style`] of the entire row. This [`Style`] can be overriden by the [`Style`] of a
+ /// any individual [`Cell`] or event by their [`Text`] content.
+ pub fn style(mut self, style: Style) -> Self {
+ self.style = style;
+ self
+ }
+
+ /// Set the bottom margin. By default, the bottom margin is `0`.
+ pub fn bottom_margin(mut self, margin: u16) -> Self {
+ self.bottom_margin = margin;
+ self
+ }
+
+ /// Returns the total height of the row.
+ fn total_height(&self) -> u16 {
+ self.height.saturating_add(self.bottom_margin)
+ }
+}
+
+/// A widget to display data in formatted columns.
+///
+/// It is a collection of [`Row`]s, themselves composed of [`Cell`]s:
+/// ```rust
+/// # use helix_tui::widgets::{Block, Borders, Table, Row, Cell};
+/// # use helix_tui::layout::Constraint;
+/// # use helix_tui::style::{Style, Color, Modifier};
+/// # use helix_tui::text::{Text, Spans, Span};
+/// Table::new(vec![
+/// // Row can be created from simple strings.
+/// Row::new(vec!["Row11", "Row12", "Row13"]),
+/// // You can style the entire row.
+/// Row::new(vec!["Row21", "Row22", "Row23"]).style(Style::default().fg(Color::Blue)),
+/// // If you need more control over the styling you may need to create Cells directly
+/// Row::new(vec![
+/// Cell::from("Row31"),
+/// Cell::from("Row32").style(Style::default().fg(Color::Yellow)),
+/// Cell::from(Spans::from(vec![
+/// Span::raw("Row"),
+/// Span::styled("33", Style::default().fg(Color::Green))
+/// ])),
+/// ]),
+/// // If a Row need to display some content over multiple lines, you just have to change
+/// // its height.
+/// Row::new(vec![
+/// Cell::from("Row\n41"),
+/// Cell::from("Row\n42"),
+/// Cell::from("Row\n43"),
+/// ]).height(2),
+/// ])
+/// // You can set the style of the entire Table.
+/// .style(Style::default().fg(Color::White))
+/// // It has an optional header, which is simply a Row always visible at the top.
+/// .header(
+/// Row::new(vec!["Col1", "Col2", "Col3"])
+/// .style(Style::default().fg(Color::Yellow))
+/// // If you want some space between the header and the rest of the rows, you can always
+/// // specify some margin at the bottom.
+/// .bottom_margin(1)
+/// )
+/// // As any other widget, a Table can be wrapped in a Block.
+/// .block(Block::default().title("Table"))
+/// // Columns widths are constrained in the same way as Layout...
+/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
+/// // ...and they can be separated by a fixed spacing.
+/// .column_spacing(1)
+/// // If you wish to highlight a row in any specific way when it is selected...
+/// .highlight_style(Style::default().add_modifier(Modifier::BOLD))
+/// // ...and potentially show a symbol in front of the selection.
+/// .highlight_symbol(">>");
+/// ```
+#[derive(Debug, Clone, PartialEq)]
+pub struct Table<'a> {
+ /// A block to wrap the widget in
+ block: Option<Block<'a>>,
+ /// Base style for the widget
+ style: Style,
+ /// Width constraints for each column
+ widths: &'a [Constraint],
+ /// Space between each column
+ column_spacing: u16,
+ /// Style used to render the selected row
+ highlight_style: Style,
+ /// Symbol in front of the selected rom
+ highlight_symbol: Option<&'a str>,
+ /// Optional header
+ header: Option<Row<'a>>,
+ /// Data to display in each row
+ rows: Vec<Row<'a>>,
+}
+
+impl<'a> Table<'a> {
+ pub fn new<T>(rows: T) -> Self
+ where
+ T: IntoIterator<Item = Row<'a>>,
+ {
+ Self {
+ block: None,
+ style: Style::default(),
+ widths: &[],
+ column_spacing: 1,
+ highlight_style: Style::default(),
+ highlight_symbol: None,
+ header: None,
+ rows: rows.into_iter().collect(),
+ }
+ }
+
+ pub fn block(mut self, block: Block<'a>) -> Self {
+ self.block = Some(block);
+ self
+ }
+
+ pub fn header(mut self, header: Row<'a>) -> Self {
+ self.header = Some(header);
+ self
+ }
+
+ pub fn widths(mut self, widths: &'a [Constraint]) -> Self {
+ let between_0_and_100 = |&w| match w {
+ Constraint::Percentage(p) => p <= 100,
+ _ => true,
+ };
+ assert!(
+ widths.iter().all(between_0_and_100),
+ "Percentages should be between 0 and 100 inclusively."
+ );
+ self.widths = widths;
+ self
+ }
+
+ pub fn style(mut self, style: Style) -> Self {
+ self.style = style;
+ self
+ }
+
+ pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
+ self.highlight_symbol = Some(highlight_symbol);
+ self
+ }
+
+ pub fn highlight_style(mut self, highlight_style: Style) -> Self {
+ self.highlight_style = highlight_style;
+ self
+ }
+
+ pub fn column_spacing(mut self, spacing: u16) -> Self {
+ self.column_spacing = spacing;
+ self
+ }
+
+ fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec<u16> {
+ let mut solver = Solver::new();
+ let mut var_indices = HashMap::new();
+ let mut ccs = Vec::new();
+ let mut variables = Vec::new();
+ for i in 0..self.widths.len() {
+ let var = cassowary::Variable::new();
+ variables.push(var);
+ var_indices.insert(var, i);
+ }
+ let spacing_width = (variables.len() as u16).saturating_sub(1) * self.column_spacing;
+ let mut available_width = max_width.saturating_sub(spacing_width);
+ if has_selection {
+ let highlight_symbol_width =
+ self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0);
+ available_width = available_width.saturating_sub(highlight_symbol_width);
+ }
+ for (i, constraint) in self.widths.iter().enumerate() {
+ ccs.push(variables[i] | GE(WEAK) | 0.);
+ ccs.push(match *constraint {
+ Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v),
+ Constraint::Percentage(v) => {
+ variables[i] | EQ(WEAK) | (f64::from(v * available_width) / 100.0)
+ }
+ Constraint::Ratio(n, d) => {
+ variables[i]
+ | EQ(WEAK)
+ | (f64::from(available_width) * f64::from(n) / f64::from(d))
+ }
+ Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v),
+ Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v),
+ })
+ }
+ solver
+ .add_constraint(
+ variables
+ .iter()
+ .fold(Expression::from_constant(0.), |acc, v| acc + *v)
+ | LE(REQUIRED)
+ | f64::from(available_width),
+ )
+ .unwrap();
+ solver.add_constraints(&ccs).unwrap();
+ let mut widths = vec![0; variables.len()];
+ for &(var, value) in solver.fetch_changes() {
+ let index = var_indices[&var];
+ let value = if value.is_sign_negative() {
+ 0
+ } else {
+ value.round() as u16
+ };
+ widths[index] = value;
+ }
+ // Cassowary could still return columns widths greater than the max width when there are
+ // fixed length constraints that cannot be satisfied. Therefore, we clamp the widths from
+ // left to right.
+ let mut available_width = max_width;
+ for w in &mut widths {
+ *w = available_width.min(*w);
+ available_width = available_width
+ .saturating_sub(*w)
+ .saturating_sub(self.column_spacing);
+ }
+ widths
+ }
+
+ fn get_row_bounds(
+ &self,
+ selected: Option<usize>,
+ offset: usize,
+ max_height: u16,
+ ) -> (usize, usize) {
+ let mut start = offset;
+ let mut end = offset;
+ let mut height = 0;
+ for item in self.rows.iter().skip(offset) {
+ if height + item.height > max_height {
+ break;
+ }
+ height += item.total_height();
+ end += 1;
+ }
+
+ let selected = selected.unwrap_or(0).min(self.rows.len() - 1);
+ while selected >= end {
+ height = height.saturating_add(self.rows[end].total_height());
+ end += 1;
+ while height > max_height {
+ height = height.saturating_sub(self.rows[start].total_height());
+ start += 1;
+ }
+ }
+ while selected < start {
+ start -= 1;
+ height = height.saturating_add(self.rows[start].total_height());
+ while height > max_height {
+ end -= 1;
+ height = height.saturating_sub(self.rows[end].total_height());
+ }
+ }
+ (start, end)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct TableState {
+ offset: usize,
+ selected: Option<usize>,
+}
+
+impl Default for TableState {
+ fn default() -> TableState {
+ TableState {
+ offset: 0,
+ selected: None,
+ }
+ }
+}
+
+impl TableState {
+ pub fn selected(&self) -> Option<usize> {
+ self.selected
+ }
+
+ pub fn select(&mut self, index: Option<usize>) {
+ self.selected = index;
+ if index.is_none() {
+ self.offset = 0;
+ }
+ }
+}
+
+impl<'a> StatefulWidget for Table<'a> {
+ type State = TableState;
+
+ fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
+ if area.area() == 0 {
+ return;
+ }
+ buf.set_style(area, self.style);
+ let table_area = match self.block.take() {
+ Some(b) => {
+ let inner_area = b.inner(area);
+ b.render(area, buf);
+ inner_area
+ }
+ None => area,
+ };
+
+ let has_selection = state.selected.is_some();
+ let columns_widths = self.get_columns_widths(table_area.width, has_selection);
+ let highlight_symbol = self.highlight_symbol.unwrap_or("");
+ let blank_symbol = iter::repeat(" ")
+ .take(highlight_symbol.width())
+ .collect::<String>();
+ let mut current_height = 0;
+ let mut rows_height = table_area.height;
+
+ // Draw header
+ if let Some(ref header) = self.header {
+ let max_header_height = table_area.height.min(header.total_height());
+ buf.set_style(
+ Rect {
+ x: table_area.left(),
+ y: table_area.top(),
+ width: table_area.width,
+ height: table_area.height.min(header.height),
+ },
+ header.style,
+ );
+ let mut col = table_area.left();
+ if has_selection {
+ col += (highlight_symbol.width() as u16).min(table_area.width);
+ }
+ for (width, cell) in columns_widths.iter().zip(header.cells.iter()) {
+ render_cell(
+ buf,
+ cell,
+ Rect {
+ x: col,
+ y: table_area.top(),
+ width: *width,
+ height: max_header_height,
+ },
+ );
+ col += *width + self.column_spacing;
+ }
+ current_height += max_header_height;
+ rows_height = rows_height.saturating_sub(max_header_height);
+ }
+
+ // Draw rows
+ if self.rows.is_empty() {
+ return;
+ }
+ let (start, end) = self.get_row_bounds(state.selected, state.offset, rows_height);
+ state.offset = start;
+ for (i, table_row) in self
+ .rows
+ .iter_mut()
+ .enumerate()
+ .skip(state.offset)
+ .take(end - start)
+ {
+ let (row, col) = (table_area.top() + current_height, table_area.left());
+ current_height += table_row.total_height();
+ let table_row_area = Rect {
+ x: col,
+ y: row,
+ width: table_area.width,
+ height: table_row.height,
+ };
+ buf.set_style(table_row_area, table_row.style);
+ let is_selected = state.selected.map(|s| s == i).unwrap_or(false);
+ let table_row_start_col = if has_selection {
+ let symbol = if is_selected {
+ highlight_symbol
+ } else {
+ &blank_symbol
+ };
+ let (col, _) =
+ buf.set_stringn(col, row, symbol, table_area.width as usize, table_row.style);
+ col
+ } else {
+ col
+ };
+ let mut col = table_row_start_col;
+ for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {
+ render_cell(
+ buf,
+ cell,
+ Rect {
+ x: col,
+ y: row,
+ width: *width,
+ height: table_row.height,
+ },
+ );
+ col += *width + self.column_spacing;
+ }
+ if is_selected {
+ buf.set_style(table_row_area, self.highlight_style);
+ }
+ }
+ }
+}
+
+fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
+ buf.set_style(area, cell.style);
+ for (i, spans) in cell.content.lines.iter().enumerate() {
+ if i as u16 >= area.height {
+ break;
+ }
+ buf.set_spans(area.x, area.y + i as u16, spans, area.width);
+ }
+}
+
+impl<'a> Widget for Table<'a> {
+ fn render(self, area: Rect, buf: &mut Buffer) {
+ let mut state = TableState::default();
+ StatefulWidget::render(self, area, buf, &mut state);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ #[should_panic]
+ fn table_invalid_percentages() {
+ Table::new(vec![]).widths(&[Constraint::Percentage(110)]);
+ }
+}