aboutsummaryrefslogtreecommitdiffstats
path: root/src/tui/widgets
diff options
context:
space:
mode:
authorConrad Ludgate <conradludgate@gmail.com>2023-02-10 17:25:43 +0000
committerGitHub <noreply@github.com>2023-02-10 17:25:43 +0000
commitedda1b741a4a0816eb6e62eafd69fc9896603cf5 (patch)
treecc5cb45caecc4fbe6b34e08f2347fdfdf897d0b5 /src/tui/widgets
parentBump debian from bullseye-20221205-slim to bullseye-20230208-slim (#701) (diff)
downloadatuin-edda1b741a4a0816eb6e62eafd69fc9896603cf5.zip
crossterm support (#331)
* crossterm v2 * patch crossterm * fix-version * no more tui dependency * lints
Diffstat (limited to 'src/tui/widgets')
-rw-r--r--src/tui/widgets/block.rs562
-rw-r--r--src/tui/widgets/mod.rs159
-rw-r--r--src/tui/widgets/paragraph.rs194
-rw-r--r--src/tui/widgets/reflow.rs537
4 files changed, 1452 insertions, 0 deletions
diff --git a/src/tui/widgets/block.rs b/src/tui/widgets/block.rs
new file mode 100644
index 00000000..eb59f9c3
--- /dev/null
+++ b/src/tui/widgets/block.rs
@@ -0,0 +1,562 @@
+use crate::tui::{
+ buffer::Buffer,
+ layout::{Alignment, Rect},
+ style::Style,
+ symbols::line,
+ text::Spans,
+ widgets::{Borders, Widget},
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+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 tui::widgets::{Block, BorderType, Borders};
+/// # use 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, Eq)]
+pub struct Block<'a> {
+ /// Optional title place on the upper left of the block
+ title: Option<Spans<'a>>,
+ /// Title alignment. The default is top left of the block, but one can choose to place
+ /// title in the top middle, or top right of the block
+ title_alignment: Alignment,
+ /// 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,
+ title_alignment: Alignment::Left,
+ borders: Borders::NONE,
+ border_style: Style::default(),
+ border_type: BorderType::Plain,
+ style: Style::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
+ }
+
+ pub fn title_alignment(mut self, alignment: Alignment) -> Block<'a> {
+ self.title_alignment = alignment;
+ 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);
+ }
+
+ // Title
+ if let Some(title) = self.title {
+ let left_border_dx = if self.borders.intersects(Borders::LEFT) {
+ 1
+ } else {
+ 0
+ };
+
+ let right_border_dx = if self.borders.intersects(Borders::RIGHT) {
+ 1
+ } else {
+ 0
+ };
+
+ let title_area_width = area
+ .width
+ .saturating_sub(left_border_dx)
+ .saturating_sub(right_border_dx);
+
+ let title_dx = match self.title_alignment {
+ Alignment::Left => left_border_dx,
+ Alignment::Center => area.width.saturating_sub(title.width() as u16) / 2,
+ Alignment::Right => area
+ .width
+ .saturating_sub(title.width() as u16)
+ .saturating_sub(right_border_dx),
+ };
+
+ let title_x = area.left() + title_dx;
+ let title_y = area.top();
+
+ buf.set_spans(title_x, title_y, &title, title_area_width);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::tui::layout::Rect;
+
+ #[test]
+ #[allow(clippy::too_many_lines)]
+ 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,
+ },
+ );
+ assert_eq!(
+ Block::default()
+ .title("Test")
+ .title_alignment(Alignment::Center)
+ .inner(Rect {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 1,
+ }),
+ Rect {
+ x: 0,
+ y: 1,
+ width: 0,
+ height: 0,
+ },
+ );
+ assert_eq!(
+ Block::default()
+ .title("Test")
+ .title_alignment(Alignment::Right)
+ .inner(Rect {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 1,
+ }),
+ Rect {
+ x: 0,
+ y: 1,
+ width: 0,
+ height: 0,
+ },
+ );
+ }
+}
diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs
new file mode 100644
index 00000000..635185e5
--- /dev/null
+++ b/src/tui/widgets/mod.rs
@@ -0,0 +1,159 @@
+//! `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`]
+//! - [`Paragraph`]
+
+mod block;
+mod paragraph;
+mod reflow;
+
+pub use self::block::{Block, BorderType};
+pub use self::paragraph::{Paragraph, Wrap};
+
+use crate::tui::{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 is the only method required
+ /// to implement a custom widget.
+ fn render(self, area: Rect, buf: &mut Buffer);
+}
+
+/// A `StatefulWidget` is a widget that can take advantage of some local state to remember things
+/// between two draw calls.
+///
+/// Most widgets can be drawn directly based on the input parameters. However, some features may
+/// require some kind of associated state to be implemented.
+///
+/// For example, the [`List`] widget can highlight the item currently selected. This can be
+/// translated in an offset, which is the number of elements to skip in order to have the selected
+/// item within the viewport currently allocated to this widget. The widget can therefore only
+/// provide the following behavior: whenever the selected item is out of the viewport scroll to a
+/// predefined position (making the selected item the last viewable item or the one in the middle
+/// for example). Nonetheless, if the widget has access to the last computed offset then it can
+/// implement a natural scrolling experience where the last offset is reused until the selected
+/// item is out of the viewport.
+///
+/// ## Examples
+///
+/// ```rust,no_run
+/// # use std::io;
+/// # use tui::Terminal;
+/// # use tui::backend::{Backend, TestBackend};
+/// # use tui::widgets::{Widget, List, ListItem, ListState};
+///
+/// // Let's say we have some events to display.
+/// struct Events {
+/// // `items` is the state managed by your application.
+/// items: Vec<String>,
+/// // `state` is the state that can be modified by the UI. It stores the index of the selected
+/// // item as well as the offset computed during the previous draw call (used to implement
+/// // natural scrolling).
+/// state: ListState
+/// }
+///
+/// impl Events {
+/// fn new(items: Vec<String>) -> Events {
+/// Events {
+/// items,
+/// state: ListState::default(),
+/// }
+/// }
+///
+/// pub fn set_items(&mut self, items: Vec<String>) {
+/// self.items = items;
+/// // We reset the state as the associated items have changed. This effectively reset
+/// // the selection as well as the stored offset.
+/// self.state = ListState::default();
+/// }
+///
+/// // Select the next item. This will not be reflected until the widget is drawn in the
+/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
+/// pub fn next(&mut self) {
+/// let i = match self.state.selected() {
+/// Some(i) => {
+/// if i >= self.items.len() - 1 {
+/// 0
+/// } else {
+/// i + 1
+/// }
+/// }
+/// None => 0,
+/// };
+/// self.state.select(Some(i));
+/// }
+///
+/// // Select the previous item. This will not be reflected until the widget is drawn in the
+/// // `Terminal::draw` callback using `Frame::render_stateful_widget`.
+/// pub fn previous(&mut self) {
+/// let i = match self.state.selected() {
+/// Some(i) => {
+/// if i == 0 {
+/// self.items.len() - 1
+/// } else {
+/// i - 1
+/// }
+/// }
+/// None => 0,
+/// };
+/// self.state.select(Some(i));
+/// }
+///
+/// // Unselect the currently selected item if any. The implementation of `ListState` makes
+/// // sure that the stored offset is also reset.
+/// pub fn unselect(&mut self) {
+/// self.state.select(None);
+/// }
+/// }
+///
+/// # let backend = TestBackend::new(5, 5);
+/// # let mut terminal = Terminal::new(backend).unwrap();
+///
+/// let mut events = Events::new(vec![
+/// String::from("Item 1"),
+/// String::from("Item 2")
+/// ]);
+///
+/// loop {
+/// terminal.draw(|f| {
+/// // The items managed by the application are transformed to something
+/// // that is understood by tui.
+/// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_ref())).collect();
+/// // The `List` widget is then built with those items.
+/// let list = List::new(items);
+/// // Finally the widget is rendered using the associated state. `events.state` is
+/// // effectively the only thing that we will "remember" from this draw call.
+/// f.render_stateful_widget(list, f.size(), &mut events.state);
+/// });
+///
+/// // In response to some input events or an external http request or whatever:
+/// events.next();
+/// }
+/// ```
+pub trait StatefulWidget {
+ type State;
+ fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
+}
diff --git a/src/tui/widgets/paragraph.rs b/src/tui/widgets/paragraph.rs
new file mode 100644
index 00000000..a5036148
--- /dev/null
+++ b/src/tui/widgets/paragraph.rs
@@ -0,0 +1,194 @@
+use crate::tui::{
+ 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 tui::text::{Text, Spans, Span};
+/// # use tui::widgets::{Block, Borders, Paragraph, Wrap};
+/// # use tui::style::{Style, Color, Modifier};
+/// # use 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 tui::widgets::{Paragraph, Wrap};
+/// # use 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: Style::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 = self.block.take().map_or(area, |b| {
+ let inner_area = b.inner(area);
+ b.render(area, buf);
+ inner_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 self.alignment == Alignment::Left {
+ 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/src/tui/widgets/reflow.rs b/src/tui/widgets/reflow.rs
new file mode 100644
index 00000000..d174932b
--- /dev/null
+++ b/src/tui/widgets/reflow.rs
@@ -0,0 +1,537 @@
+use crate::tui::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 {
+ (self.current_line.len() - 1, self.max_line_width)
+ } else {
+ (symbols_to_last_word_end, width_to_last_word_end)
+ };
+
+ // 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 crate::tui::style::Style;
+
+ use super::*;
+ use unicode_segmentation::UnicodeSegmentation;
+
+ #[derive(Clone, Copy)]
+ enum Composer {
+ WordWrapper { trim: bool },
+ LineTruncator,
+ }
+
+ fn run_composer(which: Composer, text: &str, text_area_width: u16) -> (Vec<String>, Vec<u16>) {
+ let style = Style::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!"
+ ]
+ );
+ }
+}