Compare commits

...

10 Commits

Author SHA1 Message Date
4f289d1075 chore: Upgrade to Ratatui v0.30.0
Test Coverage / coverage (push) Has been cancelled
Rust / rustfmt (push) Has been cancelled
Rust / clippy (beta) (push) Has been cancelled
Rust / clippy (nightly) (push) Has been cancelled
Rust / clippy (stable) (push) Has been cancelled
Rust / features (macos-latest, beta) (push) Has been cancelled
Rust / features (macos-latest, stable) (push) Has been cancelled
Rust / features (ubuntu-latest, beta) (push) Has been cancelled
Rust / features (ubuntu-latest, stable) (push) Has been cancelled
Rust / features (windows-latest, beta) (push) Has been cancelled
Rust / features (windows-latest, stable) (push) Has been cancelled
Rust / test (macos-latest, beta) (push) Has been cancelled
Rust / test (macos-latest, nightly) (push) Has been cancelled
Rust / test (macos-latest, stable) (push) Has been cancelled
Rust / test (ubuntu-latest, beta) (push) Has been cancelled
Rust / test (ubuntu-latest, nightly) (push) Has been cancelled
Rust / test (ubuntu-latest, stable) (push) Has been cancelled
Rust / test (windows-latest, beta) (push) Has been cancelled
Rust / test (windows-latest, nightly) (push) Has been cancelled
Rust / test (windows-latest, stable) (push) Has been cancelled
Rust / Release aarch64-apple-darwin (push) Has been cancelled
Rust / Release x86_64-apple-darwin (push) Has been cancelled
Rust / Release aarch64-unknown-linux-gnu (push) Has been cancelled
Rust / Release arm-unknown-linux-gnueabihf (push) Has been cancelled
Rust / Release armv7-unknown-linux-gnueabihf (push) Has been cancelled
Rust / Release riscv64gc-unknown-linux-gnu (push) Has been cancelled
Rust / Release x86_64-unknown-linux-gnu (push) Has been cancelled
Rust / Release aarch64-pc-windows-msvc (push) Has been cancelled
Rust / Release x86_64-pc-windows-msvc (push) Has been cancelled
2026-01-07 17:07:14 -07:00
45523fa08e chore: Updated the README to have more detailed instructions on how to use the widget 2024-11-18 14:46:19 -07:00
Alex Clarke
6a324736e5 chore: Updated the README to have installation instructions since this crate is not published to crates.io 2024-11-18 13:14:33 -07:00
b0d8d9f0bf feat(TreeItem): Added PartialEq and Eq constraints to the types that can be passed to the TreeItem struct 2024-11-15 14:40:44 -07:00
959b60de32 style: Applied formatting to all files 2024-11-15 12:50:14 -07:00
6baef469c8 Fixed a typo in the README 2024-11-14 18:39:10 -07:00
6c10db760d fix: Changed the implementation to not require the direct use of identifiers, and to instead generate them using the hashes of the contents of each tree item 2024-11-14 17:35:40 -07:00
8a91f662dd fix: Changed the type constraints to use the ToText Ratatui trait 2024-11-14 16:38:38 -07:00
37d7b77f90 fix: Corrected doctests to pass 2024-11-14 16:04:39 -07:00
3e23a73f6b feat: Updated the Ratatui Tree Widget to allow the tree to story any data type that implements Into<Text>, so that users can also easily fetch data from within the tree based on what's selected for non-text data types. 2024-11-14 15:38:37 -07:00
8 changed files with 355 additions and 259 deletions
+13 -10
View File
@@ -1,15 +1,18 @@
[package]
name = "tui-tree-widget"
description = "Tree Widget for ratatui"
version = "0.23.0"
name = "managarr-tree-widget"
description = "Tree Widget for Managarr"
version = "0.25.0"
license = "MIT"
repository = "https://github.com/EdJoPaTo/tui-rs-tree-widget"
authors = ["EdJoPaTo <tui-tree-widget-rust-crate@edjopato.de>"]
repository = "https://github.com/Dark-Alex-17/managarr-tree-widget"
authors = [
"EdJoPaTo <tui-tree-widget-rust-crate@edjopato.de>",
"Dark-Alex-17 <alex.j.tusa@gmail.com>",
]
edition = "2021"
keywords = ["tui", "terminal", "tree", "widget"]
keywords = ["tui", "terminal", "tree", "widget", "managarr"]
categories = ["command-line-interface"]
rust-version = "1.82.0"
include = ["src/**/*", "examples/**/*", "benches/**/*", "README.md"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
unsafe_code = "forbid"
@@ -23,15 +26,15 @@ debug = true
lto = true
[dependencies]
ratatui = { version = "0.29", default-features = false }
ratatui = { version = "0.30", default-features = false }
unicode-width = "0.2"
[dev-dependencies]
criterion = "0.5"
ratatui = "0.29"
ratatui = "0.30"
[target.'cfg(target_family = "unix")'.dev-dependencies]
pprof = { version = "0.13", features = ["criterion", "flamegraph"] }
pprof = { version = "0.14.0", features = ["criterion", "flamegraph"] }
[[bench]]
name = "bench"
+78 -2
View File
@@ -1,7 +1,83 @@
# Ratatui Tree Widget
# Managarr Tree Widget
[Ratatui](https://docs.rs/ratatui) Widget built to show Tree Data structures.
![Screenshot](media/screenshot.png)
Built for the specific use case of [`mqttui`](https://github.com/EdJoPaTo/mqttui).
## Installation
Add this widget to your project using the following command:
```shell
cargo add managarr-tree-widget
```
## Running the example
To run the example widget, simply run:
```shell
cargo run --example example
```
## Usage
The following is an example of how to create a tree of strings (namely one like the one used in the [example](./examples/example.rs)):
```rust
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
let tree_items = vec![
TreeItem::new_leaf("Alfa"),
TreeItem::new(
"Bravo",
vec![
TreeItem::new_leaf("Charlie"),
TreeItem::new(
"Delta",
vec![TreeItem::new_leaf("Echo"), TreeItem::new_leaf("Foxtrot")],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("Golf"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("Hotel"),
];
let widget = Tree::new(&tree_items)
.expect("all item identifiers are unique")
.block(
Block::bordered()
.title("Tree Widget"),
)
.highlight_style(
Style::new()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
frame.render_stateful_widget(widget, area, &mut self.state);
}
```
This will generate the following tree structure:
```
┌── Alfa
├── Bravo
│ ├── Charlie
│ ├── Delta
│ │ ├── Echo
│ │ └── Foxtrot
│ └── Golf
└── Hotel
```
This example assumes the existence of a `self.state` field that is initialized with `TreeState::default()`. The `TreeItem` struct is used to create a tree of items, and the `Tree` struct is used to create the widget itself.
A more detailed and feature-complete example is available in the [example](./examples/example.rs) file.
## Credit
The original project for this widget is the [Ratatui Tree Widget](https://github.com/EdJoPaTo/tui-rs-tree-widget), which was purpose built for the specific use
case of [`mqttui`](https://github.com/EdJoPaTo/mqttui).
The updated version of the tree widget that allows more generic types is created by me, [Alex Clarke](https://github.com/Dark-Alex-17).
+30 -38
View File
@@ -1,70 +1,62 @@
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput};
use managarr_tree_widget::{Tree, TreeItem, TreeState};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidget;
use tui_tree_widget::{Tree, TreeItem, TreeState};
#[must_use]
fn example_items() -> Vec<TreeItem<'static, &'static str>> {
fn example_items() -> Vec<TreeItem<&'static str>> {
vec![
TreeItem::new_leaf("a", "Alfa"),
TreeItem::new_leaf("Alfa"),
TreeItem::new(
"b",
"Bravo",
vec![
TreeItem::new_leaf("c", "Charlie"),
TreeItem::new_leaf("Charlie"),
TreeItem::new(
"d",
"Delta",
vec![
TreeItem::new_leaf("e", "Echo"),
TreeItem::new_leaf("f", "Foxtrot"),
vec![TreeItem::new_leaf("Echo"), TreeItem::new_leaf("Foxtrot")],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("Golf"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("g", "Golf"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("h", "Hotel"),
TreeItem::new_leaf("Hotel"),
TreeItem::new(
"i",
"India",
vec![
TreeItem::new_leaf("j", "Juliett"),
TreeItem::new_leaf("k", "Kilo"),
TreeItem::new_leaf("l", "Lima"),
TreeItem::new_leaf("m", "Mike"),
TreeItem::new_leaf("n", "November"),
TreeItem::new_leaf("Juliet"),
TreeItem::new_leaf("Kilo"),
TreeItem::new_leaf("Lima"),
TreeItem::new_leaf("Mike"),
TreeItem::new_leaf("November"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("o", "Oscar"),
TreeItem::new_leaf("Oscar"),
TreeItem::new(
"p",
"Papa",
vec![
TreeItem::new_leaf("q", "Quebec"),
TreeItem::new_leaf("r", "Romeo"),
TreeItem::new_leaf("s", "Sierra"),
TreeItem::new_leaf("t", "Tango"),
TreeItem::new_leaf("u", "Uniform"),
TreeItem::new_leaf("Quebec"),
TreeItem::new_leaf("Romeo"),
TreeItem::new_leaf("Sierra"),
TreeItem::new_leaf("Tango"),
TreeItem::new_leaf("Uniform"),
TreeItem::new(
"v",
"Victor",
vec![
TreeItem::new_leaf("w", "Whiskey"),
TreeItem::new_leaf("x", "Xray"),
TreeItem::new_leaf("y", "Yankee"),
TreeItem::new_leaf("Whiskey"),
TreeItem::new_leaf("Xray"),
TreeItem::new_leaf("Yankee"),
],
)
.expect("all item identifiers are unique"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("z", "Zulu"),
TreeItem::new_leaf("Zulu"),
]
}
@@ -74,15 +66,15 @@ fn init(criterion: &mut Criterion) {
group.bench_function("empty", |bencher| {
bencher.iter(|| {
let items = vec![];
let _: Tree<usize> = black_box(Tree::new(black_box(&items))).unwrap();
let items = Vec::<TreeItem<String>>::new();
let _ = black_box(Tree::new(black_box(&items))).unwrap();
});
});
group.bench_function("example-items", |bencher| {
bencher.iter(|| {
let items = example_items();
let _: Tree<_> = black_box(Tree::new(black_box(&items))).unwrap();
let _ = black_box(Tree::new(black_box(&items))).unwrap();
});
});
@@ -96,7 +88,7 @@ fn renders(criterion: &mut Criterion) {
let buffer_size = Rect::new(0, 0, 100, 100);
group.bench_function("empty", |bencher| {
let items: Vec<TreeItem<usize>> = vec![];
let items: Vec<TreeItem<String>> = vec![];
let tree = Tree::new(&items).unwrap();
let mut state = TreeState::default();
bencher.iter_batched(
@@ -112,8 +104,8 @@ fn renders(criterion: &mut Criterion) {
let items = example_items();
let tree = Tree::new(&items).unwrap();
let mut state = TreeState::default();
state.open(vec!["b"]);
state.open(vec!["b", "d"]);
state.open(vec![2]);
state.open(vec![2, 4]);
bencher.iter_batched(
|| (tree.clone(), Buffer::empty(buffer_size)),
|(tree, mut buffer)| {
+44 -62
View File
@@ -1,18 +1,18 @@
use std::time::{Duration, Instant};
use managarr_tree_widget::{Tree, TreeItem, TreeState};
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind};
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Scrollbar, ScrollbarOrientation};
use ratatui::{crossterm, Frame, Terminal};
use tui_tree_widget::{Tree, TreeItem, TreeState};
use ratatui::{Frame, Terminal, crossterm};
#[must_use]
struct App {
state: TreeState<&'static str>,
items: Vec<TreeItem<&'static str, String>>,
state: TreeState,
items: Vec<TreeItem<&'static str>>,
}
impl App {
@@ -20,86 +20,60 @@ impl App {
Self {
state: TreeState::default(),
items: vec![
TreeItem::new_leaf("a", "Alfa".to_owned()),
TreeItem::new_leaf("Alfa"),
TreeItem::new(
"b",
"Bravo".to_owned(),
"Bravo",
vec![
TreeItem::new_leaf("c", "Charlie".to_owned()),
TreeItem::new_leaf("Charlie"),
TreeItem::new(
"d",
"Delta".to_owned(),
vec![
TreeItem::new_leaf("e", "Echo".to_owned()),
TreeItem::new_leaf("f", "Foxtrot".to_owned()),
"Delta",
vec![TreeItem::new_leaf("Echo"), TreeItem::new_leaf("Foxtrot")],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("Golf"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("g", "Golf".to_owned()),
TreeItem::new_leaf("Hotel"),
TreeItem::new(
"India",
vec![
TreeItem::new_leaf("Juliet"),
TreeItem::new_leaf("Kilo"),
TreeItem::new_leaf("Lima"),
TreeItem::new_leaf("Mike"),
TreeItem::new_leaf("November"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("h", "Hotel".to_owned()),
TreeItem::new_leaf("Oscar"),
TreeItem::new(
"i",
"India".to_owned(),
"Papa",
vec![
TreeItem::new_leaf("j", "Juliett".to_owned()),
TreeItem::new_leaf("k", "Kilo".to_owned()),
TreeItem::new_leaf("l", "Lima".to_owned()),
TreeItem::new_leaf("m", "Mike".to_owned()),
TreeItem::new_leaf("n", "November".to_owned()),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("o", "Oscar".to_owned()),
TreeItem::new_leaf("Quebec"),
TreeItem::new_leaf("Romeo"),
TreeItem::new_leaf("Sierra"),
TreeItem::new_leaf("Tango"),
TreeItem::new_leaf("Uniform"),
TreeItem::new(
"p",
"Papa".to_owned(),
"Victor",
vec![
TreeItem::new_leaf("q", "Quebec".to_owned()),
TreeItem::new_leaf("r", "Romeo".to_owned()),
TreeItem::new_leaf("s", "Sierra".to_owned()),
TreeItem::new_leaf("t", "Tango".to_owned()),
TreeItem::new_leaf("u", "Uniform".to_owned()),
TreeItem::new(
"v",
"Victor".to_owned(),
vec![
TreeItem::new_leaf("w", "Whiskey".to_owned()),
TreeItem::new_leaf("x", "Xray".to_owned()),
TreeItem::new_leaf("y", "Yankee".to_owned()),
TreeItem::new_leaf("Whiskey"),
TreeItem::new_leaf("Xray"),
TreeItem::new_leaf("Yankee"),
],
)
.expect("all item identifiers are unique"),
],
)
.expect("all item identifiers are unique"),
TreeItem::new_leaf("z", "Zulu".to_owned()),
TreeItem::new_leaf("Zulu"),
],
}
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
let selected = self.state.selected();
let flatten = self.state.flatten(&self.items);
let current_selection = flatten
.iter()
.find(|i| self.state.selected() == i.identifier);
let is_selected = current_selection.is_some()
&& current_selection.unwrap().item.content().to_string() == *"Echo";
let style = if is_selected {
Style::new()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD)
};
let widget = Tree::new(&self.items)
.expect("all item identifiers are unique")
.block(
@@ -113,7 +87,12 @@ impl App {
.track_symbol(None)
.end_symbol(None),
))
.highlight_style(style)
.highlight_style(
Style::new()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
frame.render_stateful_widget(widget, area, &mut self.state);
}
@@ -150,7 +129,10 @@ fn main() -> std::io::Result<()> {
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> std::io::Result<()> {
fn run_app<B: Backend<Error = std::io::Error>>(
terminal: &mut Terminal<B>,
mut app: App,
) -> std::io::Result<()> {
const DEBOUNCE: Duration = Duration::from_millis(20); // 50 FPS
let before = Instant::now();
@@ -165,7 +147,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> std::io::Res
let update = match crossterm::event::read()? {
Event::Key(key) => match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(())
return Ok(());
}
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('\n' | ' ') => app.state.toggle_selected(),
+81 -30
View File
@@ -1,24 +1,24 @@
use std::collections::HashSet;
use ratatui::text::Text;
use crate::tree_item::TreeItem;
use ratatui::text::ToText;
use std::collections::HashSet;
use std::fmt::Display;
use std::hash::Hash;
/// A flattened item of all visible [`TreeItem`]s.
///
/// Generated via [`TreeState::flatten`](crate::TreeState::flatten).
#[must_use]
pub struct Flattened<'a, Identifier, T>
pub struct Flattened<'a, T>
where
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
pub identifier: Vec<Identifier>,
pub item: &'a TreeItem<Identifier, T>,
pub identifier: Vec<u64>,
pub item: &'a TreeItem<T>,
}
impl<'a, Identifier, T> Flattened<'a, Identifier, T>
impl<'a, T> Flattened<'a, T>
where
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
/// Zero based depth. Depth 0 means top level with 0 indentation.
#[must_use]
@@ -31,19 +31,18 @@ where
///
/// `current` starts empty: `&[]`
#[must_use]
pub fn flatten<'a, Identifier, T>(
open_identifiers: &HashSet<Vec<Identifier>>,
items: &'a [TreeItem<Identifier, T>],
current: &[Identifier],
) -> Vec<Flattened<'a, Identifier, T>>
pub fn flatten<'a, T>(
open_identifiers: &HashSet<Vec<u64>>,
items: &'a [TreeItem<T>],
current: &[u64],
) -> Vec<Flattened<'a, T>>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
let mut result = Vec::new();
for item in items {
let mut child_identifier = current.to_vec();
child_identifier.push(item.identifier.clone());
child_identifier.push(item.identifier);
let child_result = open_identifiers
.contains(&child_identifier)
@@ -63,9 +62,15 @@ where
#[test]
fn depth_works() {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut open = HashSet::new();
open.insert(vec!["b"]);
open.insert(vec!["b", "d"]);
let hash = |s: &str| {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
};
open.insert(vec![hash("Bravo")]);
open.insert(vec![hash("Bravo"), hash("Delta")]);
let depths = flatten(&open, &TreeItem::example(), &[])
.into_iter()
.map(|flattened| flattened.depth())
@@ -74,7 +79,7 @@ fn depth_works() {
}
#[cfg(test)]
fn flatten_works(open: &HashSet<Vec<&'static str>>, expected: &[&str]) {
fn flatten_works(open: &HashSet<Vec<u64>>, expected: &[u64]) {
let items = TreeItem::example();
let result = flatten(open, &items, &[]);
let actual = result
@@ -86,29 +91,75 @@ fn flatten_works(open: &HashSet<Vec<&'static str>>, expected: &[&str]) {
#[test]
fn flatten_nothing_open_is_top_level() {
use std::hash::{DefaultHasher, Hash, Hasher};
let open = HashSet::new();
flatten_works(&open, &["a", "b", "h"]);
let hash = |s: &str| {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
};
flatten_works(&open, &[hash("Alfa"), hash("Bravo"), hash("Hotel")]);
}
#[test]
fn flatten_wrong_open_is_only_top_level() {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut open = HashSet::new();
open.insert(vec!["a"]);
open.insert(vec!["b", "d"]);
flatten_works(&open, &["a", "b", "h"]);
let hash = |s: &str| {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
};
open.insert(vec![hash("Alfa")]);
open.insert(vec![hash("Bravo"), hash("Delta")]);
flatten_works(&open, &[hash("Alfa"), hash("Bravo"), hash("Hotel")]);
}
#[test]
fn flatten_one_is_open() {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut open = HashSet::new();
open.insert(vec!["b"]);
flatten_works(&open, &["a", "b", "c", "d", "g", "h"]);
let hash = |s: &str| {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
};
open.insert(vec![hash("Bravo")]);
flatten_works(
&open,
&[
hash("Alfa"),
hash("Bravo"),
hash("Charlie"),
hash("Delta"),
hash("Golf"),
hash("Hotel"),
],
);
}
#[test]
fn flatten_all_open() {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut open = HashSet::new();
open.insert(vec!["b"]);
open.insert(vec!["b", "d"]);
flatten_works(&open, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let hash = |s: &str| {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
};
open.insert(vec![hash("Bravo")]);
open.insert(vec![hash("Bravo"), hash("Delta")]);
flatten_works(
&open,
&[
hash("Alfa"),
hash("Bravo"),
hash("Charlie"),
hash("Delta"),
hash("Echo"),
hash("Foxtrot"),
hash("Golf"),
hash("Hotel"),
],
);
}
+34 -32
View File
@@ -5,13 +5,14 @@ Tree widget [`Tree`] is generated with [`TreeItem`]s (which itself can contain [
The user interaction state (like the current selection) is stored in the [`TreeState`].
*/
use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Text;
use ratatui::text::ToText;
use ratatui::widgets::{Block, Scrollbar, ScrollbarState, StatefulWidget, Widget};
use std::collections::HashSet;
use std::fmt::Display;
use std::hash::Hash;
use unicode_width::UnicodeWidthStr;
pub use crate::flatten::Flattened;
@@ -24,40 +25,36 @@ mod tree_state;
/// A `Tree` which can be rendered.
///
/// The generic argument `Identifier` is used to keep the state like the currently selected or opened [`TreeItem`]s in the [`TreeState`].
/// For more information see [`TreeItem`].
///
/// # Example
///
/// ```
/// # use tui_tree_widget::{Tree, TreeItem, TreeState};
/// # use managarr_tree_widget::{Tree, TreeItem, TreeState};
/// # use ratatui::backend::TestBackend;
/// # use ratatui::Terminal;
/// # use ratatui::widgets::Block;
/// # let mut terminal = Terminal::new(TestBackend::new(32, 32)).unwrap();
/// let mut state = TreeState::default();
///
/// let item = TreeItem::new_leaf("l", "leaf");
/// let item = TreeItem::new_leaf("leaf");
/// let items = vec![item];
///
/// terminal.draw(|frame| {
/// let area = frame.size();
/// let area = frame.area();
///
/// let tree_widget = Tree::new(&items)
/// .expect("all item identifiers are unique")
/// .block(Block::bordered().title("Tree Widget"));
///
/// frame.render_stateful_widget(tree_widget, area, &mut state);
/// })?;
/// # Ok::<(), std::io::Error>(())
/// }).unwrap();
/// ```
#[must_use]
#[derive(Debug, Clone)]
pub struct Tree<'a, Identifier, T>
pub struct Tree<'a, T>
where
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
items: &'a [TreeItem<Identifier, T>],
items: &'a [TreeItem<T>],
block: Option<Block<'a>>,
scrollbar: Option<Scrollbar<'a>>,
@@ -77,17 +74,16 @@ where
node_no_children_symbol: &'a str,
}
impl<'a, Identifier, T> Tree<'a, Identifier, T>
impl<'a, T> Tree<'a, T>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
/// Create a new `Tree`.
///
/// # Errors
///
/// Errors when there are duplicate identifiers in the children.
pub fn new(items: &'a [TreeItem<Identifier, T>]) -> std::io::Result<Self> {
pub fn new(items: &'a [TreeItem<T>]) -> std::io::Result<Self> {
let identifiers = items
.iter()
.map(|item| &item.identifier)
@@ -121,7 +117,7 @@ where
/// Show the scrollbar when rendering this widget.
///
/// Experimental: Can change on any release without any additional notice.
/// Its there to test and experiment with whats possible with scrolling widgets.
/// It's there to test and experiment with what's possible with scrolling widgets.
/// Also see <https://github.com/ratatui-org/ratatui/issues/174>
pub const fn experimental_scrollbar(mut self, scrollbar: Option<Scrollbar<'a>>) -> Self {
self.scrollbar = scrollbar;
@@ -162,18 +158,17 @@ where
#[test]
#[should_panic = "duplicate identifiers"]
fn tree_new_errors_with_duplicate_identifiers() {
let item = TreeItem::new_leaf("same", "text");
let item = TreeItem::new_leaf("text".to_owned());
let another = item.clone();
let items = [item, another];
let _ = Tree::new(&items).unwrap();
}
impl<'a, Identifier, T> StatefulWidget for Tree<'a, Identifier, T>
impl<'a, T> StatefulWidget for Tree<'a, T>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
type State = TreeState<Identifier>;
type State = TreeState;
#[allow(clippy::too_many_lines)]
fn render(self, full_area: Rect, buf: &mut Buffer, state: &mut Self::State) {
@@ -278,7 +273,7 @@ where
height,
};
let text = item.content.clone().into();
let text = item.content.to_text();
let item_style = text.style;
let is_selected = state.selected == *identifier;
@@ -338,10 +333,9 @@ where
}
}
impl<'a, Identifier, T> Widget for Tree<'a, Identifier, T>
impl<'a, T> Widget for Tree<'a, T>
where
Identifier: Clone + Default + Eq + core::hash::Hash,
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = TreeState::default();
@@ -352,10 +346,11 @@ where
#[cfg(test)]
mod render_tests {
use super::*;
use std::hash::{DefaultHasher, Hasher};
#[must_use]
#[track_caller]
fn render(width: u16, height: u16, state: &mut TreeState<&'static str>) -> Buffer {
fn render(width: u16, height: u16, state: &mut TreeState) -> Buffer {
let items = TreeItem::example();
let tree = Tree::new(&items).unwrap();
let area = Rect::new(0, 0, width, height);
@@ -388,7 +383,9 @@ mod render_tests {
#[test]
fn depth_one() {
let mut state = TreeState::default();
state.open(vec!["b"]);
let mut hasher = DefaultHasher::new();
"Bravo".hash(&mut hasher);
state.open(vec![hasher.finish()]);
let buffer = render(13, 7, &mut state);
let expected = Buffer::with_lines([
" Alfa ",
@@ -405,8 +402,13 @@ mod render_tests {
#[test]
fn depth_two() {
let mut state = TreeState::default();
state.open(vec!["b"]);
state.open(vec!["b", "d"]);
let mut hasher = DefaultHasher::new();
"Bravo".hash(&mut hasher);
let bravo_hash = hasher.finish();
let mut hasher = DefaultHasher::new();
"Delta".hash(&mut hasher);
state.open(vec![bravo_hash]);
state.open(vec![bravo_hash, hasher.finish()]);
let buffer = render(15, 9, &mut state);
let expected = Buffer::with_lines([
" Alfa ",
+51 -50
View File
@@ -1,21 +1,22 @@
use ratatui::text::ToText;
use std::collections::HashSet;
use ratatui::text::Text;
use std::fmt::Display;
use std::hash::{DefaultHasher, Hash, Hasher};
/// One item inside a [`Tree`](crate::Tree).
///
/// Can have zero or more `children`.
///
/// # Identifier
/// # identifier
///
/// The generic argument `Identifier` is used to keep the state like the currently selected or opened [`TreeItem`]s in the [`TreeState`](crate::TreeState).
/// The `identifier` is used to keep the state like the currently selected or opened [`TreeItem`]s in the [`TreeState`](crate::TreeState).
///
/// It needs to be unique among its siblings but can be used again on parent or child [`TreeItem`]s.
/// A common example would be a filename which has to be unique in its directory while it can exist in another.
///
/// The `text` can be different from its `identifier`.
/// To repeat the filename analogy: File browsers sometimes hide file extensions.
/// The filename `main.rs` is the identifier while its shown as `main`.
/// The filename `main.rs` is the identifier while it's shown as `main`.
/// Two files `main.rs` and `main.toml` can exist in the same directory and can both be displayed as `main` but their identifier is different.
///
/// Just like every file in a file system can be uniquely identified with its file and directory names each [`TreeItem`] in a [`Tree`](crate::Tree) can be with these identifiers.
@@ -28,42 +29,31 @@ use ratatui::text::Text;
/// # Example
///
/// ```
/// # use tui_tree_widget::TreeItem;
/// let a = TreeItem::new_leaf("l", "Leaf");
/// let b = TreeItem::new("r", "Root", vec![a])?;
/// # use managarr_tree_widget::TreeItem;
/// let a = TreeItem::new_leaf("Leaf");
/// let b = TreeItem::new("Root", vec![a])?;
/// # Ok::<(), std::io::Error>(())
/// ```
#[derive(Debug, Clone)]
pub struct TreeItem<Identifier, T>
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeItem<T>
where
T: for<'a> Into<Text<'a>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
pub(super) identifier: Identifier,
pub(super) identifier: u64,
pub(super) content: T,
pub(super) children: Vec<Self>,
}
impl<Identifier, T> TreeItem<Identifier, T>
impl<T> TreeItem<T>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
T: for<'a> Into<Text<'a>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
/// Create a new `TreeItem` without children.
#[must_use]
pub fn new_leaf(identifier: Identifier, content: T) -> Self {
Self {
identifier,
content,
children: Vec::new(),
}
}
/// Create a new `TreeItem` with children.
///
/// # Errors
///
/// Errors when there are duplicate identifiers in the children.
pub fn new(identifier: Identifier, content: T, children: Vec<Self>) -> std::io::Result<Self> {
pub fn new(content: T, children: Vec<Self>) -> std::io::Result<Self> {
let identifiers = children
.iter()
.map(|item| &item.identifier)
@@ -75,17 +65,33 @@ where
));
}
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Ok(Self {
identifier,
identifier: hasher.finish(),
content,
children,
})
}
/// Create a new `TreeItem` without children.
#[must_use]
pub fn new_leaf(content: T) -> Self {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Self {
identifier: hasher.finish(),
content,
children: Vec::new(),
}
}
/// Get a reference to the identifier.
#[must_use]
pub const fn identifier(&self) -> &Identifier {
&self.identifier
pub const fn identifier(&self) -> u64 {
self.identifier
}
/// Get a reference to the text.
@@ -107,7 +113,7 @@ where
/// Get a mutable reference to a child by index.
///
/// When you choose to change the `identifier` the [`TreeState`](crate::TreeState) might not work as expected afterwards.
/// When you choose to change the `identifier` the [`TreeState`](crate::TreeState) might not work as expected afterward.
#[must_use]
pub fn child_mut(&mut self, index: usize) -> Option<&mut Self> {
self.children.get_mut(index)
@@ -115,7 +121,7 @@ where
#[must_use]
pub fn height(&self) -> usize {
self.content.clone().into().height()
self.content.clone().to_text().height()
}
/// Add a child to the `TreeItem`.
@@ -141,31 +147,26 @@ where
}
}
impl TreeItem<&'static str, String> {
impl TreeItem<&'static str> {
#[cfg(test)]
#[must_use]
pub(crate) fn example() -> Vec<Self> {
vec![
Self::new_leaf("a", "Alfa".to_owned()),
Self::new_leaf("Alfa"),
Self::new(
"b",
"Bravo".to_owned(),
"Bravo",
vec![
Self::new_leaf("c", "Charlie".to_owned()),
Self::new_leaf("Charlie"),
Self::new(
"d",
"Delta".to_owned(),
vec![
Self::new_leaf("e", "Echo".to_owned()),
Self::new_leaf("f", "Foxtrot".to_owned()),
"Delta",
vec![Self::new_leaf("Echo"), Self::new_leaf("Foxtrot")],
)
.expect("all item identifiers are unique"),
Self::new_leaf("Golf"),
],
)
.expect("all item identifiers are unique"),
Self::new_leaf("g", "Golf".to_owned()),
],
)
.expect("all item identifiers are unique"),
Self::new_leaf("h", "Hotel".to_owned()),
Self::new_leaf("Hotel"),
]
}
}
@@ -173,16 +174,16 @@ impl TreeItem<&'static str, String> {
#[test]
#[should_panic = "duplicate identifiers"]
fn tree_item_new_errors_with_duplicate_identifiers() {
let item = TreeItem::new_leaf("same", "text");
let item = TreeItem::new_leaf("text");
let another = item.clone();
TreeItem::new("root", "Root", vec![item, another]).unwrap();
TreeItem::new("Root", vec![item, another]).unwrap();
}
#[test]
#[should_panic = "identifier already exists"]
fn tree_item_add_child_errors_with_duplicate_identifiers() {
let item = TreeItem::new_leaf("same", "text");
let item = TreeItem::new_leaf("text");
let another = item.clone();
let mut root = TreeItem::new("root", "Root", vec![item]).unwrap();
let mut root = TreeItem::new("Root", vec![item]).unwrap();
root.add_child(another).unwrap();
}
+32 -43
View File
@@ -1,44 +1,38 @@
use std::collections::HashSet;
use ratatui::layout::{Position, Rect};
use ratatui::text::Text;
use ratatui::text::ToText;
use std::collections::HashSet;
use std::fmt::Display;
use std::hash::Hash;
use crate::flatten::{flatten, Flattened};
use crate::flatten::{Flattened, flatten};
use crate::tree_item::TreeItem;
/// Keeps the state of what is currently selected and what was opened in a [`Tree`](crate::Tree).
///
/// The generic argument `Identifier` is used to keep the state like the currently selected or opened [`TreeItem`]s in the [`TreeState`].
/// For more information see [`TreeItem`].
///
/// # Example
///
/// ```
/// # use tui_tree_widget::TreeState;
/// type Identifier = usize;
/// # use managarr_tree_widget::TreeState;
///
/// let mut state = TreeState::<Identifier>::default();
/// let mut state = TreeState::default();
/// ```
#[must_use]
#[derive(Debug, Default)]
pub struct TreeState<Identifier> {
pub struct TreeState {
pub(super) offset: usize,
pub(super) opened: HashSet<Vec<Identifier>>,
pub(super) selected: Vec<Identifier>,
pub(super) opened: HashSet<Vec<u64>>,
pub(super) selected: Vec<u64>,
pub(super) ensure_selected_in_view_on_next_render: bool,
pub(super) last_area: Rect,
pub(super) last_biggest_index: usize,
/// All identifiers open on last render
pub(super) last_identifiers: Vec<Vec<Identifier>>,
pub(super) last_identifiers: Vec<Vec<u64>>,
/// Identifier rendered at `y` on last render
pub(super) last_rendered_identifiers: Vec<(u16, Vec<Identifier>)>,
pub(super) last_rendered_identifiers: Vec<(u16, Vec<u64>)>,
}
impl<Identifier> TreeState<Identifier>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
{
impl TreeState {
#[must_use]
pub const fn get_offset(&self) -> usize {
self.offset
@@ -46,28 +40,25 @@ where
#[must_use]
#[deprecated = "Use self.opened()"]
pub fn get_all_opened(&self) -> Vec<Vec<Identifier>> {
pub fn get_all_opened(&self) -> Vec<Vec<u64>> {
self.opened.iter().cloned().collect()
}
#[must_use]
pub const fn opened(&self) -> &HashSet<Vec<Identifier>> {
pub const fn opened(&self) -> &HashSet<Vec<u64>> {
&self.opened
}
#[must_use]
pub fn selected(&self) -> &[Identifier] {
pub fn selected(&self) -> &[u64] {
&self.selected
}
/// Get a flat list of all currently viewable (including by scrolling) [`TreeItem`]s with this `TreeState`.
#[must_use]
pub fn flatten<'a, T>(
&self,
items: &'a [TreeItem<Identifier, T>],
) -> Vec<Flattened<'a, Identifier, T>>
pub fn flatten<'a, T>(&self, items: &'a [TreeItem<T>]) -> Vec<Flattened<'a, T>>
where
T: for<'b> Into<Text<'b>> + Clone,
T: ToText + Clone + Default + Display + Hash + PartialEq + Eq,
{
flatten(&self.opened, items, &[])
}
@@ -79,11 +70,11 @@ where
/// Clear the selection by passing an empty identifier vector:
///
/// ```rust
/// # use tui_tree_widget::TreeState;
/// # let mut state = TreeState::<usize>::default();
/// # use managarr_tree_widget::TreeState;
/// # let mut state = TreeState::default();
/// state.select(Vec::new());
/// ```
pub fn select(&mut self, identifier: Vec<Identifier>) -> bool {
pub fn select(&mut self, identifier: Vec<u64>) -> bool {
self.ensure_selected_in_view_on_next_render = true;
let changed = self.selected != identifier;
self.selected = identifier;
@@ -93,7 +84,7 @@ where
/// Open a tree node.
/// Returns `true` when it was closed and has been opened.
/// Returns `false` when it was already open.
pub fn open(&mut self, identifier: Vec<Identifier>) -> bool {
pub fn open(&mut self, identifier: Vec<u64>) -> bool {
if identifier.is_empty() {
false
} else {
@@ -104,7 +95,7 @@ where
/// Close a tree node.
/// Returns `true` when it was open and has been closed.
/// Returns `false` when it was already closed.
pub fn close(&mut self, identifier: &[Identifier]) -> bool {
pub fn close(&mut self, identifier: &[u64]) -> bool {
self.opened.remove(identifier)
}
@@ -113,7 +104,7 @@ where
///
/// Returns `true` when a node is opened / closed.
/// As toggle always changes something, this only returns `false` when an empty identifier is given.
pub fn toggle(&mut self, identifier: Vec<Identifier>) -> bool {
pub fn toggle(&mut self, identifier: Vec<u64>) -> bool {
if identifier.is_empty() {
false
} else if self.opened.contains(&identifier) {
@@ -195,13 +186,12 @@ where
/// # Example
///
/// ```
/// # use tui_tree_widget::TreeState;
/// # type Identifier = usize;
/// # let mut state = TreeState::<Identifier>::default();
/// # use managarr_tree_widget::TreeState;
/// # let mut state = TreeState::default();
/// // Move the selection one down
/// state.select_visible_relative(|current| {
/// state.select_relative(|current| {
/// // When nothing is currently selected, select index 0
/// // Otherwise select current + 1 (without panicing)
/// // Otherwise select current + 1 (without panicking)
/// current.map_or(0, |current| current.saturating_add(1))
/// });
/// ```
@@ -230,13 +220,12 @@ where
/// # Example
///
/// ```
/// # use tui_tree_widget::TreeState;
/// # type Identifier = usize;
/// # let mut state = TreeState::<Identifier>::default();
/// # use managarr_tree_widget::TreeState;
/// # let mut state = TreeState::default();
/// // Move the selection one down
/// state.select_relative(|current| {
/// // When nothing is currently selected, select index 0
/// // Otherwise select current + 1 (without panicing)
/// // Otherwise select current + 1 (without panicking)
/// current.map_or(0, |current| current.saturating_add(1))
/// });
/// ```
@@ -259,7 +248,7 @@ where
/// Get the identifier that was rendered for the given position on last render.
#[must_use]
pub fn rendered_at(&self, position: Position) -> Option<&[Identifier]> {
pub fn rendered_at(&self, position: Position) -> Option<&[u64]> {
if !self.last_area.contains(position) {
return None;
}