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

@@ -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))