255 lines
9.2 KiB
Rust
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);
|
|
}
|
|
}
|