use crate::{ buffer::Buffer, layout::Constraint, text::Text, widgets::{Block, Widget}, }; use cassowary::{ strength::{MEDIUM, REQUIRED, WEAK}, WeightedRelation::*, {Expression, Solver}, }; use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::{Rect, Style}; use std::collections::HashMap; /// 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::text::{Span, Spans, Text}; /// # use helix_view::graphics::{Style, Modifier}; /// 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, Eq, Default)] pub struct Cell<'a> { pub 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 for Cell<'a> where T: Into>, { 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 explicitly create [`Cell`]s: /// ```rust /// # use helix_tui::widgets::{Row, Cell}; /// # use helix_view::graphics::{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, Eq, Default)] pub struct Row<'a> { pub cells: Vec>, 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(cells: T) -> Self where T: IntoIterator, T::Item: Into>, { 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 overridden 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_view::graphics::{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, Eq)] pub struct Table<'a> { /// A block to wrap the widget in block: Option>, /// 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>, /// Data to display in each row rows: Vec>, } impl<'a> Table<'a> { pub fn new(rows: T) -> Self where T: IntoIterator>, { 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 { 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, 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, Default, Clone)] pub struct TableState { pub offset: usize, pub selected: Option, } impl TableState { pub fn selected(&self) -> Option { self.selected } pub fn select(&mut self, index: Option) { self.selected = index; if index.is_none() { self.offset = 0; } } } // impl<'a> StatefulWidget for Table<'a> { impl<'a> Table<'a> { // type State = TableState; pub fn render_table(mut self, area: Rect, buf: &mut Buffer, state: &mut TableState) { 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 = " ".repeat(highlight_symbol.width()); 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(); Table::render_table(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)]); } }