diff --git a/crates/ratatui/src/file_browser.rs b/crates/ratatui/src/file_browser.rs index 6b69716..a37cefc 100644 --- a/crates/ratatui/src/file_browser.rs +++ b/crates/ratatui/src/file_browser.rs @@ -14,11 +14,14 @@ pub struct FileBrowserModal<'a> { title: &'a str, input: &'a str, entries: &'a [(String, bool, bool)], + audio_counts: &'a [Option], selected: usize, scroll_offset: usize, border_color: Option, width: u16, height: u16, + hints: Option>, + color_path: bool, } impl<'a> FileBrowserModal<'a> { @@ -27,11 +30,14 @@ impl<'a> FileBrowserModal<'a> { title, input, entries, + audio_counts: &[], selected: 0, scroll_offset: 0, border_color: None, width: 60, height: 16, + hints: None, + color_path: false, } } @@ -60,6 +66,21 @@ impl<'a> FileBrowserModal<'a> { self } + pub fn hints(mut self, hints: Line<'a>) -> Self { + self.hints = Some(hints); + self + } + + pub fn audio_counts(mut self, counts: &'a [Option]) -> Self { + self.audio_counts = counts; + self + } + + pub fn color_path(mut self) -> Self { + self.color_path = true; + self + } + pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect { let colors = theme::get(); let border_color = self.border_color.unwrap_or(colors.ui.text_primary); @@ -70,37 +91,61 @@ impl<'a> FileBrowserModal<'a> { .border_color(border_color) .render_centered(frame, term); - let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner); + let has_hints = self.hints.is_some(); + let constraints = if has_hints { + vec![ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + } else { + vec![Constraint::Length(1), Constraint::Min(1)] + }; + let rows = Layout::vertical(constraints).split(inner); // Input line - frame.render_widget( - Paragraph::new(Line::from(vec![ + let input_spans = if self.color_path { + let (path_part, filter_part) = match self.input.rfind('/') { + Some(pos) => (&self.input[..=pos], &self.input[pos + 1..]), + None => ("", self.input), + }; + vec![ + Span::raw("> "), + Span::styled(path_part.to_string(), Style::new().fg(colors.browser.directory)), + Span::styled(filter_part.to_string(), Style::new().fg(colors.input.text)), + Span::styled("█", Style::new().fg(colors.input.cursor)), + ] + } else { + vec![ Span::raw("> "), Span::styled(self.input, Style::new().fg(colors.input.text)), Span::styled("█", Style::new().fg(colors.input.cursor)), - ])), - rows[0], - ); + ] + }; + frame.render_widget(Paragraph::new(Line::from(input_spans)), rows[0]); + + // Hints bar + if let Some(hints) = self.hints { + let hint_row = rows[2]; + frame.render_widget( + Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right), + hint_row, + ); + } // Entries list let visible_height = rows[1].height as usize; let visible_entries = self .entries .iter() + .enumerate() .skip(self.scroll_offset) .take(visible_height); let lines: Vec = visible_entries - .enumerate() - .map(|(i, (name, is_dir, is_cagire))| { - let abs_idx = i + self.scroll_offset; + .map(|(abs_idx, (name, is_dir, is_cagire))| { let is_selected = abs_idx == self.selected; let prefix = if is_selected { "> " } else { " " }; - let display = if *is_dir { - format!("{prefix}{name}/") - } else { - format!("{prefix}{name}") - }; let color = if is_selected { colors.browser.selected } else if *is_dir { @@ -110,7 +155,21 @@ impl<'a> FileBrowserModal<'a> { } else { colors.browser.file }; - Line::from(Span::styled(display, Style::new().fg(color))) + let display = if *is_dir { + format!("{prefix}{name}/") + } else { + format!("{prefix}{name}") + }; + let mut spans = vec![Span::styled(display, Style::new().fg(color))]; + if *is_dir && name != ".." { + if let Some(Some(count)) = self.audio_counts.get(abs_idx) { + spans.push(Span::styled( + format!(" ({count})"), + Style::new().fg(colors.browser.file), + )); + } + } + Line::from(spans) }) .collect(); diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 28a2274..9c5a512 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -413,9 +413,15 @@ pub fn build_stream( }, { let device_lost = Arc::clone(&device_lost); - move |err| { + move |err: cpal::StreamError| { eprintln!("input stream error: {err}"); - device_lost.store(true, Ordering::Release); + match err { + cpal::StreamError::DeviceNotAvailable + | cpal::StreamError::StreamInvalidated => { + device_lost.store(true, Ordering::Release); + } + _ => {} + } } }, None, @@ -528,9 +534,15 @@ pub fn build_stream( let _ = fft_producer.try_push(mono); } }, - move |err| { + move |err: cpal::StreamError| { let _ = error_tx.try_send(format!("stream error: {err}")); - device_lost.store(true, Ordering::Release); + match err { + cpal::StreamError::DeviceNotAvailable + | cpal::StreamError::StreamInvalidated => { + device_lost.store(true, Ordering::Release); + } + _ => {} + } }, None, ) diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index 7295a4c..aee636f 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -267,7 +267,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input } KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => { use crate::state::file_browser::FileBrowserState; - let state = FileBrowserState::new_load(String::new()); + let mut state = FileBrowserState::new_load(String::new()); + state.compute_audio_counts(); ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state)))); } KeyCode::Char('D') => { diff --git a/src/input/modal.rs b/src/input/modal.rs index 5954d39..9454444 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -249,8 +249,12 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input Some(state.current_dir().join(&entry.name)) } else if entry.is_dir { state.enter_selected(); + state.compute_audio_counts(); None } else { + ctx.dispatch(AppCommand::SetStatus( + "Select a directory, not a file".into(), + )); None } } else { @@ -288,15 +292,16 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input } } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Tab => state.autocomplete(), - KeyCode::Left => state.go_up(), - KeyCode::Right => state.enter_selected(), - KeyCode::Up => state.select_prev(14), - KeyCode::Down => state.select_next(14), - KeyCode::Backspace => state.backspace(), + KeyCode::Tab => { state.autocomplete(); state.compute_audio_counts(); } + KeyCode::Left => { state.go_up(); state.compute_audio_counts(); } + KeyCode::Right => { state.enter_selected(); state.compute_audio_counts(); } + KeyCode::Up => state.select_prev(16), + KeyCode::Down => state.select_next(16), + KeyCode::Backspace => { state.backspace(); state.compute_audio_counts(); } KeyCode::Char(c) => { state.input.push(c); state.refresh_entries(); + state.compute_audio_counts(); } _ => {} }, diff --git a/src/input/mouse.rs b/src/input/mouse.rs index fc1f1ce..5087d2a 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -983,7 +983,8 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { Modal::PatternProps { .. } => (50, 18), Modal::EuclideanDistribution { .. } => (50, 11), Modal::Onboarding { .. } => (57, 20), - Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18), + Modal::FileBrowser(_) => (60, 18), + Modal::AddSamplePath(_) => (70, 20), Modal::Rename { .. } => (40, 5), Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5), Modal::SetTempo(_) => (30, 5), diff --git a/src/state/file_browser.rs b/src/state/file_browser.rs index 36669d4..f4dc4a5 100644 --- a/src/state/file_browser.rs +++ b/src/state/file_browser.rs @@ -27,6 +27,7 @@ pub struct FileBrowserState { pub entries: Vec, pub selected: usize, pub scroll_offset: usize, + pub audio_counts: Vec>, } impl FileBrowserState { @@ -37,6 +38,7 @@ impl FileBrowserState { entries: Vec::new(), selected: 0, scroll_offset: 0, + audio_counts: Vec::new(), }; state.refresh_entries(); state @@ -49,6 +51,7 @@ impl FileBrowserState { entries: Vec::new(), selected: 0, scroll_offset: 0, + audio_counts: Vec::new(), }; state.refresh_entries(); state @@ -119,10 +122,27 @@ impl FileBrowserState { }); self.entries = entries; + self.audio_counts = Vec::new(); self.selected = 0; self.scroll_offset = 0; } + pub fn compute_audio_counts(&mut self) { + let dir = self.current_dir(); + self.audio_counts = self + .entries + .iter() + .map(|entry| { + if !entry.is_dir || entry.name == ".." { + return None; + } + let path = dir.join(&entry.name); + let count = count_audio_files(&path); + if count > 0 { Some(count) } else { None } + }) + .collect(); + } + pub fn autocomplete(&mut self) { let real_entries: Vec<&DirEntry> = self.entries.iter().filter(|e| e.name != "..").collect(); @@ -249,6 +269,23 @@ fn ensure_parent_dirs(path: &Path) { } } +fn count_audio_files(path: &Path) -> usize { + let Ok(read_dir) = fs::read_dir(path) else { + return 0; + }; + read_dir + .flatten() + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + matches!( + name.rsplit('.').next().map(|ext| ext.to_lowercase()).as_deref(), + Some("wav" | "flac" | "ogg" | "aiff" | "aif" | "mp3") + ) + }) + .count() +} + fn longest_common_prefix(entries: &[&DirEntry]) -> String { if entries.is_empty() { return String::new(); diff --git a/src/views/render.rs b/src/views/render.rs index d2c4a7e..d6563d6 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -719,12 +719,21 @@ fn render_modal( .iter() .map(|e| (e.name.clone(), e.is_dir, e.is_cagire())) .collect(); - FileBrowserModal::new("Add Sample Path", &state.input, &entries) + let hints = hint_line(&[ + ("\u{2190}", "parent"), + ("\u{2192}", "enter"), + ("Enter", "add"), + ("Esc", "cancel"), + ]); + FileBrowserModal::new("Browse Samples", &state.input, &entries) .selected(state.selected) .scroll_offset(state.scroll_offset) .border_color(theme.modal.rename) - .width(60) - .height(18) + .audio_counts(&state.audio_counts) + .hints(hints) + .color_path() + .width(70) + .height(20) .render_centered(frame, term) } Modal::Editor => {