aboutsummaryrefslogtreecommitdiff
path: root/helix-tui/src/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'helix-tui/src/widgets')
-rw-r--r--helix-tui/src/widgets/block.rs511
-rw-r--r--helix-tui/src/widgets/clear.rs36
-rw-r--r--helix-tui/src/widgets/list.rs249
-rw-r--r--helix-tui/src/widgets/mod.rs53
-rw-r--r--helix-tui/src/widgets/paragraph.rs197
-rw-r--r--helix-tui/src/widgets/reflow.rs534
-rw-r--r--helix-tui/src/widgets/table.rs538
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)]);
+ }
+}