This commit is contained in:
@@ -14,11 +14,14 @@ pub struct FileBrowserModal<'a> {
|
||||
title: &'a str,
|
||||
input: &'a str,
|
||||
entries: &'a [(String, bool, bool)],
|
||||
audio_counts: &'a [Option<usize>],
|
||||
selected: usize,
|
||||
scroll_offset: usize,
|
||||
border_color: Option<Color>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
hints: Option<Line<'a>>,
|
||||
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<usize>]) -> 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<Line> = 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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct FileBrowserState {
|
||||
pub entries: Vec<DirEntry>,
|
||||
pub selected: usize,
|
||||
pub scroll_offset: usize,
|
||||
pub audio_counts: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user