diff options
author | Blaž Hrastnik | 2021-05-09 08:52:55 +0000 |
---|---|---|
committer | Blaž Hrastnik | 2021-05-09 08:52:55 +0000 |
commit | 35606a3daa7ee273845a12f3e03728e0ae23928e (patch) | |
tree | 643684eaff6627dbebc4156d33fdb541bf87bbd9 /helix-tui/src/widgets | |
parent | 6c705f09e88a4b63c4ed854bc9e956b0539ca8af (diff) |
Inline tui as helix-tui fork.
We only rely on some of the rendering primitives and implement our
Cursive-style compositor on top.
Diffstat (limited to 'helix-tui/src/widgets')
-rw-r--r-- | helix-tui/src/widgets/block.rs | 511 | ||||
-rw-r--r-- | helix-tui/src/widgets/clear.rs | 36 | ||||
-rw-r--r-- | helix-tui/src/widgets/list.rs | 249 | ||||
-rw-r--r-- | helix-tui/src/widgets/mod.rs | 53 | ||||
-rw-r--r-- | helix-tui/src/widgets/paragraph.rs | 197 | ||||
-rw-r--r-- | helix-tui/src/widgets/reflow.rs | 534 | ||||
-rw-r--r-- | helix-tui/src/widgets/table.rs | 538 |
7 files changed, 2118 insertions, 0 deletions
diff --git a/helix-tui/src/widgets/block.rs b/helix-tui/src/widgets/block.rs new file mode 100644 index 00000000..2569c17d --- /dev/null +++ b/helix-tui/src/widgets/block.rs @@ -0,0 +1,511 @@ +use crate::{ + buffer::Buffer, + layout::Rect, + style::Style, + symbols::line, + text::{Span, Spans}, + widgets::{Borders, Widget}, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BorderType { + Plain, + Rounded, + Double, + Thick, +} + +impl BorderType { + pub fn line_symbols(border_type: BorderType) -> line::Set { + match border_type { + BorderType::Plain => line::NORMAL, + BorderType::Rounded => line::ROUNDED, + BorderType::Double => line::DOUBLE, + BorderType::Thick => line::THICK, + } + } +} + +/// Base widget to be used with all upper level ones. It may be used to display a box border around +/// the widget and/or add a title. +/// +/// # Examples +/// +/// ``` +/// # use helix_tui::widgets::{Block, BorderType, Borders}; +/// # use helix_tui::style::{Style, Color}; +/// Block::default() +/// .title("Block") +/// .borders(Borders::LEFT | Borders::RIGHT) +/// .border_style(Style::default().fg(Color::White)) +/// .border_type(BorderType::Rounded) +/// .style(Style::default().bg(Color::Black)); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct Block<'a> { + /// Optional title place on the upper left of the block + title: Option<Spans<'a>>, + /// Visible borders + borders: Borders, + /// Border style + border_style: Style, + /// Type of the border. The default is plain lines but one can choose to have rounded corners + /// or doubled lines instead. + border_type: BorderType, + /// Widget style + style: Style, +} + +impl<'a> Default for Block<'a> { + fn default() -> Block<'a> { + Block { + title: None, + borders: Borders::NONE, + border_style: Default::default(), + border_type: BorderType::Plain, + style: Default::default(), + } + } +} + +impl<'a> Block<'a> { + pub fn title<T>(mut self, title: T) -> Block<'a> + where + T: Into<Spans<'a>>, + { + self.title = Some(title.into()); + self + } + + #[deprecated( + since = "0.10.0", + note = "You should use styling capabilities of `text::Spans` given as argument of the `title` method to apply styling to the title." + )] + pub fn title_style(mut self, style: Style) -> Block<'a> { + if let Some(t) = self.title { + let title = String::from(t); + self.title = Some(Spans::from(Span::styled(title, style))); + } + self + } + + pub fn border_style(mut self, style: Style) -> Block<'a> { + self.border_style = style; + self + } + + pub fn style(mut self, style: Style) -> Block<'a> { + self.style = style; + self + } + + pub fn borders(mut self, flag: Borders) -> Block<'a> { + self.borders = flag; + self + } + + pub fn border_type(mut self, border_type: BorderType) -> Block<'a> { + self.border_type = border_type; + self + } + + /// Compute the inner area of a block based on its border visibility rules. + pub fn inner(&self, area: Rect) -> Rect { + let mut inner = area; + if self.borders.intersects(Borders::LEFT) { + inner.x = inner.x.saturating_add(1).min(inner.right()); + inner.width = inner.width.saturating_sub(1); + } + if self.borders.intersects(Borders::TOP) || self.title.is_some() { + inner.y = inner.y.saturating_add(1).min(inner.bottom()); + inner.height = inner.height.saturating_sub(1); + } + if self.borders.intersects(Borders::RIGHT) { + inner.width = inner.width.saturating_sub(1); + } + if self.borders.intersects(Borders::BOTTOM) { + inner.height = inner.height.saturating_sub(1); + } + inner + } +} + +impl<'a> Widget for Block<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.area() == 0 { + return; + } + buf.set_style(area, self.style); + let symbols = BorderType::line_symbols(self.border_type); + + // Sides + if self.borders.intersects(Borders::LEFT) { + for y in area.top()..area.bottom() { + buf.get_mut(area.left(), y) + .set_symbol(symbols.vertical) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::TOP) { + for x in area.left()..area.right() { + buf.get_mut(x, area.top()) + .set_symbol(symbols.horizontal) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::RIGHT) { + let x = area.right() - 1; + for y in area.top()..area.bottom() { + buf.get_mut(x, y) + .set_symbol(symbols.vertical) + .set_style(self.border_style); + } + } + if self.borders.intersects(Borders::BOTTOM) { + let y = area.bottom() - 1; + for x in area.left()..area.right() { + buf.get_mut(x, y) + .set_symbol(symbols.horizontal) + .set_style(self.border_style); + } + } + + // Corners + if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) { + buf.get_mut(area.right() - 1, area.bottom() - 1) + .set_symbol(symbols.bottom_right) + .set_style(self.border_style); + } + if self.borders.contains(Borders::RIGHT | Borders::TOP) { + buf.get_mut(area.right() - 1, area.top()) + .set_symbol(symbols.top_right) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::BOTTOM) { + buf.get_mut(area.left(), area.bottom() - 1) + .set_symbol(symbols.bottom_left) + .set_style(self.border_style); + } + if self.borders.contains(Borders::LEFT | Borders::TOP) { + buf.get_mut(area.left(), area.top()) + .set_symbol(symbols.top_left) + .set_style(self.border_style); + } + + if let Some(title) = self.title { + let lx = if self.borders.intersects(Borders::LEFT) { + 1 + } else { + 0 + }; + let rx = if self.borders.intersects(Borders::RIGHT) { + 1 + } else { + 0 + }; + let width = area.width.saturating_sub(lx).saturating_sub(rx); + buf.set_spans(area.left() + lx, area.top(), &title, width); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::Rect; + + #[test] + fn inner_takes_into_account_the_borders() { + // No borders + assert_eq!( + Block::default().inner(Rect::default()), + Rect { + x: 0, + y: 0, + width: 0, + height: 0 + }, + "no borders, width=0, height=0" + ); + assert_eq!( + Block::default().inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }, + "no borders, width=1, height=1" + ); + + // Left border + assert_eq!( + Block::default().borders(Borders::LEFT).inner(Rect { + x: 0, + y: 0, + width: 0, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 0, + height: 1 + }, + "left, width=0" + ); + assert_eq!( + Block::default().borders(Borders::LEFT).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 1, + y: 0, + width: 0, + height: 1 + }, + "left, width=1" + ); + assert_eq!( + Block::default().borders(Borders::LEFT).inner(Rect { + x: 0, + y: 0, + width: 2, + height: 1 + }), + Rect { + x: 1, + y: 0, + width: 1, + height: 1 + }, + "left, width=2" + ); + + // Top border + assert_eq!( + Block::default().borders(Borders::TOP).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 0 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 0 + }, + "top, height=0" + ); + assert_eq!( + Block::default().borders(Borders::TOP).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 0, + y: 1, + width: 1, + height: 0 + }, + "top, height=1" + ); + assert_eq!( + Block::default().borders(Borders::TOP).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 2 + }), + Rect { + x: 0, + y: 1, + width: 1, + height: 1 + }, + "top, height=2" + ); + + // Right border + assert_eq!( + Block::default().borders(Borders::RIGHT).inner(Rect { + x: 0, + y: 0, + width: 0, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 0, + height: 1 + }, + "right, width=0" + ); + assert_eq!( + Block::default().borders(Borders::RIGHT).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 0, + height: 1 + }, + "right, width=1" + ); + assert_eq!( + Block::default().borders(Borders::RIGHT).inner(Rect { + x: 0, + y: 0, + width: 2, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }, + "right, width=2" + ); + + // Bottom border + assert_eq!( + Block::default().borders(Borders::BOTTOM).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 0 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 0 + }, + "bottom, height=0" + ); + assert_eq!( + Block::default().borders(Borders::BOTTOM).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 0 + }, + "bottom, height=1" + ); + assert_eq!( + Block::default().borders(Borders::BOTTOM).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 2 + }), + Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }, + "bottom, height=2" + ); + + // All borders + assert_eq!( + Block::default() + .borders(Borders::ALL) + .inner(Rect::default()), + Rect { + x: 0, + y: 0, + width: 0, + height: 0 + }, + "all borders, width=0, height=0" + ); + assert_eq!( + Block::default().borders(Borders::ALL).inner(Rect { + x: 0, + y: 0, + width: 1, + height: 1 + }), + Rect { + x: 1, + y: 1, + width: 0, + height: 0, + }, + "all borders, width=1, height=1" + ); + assert_eq!( + Block::default().borders(Borders::ALL).inner(Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }), + Rect { + x: 1, + y: 1, + width: 0, + height: 0, + }, + "all borders, width=2, height=2" + ); + assert_eq!( + Block::default().borders(Borders::ALL).inner(Rect { + x: 0, + y: 0, + width: 3, + height: 3, + }), + Rect { + x: 1, + y: 1, + width: 1, + height: 1, + }, + "all borders, width=3, height=3" + ); + } + + #[test] + fn inner_takes_into_account_the_title() { + assert_eq!( + Block::default().title("Test").inner(Rect { + x: 0, + y: 0, + width: 0, + height: 1, + }), + Rect { + x: 0, + y: 1, + width: 0, + height: 0, + }, + ); + } +} diff --git a/helix-tui/src/widgets/clear.rs b/helix-tui/src/widgets/clear.rs new file mode 100644 index 00000000..d1da40de --- /dev/null +++ b/helix-tui/src/widgets/clear.rs @@ -0,0 +1,36 @@ +use crate::buffer::Buffer; +use crate::layout::Rect; +use crate::widgets::Widget; + +/// A widget to to clear/reset a certain area to allow overdrawing (e.g. for popups) +/// +/// # Examples +/// +/// ``` +/// # use helix_tui::widgets::{Clear, Block, Borders}; +/// # use helix_tui::layout::Rect; +/// # use helix_tui::Frame; +/// # use helix_tui::backend::Backend; +/// fn draw_on_clear<B: Backend>(f: &mut Frame<B>, area: Rect) { +/// let block = Block::default().title("Block").borders(Borders::ALL); +/// f.render_widget(Clear, area); // <- this will clear/reset the area first +/// f.render_widget(block, area); // now render the block widget +/// } +/// ``` +/// +/// # Popup Example +/// +/// For a more complete example how to utilize `Clear` to realize popups see +/// the example `examples/popup.rs` +#[derive(Debug, Clone)] +pub struct Clear; + +impl Widget for Clear { + fn render(self, area: Rect, buf: &mut Buffer) { + for x in area.left()..area.right() { + for y in area.top()..area.bottom() { + buf.get_mut(x, y).reset(); + } + } + } +} diff --git a/helix-tui/src/widgets/list.rs b/helix-tui/src/widgets/list.rs new file mode 100644 index 00000000..e913ce78 --- /dev/null +++ b/helix-tui/src/widgets/list.rs @@ -0,0 +1,249 @@ +use crate::{ + buffer::Buffer, + layout::{Corner, Rect}, + style::Style, + text::Text, + widgets::{Block, StatefulWidget, Widget}, +}; +use std::iter::{self, Iterator}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone)] +pub struct ListState { + offset: usize, + selected: Option<usize>, +} + +impl Default for ListState { + fn default() -> ListState { + ListState { + offset: 0, + selected: None, + } + } +} + +impl ListState { + 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; + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ListItem<'a> { + content: Text<'a>, + style: Style, +} + +impl<'a> ListItem<'a> { + pub fn new<T>(content: T) -> ListItem<'a> + where + T: Into<Text<'a>>, + { + ListItem { + content: content.into(), + style: Style::default(), + } + } + + pub fn style(mut self, style: Style) -> ListItem<'a> { + self.style = style; + self + } + + pub fn height(&self) -> usize { + self.content.height() + } +} + +/// A widget to display several items among which one can be selected (optional) +/// +/// # Examples +/// +/// ``` +/// # use helix_tui::widgets::{Block, Borders, List, ListItem}; +/// # use helix_tui::style::{Style, Color, Modifier}; +/// let items = [ListItem::new("Item 1"), ListItem::new("Item 2"), ListItem::new("Item 3")]; +/// List::new(items) +/// .block(Block::default().title("List").borders(Borders::ALL)) +/// .style(Style::default().fg(Color::White)) +/// .highlight_style(Style::default().add_modifier(Modifier::ITALIC)) +/// .highlight_symbol(">>"); +/// ``` +#[derive(Debug, Clone)] +pub struct List<'a> { + block: Option<Block<'a>>, + items: Vec<ListItem<'a>>, + /// Style used as a base style for the widget + style: Style, + start_corner: Corner, + /// Style used to render selected item + highlight_style: Style, + /// Symbol in front of the selected item (Shift all items to the right) + highlight_symbol: Option<&'a str>, +} + +impl<'a> List<'a> { + pub fn new<T>(items: T) -> List<'a> + where + T: Into<Vec<ListItem<'a>>>, + { + List { + block: None, + style: Style::default(), + items: items.into(), + start_corner: Corner::TopLeft, + highlight_style: Style::default(), + highlight_symbol: None, + } + } + + pub fn block(mut self, block: Block<'a>) -> List<'a> { + self.block = Some(block); + self + } + + pub fn style(mut self, style: Style) -> List<'a> { + self.style = style; + self + } + + pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> List<'a> { + self.highlight_symbol = Some(highlight_symbol); + self + } + + pub fn highlight_style(mut self, style: Style) -> List<'a> { + self.highlight_style = style; + self + } + + pub fn start_corner(mut self, corner: Corner) -> List<'a> { + self.start_corner = corner; + self + } +} + +impl<'a> StatefulWidget for List<'a> { + type State = ListState; + + fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + buf.set_style(area, self.style); + let list_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + if list_area.width < 1 || list_area.height < 1 { + return; + } + + if self.items.is_empty() { + return; + } + let list_height = list_area.height as usize; + + let mut start = state.offset; + let mut end = state.offset; + let mut height = 0; + for item in self.items.iter().skip(state.offset) { + if height + item.height() > list_height { + break; + } + height += item.height(); + end += 1; + } + + let selected = state.selected.unwrap_or(0).min(self.items.len() - 1); + while selected >= end { + height = height.saturating_add(self.items[end].height()); + end += 1; + while height > list_height { + height = height.saturating_sub(self.items[start].height()); + start += 1; + } + } + while selected < start { + start -= 1; + height = height.saturating_add(self.items[start].height()); + while height > list_height { + end -= 1; + height = height.saturating_sub(self.items[end].height()); + } + } + state.offset = start; + + 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 has_selection = state.selected.is_some(); + for (i, item) in self + .items + .iter_mut() + .enumerate() + .skip(state.offset) + .take(end - start) + { + let (x, y) = match self.start_corner { + Corner::BottomLeft => { + current_height += item.height() as u16; + (list_area.left(), list_area.bottom() - current_height) + } + _ => { + let pos = (list_area.left(), list_area.top() + current_height); + current_height += item.height() as u16; + pos + } + }; + let area = Rect { + x, + y, + width: list_area.width, + height: item.height() as u16, + }; + let item_style = self.style.patch(item.style); + buf.set_style(area, item_style); + + let is_selected = state.selected.map(|s| s == i).unwrap_or(false); + let elem_x = if has_selection { + let symbol = if is_selected { + highlight_symbol + } else { + &blank_symbol + }; + let (x, _) = buf.set_stringn(x, y, symbol, list_area.width as usize, item_style); + x + } else { + x + }; + let max_element_width = (list_area.width - (elem_x - x)) as usize; + for (j, line) in item.content.lines.iter().enumerate() { + buf.set_spans(elem_x, y + j as u16, line, max_element_width as u16); + } + if is_selected { + buf.set_style(area, self.highlight_style); + } + } + } +} + +impl<'a> Widget for List<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut state = ListState::default(); + StatefulWidget::render(self, area, buf, &mut state); + } +} diff --git a/helix-tui/src/widgets/mod.rs b/helix-tui/src/widgets/mod.rs new file mode 100644 index 00000000..76eb73c1 --- /dev/null +++ b/helix-tui/src/widgets/mod.rs @@ -0,0 +1,53 @@ +//! `widgets` is a collection of types that implement [`Widget`] or [`StatefulWidget`] or both. +//! +//! All widgets are implemented using the builder pattern and are consumable objects. They are not +//! meant to be stored but used as *commands* to draw common figures in the UI. +//! +//! The available widgets are: +//! - [`Block`] +//! - [`Tabs`] +//! - [`List`] +//! - [`Table`] +//! - [`Paragraph`] +//! - [`Clear`] + +mod block; +mod clear; +// mod list; +mod paragraph; +mod reflow; +// mod table; + +pub use self::block::{Block, BorderType}; +pub use self::clear::Clear; +// pub use self::list::{List, ListItem, ListState}; +pub use self::paragraph::{Paragraph, Wrap}; +// pub use self::table::{Cell, Row, Table, TableState}; + +use crate::{buffer::Buffer, layout::Rect}; +use bitflags::bitflags; + +bitflags! { + /// Bitflags that can be composed to set the visible borders essentially on the block widget. + pub struct Borders: u32 { + /// Show no border (default) + const NONE = 0b0000_0001; + /// Show the top border + const TOP = 0b0000_0010; + /// Show the right border + const RIGHT = 0b0000_0100; + /// Show the bottom border + const BOTTOM = 0b000_1000; + /// Show the left border + const LEFT = 0b0001_0000; + /// Show all borders + const ALL = Self::TOP.bits | Self::RIGHT.bits | Self::BOTTOM.bits | Self::LEFT.bits; + } +} + +/// Base requirements for a Widget +pub trait Widget { + /// Draws the current state of the widget in the given buffer. That the only method required to + /// implement a custom widget. + fn render(self, area: Rect, buf: &mut Buffer); +} diff --git a/helix-tui/src/widgets/paragraph.rs b/helix-tui/src/widgets/paragraph.rs new file mode 100644 index 00000000..ecce8581 --- /dev/null +++ b/helix-tui/src/widgets/paragraph.rs @@ -0,0 +1,197 @@ +use crate::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::Style, + text::{StyledGrapheme, Text}, + widgets::{ + reflow::{LineComposer, LineTruncator, WordWrapper}, + Block, Widget, + }, +}; +use std::iter; +use unicode_width::UnicodeWidthStr; + +fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { + match alignment { + Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2), + Alignment::Right => text_area_width.saturating_sub(line_width), + Alignment::Left => 0, + } +} + +/// A widget to display some text. +/// +/// # Examples +/// +/// ``` +/// # use helix_tui::text::{Text, Spans, Span}; +/// # use helix_tui::widgets::{Block, Borders, Paragraph, Wrap}; +/// # use helix_tui::style::{Style, Color, Modifier}; +/// # use helix_tui::layout::{Alignment}; +/// let text = vec![ +/// Spans::from(vec![ +/// Span::raw("First"), +/// Span::styled("line",Style::default().add_modifier(Modifier::ITALIC)), +/// Span::raw("."), +/// ]), +/// Spans::from(Span::styled("Second line", Style::default().fg(Color::Red))), +/// ]; +/// Paragraph::new(text) +/// .block(Block::default().title("Paragraph").borders(Borders::ALL)) +/// .style(Style::default().fg(Color::White).bg(Color::Black)) +/// .alignment(Alignment::Center) +/// .wrap(Wrap { trim: true }); +/// ``` +#[derive(Debug, Clone)] +pub struct Paragraph<'a> { + /// A block to wrap the widget in + block: Option<Block<'a>>, + /// Widget style + style: Style, + /// How to wrap the text + wrap: Option<Wrap>, + /// The text to display + text: Text<'a>, + /// Scroll + scroll: (u16, u16), + /// Alignment of the text + alignment: Alignment, +} + +/// Describes how to wrap text across lines. +/// +/// ## Examples +/// +/// ``` +/// # use helix_tui::widgets::{Paragraph, Wrap}; +/// # use helix_tui::text::Text; +/// let bullet_points = Text::from(r#"Some indented points: +/// - First thing goes here and is long so that it wraps +/// - Here is another point that is long enough to wrap"#); +/// +/// // With leading spaces trimmed (window width of 30 chars): +/// Paragraph::new(bullet_points.clone()).wrap(Wrap { trim: true }); +/// // Some indented points: +/// // - First thing goes here and is +/// // long so that it wraps +/// // - Here is another point that +/// // is long enough to wrap +/// +/// // But without trimming, indentation is preserved: +/// Paragraph::new(bullet_points).wrap(Wrap { trim: false }); +/// // Some indented points: +/// // - First thing goes here +/// // and is long so that it wraps +/// // - Here is another point +/// // that is long enough to wrap +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct Wrap { + /// Should leading whitespace be trimmed + pub trim: bool, +} + +impl<'a> Paragraph<'a> { + pub fn new<T>(text: T) -> Paragraph<'a> + where + T: Into<Text<'a>>, + { + Paragraph { + block: None, + style: Default::default(), + wrap: None, + text: text.into(), + scroll: (0, 0), + alignment: Alignment::Left, + } + } + + pub fn block(mut self, block: Block<'a>) -> Paragraph<'a> { + self.block = Some(block); + self + } + + pub fn style(mut self, style: Style) -> Paragraph<'a> { + self.style = style; + self + } + + pub fn wrap(mut self, wrap: Wrap) -> Paragraph<'a> { + self.wrap = Some(wrap); + self + } + + pub fn scroll(mut self, offset: (u16, u16)) -> Paragraph<'a> { + self.scroll = offset; + self + } + + pub fn alignment(mut self, alignment: Alignment) -> Paragraph<'a> { + self.alignment = alignment; + self + } +} + +impl<'a> Widget for Paragraph<'a> { + fn render(mut self, area: Rect, buf: &mut Buffer) { + buf.set_style(area, self.style); + let text_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + if text_area.height < 1 { + return; + } + + let style = self.style; + let mut styled = self.text.lines.iter().flat_map(|spans| { + spans + .0 + .iter() + .flat_map(|span| span.styled_graphemes(style)) + // Required given the way composers work but might be refactored out if we change + // composers to operate on lines instead of a stream of graphemes. + .chain(iter::once(StyledGrapheme { + symbol: "\n", + style: self.style, + })) + }); + + let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap { + Box::new(WordWrapper::new(&mut styled, text_area.width, trim)) + } else { + let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width)); + if let Alignment::Left = self.alignment { + line_composer.set_horizontal_offset(self.scroll.1); + } + line_composer + }; + let mut y = 0; + while let Some((current_line, current_line_width)) = line_composer.next_line() { + if y >= self.scroll.0 { + let mut x = get_line_offset(current_line_width, text_area.width, self.alignment); + for StyledGrapheme { symbol, style } in current_line { + buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0) + .set_symbol(if symbol.is_empty() { + // If the symbol is empty, the last char which rendered last time will + // leave on the line. It's a quick fix. + " " + } else { + symbol + }) + .set_style(*style); + x += symbol.width() as u16; + } + } + y += 1; + if y >= text_area.height + self.scroll.0 { + break; + } + } + } +} diff --git a/helix-tui/src/widgets/reflow.rs b/helix-tui/src/widgets/reflow.rs new file mode 100644 index 00000000..94ff7330 --- /dev/null +++ b/helix-tui/src/widgets/reflow.rs @@ -0,0 +1,534 @@ +use crate::text::StyledGrapheme; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +const NBSP: &str = "\u{00a0}"; + +/// A state machine to pack styled symbols into lines. +/// Cannot implement it as Iterator since it yields slices of the internal buffer (need streaming +/// iterators for that). +pub trait LineComposer<'a> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)>; +} + +/// A state machine that wraps lines on word boundaries. +pub struct WordWrapper<'a, 'b> { + symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>, + max_line_width: u16, + current_line: Vec<StyledGrapheme<'a>>, + next_line: Vec<StyledGrapheme<'a>>, + /// Removes the leading whitespace from lines + trim: bool, +} + +impl<'a, 'b> WordWrapper<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>, + max_line_width: u16, + trim: bool, + ) -> WordWrapper<'a, 'b> { + WordWrapper { + symbols, + max_line_width, + current_line: vec![], + next_line: vec![], + trim, + } + } +} + +impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + std::mem::swap(&mut self.current_line, &mut self.next_line); + self.next_line.truncate(0); + + let mut current_line_width = self + .current_line + .iter() + .map(|StyledGrapheme { symbol, .. }| symbol.width() as u16) + .sum(); + + let mut symbols_to_last_word_end: usize = 0; + let mut width_to_last_word_end: u16 = 0; + let mut prev_whitespace = false; + let mut symbols_exhausted = true; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + let symbol_whitespace = symbol.chars().all(&char::is_whitespace) && symbol != NBSP; + + // Ignore characters wider that the total max width. + if symbol.width() as u16 > self.max_line_width + // Skip leading whitespace when trim is enabled. + || self.trim && symbol_whitespace && symbol != "\n" && current_line_width == 0 + { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + if prev_whitespace { + current_line_width = width_to_last_word_end; + self.current_line.truncate(symbols_to_last_word_end); + } + break; + } + + // Mark the previous symbol as word end. + if symbol_whitespace && !prev_whitespace { + symbols_to_last_word_end = self.current_line.len(); + width_to_last_word_end = current_line_width; + } + + self.current_line.push(StyledGrapheme { symbol, style }); + current_line_width += symbol.width() as u16; + + if current_line_width > self.max_line_width { + // If there was no word break in the text, wrap at the end of the line. + let (truncate_at, truncated_width) = if symbols_to_last_word_end != 0 { + (symbols_to_last_word_end, width_to_last_word_end) + } else { + (self.current_line.len() - 1, self.max_line_width) + }; + + // Push the remainder to the next line but strip leading whitespace: + { + let remainder = &self.current_line[truncate_at..]; + if let Some(remainder_nonwhite) = + remainder.iter().position(|StyledGrapheme { symbol, .. }| { + !symbol.chars().all(&char::is_whitespace) + }) + { + self.next_line + .extend_from_slice(&remainder[remainder_nonwhite..]); + } + } + self.current_line.truncate(truncate_at); + current_line_width = truncated_width; + break; + } + + prev_whitespace = symbol_whitespace; + } + + // Even if the iterator is exhausted, pass the previous remainder. + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// A state machine that truncates overhanging lines. +pub struct LineTruncator<'a, 'b> { + symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>, + max_line_width: u16, + current_line: Vec<StyledGrapheme<'a>>, + /// Record the offet to skip render + horizontal_offset: u16, +} + +impl<'a, 'b> LineTruncator<'a, 'b> { + pub fn new( + symbols: &'b mut dyn Iterator<Item = StyledGrapheme<'a>>, + max_line_width: u16, + ) -> LineTruncator<'a, 'b> { + LineTruncator { + symbols, + max_line_width, + horizontal_offset: 0, + current_line: vec![], + } + } + + pub fn set_horizontal_offset(&mut self, horizontal_offset: u16) { + self.horizontal_offset = horizontal_offset; + } +} + +impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> { + fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { + if self.max_line_width == 0 { + return None; + } + + self.current_line.truncate(0); + let mut current_line_width = 0; + + let mut skip_rest = false; + let mut symbols_exhausted = true; + let mut horizontal_offset = self.horizontal_offset as usize; + for StyledGrapheme { symbol, style } in &mut self.symbols { + symbols_exhausted = false; + + // Ignore characters wider that the total max width. + if symbol.width() as u16 > self.max_line_width { + continue; + } + + // Break on newline and discard it. + if symbol == "\n" { + break; + } + + if current_line_width + symbol.width() as u16 > self.max_line_width { + // Exhaust the remainder of the line. + skip_rest = true; + break; + } + + let symbol = if horizontal_offset == 0 { + symbol + } else { + let w = symbol.width(); + if w > horizontal_offset { + let t = trim_offset(symbol, horizontal_offset); + horizontal_offset = 0; + t + } else { + horizontal_offset -= w; + "" + } + }; + current_line_width += symbol.width() as u16; + self.current_line.push(StyledGrapheme { symbol, style }); + } + + if skip_rest { + for StyledGrapheme { symbol, .. } in &mut self.symbols { + if symbol == "\n" { + break; + } + } + } + + if symbols_exhausted && self.current_line.is_empty() { + None + } else { + Some((&self.current_line[..], current_line_width)) + } + } +} + +/// This function will return a str slice which start at specified offset. +/// As src is a unicode str, start offset has to be calculated with each character. +fn trim_offset(src: &str, mut offset: usize) -> &str { + let mut start = 0; + for c in UnicodeSegmentation::graphemes(src, true) { + let w = c.width(); + if w <= offset { + offset -= w; + start += c.len(); + } else { + break; + } + } + &src[start..] +} + +#[cfg(test)] +mod test { + use super::*; + use unicode_segmentation::UnicodeSegmentation; + + enum Composer { + WordWrapper { trim: bool }, + LineTruncator, + } + + fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) { + let style = Default::default(); + let mut styled = + UnicodeSegmentation::graphemes(text, true).map(|g| StyledGrapheme { symbol: g, style }); + let mut composer: Box<dyn LineComposer> = match which { + Composer::WordWrapper { trim } => { + Box::new(WordWrapper::new(&mut styled, text_area_width, trim)) + } + Composer::LineTruncator => Box::new(LineTruncator::new(&mut styled, text_area_width)), + }; + let mut lines = vec![]; + let mut widths = vec![]; + while let Some((styled, width)) = composer.next_line() { + let line = styled + .iter() + .map(|StyledGrapheme { symbol, .. }| *symbol) + .collect::<String>(); + assert!(width <= text_area_width); + lines.push(line); + widths.push(width); + } + (lines, widths) + } + + #[test] + fn line_composer_one_line() { + let width = 40; + for i in 1..width { + let text = "a".repeat(i); + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, &text, width as u16); + let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width as u16); + let expected = vec![text]; + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + } + + #[test] + fn line_composer_short_lines() { + let width = 20; + let text = + "abcdefg\nhijklmno\npabcdefg\nhijklmn\nopabcdefghijk\nlmnopabcd\n\n\nefghijklmno"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let wrapped: Vec<&str> = text.split('\n').collect(); + assert_eq!(word_wrapper, wrapped); + assert_eq!(line_truncator, wrapped); + } + + #[test] + fn line_composer_long_word() { + let width = 20; + let text = "abcdefghijklmnopabcdefghijklmnopabcdefghijklmnopabcdefghijklmno"; + let (word_wrapper, _) = + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); + + let wrapped = vec![ + &text[..width], + &text[width..width * 2], + &text[width * 2..width * 3], + &text[width * 3..], + ]; + assert_eq!( + word_wrapper, wrapped, + "WordWrapper should detect the line cannot be broken on word boundary and \ + break it at line width limit." + ); + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_long_sentence() { + let width = 20; + let text = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l m n o"; + let text_multi_space = + "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab c d e f g h i j k l \ + m n o"; + let (word_wrapper_single_space, _) = + run_composer(Composer::WordWrapper { trim: true }, text, width as u16); + let (word_wrapper_multi_space, _) = run_composer( + Composer::WordWrapper { trim: true }, + text_multi_space, + width as u16, + ); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width as u16); + + let word_wrapped = vec![ + "abcd efghij", + "klmnopabcd efgh", + "ijklmnopabcdefg", + "hijkl mnopab c d e f", + "g h i j k l m n o", + ]; + assert_eq!(word_wrapper_single_space, word_wrapped); + assert_eq!(word_wrapper_multi_space, word_wrapped); + + assert_eq!(line_truncator, vec![&text[..width]]); + } + + #[test] + fn line_composer_zero_width() { + let width = 0; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = Vec::new(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, expected); + } + + #[test] + fn line_composer_max_line_width_of_1() { + let width = 1; + let text = "abcd efghij klmnopabcd efgh ijklmnopabcdefg hijkl mnopab "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + + let expected: Vec<&str> = UnicodeSegmentation::graphemes(text, true) + .filter(|g| g.chars().any(|c| !c.is_whitespace())) + .collect(); + assert_eq!(word_wrapper, expected); + assert_eq!(line_truncator, vec!["a"]); + } + + #[test] + fn line_composer_max_line_width_of_1_double_width_characters() { + let width = 1; + let text = "コンピュータ上で文字を扱う場合、典型的には文字\naaaによる通信を行う場合にその\ + 両端点では、"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec!["", "a", "a", "a"]); + assert_eq!(line_truncator, vec!["", "a"]); + } + + /// Tests WordWrapper with words some of which exceed line length and some not. + #[test] + fn line_composer_word_wrapper_mixed_length() { + let width = 20; + let text = "abcd efghij klmnopabcdefghijklmnopabcdefghijkl mnopab cdefghi j klmno"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!( + word_wrapper, + vec![ + "abcd efghij", + "klmnopabcdefghijklmn", + "opabcdefghijkl", + "mnopab cdefghi j", + "klmno", + ] + ) + } + + #[test] + fn line_composer_double_width_chars() { + let width = 20; + let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\ + では、"; + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, &text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width); + assert_eq!(line_truncator, vec!["コンピュータ上で文字"]); + let wrapped = vec![ + "コンピュータ上で文字", + "を扱う場合、典型的に", + "は文字による通信を行", + "う場合にその両端点で", + "は、", + ]; + assert_eq!(word_wrapper, wrapped); + assert_eq!(word_wrapper_width, vec![width, width, width, width, 4]); + } + + #[test] + fn line_composer_leading_whitespace_removal() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", "AAA",]); + assert_eq!(line_truncator, vec!["AAAAAAAAAAAAAAAAAAAA"]); + } + + /// Tests truncation of leading whitespace. + #[test] + fn line_composer_lots_of_spaces() { + let width = 20; + let text = " "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + assert_eq!(word_wrapper, vec![""]); + assert_eq!(line_truncator, vec![" "]); + } + + /// Tests an input starting with a letter, folowed by spaces - some of the behaviour is + /// incidental. + #[test] + fn line_composer_char_plus_lots_of_spaces() { + let width = 20; + let text = "a "; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); + // What's happening below is: the first line gets consumed, trailing spaces discarded, + // after 20 of which a word break occurs (probably shouldn't). The second line break + // discards all whitespace. The result should probably be vec!["a"] but it doesn't matter + // that much. + assert_eq!(word_wrapper, vec!["a", ""]); + assert_eq!(line_truncator, vec!["a "]); + } + + #[test] + fn line_composer_word_wrapper_double_width_chars_mixed_with_spaces() { + let width = 20; + // Japanese seems not to use spaces but we should break on spaces anyway... We're using it + // to test double-width chars. + // You are more than welcome to add word boundary detection based of alterations of + // hiragana and katakana... + // This happens to also be a test case for mixed width because regular spaces are single width. + let text = "コンピュ ータ上で文字を扱う場合、 典型的には文 字による 通信を行 う場合にその両端点では、"; + let (word_wrapper, word_wrapper_width) = + run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!( + word_wrapper, + vec![ + "コンピュ", + "ータ上で文字を扱う場", + "合、 典型的には文", + "字による 通信を行", + "う場合にその両端点で", + "は、", + ] + ); + // Odd-sized lines have a space in them. + assert_eq!(word_wrapper_width, vec![8, 20, 17, 17, 20, 4]); + } + + /// Ensure words separated by nbsp are wrapped as if they were a single one. + #[test] + fn line_composer_word_wrapper_nbsp() { + let width = 20; + let text = "AAAAAAAAAAAAAAA AAAA\u{00a0}AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: true }, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAA", "AAAA\u{00a0}AAA",]); + + // Ensure that if the character was a regular space, it would be wrapped differently. + let text_space = text.replace("\u{00a0}", " "); + let (word_wrapper_space, _) = + run_composer(Composer::WordWrapper { trim: true }, &text_space, width); + assert_eq!(word_wrapper_space, vec!["AAAAAAAAAAAAAAA AAAA", "AAA",]); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation() { + let width = 20; + let text = "AAAAAAAAAAAAAAAAAAAA AAA"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!(word_wrapper, vec!["AAAAAAAAAAAAAAAAAAAA", " AAA",]); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_with_wrap() { + let width = 10; + let text = "AAA AAA AAAAA AA AAAAAA\n B\n C\n D"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec!["AAA AAA", "AAAAA AA", "AAAAAA", " B", " C", " D"] + ); + } + + #[test] + fn line_composer_word_wrapper_preserve_indentation_lots_of_whitespace() { + let width = 10; + let text = " 4 Indent\n must wrap!"; + let (word_wrapper, _) = run_composer(Composer::WordWrapper { trim: false }, text, width); + assert_eq!( + word_wrapper, + vec![ + " ", + " 4", + "Indent", + " ", + " must", + "wrap!" + ] + ); + } +} 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)]); + } +} |