From b5398067348b947226e54a967bfa658ded042f29 Mon Sep 17 00:00:00 2001 From: EdJoPaTo Date: Fri, 30 Oct 2020 16:10:26 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + Cargo.toml | 22 +++ examples/example.rs | 98 +++++++++++++ examples/util/event.rs | 72 ++++++++++ examples/util/mod.rs | 63 +++++++++ src/flatten.rs | 124 +++++++++++++++++ src/identifier.rs | 35 +++++ src/lib.rs | 308 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 724 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 examples/example.rs create mode 100644 examples/util/event.rs create mode 100644 examples/util/mod.rs create mode 100644 src/flatten.rs create mode 100644 src/identifier.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8441c1a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tui-tree-widget" +version = "0.1.0" +authors = ["EdJoPaTo "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +unicode-width = "0.1" + +[dependencies.tui] +version = "0.12" +default-features = false + +[dev-dependencies] +termion = "1.5" + +[dev-dependencies.tui] +version = "0.12" +default-features = false +features = ["termion"] diff --git a/examples/example.rs b/examples/example.rs new file mode 100644 index 0000000..e2927fb --- /dev/null +++ b/examples/example.rs @@ -0,0 +1,98 @@ +mod util; + +use crate::util::{ + event::{Event, Events}, + StatefulTree, +}; +use std::{error::Error, io}; +use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; +use tui::{ + backend::TermionBackend, + style::{Color, Modifier, Style}, + widgets::{Block, Borders}, + Terminal, +}; + +use tui_tree_widget::{Tree, TreeItem}; + +struct App<'a> { + tree: StatefulTree<'a>, +} + +impl<'a> App<'a> { + fn new() -> App<'a> { + App { + tree: StatefulTree::with_items(vec![ + TreeItem::new_leaf("a"), + TreeItem::new( + "b", + vec![ + TreeItem::new_leaf("c"), + TreeItem::new("d", vec![TreeItem::new_leaf("e"), TreeItem::new_leaf("f")]), + TreeItem::new_leaf("g"), + ], + ), + TreeItem::new_leaf("h"), + ]), + } + } +} + +fn main() -> Result<(), Box> { + // Terminal initialization + let stdout = io::stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let events = Events::new(); + + // App + let mut app = App::new(); + + loop { + terminal.draw(|f| { + let area = f.size(); + + let items = Tree::new(app.tree.items.to_vec()) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Tree Widget {:?}", app.tree.state)), + ) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + f.render_stateful_widget(items, area, &mut app.tree.state); + })?; + + match events.next()? { + Event::Input(input) => match input { + Key::Char('q') => { + break; + } + Key::Left => { + app.tree.close(); + } + Key::Right => { + app.tree.open(); + } + Key::Down => { + app.tree.next(); + } + Key::Up => { + app.tree.previous(); + } + _ => {} + }, + Event::Tick => {} + } + } + + Ok(()) +} diff --git a/examples/util/event.rs b/examples/util/event.rs new file mode 100644 index 0000000..639abf2 --- /dev/null +++ b/examples/util/event.rs @@ -0,0 +1,72 @@ +use std::io; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use termion::event::Key; +use termion::input::TermRead; + +pub enum Event { + Input(I), + Tick, +} + +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +#[allow(dead_code)] +pub struct Events { + rx: mpsc::Receiver>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub exit_key: Key, + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Config { + Config { + exit_key: Key::Char('q'), + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = mpsc::channel(); + { + let tx = tx.clone(); + thread::spawn(move || { + let stdin = io::stdin(); + for evt in stdin.keys() { + if let Ok(key) = evt { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{}", err); + return; + } + if key == config.exit_key { + return; + } + } + } + }) + }; + thread::spawn(move || loop { + if tx.send(Event::Tick).is_err() { + break; + } + thread::sleep(config.tick_rate); + }); + Events { rx } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } +} diff --git a/examples/util/mod.rs b/examples/util/mod.rs new file mode 100644 index 0000000..77250ee --- /dev/null +++ b/examples/util/mod.rs @@ -0,0 +1,63 @@ +pub mod event; + +use tui_tree_widget::{flatten, identifier, TreeItem, TreeState}; + +pub struct StatefulTree<'a> { + pub state: TreeState, + pub items: Vec>, +} + +impl<'a> StatefulTree<'a> { + #[allow(dead_code)] + pub fn new() -> StatefulTree<'a> { + StatefulTree { + state: TreeState::default(), + items: Vec::new(), + } + } + + pub fn with_items(items: Vec>) -> StatefulTree<'a> { + StatefulTree { + state: TreeState::default(), + items, + } + } + + fn move_up_down(&mut self, down: bool) { + let visible = flatten(&self.state.opened, &self.items); + let current_identifier = self.state.selected(); + let current_index = visible + .iter() + .position(|o| o.identifier == current_identifier); + let new_index = current_index.map_or(0, |current_index| { + if down { + current_index.saturating_add(1) + } else { + current_index.saturating_sub(1) + } + .min(visible.len() - 1) + }); + let new_identifier = visible.get(new_index).unwrap().identifier.to_owned(); + self.state.select(new_identifier); + } + + pub fn next(&mut self) { + self.move_up_down(true); + } + + pub fn previous(&mut self) { + self.move_up_down(false); + } + + pub fn close(&mut self) { + let selected = self.state.selected(); + if !self.state.close(&selected) { + let (head, _) = identifier::get_without_leaf(&selected); + self.state.select(head); + } + } + + pub fn open(&mut self) { + self.state.open(self.state.selected()); + } +} diff --git a/src/flatten.rs b/src/flatten.rs new file mode 100644 index 0000000..136ab9f --- /dev/null +++ b/src/flatten.rs @@ -0,0 +1,124 @@ +#![allow(clippy::implicit_hasher)] + +use crate::identifier::{TreeIdentifier, TreeIdentifierVec}; +use crate::TreeItem; +use std::collections::HashSet; + +pub struct Flattened<'a> { + pub identifier: Vec, + pub item: &'a TreeItem<'a>, +} + +impl<'a> Flattened<'a> { + pub fn depth(&self) -> usize { + self.identifier.len() - 1 + } +} + +pub fn flatten<'a>( + opened: &HashSet, + items: &'a [TreeItem<'a>], +) -> Vec> { + internal(opened, items, &[]) +} + +fn internal<'a>( + opened: &HashSet, + items: &'a [TreeItem<'a>], + current: TreeIdentifier, +) -> Vec> { + let mut result = Vec::new(); + + for (index, item) in items.iter().enumerate() { + let mut child_identifier = current.to_vec(); + child_identifier.push(index); + + result.push(Flattened { + item, + identifier: child_identifier.to_vec(), + }); + + if opened.contains(&child_identifier) { + let mut child_result = internal(opened, &item.children, &child_identifier); + result.append(&mut child_result); + } + } + + result +} + +#[cfg(test)] +fn get_naive_string_from_text<'a>(text: &tui::text::Text<'a>) -> String { + text.lines + .first() + .unwrap() + .0 + .first() + .unwrap() + .content + .to_string() +} + +#[cfg(test)] +fn get_example_tree_items<'a>() -> Vec> { + vec![ + TreeItem::new_leaf("a"), + TreeItem::new( + "b", + vec![ + TreeItem::new_leaf("c"), + TreeItem::new("d", vec![TreeItem::new_leaf("e"), TreeItem::new_leaf("f")]), + TreeItem::new_leaf("g"), + ], + ), + TreeItem::new_leaf("h"), + ] +} + +#[test] +fn get_opened_nothing_opened_is_top_level() { + let items = get_example_tree_items(); + let opened = HashSet::new(); + let result = flatten(&opened, &items); + let result_text: Vec<_> = result + .iter() + .map(|o| get_naive_string_from_text(&o.item.text)) + .collect(); + assert_eq!(result_text, ["a", "b", "h"]); +} + +#[test] +fn get_opened_wrong_opened_is_only_top_level() { + let items = get_example_tree_items(); + let opened = [vec![0], vec![1, 1]].iter().cloned().collect(); + let result = flatten(&opened, &items); + let result_text: Vec<_> = result + .iter() + .map(|o| get_naive_string_from_text(&o.item.text)) + .collect(); + assert_eq!(result_text, ["a", "b", "h"]); +} + +#[test] +fn get_opened_one_is_opened() { + let items = get_example_tree_items(); + let opened = [vec![1]].iter().cloned().collect(); + let result = flatten(&opened, &items); + let result_text: Vec<_> = result + .iter() + .map(|o| get_naive_string_from_text(&o.item.text)) + .collect(); + assert_eq!(result_text, ["a", "b", "c", "d", "g", "h"]); +} + +#[test] +fn get_opened_all_opened() { + let items = get_example_tree_items(); + let opened = [vec![1], vec![1, 1]].iter().cloned().collect(); + let result = flatten(&opened, &items); + let result_text: Vec<_> = result + .iter() + .map(|o| get_naive_string_from_text(&o.item.text)) + .collect(); + assert_eq!(result_text, ["a", "b", "c", "d", "e", "f", "g", "h"]); +} diff --git a/src/identifier.rs b/src/identifier.rs new file mode 100644 index 0000000..bdc5a11 --- /dev/null +++ b/src/identifier.rs @@ -0,0 +1,35 @@ +#![allow(clippy::module_name_repetitions)] + +pub type TreeIdentifier<'a> = &'a [usize]; +pub type TreeIdentifierVec = Vec; + +pub fn get_without_leaf(identifier: &[usize]) -> (&[usize], Option<&usize>) { + let length = identifier.len(); + let length_without_leaf = length.saturating_sub(1); + + let head = &identifier[0..length_without_leaf]; + let tail = identifier.get(length_without_leaf); + + (head, tail) +} + +#[test] +fn get_without_leaf_empty() { + let (head, tail) = get_without_leaf(&[]); + assert_eq!(head.len(), 0); + assert_eq!(tail, None); +} + +#[test] +fn get_without_leaf_single() { + let (head, tail) = get_without_leaf(&[2]); + assert_eq!(head.len(), 0); + assert_eq!(tail, Some(&2)); +} + +#[test] +fn get_without_leaf_multiple() { + let (head, tail) = get_without_leaf(&[2, 4, 6]); + assert_eq!(head, [2, 4]); + assert_eq!(tail, Some(&6)); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cedf0c7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,308 @@ +#![allow(clippy::must_use_candidate)] + +use crate::identifier::{TreeIdentifier, TreeIdentifierVec}; +use std::collections::HashSet; +use std::iter; +use tui::buffer::Buffer; +use tui::layout::{Corner, Rect}; +use tui::style::Style; +use tui::text::Text; +use tui::widgets::{Block, StatefulWidget, Widget}; +use unicode_width::UnicodeWidthStr; + +pub mod flatten; +pub mod identifier; + +pub use self::flatten::flatten; + +// https://docs.rs/tui/0.12.0/tui/widgets/index.html + +#[derive(Debug, Clone)] +pub struct TreeState { + offset: usize, + selected: TreeIdentifierVec, + pub opened: HashSet, +} + +impl Default for TreeState { + fn default() -> TreeState { + TreeState { + offset: 0, + selected: Vec::new(), + opened: HashSet::new(), + } + } +} + +impl TreeState { + pub fn selected(&self) -> Vec { + self.selected.to_owned() + } + + pub fn select(&mut self, identifier: I) + where + I: Into>, + { + self.selected = identifier.into(); + + // TODO: ListState does this. Is this relevant? + if self.selected.is_empty() { + self.offset = 0; + } + } + + pub fn open(&mut self, identifier: TreeIdentifierVec) -> bool { + if identifier.is_empty() { + false + } else { + self.opened.insert(identifier) + } + } + + pub fn close(&mut self, identifier: TreeIdentifier) -> bool { + self.opened.remove(identifier) + } + + pub fn close_all(&mut self) { + self.opened.clear(); + } +} + +#[derive(Debug, Clone)] +pub struct TreeItem<'a> { + text: Text<'a>, + style: Style, + children: Vec>, +} + +impl<'a> TreeItem<'a> { + pub fn new_leaf(text: T) -> TreeItem<'a> + where + T: Into>, + { + TreeItem { + text: text.into(), + style: Style::default(), + children: Vec::new(), + } + } + + pub fn new(text: T, children: Children) -> TreeItem<'a> + where + T: Into>, + Children: Into>>, + { + TreeItem { + text: text.into(), + style: Style::default(), + children: children.into(), + } + } + + pub fn height(&self) -> usize { + self.text.height() + } + + pub fn style(mut self, style: Style) -> TreeItem<'a> { + self.style = style; + self + } + + pub fn add_child(&mut self, child: TreeItem<'a>) { + self.children.push(child); + } +} + +#[derive(Debug, Clone)] +pub struct Tree<'a> { + block: Option>, + items: Vec>, + /// 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> Tree<'a> { + pub fn new(items: T) -> Tree<'a> + where + T: Into>>, + { + Tree { + 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>) -> Tree<'a> { + self.block = Some(block); + self + } + + pub fn style(mut self, style: Style) -> Tree<'a> { + self.style = style; + self + } + + pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Tree<'a> { + self.highlight_symbol = Some(highlight_symbol); + self + } + + pub fn highlight_style(mut self, style: Style) -> Tree<'a> { + self.highlight_style = style; + self + } + + pub fn start_corner(mut self, corner: Corner) -> Tree<'a> { + self.start_corner = corner; + self + } +} + +impl<'a> StatefulWidget for Tree<'a> { + type State = TreeState; + + #[allow(clippy::too_many_lines)] + fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + buf.set_style(area, self.style); + let area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + if area.width < 1 || area.height < 1 { + return; + } + + let visible = flatten(&state.opened, &self.items); + if visible.is_empty() { + return; + } + let available_height = area.height as usize; + + let selected_index = if state.selected.is_empty() { + 0 + } else { + visible + .iter() + // TODO: use longest possible path, it does not need to be exactly the same + .position(|o| o.identifier == state.selected) + .unwrap_or(0) + }; + + let mut start = state.offset.min(selected_index); + let mut end = start; + let mut height = 0; + for item in visible.iter().skip(start) { + if height + item.item.height() > available_height { + break; + } + + height += item.item.height(); + end += 1; + } + + while selected_index >= end { + height = height.saturating_add(visible[end].item.height()); + end += 1; + while height > available_height { + height = height.saturating_sub(visible[start].item.height()); + start += 1; + } + } + + state.offset = start; + + let highlight_symbol = self.highlight_symbol.unwrap_or(""); + let blank_symbol = iter::repeat(" ") + .take(highlight_symbol.width()) + .collect::(); + + let mut current_height = 0; + let has_selection = !state.selected.is_empty(); + #[allow(clippy::cast_possible_truncation)] + for item in visible.iter().skip(state.offset).take(end - start) { + #[allow(clippy::single_match_else)] // Keep same as List impl + let (x, y) = match self.start_corner { + Corner::BottomLeft => { + current_height += item.item.height() as u16; + (area.left(), area.bottom() - current_height) + } + _ => { + let pos = (area.left(), area.top() + current_height); + current_height += item.item.height() as u16; + pos + } + }; + let area = Rect { + x, + y, + width: area.width, + height: item.item.height() as u16, + }; + + let item_style = self.style.patch(item.item.style); + buf.set_style(area, item_style); + + let is_selected = state.selected == item.identifier; + let after_highlight_symbol_x = if has_selection { + let symbol = if is_selected { + highlight_symbol + } else { + &blank_symbol + }; + let (x, _) = buf.set_stringn(x, y, symbol, area.width as usize, item_style); + x + } else { + x + }; + + let after_depth_x = { + let symbol = if item.item.children.is_empty() { + " " + } else if state.opened.contains(&item.identifier) { + "\u{25bc}" // Arrow down + } else { + "\u{25b6}" // Arrow to right + }; + let string = format!("{:>width$}{} ", "", symbol, width = item.depth() * 3); + let max_width = area.width.saturating_sub(after_highlight_symbol_x - x); + let (x, _) = buf.set_stringn( + after_highlight_symbol_x, + y, + string, + max_width as usize, + item_style, + ); + x + }; + + let max_element_width = area.width.saturating_sub(after_depth_x - x); + for (j, line) in item.item.text.lines.iter().enumerate() { + buf.set_spans(after_depth_x, y + j as u16, line, max_element_width); + } + if is_selected { + buf.set_style(area, self.highlight_style); + } + } + } +} + +impl<'a> Widget for Tree<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut state = TreeState::default(); + StatefulWidget::render(self, area, buf, &mut state); + } +}