From f75ea4bb97d840aa37e3295ba2d21c1ab872d4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Fri, 23 Jan 2026 23:36:23 +0100 Subject: [PATCH] chain word and better save/load UI --- crates/forth/src/ops.rs | 1 + crates/forth/src/types.rs | 1 + crates/forth/src/vm.rs | 19 +++ crates/forth/src/words.rs | 15 ++ crates/ratatui/src/file_browser.rs | 110 ++++++++++++ crates/ratatui/src/lib.rs | 4 + crates/ratatui/src/list_select.rs | 103 +++++++++++ src/app.rs | 2 + src/engine/sequencer.rs | 39 ++++- src/input.rs | 129 ++++++++++---- src/model/mod.rs | 2 +- src/model/script.rs | 2 +- src/state/audio.rs | 106 +++++------- src/state/file_browser.rs | 266 +++++++++++++++++++++++++++++ src/state/mod.rs | 1 + src/state/modal.rs | 4 +- src/views/audio_view.rs | 71 +++++--- src/views/render.rs | 26 ++- src/widgets/mod.rs | 5 +- tests/forth/harness.rs | 1 + 20 files changed, 775 insertions(+), 132 deletions(-) create mode 100644 crates/ratatui/src/file_browser.rs create mode 100644 crates/ratatui/src/list_select.rs create mode 100644 src/state/file_browser.rs diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 22b39c2..374b117 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -79,4 +79,5 @@ pub enum Op { Ramp, Range, Noise, + Chain, } diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index ae1fd69..a4a0822 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -19,6 +19,7 @@ pub struct ExecutionTrace { pub struct StepContext { pub step: usize, pub beat: f64, + pub bank: usize, pub pattern: usize, pub tempo: f64, pub phase: f64, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index ab4171c..5000e28 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -299,6 +299,7 @@ impl Forth { let val = match name.as_str() { "step" => Value::Int(ctx.step as i64, None), "beat" => Value::Float(ctx.beat, None), + "bank" => Value::Int(ctx.bank as i64, None), "pattern" => Value::Int(ctx.pattern as i64, None), "tempo" => Value::Float(ctx.tempo, None), "phase" => Value::Float(ctx.phase, None), @@ -628,6 +629,24 @@ impl Forth { .insert("__tempo__".to_string(), Value::Float(clamped, None)); } + Op::Chain => { + let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1; + let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1; + if bank < 0 || pattern < 0 { + return Err("chain: bank and pattern must be >= 1".into()); + } + if bank as usize == ctx.bank && pattern as usize == ctx.pattern { + // chaining to self is a no-op + } else { + let key = format!("__chain_{}_{}__", ctx.bank, ctx.pattern); + let val = format!("{bank}:{pattern}"); + self.vars + .lock() + .unwrap() + .insert(key, Value::Str(val, None)); + } + } + Op::ListStart => { stack.push(Value::Marker); } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 73fc8c7..b410c16 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -428,6 +428,13 @@ pub const WORDS: &[Word] = &[ example: "pattern => 0", compile: Context("pattern"), }, + Word { + name: "pbank", + stack: "(-- n)", + desc: "Current pattern's bank index", + example: "pbank => 0", + compile: Context("bank"), + }, Word { name: "tempo", stack: "(-- f)", @@ -621,6 +628,13 @@ pub const WORDS: &[Word] = &[ example: "140 tempo!", compile: Simple, }, + Word { + name: "chain", + stack: "(bank pattern --)", + desc: "Chain to bank/pattern (1-indexed) when current pattern ends", + example: "1 4 chain", + compile: Simple, + }, // Lists Word { name: "[", @@ -1550,6 +1564,7 @@ pub(super) fn simple_op(name: &str) -> Option { "ramp" => Op::Ramp, "range" => Op::Range, "noise" => Op::Noise, + "chain" => Op::Chain, _ => return None, }) } diff --git a/crates/ratatui/src/file_browser.rs b/crates/ratatui/src/file_browser.rs new file mode 100644 index 0000000..ac45a5c --- /dev/null +++ b/crates/ratatui/src/file_browser.rs @@ -0,0 +1,110 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::ModalFrame; + +pub struct FileBrowserModal<'a> { + title: &'a str, + input: &'a str, + entries: &'a [(String, bool)], + selected: usize, + scroll_offset: usize, + border_color: Color, + width: u16, + height: u16, +} + +impl<'a> FileBrowserModal<'a> { + pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool)]) -> Self { + Self { + title, + input, + entries, + selected: 0, + scroll_offset: 0, + border_color: Color::White, + width: 60, + height: 16, + } + } + + pub fn selected(mut self, idx: usize) -> Self { + self.selected = idx; + self + } + + pub fn scroll_offset(mut self, offset: usize) -> Self { + self.scroll_offset = offset; + self + } + + pub fn border_color(mut self, c: Color) -> Self { + self.border_color = c; + self + } + + pub fn width(mut self, w: u16) -> Self { + self.width = w; + self + } + + pub fn height(mut self, h: u16) -> Self { + self.height = h; + self + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + let inner = ModalFrame::new(self.title) + .width(self.width) + .height(self.height) + .border_color(self.border_color) + .render_centered(frame, term); + + let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner); + + // Input line + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(self.input, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])), + rows[0], + ); + + // Entries list + let visible_height = rows[1].height as usize; + let visible_entries = self + .entries + .iter() + .skip(self.scroll_offset) + .take(visible_height); + + let lines: Vec = visible_entries + .enumerate() + .map(|(i, (name, is_dir))| { + let abs_idx = i + self.scroll_offset; + 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 { + Color::Yellow + } else if *is_dir { + Color::Blue + } else { + Color::White + }; + Line::from(Span::styled(display, Style::new().fg(color))) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), rows[1]); + } +} diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 5f7fc07..4929b06 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -1,4 +1,6 @@ mod confirm; +mod file_browser; +mod list_select; mod modal; mod scope; mod spectrum; @@ -6,6 +8,8 @@ mod text_input; mod vu_meter; pub use confirm::ConfirmModal; +pub use file_browser::FileBrowserModal; +pub use list_select::ListSelect; pub use modal::ModalFrame; pub use scope::{Orientation, Scope}; pub use spectrum::Spectrum; diff --git a/crates/ratatui/src/list_select.rs b/crates/ratatui/src/list_select.rs new file mode 100644 index 0000000..297498f --- /dev/null +++ b/crates/ratatui/src/list_select.rs @@ -0,0 +1,103 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub struct ListSelect<'a> { + items: &'a [String], + selected: usize, + cursor: usize, + focused: bool, + visible_count: usize, + scroll_offset: usize, +} + +impl<'a> ListSelect<'a> { + pub fn new(items: &'a [String], selected: usize, cursor: usize) -> Self { + Self { + items, + selected, + cursor, + focused: false, + visible_count: 5, + scroll_offset: 0, + } + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn scroll_offset(mut self, offset: usize) -> Self { + self.scroll_offset = offset; + self + } + + pub fn visible_count(mut self, n: usize) -> Self { + self.visible_count = n; + self + } + + pub fn height(&self) -> u16 { + let item_lines = self.items.len().min(self.visible_count) as u16; + if self.items.len() > self.visible_count { + item_lines + 1 + } else { + item_lines + } + } + + pub fn render(self, frame: &mut Frame, area: Rect) { + let cursor_style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let selected_style = Style::new().fg(Color::Cyan); + let normal_style = Style::default(); + let indicator_style = Style::new().fg(Color::DarkGray); + + let visible_end = (self.scroll_offset + self.visible_count).min(self.items.len()); + let has_above = self.scroll_offset > 0; + let has_below = visible_end < self.items.len(); + + let mut lines: Vec = Vec::new(); + + for i in self.scroll_offset..visible_end { + let name = &self.items[i]; + let is_cursor = self.focused && i == self.cursor; + let is_selected = i == self.selected; + + let style = if is_cursor { + cursor_style + } else if is_selected { + selected_style + } else { + normal_style + }; + + let prefix = if is_selected { "● " } else { " " }; + let mut spans = vec![ + Span::styled(prefix.to_string(), style), + Span::styled(name.clone(), style), + ]; + + if has_above && i == self.scroll_offset { + spans.push(Span::styled(" ▲", indicator_style)); + } else if has_below && i == visible_end - 1 { + spans.push(Span::styled(" ▼", indicator_style)); + } + + lines.push(Line::from(spans)); + } + + if self.items.len() > self.visible_count { + let position = self.cursor + 1; + let total = self.items.len(); + lines.push(Line::from(Span::styled( + format!(" ({position}/{total})"), + indicator_style, + ))); + } + + frame.render_widget(Paragraph::new(lines), area); + } +} diff --git a/src/app.rs b/src/app.rs index ffabcb4..25dc86b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -279,6 +279,7 @@ impl App { let ctx = StepContext { step: step_idx, beat: link.beat(), + bank, pattern, tempo: link.tempo(), phase: link.phase(), @@ -353,6 +354,7 @@ impl App { let ctx = StepContext { step: step_idx, beat: 0.0, + bank, pattern, tempo: link.tempo(), phase: 0.0, diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 4391541..d9bb792 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -6,7 +6,7 @@ use std::time::Duration; use super::LinkState; use crate::model::{MAX_BANKS, MAX_PATTERNS}; -use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Variables}; +use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables}; use crate::state::LiveKeyState; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -367,10 +367,13 @@ fn sequencer_loop( step_traces.retain(|&(bank, pattern, _), _| { bank != id.bank || pattern != id.pattern }); + let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern); + variables.lock().unwrap().remove(&chain_key); } } let prev_beat = audio_state.prev_beat; + let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new(); for (_id, active) in audio_state.active_patterns.iter_mut() { let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else { @@ -397,6 +400,7 @@ fn sequencer_loop( let ctx = StepContext { step: step_idx, beat, + bank: active.bank, pattern: active.pattern, tempo, phase: beat % quantum, @@ -440,11 +444,44 @@ fn sequencer_loop( let next_step = active.step_index + 1; if next_step >= pattern.length { active.iter += 1; + let chain_key = format!("__chain_{}_{}__", active.bank, active.pattern); + let chain_target = { + let vars = variables.lock().unwrap(); + vars.get(&chain_key).and_then(|v| { + if let Value::Str(s, _) = v { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() == 2 { + let b = parts[0].parse::().ok()?; + let p = parts[1].parse::().ok()?; + Some(PatternId { bank: b, pattern: p }) + } else { + None + } + } else { + None + } + }) + }; + if let Some(target) = chain_target { + let source = PatternId { bank: active.bank, pattern: active.pattern }; + chain_transitions.push((source, target)); + } } active.step_index = next_step % pattern.length; } } + for (source, target) in chain_transitions { + if !audio_state.pending_stops.contains(&source) { + audio_state.pending_stops.push(source); + } + if !audio_state.pending_starts.contains(&target) { + audio_state.pending_starts.push(target); + } + let chain_key = format!("__chain_{}_{}__", source.bank, source.pattern); + variables.lock().unwrap().remove(&chain_key); + } + { let mut state = shared_state.lock().unwrap(); state.active_patterns = audio_state diff --git a/src/input.rs b/src/input.rs index 9355db9..ffdd752 100644 --- a/src/input.rs +++ b/src/input.rs @@ -190,31 +190,32 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } - Modal::SaveAs(path) => match key.code { + Modal::FileBrowser(state) => match key.code { KeyCode::Enter => { - let save_path = PathBuf::from(path.as_str()); - ctx.dispatch(AppCommand::CloseModal); - ctx.dispatch(AppCommand::Save(save_path)); + use crate::state::file_browser::FileBrowserMode; + let mode = state.mode.clone(); + if let Some(path) = state.confirm() { + ctx.dispatch(AppCommand::CloseModal); + match mode { + FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)), + FileBrowserMode::Load => { + ctx.dispatch(AppCommand::Load(path)); + load_project_samples(ctx); + } + } + } } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - path.pop(); + KeyCode::Tab => state.autocomplete(), + KeyCode::Left => state.go_up(), + KeyCode::Right => state.enter_selected(), + KeyCode::Up => state.select_prev(12), + KeyCode::Down => state.select_next(12), + KeyCode::Backspace => state.backspace(), + KeyCode::Char(c) => { + state.input.push(c); + state.refresh_entries(); } - KeyCode::Char(c) => path.push(c), - _ => {} - }, - Modal::LoadFrom(path) => match key.code { - KeyCode::Enter => { - let load_path = PathBuf::from(path.as_str()); - ctx.dispatch(AppCommand::CloseModal); - ctx.dispatch(AppCommand::Load(load_path)); - load_project_samples(ctx); - } - KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - path.pop(); - } - KeyCode::Char(c) => path.push(c), _ => {} }, Modal::RenameBank { bank, name } => match key.code { @@ -438,13 +439,23 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)), KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep), KeyCode::Char('s') => { - ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new()))); + use crate::state::file_browser::FileBrowserState; + let initial = ctx + .app + .project_state + .file_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let state = FileBrowserState::new_save(initial); + ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state))); } KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep), KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep), KeyCode::Char('l') => { + use crate::state::file_browser::FileBrowserState; let default_dir = ctx .app .project_state @@ -459,7 +470,8 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR s }) .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir))); + let state = FileBrowserState::new_load(default_dir); + ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state))); } KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), @@ -589,12 +601,62 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { selected: false, })); } - KeyCode::Up | KeyCode::Char('k') => ctx.app.audio.prev_focus(), - KeyCode::Down | KeyCode::Char('j') => ctx.app.audio.next_focus(), + KeyCode::Tab => ctx.app.audio.next_focus(), + KeyCode::BackTab => ctx.app.audio.prev_focus(), + KeyCode::Up => match ctx.app.audio.focus { + AudioFocus::OutputDevice => ctx.app.audio.output_list.move_up(), + AudioFocus::InputDevice => ctx.app.audio.input_list.move_up(), + _ => {} + }, + KeyCode::Down => match ctx.app.audio.focus { + AudioFocus::OutputDevice => { + let count = ctx.app.audio.output_devices.len(); + ctx.app.audio.output_list.move_down(count); + } + AudioFocus::InputDevice => { + let count = ctx.app.audio.input_devices.len(); + ctx.app.audio.input_list.move_down(count); + } + _ => {} + }, + KeyCode::PageUp => match ctx.app.audio.focus { + AudioFocus::OutputDevice => ctx.app.audio.output_list.page_up(), + AudioFocus::InputDevice => ctx.app.audio.input_list.page_up(), + _ => {} + }, + KeyCode::PageDown => match ctx.app.audio.focus { + AudioFocus::OutputDevice => { + let count = ctx.app.audio.output_devices.len(); + ctx.app.audio.output_list.page_down(count); + } + AudioFocus::InputDevice => { + let count = ctx.app.audio.input_devices.len(); + ctx.app.audio.input_list.page_down(count); + } + _ => {} + }, + KeyCode::Enter => match ctx.app.audio.focus { + AudioFocus::OutputDevice => { + let cursor = ctx.app.audio.output_list.cursor; + if cursor < ctx.app.audio.output_devices.len() { + ctx.app.audio.config.output_device = + Some(ctx.app.audio.output_devices[cursor].name.clone()); + ctx.app.save_settings(ctx.link); + } + } + AudioFocus::InputDevice => { + let cursor = ctx.app.audio.input_list.cursor; + if cursor < ctx.app.audio.input_devices.len() { + ctx.app.audio.config.input_device = + Some(ctx.app.audio.input_devices[cursor].name.clone()); + ctx.app.save_settings(ctx.link); + } + } + _ => {} + }, KeyCode::Left => { match ctx.app.audio.focus { - AudioFocus::OutputDevice => ctx.app.audio.prev_output_device(), - AudioFocus::InputDevice => ctx.app.audio.prev_input_device(), + AudioFocus::OutputDevice | AudioFocus::InputDevice => {} AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), @@ -614,14 +676,16 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0), } - if ctx.app.audio.focus != AudioFocus::SamplePaths { + if !matches!( + ctx.app.audio.focus, + AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice + ) { ctx.app.save_settings(ctx.link); } } KeyCode::Right => { match ctx.app.audio.focus { - AudioFocus::OutputDevice => ctx.app.audio.next_output_device(), - AudioFocus::InputDevice => ctx.app.audio.next_input_device(), + AudioFocus::OutputDevice | AudioFocus::InputDevice => {} AudioFocus::Channels => ctx.app.audio.adjust_channels(1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), @@ -641,7 +705,10 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0), } - if ctx.app.audio.focus != AudioFocus::SamplePaths { + if !matches!( + ctx.app.audio.focus, + AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice + ) { ctx.app.save_settings(ctx.link); } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 7bff813..ccce919 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,4 +2,4 @@ mod script; pub use cagire_forth::{Word, WordCompile, WORDS}; pub use cagire_project::{load, save, Bank, Pattern, PatternSpeed, Project, MAX_BANKS, MAX_PATTERNS}; -pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables}; +pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables}; diff --git a/src/model/script.rs b/src/model/script.rs index 476c52f..5ba1f38 100644 --- a/src/model/script.rs +++ b/src/model/script.rs @@ -1,6 +1,6 @@ use cagire_forth::Forth; -pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Variables}; +pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables}; pub struct ScriptEngine { forth: Forth, diff --git a/src/state/audio.rs b/src/state/audio.rs index 5dee5a0..a9ab2c9 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -77,6 +77,45 @@ impl Default for AudioConfig { } } +pub struct ListSelectState { + pub cursor: usize, + pub scroll_offset: usize, +} + +impl ListSelectState { + pub fn move_up(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + if self.cursor < self.scroll_offset { + self.scroll_offset = self.cursor; + } + } + } + + pub fn move_down(&mut self, item_count: usize) { + if self.cursor + 1 < item_count { + self.cursor += 1; + if self.cursor >= self.scroll_offset + 5 { + self.scroll_offset = self.cursor - 4; + } + } + } + + pub fn page_up(&mut self) { + self.cursor = self.cursor.saturating_sub(5); + if self.cursor < self.scroll_offset { + self.scroll_offset = self.cursor; + } + } + + pub fn page_down(&mut self, item_count: usize) { + self.cursor = (self.cursor + 5).min(item_count.saturating_sub(1)); + if self.cursor >= self.scroll_offset + 5 { + self.scroll_offset = self.cursor.saturating_sub(4); + } + } +} + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum AudioFocus { #[default] @@ -127,6 +166,8 @@ pub struct AudioSettings { pub focus: AudioFocus, pub output_devices: Vec, pub input_devices: Vec, + pub output_list: ListSelectState, + pub input_list: ListSelectState, pub restart_pending: bool, pub error: Option, } @@ -138,6 +179,8 @@ impl Default for AudioSettings { focus: AudioFocus::default(), output_devices: doux::audio::list_output_devices(), input_devices: doux::audio::list_input_devices(), + output_list: ListSelectState { cursor: 0, scroll_offset: 0 }, + input_list: ListSelectState { cursor: 0, scroll_offset: 0 }, restart_pending: false, error: None, } @@ -184,25 +227,7 @@ impl AudioSettings { }; } - pub fn next_output_device(&mut self) { - if self.output_devices.is_empty() { - return; - } - let current_idx = self.current_output_device_index(); - let next_idx = (current_idx + 1) % self.output_devices.len(); - self.config.output_device = Some(self.output_devices[next_idx].name.clone()); - } - - pub fn prev_output_device(&mut self) { - if self.output_devices.is_empty() { - return; - } - let current_idx = self.current_output_device_index(); - let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len(); - self.config.output_device = Some(self.output_devices[prev_idx].name.clone()); - } - - fn current_output_device_index(&self) -> usize { + pub fn current_output_device_index(&self) -> usize { match &self.config.output_device { Some(name) => self .output_devices @@ -217,25 +242,7 @@ impl AudioSettings { } } - pub fn next_input_device(&mut self) { - if self.input_devices.is_empty() { - return; - } - let current_idx = self.current_input_device_index(); - let next_idx = (current_idx + 1) % self.input_devices.len(); - self.config.input_device = Some(self.input_devices[next_idx].name.clone()); - } - - pub fn prev_input_device(&mut self) { - if self.input_devices.is_empty() { - return; - } - let current_idx = self.current_input_device_index(); - let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len(); - self.config.input_device = Some(self.input_devices[prev_idx].name.clone()); - } - - fn current_input_device_index(&self) -> usize { + pub fn current_input_device_index(&self) -> usize { match &self.config.input_device { Some(name) => self .input_devices @@ -264,29 +271,6 @@ impl AudioSettings { self.config.refresh_rate = self.config.refresh_rate.toggle(); } - pub fn current_output_device_name(&self) -> &str { - match &self.config.output_device { - Some(name) => name, - None => self - .output_devices - .iter() - .find(|d| d.is_default) - .map(|d| d.name.as_str()) - .unwrap_or("Default"), - } - } - - pub fn current_input_device_name(&self) -> &str { - match &self.config.input_device { - Some(name) => name, - None => self - .input_devices - .iter() - .find(|d| d.is_default) - .map(|d| d.name.as_str()) - .unwrap_or("None"), - } - } pub fn add_sample_path(&mut self, path: PathBuf) { if !self.config.sample_paths.contains(&path) { diff --git a/src/state/file_browser.rs b/src/state/file_browser.rs new file mode 100644 index 0000000..d57150d --- /dev/null +++ b/src/state/file_browser.rs @@ -0,0 +1,266 @@ +use std::cmp::Ordering; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Clone, PartialEq, Eq)] +pub enum FileBrowserMode { + Save, + Load, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct DirEntry { + pub name: String, + pub is_dir: bool, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct FileBrowserState { + pub mode: FileBrowserMode, + pub input: String, + pub entries: Vec, + pub selected: usize, + pub scroll_offset: usize, +} + +impl FileBrowserState { + pub fn new_save(initial_path: String) -> Self { + let mut state = Self { + mode: FileBrowserMode::Save, + input: initial_path, + entries: Vec::new(), + selected: 0, + scroll_offset: 0, + }; + state.refresh_entries(); + state + } + + pub fn new_load(initial_path: String) -> Self { + let mut state = Self { + mode: FileBrowserMode::Load, + input: initial_path, + entries: Vec::new(), + selected: 0, + scroll_offset: 0, + }; + state.refresh_entries(); + state + } + + fn current_dir(&self) -> PathBuf { + if self.input.is_empty() { + return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + } + let path = Path::new(&self.input); + if self.input.ends_with('/') { + path.to_path_buf() + } else { + match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), + } + } + } + + fn partial_name(&self) -> &str { + if self.input.is_empty() || self.input.ends_with('/') { + "" + } else if !self.input.contains('/') { + &self.input + } else { + Path::new(&self.input) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + } + } + + pub fn refresh_entries(&mut self) { + let dir = self.current_dir(); + let prefix = self.partial_name().to_lowercase(); + + let mut entries = Vec::new(); + + if prefix.is_empty() && dir.parent().is_some() { + entries.push(DirEntry { + name: "..".to_string(), + is_dir: true, + }); + } + + if let Ok(read_dir) = fs::read_dir(&dir) { + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + if prefix.is_empty() || name.to_lowercase().starts_with(&prefix) { + entries.push(DirEntry { name, is_dir }); + } + } + } + + entries.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { + ("..", _) => Ordering::Less, + (_, "..") => Ordering::Greater, + _ => match (a.is_dir, b.is_dir) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }, + }); + + self.entries = entries; + self.selected = 0; + self.scroll_offset = 0; + } + + pub fn autocomplete(&mut self) { + let real_entries: Vec<&DirEntry> = + self.entries.iter().filter(|e| e.name != "..").collect(); + if real_entries.is_empty() { + return; + } + + let lcp = longest_common_prefix(&real_entries); + if lcp.is_empty() { + return; + } + + let dir = self.current_dir(); + let mut new_input = dir.join(&lcp).display().to_string(); + + if real_entries.len() == 1 && real_entries[0].is_dir && !new_input.ends_with('/') { + new_input.push('/'); + } + + if new_input != self.input { + self.input = new_input; + self.refresh_entries(); + } + } + + pub fn select_next(&mut self, visible_height: usize) { + if self.selected + 1 < self.entries.len() { + self.selected += 1; + if self.selected >= self.scroll_offset + visible_height { + self.scroll_offset = self.selected + 1 - visible_height; + } + } + } + + pub fn select_prev(&mut self, _visible_height: usize) { + if self.selected > 0 { + self.selected -= 1; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } + } + } + + pub fn confirm(&mut self) -> Option { + if !self.entries.is_empty() { + let entry = &self.entries[self.selected]; + if entry.is_dir { + self.navigate_to_selected(); + return None; + } + let path = self.current_dir().join(&entry.name); + if self.mode == FileBrowserMode::Save { + ensure_parent_dirs(&path); + } + return Some(path); + } + + if self.mode == FileBrowserMode::Save && !self.input.is_empty() { + let path = PathBuf::from(&self.input); + ensure_parent_dirs(&path); + return Some(path); + } + + None + } + + pub fn enter_selected(&mut self) { + if let Some(entry) = self.entries.get(self.selected) { + if entry.is_dir { + self.navigate_to_selected(); + } + } + } + + pub fn go_up(&mut self) { + let dir = self.current_dir(); + if let Some(parent) = dir.parent() { + self.input = format_dir_path(parent); + self.refresh_entries(); + } + } + + pub fn backspace(&mut self) { + if self.input.is_empty() { + return; + } + if self.input.ends_with('/') && self.input.len() > 1 { + let trimmed = &self.input[..self.input.len() - 1]; + match trimmed.rfind('/') { + Some(pos) => self.input = trimmed[..=pos].to_string(), + None => self.input.clear(), + } + } else { + self.input.pop(); + } + self.refresh_entries(); + } + + fn navigate_to_selected(&mut self) { + let entry = &self.entries[self.selected]; + if entry.name == ".." { + self.go_up(); + } else { + let dir = self.current_dir(); + self.input = format_dir_path(&dir.join(&entry.name)); + self.refresh_entries(); + } + } +} + +fn format_dir_path(path: &Path) -> String { + let mut s = path.display().to_string(); + if !s.ends_with('/') { + s.push('/'); + } + s +} + +fn ensure_parent_dirs(path: &Path) { + if let Some(parent) = path.parent() { + if !parent.exists() { + let _ = fs::create_dir_all(parent); + } + } +} + +fn longest_common_prefix(entries: &[&DirEntry]) -> String { + if entries.is_empty() { + return String::new(); + } + let first = &entries[0].name; + let mut len = first.len(); + for entry in &entries[1..] { + len = first + .chars() + .zip(entry.name.chars()) + .take_while(|(a, b)| a.to_lowercase().eq(b.to_lowercase())) + .count() + .min(len); + } + first[..first + .char_indices() + .nth(len) + .map(|(i, _)| i) + .unwrap_or(first.len())] + .to_string() +} diff --git a/src/state/mod.rs b/src/state/mod.rs index 92b6491..cbe214f 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -1,5 +1,6 @@ pub mod audio; pub mod editor; +pub mod file_browser; pub mod live_keys; pub mod modal; pub mod patterns_nav; diff --git a/src/state/modal.rs b/src/state/modal.rs index be2c772..3e813b1 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -1,4 +1,5 @@ use crate::state::editor::PatternField; +use crate::state::file_browser::FileBrowserState; #[derive(Clone, PartialEq, Eq)] pub enum Modal { @@ -21,8 +22,7 @@ pub enum Modal { bank: usize, selected: bool, }, - SaveAs(String), - LoadFrom(String), + FileBrowser(FileBrowserState), RenameBank { bank: usize, name: String, diff --git a/src/views/audio_view.rs b/src/views/audio_view.rs index 579a279..1587025 100644 --- a/src/views/audio_view.rs +++ b/src/views/audio_view.rs @@ -1,3 +1,4 @@ +use cagire_ratatui::ListSelect; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -44,8 +45,10 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { height: inner.height.saturating_sub(1), }; + let devices_height = devices_section_height(app); + let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ - Constraint::Length(4), + Constraint::Length(devices_height), Constraint::Length(1), Constraint::Length(8), Constraint::Length(1), @@ -58,39 +61,57 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { render_samples(frame, app, samples_area); } +fn list_height(item_count: usize) -> u16 { + let visible = item_count.min(5) as u16; + if item_count > 5 { visible + 1 } else { visible } +} + +fn devices_section_height(app: &App) -> u16 { + // header(1) + "Output" label(1) + output list + "Input" label(1) + input list + 1 + 1 + list_height(app.audio.output_devices.len()) + + 1 + list_height(app.audio.input_devices.len()) +} + fn render_devices(frame: &mut Frame, app: &App, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); - - let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - - frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); - - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal = Style::new().fg(Color::White); let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - let output_name = truncate_name(app.audio.current_output_device_name(), 35); - let input_name = truncate_name(app.audio.current_input_device_name(), 35); + let output_h = list_height(app.audio.output_devices.len()); + let input_h = list_height(app.audio.input_devices.len()); - let output_focused = app.audio.focus == AudioFocus::OutputDevice; - let input_focused = app.audio.focus == AudioFocus::InputDevice; + let [header_area, output_label_area, output_list_area, input_label_area, input_list_area] = + Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(output_h), + Constraint::Length(1), + Constraint::Length(input_h), + ]) + .areas(area); - let rows = vec![ - Row::new(vec![ - Span::styled("Output", label_style), - render_selector(&output_name, output_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Input", label_style), - render_selector(&input_name, input_focused, highlight, normal), - ]), - ]; + frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); + frame.render_widget(Paragraph::new(Span::styled("Output", label_style)), output_label_area); + frame.render_widget(Paragraph::new(Span::styled("Input", label_style)), input_label_area); - let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); - frame.render_widget(table, content_area); + let output_items: Vec = app.audio.output_devices.iter() + .map(|d| truncate_name(&d.name, 35)) + .collect(); + let output_selected = app.audio.current_output_device_index(); + ListSelect::new(&output_items, output_selected, app.audio.output_list.cursor) + .focused(app.audio.focus == AudioFocus::OutputDevice) + .scroll_offset(app.audio.output_list.scroll_offset) + .render(frame, output_list_area); + + let input_items: Vec = app.audio.input_devices.iter() + .map(|d| truncate_name(&d.name, 35)) + .collect(); + let input_selected = app.audio.current_input_device_index(); + ListSelect::new(&input_items, input_selected, app.audio.input_list.cursor) + .focused(app.audio.focus == AudioFocus::InputDevice) + .scroll_offset(app.audio.input_list.scroll_offset) + .render(frame, input_list_area); } fn render_settings(frame: &mut Frame, app: &App, area: Rect) { diff --git a/src/views/render.rs b/src/views/render.rs index 507b12b..a68db31 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -316,16 +316,24 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) .render_centered(frame, term); } - Modal::SaveAs(path) => { - TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) + Modal::FileBrowser(state) => { + use crate::state::file_browser::FileBrowserMode; + use crate::widgets::FileBrowserModal; + let (title, border_color) = match state.mode { + FileBrowserMode::Save => ("Save As", Color::Green), + FileBrowserMode::Load => ("Load From", Color::Blue), + }; + let entries: Vec<(String, bool)> = state + .entries + .iter() + .map(|e| (e.name.clone(), e.is_dir)) + .collect(); + FileBrowserModal::new(title, &state.input, &entries) + .selected(state.selected) + .scroll_offset(state.scroll_offset) + .border_color(border_color) .width(60) - .border_color(Color::Green) - .render_centered(frame, term); - } - Modal::LoadFrom(path) => { - TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path) - .width(60) - .border_color(Color::Blue) + .height(18) .render_centered(frame, term); } Modal::RenameBank { bank, name } => { diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index d300371..c14d8d1 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1 +1,4 @@ -pub use cagire_ratatui::{ConfirmModal, ModalFrame, Orientation, Scope, Spectrum, TextInputModal, VuMeter}; +pub use cagire_ratatui::{ + ConfirmModal, FileBrowserModal, ModalFrame, Orientation, Scope, Spectrum, TextInputModal, + VuMeter, +}; diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs index 4328c03..25af589 100644 --- a/tests/forth/harness.rs +++ b/tests/forth/harness.rs @@ -8,6 +8,7 @@ pub fn default_ctx() -> StepContext { StepContext { step: 0, beat: 0.0, + bank: 0, pattern: 0, tempo: 120.0, phase: 0.0,