Feat: rework audio sample library viewer

This commit is contained in:
2026-02-05 18:37:32 +01:00
parent 3e364a6622
commit e42476dd4d
7 changed files with 496 additions and 63 deletions

View File

@@ -850,13 +850,23 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
KeyCode::Down | KeyCode::Char('j') => state.move_down(30),
KeyCode::PageUp => {
for _ in 0..20 {
state.move_up();
}
}
KeyCode::PageDown => {
for _ in 0..20 {
state.move_down(30);
}
}
KeyCode::Enter | KeyCode::Right => {
if let Some(entry) = state.current_entry() {
match entry.kind {
TreeLineKind::File => {
let folder = &entry.folder;
let idx = entry.index;
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/dur/1");
let _ = ctx
.audio_tx
.load()
@@ -868,7 +878,14 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
KeyCode::Left => state.collapse_at_cursor(),
KeyCode::Char('/') => state.activate_search(),
KeyCode::Esc | KeyCode::Tab => {
KeyCode::Esc => {
if state.has_filter() {
state.clear_filter();
} else {
ctx.dispatch(AppCommand::ClosePanel);
}
}
KeyCode::Tab => {
ctx.dispatch(AppCommand::ClosePanel);
}
_ => {}

View File

@@ -93,7 +93,6 @@ pub struct SampleTree {
impl SampleTree {
pub fn from_paths(paths: &[PathBuf]) -> Self {
if paths.len() == 1 {
// Single path: show its contents directly at root level
let roots = Self::scan_children(&paths[0]);
return Self { roots };
}
@@ -251,6 +250,87 @@ impl SampleTree {
}
None
}
fn find_folder_mut(&mut self, name: &str) -> Option<&mut SampleNode> {
for root in &mut self.roots {
if let Some(node) = Self::find_folder_in(root, name) {
return Some(node);
}
}
None
}
fn find_folder_in<'a>(node: &'a mut SampleNode, name: &str) -> Option<&'a mut SampleNode> {
match node {
SampleNode::Folder { name: n, .. } if n == name => Some(node),
SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => {
for child in children.iter_mut() {
if let Some(found) = Self::find_folder_in(child, name) {
return Some(found);
}
}
None
}
SampleNode::File { .. } => None,
}
}
fn filtered_entries(&self, names: &[String], collapsed: bool) -> Vec<TreeLine> {
let mut out = Vec::new();
for name in names {
for root in &self.roots {
Self::emit_filtered(root, name, collapsed, &mut out);
}
}
out
}
fn emit_filtered(
node: &SampleNode,
target_name: &str,
collapsed: bool,
out: &mut Vec<TreeLine>,
) {
match node {
SampleNode::Folder {
name,
children,
expanded,
} if name == target_name => {
let show_children = !collapsed && *expanded;
out.push(TreeLine {
depth: 0,
kind: TreeLineKind::Folder {
expanded: show_children,
},
label: name.clone(),
folder: String::new(),
index: 0,
});
if show_children {
let mut idx = 0;
for child in children {
if let SampleNode::File { name: fname } = child {
out.push(TreeLine {
depth: 1,
kind: TreeLineKind::File,
label: fname.clone(),
folder: name.clone(),
index: idx,
});
idx += 1;
}
}
}
}
SampleNode::Root { children, .. } => {
for child in children {
Self::emit_filtered(child, target_name, collapsed, out);
}
}
_ => {}
}
}
}
pub struct SampleBrowserState {
@@ -259,7 +339,7 @@ pub struct SampleBrowserState {
pub scroll_offset: usize,
pub search_query: String,
pub search_active: bool,
filtered: Option<Vec<TreeLine>>,
filter: Option<Vec<String>>,
}
impl SampleBrowserState {
@@ -270,15 +350,15 @@ impl SampleBrowserState {
scroll_offset: 0,
search_query: String::new(),
search_active: false,
filtered: None,
filter: None,
}
}
pub fn entries(&self) -> Vec<TreeLine> {
if let Some(ref filtered) = self.filtered {
return filtered.clone();
match &self.filter {
Some(names) => self.tree.filtered_entries(names, self.search_active),
None => self.tree.visible_entries(),
}
self.tree.visible_entries()
}
pub fn current_entry(&self) -> Option<TreeLine> {
@@ -286,11 +366,44 @@ impl SampleBrowserState {
entries.into_iter().nth(self.cursor)
}
pub fn visible_count(&self) -> usize {
if let Some(ref filtered) = self.filtered {
return filtered.len();
pub fn sample_key(&self) -> Option<String> {
let entry = self.current_entry()?;
if !matches!(entry.kind, TreeLineKind::File) {
return None;
}
if entry.folder.is_empty() {
Some(
entry
.label
.rsplit_once('.')
.map_or(entry.label.clone(), |(stem, _)| stem.to_string()),
)
} else {
Some(format!("{}/{}", entry.folder, entry.index))
}
}
pub fn visible_count(&self) -> usize {
self.entries().len()
}
pub fn has_filter(&self) -> bool {
self.filter.is_some()
}
fn clamp_view(&mut self) {
let count = self.entries().len();
if count == 0 {
self.cursor = 0;
self.scroll_offset = 0;
return;
}
if self.cursor >= count {
self.cursor = count - 1;
}
if self.scroll_offset > self.cursor {
self.scroll_offset = self.cursor;
}
self.tree.visible_entries().len()
}
pub fn move_up(&mut self) {
@@ -316,26 +429,76 @@ impl SampleBrowserState {
}
pub fn toggle_expand(&mut self) {
if self.filtered.is_some() {
if self.search_active {
return;
}
if let Some(node) = self.tree.node_at_mut(self.cursor) {
if let Some(ref names) = self.filter {
let entries = self.tree.filtered_entries(names, false);
if let Some(entry) = entries.get(self.cursor) {
if matches!(entry.kind, TreeLineKind::Folder { .. }) {
let label = entry.label.clone();
if let Some(node) = self.tree.find_folder_mut(&label) {
let new_val = !node.expanded();
node.set_expanded(new_val);
}
}
}
} else if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.is_expandable() {
let new_val = !node.expanded();
node.set_expanded(new_val);
}
}
self.clamp_view();
}
pub fn collapse_at_cursor(&mut self) {
if self.filtered.is_some() {
if self.search_active {
return;
}
if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.expanded() {
node.set_expanded(false);
let entries = self.entries();
let entry = match entries.get(self.cursor) {
Some(e) => e,
None => return,
};
let is_file = matches!(entry.kind, TreeLineKind::File);
if is_file {
// Scan backward to find parent folder
for i in (0..self.cursor).rev() {
if matches!(
entries[i].kind,
TreeLineKind::Folder { .. } | TreeLineKind::Root { .. }
) {
let label = entries[i].label.clone();
if self.filter.is_some() {
if let Some(node) = self.tree.find_folder_mut(&label) {
node.set_expanded(false);
}
} else if let Some(node) = self.tree.node_at_mut(i) {
node.set_expanded(false);
}
self.cursor = i;
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
}
return;
}
}
} else {
let label = entry.label.clone();
if self.filter.is_some() {
if let Some(node) = self.tree.find_folder_mut(&label) {
if node.expanded() {
node.set_expanded(false);
}
}
} else if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.expanded() {
node.set_expanded(false);
}
}
}
self.clamp_view();
}
pub fn activate_search(&mut self) {
@@ -344,15 +507,16 @@ impl SampleBrowserState {
pub fn update_search(&mut self) {
if self.search_query.is_empty() {
self.filtered = None;
self.filter = None;
} else {
let query = self.search_query.to_lowercase();
let full = self.full_entries();
let filtered: Vec<TreeLine> = full
.into_iter()
.filter(|line| line.label.to_lowercase().contains(&query))
.collect();
self.filtered = Some(filtered);
let query = &self.search_query;
let mut scored: Vec<(usize, String)> = Vec::new();
for root in &self.tree.roots {
Self::collect_matching_folder_names(root, query, &mut scored);
}
scored.sort_by_key(|(score, _)| *score);
let names = scored.into_iter().map(|(_, name)| name).collect();
self.filter = Some(names);
}
self.cursor = 0;
self.scroll_offset = 0;
@@ -361,53 +525,64 @@ impl SampleBrowserState {
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_active = false;
self.filtered = None;
self.filter = None;
self.cursor = 0;
self.scroll_offset = 0;
}
fn full_entries(&self) -> Vec<TreeLine> {
let mut out = Vec::new();
for root in &self.tree.roots {
Self::flatten_all(root, 0, "", 0, &mut out);
}
out
pub fn clear_filter(&mut self) {
self.filter = None;
self.search_query.clear();
self.cursor = 0;
self.scroll_offset = 0;
}
fn flatten_all(
fn collect_matching_folder_names(
node: &SampleNode,
depth: u8,
parent_folder: &str,
file_index: usize,
out: &mut Vec<TreeLine>,
query: &str,
out: &mut Vec<(usize, String)>,
) {
let kind = match node {
SampleNode::Root { .. } => TreeLineKind::Root { expanded: true },
SampleNode::Folder { .. } => TreeLineKind::Folder { expanded: true },
SampleNode::File { .. } => TreeLineKind::File,
};
out.push(TreeLine {
depth,
kind,
label: node.label().to_string(),
folder: parent_folder.to_string(),
index: file_index,
});
let folder_name = node.label();
let mut idx = 0;
for child in node.children() {
let child_idx = if matches!(child, SampleNode::File { .. }) {
let i = idx;
idx += 1;
i
} else {
0
};
Self::flatten_all(child, depth + 1, folder_name, child_idx, out);
match node {
SampleNode::Root { children, .. } => {
for child in children {
Self::collect_matching_folder_names(child, query, out);
}
}
SampleNode::Folder { name, .. } => {
if let Some(score) = fuzzy_match(query, name) {
out.push((score, name.clone()));
}
}
SampleNode::File { .. } => {}
}
}
}
fn fuzzy_match(query: &str, target: &str) -> Option<usize> {
let target_lower: Vec<char> = target.to_lowercase().chars().collect();
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
let mut ti = 0;
let mut score = 0;
let mut prev_pos = 0;
for (qi, &qc) in query_lower.iter().enumerate() {
loop {
if ti >= target_lower.len() {
return None;
}
if target_lower[ti] == qc {
if qi > 0 {
score += ti - prev_pos;
}
prev_pos = ti;
ti += 1;
break;
}
ti += 1;
}
}
Some(score)
}
fn is_audio_file(name: &str) -> bool {
let lower = name.to_lowercase();
AUDIO_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))

View File

@@ -170,12 +170,49 @@ fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
let focused = app.panel.focus == PanelFocus::Side;
match &app.panel.side {
Some(SidePanel::SampleBrowser(state)) => {
let [tree_area, preview_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area);
let entries = state.entries();
SampleBrowser::new(&entries, state.cursor)
.scroll_offset(state.scroll_offset)
.search(&state.search_query, state.search_active)
.focused(focused)
.render(frame, area);
.render(frame, tree_area);
if let Some(sample) = state
.sample_key()
.and_then(|key| app.audio.sample_registry.as_ref()?.get(&key))
.filter(|s| s.frame_count >= s.total_frames)
{
use crate::widgets::Waveform;
use std::cell::RefCell;
thread_local! {
static MONO_BUF: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
}
let [wave_area, info_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
.areas(preview_area);
MONO_BUF.with(|buf| {
let mut buf = buf.borrow_mut();
let channels = sample.channels as usize;
let frame_count = sample.frame_count as usize;
buf.clear();
buf.reserve(frame_count);
for i in 0..frame_count {
buf.push(sample.frames[i * channels]);
}
frame.render_widget(Waveform::new(&buf), wave_area);
});
let duration = sample.total_frames as f32 / app.audio.config.sample_rate;
let ch_label = if sample.channels == 1 { "mono" } else { "stereo" };
let info = Paragraph::new(format!(" {duration:.1}s · {ch_label}"))
.style(Style::new().fg(theme::get().ui.text_dim));
frame.render_widget(info, info_area);
}
}
None => {}
}

View File

@@ -1,4 +1,4 @@
pub use cagire_ratatui::{
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile,
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform,
};