//! 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 = 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 = 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); } }