Feat: UI/UX fixes
All checks were successful
Deploy Website / deploy (push) Has been skipped

This commit is contained in:
2026-03-05 00:28:30 +01:00
parent 60fb62829f
commit 2c8a6794a3
7 changed files with 154 additions and 30 deletions

View File

@@ -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();

View File

@@ -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,
)

View File

@@ -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') => {

View File

@@ -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();
}
_ => {}
},

View File

@@ -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),

View File

@@ -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();

View File

@@ -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 => {