diff options
Diffstat (limited to 'helix-tui')
25 files changed, 6397 insertions, 0 deletions
diff --git a/helix-tui/.gitignore b/helix-tui/.gitignore new file mode 100644 index 00000000..dcb33fbb --- /dev/null +++ b/helix-tui/.gitignore @@ -0,0 +1,6 @@ +target +Cargo.lock +*.log +*.rs.rustfmt +.gdb_history +.idea/ diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml new file mode 100644 index 00000000..badefe4c --- /dev/null +++ b/helix-tui/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "helix-tui" +version = "0.1.0" +authors = ["Blaž Hrastnik <blaz@mxxn.io>"] +description = """ +A library to build rich terminal user interfaces or dashboards +""" +edition = "2018" + +[features] +default = ["crossterm"] + +[dependencies] +bitflags = "1.0" +cassowary = "0.3" +unicode-segmentation = "1.2" +unicode-width = "0.1" +crossterm = { version = "0.19", optional = true } +serde = { version = "1", "optional" = true, features = ["derive"]} diff --git a/helix-tui/README.md b/helix-tui/README.md new file mode 100644 index 00000000..97b3d1d9 --- /dev/null +++ b/helix-tui/README.md @@ -0,0 +1,6 @@ +# helix-tui + +This library is a fork of the great library +[tui-rs](https://github.com/fdehau/tui-rs/). We've mainly relied on the double +buffer implementation and render diffing, side-stepping it's widget and +layouting. diff --git a/helix-tui/src/backend/crossterm.rs b/helix-tui/src/backend/crossterm.rs new file mode 100644 index 00000000..f6703e14 --- /dev/null +++ b/helix-tui/src/backend/crossterm.rs @@ -0,0 +1,221 @@ +use crate::{ + backend::Backend, + buffer::Cell, + layout::Rect, + style::{Color, Modifier}, +}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + execute, queue, + style::{ + Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor, + SetForegroundColor, + }, + terminal::{self, Clear, ClearType}, +}; +use std::io::{self, Write}; + +pub struct CrosstermBackend<W: Write> { + buffer: W, +} + +impl<W> CrosstermBackend<W> +where + W: Write, +{ + pub fn new(buffer: W) -> CrosstermBackend<W> { + CrosstermBackend { buffer } + } +} + +impl<W> Write for CrosstermBackend<W> +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + self.buffer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +impl<W> Backend for CrosstermBackend<W> +where + W: Write, +{ + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator<Item = (u16, u16, &'a Cell)>, + { + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option<(u16, u16)> = None; + for (x, y, cell) in content { + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) { + map_error(queue!(self.buffer, MoveTo(x, y)))?; + } + last_pos = Some((x, y)); + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(&mut self.buffer)?; + modifier = cell.modifier; + } + if cell.fg != fg { + let color = CColor::from(cell.fg); + map_error(queue!(self.buffer, SetForegroundColor(color)))?; + fg = cell.fg; + } + if cell.bg != bg { + let color = CColor::from(cell.bg); + map_error(queue!(self.buffer, SetBackgroundColor(color)))?; + bg = cell.bg; + } + + map_error(queue!(self.buffer, Print(&cell.symbol)))?; + } + + map_error(queue!( + self.buffer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(CAttribute::Reset) + )) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Hide)) + } + + fn show_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Show)) + } + + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + crossterm::cursor::position() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) + } + + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + map_error(execute!(self.buffer, MoveTo(x, y))) + } + + fn clear(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Clear(ClearType::All))) + } + + fn size(&self) -> io::Result<Rect> { + let (width, height) = + terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + Ok(Rect::new(0, 0, width, height)) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +fn map_error(error: crossterm::Result<()>) -> io::Result<()> { + error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +impl From<Color> for CColor { + fn from(color: Color) -> Self { + match color { + Color::Reset => CColor::Reset, + Color::Black => CColor::Black, + Color::Red => CColor::DarkRed, + Color::Green => CColor::DarkGreen, + Color::Yellow => CColor::DarkYellow, + Color::Blue => CColor::DarkBlue, + Color::Magenta => CColor::DarkMagenta, + Color::Cyan => CColor::DarkCyan, + Color::Gray => CColor::Grey, + Color::DarkGray => CColor::DarkGrey, + Color::LightRed => CColor::Red, + Color::LightGreen => CColor::Green, + Color::LightBlue => CColor::Blue, + Color::LightYellow => CColor::Yellow, + Color::LightMagenta => CColor::Magenta, + Color::LightCyan => CColor::Cyan, + Color::White => CColor::White, + Color::Indexed(i) => CColor::AnsiValue(i), + Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, + } + } +} + +#[derive(Debug)] +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue<W>(&self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + //use crossterm::Attribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?; + } + if removed.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + if self.to.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + } + if removed.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; + } + if removed.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; + } + if removed.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?; + } + if added.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; + } + if added.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; + } + if added.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; + } + if added.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + if added.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?; + } + if added.contains(Modifier::SLOW_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?; + } + if added.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?; + } + + Ok(()) + } +} diff --git a/helix-tui/src/backend/mod.rs b/helix-tui/src/backend/mod.rs new file mode 100644 index 00000000..ed125200 --- /dev/null +++ b/helix-tui/src/backend/mod.rs @@ -0,0 +1,25 @@ +use std::io; + +use crate::buffer::Cell; +use crate::layout::Rect; + +#[cfg(feature = "crossterm")] +mod crossterm; +#[cfg(feature = "crossterm")] +pub use self::crossterm::CrosstermBackend; + +mod test; +pub use self::test::TestBackend; + +pub trait Backend { + fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> + where + I: Iterator<Item = (u16, u16, &'a Cell)>; + fn hide_cursor(&mut self) -> Result<(), io::Error>; + fn show_cursor(&mut self) -> Result<(), io::Error>; + fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>; + fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; + fn clear(&mut self) -> Result<(), io::Error>; + fn size(&self) -> Result<Rect, io::Error>; + fn flush(&mut self) -> Result<(), io::Error>; +} diff --git a/helix-tui/src/backend/test.rs b/helix-tui/src/backend/test.rs new file mode 100644 index 00000000..46b37261 --- /dev/null +++ b/helix-tui/src/backend/test.rs @@ -0,0 +1,151 @@ +use crate::{ + backend::Backend, + buffer::{Buffer, Cell}, + layout::Rect, +}; +use std::{fmt::Write, io}; +use unicode_width::UnicodeWidthStr; + +/// A backend used for the integration tests. +#[derive(Debug)] +pub struct TestBackend { + width: u16, + buffer: Buffer, + height: u16, + cursor: bool, + pos: (u16, u16), +} + +/// Returns a string representation of the given buffer for debugging purpose. +fn buffer_view(buffer: &Buffer) -> String { + let mut view = String::with_capacity(buffer.content.len() + buffer.area.height as usize * 3); + for cells in buffer.content.chunks(buffer.area.width as usize) { + let mut overwritten = vec![]; + let mut skip: usize = 0; + view.push('"'); + for (x, c) in cells.iter().enumerate() { + if skip == 0 { + view.push_str(&c.symbol); + } else { + overwritten.push((x, &c.symbol)) + } + skip = std::cmp::max(skip, c.symbol.width()).saturating_sub(1); + } + view.push('"'); + if !overwritten.is_empty() { + write!( + &mut view, + " Hidden by multi-width symbols: {:?}", + overwritten + ) + .unwrap(); + } + view.push('\n'); + } + view +} + +impl TestBackend { + pub fn new(width: u16, height: u16) -> TestBackend { + TestBackend { + width, + height, + buffer: Buffer::empty(Rect::new(0, 0, width, height)), + cursor: false, + pos: (0, 0), + } + } + + pub fn buffer(&self) -> &Buffer { + &self.buffer + } + + pub fn resize(&mut self, width: u16, height: u16) { + self.buffer.resize(Rect::new(0, 0, width, height)); + self.width = width; + self.height = height; + } + + pub fn assert_buffer(&self, expected: &Buffer) { + assert_eq!(expected.area, self.buffer.area); + let diff = expected.diff(&self.buffer); + if diff.is_empty() { + return; + } + + let mut debug_info = String::from("Buffers are not equal"); + debug_info.push('\n'); + debug_info.push_str("Expected:"); + debug_info.push('\n'); + let expected_view = buffer_view(expected); + debug_info.push_str(&expected_view); + debug_info.push('\n'); + debug_info.push_str("Got:"); + debug_info.push('\n'); + let view = buffer_view(&self.buffer); + debug_info.push_str(&view); + debug_info.push('\n'); + + debug_info.push_str("Diff:"); + debug_info.push('\n'); + let nice_diff = diff + .iter() + .enumerate() + .map(|(i, (x, y, cell))| { + let expected_cell = expected.get(*x, *y); + format!( + "{}: at ({}, {}) expected {:?} got {:?}", + i, x, y, expected_cell, cell + ) + }) + .collect::<Vec<String>>() + .join("\n"); + debug_info.push_str(&nice_diff); + panic!("{}", debug_info); + } +} + +impl Backend for TestBackend { + fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> + where + I: Iterator<Item = (u16, u16, &'a Cell)>, + { + for (x, y, c) in content { + let cell = self.buffer.get_mut(x, y); + *cell = c.clone(); + } + Ok(()) + } + + fn hide_cursor(&mut self) -> Result<(), io::Error> { + self.cursor = false; + Ok(()) + } + + fn show_cursor(&mut self) -> Result<(), io::Error> { + self.cursor = true; + Ok(()) + } + + fn get_cursor(&mut self) -> Result<(u16, u16), io::Error> { + Ok(self.pos) + } + + fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error> { + self.pos = (x, y); + Ok(()) + } + + fn clear(&mut self) -> Result<(), io::Error> { + self.buffer.reset(); + Ok(()) + } + + fn size(&self) -> Result<Rect, io::Error> { + Ok(Rect::new(0, 0, self.width, self.height)) + } + + fn flush(&mut self) -> Result<(), io::Error> { + Ok(()) + } +} diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs new file mode 100644 index 00000000..8a227ce7 --- /dev/null +++ b/helix-tui/src/buffer.rs @@ -0,0 +1,745 @@ +use crate::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Span, Spans}, +}; +use std::cmp::min; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A buffer cell +#[derive(Debug, Clone, PartialEq)] +pub struct Cell { + pub symbol: String, + pub fg: Color, + pub bg: Color, + pub modifier: Modifier, +} + +impl Cell { + pub fn set_symbol(&mut self, symbol: &str) -> &mut Cell { + self.symbol.clear(); + self.symbol.push_str(symbol); + self + } + + pub fn set_char(&mut self, ch: char) -> &mut Cell { + self.symbol.clear(); + self.symbol.push(ch); + self + } + + pub fn set_fg(&mut self, color: Color) -> &mut Cell { + self.fg = color; + self + } + + pub fn set_bg(&mut self, color: Color) -> &mut Cell { + self.bg = color; + self + } + + pub fn set_style(&mut self, style: Style) -> &mut Cell { + if let Some(c) = style.fg { + self.fg = c; + } + if let Some(c) = style.bg { + self.bg = c; + } + self.modifier.insert(style.add_modifier); + self.modifier.remove(style.sub_modifier); + self + } + + pub fn style(&self) -> Style { + Style::default() + .fg(self.fg) + .bg(self.bg) + .add_modifier(self.modifier) + } + + pub fn reset(&mut self) { + self.symbol.clear(); + self.symbol.push(' '); + self.fg = Color::Reset; + self.bg = Color::Reset; + self.modifier = Modifier::empty(); + } +} + +impl Default for Cell { + fn default() -> Cell { + Cell { + symbol: " ".into(), + fg: Color::Reset, + bg: Color::Reset, + modifier: Modifier::empty(), + } + } +} + +/// A buffer that maps to the desired content of the terminal after the draw call +/// +/// No widget in the library interacts directly with the terminal. Instead each of them is required +/// to draw their state to an intermediate buffer. It is basically a grid where each cell contains +/// a grapheme, a foreground color and a background color. This grid will then be used to output +/// the appropriate escape sequences and characters to draw the UI as the user has defined it. +/// +/// # Examples: +/// +/// ``` +/// use helix_tui::buffer::{Buffer, Cell}; +/// use helix_tui::layout::Rect; +/// use helix_tui::style::{Color, Style, Modifier}; +/// +/// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5}); +/// buf.get_mut(0, 2).set_symbol("x"); +/// assert_eq!(buf.get(0, 2).symbol, "x"); +/// buf.set_string(3, 0, "string", Style::default().fg(Color::Red).bg(Color::White)); +/// assert_eq!(buf.get(5, 0), &Cell{ +/// symbol: String::from("r"), +/// fg: Color::Red, +/// bg: Color::White, +/// modifier: Modifier::empty() +/// }); +/// buf.get_mut(5, 0).set_char('x'); +/// assert_eq!(buf.get(5, 0).symbol, "x"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct Buffer { + /// The area represented by this buffer + pub area: Rect, + /// The content of the buffer. The length of this Vec should always be equal to area.width * + /// area.height + pub content: Vec<Cell>, +} + +impl Default for Buffer { + fn default() -> Buffer { + Buffer { + area: Default::default(), + content: Vec::new(), + } + } +} + +impl Buffer { + /// Returns a Buffer with all cells set to the default one + pub fn empty(area: Rect) -> Buffer { + let cell: Cell = Default::default(); + Buffer::filled(area, &cell) + } + + /// Returns a Buffer with all cells initialized with the attributes of the given Cell + pub fn filled(area: Rect, cell: &Cell) -> Buffer { + let size = area.area() as usize; + let mut content = Vec::with_capacity(size); + for _ in 0..size { + content.push(cell.clone()); + } + Buffer { area, content } + } + + /// Returns a Buffer containing the given lines + pub fn with_lines<S>(lines: Vec<S>) -> Buffer + where + S: AsRef<str>, + { + let height = lines.len() as u16; + let width = lines + .iter() + .map(|i| i.as_ref().width() as u16) + .max() + .unwrap_or_default(); + let mut buffer = Buffer::empty(Rect { + x: 0, + y: 0, + width, + height, + }); + for (y, line) in lines.iter().enumerate() { + buffer.set_string(0, y as u16, line, Style::default()); + } + buffer + } + + /// Returns the content of the buffer as a slice + pub fn content(&self) -> &[Cell] { + &self.content + } + + /// Returns the area covered by this buffer + pub fn area(&self) -> &Rect { + &self.area + } + + /// Returns a reference to Cell at the given coordinates + pub fn get(&self, x: u16, y: u16) -> &Cell { + let i = self.index_of(x, y); + &self.content[i] + } + + /// Returns a mutable reference to Cell at the given coordinates + pub fn get_mut(&mut self, x: u16, y: u16) -> &mut Cell { + let i = self.index_of(x, y); + &mut self.content[i] + } + + /// Returns the index in the Vec<Cell> for the given global (x, y) coordinates. + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use helix_tui::buffer::Buffer; + /// # use helix_tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Global coordinates to the top corner of this buffer's area + /// assert_eq!(buffer.index_of(200, 100), 0); + /// ``` + /// + /// # Panics + /// + /// Panics when given an coordinate that is outside of this Buffer's area. + /// + /// ```should_panic + /// # use helix_tui::buffer::Buffer; + /// # use helix_tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area + /// // starts at (200, 100). + /// buffer.index_of(0, 0); // Panics + /// ``` + pub fn index_of(&self, x: u16, y: u16) -> usize { + debug_assert!( + x >= self.area.left() + && x < self.area.right() + && y >= self.area.top() + && y < self.area.bottom(), + "Trying to access position outside the buffer: x={}, y={}, area={:?}", + x, + y, + self.area + ); + ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize + } + + /// Returns the (global) coordinates of a cell given its index + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use helix_tui::buffer::Buffer; + /// # use helix_tui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// assert_eq!(buffer.pos_of(0), (200, 100)); + /// assert_eq!(buffer.pos_of(14), (204, 101)); + /// ``` + /// + /// # Panics + /// + /// Panics when given an index that is outside the Buffer's content. + /// + /// ```should_panic + /// # use helix_tui::buffer::Buffer; + /// # use helix_tui::layout::Rect; + /// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total + /// let buffer = Buffer::empty(rect); + /// // Index 100 is the 101th cell, which lies outside of the area of this Buffer. + /// buffer.pos_of(100); // Panics + /// ``` + pub fn pos_of(&self, i: usize) -> (u16, u16) { + debug_assert!( + i < self.content.len(), + "Trying to get the coords of a cell outside the buffer: i={} len={}", + i, + self.content.len() + ); + ( + self.area.x + i as u16 % self.area.width, + self.area.y + i as u16 / self.area.width, + ) + } + + /// Print a string, starting at the position (x, y) + pub fn set_string<S>(&mut self, x: u16, y: u16, string: S, style: Style) + where + S: AsRef<str>, + { + self.set_stringn(x, y, string, usize::MAX, style); + } + + /// Print at most the first n characters of a string if enough space is available + /// until the end of the line + pub fn set_stringn<S>( + &mut self, + x: u16, + y: u16, + string: S, + width: usize, + style: Style, + ) -> (u16, u16) + where + S: AsRef<str>, + { + let mut index = self.index_of(x, y); + let mut x_offset = x as usize; + let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true); + let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); + for s in graphemes { + let width = s.width(); + if width == 0 { + continue; + } + // `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we + // change dimenstions to usize or u32 and someone resizes the terminal to 1x2^32. + if width > max_offset.saturating_sub(x_offset) { + break; + } + + self.content[index].set_symbol(s); + self.content[index].set_style(style); + // Reset following cells if multi-width (they would be hidden by the grapheme), + for i in index + 1..index + width { + self.content[i].reset(); + } + index += width; + x_offset += width; + } + (x_offset as u16, y) + } + + pub fn set_spans<'a>(&mut self, x: u16, y: u16, spans: &Spans<'a>, width: u16) -> (u16, u16) { + let mut remaining_width = width; + let mut x = x; + for span in &spans.0 { + if remaining_width == 0 { + break; + } + let pos = self.set_stringn( + x, + y, + span.content.as_ref(), + remaining_width as usize, + span.style, + ); + let w = pos.0.saturating_sub(x); + x = pos.0; + remaining_width = remaining_width.saturating_sub(w); + } + (x, y) + } + + pub fn set_span<'a>(&mut self, x: u16, y: u16, span: &Span<'a>, width: u16) -> (u16, u16) { + self.set_stringn(x, y, span.content.as_ref(), width as usize, span.style) + } + + #[deprecated( + since = "0.10.0", + note = "You should use styling capabilities of `Buffer::set_style`" + )] + pub fn set_background(&mut self, area: Rect, color: Color) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_bg(color); + } + } + } + + pub fn set_style(&mut self, area: Rect, style: Style) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_style(style); + } + } + } + + /// Resize the buffer so that the mapped area matches the given area and that the buffer + /// length is equal to area.width * area.height + pub fn resize(&mut self, area: Rect) { + let length = area.area() as usize; + if self.content.len() > length { + self.content.truncate(length); + } else { + self.content.resize(length, Default::default()); + } + self.area = area; + } + + /// Reset all cells in the buffer + pub fn reset(&mut self) { + for c in &mut self.content { + c.reset(); + } + } + + /// Merge an other buffer into this one + pub fn merge(&mut self, other: &Buffer) { + let area = self.area.union(other.area); + let cell: Cell = Default::default(); + self.content.resize(area.area() as usize, cell.clone()); + + // Move original content to the appropriate space + let size = self.area.area() as usize; + for i in (0..size).rev() { + let (x, y) = self.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + if i != k { + self.content[k] = self.content[i].clone(); + self.content[i] = cell.clone(); + } + } + + // Push content of the other buffer into this one (may erase previous + // data) + let size = other.area.area() as usize; + for i in 0..size { + let (x, y) = other.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + self.content[k] = other.content[i].clone(); + } + self.area = area; + } + + /// Builds a minimal sequence of coordinates and Cells necessary to update the UI from + /// self to other. + /// + /// We're assuming that buffers are well-formed, that is no double-width cell is followed by + /// a non-blank cell. + /// + /// # Multi-width characters handling: + /// + /// ```text + /// (Index:) `01` + /// Prev: `コ` + /// Next: `aa` + /// Updates: `0: a, 1: a' + /// ``` + /// + /// ```text + /// (Index:) `01` + /// Prev: `a ` + /// Next: `コ` + /// Updates: `0: コ` (double width symbol at index 0 - skip index 1) + /// ``` + /// + /// ```text + /// (Index:) `012` + /// Prev: `aaa` + /// Next: `aコ` + /// Updates: `0: a, 1: コ` (double width symbol at index 1 - skip index 2) + /// ``` + pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> { + let previous_buffer = &self.content; + let next_buffer = &other.content; + let width = self.area.width; + + let mut updates: Vec<(u16, u16, &Cell)> = vec![]; + // Cells invalidated by drawing/replacing preceeding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceeding multi-width characters taking their + // place (the skipped cells should be blank anyway): + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if (current != previous || invalidated > 0) && to_skip == 0 { + let x = i as u16 % width; + let y = i as u16 / width; + updates.push((x, y, &next_buffer[i])); + } + + to_skip = current.symbol.width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(s: &str) -> Cell { + let mut cell = Cell::default(); + cell.set_symbol(s); + cell + } + + #[test] + fn it_translates_to_and_from_coordinates() { + let rect = Rect::new(200, 100, 50, 80); + let buf = Buffer::empty(rect); + + // First cell is at the upper left corner. + assert_eq!(buf.pos_of(0), (200, 100)); + assert_eq!(buf.index_of(200, 100), 0); + + // Last cell is in the lower right. + assert_eq!(buf.pos_of(buf.content.len() - 1), (249, 179)); + assert_eq!(buf.index_of(249, 179), buf.content.len() - 1); + } + + #[test] + #[should_panic(expected = "outside the buffer")] + fn pos_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // There are a total of 100 cells; zero-indexed means that 100 would be the 101st cell. + buf.pos_of(100); + } + + #[test] + #[should_panic(expected = "outside the buffer")] + fn index_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // width is 10; zero-indexed means that 10 would be the 11th cell. + buf.index_of(10, 0); + } + + #[test] + fn buffer_set_string() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + + // Zero-width + buffer.set_stringn(0, 0, "aaa", 0, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec![" "])); + + buffer.set_string(0, 0, "aaa", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["aaa "])); + + // Width limit: + buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "])); + + buffer.set_string(0, 0, "12345", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + + // Width truncation: + buffer.set_string(0, 0, "123456", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + } + + #[test] + fn buffer_set_string_zero_width() { + let area = Rect::new(0, 0, 1, 1); + let mut buffer = Buffer::empty(area); + + // Leading grapheme with zero width + let s = "\u{1}a"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["a"])); + + // Trailing grapheme with zero with + let s = "a\u{1}"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["a"])); + } + + #[test] + fn buffer_set_string_double_width() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + buffer.set_string(0, 0, "コン", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + + // Only 1 space left. + buffer.set_string(0, 0, "コンピ", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + } + + #[test] + fn buffer_with_lines() { + let buffer = + Buffer::with_lines(vec!["┌────────┐", "│コンピュ│", "│ーa 上で│", "└────────┘"]); + assert_eq!(buffer.area.x, 0); + assert_eq!(buffer.area.y, 0); + assert_eq!(buffer.area.width, 10); + assert_eq!(buffer.area.height, 4); + } + + #[test] + fn buffer_diffing_empty_empty() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::empty(area); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_empty_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff.len(), 40 * 40); + } + + #[test] + fn buffer_diffing_filled_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::filled(area, Cell::default().set_symbol("a")); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_single_width() { + let prev = Buffer::with_lines(vec![ + " ", + "┌Title─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + " ", + "┌TITLE─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (2, 1, &cell("I")), + (3, 1, &cell("T")), + (4, 1, &cell("L")), + (5, 1, &cell("E")), + ] + ); + } + + #[test] + #[rustfmt::skip] + fn buffer_diffing_multi_width() { + let prev = Buffer::with_lines(vec![ + "┌Title─┐ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + "┌称号──┐ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (1, 0, &cell("称")), + // Skipped "i" + (3, 0, &cell("号")), + // Skipped "l" + (5, 0, &cell("─")), + ] + ); + } + + #[test] + fn buffer_diffing_multi_width_offset() { + let prev = Buffer::with_lines(vec!["┌称号──┐"]); + let next = Buffer::with_lines(vec!["┌─称号─┐"]); + + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![(1, 0, &cell("─")), (2, 0, &cell("称")), (4, 0, &cell("号")),] + ); + } + + #[test] + fn buffer_merge() { + let mut one = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"])); + } + + #[test] + fn buffer_merge2() { + let mut one = Buffer::filled( + Rect { + x: 2, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!( + one, + Buffer::with_lines(vec!["22 ", "22 ", " 11", " 11"]) + ); + } + + #[test] + fn buffer_merge3() { + let mut one = Buffer::filled( + Rect { + x: 3, + y: 3, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 1, + y: 1, + width: 3, + height: 4, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + let mut merged = Buffer::with_lines(vec!["222 ", "222 ", "2221", "2221"]); + merged.area = Rect { + x: 1, + y: 1, + width: 4, + height: 4, + }; + assert_eq!(one, merged); + } +} diff --git a/helix-tui/src/layout.rs b/helix-tui/src/layout.rs new file mode 100644 index 00000000..5248f7bf --- /dev/null +++ b/helix-tui/src/layout.rs @@ -0,0 +1,534 @@ +use std::cell::RefCell; +use std::cmp::{max, min}; +use std::collections::HashMap; + +use cassowary::strength::{REQUIRED, WEAK}; +use cassowary::WeightedRelation::*; +use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable}; + +#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +#[derive(Debug, Hash, Clone, PartialEq, Eq)] +pub enum Direction { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Constraint { + // TODO: enforce range 0 - 100 + Percentage(u16), + Ratio(u32, u32), + Length(u16), + Max(u16), + Min(u16), +} + +impl Constraint { + pub fn apply(&self, length: u16) -> u16 { + match *self { + Constraint::Percentage(p) => length * p / 100, + Constraint::Ratio(num, den) => { + let r = num * u32::from(length) / den; + r as u16 + } + Constraint::Length(l) => length.min(l), + Constraint::Max(m) => length.min(m), + Constraint::Min(m) => length.max(m), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Margin { + pub vertical: u16, + pub horizontal: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Alignment { + Left, + Center, + Right, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Layout { + direction: Direction, + margin: Margin, + constraints: Vec<Constraint>, +} + +thread_local! { + static LAYOUT_CACHE: RefCell<HashMap<(Rect, Layout), Vec<Rect>>> = RefCell::new(HashMap::new()); +} + +impl Default for Layout { + fn default() -> Layout { + Layout { + direction: Direction::Vertical, + margin: Margin { + horizontal: 0, + vertical: 0, + }, + constraints: Vec::new(), + } + } +} + +impl Layout { + pub fn constraints<C>(mut self, constraints: C) -> Layout + where + C: Into<Vec<Constraint>>, + { + self.constraints = constraints.into(); + self + } + + pub fn margin(mut self, margin: u16) -> Layout { + self.margin = Margin { + horizontal: margin, + vertical: margin, + }; + self + } + + pub fn horizontal_margin(mut self, horizontal: u16) -> Layout { + self.margin.horizontal = horizontal; + self + } + + pub fn vertical_margin(mut self, vertical: u16) -> Layout { + self.margin.vertical = vertical; + self + } + + pub fn direction(mut self, direction: Direction) -> Layout { + self.direction = direction; + self + } + + /// Wrapper function around the cassowary-rs solver to be able to split a given + /// area into smaller ones based on the preferred widths or heights and the direction. + /// + /// # Examples + /// ``` + /// # use helix_tui::layout::{Rect, Constraint, Direction, Layout}; + /// let chunks = Layout::default() + /// .direction(Direction::Vertical) + /// .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref()) + /// .split(Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 10, + /// }); + /// assert_eq!( + /// chunks, + /// vec![ + /// Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 5 + /// }, + /// Rect { + /// x: 2, + /// y: 7, + /// width: 10, + /// height: 5 + /// } + /// ] + /// ); + /// + /// let chunks = Layout::default() + /// .direction(Direction::Horizontal) + /// .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) + /// .split(Rect { + /// x: 0, + /// y: 0, + /// width: 9, + /// height: 2, + /// }); + /// assert_eq!( + /// chunks, + /// vec![ + /// Rect { + /// x: 0, + /// y: 0, + /// width: 3, + /// height: 2 + /// }, + /// Rect { + /// x: 3, + /// y: 0, + /// width: 6, + /// height: 2 + /// } + /// ] + /// ); + /// ``` + pub fn split(&self, area: Rect) -> Vec<Rect> { + // TODO: Maybe use a fixed size cache ? + LAYOUT_CACHE.with(|c| { + c.borrow_mut() + .entry((area, self.clone())) + .or_insert_with(|| split(area, self)) + .clone() + }) + } +} + +fn split(area: Rect, layout: &Layout) -> Vec<Rect> { + let mut solver = Solver::new(); + let mut vars: HashMap<Variable, (usize, usize)> = HashMap::new(); + let elements = layout + .constraints + .iter() + .map(|_| Element::new()) + .collect::<Vec<Element>>(); + let mut results = layout + .constraints + .iter() + .map(|_| Rect::default()) + .collect::<Vec<Rect>>(); + + let dest_area = area.inner(&layout.margin); + for (i, e) in elements.iter().enumerate() { + vars.insert(e.x, (i, 0)); + vars.insert(e.y, (i, 1)); + vars.insert(e.width, (i, 2)); + vars.insert(e.height, (i, 3)); + } + let mut ccs: Vec<CassowaryConstraint> = + Vec::with_capacity(elements.len() * 4 + layout.constraints.len() * 6); + for elt in &elements { + ccs.push(elt.width | GE(REQUIRED) | 0f64); + ccs.push(elt.height | GE(REQUIRED) | 0f64); + ccs.push(elt.left() | GE(REQUIRED) | f64::from(dest_area.left())); + ccs.push(elt.top() | GE(REQUIRED) | f64::from(dest_area.top())); + ccs.push(elt.right() | LE(REQUIRED) | f64::from(dest_area.right())); + ccs.push(elt.bottom() | LE(REQUIRED) | f64::from(dest_area.bottom())); + } + if let Some(first) = elements.first() { + ccs.push(match layout.direction { + Direction::Horizontal => first.left() | EQ(REQUIRED) | f64::from(dest_area.left()), + Direction::Vertical => first.top() | EQ(REQUIRED) | f64::from(dest_area.top()), + }); + } + if let Some(last) = elements.last() { + ccs.push(match layout.direction { + Direction::Horizontal => last.right() | EQ(REQUIRED) | f64::from(dest_area.right()), + Direction::Vertical => last.bottom() | EQ(REQUIRED) | f64::from(dest_area.bottom()), + }); + } + match layout.direction { + Direction::Horizontal => { + for pair in elements.windows(2) { + ccs.push((pair[0].x + pair[0].width) | EQ(REQUIRED) | pair[1].x); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y)); + ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].width | EQ(WEAK) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].width | EQ(WEAK) | (f64::from(v * dest_area.width) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].width + | EQ(WEAK) + | (f64::from(dest_area.width) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].width | GE(WEAK) | f64::from(v), + Constraint::Max(v) => elements[i].width | LE(WEAK) | f64::from(v), + }); + } + } + Direction::Vertical => { + for pair in elements.windows(2) { + ccs.push((pair[0].y + pair[0].height) | EQ(REQUIRED) | pair[1].y); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x)); + ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].height | EQ(WEAK) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].height | EQ(WEAK) | (f64::from(v * dest_area.height) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].height + | EQ(WEAK) + | (f64::from(dest_area.height) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].height | GE(WEAK) | f64::from(v), + Constraint::Max(v) => elements[i].height | LE(WEAK) | f64::from(v), + }); + } + } + } + solver.add_constraints(&ccs).unwrap(); + for &(var, value) in solver.fetch_changes() { + let (index, attr) = vars[&var]; + let value = if value.is_sign_negative() { + 0 + } else { + value as u16 + }; + match attr { + 0 => { + results[index].x = value; + } + 1 => { + results[index].y = value; + } + 2 => { + results[index].width = value; + } + 3 => { + results[index].height = value; + } + _ => {} + } + } + + // Fix imprecision by extending the last item a bit if necessary + if let Some(last) = results.last_mut() { + match layout.direction { + Direction::Vertical => { + last.height = dest_area.bottom() - last.y; + } + Direction::Horizontal => { + last.width = dest_area.right() - last.x; + } + } + } + results +} + +/// A container used by the solver inside split +struct Element { + x: Variable, + y: Variable, + width: Variable, + height: Variable, +} + +impl Element { + fn new() -> Element { + Element { + x: Variable::new(), + y: Variable::new(), + width: Variable::new(), + height: Variable::new(), + } + } + + fn left(&self) -> Variable { + self.x + } + + fn top(&self) -> Variable { + self.y + } + + fn right(&self) -> Expression { + self.x + self.width + } + + fn bottom(&self) -> Expression { + self.y + self.height + } +} + +/// A simple rectangle used in the computation of the layout and to give widgets an hint about the +/// area they are supposed to render to. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub struct Rect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl Default for Rect { + fn default() -> Rect { + Rect { + x: 0, + y: 0, + width: 0, + height: 0, + } + } +} + +impl Rect { + /// Creates a new rect, with width and height limited to keep the area under max u16. + /// If clipped, aspect ratio will be preserved. + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect { + let max_area = u16::max_value(); + let (clipped_width, clipped_height) = + if u32::from(width) * u32::from(height) > u32::from(max_area) { + let aspect_ratio = f64::from(width) / f64::from(height); + let max_area_f = f64::from(max_area); + let height_f = (max_area_f / aspect_ratio).sqrt(); + let width_f = height_f * aspect_ratio; + (width_f as u16, height_f as u16) + } else { + (width, height) + }; + Rect { + x, + y, + width: clipped_width, + height: clipped_height, + } + } + + pub fn area(self) -> u16 { + self.width * self.height + } + + pub fn left(self) -> u16 { + self.x + } + + pub fn right(self) -> u16 { + self.x.saturating_add(self.width) + } + + pub fn top(self) -> u16 { + self.y + } + + pub fn bottom(self) -> u16 { + self.y.saturating_add(self.height) + } + + pub fn inner(self, margin: &Margin) -> Rect { + if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical { + Rect::default() + } else { + Rect { + x: self.x + margin.horizontal, + y: self.y + margin.vertical, + width: self.width - 2 * margin.horizontal, + height: self.height - 2 * margin.vertical, + } + } + } + + pub fn union(self, other: Rect) -> Rect { + let x1 = min(self.x, other.x); + let y1 = min(self.y, other.y); + let x2 = max(self.x + self.width, other.x + other.width); + let y2 = max(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersection(self, other: Rect) -> Rect { + let x1 = max(self.x, other.x); + let y1 = max(self.y, other.y); + let x2 = min(self.x + self.width, other.x + other.width); + let y2 = min(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersects(self, other: Rect) -> bool { + self.x < other.x + other.width + && self.x + self.width > other.x + && self.y < other.y + other.height + && self.y + self.height > other.y + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vertical_split_by_height() { + let target = Rect { + x: 2, + y: 2, + width: 10, + height: 10, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(10), + Constraint::Max(5), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(target); + + assert_eq!(target.height, chunks.iter().map(|r| r.height).sum::<u16>()); + chunks.windows(2).for_each(|w| assert!(w[0].y <= w[1].y)); + } + + #[test] + fn test_rect_size_truncation() { + for width in 256u16..300u16 { + for height in 256u16..300u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert!(rect.width < width || rect.height < height); + // The target dimensions are rounded down so the math will not be too precise + // but let's make sure the ratios don't diverge crazily. + assert!( + (f64::from(rect.width) / f64::from(rect.height) + - f64::from(width) / f64::from(height)) + .abs() + < 1.0 + ) + } + } + + // One dimension below 255, one above. Area above max u16. + let width = 900; + let height = 100; + let rect = Rect::new(0, 0, width, height); + assert_ne!(rect.width, 900); + assert_ne!(rect.height, 100); + assert!(rect.width < width || rect.height < height); + } + + #[test] + fn test_rect_size_preservation() { + for width in 0..256u16 { + for height in 0..256u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert_eq!(rect.width, width); + assert_eq!(rect.height, height); + } + } + + // One dimension below 255, one above. Area below max u16. + let rect = Rect::new(0, 0, 300, 100); + assert_eq!(rect.width, 300); + assert_eq!(rect.height, 100); + } +} diff --git a/helix-tui/src/lib.rs b/helix-tui/src/lib.rs new file mode 100644 index 00000000..5f59fd3d --- /dev/null +++ b/helix-tui/src/lib.rs @@ -0,0 +1,134 @@ +//! [tui](https://github.com/fdehau/tui-rs) is a library used to build rich +//! terminal users interfaces and dashboards. +//! +//! ![](https://raw.githubusercontent.com/fdehau/tui-rs/master/assets/demo.gif) +//! +//! # Get started +//! +//! ## Adding `tui` as a dependency +//! +//! ```toml +//! [dependencies] +//! tui = "0.15" +//! crossterm = "0.19" +//! ``` +//! +//! The same logic applies for all other available backends. +//! +//! ## Creating a `Terminal` +//! +//! Every application using `tui` should start by instantiating a `Terminal`. It is a light +//! abstraction over available backends that provides basic functionalities such as clearing the +//! screen, hiding the cursor, etc. +//! +//! ```rust,no_run +//! use std::io; +//! use helix_tui::Terminal; +//! use helix_tui::backend::CrosstermBackend; +//! +//! fn main() -> Result<(), io::Error> { +//! let stdout = io::stdout(); +//! let backend = CrosstermBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! Ok(()) +//! } +//! ``` +//! +//! You may also refer to the examples to find out how to create a `Terminal` for each available +//! backend. +//! +//! ## Building a User Interface (UI) +//! +//! Every component of your interface will be implementing the `Widget` trait. The library comes +//! with a predefined set of widgets that should meet most of your use cases. You are also free to +//! implement your own. +//! +//! Each widget follows a builder pattern API providing a default configuration along with methods +//! to customize them. The widget is then rendered using the [`Frame::render_widget`] which take +//! your widget instance an area to draw to. +//! +//! The following example renders a block of the size of the terminal: +//! +//! ```rust,no_run +//! use std::io; +//! use crossterm::terminal; +//! use helix_tui::Terminal; +//! use helix_tui::backend::CrosstermBackend; +//! use helix_tui::widgets::{Widget, Block, Borders}; +//! use helix_tui::layout::{Layout, Constraint, Direction}; +//! +//! fn main() -> Result<(), io::Error> { +//! terminal::enable_raw_mode()?; +//! let stdout = io::stdout(); +//! let backend = CrosstermBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! terminal.draw(|f| { +//! let size = f.size(); +//! let block = Block::default() +//! .title("Block") +//! .borders(Borders::ALL); +//! f.render_widget(block, size); +//! })?; +//! Ok(()) +//! } +//! ``` +//! +//! ## Layout +//! +//! The library comes with a basic yet useful layout management object called `Layout`. As you may +//! see below and in the examples, the library makes heavy use of the builder pattern to provide +//! full customization. And `Layout` is no exception: +//! +//! ```rust,no_run +//! use std::io; +//! use crossterm::terminal; +//! use helix_tui::Terminal; +//! use helix_tui::backend::CrosstermBackend; +//! use helix_tui::widgets::{Widget, Block, Borders}; +//! use helix_tui::layout::{Layout, Constraint, Direction}; +//! +//! fn main() -> Result<(), io::Error> { +//! terminal::enable_raw_mode()?; +//! let stdout = io::stdout(); +//! let backend = CrosstermBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! terminal.draw(|f| { +//! let chunks = Layout::default() +//! .direction(Direction::Vertical) +//! .margin(1) +//! .constraints( +//! [ +//! Constraint::Percentage(10), +//! Constraint::Percentage(80), +//! Constraint::Percentage(10) +//! ].as_ref() +//! ) +//! .split(f.size()); +//! let block = Block::default() +//! .title("Block") +//! .borders(Borders::ALL); +//! f.render_widget(block, chunks[0]); +//! let block = Block::default() +//! .title("Block 2") +//! .borders(Borders::ALL); +//! f.render_widget(block, chunks[1]); +//! })?; +//! Ok(()) +//! } +//! ``` +//! +//! This let you describe responsive terminal UI by nesting layouts. You should note that by +//! default the computed layout tries to fill the available space completely. So if for any reason +//! you might need a blank space somewhere, try to pass an additional constraint and don't use the +//! corresponding area. + +pub mod backend; +pub mod buffer; +pub mod layout; +pub mod style; +pub mod symbols; +pub mod terminal; +pub mod text; +pub mod widgets; + +pub use self::terminal::{Terminal, TerminalOptions, Viewport}; diff --git a/helix-tui/src/style.rs b/helix-tui/src/style.rs new file mode 100644 index 00000000..f322d304 --- /dev/null +++ b/helix-tui/src/style.rs @@ -0,0 +1,281 @@ +//! `style` contains the primitives used to control how your user interface will look. + +use bitflags::bitflags; + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Color { + Reset, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, + Rgb(u8, u8, u8), + Indexed(u8), +} + +bitflags! { + /// Modifier changes the way a piece of text is displayed. + /// + /// They are bitflags so they can easily be composed. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::style::Modifier; + /// + /// let m = Modifier::BOLD | Modifier::ITALIC; + /// ``` + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct Modifier: u16 { + const BOLD = 0b0000_0000_0001; + const DIM = 0b0000_0000_0010; + const ITALIC = 0b0000_0000_0100; + const UNDERLINED = 0b0000_0000_1000; + const SLOW_BLINK = 0b0000_0001_0000; + const RAPID_BLINK = 0b0000_0010_0000; + const REVERSED = 0b0000_0100_0000; + const HIDDEN = 0b0000_1000_0000; + const CROSSED_OUT = 0b0001_0000_0000; + } +} + +/// Style let you control the main characteristics of the displayed elements. +/// +/// ```rust +/// # use helix_tui::style::{Color, Modifier, Style}; +/// Style::default() +/// .fg(Color::Black) +/// .bg(Color::Green) +/// .add_modifier(Modifier::ITALIC | Modifier::BOLD); +/// ``` +/// +/// It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the +/// terminal buffer, the style of this cell will be the result of the merge of S1, S2 and S3, not +/// just S3. +/// +/// ```rust +/// # use helix_tui::style::{Color, Modifier, Style}; +/// # use helix_tui::buffer::Buffer; +/// # use helix_tui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::default().bg(Color::Red), +/// Style::default().fg(Color::Yellow).remove_modifier(Modifier::ITALIC), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Red), +/// add_modifier: Modifier::BOLD, +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +/// +/// The default implementation returns a `Style` that does not modify anything. If you wish to +/// reset all properties until that point use [`Style::reset`]. +/// +/// ``` +/// # use helix_tui::style::{Color, Modifier, Style}; +/// # use helix_tui::buffer::Buffer; +/// # use helix_tui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::reset().fg(Color::Yellow), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Reset), +/// add_modifier: Modifier::empty(), +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Style { + pub fg: Option<Color>, + pub bg: Option<Color>, + pub add_modifier: Modifier, + pub sub_modifier: Modifier, +} + +impl Default for Style { + fn default() -> Style { + Style { + fg: None, + bg: None, + add_modifier: Modifier::empty(), + sub_modifier: Modifier::empty(), + } + } +} + +impl Style { + /// Returns a `Style` resetting all properties. + pub fn reset() -> Style { + Style { + fg: Some(Color::Reset), + bg: Some(Color::Reset), + add_modifier: Modifier::empty(), + sub_modifier: Modifier::all(), + } + } + + /// Changes the foreground color. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::style::{Color, Style}; + /// let style = Style::default().fg(Color::Blue); + /// let diff = Style::default().fg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().fg(Color::Red)); + /// ``` + pub fn fg(mut self, color: Color) -> Style { + self.fg = Some(color); + self + } + + /// Changes the background color. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::style::{Color, Style}; + /// let style = Style::default().bg(Color::Blue); + /// let diff = Style::default().bg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().bg(Color::Red)); + /// ``` + pub fn bg(mut self, color: Color) -> Style { + self.bg = Some(color); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it adds the given modifier to the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD); + /// let diff = Style::default().add_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD | Modifier::ITALIC); + /// assert_eq!(patched.sub_modifier, Modifier::empty()); + /// ``` + pub fn add_modifier(mut self, modifier: Modifier) -> Style { + self.sub_modifier.remove(modifier); + self.add_modifier.insert(modifier); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it removes the given modifier from the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC); + /// let diff = Style::default().remove_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD); + /// assert_eq!(patched.sub_modifier, Modifier::ITALIC); + /// ``` + pub fn remove_modifier(mut self, modifier: Modifier) -> Style { + self.add_modifier.remove(modifier); + self.sub_modifier.insert(modifier); + self + } + + /// Results in a combined style that is equivalent to applying the two individual styles to + /// a style one after the other. + /// + /// ## Examples + /// ``` + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style_1 = Style::default().fg(Color::Yellow); + /// let style_2 = Style::default().bg(Color::Red); + /// let combined = style_1.patch(style_2); + /// assert_eq!( + /// Style::default().patch(style_1).patch(style_2), + /// Style::default().patch(combined)); + /// ``` + pub fn patch(mut self, other: Style) -> Style { + self.fg = other.fg.or(self.fg); + self.bg = other.bg.or(self.bg); + + self.add_modifier.remove(other.sub_modifier); + self.add_modifier.insert(other.add_modifier); + self.sub_modifier.remove(other.add_modifier); + self.sub_modifier.insert(other.sub_modifier); + + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styles() -> Vec<Style> { + vec![ + Style::default(), + Style::default().fg(Color::Yellow), + Style::default().bg(Color::Yellow), + Style::default().add_modifier(Modifier::BOLD), + Style::default().remove_modifier(Modifier::BOLD), + Style::default().add_modifier(Modifier::ITALIC), + Style::default().remove_modifier(Modifier::ITALIC), + Style::default().add_modifier(Modifier::ITALIC | Modifier::BOLD), + Style::default().remove_modifier(Modifier::ITALIC | Modifier::BOLD), + ] + } + + #[test] + fn combined_patch_gives_same_result_as_individual_patch() { + let styles = styles(); + for &a in &styles { + for &b in &styles { + for &c in &styles { + for &d in &styles { + let combined = a.patch(b.patch(c.patch(d))); + + assert_eq!( + Style::default().patch(a).patch(b).patch(c).patch(d), + Style::default().patch(combined) + ); + } + } + } + } + } +} diff --git a/helix-tui/src/symbols.rs b/helix-tui/src/symbols.rs new file mode 100644 index 00000000..040e77f6 --- /dev/null +++ b/helix-tui/src/symbols.rs @@ -0,0 +1,233 @@ +pub mod block { + pub const FULL: &str = "█"; + pub const SEVEN_EIGHTHS: &str = "▉"; + pub const THREE_QUARTERS: &str = "▊"; + pub const FIVE_EIGHTHS: &str = "▋"; + pub const HALF: &str = "▌"; + pub const THREE_EIGHTHS: &str = "▍"; + pub const ONE_QUARTER: &str = "▎"; + pub const ONE_EIGHTH: &str = "▏"; + + #[derive(Debug, Clone)] + pub struct Set { + pub full: &'static str, + pub seven_eighths: &'static str, + pub three_quarters: &'static str, + pub five_eighths: &'static str, + pub half: &'static str, + pub three_eighths: &'static str, + pub one_quarter: &'static str, + pub one_eighth: &'static str, + pub empty: &'static str, + } + + pub const THREE_LEVELS: Set = Set { + full: FULL, + seven_eighths: FULL, + three_quarters: HALF, + five_eighths: HALF, + half: HALF, + three_eighths: HALF, + one_quarter: HALF, + one_eighth: " ", + empty: " ", + }; + + pub const NINE_LEVELS: Set = Set { + full: FULL, + seven_eighths: SEVEN_EIGHTHS, + three_quarters: THREE_QUARTERS, + five_eighths: FIVE_EIGHTHS, + half: HALF, + three_eighths: THREE_EIGHTHS, + one_quarter: ONE_QUARTER, + one_eighth: ONE_EIGHTH, + empty: " ", + }; +} + +pub mod bar { + pub const FULL: &str = "█"; + pub const SEVEN_EIGHTHS: &str = "▇"; + pub const THREE_QUARTERS: &str = "▆"; + pub const FIVE_EIGHTHS: &str = "▅"; + pub const HALF: &str = "▄"; + pub const THREE_EIGHTHS: &str = "▃"; + pub const ONE_QUARTER: &str = "▂"; + pub const ONE_EIGHTH: &str = "▁"; + + #[derive(Debug, Clone)] + pub struct Set { + pub full: &'static str, + pub seven_eighths: &'static str, + pub three_quarters: &'static str, + pub five_eighths: &'static str, + pub half: &'static str, + pub three_eighths: &'static str, + pub one_quarter: &'static str, + pub one_eighth: &'static str, + pub empty: &'static str, + } + + pub const THREE_LEVELS: Set = Set { + full: FULL, + seven_eighths: FULL, + three_quarters: HALF, + five_eighths: HALF, + half: HALF, + three_eighths: HALF, + one_quarter: HALF, + one_eighth: " ", + empty: " ", + }; + + pub const NINE_LEVELS: Set = Set { + full: FULL, + seven_eighths: SEVEN_EIGHTHS, + three_quarters: THREE_QUARTERS, + five_eighths: FIVE_EIGHTHS, + half: HALF, + three_eighths: THREE_EIGHTHS, + one_quarter: ONE_QUARTER, + one_eighth: ONE_EIGHTH, + empty: " ", + }; +} + +pub mod line { + pub const VERTICAL: &str = "│"; + pub const DOUBLE_VERTICAL: &str = "║"; + pub const THICK_VERTICAL: &str = "┃"; + + pub const HORIZONTAL: &str = "─"; + pub const DOUBLE_HORIZONTAL: &str = "═"; + pub const THICK_HORIZONTAL: &str = "━"; + + pub const TOP_RIGHT: &str = "┐"; + pub const ROUNDED_TOP_RIGHT: &str = "╮"; + pub const DOUBLE_TOP_RIGHT: &str = "╗"; + pub const THICK_TOP_RIGHT: &str = "┓"; + + pub const TOP_LEFT: &str = "┌"; + pub const ROUNDED_TOP_LEFT: &str = "╭"; + pub const DOUBLE_TOP_LEFT: &str = "╔"; + pub const THICK_TOP_LEFT: &str = "┏"; + + pub const BOTTOM_RIGHT: &str = "┘"; + pub const ROUNDED_BOTTOM_RIGHT: &str = "╯"; + pub const DOUBLE_BOTTOM_RIGHT: &str = "╝"; + pub const THICK_BOTTOM_RIGHT: &str = "┛"; + + pub const BOTTOM_LEFT: &str = "└"; + pub const ROUNDED_BOTTOM_LEFT: &str = "╰"; + pub const DOUBLE_BOTTOM_LEFT: &str = "╚"; + pub const THICK_BOTTOM_LEFT: &str = "┗"; + + pub const VERTICAL_LEFT: &str = "┤"; + pub const DOUBLE_VERTICAL_LEFT: &str = "╣"; + pub const THICK_VERTICAL_LEFT: &str = "┫"; + + pub const VERTICAL_RIGHT: &str = "├"; + pub const DOUBLE_VERTICAL_RIGHT: &str = "╠"; + pub const THICK_VERTICAL_RIGHT: &str = "┣"; + + pub const HORIZONTAL_DOWN: &str = "┬"; + pub const DOUBLE_HORIZONTAL_DOWN: &str = "╦"; + pub const THICK_HORIZONTAL_DOWN: &str = "┳"; + + pub const HORIZONTAL_UP: &str = "┴"; + pub const DOUBLE_HORIZONTAL_UP: &str = "╩"; + pub const THICK_HORIZONTAL_UP: &str = "┻"; + + pub const CROSS: &str = "┼"; + pub const DOUBLE_CROSS: &str = "╬"; + pub const THICK_CROSS: &str = "╋"; + + #[derive(Debug, Clone)] + pub struct Set { + pub vertical: &'static str, + pub horizontal: &'static str, + pub top_right: &'static str, + pub top_left: &'static str, + pub bottom_right: &'static str, + pub bottom_left: &'static str, + pub vertical_left: &'static str, + pub vertical_right: &'static str, + pub horizontal_down: &'static str, + pub horizontal_up: &'static str, + pub cross: &'static str, + } + + pub const NORMAL: Set = Set { + vertical: VERTICAL, + horizontal: HORIZONTAL, + top_right: TOP_RIGHT, + top_left: TOP_LEFT, + bottom_right: BOTTOM_RIGHT, + bottom_left: BOTTOM_LEFT, + vertical_left: VERTICAL_LEFT, + vertical_right: VERTICAL_RIGHT, + horizontal_down: HORIZONTAL_DOWN, + horizontal_up: HORIZONTAL_UP, + cross: CROSS, + }; + + pub const ROUNDED: Set = Set { + top_right: ROUNDED_TOP_RIGHT, + top_left: ROUNDED_TOP_LEFT, + bottom_right: ROUNDED_BOTTOM_RIGHT, + bottom_left: ROUNDED_BOTTOM_LEFT, + ..NORMAL + }; + + pub const DOUBLE: Set = Set { + vertical: DOUBLE_VERTICAL, + horizontal: DOUBLE_HORIZONTAL, + top_right: DOUBLE_TOP_RIGHT, + top_left: DOUBLE_TOP_LEFT, + bottom_right: DOUBLE_BOTTOM_RIGHT, + bottom_left: DOUBLE_BOTTOM_LEFT, + vertical_left: DOUBLE_VERTICAL_LEFT, + vertical_right: DOUBLE_VERTICAL_RIGHT, + horizontal_down: DOUBLE_HORIZONTAL_DOWN, + horizontal_up: DOUBLE_HORIZONTAL_UP, + cross: DOUBLE_CROSS, + }; + + pub const THICK: Set = Set { + vertical: THICK_VERTICAL, + horizontal: THICK_HORIZONTAL, + top_right: THICK_TOP_RIGHT, + top_left: THICK_TOP_LEFT, + bottom_right: THICK_BOTTOM_RIGHT, + bottom_left: THICK_BOTTOM_LEFT, + vertical_left: THICK_VERTICAL_LEFT, + vertical_right: THICK_VERTICAL_RIGHT, + horizontal_down: THICK_HORIZONTAL_DOWN, + horizontal_up: THICK_HORIZONTAL_UP, + cross: THICK_CROSS, + }; +} + +pub const DOT: &str = "•"; + +pub mod braille { + pub const BLANK: u16 = 0x2800; + pub const DOTS: [[u16; 2]; 4] = [ + [0x0001, 0x0008], + [0x0002, 0x0010], + [0x0004, 0x0020], + [0x0040, 0x0080], + ]; +} + +/// Marker to use when plotting data points +#[derive(Debug, Clone, Copy)] +pub enum Marker { + /// One point per cell in shape of dot + Dot, + /// One point per cell in shape of a block + Block, + /// Up to 8 points per cell + Braille, +} diff --git a/helix-tui/src/terminal.rs b/helix-tui/src/terminal.rs new file mode 100644 index 00000000..7346d66d --- /dev/null +++ b/helix-tui/src/terminal.rs @@ -0,0 +1,216 @@ +use crate::{backend::Backend, buffer::Buffer, layout::Rect}; +use std::io; + +#[derive(Debug, Clone, PartialEq)] +/// UNSTABLE +enum ResizeBehavior { + Fixed, + Auto, +} + +#[derive(Debug, Clone, PartialEq)] +/// UNSTABLE +pub struct Viewport { + area: Rect, + resize_behavior: ResizeBehavior, +} + +impl Viewport { + /// UNSTABLE + pub fn fixed(area: Rect) -> Viewport { + Viewport { + area, + resize_behavior: ResizeBehavior::Fixed, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +/// Options to pass to [`Terminal::with_options`] +pub struct TerminalOptions { + /// Viewport used to draw to the terminal + pub viewport: Viewport, +} + +/// Interface to the terminal backed by Termion +#[derive(Debug)] +pub struct Terminal<B> +where + B: Backend, +{ + backend: B, + /// Holds the results of the current and previous draw calls. The two are compared at the end + /// of each draw pass to output the necessary updates to the terminal + buffers: [Buffer; 2], + /// Index of the current buffer in the previous array + current: usize, + /// Whether the cursor is currently hidden + hidden_cursor: bool, + /// Viewport + viewport: Viewport, +} + +impl<B> Drop for Terminal<B> +where + B: Backend, +{ + fn drop(&mut self) { + // Attempt to restore the cursor state + if self.hidden_cursor { + if let Err(err) = self.show_cursor() { + eprintln!("Failed to show the cursor: {}", err); + } + } + } +} + +impl<B> Terminal<B> +where + B: Backend, +{ + /// Wrapper around Terminal initialization. Each buffer is initialized with a blank string and + /// default colors for the foreground and the background + pub fn new(backend: B) -> io::Result<Terminal<B>> { + let size = backend.size()?; + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport { + area: size, + resize_behavior: ResizeBehavior::Auto, + }, + }, + ) + } + + /// UNSTABLE + pub fn with_options(backend: B, options: TerminalOptions) -> io::Result<Terminal<B>> { + Ok(Terminal { + backend, + buffers: [ + Buffer::empty(options.viewport.area), + Buffer::empty(options.viewport.area), + ], + current: 0, + hidden_cursor: false, + viewport: options.viewport, + }) + } + + // /// Get a Frame object which provides a consistent view into the terminal state for rendering. + // pub fn get_frame(&mut self) -> Frame<B> { + // Frame { + // terminal: self, + // cursor_position: None, + // } + // } + + pub fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + pub fn backend(&self) -> &B { + &self.backend + } + + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + /// Obtains a difference between the previous and the current buffer and passes it to the + /// current backend for drawing. + pub fn flush(&mut self) -> io::Result<()> { + let previous_buffer = &self.buffers[1 - self.current]; + let current_buffer = &self.buffers[self.current]; + let updates = previous_buffer.diff(current_buffer); + self.backend.draw(updates.into_iter()) + } + + /// Updates the Terminal so that internal buffers match the requested size. Requested size will + /// be saved so the size can remain consistent when rendering. + /// This leads to a full clear of the screen. + pub fn resize(&mut self, area: Rect) -> io::Result<()> { + self.buffers[self.current].resize(area); + self.buffers[1 - self.current].resize(area); + self.viewport.area = area; + self.clear() + } + + /// Queries the backend for size and resizes if it doesn't match the previous size. + pub fn autoresize(&mut self) -> io::Result<()> { + if self.viewport.resize_behavior == ResizeBehavior::Auto { + let size = self.size()?; + if size != self.viewport.area { + self.resize(size)?; + } + }; + Ok(()) + } + + /// Synchronizes terminal size, calls the rendering closure, flushes the current internal state + /// and prepares for the next draw call. + pub fn draw(&mut self, cursor_position: Option<(u16, u16)>) -> io::Result<()> { + // // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets + // // and the terminal (if growing), which may OOB. + // self.autoresize()?; + + // let mut frame = self.get_frame(); + // f(&mut frame); + // // We can't change the cursor position right away because we have to flush the frame to + // // stdout first. But we also can't keep the frame around, since it holds a &mut to + // // Terminal. Thus, we're taking the important data out of the Frame and dropping it. + // let cursor_position = frame.cursor_position; + + // Draw to stdout + self.flush()?; + + match cursor_position { + None => self.hide_cursor()?, + Some((x, y)) => { + self.show_cursor()?; + self.set_cursor(x, y)?; + } + } + + // Swap buffers + self.buffers[1 - self.current].reset(); + self.current = 1 - self.current; + + // Flush + self.backend.flush()?; + Ok(()) + } + + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + self.backend.get_cursor() + } + + pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + self.backend.set_cursor(x, y) + } + + /// Clear the terminal and force a full redraw on the next draw call. + pub fn clear(&mut self) -> io::Result<()> { + self.backend.clear()?; + // Reset the back buffer to make sure the next update will redraw everything. + self.buffers[1 - self.current].reset(); + Ok(()) + } + + /// Queries the real size of the backend. + pub fn size(&self) -> io::Result<Rect> { + self.backend.size() + } +} diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs new file mode 100644 index 00000000..c671e918 --- /dev/null +++ b/helix-tui/src/text.rs @@ -0,0 +1,434 @@ +//! Primitives for styled text. +//! +//! A terminal UI is at its root a lot of strings. In order to make it accessible and stylish, +//! those strings may be associated to a set of styles. `tui` has three ways to represent them: +//! - A single line string where all graphemes have the same style is represented by a [`Span`]. +//! - A single line string where each grapheme may have its own style is represented by [`Spans`]. +//! - A multiple line string where each grapheme may have its own style is represented by a +//! [`Text`]. +//! +//! These types form a hierarchy: [`Spans`] is a collection of [`Span`] and each line of [`Text`] +//! is a [`Spans`]. +//! +//! Keep it mind that a lot of widgets will use those types to advertise what kind of string is +//! supported for their properties. Moreover, `tui` provides convenient `From` implementations so +//! that you can start by using simple `String` or `&str` and then promote them to the previous +//! primitives when you need additional styling capabilities. +//! +//! For example, for the [`crate::widgets::Block`] widget, all the following calls are valid to set +//! its `title` property (which is a [`Spans`] under the hood): +//! +//! ```rust +//! # use helix_tui::widgets::Block; +//! # use helix_tui::text::{Span, Spans}; +//! # use helix_tui::style::{Color, Style}; +//! // A simple string with no styling. +//! // Converted to Spans(vec![ +//! // Span { content: Cow::Borrowed("My title"), style: Style { .. } } +//! // ]) +//! let block = Block::default().title("My title"); +//! +//! // A simple string with a unique style. +//! // Converted to Spans(vec![ +//! // Span { content: Cow::Borrowed("My title"), style: Style { fg: Some(Color::Yellow), .. } +//! // ]) +//! let block = Block::default().title( +//! Span::styled("My title", Style::default().fg(Color::Yellow)) +//! ); +//! +//! // A string with multiple styles. +//! // Converted to Spans(vec![ +//! // Span { content: Cow::Borrowed("My"), style: Style { fg: Some(Color::Yellow), .. } }, +//! // Span { content: Cow::Borrowed(" title"), .. } +//! // ]) +//! let block = Block::default().title(vec![ +//! Span::styled("My", Style::default().fg(Color::Yellow)), +//! Span::raw(" title"), +//! ]); +//! ``` +use crate::style::Style; +use std::borrow::Cow; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A grapheme associated to a style. +#[derive(Debug, Clone, PartialEq)] +pub struct StyledGrapheme<'a> { + pub symbol: &'a str, + pub style: Style, +} + +/// A string where all graphemes have the same style. +#[derive(Debug, Clone, PartialEq)] +pub struct Span<'a> { + pub content: Cow<'a, str>, + pub style: Style, +} + +impl<'a> Span<'a> { + /// Create a span with no style. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::text::Span; + /// Span::raw("My text"); + /// Span::raw(String::from("My text")); + /// ``` + pub fn raw<T>(content: T) -> Span<'a> + where + T: Into<Cow<'a, str>>, + { + Span { + content: content.into(), + style: Style::default(), + } + } + + /// Create a span with a style. + /// + /// # Examples + /// + /// ```rust + /// # use helix_tui::text::Span; + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC); + /// Span::styled("My text", style); + /// Span::styled(String::from("My text"), style); + /// ``` + pub fn styled<T>(content: T, style: Style) -> Span<'a> + where + T: Into<Cow<'a, str>>, + { + Span { + content: content.into(), + style, + } + } + + /// Returns the width of the content held by this span. + pub fn width(&self) -> usize { + self.content.width() + } + + /// Returns an iterator over the graphemes held by this span. + /// + /// `base_style` is the [`Style`] that will be patched with each grapheme [`Style`] to get + /// the resulting [`Style`]. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::text::{Span, StyledGrapheme}; + /// # use helix_tui::style::{Color, Modifier, Style}; + /// # use std::iter::Iterator; + /// let style = Style::default().fg(Color::Yellow); + /// let span = Span::styled("Text", style); + /// let style = Style::default().fg(Color::Green).bg(Color::Black); + /// let styled_graphemes = span.styled_graphemes(style); + /// assert_eq!( + /// vec![ + /// StyledGrapheme { + /// symbol: "T", + /// style: Style { + /// fg: Some(Color::Yellow), + /// bg: Some(Color::Black), + /// add_modifier: Modifier::empty(), + /// sub_modifier: Modifier::empty(), + /// }, + /// }, + /// StyledGrapheme { + /// symbol: "e", + /// style: Style { + /// fg: Some(Color::Yellow), + /// bg: Some(Color::Black), + /// add_modifier: Modifier::empty(), + /// sub_modifier: Modifier::empty(), + /// }, + /// }, + /// StyledGrapheme { + /// symbol: "x", + /// style: Style { + /// fg: Some(Color::Yellow), + /// bg: Some(Color::Black), + /// add_modifier: Modifier::empty(), + /// sub_modifier: Modifier::empty(), + /// }, + /// }, + /// StyledGrapheme { + /// symbol: "t", + /// style: Style { + /// fg: Some(Color::Yellow), + /// bg: Some(Color::Black), + /// add_modifier: Modifier::empty(), + /// sub_modifier: Modifier::empty(), + /// }, + /// }, + /// ], + /// styled_graphemes.collect::<Vec<StyledGrapheme>>() + /// ); + /// ``` + pub fn styled_graphemes( + &'a self, + base_style: Style, + ) -> impl Iterator<Item = StyledGrapheme<'a>> { + UnicodeSegmentation::graphemes(self.content.as_ref(), true) + .map(move |g| StyledGrapheme { + symbol: g, + style: base_style.patch(self.style), + }) + .filter(|s| s.symbol != "\n") + } +} + +impl<'a> From<String> for Span<'a> { + fn from(s: String) -> Span<'a> { + Span::raw(s) + } +} + +impl<'a> From<&'a str> for Span<'a> { + fn from(s: &'a str) -> Span<'a> { + Span::raw(s) + } +} + +/// A string composed of clusters of graphemes, each with their own style. +#[derive(Debug, Clone, PartialEq)] +pub struct Spans<'a>(pub Vec<Span<'a>>); + +impl<'a> Default for Spans<'a> { + fn default() -> Spans<'a> { + Spans(Vec::new()) + } +} + +impl<'a> Spans<'a> { + /// Returns the width of the underlying string. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::text::{Span, Spans}; + /// # use helix_tui::style::{Color, Style}; + /// let spans = Spans::from(vec![ + /// Span::styled("My", Style::default().fg(Color::Yellow)), + /// Span::raw(" text"), + /// ]); + /// assert_eq!(7, spans.width()); + /// ``` + pub fn width(&self) -> usize { + self.0.iter().map(Span::width).sum() + } +} + +impl<'a> From<String> for Spans<'a> { + fn from(s: String) -> Spans<'a> { + Spans(vec![Span::from(s)]) + } +} + +impl<'a> From<&'a str> for Spans<'a> { + fn from(s: &'a str) -> Spans<'a> { + Spans(vec![Span::from(s)]) + } +} + +impl<'a> From<Vec<Span<'a>>> for Spans<'a> { + fn from(spans: Vec<Span<'a>>) -> Spans<'a> { + Spans(spans) + } +} + +impl<'a> From<Span<'a>> for Spans<'a> { + fn from(span: Span<'a>) -> Spans<'a> { + Spans(vec![span]) + } +} + +impl<'a> From<Spans<'a>> for String { + fn from(line: Spans<'a>) -> String { + line.0.iter().fold(String::new(), |mut acc, s| { + acc.push_str(s.content.as_ref()); + acc + }) + } +} + +/// A string split over multiple lines where each line is composed of several clusters, each with +/// their own style. +/// +/// A [`Text`], like a [`Span`], can be constructed using one of the many `From` implementations +/// or via the [`Text::raw`] and [`Text::styled`] methods. Helpfully, [`Text`] also implements +/// [`core::iter::Extend`] which enables the concatenation of several [`Text`] blocks. +/// +/// ```rust +/// # use helix_tui::text::Text; +/// # use helix_tui::style::{Color, Modifier, Style}; +/// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC); +/// +/// // An initial two lines of `Text` built from a `&str` +/// let mut text = Text::from("The first line\nThe second line"); +/// assert_eq!(2, text.height()); +/// +/// // Adding two more unstyled lines +/// text.extend(Text::raw("These are two\nmore lines!")); +/// assert_eq!(4, text.height()); +/// +/// // Adding a final two styled lines +/// text.extend(Text::styled("Some more lines\nnow with more style!", style)); +/// assert_eq!(6, text.height()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct Text<'a> { + pub lines: Vec<Spans<'a>>, +} + +impl<'a> Default for Text<'a> { + fn default() -> Text<'a> { + Text { lines: Vec::new() } + } +} + +impl<'a> Text<'a> { + /// Create some text (potentially multiple lines) with no style. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_tui::text::Text; + /// Text::raw("The first line\nThe second line"); + /// Text::raw(String::from("The first line\nThe second line")); + /// ``` + pub fn raw<T>(content: T) -> Text<'a> + where + T: Into<Cow<'a, str>>, + { + Text { + lines: match content.into() { + Cow::Borrowed(s) => s.lines().map(Spans::from).collect(), + Cow::Owned(s) => s.lines().map(|l| Spans::from(l.to_owned())).collect(), + }, + } + } + + /// Create some text (potentially multiple lines) with a style. + /// + /// # Examples + /// + /// ```rust + /// # use helix_tui::text::Text; + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC); + /// Text::styled("The first line\nThe second line", style); + /// Text::styled(String::from("The first line\nThe second line"), style); + /// ``` + pub fn styled<T>(content: T, style: Style) -> Text<'a> + where + T: Into<Cow<'a, str>>, + { + let mut text = Text::raw(content); + text.patch_style(style); + text + } + + /// Returns the max width of all the lines. + /// + /// ## Examples + /// + /// ```rust + /// use helix_tui::text::Text; + /// let text = Text::from("The first line\nThe second line"); + /// assert_eq!(15, text.width()); + /// ``` + pub fn width(&self) -> usize { + self.lines + .iter() + .map(Spans::width) + .max() + .unwrap_or_default() + } + + /// Returns the height. + /// + /// ## Examples + /// + /// ```rust + /// use helix_tui::text::Text; + /// let text = Text::from("The first line\nThe second line"); + /// assert_eq!(2, text.height()); + /// ``` + pub fn height(&self) -> usize { + self.lines.len() + } + + /// Apply a new style to existing text. + /// + /// # Examples + /// + /// ```rust + /// # use helix_tui::text::Text; + /// # use helix_tui::style::{Color, Modifier, Style}; + /// let style = Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC); + /// let mut raw_text = Text::raw("The first line\nThe second line"); + /// let styled_text = Text::styled(String::from("The first line\nThe second line"), style); + /// assert_ne!(raw_text, styled_text); + /// + /// raw_text.patch_style(style); + /// assert_eq!(raw_text, styled_text); + /// ``` + pub fn patch_style(&mut self, style: Style) { + for line in &mut self.lines { + for span in &mut line.0 { + span.style = span.style.patch(style); + } + } + } +} + +impl<'a> From<String> for Text<'a> { + fn from(s: String) -> Text<'a> { + Text::raw(s) + } +} + +impl<'a> From<&'a str> for Text<'a> { + fn from(s: &'a str) -> Text<'a> { + Text::raw(s) + } +} + +impl<'a> From<Span<'a>> for Text<'a> { + fn from(span: Span<'a>) -> Text<'a> { + Text { + lines: vec![Spans::from(span)], + } + } +} + +impl<'a> From<Spans<'a>> for Text<'a> { + fn from(spans: Spans<'a>) -> Text<'a> { + Text { lines: vec![spans] } + } +} + +impl<'a> From<Vec<Spans<'a>>> for Text<'a> { + fn from(lines: Vec<Spans<'a>>) -> Text<'a> { + Text { lines } + } +} + +impl<'a> IntoIterator for Text<'a> { + type Item = Spans<'a>; + type IntoIter = std::vec::IntoIter<Self::Item>; + + fn into_iter(self) -> Self::IntoIter { + self.lines.into_iter() + } +} + +impl<'a> Extend<Spans<'a>> for Text<'a> { + fn extend<T: IntoIterator<Item = Spans<'a>>>(&mut self, iter: T) { + self.lines.extend(iter); + } +} 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)]); + } +} diff --git a/helix-tui/tests/terminal.rs b/helix-tui/tests/terminal.rs new file mode 100644 index 00000000..4734dd9a --- /dev/null +++ b/helix-tui/tests/terminal.rs @@ -0,0 +1,36 @@ +use helix_tui::{ + backend::{Backend, TestBackend}, + layout::Rect, + widgets::Paragraph, + Terminal, +}; +use std::error::Error; + +#[test] +fn terminal_buffer_size_should_be_limited() { + let backend = TestBackend::new(400, 400); + let terminal = Terminal::new(backend).unwrap(); + let size = terminal.backend().size().unwrap(); + assert_eq!(size.width, 255); + assert_eq!(size.height, 255); +} + +// #[test] +// fn terminal_draw_returns_the_completed_frame() -> Result<(), Box<dyn Error>> { +// let backend = TestBackend::new(10, 10); +// let mut terminal = Terminal::new(backend)?; +// let frame = terminal.draw(|f| { +// let paragrah = Paragraph::new("Test"); +// f.render_widget(paragrah, f.size()); +// })?; +// assert_eq!(frame.buffer.get(0, 0).symbol, "T"); +// assert_eq!(frame.area, Rect::new(0, 0, 10, 10)); +// terminal.backend_mut().resize(8, 8); +// let frame = terminal.draw(|f| { +// let paragrah = Paragraph::new("test"); +// f.render_widget(paragrah, f.size()); +// })?; +// assert_eq!(frame.buffer.get(0, 0).symbol, "t"); +// assert_eq!(frame.area, Rect::new(0, 0, 8, 8)); +// Ok(()) +// } diff --git a/helix-tui/tests/widgets_block.rs b/helix-tui/tests/widgets_block.rs new file mode 100644 index 00000000..8aaf905b --- /dev/null +++ b/helix-tui/tests/widgets_block.rs @@ -0,0 +1,213 @@ +use helix_tui::{ + backend::TestBackend, + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + text::Span, + widgets::{Block, Borders}, + Terminal, +}; + +#[test] +fn widgets_block_renders() { + let backend = TestBackend::new(10, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let block = Block::default() + .title(Span::styled("Title", Style::default().fg(Color::LightBlue))) + .borders(Borders::ALL); + f.render_widget( + block, + Rect { + x: 0, + y: 0, + width: 8, + height: 8, + }, + ); + }) + .unwrap(); + let mut expected = Buffer::with_lines(vec![ + "┌Title─┐ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + " ", + " ", + ]); + for x in 1..=5 { + expected.get_mut(x, 0).set_fg(Color::LightBlue); + } + terminal.backend().assert_buffer(&expected); +} + +#[test] +fn widgets_block_renders_on_small_areas() { + let test_case = |block, area: Rect, expected| { + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + f.render_widget(block, area); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + let one_cell_test_cases = [ + (Borders::NONE, "T"), + (Borders::LEFT, "│"), + (Borders::TOP, "T"), + (Borders::RIGHT, "│"), + (Borders::BOTTOM, "T"), + (Borders::ALL, "┌"), + ]; + for (borders, symbol) in one_cell_test_cases.iter().cloned() { + test_case( + Block::default().title("Test").borders(borders), + Rect { + x: 0, + y: 0, + width: 0, + height: 0, + }, + Buffer::empty(Rect { + x: 0, + y: 0, + width: 0, + height: 0, + }), + ); + test_case( + Block::default().title("Test").borders(borders), + Rect { + x: 0, + y: 0, + width: 1, + height: 0, + }, + Buffer::empty(Rect { + x: 0, + y: 0, + width: 1, + height: 0, + }), + ); + test_case( + Block::default().title("Test").borders(borders), + Rect { + x: 0, + y: 0, + width: 0, + height: 1, + }, + Buffer::empty(Rect { + x: 0, + y: 0, + width: 0, + height: 1, + }), + ); + test_case( + Block::default().title("Test").borders(borders), + Rect { + x: 0, + y: 0, + width: 1, + height: 1, + }, + Buffer::with_lines(vec![symbol]), + ); + } + test_case( + Block::default().title("Test").borders(Borders::LEFT), + Rect { + x: 0, + y: 0, + width: 4, + height: 1, + }, + Buffer::with_lines(vec!["│Tes"]), + ); + test_case( + Block::default().title("Test").borders(Borders::RIGHT), + Rect { + x: 0, + y: 0, + width: 4, + height: 1, + }, + Buffer::with_lines(vec!["Tes│"]), + ); + test_case( + Block::default().title("Test").borders(Borders::RIGHT), + Rect { + x: 0, + y: 0, + width: 4, + height: 1, + }, + Buffer::with_lines(vec!["Tes│"]), + ); + test_case( + Block::default() + .title("Test") + .borders(Borders::LEFT | Borders::RIGHT), + Rect { + x: 0, + y: 0, + width: 4, + height: 1, + }, + Buffer::with_lines(vec!["│Te│"]), + ); + test_case( + Block::default().title("Test").borders(Borders::TOP), + Rect { + x: 0, + y: 0, + width: 4, + height: 1, + }, + Buffer::with_lines(vec!["Test"]), + ); + test_case( + Block::default().title("Test").borders(Borders::TOP), + Rect { + x: 0, + y: 0, + width: 5, + height: 1, + }, + Buffer::with_lines(vec!["Test─"]), + ); + test_case( + Block::default() + .title("Test") + .borders(Borders::LEFT | Borders::TOP), + Rect { + x: 0, + y: 0, + width: 5, + height: 1, + }, + Buffer::with_lines(vec!["┌Test"]), + ); + test_case( + Block::default() + .title("Test") + .borders(Borders::LEFT | Borders::TOP), + Rect { + x: 0, + y: 0, + width: 6, + height: 1, + }, + Buffer::with_lines(vec!["┌Test─"]), + ); +} diff --git a/helix-tui/tests/widgets_list.rs b/helix-tui/tests/widgets_list.rs new file mode 100644 index 00000000..e59accd8 --- /dev/null +++ b/helix-tui/tests/widgets_list.rs @@ -0,0 +1,88 @@ +use helix_tui::{ + backend::TestBackend, + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + symbols, + widgets::{Block, Borders, List, ListItem, ListState}, + Terminal, +}; + +#[test] +fn widgets_list_should_highlight_the_selected_item() { + let backend = TestBackend::new(10, 3); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = ListState::default(); + state.select(Some(1)); + terminal + .draw(|f| { + let size = f.size(); + let items = vec![ + ListItem::new("Item 1"), + ListItem::new("Item 2"), + ListItem::new("Item 3"), + ]; + let list = List::new(items) + .highlight_style(Style::default().bg(Color::Yellow)) + .highlight_symbol(">> "); + f.render_stateful_widget(list, size, &mut state); + }) + .unwrap(); + let mut expected = Buffer::with_lines(vec![" Item 1 ", ">> Item 2 ", " Item 3 "]); + for x in 0..10 { + expected.get_mut(x, 1).set_bg(Color::Yellow); + } + terminal.backend().assert_buffer(&expected); +} + +#[test] +fn widgets_list_should_truncate_items() { + let backend = TestBackend::new(10, 2); + let mut terminal = Terminal::new(backend).unwrap(); + + struct TruncateTestCase<'a> { + selected: Option<usize>, + items: Vec<ListItem<'a>>, + expected: Buffer, + } + + let cases = vec![ + // An item is selected + TruncateTestCase { + selected: Some(0), + items: vec![ + ListItem::new("A very long line"), + ListItem::new("A very long line"), + ], + expected: Buffer::with_lines(vec![ + format!(">> A ve{} ", symbols::line::VERTICAL), + format!(" A ve{} ", symbols::line::VERTICAL), + ]), + }, + // No item is selected + TruncateTestCase { + selected: None, + items: vec![ + ListItem::new("A very long line"), + ListItem::new("A very long line"), + ], + expected: Buffer::with_lines(vec![ + format!("A very {} ", symbols::line::VERTICAL), + format!("A very {} ", symbols::line::VERTICAL), + ]), + }, + ]; + for case in cases { + let mut state = ListState::default(); + state.select(case.selected); + terminal + .draw(|f| { + let list = List::new(case.items.clone()) + .block(Block::default().borders(Borders::RIGHT)) + .highlight_symbol(">> "); + f.render_stateful_widget(list, Rect::new(0, 0, 8, 2), &mut state); + }) + .unwrap(); + terminal.backend().assert_buffer(&case.expected); + } +} diff --git a/helix-tui/tests/widgets_paragraph.rs b/helix-tui/tests/widgets_paragraph.rs new file mode 100644 index 00000000..33d693d8 --- /dev/null +++ b/helix-tui/tests/widgets_paragraph.rs @@ -0,0 +1,220 @@ +use helix_tui::{ + backend::TestBackend, + buffer::Buffer, + layout::Alignment, + text::{Span, Spans, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, + Terminal, +}; + +const SAMPLE_STRING: &str = "The library is based on the principle of immediate rendering with \ + intermediate buffers. This means that at each new frame you should build all widgets that are \ + supposed to be part of the UI. While providing a great flexibility for rich and \ + interactive UI, this may introduce overhead for highly dynamic content."; + +#[test] +fn widgets_paragraph_can_wrap_its_content() { + let test_case = |alignment, expected| { + let backend = TestBackend::new(20, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let text = vec![Spans::from(SAMPLE_STRING)]; + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL)) + .alignment(alignment) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + test_case( + Alignment::Left, + Buffer::with_lines(vec![ + "┌──────────────────┐", + "│The library is │", + "│based on the │", + "│principle of │", + "│immediate │", + "│rendering with │", + "│intermediate │", + "│buffers. This │", + "│means that at each│", + "└──────────────────┘", + ]), + ); + test_case( + Alignment::Right, + Buffer::with_lines(vec![ + "┌──────────────────┐", + "│ The library is│", + "│ based on the│", + "│ principle of│", + "│ immediate│", + "│ rendering with│", + "│ intermediate│", + "│ buffers. This│", + "│means that at each│", + "└──────────────────┘", + ]), + ); + test_case( + Alignment::Center, + Buffer::with_lines(vec![ + "┌──────────────────┐", + "│ The library is │", + "│ based on the │", + "│ principle of │", + "│ immediate │", + "│ rendering with │", + "│ intermediate │", + "│ buffers. This │", + "│means that at each│", + "└──────────────────┘", + ]), + ); +} + +#[test] +fn widgets_paragraph_renders_double_width_graphemes() { + let backend = TestBackend::new(10, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + let s = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点では、"; + terminal + .draw(|f| { + let size = f.size(); + let text = vec![Spans::from(s)]; + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, size); + }) + .unwrap(); + + let expected = Buffer::with_lines(vec![ + "┌────────┐", + "│コンピュ│", + "│ータ上で│", + "│文字を扱│", + "│う場合、│", + "│典型的に│", + "│は文字に│", + "│よる通信│", + "│を行う場│", + "└────────┘", + ]); + terminal.backend().assert_buffer(&expected); +} + +#[test] +fn widgets_paragraph_renders_mixed_width_graphemes() { + let backend = TestBackend::new(10, 7); + let mut terminal = Terminal::new(backend).unwrap(); + + let s = "aコンピュータ上で文字を扱う場合、"; + terminal + .draw(|f| { + let size = f.size(); + let text = vec![Spans::from(s)]; + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, size); + }) + .unwrap(); + + let expected = Buffer::with_lines(vec![ + // The internal width is 8 so only 4 slots for double-width characters. + "┌────────┐", + "│aコンピ │", // Here we have 1 latin character so only 3 double-width ones can fit. + "│ュータ上│", + "│で文字を│", + "│扱う場合│", + "│、 │", + "└────────┘", + ]); + terminal.backend().assert_buffer(&expected); +} + +#[test] +fn widgets_paragraph_can_wrap_with_a_trailing_nbsp() { + let nbsp: &str = "\u{00a0}"; + let line = Spans::from(vec![Span::raw("NBSP"), Span::raw(nbsp)]); + let backend = TestBackend::new(20, 3); + let mut terminal = Terminal::new(backend).unwrap(); + let expected = Buffer::with_lines(vec![ + "┌──────────────────┐", + "│NBSP\u{00a0} │", + "└──────────────────┘", + ]); + terminal + .draw(|f| { + let size = f.size(); + + let paragraph = Paragraph::new(line).block(Block::default().borders(Borders::ALL)); + f.render_widget(paragraph, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); +} +#[test] +fn widgets_paragraph_can_scroll_horizontally() { + let test_case = |alignment, scroll, expected| { + let backend = TestBackend::new(20, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let text = Text::from( + "段落现在可以水平滚动了!\nParagraph can scroll horizontally!\nShort line", + ); + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL)) + .alignment(alignment) + .scroll(scroll); + f.render_widget(paragraph, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + test_case( + Alignment::Left, + (0, 7), + Buffer::with_lines(vec![ + "┌──────────────────┐", + "│在可以水平滚动了!│", + "│ph can scroll hori│", + "│ine │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└──────────────────┘", + ]), + ); + // only support Alignment::Left + test_case( + Alignment::Right, + (0, 7), + Buffer::with_lines(vec![ + "┌──────────────────┐", + "│段落现在可以水平滚│", + "│Paragraph can scro│", + "│ Short line│", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└──────────────────┘", + ]), + ); +} diff --git a/helix-tui/tests/widgets_table.rs b/helix-tui/tests/widgets_table.rs new file mode 100644 index 00000000..0e0b3003 --- /dev/null +++ b/helix-tui/tests/widgets_table.rs @@ -0,0 +1,717 @@ +use helix_tui::{ + backend::TestBackend, + buffer::Buffer, + layout::Constraint, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{Block, Borders, Cell, Row, Table, TableState}, + Terminal, +}; + +#[test] +fn widgets_table_column_spacing_can_be_changed() { + let test_case = |column_spacing, expected| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(&[ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(5), + ]) + .column_spacing(column_spacing); + f.render_widget(table, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + // no space between columns + test_case( + 0, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1Head2Head3 │", + "│ │", + "│Row11Row12Row13 │", + "│Row21Row22Row23 │", + "│Row31Row32Row33 │", + "│Row41Row42Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // one space between columns + test_case( + 1, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // enough space to just not hide the third column + test_case( + 6, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // enough space to hide part of the third column + test_case( + 7, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head│", + "│ │", + "│Row11 Row12 Row1│", + "│Row21 Row22 Row2│", + "│Row31 Row32 Row3│", + "│Row41 Row42 Row4│", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_columns_widths_can_use_fixed_length_constraints() { + let test_case = |widths, expected| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(widths); + f.render_widget(table, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + // columns of zero width show nothing + test_case( + &[ + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(0), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of 1 width trim + test_case( + &[ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│H H H │", + "│ │", + "│R R R │", + "│R R R │", + "│R R R │", + "│R R R │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of large width just before pushing a column off + test_case( + &[ + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(8), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_columns_widths_can_use_percentage_constraints() { + let test_case = |widths, expected| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(widths) + .column_spacing(0); + f.render_widget(table, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + // columns of zero width show nothing + test_case( + &[ + Constraint::Percentage(0), + Constraint::Percentage(0), + Constraint::Percentage(0), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of not enough width trims the data + test_case( + &[ + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(11), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│HeaHeaHea │", + "│ │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of large width just before pushing a column off + test_case( + &[ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(33), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // percentages summing to 100 should give equal widths + test_case( + &[Constraint::Percentage(50), Constraint::Percentage(50)], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 │", + "│ │", + "│Row11 Row12 │", + "│Row21 Row22 │", + "│Row31 Row32 │", + "│Row41 Row42 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_columns_widths_can_use_mixed_constraints() { + let test_case = |widths, expected| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(widths); + f.render_widget(table, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + // columns of zero width show nothing + test_case( + &[ + Constraint::Percentage(0), + Constraint::Length(0), + Constraint::Percentage(0), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of not enough width trims the data + test_case( + &[ + Constraint::Percentage(11), + Constraint::Length(20), + Constraint::Percentage(11), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Hea Head2 Hea│", + "│ │", + "│Row Row12 Row│", + "│Row Row22 Row│", + "│Row Row32 Row│", + "│Row Row42 Row│", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of large width just before pushing a column off + test_case( + &[ + Constraint::Percentage(33), + Constraint::Length(10), + Constraint::Percentage(33), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of large size (>100% total) hide the last column + test_case( + &[ + Constraint::Percentage(60), + Constraint::Length(10), + Constraint::Percentage(60), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 │", + "│ │", + "│Row11 Row12 │", + "│Row21 Row22 │", + "│Row31 Row32 │", + "│Row41 Row42 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_columns_widths_can_use_ratio_constraints() { + let test_case = |widths, expected| { + let backend = TestBackend::new(30, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .widths(widths) + .column_spacing(0); + f.render_widget(table, size); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + // columns of zero width show nothing + test_case( + &[ + Constraint::Ratio(0, 1), + Constraint::Ratio(0, 1), + Constraint::Ratio(0, 1), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of not enough width trims the data + test_case( + &[ + Constraint::Ratio(1, 9), + Constraint::Ratio(1, 9), + Constraint::Ratio(1, 9), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│HeaHeaHea │", + "│ │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│RowRowRow │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // columns of large width just before pushing a column off + test_case( + &[ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│Row31 Row32 Row33 │", + "│Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); + + // percentages summing to 100 should give equal widths + test_case( + &[Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 │", + "│ │", + "│Row11 Row12 │", + "│Row21 Row22 │", + "│Row31 Row32 │", + "│Row41 Row42 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_can_have_rows_with_multi_lines() { + let test_case = |state: &mut TableState, expected: Buffer| { + let backend = TestBackend::new(30, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]), + Row::new(vec!["Row21", "Row22", "Row23"]).height(2), + Row::new(vec!["Row31", "Row32", "Row33"]), + Row::new(vec!["Row41", "Row42", "Row43"]).height(2), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::ALL)) + .highlight_symbol(">> ") + .widths(&[ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(5), + ]) + .column_spacing(1); + f.render_stateful_widget(table, size, state); + }) + .unwrap(); + terminal.backend().assert_buffer(&expected); + }; + + let mut state = TableState::default(); + // no selection + test_case( + &mut state, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│Head1 Head2 Head3 │", + "│ │", + "│Row11 Row12 Row13 │", + "│Row21 Row22 Row23 │", + "│ │", + "│Row31 Row32 Row33 │", + "└────────────────────────────┘", + ]), + ); + + // select first + state.select(Some(0)); + test_case( + &mut state, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ Head1 Head2 Head3 │", + "│ │", + "│>> Row11 Row12 Row13 │", + "│ Row21 Row22 Row23 │", + "│ │", + "│ Row31 Row32 Row33 │", + "└────────────────────────────┘", + ]), + ); + + // select second (we don't show partially the 4th row) + state.select(Some(1)); + test_case( + &mut state, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ Head1 Head2 Head3 │", + "│ │", + "│ Row11 Row12 Row13 │", + "│>> Row21 Row22 Row23 │", + "│ │", + "│ Row31 Row32 Row33 │", + "└────────────────────────────┘", + ]), + ); + + // select 4th (we don't show partially the 1st row) + state.select(Some(3)); + test_case( + &mut state, + Buffer::with_lines(vec![ + "┌────────────────────────────┐", + "│ Head1 Head2 Head3 │", + "│ │", + "│ Row31 Row32 Row33 │", + "│>> Row41 Row42 Row43 │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]), + ); +} + +#[test] +fn widgets_table_can_have_elements_styled_individually() { + let backend = TestBackend::new(30, 4); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = TableState::default(); + state.select(Some(0)); + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![ + Row::new(vec!["Row11", "Row12", "Row13"]).style(Style::default().fg(Color::Green)), + Row::new(vec![ + Cell::from("Row21"), + Cell::from("Row22").style(Style::default().fg(Color::Yellow)), + Cell::from(Spans::from(vec![ + Span::raw("Row"), + Span::styled("23", Style::default().fg(Color::Blue)), + ])) + .style(Style::default().fg(Color::Red)), + ]) + .style(Style::default().fg(Color::LightGreen)), + ]) + .header(Row::new(vec!["Head1", "Head2", "Head3"]).bottom_margin(1)) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .highlight_symbol(">> ") + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .widths(&[ + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + ]) + .column_spacing(1); + f.render_stateful_widget(table, size, &mut state); + }) + .unwrap(); + + let mut expected = Buffer::with_lines(vec![ + "│ Head1 Head2 Head3 │", + "│ │", + "│>> Row11 Row12 Row13 │", + "│ Row21 Row22 Row23 │", + ]); + // First row = row color + highlight style + for col in 1..=28 { + expected.get_mut(col, 2).set_style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ); + } + // Second row: + // 1. row color + for col in 1..=28 { + expected + .get_mut(col, 3) + .set_style(Style::default().fg(Color::LightGreen)); + } + // 2. cell color + for col in 11..=16 { + expected + .get_mut(col, 3) + .set_style(Style::default().fg(Color::Yellow)); + } + for col in 18..=23 { + expected + .get_mut(col, 3) + .set_style(Style::default().fg(Color::Red)); + } + // 3. text color + for col in 21..=22 { + expected + .get_mut(col, 3) + .set_style(Style::default().fg(Color::Blue)); + } + terminal.backend().assert_buffer(&expected); +} + +#[test] +fn widgets_table_should_render_even_if_empty() { + let backend = TestBackend::new(30, 4); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let size = f.size(); + let table = Table::new(vec![]) + .header(Row::new(vec!["Head1", "Head2", "Head3"])) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .widths(&[ + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + ]) + .column_spacing(1); + f.render_widget(table, size); + }) + .unwrap(); + + let expected = Buffer::with_lines(vec![ + "│Head1 Head2 Head3 │", + "│ │", + "│ │", + "│ │", + ]); + + terminal.backend().assert_buffer(&expected); +} |