Feat: rework audio sample library viewer
This commit is contained in:
21
src/input.rs
21
src/input.rs
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user