use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::{
block::{focusable_block, render_scrollbar},
Theme,
};
pub struct TreeState {
pub selected: usize,
offset: usize,
}
impl TreeState {
pub fn new() -> Self {
Self { selected: 0, offset: 0 }
}
pub fn select_next(&mut self, row_count: usize) {
if row_count == 0 {
return;
}
self.selected = (self.selected + 1) % row_count;
}
pub fn select_prev(&mut self, row_count: usize) {
if row_count == 0 {
return;
}
if self.selected == 0 {
self.selected = row_count - 1;
} else {
self.selected -= 1;
}
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn offset(&self) -> usize {
self.offset
}
fn clamp_offset(&mut self, visible_height: usize) {
if visible_height == 0 {
return;
}
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + visible_height {
self.offset = self.selected - visible_height + 1;
}
}
}
impl Default for TreeState {
fn default() -> Self {
Self::new()
}
}
pub struct TreeRow {
pub depth: u16,
pub label: String,
pub secondary: Option<String>,
pub expanded: Option<bool>,
pub style: Option<Style>,
}
impl TreeRow {
pub fn leaf(depth: u16, label: impl Into<String>) -> Self {
Self {
depth,
label: label.into(),
secondary: None,
expanded: None,
style: None,
}
}
pub fn branch(depth: u16, label: impl Into<String>, expanded: bool) -> Self {
Self {
depth,
label: label.into(),
secondary: None,
expanded: Some(expanded),
style: None,
}
}
pub fn secondary(mut self, secondary: impl Into<String>) -> Self {
self.secondary = Some(secondary.into());
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = Some(style);
self
}
}
pub fn render_tree(
f: &mut Frame,
area: Rect,
title: &str,
shortcut: Option<u8>,
rows: &[TreeRow],
state: &mut TreeState,
focused: bool,
theme: &Theme,
) {
let block = focusable_block(title, shortcut, focused, theme);
let inner = block.inner(area);
f.render_widget(block, area);
render_scrollbar(f, area, rows.len(), state.offset);
let visible_height = inner.height as usize;
if !rows.is_empty() && state.selected >= rows.len() {
state.selected = rows.len() - 1;
}
state.clamp_offset(visible_height);
if rows.is_empty() || visible_height == 0 {
return;
}
for (idx, row) in rows
.iter()
.enumerate()
.skip(state.offset)
.take(visible_height)
{
let row_y = inner.y + (idx - state.offset) as u16;
let row_area = Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
};
let is_selected = idx == state.selected;
let label_style = if is_selected {
theme.selection
} else {
row.style.unwrap_or(theme.body)
};
let glyph_style = if is_selected { theme.selection } else { theme.hint };
let indent = " ".repeat(row.depth as usize);
let glyph = match row.expanded {
Some(true) => "▾ ",
Some(false) => "▸ ",
None => " ",
};
let prefix = format!("{indent}{glyph}");
let left = Line::from(vec![
Span::styled(prefix, glyph_style),
Span::styled(row.label.clone(), label_style),
]);
match &row.secondary {
None => {
f.render_widget(Paragraph::new(left), row_area);
}
Some(sec) => {
let sec_width = (sec.chars().count() as u16 + 1)
.min(inner.width.saturating_sub(1));
let prim_width = inner.width.saturating_sub(sec_width);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(prim_width),
Constraint::Length(sec_width),
])
.split(row_area);
let sec_style = if is_selected { theme.selection } else { theme.hint };
let sec_para = Paragraph::new(Line::from(Span::styled(sec.clone(), sec_style)))
.alignment(Alignment::Right);
f.render_widget(Paragraph::new(left), chunks[0]);
f.render_widget(sec_para, chunks[1]);
}
}
}
}