This commit is contained in:
@@ -14,11 +14,14 @@ pub struct FileBrowserModal<'a> {
|
|||||||
title: &'a str,
|
title: &'a str,
|
||||||
input: &'a str,
|
input: &'a str,
|
||||||
entries: &'a [(String, bool, bool)],
|
entries: &'a [(String, bool, bool)],
|
||||||
|
audio_counts: &'a [Option<usize>],
|
||||||
selected: usize,
|
selected: usize,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
border_color: Option<Color>,
|
border_color: Option<Color>,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
|
hints: Option<Line<'a>>,
|
||||||
|
color_path: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FileBrowserModal<'a> {
|
impl<'a> FileBrowserModal<'a> {
|
||||||
@@ -27,11 +30,14 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
title,
|
title,
|
||||||
input,
|
input,
|
||||||
entries,
|
entries,
|
||||||
|
audio_counts: &[],
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
border_color: None,
|
border_color: None,
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
hints: None,
|
||||||
|
color_path: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +66,21 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
self
|
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 {
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||||
let colors = theme::get();
|
let colors = theme::get();
|
||||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||||
@@ -70,37 +91,61 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
.border_color(border_color)
|
.border_color(border_color)
|
||||||
.render_centered(frame, term);
|
.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
|
// Input line
|
||||||
frame.render_widget(
|
let input_spans = if self.color_path {
|
||||||
Paragraph::new(Line::from(vec![
|
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::raw("> "),
|
||||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
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
|
// Entries list
|
||||||
let visible_height = rows[1].height as usize;
|
let visible_height = rows[1].height as usize;
|
||||||
let visible_entries = self
|
let visible_entries = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
|
.enumerate()
|
||||||
.skip(self.scroll_offset)
|
.skip(self.scroll_offset)
|
||||||
.take(visible_height);
|
.take(visible_height);
|
||||||
|
|
||||||
let lines: Vec<Line> = visible_entries
|
let lines: Vec<Line> = visible_entries
|
||||||
.enumerate()
|
.map(|(abs_idx, (name, is_dir, is_cagire))| {
|
||||||
.map(|(i, (name, is_dir, is_cagire))| {
|
|
||||||
let abs_idx = i + self.scroll_offset;
|
|
||||||
let is_selected = abs_idx == self.selected;
|
let is_selected = abs_idx == self.selected;
|
||||||
let prefix = if is_selected { "> " } else { " " };
|
let prefix = if is_selected { "> " } else { " " };
|
||||||
let display = if *is_dir {
|
|
||||||
format!("{prefix}{name}/")
|
|
||||||
} else {
|
|
||||||
format!("{prefix}{name}")
|
|
||||||
};
|
|
||||||
let color = if is_selected {
|
let color = if is_selected {
|
||||||
colors.browser.selected
|
colors.browser.selected
|
||||||
} else if *is_dir {
|
} else if *is_dir {
|
||||||
@@ -110,7 +155,21 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
} else {
|
} else {
|
||||||
colors.browser.file
|
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();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -413,10 +413,16 @@ pub fn build_stream(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
let device_lost = Arc::clone(&device_lost);
|
let device_lost = Arc::clone(&device_lost);
|
||||||
move |err| {
|
move |err: cpal::StreamError| {
|
||||||
eprintln!("input stream error: {err}");
|
eprintln!("input stream error: {err}");
|
||||||
|
match err {
|
||||||
|
cpal::StreamError::DeviceNotAvailable
|
||||||
|
| cpal::StreamError::StreamInvalidated => {
|
||||||
device_lost.store(true, Ordering::Release);
|
device_lost.store(true, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -528,9 +534,15 @@ pub fn build_stream(
|
|||||||
let _ = fft_producer.try_push(mono);
|
let _ = fft_producer.try_push(mono);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
move |err| {
|
move |err: cpal::StreamError| {
|
||||||
let _ = error_tx.try_send(format!("stream error: {err}"));
|
let _ = error_tx.try_send(format!("stream error: {err}"));
|
||||||
|
match err {
|
||||||
|
cpal::StreamError::DeviceNotAvailable
|
||||||
|
| cpal::StreamError::StreamInvalidated => {
|
||||||
device_lost.store(true, Ordering::Release);
|
device_lost.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None,
|
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 => {
|
KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
|
||||||
use crate::state::file_browser::FileBrowserState;
|
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))));
|
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
||||||
}
|
}
|
||||||
KeyCode::Char('D') => {
|
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))
|
Some(state.current_dir().join(&entry.name))
|
||||||
} else if entry.is_dir {
|
} else if entry.is_dir {
|
||||||
state.enter_selected();
|
state.enter_selected();
|
||||||
|
state.compute_audio_counts();
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(
|
||||||
|
"Select a directory, not a file".into(),
|
||||||
|
));
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -288,15 +292,16 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
KeyCode::Tab => state.autocomplete(),
|
KeyCode::Tab => { state.autocomplete(); state.compute_audio_counts(); }
|
||||||
KeyCode::Left => state.go_up(),
|
KeyCode::Left => { state.go_up(); state.compute_audio_counts(); }
|
||||||
KeyCode::Right => state.enter_selected(),
|
KeyCode::Right => { state.enter_selected(); state.compute_audio_counts(); }
|
||||||
KeyCode::Up => state.select_prev(14),
|
KeyCode::Up => state.select_prev(16),
|
||||||
KeyCode::Down => state.select_next(14),
|
KeyCode::Down => state.select_next(16),
|
||||||
KeyCode::Backspace => state.backspace(),
|
KeyCode::Backspace => { state.backspace(); state.compute_audio_counts(); }
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
state.input.push(c);
|
state.input.push(c);
|
||||||
state.refresh_entries();
|
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::PatternProps { .. } => (50, 18),
|
||||||
Modal::EuclideanDistribution { .. } => (50, 11),
|
Modal::EuclideanDistribution { .. } => (50, 11),
|
||||||
Modal::Onboarding { .. } => (57, 20),
|
Modal::Onboarding { .. } => (57, 20),
|
||||||
Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18),
|
Modal::FileBrowser(_) => (60, 18),
|
||||||
|
Modal::AddSamplePath(_) => (70, 20),
|
||||||
Modal::Rename { .. } => (40, 5),
|
Modal::Rename { .. } => (40, 5),
|
||||||
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
|
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
|
||||||
Modal::SetTempo(_) => (30, 5),
|
Modal::SetTempo(_) => (30, 5),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub struct FileBrowserState {
|
|||||||
pub entries: Vec<DirEntry>,
|
pub entries: Vec<DirEntry>,
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub scroll_offset: usize,
|
pub scroll_offset: usize,
|
||||||
|
pub audio_counts: Vec<Option<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileBrowserState {
|
impl FileBrowserState {
|
||||||
@@ -37,6 +38,7 @@ impl FileBrowserState {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
|
audio_counts: Vec::new(),
|
||||||
};
|
};
|
||||||
state.refresh_entries();
|
state.refresh_entries();
|
||||||
state
|
state
|
||||||
@@ -49,6 +51,7 @@ impl FileBrowserState {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
|
audio_counts: Vec::new(),
|
||||||
};
|
};
|
||||||
state.refresh_entries();
|
state.refresh_entries();
|
||||||
state
|
state
|
||||||
@@ -119,10 +122,27 @@ impl FileBrowserState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.entries = entries;
|
self.entries = entries;
|
||||||
|
self.audio_counts = Vec::new();
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
self.scroll_offset = 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) {
|
pub fn autocomplete(&mut self) {
|
||||||
let real_entries: Vec<&DirEntry> =
|
let real_entries: Vec<&DirEntry> =
|
||||||
self.entries.iter().filter(|e| e.name != "..").collect();
|
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 {
|
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
|
|||||||
@@ -719,12 +719,21 @@ fn render_modal(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
||||||
.collect();
|
.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)
|
.selected(state.selected)
|
||||||
.scroll_offset(state.scroll_offset)
|
.scroll_offset(state.scroll_offset)
|
||||||
.border_color(theme.modal.rename)
|
.border_color(theme.modal.rename)
|
||||||
.width(60)
|
.audio_counts(&state.audio_counts)
|
||||||
.height(18)
|
.hints(hints)
|
||||||
|
.color_path()
|
||||||
|
.width(70)
|
||||||
|
.height(20)
|
||||||
.render_centered(frame, term)
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::Editor => {
|
Modal::Editor => {
|
||||||
|
|||||||
Reference in New Issue
Block a user