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

@@ -2,6 +2,11 @@
All notable changes to this project will be documented in this file.
## [0.0.8] - 2026-06-05
### Improved
- Sample library browser: search now shows folder names only (no files) while typing, sorted by fuzzy match score. After confirming search with Enter, folders can be expanded and collapsed normally. Esc clears the search filter before closing the panel. Left arrow on a file collapses the parent folder. Cursor and scroll position stay valid after expand/collapse operations.
## [0.0.7] - 2026-05-02
### Added

View File

@@ -12,6 +12,7 @@ mod spectrum;
mod text_input;
pub mod theme;
mod vu_meter;
mod waveform;
pub use active_patterns::{ActivePatterns, MuteStatus};
pub use confirm::ConfirmModal;
@@ -26,3 +27,4 @@ pub use sparkles::Sparkles;
pub use spectrum::Spectrum;
pub use text_input::TextInputModal;
pub use vu_meter::VuMeter;
pub use waveform::Waveform;

View File

@@ -0,0 +1,197 @@
use crate::scope::Orientation;
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub struct Waveform<'a> {
data: &'a [f32],
orientation: Orientation,
color: Option<Color>,
gain: f32,
}
impl<'a> Waveform<'a> {
pub fn new(data: &'a [f32]) -> Self {
Self {
data,
orientation: Orientation::Horizontal,
color: None,
gain: 1.0,
}
}
pub fn orientation(mut self, o: Orientation) -> Self {
self.orientation = o;
self
}
pub fn color(mut self, c: Color) -> Self {
self.color = Some(c);
self
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
}
impl Widget for Waveform<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.data.is_empty() {
return;
}
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
match self.orientation {
Orientation::Horizontal => {
render_horizontal(self.data, area, buf, color, self.gain)
}
Orientation::Vertical => render_vertical(self.data, area, buf, color, self.gain),
}
}
}
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
}
}
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
for fine_x in 0..fine_width {
let start = fine_x * len / fine_width;
let end = ((fine_x + 1) * len / fine_width).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * auto_gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fy_top = ((1.0 - max_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_bot = ((1.0 - min_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_top = fy_top.min(fine_height - 1);
let fy_bot = fy_bot.min(fine_height - 1);
let char_x = fine_x / 2;
let dot_x = fine_x % 2;
for fy in fy_top..=fy_bot {
let char_y = fy / 4;
let dot_y = fy % 4;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
PATTERNS.with(|p| {
let mut patterns = p.borrow_mut();
patterns.clear();
patterns.resize(width * height, 0);
for fine_y in 0..fine_height {
let start = fine_y * len / fine_height;
let end = ((fine_y + 1) * len / fine_height).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * auto_gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fx_left = ((min_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_right = ((max_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_left = fx_left.min(fine_width - 1);
let fx_right = fx_right.min(fine_width - 1);
let char_y = fine_y / 4;
let dot_y = fine_y % 4;
for fx in fx_left..=fx_right {
let char_x = fx / 2;
let dot_x = fx % 2;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}

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,
};