Files
Cagire/crates/ratatui/src/sample_browser.rs
2026-03-05 18:24:09 +01:00

255 lines
9.2 KiB
Rust

//! Tree-view sample browser with search filtering.
use crate::theme;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
/// Node type in the sample tree.
#[derive(Clone, Copy)]
pub enum TreeLineKind {
Root { expanded: bool },
Folder { expanded: bool },
File,
}
/// A single row in the sample browser tree.
#[derive(Clone)]
pub struct TreeLine {
pub depth: u8,
pub kind: TreeLineKind,
pub label: String,
pub folder: String,
pub index: usize,
pub child_count: usize,
}
/// Tree-view browser for navigating sample folders.
pub struct SampleBrowser<'a> {
entries: &'a [TreeLine],
cursor: usize,
scroll_offset: usize,
search_query: &'a str,
search_active: bool,
focused: bool,
}
impl<'a> SampleBrowser<'a> {
pub fn new(entries: &'a [TreeLine], cursor: usize) -> Self {
Self {
entries,
cursor,
scroll_offset: 0,
search_query: "",
search_active: false,
focused: false,
}
}
pub fn scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn search(mut self, query: &'a str, active: bool) -> Self {
self.search_query = query;
self.search_active = active;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let colors = theme::get();
let border_style = if self.focused {
Style::new().fg(colors.browser.focused_border)
} else {
Style::new().fg(colors.browser.unfocused_border)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Samples ");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let show_search = self.search_active || !self.search_query.is_empty();
let (search_area, list_area) = if show_search {
let [s, l] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner);
(Some(s), l)
} else {
(None, inner)
};
if let Some(sa) = search_area {
self.render_search(frame, sa, &colors);
}
self.render_tree(frame, list_area, &colors);
}
fn render_search(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let style = if self.search_active {
Style::new().fg(colors.search.active)
} else {
Style::new().fg(colors.search.inactive)
};
let cursor = if self.search_active { "_" } else { "" };
let text = format!("/{}{}", self.search_query, cursor);
let line = Line::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(vec![line]), area);
}
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let height = area.height as usize;
if self.entries.is_empty() {
if self.search_query.is_empty() {
self.render_empty_guide(frame, area, colors);
} else {
let line =
Line::from(Span::styled("No matches", Style::new().fg(colors.browser.empty_text)));
frame.render_widget(Paragraph::new(vec![line]), area);
}
return;
}
let visible_end = (self.scroll_offset + height).min(self.entries.len());
let mut lines: Vec<Line> = Vec::with_capacity(height);
for i in self.scroll_offset..visible_end {
let entry = &self.entries[i];
let is_cursor = i == self.cursor;
let indent = " ".repeat(entry.depth as usize);
let (icon, icon_color) = match entry.kind {
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
("\u{2212} ", colors.browser.folder_icon)
}
TreeLineKind::Root { expanded: false }
| TreeLineKind::Folder { expanded: false } => ("+ ", colors.browser.folder_icon),
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
};
let label_style = if is_cursor && self.focused {
Style::new().fg(colors.browser.selected).add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::new().fg(colors.browser.file)
} else {
match entry.kind {
TreeLineKind::Root { .. } => {
Style::new().fg(colors.browser.root).add_modifier(Modifier::BOLD)
}
TreeLineKind::Folder { .. } => Style::new().fg(colors.browser.directory),
TreeLineKind::File => Style::default(),
}
};
let icon_style = if is_cursor && self.focused {
label_style
} else {
Style::new().fg(icon_color)
};
let prefix_width = indent.len() + 2; // indent + icon
let suffix = match entry.kind {
TreeLineKind::File => format!(" {}", entry.index),
TreeLineKind::Root { expanded: false }
| TreeLineKind::Folder { expanded: false }
if entry.child_count > 0 =>
{
format!(" ({})", entry.child_count)
}
_ => String::new(),
};
let max_label = (area.width as usize)
.saturating_sub(prefix_width)
.saturating_sub(suffix.len());
let label: std::borrow::Cow<str> = if entry.label.len() > max_label && max_label > 1 {
let truncated: String = entry.label.chars().take(max_label - 1).collect();
format!("{}\u{2026}", truncated).into()
} else {
(&entry.label).into()
};
let mut spans = vec![
Span::raw(indent),
Span::styled(icon, icon_style),
Span::styled(label, label_style),
];
match entry.kind {
TreeLineKind::File => {
let idx_style = Style::new().fg(colors.browser.empty_text);
spans.push(Span::styled(suffix, idx_style));
}
_ if !suffix.is_empty() => {
let dim_style = Style::new().fg(colors.browser.empty_text);
spans.push(Span::styled(suffix, dim_style));
}
_ => {}
}
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(lines), area);
}
fn render_empty_guide(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let muted = Style::new().fg(colors.browser.empty_text);
let heading = Style::new().fg(colors.ui.text_primary);
let key = Style::new().fg(colors.hint.key);
let desc = Style::new().fg(colors.hint.text);
let code = Style::new().fg(colors.ui.accent);
let lines = vec![
Line::from(Span::styled(" No samples loaded.", muted)),
Line::from(""),
Line::from(Span::styled(" Load from the Engine page:", heading)),
Line::from(""),
Line::from(vec![
Span::styled(" F6 ", key),
Span::styled("Go to Engine page", desc),
]),
Line::from(vec![
Span::styled(" A ", key),
Span::styled("Add a sample folder", desc),
]),
Line::from(""),
Line::from(Span::styled(" Organize samples like this:", heading)),
Line::from(""),
Line::from(Span::styled(" samples/", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} kick/", code)),
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} kick.wav", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} snare/", code)),
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} snare.wav", code)),
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} hats/", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} closed.wav", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} open.wav", code)),
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} pedal.wav", code)),
Line::from(""),
Line::from(Span::styled(" Folders become Forth words:", heading)),
Line::from(""),
Line::from(Span::styled(" kick sound .", code)),
Line::from(Span::styled(" hats sound 2 n .", code)),
Line::from(Span::styled(" snare sound 0.5 speed .", code)),
];
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}
}