diff options
author | Sebastian Thiel <sthiel@thoughtworks.com> | 2019-06-05 17:46:28 +0530 |
---|---|---|
committer | Sebastian Thiel <sthiel@thoughtworks.com> | 2019-06-05 17:46:28 +0530 |
commit | 3aa9b0168425706b6bdfa4eb2b9335da24bc15fd (patch) | |
tree | f370b0e32ef7f852f087320e3cd76b5d8b78a6fd /tui-react | |
parent | 80ae2ac79c1525886c613452c835099eeae97c4d (diff) |
Add tui-react as library - it's proven (enough)...
...to be working and worth a slot on crates.io :D
Diffstat (limited to 'tui-react')
-rw-r--r-- | tui-react/Cargo.toml | 12 | ||||
-rw-r--r-- | tui-react/src/block.rs | 168 | ||||
-rw-r--r-- | tui-react/src/lib.rs | 7 | ||||
-rw-r--r-- | tui-react/src/list.rs | 89 | ||||
-rw-r--r-- | tui-react/src/terminal.rs | 176 |
5 files changed, 452 insertions, 0 deletions
diff --git a/tui-react/Cargo.toml b/tui-react/Cargo.toml new file mode 100644 index 0000000..11bfb4e --- /dev/null +++ b/tui-react/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tui-react" +version = "0.1.0" +authors = ["Sebastian Thiel <sthiel@thoughtworks.com>"] +edition = "2018" +repository = "https://github.com/Byron/dua-cli" +description = "TUI widgets using a react-like paradigm, allowing mutable component state and render properties." +license = "MIT" + +[dependencies] +tui = "0.6.0" +log = "0.4.6" diff --git a/tui-react/src/block.rs b/tui-react/src/block.rs new file mode 100644 index 0000000..ee6fece --- /dev/null +++ b/tui-react/src/block.rs @@ -0,0 +1,168 @@ +//! Derived from TUI-rs, license: MIT, Copyright (c) 2016 Florian Dehau +use super::ToplevelComponent; +use std::borrow::Borrow; +use std::marker::PhantomData; +use tui::{ + buffer::Buffer, layout::Rect, style::Color, style::Style, symbols::line, widgets::Borders, +}; + +pub fn fill_background(area: Rect, buf: &mut Buffer, color: Color) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + buf.get_mut(x, y).set_bg(color); + } + } +} + +#[derive(Clone, Copy, Default)] +pub struct Block<'a, T>(PhantomData<&'a T>); + +pub struct BlockProps<'a> { + /// Optional title place on the upper left of the block + pub title: Option<&'a str>, + /// Title style + pub title_style: Style, + /// Visible borders + pub borders: Borders, + /// Border style + pub border_style: Style, + /// Widget style + pub style: Style, +} + +impl<'a> Default for BlockProps<'a> { + fn default() -> BlockProps<'a> { + BlockProps { + title: None, + title_style: Default::default(), + borders: Borders::NONE, + border_style: Default::default(), + style: Default::default(), + } + } +} + +impl<'a> BlockProps<'a> { + /// Compute the inner area of a block based on its border visibility rules. + pub fn inner(&self, area: Rect) -> Rect { + if area.width < 2 || area.height < 2 { + return Rect::default(); + } + let mut inner = area; + if self.borders.intersects(Borders::LEFT) { + inner.x += 1; + inner.width -= 1; + } + if self.borders.intersects(Borders::TOP) || self.title.is_some() { + inner.y += 1; + inner.height -= 1; + } + if self.borders.intersects(Borders::RIGHT) { + inner.width -= 1; + } + if self.borders.intersects(Borders::BOTTOM) { + inner.height -= 1; + } + inner + } + + pub fn render(&self, area: Rect, buf: &mut Buffer) { + Block::<()>::default().render(self, area, buf); + } +} + +impl<'a, T> ToplevelComponent for Block<'a, T> { + type Props = BlockProps<'a>; + + fn render(&mut self, props: impl Borrow<Self::Props>, area: Rect, buf: &mut Buffer) { + if area.width < 2 || area.height < 2 { + return; + } + let BlockProps { + title, + title_style, + borders, + border_style, + style, + } = props.borrow(); + + fill_background(area, buf, style.bg); + + // Sides + if borders.intersects(Borders::LEFT) { + for y in area.top()..area.bottom() { + buf.get_mut(area.left(), y) + .set_symbol(line::VERTICAL) + .set_style(*border_style); + } + } + if borders.intersects(Borders::TOP) { + for x in area.left()..area.right() { + buf.get_mut(x, area.top()) + .set_symbol(line::HORIZONTAL) + .set_style(*border_style); + } + } + if borders.intersects(Borders::RIGHT) { + let x = area.right() - 1; + for y in area.top()..area.bottom() { + buf.get_mut(x, y) + .set_symbol(line::VERTICAL) + .set_style(*border_style); + } + } + if borders.intersects(Borders::BOTTOM) { + let y = area.bottom() - 1; + for x in area.left()..area.right() { + buf.get_mut(x, y) + .set_symbol(line::HORIZONTAL) + .set_style(*border_style); + } + } + + // Corners + if borders.contains(Borders::LEFT | Borders::TOP) { + buf.get_mut(area.left(), area.top()) + .set_symbol(line::TOP_LEFT) + .set_style(*border_style); + } + if borders.contains(Borders::RIGHT | Borders::TOP) { + buf.get_mut(area.right() - 1, area.top()) + .set_symbol(line::TOP_RIGHT) + .set_style(*border_style); + } + if borders.contains(Borders::LEFT | Borders::BOTTOM) { + buf.get_mut(area.left(), area.bottom() - 1) + .set_symbol(line::BOTTOM_LEFT) + .set_style(*border_style); + } + if borders.contains(Borders::RIGHT | Borders::BOTTOM) { + buf.get_mut(area.right() - 1, area.bottom() - 1) + .set_symbol(line::BOTTOM_RIGHT) + .set_style(*border_style); + } + + if area.width > 2 { + if let Some(title) = title { + let lx = if borders.intersects(Borders::LEFT) { + 1 + } else { + 0 + }; + let rx = if borders.intersects(Borders::RIGHT) { + 1 + } else { + 0 + }; + let width = area.width - lx - rx; + buf.set_stringn( + area.left() + lx, + area.top(), + title, + width as usize, + *title_style, + ); + } + } + } +} diff --git a/tui-react/src/lib.rs b/tui-react/src/lib.rs new file mode 100644 index 0000000..e8af82b --- /dev/null +++ b/tui-react/src/lib.rs @@ -0,0 +1,7 @@ +mod block; +mod list; +mod terminal; + +pub use block::*; +pub use list::*; +pub use terminal::*; diff --git a/tui-react/src/list.rs b/tui-react/src/list.rs new file mode 100644 index 0000000..5975a85 --- /dev/null +++ b/tui-react/src/list.rs @@ -0,0 +1,89 @@ +use super::BlockProps; +use std::borrow::Borrow; +use std::iter::repeat; +use tui::{ + buffer::Buffer, + layout::Rect, + widgets::{Paragraph, Text, Widget}, +}; + +pub fn fill_background_to_right(mut s: String, entire_width: u16) -> String { + match (s.len(), entire_width as usize) { + (x, y) if x >= y => s, + (x, y) => { + s.extend(repeat(' ').take(y - x)); + s + } + } +} + +#[derive(Default)] // TODO: remove Clone derive +pub struct ReactList { + /// The index at which the list last started. Used for scrolling + offset: usize, +} + +impl ReactList { + fn list_offset_for(&self, entry_in_view: Option<usize>, height: usize) -> usize { + match entry_in_view { + Some(pos) => match height as usize { + h if self.offset + h - 1 < pos => pos - h + 1, + _ if self.offset > pos => pos, + _ => self.offset, + }, + None => 0, + } + } +} + +#[derive(Default)] +pub struct ReactListProps<'b> { + pub block: Option<BlockProps<'b>>, + pub entry_in_view: Option<usize>, +} + +impl ReactList { + pub fn render<'a, 't>( + &mut self, + props: impl Borrow<ReactListProps<'a>>, + items: impl IntoIterator<Item = Vec<Text<'t>>>, + area: Rect, + buf: &mut Buffer, + ) { + let ReactListProps { + block, + entry_in_view, + } = props.borrow(); + + let list_area = match block { + Some(b) => { + b.render(area, buf); + b.inner(area) + } + None => area, + }; + self.offset = self.list_offset_for(*entry_in_view, list_area.height as usize); + + if list_area.width < 1 || list_area.height < 1 { + return; + } + + for (i, text_iterator) in items + .into_iter() + .skip(self.offset) + .enumerate() + .take(list_area.height as usize) + { + let (x, y) = (list_area.left(), list_area.top() + i as u16); + Paragraph::new(text_iterator.iter()).draw( + Rect { + x, + y, + width: list_area.width, + height: 1, + }, + buf, + ); + } + } +} diff --git a/tui-react/src/terminal.rs b/tui-react/src/terminal.rs new file mode 100644 index 0000000..bc7c37b --- /dev/null +++ b/tui-react/src/terminal.rs @@ -0,0 +1,176 @@ +//! Derived from TUI-rs, license: MIT, Copyright (c) 2016 Florian Dehau +use log::error; +use std::{borrow::Borrow, io}; + +use tui::{backend::Backend, buffer::Buffer, layout::Rect}; + +/// A component meant to be rendered by `Terminal::render(...)`. +/// All other components don't have to implement this trait, and instead +/// provide a render method by convention, tuned towards their needs using whichever +/// generic types or lifetimes they need. +pub trait ToplevelComponent { + type Props; + + fn render(&mut self, props: impl Borrow<Self::Props>, area: Rect, buf: &mut Buffer); +} + +#[derive(Debug)] +pub struct Terminal<B> +where + B: Backend, +{ + pub backend: B, + buffers: [Buffer; 2], + current: usize, + hidden_cursor: bool, + known_size: Rect, +} + +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() { + error!("Failed to show the cursor: {}", err); + } + } + } +} + +impl<B> Terminal<B> +where + B: Backend, +{ + pub fn new(backend: B) -> io::Result<Terminal<B>> { + let size = backend.size()?; + Ok(Terminal { + backend, + buffers: [Buffer::empty(size), Buffer::empty(size)], + current: 0, + hidden_cursor: false, + known_size: size, + }) + } + + pub fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + pub fn reconcile_and_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()) + } + + pub fn resize(&mut self, area: Rect) -> io::Result<()> { + self.buffers[self.current].resize(area); + self.buffers[1 - self.current].reset(); + self.buffers[1 - self.current].resize(area); + self.known_size = area; + self.backend.clear() + } + + pub fn autoresize(&mut self) -> io::Result<()> { + let size = self.size()?; + if self.known_size != size { + self.resize(size)?; + } + Ok(()) + } + + pub fn render<C>(&mut self, component: &mut C, props: impl Borrow<C::Props>) -> io::Result<()> + where + C: ToplevelComponent, + { + // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets + // and the terminal (if growing), which may OOB. + self.autoresize()?; + + component.render(props, self.known_size, self.current_buffer_mut()); + + self.reconcile_and_flush()?; + + self.buffers[1 - self.current].reset(); + self.current = 1 - self.current; + + 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(()) + } + #[allow(unused)] + pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + self.backend.get_cursor() + } + #[allow(unused)] + pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + self.backend.set_cursor(x, y) + } + #[allow(unused)] + pub fn clear(&mut self) -> io::Result<()> { + self.backend.clear() + } + pub fn size(&self) -> io::Result<Rect> { + self.backend.size() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tui::backend::TestBackend; + + #[derive(Default, Clone)] + struct ComplexProps { + x: usize, + y: String, + } + + #[derive(Default)] + struct StatefulComponent { + x: usize, + } + + #[derive(Default)] + struct StatelessComponent; + + impl ToplevelComponent for StatefulComponent { + type Props = usize; + + fn render(&mut self, props: impl Borrow<Self::Props>, _area: Rect, _buf: &mut Buffer) { + self.x += *props.borrow(); + } + } + + impl ToplevelComponent for StatelessComponent { + type Props = ComplexProps; + fn render(&mut self, _props: impl Borrow<Self::Props>, _area: Rect, _buf: &mut Buffer) { + // does not matter - we want to see it compiles essentially + } + } + + #[test] + fn it_does_render_with_simple_and_complex_props() { + let mut term = Terminal::new(TestBackend::new(20, 20)).unwrap(); + let mut c = StatefulComponent::default(); + + term.render(&mut c, 3usize).ok(); + assert_eq!(c.x, 3); + + let mut c = StatelessComponent::default(); + term.render(&mut c, ComplexProps::default()).ok(); + } +} |