#![forbid(unsafe_code)]
#![warn(clippy::pedantic, clippy::nursery)]
use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Scrollbar, ScrollbarState, StatefulWidget, Widget};
use unicode_width::UnicodeWidthStr;
mod flatten;
mod item;
mod state;
pub use crate::flatten::Flattened;
pub use crate::item::Item as TreeItem;
pub use crate::state::State as TreeState;
#[derive(Debug, Clone)]
pub struct Tree<'a, Identifier> {
items: Vec<TreeItem<'a, Identifier>>,
block: Option<Block<'a>>,
scrollbar: Option<Scrollbar<'a>>,
style: Style,
highlight_style: Style,
highlight_symbol: &'a str,
node_closed_symbol: &'a str,
node_open_symbol: &'a str,
node_no_children_symbol: &'a str,
}
impl<'a, Identifier> Tree<'a, Identifier>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
{
pub fn new(items: Vec<TreeItem<'a, Identifier>>) -> std::io::Result<Self> {
let identifiers = items
.iter()
.map(|item| &item.identifier)
.collect::<HashSet<_>>();
if identifiers.len() != items.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"The items contain duplicate identifiers",
));
}
Ok(Self {
items,
block: None,
scrollbar: None,
style: Style::new(),
highlight_style: Style::new(),
highlight_symbol: "",
node_closed_symbol: "\u{25b6} ", node_open_symbol: "\u{25bc} ", node_no_children_symbol: " ",
})
}
#[allow(clippy::missing_const_for_fn)]
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub const fn experimental_scrollbar(mut self, scrollbar: Option<Scrollbar<'a>>) -> Self {
self.scrollbar = scrollbar;
self
}
#[must_use]
pub const fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
#[must_use]
pub const fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Self {
self.highlight_symbol = highlight_symbol;
self
}
#[must_use]
pub const fn node_closed_symbol(mut self, symbol: &'a str) -> Self {
self.node_closed_symbol = symbol;
self
}
#[must_use]
pub const fn node_open_symbol(mut self, symbol: &'a str) -> Self {
self.node_open_symbol = symbol;
self
}
#[must_use]
pub const fn node_no_children_symbol(mut self, symbol: &'a str) -> Self {
self.node_no_children_symbol = symbol;
self
}
}
#[test]
#[should_panic = "duplicate identifiers"]
fn tree_new_errors_with_duplicate_identifiers() {
let item = TreeItem::new_leaf("same", "text");
let another = item.clone();
Tree::new(vec![item, another]).unwrap();
}
impl<'a, Identifier> StatefulWidget for Tree<'a, Identifier>
where
Identifier: Clone + PartialEq + Eq + core::hash::Hash,
{
type State = TreeState<Identifier>;
#[allow(clippy::too_many_lines)]
fn render(self, full_area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_style(full_area, self.style);
let area = self.block.map_or(full_area, |block| {
let inner_area = block.inner(full_area);
block.render(full_area, buf);
inner_area
});
if area.width < 1 || area.height < 1 {
return;
}
let visible = state.flatten(&self.items);
if visible.is_empty() {
return;
}
let available_height = area.height as usize;
let ensure_index_in_view =
if state.ensure_selected_in_view_on_next_render && !state.selected.is_empty() {
visible
.iter()
.position(|flattened| flattened.identifier == state.selected)
} else {
None
};
let mut start = state.offset.min(visible.len().saturating_sub(1));
if let Some(ensure_index_in_view) = ensure_index_in_view {
start = start.min(ensure_index_in_view);
}
let mut end = start;
let mut height = 0;
for item_height in visible
.iter()
.skip(start)
.map(|flattened| flattened.item.height())
{
if height + item_height > available_height {
break;
}
height += item_height;
end += 1;
}
if let Some(ensure_index_in_view) = ensure_index_in_view {
while ensure_index_in_view >= end {
height += visible[end].item.height();
end += 1;
while height > available_height {
height = height.saturating_sub(visible[start].item.height());
start += 1;
}
}
}
state.offset = start;
state.ensure_selected_in_view_on_next_render = false;
if let Some(scrollbar) = self.scrollbar {
let mut scrollbar_state = ScrollbarState::new(visible.len().saturating_sub(height))
.position(start)
.viewport_content_length(height);
let scrollbar_area = Rect {
y: area.y,
height: area.height,
x: full_area.x,
width: full_area.width,
};
scrollbar.render(scrollbar_area, buf, &mut scrollbar_state);
}
let blank_symbol = " ".repeat(self.highlight_symbol.width());
let mut current_height = 0;
let has_selection = !state.selected.is_empty();
#[allow(clippy::cast_possible_truncation)]
for flattened in visible.into_iter().skip(state.offset).take(end - start) {
let Flattened {
ref identifier,
item,
} = flattened;
let x = area.x;
let y = area.y + current_height;
let height = item.height() as u16;
current_height += height;
let area = Rect {
x,
y,
width: area.width,
height,
};
let item_style = self.style.patch(item.style);
buf.set_style(area, item_style);
let is_selected = state.selected == *identifier;
let after_highlight_symbol_x = if has_selection {
let symbol = if is_selected {
self.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 indent_width = flattened.depth() * 2;
let (after_indent_x, _) = buf.set_stringn(
after_highlight_symbol_x,
y,
" ".repeat(indent_width),
indent_width,
item_style,
);
let symbol = if item.children.is_empty() {
self.node_no_children_symbol
} else if state.opened.contains(identifier) {
self.node_open_symbol
} else {
self.node_closed_symbol
};
let max_width = area.width.saturating_sub(after_indent_x - x);
let (x, _) =
buf.set_stringn(after_indent_x, y, symbol, max_width as usize, item_style);
x
};
let max_element_width = area.width.saturating_sub(after_depth_x - x);
for (j, line) in item.text.lines.iter().enumerate() {
buf.set_line(after_depth_x, y + j as u16, line, max_element_width);
}
if is_selected {
buf.set_style(area, self.highlight_style);
}
}
}
}
impl<'a, Identifier> Widget for Tree<'a, Identifier>
where
Identifier: Clone + Default + Eq + core::hash::Hash,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = TreeState::default();
StatefulWidget::render(self, area, buf, &mut state);
}
}