From 04f5e19ab20f730dbba1eb22513d6f23979a6563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 24 Jan 2026 01:59:51 +0100 Subject: [PATCH] WIP: half broken --- Cargo.toml | 1 - crates/ratatui/Cargo.toml | 1 + crates/ratatui/src/editor.rs | 392 +++++++++++++++++++++++++ crates/ratatui/src/lib.rs | 4 + crates/ratatui/src/sample_browser.rs | 170 +++++++++++ src/app.rs | 23 +- src/engine/audio.rs | 6 +- src/input.rs | 155 ++++++++-- src/main.rs | 4 + src/settings.rs | 10 + src/state/audio.rs | 21 +- src/state/editor.rs | 6 +- src/state/file_browser.rs | 2 +- src/state/mod.rs | 4 + src/state/modal.rs | 2 +- src/state/panel.rs | 28 ++ src/state/sample_browser.rs | 414 +++++++++++++++++++++++++++ src/state/ui.rs | 2 + src/views/audio_view.rs | 34 ++- src/views/render.rs | 146 +++++----- src/widgets/mod.rs | 4 +- 21 files changed, 1310 insertions(+), 119 deletions(-) create mode 100644 crates/ratatui/src/editor.rs create mode 100644 crates/ratatui/src/sample_browser.rs create mode 100644 src/state/panel.rs create mode 100644 src/state/sample_browser.rs diff --git a/Cargo.toml b/Cargo.toml index 5d17c7f..b9f919b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ clap = { version = "4", features = ["derive"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -tui-textarea = "0.7" tui-big-text = "0.7" arboard = "3" minimad = "0.13" diff --git a/crates/ratatui/Cargo.toml b/crates/ratatui/Cargo.toml index 1cf324c..060c608 100644 --- a/crates/ratatui/Cargo.toml +++ b/crates/ratatui/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] ratatui = "0.29" +tui-textarea = "0.7" diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs new file mode 100644 index 0000000..5f4b416 --- /dev/null +++ b/crates/ratatui/src/editor.rs @@ -0,0 +1,392 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; +use tui_textarea::TextArea; + +pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String)>; + +pub struct CompletionCandidate { + pub name: String, + pub signature: String, + pub description: String, + pub example: String, +} + +struct CompletionState { + candidates: Vec, + matches: Vec, + cursor: usize, + prefix: String, + prefix_start_col: usize, + active: bool, + enabled: bool, +} + +impl CompletionState { + fn new() -> Self { + Self { + candidates: Vec::new(), + matches: Vec::new(), + cursor: 0, + prefix: String::new(), + prefix_start_col: 0, + active: false, + enabled: true, + } + } +} + +pub struct Editor { + text: TextArea<'static>, + completion: CompletionState, +} + +impl Default for Editor { + fn default() -> Self { + Self::new() + } +} + +impl Editor { + pub fn new() -> Self { + Self { + text: TextArea::default(), + completion: CompletionState::new(), + } + } + + pub fn set_content(&mut self, lines: Vec) { + self.text = TextArea::new(lines); + self.completion.active = false; + } + + pub fn set_candidates(&mut self, candidates: Vec) { + self.completion.candidates = candidates; + } + + pub fn content(&self) -> String { + self.text.lines().join("\n") + } + + pub fn lines(&self) -> &[String] { + self.text.lines() + } + + pub fn cursor(&self) -> (usize, usize) { + self.text.cursor() + } + + pub fn completion_active(&self) -> bool { + self.completion.active + } + + pub fn dismiss_completion(&mut self) { + self.completion.active = false; + } + + pub fn set_completion_enabled(&mut self, enabled: bool) { + self.completion.enabled = enabled; + if !enabled { + self.completion.active = false; + } + } + + pub fn input(&mut self, input: impl Into) { + let input: tui_textarea::Input = input.into(); + let has_modifier = input.ctrl || input.alt; + + if self.completion.active && !has_modifier { + match &input { + tui_textarea::Input { key: tui_textarea::Key::Up, .. } => { + if self.completion.cursor > 0 { + self.completion.cursor -= 1; + } + return; + } + tui_textarea::Input { key: tui_textarea::Key::Down, .. } => { + if self.completion.cursor + 1 < self.completion.matches.len() { + self.completion.cursor += 1; + } + return; + } + tui_textarea::Input { key: tui_textarea::Key::Tab, .. } => { + self.accept_completion(); + return; + } + tui_textarea::Input { key: tui_textarea::Key::Esc, .. } => { + self.completion.active = false; + return; + } + tui_textarea::Input { key: tui_textarea::Key::Char(c), .. } => { + if !is_word_char(*c) { + self.completion.active = false; + } + self.text.input(input); + self.update_completion(); + return; + } + _ => { + self.completion.active = false; + } + } + } + + self.text.input(input); + if !has_modifier { + self.update_completion(); + } + } + + fn update_completion(&mut self) { + if !self.completion.enabled || self.completion.candidates.is_empty() { + return; + } + + let (row, col) = self.text.cursor(); + let line = &self.text.lines()[row]; + + // col is a character index; convert to byte offset for slicing + let byte_col = line.char_indices() + .nth(col) + .map(|(i, _)| i) + .unwrap_or(line.len()); + + let prefix_start = line[..byte_col] + .char_indices() + .rev() + .take_while(|(_, c)| is_word_char(*c)) + .last() + .map(|(i, _)| i) + .unwrap_or(byte_col); + + let prefix = &line[prefix_start..byte_col]; + + if prefix.len() < 2 { + self.completion.active = false; + return; + } + + let prefix_lower = prefix.to_lowercase(); + let matches: Vec = self + .completion + .candidates + .iter() + .enumerate() + .filter(|(_, c)| c.name.to_lowercase().starts_with(&prefix_lower)) + .map(|(i, _)| i) + .collect(); + + if matches.is_empty() { + self.completion.active = false; + return; + } + + if matches.len() == 1 + && self.completion.candidates[matches[0]].name.to_lowercase() == prefix_lower + { + self.completion.active = false; + return; + } + + self.completion.prefix = prefix.to_string(); + self.completion.prefix_start_col = prefix_start; + self.completion.matches = matches; + self.completion.cursor = self.completion.cursor.min( + self.completion.matches.len().saturating_sub(1), + ); + self.completion.active = true; + } + + fn accept_completion(&mut self) { + if self.completion.matches.is_empty() { + self.completion.active = false; + return; + } + + let idx = self.completion.matches[self.completion.cursor]; + let name = self.completion.candidates[idx].name.clone(); + let prefix_len = self.completion.prefix.len(); + + for _ in 0..prefix_len { + self.text.delete_char(); + } + self.text.insert_str(&name); + + self.completion.active = false; + } + + pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) { + let (cursor_row, cursor_col) = self.text.cursor(); + let cursor_style = Style::default().bg(Color::White).fg(Color::Black); + + let lines: Vec = self + .text + .lines() + .iter() + .enumerate() + .map(|(row, line)| { + let tokens = highlighter(row, line); + let mut spans: Vec = Vec::new(); + + if row == cursor_row { + let mut col = 0; + for (style, text) in tokens { + let text_len = text.chars().count(); + if cursor_col >= col && cursor_col < col + text_len { + let before = text.chars().take(cursor_col - col).collect::(); + let cursor_char = + text.chars().nth(cursor_col - col).unwrap_or(' '); + let after = + text.chars().skip(cursor_col - col + 1).collect::(); + + if !before.is_empty() { + spans.push(Span::styled(before, style)); + } + spans.push(Span::styled(cursor_char.to_string(), cursor_style)); + if !after.is_empty() { + spans.push(Span::styled(after, style)); + } + } else { + spans.push(Span::styled(text, style)); + } + col += text_len; + } + if cursor_col >= col { + spans.push(Span::styled(" ", cursor_style)); + } + } else { + for (style, text) in tokens { + spans.push(Span::styled(text, style)); + } + } + + Line::from(spans) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), area); + + if self.completion.active && !self.completion.matches.is_empty() { + self.render_completion(frame, area, cursor_row); + } + } + + fn render_completion(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) { + let max_visible: usize = 6; + let list_width: u16 = 18; + let doc_width: u16 = 40; + let total_width = list_width + doc_width; + + let visible_count = self.completion.matches.len().min(max_visible); + let list_height = visible_count as u16; + let doc_height = 4u16; + let total_height = list_height.max(doc_height); + + let popup_x = (editor_area.x + self.completion.prefix_start_col as u16) + .min(editor_area.x + editor_area.width.saturating_sub(total_width)); + + let below_y = editor_area.y + cursor_row as u16 + 1; + let popup_y = if below_y + total_height > editor_area.y + editor_area.height { + (editor_area.y + cursor_row as u16).saturating_sub(total_height) + } else { + below_y + }; + + let scroll_offset = if self.completion.cursor >= max_visible { + self.completion.cursor - max_visible + 1 + } else { + 0 + }; + + // List panel + let list_area = Rect::new(popup_x, popup_y, list_width, total_height); + frame.render_widget(Clear, list_area); + + let highlight_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal_style = Style::default().fg(Color::White); + let bg_style = Style::default().bg(Color::Rgb(30, 30, 40)); + + let list_lines: Vec = (scroll_offset..scroll_offset + visible_count) + .map(|i| { + let idx = self.completion.matches[i]; + let name = &self.completion.candidates[idx].name; + let style = if i == self.completion.cursor { + highlight_style + } else { + normal_style + }; + let prefix = if i == self.completion.cursor { "> " } else { " " }; + let display = format!("{prefix}{name: = Vec::new(); + + let header = format!(" {} {}", candidate.name, candidate.signature); + doc_lines.push(Line::from(Span::styled( + format!("{header: bool { + c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#') +} diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 4929b06..9cd727d 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -1,16 +1,20 @@ mod confirm; +mod editor; mod file_browser; mod list_select; mod modal; +mod sample_browser; mod scope; mod spectrum; mod text_input; mod vu_meter; pub use confirm::ConfirmModal; +pub use editor::{CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; pub use list_select::ListSelect; pub use modal::ModalFrame; +pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind}; pub use scope::{Orientation, Scope}; pub use spectrum::Spectrum; pub use text_input::TextInputModal; diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs new file mode 100644 index 0000000..fcde87d --- /dev/null +++ b/crates/ratatui/src/sample_browser.rs @@ -0,0 +1,170 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +#[derive(Clone, Copy)] +pub enum TreeLineKind { + Root { expanded: bool }, + Folder { expanded: bool }, + File, +} + +#[derive(Clone)] +pub struct TreeLine { + pub depth: u8, + pub kind: TreeLineKind, + pub label: String, + pub folder: String, + pub index: usize, +} + +pub struct SampleBrowser<'a> { + entries: &'a [TreeLine], + cursor: usize, + scroll_offset: usize, + search_query: &'a str, + search_active: bool, + focused: bool, +} + +impl<'a> SampleBrowser<'a> { + pub fn new(entries: &'a [TreeLine], cursor: usize) -> Self { + Self { + entries, + cursor, + scroll_offset: 0, + search_query: "", + search_active: false, + focused: false, + } + } + + pub fn scroll_offset(mut self, offset: usize) -> Self { + self.scroll_offset = offset; + self + } + + pub fn search(mut self, query: &'a str, active: bool) -> Self { + self.search_query = query; + self.search_active = active; + self + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn render(self, frame: &mut Frame, area: Rect) { + let border_style = if self.focused { + Style::new().fg(Color::Yellow) + } else { + Style::new().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Samples "); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 || inner.width == 0 { + return; + } + + let show_search = self.search_active || !self.search_query.is_empty(); + let (search_area, list_area) = if show_search { + let [s, l] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(inner); + (Some(s), l) + } else { + (None, inner) + }; + + if let Some(sa) = search_area { + self.render_search(frame, sa); + } + self.render_tree(frame, list_area); + } + + fn render_search(&self, frame: &mut Frame, area: Rect) { + let style = if self.search_active { + Style::new().fg(Color::Yellow) + } else { + Style::new().fg(Color::DarkGray) + }; + let cursor = if self.search_active { "_" } else { "" }; + let text = format!("/{}{}", self.search_query, cursor); + let line = Line::from(Span::styled(text, style)); + frame.render_widget(Paragraph::new(vec![line]), area); + } + + fn render_tree(&self, frame: &mut Frame, area: Rect) { + let height = area.height as usize; + if self.entries.is_empty() { + let msg = if self.search_query.is_empty() { + "No samples loaded" + } else { + "No matches" + }; + let line = Line::from(Span::styled(msg, Style::new().fg(Color::DarkGray))); + frame.render_widget(Paragraph::new(vec![line]), area); + return; + } + + let visible_end = (self.scroll_offset + height).min(self.entries.len()); + let mut lines: Vec = Vec::with_capacity(height); + + for i in self.scroll_offset..visible_end { + let entry = &self.entries[i]; + let is_cursor = i == self.cursor; + let indent = " ".repeat(entry.depth as usize); + + let (icon, icon_color) = match entry.kind { + TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => { + ("\u{25BC} ", Color::Cyan) + } + TreeLineKind::Root { expanded: false } + | TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", Color::Cyan), + TreeLineKind::File => ("\u{266A} ", Color::DarkGray), + }; + + let label_style = if is_cursor && self.focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else if is_cursor { + Style::new().fg(Color::White) + } else { + match entry.kind { + TreeLineKind::Root { .. } => { + Style::new().fg(Color::White).add_modifier(Modifier::BOLD) + } + TreeLineKind::Folder { .. } => Style::new().fg(Color::Cyan), + TreeLineKind::File => Style::default(), + } + }; + + let icon_style = if is_cursor && self.focused { + label_style + } else { + Style::new().fg(icon_color) + }; + + let spans = vec![ + Span::raw(indent), + Span::styled(icon, icon_style), + Span::styled(&entry.label, label_style), + ]; + + lines.push(Line::from(spans)); + } + + frame.render_widget(Paragraph::new(lines), area); + } +} diff --git a/src/app.rs b/src/app.rs index 25dc86b..b95d480 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,8 +15,8 @@ use crate::page::Page; use crate::services::pattern_editor; use crate::settings::Settings; use crate::state::{ - AudioSettings, EditorContext, Focus, LiveKeyState, Metrics, Modal, PatternField, PatternsNav, - PlaybackState, ProjectState, UiState, + AudioSettings, EditorContext, Focus, LiveKeyState, Metrics, Modal, PanelState, PatternField, + PatternsNav, PlaybackState, ProjectState, UiState, }; use crate::views::doc_view; @@ -43,6 +43,7 @@ pub struct App { pub copied_bank: Option, pub audio: AudioSettings, + pub panel: PanelState, } impl App { @@ -74,6 +75,7 @@ impl App { copied_bank: None, audio: AudioSettings::default(), + panel: PanelState::default(), } } @@ -84,12 +86,14 @@ impl App { input_device: self.audio.config.input_device.clone(), channels: self.audio.config.channels, buffer_size: self.audio.config.buffer_size, + max_voices: self.audio.config.max_voices, }, display: crate::settings::DisplaySettings { fps: self.audio.config.refresh_rate.to_fps(), runtime_highlight: self.ui.runtime_highlight, show_scope: self.audio.config.show_scope, show_spectrum: self.audio.config.show_spectrum, + show_completion: self.ui.show_completion, }, link: crate::settings::LinkSettings { enabled: link.is_enabled(), @@ -233,12 +237,23 @@ impl App { } else { script.lines().map(String::from).collect() }; - self.editor_ctx.text = tui_textarea::TextArea::new(lines); + self.editor_ctx.editor.set_content(lines); + let candidates = model::WORDS + .iter() + .map(|w| cagire_ratatui::CompletionCandidate { + name: w.name.to_string(), + signature: w.stack.to_string(), + description: w.desc.to_string(), + example: w.example.to_string(), + }) + .collect(); + self.editor_ctx.editor.set_candidates(candidates); + self.editor_ctx.editor.set_completion_enabled(self.ui.show_completion); } } pub fn save_editor_to_step(&mut self) { - let text = self.editor_ctx.text.lines().join("\n"); + let text = self.editor_ctx.editor.content(); let (bank, pattern) = self.current_bank_pattern(); let change = pattern_editor::set_step_script( &mut self.project_state.project, diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 510f1a6..32257a2 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -155,6 +155,7 @@ pub struct AudioStreamConfig { pub output_device: Option, pub channels: u16, pub buffer_size: u32, + pub max_voices: usize, } pub fn build_stream( @@ -192,9 +193,10 @@ pub fn build_stream( let sr = sample_rate; let channels = config.channels as usize; + let max_voices = config.max_voices; let metrics_clone = Arc::clone(&metrics); - let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics)); + let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); engine.sample_index = initial_samples; let mut analyzer = SpectrumAnalyzer::new(sample_rate); @@ -223,7 +225,7 @@ pub fn build_stream( AudioCommand::ResetEngine => { let old_samples = std::mem::take(&mut engine.sample_index); engine = - Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone)); + Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone)); engine.sample_index = old_samples; } } diff --git a/src/input.rs b/src/input.rs index ffdd752..99e3f06 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,6 +1,5 @@ use crossbeam_channel::Sender; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -9,7 +8,7 @@ use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; -use crate::state::{AudioFocus, Modal, PatternField}; +use crate::state::{AudioFocus, Modal, PanelFocus, PatternField, SampleBrowserState, SidePanel}; pub enum InputResult { Continue, @@ -335,42 +334,62 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c), _ => {} }, - Modal::AddSamplePath(path) => match key.code { + Modal::AddSamplePath(state) => match key.code { KeyCode::Enter => { - let sample_path = PathBuf::from(path.as_str()); - if sample_path.is_dir() { - let index = doux::loader::scan_samples_dir(&sample_path); + let sample_path = if let Some(entry) = state.entries.get(state.selected) { + if entry.is_dir && entry.name != ".." { + Some(state.current_dir().join(&entry.name)) + } else if entry.is_dir { + state.enter_selected(); + None + } else { + None + } + } else { + let dir = state.current_dir(); + if dir.is_dir() { Some(dir) } else { None } + }; + if let Some(path) = sample_path { + let index = doux::loader::scan_samples_dir(&path); let count = index.len(); let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); ctx.app.audio.config.sample_count += count; - ctx.app.audio.add_sample_path(sample_path); + ctx.app.audio.add_sample_path(path); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); - } else { - ctx.dispatch(AppCommand::SetStatus("Path is not a directory".to_string())); + ctx.dispatch(AppCommand::CloseModal); } - ctx.dispatch(AppCommand::CloseModal); } 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(14), + KeyCode::Down => state.select_next(14), + KeyCode::Backspace => state.backspace(), + KeyCode::Char(c) => { + state.input.push(c); + state.refresh_entries(); } - KeyCode::Char(c) => path.push(c), _ => {} }, Modal::Editor => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Esc => { - ctx.dispatch(AppCommand::SaveEditorToStep); - ctx.dispatch(AppCommand::CompileCurrentStep); - ctx.dispatch(AppCommand::CloseModal); + if ctx.app.editor_ctx.editor.completion_active() { + ctx.app.editor_ctx.editor.dismiss_completion(); + } else { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + ctx.dispatch(AppCommand::CloseModal); + } } KeyCode::Char('e') if ctrl => { ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep); } _ => { - ctx.app.editor_ctx.text.input(Event::Key(key)); + ctx.app.editor_ctx.editor.input(Event::Key(key)); } } } @@ -390,6 +409,10 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side { + return handle_panel_input(ctx, key); + } + if ctrl { match key.code { KeyCode::Left => { @@ -420,8 +443,94 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } } +fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + use cagire_ratatui::TreeLineKind; + use crate::engine::AudioCommand; + + let state = match &mut ctx.app.panel.side { + Some(SidePanel::SampleBrowser(s)) => s, + None => return InputResult::Continue, + }; + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + if state.search_active { + match key.code { + KeyCode::Esc => { + state.clear_search(); + } + KeyCode::Backspace => { + state.search_query.pop(); + state.update_search(); + } + KeyCode::Enter => { + state.search_active = false; + } + KeyCode::Char(c) => { + state.search_query.push(c); + state.update_search(); + } + _ => {} + } + } else if ctrl { + match key.code { + KeyCode::Up => { + for _ in 0..10 { + state.move_up(); + } + } + KeyCode::Down => { + for _ in 0..10 { + state.move_down(30); + } + } + _ => {} + } + } else { + match key.code { + KeyCode::Up | KeyCode::Char('k') => state.move_up(), + KeyCode::Down | KeyCode::Char('j') => state.move_down(30), + KeyCode::Enter | KeyCode::Right => { + if let Some(entry) = state.current_entry() { + match entry.kind { + TreeLineKind::File => { + let folder = &entry.folder; + let idx = entry.index; + let cmd = + format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1"); + let _ = ctx.audio_tx.send(AudioCommand::Evaluate(cmd)); + } + _ => state.toggle_expand(), + } + } + } + KeyCode::Left => state.collapse_at_cursor(), + KeyCode::Char('/') => state.activate_search(), + KeyCode::Esc | KeyCode::Tab => { + ctx.app.panel.visible = false; + ctx.app.panel.focus = PanelFocus::Main; + } + _ => {} + } + } + InputResult::Continue +} + fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { match key.code { + KeyCode::Tab => { + if ctx.app.panel.visible { + ctx.app.panel.visible = false; + ctx.app.panel.focus = PanelFocus::Main; + } else { + if ctx.app.panel.side.is_none() { + let state = SampleBrowserState::new(&ctx.app.audio.config.sample_paths); + ctx.app.panel.side = Some(SidePanel::SampleBrowser(state)); + } + ctx.app.panel.visible = true; + ctx.app.panel.focus = PanelFocus::Side; + } + } KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, @@ -659,6 +768,7 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::OutputDevice | AudioFocus::InputDevice => {} AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), + AudioFocus::Polyphony => ctx.app.audio.adjust_max_voices(-1), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight @@ -669,6 +779,9 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::ShowSpectrum => { ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; } + AudioFocus::ShowCompletion => { + ctx.app.ui.show_completion = !ctx.app.ui.show_completion; + } AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(), AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx @@ -688,6 +801,7 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::OutputDevice | AudioFocus::InputDevice => {} AudioFocus::Channels => ctx.app.audio.adjust_channels(1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), + AudioFocus::Polyphony => ctx.app.audio.adjust_max_voices(1), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight @@ -698,6 +812,9 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::ShowSpectrum => { ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; } + AudioFocus::ShowCompletion => { + ctx.app.ui.show_completion = !ctx.app.ui.show_completion; + } AudioFocus::SamplePaths => {} AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx @@ -714,7 +831,9 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } KeyCode::Char('R') => ctx.app.audio.trigger_restart(), KeyCode::Char('A') => { - ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(String::new()))); + use crate::state::file_browser::FileBrowserState; + let state = FileBrowserState::new_load(String::new()); + ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(state))); } KeyCode::Char('D') => { ctx.app.audio.refresh_devices(); diff --git a/src/main.rs b/src/main.rs index b2f6623..5f1d1e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,11 +80,13 @@ fn main() -> io::Result<()> { app.audio.config.input_device = args.input.or(settings.audio.input_device); app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels); app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size); + app.audio.config.max_voices = settings.audio.max_voices; app.audio.config.sample_paths = args.samples; app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); app.ui.runtime_highlight = settings.display.runtime_highlight; app.audio.config.show_scope = settings.display.show_scope; app.audio.config.show_spectrum = settings.display.show_spectrum; + app.ui.show_completion = settings.display.show_completion; let metrics = Arc::new(EngineMetrics::default()); let scope_buffer = Arc::new(ScopeBuffer::new()); @@ -111,6 +113,7 @@ fn main() -> io::Result<()> { output_device: app.audio.config.output_device.clone(), channels: app.audio.config.channels, buffer_size: app.audio.config.buffer_size, + max_voices: app.audio.config.max_voices, }; let mut _stream = match build_stream( @@ -148,6 +151,7 @@ fn main() -> io::Result<()> { output_device: app.audio.config.output_device.clone(), channels: app.audio.config.channels, buffer_size: app.audio.config.buffer_size, + max_voices: app.audio.config.max_voices, }; let mut restart_samples = Vec::new(); diff --git a/src/settings.rs b/src/settings.rs index c5dfe52..d111a7f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -15,14 +15,20 @@ pub struct AudioSettings { pub input_device: Option, pub channels: u16, pub buffer_size: u32, + #[serde(default = "default_max_voices")] + pub max_voices: usize, } +fn default_max_voices() -> usize { 32 } + #[derive(Debug, Serialize, Deserialize)] pub struct DisplaySettings { pub fps: u32, pub runtime_highlight: bool, pub show_scope: bool, pub show_spectrum: bool, + #[serde(default = "default_true")] + pub show_completion: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -39,10 +45,13 @@ impl Default for AudioSettings { input_device: None, channels: 2, buffer_size: 512, + max_voices: 32, } } } +fn default_true() -> bool { true } + impl Default for DisplaySettings { fn default() -> Self { Self { @@ -50,6 +59,7 @@ impl Default for DisplaySettings { runtime_highlight: false, show_scope: true, show_spectrum: true, + show_completion: true, } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index a9ab2c9..1ab049d 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -52,6 +52,7 @@ pub struct AudioConfig { pub input_device: Option, pub channels: u16, pub buffer_size: u32, + pub max_voices: usize, pub sample_rate: f32, pub sample_paths: Vec, pub sample_count: usize, @@ -67,6 +68,7 @@ impl Default for AudioConfig { input_device: None, channels: 2, buffer_size: 512, + max_voices: 32, sample_rate: 44100.0, sample_paths: Vec::new(), sample_count: 0, @@ -123,10 +125,12 @@ pub enum AudioFocus { InputDevice, Channels, BufferSize, + Polyphony, RefreshRate, RuntimeHighlight, ShowScope, ShowSpectrum, + ShowCompletion, SamplePaths, LinkEnabled, StartStopSync, @@ -198,11 +202,13 @@ impl AudioSettings { AudioFocus::OutputDevice => AudioFocus::InputDevice, AudioFocus::InputDevice => AudioFocus::Channels, AudioFocus::Channels => AudioFocus::BufferSize, - AudioFocus::BufferSize => AudioFocus::RefreshRate, + AudioFocus::BufferSize => AudioFocus::Polyphony, + AudioFocus::Polyphony => AudioFocus::RefreshRate, AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight, AudioFocus::RuntimeHighlight => AudioFocus::ShowScope, AudioFocus::ShowScope => AudioFocus::ShowSpectrum, - AudioFocus::ShowSpectrum => AudioFocus::SamplePaths, + AudioFocus::ShowSpectrum => AudioFocus::ShowCompletion, + AudioFocus::ShowCompletion => AudioFocus::SamplePaths, AudioFocus::SamplePaths => AudioFocus::LinkEnabled, AudioFocus::LinkEnabled => AudioFocus::StartStopSync, AudioFocus::StartStopSync => AudioFocus::Quantum, @@ -216,11 +222,13 @@ impl AudioSettings { AudioFocus::InputDevice => AudioFocus::OutputDevice, AudioFocus::Channels => AudioFocus::InputDevice, AudioFocus::BufferSize => AudioFocus::Channels, - AudioFocus::RefreshRate => AudioFocus::BufferSize, + AudioFocus::Polyphony => AudioFocus::BufferSize, + AudioFocus::RefreshRate => AudioFocus::Polyphony, AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate, AudioFocus::ShowScope => AudioFocus::RuntimeHighlight, AudioFocus::ShowSpectrum => AudioFocus::ShowScope, - AudioFocus::SamplePaths => AudioFocus::ShowSpectrum, + AudioFocus::ShowCompletion => AudioFocus::ShowSpectrum, + AudioFocus::SamplePaths => AudioFocus::ShowCompletion, AudioFocus::LinkEnabled => AudioFocus::SamplePaths, AudioFocus::StartStopSync => AudioFocus::LinkEnabled, AudioFocus::Quantum => AudioFocus::StartStopSync, @@ -267,6 +275,11 @@ impl AudioSettings { self.config.buffer_size = new_val; } + pub fn adjust_max_voices(&mut self, delta: i32) { + let new_val = (self.config.max_voices as i32 + delta).clamp(1, 128) as usize; + self.config.max_voices = new_val; + } + pub fn toggle_refresh_rate(&mut self) { self.config.refresh_rate = self.config.refresh_rate.toggle(); } diff --git a/src/state/editor.rs b/src/state/editor.rs index 4b833b3..bb50e6e 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -1,4 +1,4 @@ -use tui_textarea::TextArea; +use cagire_ratatui::Editor; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -17,7 +17,7 @@ pub struct EditorContext { pub pattern: usize, pub step: usize, pub focus: Focus, - pub text: TextArea<'static>, + pub editor: Editor, pub copied_step: Option, } @@ -35,7 +35,7 @@ impl Default for EditorContext { pattern: 0, step: 0, focus: Focus::Sequencer, - text: TextArea::default(), + editor: Editor::new(), copied_step: None, } } diff --git a/src/state/file_browser.rs b/src/state/file_browser.rs index d57150d..f54b999 100644 --- a/src/state/file_browser.rs +++ b/src/state/file_browser.rs @@ -48,7 +48,7 @@ impl FileBrowserState { state } - fn current_dir(&self) -> PathBuf { + pub fn current_dir(&self) -> PathBuf { if self.input.is_empty() { return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); } diff --git a/src/state/mod.rs b/src/state/mod.rs index cbe214f..568a939 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -3,16 +3,20 @@ pub mod editor; pub mod file_browser; pub mod live_keys; pub mod modal; +pub mod panel; pub mod patterns_nav; pub mod playback; pub mod project; +pub mod sample_browser; pub mod ui; pub use audio::{AudioFocus, AudioSettings, Metrics}; pub use editor::{CopiedStep, EditorContext, Focus, PatternField}; pub use live_keys::LiveKeyState; pub use modal::Modal; +pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::PlaybackState; pub use project::ProjectState; +pub use sample_browser::SampleBrowserState; pub use ui::UiState; diff --git a/src/state/modal.rs b/src/state/modal.rs index 3e813b1..4f8c44c 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -37,7 +37,7 @@ pub enum Modal { input: String, }, SetTempo(String), - AddSamplePath(String), + AddSamplePath(FileBrowserState), Editor, Preview, } diff --git a/src/state/panel.rs b/src/state/panel.rs new file mode 100644 index 0000000..32c9d1b --- /dev/null +++ b/src/state/panel.rs @@ -0,0 +1,28 @@ +use super::sample_browser::SampleBrowserState; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PanelFocus { + #[default] + Main, + Side, +} + +pub enum SidePanel { + SampleBrowser(SampleBrowserState), +} + +pub struct PanelState { + pub side: Option, + pub focus: PanelFocus, + pub visible: bool, +} + +impl Default for PanelState { + fn default() -> Self { + Self { + side: None, + focus: PanelFocus::Main, + visible: false, + } + } +} diff --git a/src/state/sample_browser.rs b/src/state/sample_browser.rs new file mode 100644 index 0000000..7a1cbd1 --- /dev/null +++ b/src/state/sample_browser.rs @@ -0,0 +1,414 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use cagire_ratatui::{TreeLine, TreeLineKind}; + +const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "ogg", "aiff", "aif", "mp3"]; + +pub enum SampleNode { + Root { + label: String, + children: Vec, + expanded: bool, + }, + Folder { + name: String, + children: Vec, + expanded: bool, + }, + File { + name: String, + }, +} + +impl SampleNode { + fn expanded(&self) -> bool { + match self { + SampleNode::Root { expanded, .. } | SampleNode::Folder { expanded, .. } => *expanded, + SampleNode::File { .. } => false, + } + } + + fn set_expanded(&mut self, val: bool) { + match self { + SampleNode::Root { expanded, .. } | SampleNode::Folder { expanded, .. } => { + *expanded = val; + } + SampleNode::File { .. } => {} + } + } + + fn is_expandable(&self) -> bool { + !matches!(self, SampleNode::File { .. }) + } + + fn children(&self) -> &[SampleNode] { + match self { + SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => children, + SampleNode::File { .. } => &[], + } + } + + fn label(&self) -> &str { + match self { + SampleNode::Root { label, .. } => label, + SampleNode::Folder { name, .. } | SampleNode::File { name, .. } => name, + } + } + + fn flatten(&self, depth: u8, parent_folder: &str, file_index: usize, out: &mut Vec) { + let kind = match self { + SampleNode::Root { expanded, .. } => TreeLineKind::Root { expanded: *expanded }, + SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded }, + SampleNode::File { .. } => TreeLineKind::File, + }; + out.push(TreeLine { + depth, + kind, + label: self.label().to_string(), + folder: parent_folder.to_string(), + index: file_index, + }); + if self.expanded() { + let folder_name = self.label(); + let mut idx = 0; + for child in self.children() { + let child_idx = if matches!(child, SampleNode::File { .. }) { + let i = idx; + idx += 1; + i + } else { + 0 + }; + child.flatten(depth + 1, folder_name, child_idx, out); + } + } + } +} + +pub struct SampleTree { + roots: Vec, +} + +impl SampleTree { + pub fn from_paths(paths: &[PathBuf]) -> Self { + if paths.len() == 1 { + // Single path: show its contents directly at root level + let roots = Self::scan_children(&paths[0]); + return Self { roots }; + } + let mut roots = Vec::new(); + for path in paths { + if let Some(root) = Self::scan_root(path) { + roots.push(root); + } + } + Self { roots } + } + + fn scan_children(path: &Path) -> Vec { + let entries = match fs::read_dir(path) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut files: Vec = Vec::new(); + let mut folders: Vec<(String, PathBuf)> = Vec::new(); + + for entry in entries.flatten() { + let ft = entry.file_type().ok(); + let name = entry.file_name().to_string_lossy().into_owned(); + if ft.is_some_and(|t| t.is_dir()) { + folders.push((name, entry.path())); + } else if is_audio_file(&name) { + files.push(name); + } + } + + folders.sort_by_key(|a| a.0.to_lowercase()); + files.sort_by_key(|a| a.to_lowercase()); + + let mut nodes = Vec::new(); + for (name, folder_path) in folders { + if let Some(folder) = Self::scan_folder(&name, &folder_path) { + nodes.push(folder); + } + } + for name in files { + nodes.push(SampleNode::File { name }); + } + nodes + } + + fn scan_root(path: &Path) -> Option { + let entries = fs::read_dir(path).ok()?; + let label = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + let mut children = Vec::new(); + let mut files: Vec = Vec::new(); + let mut folders: Vec<(String, PathBuf)> = Vec::new(); + + for entry in entries.flatten() { + let ft = entry.file_type().ok(); + let name = entry.file_name().to_string_lossy().into_owned(); + if ft.is_some_and(|t| t.is_dir()) { + folders.push((name, entry.path())); + } else if is_audio_file(&name) { + files.push(name); + } + } + + folders.sort_by_key(|a| a.0.to_lowercase()); + files.sort_by_key(|a| a.to_lowercase()); + + for (name, folder_path) in folders { + if let Some(folder) = Self::scan_folder(&name, &folder_path) { + children.push(folder); + } + } + for name in files { + children.push(SampleNode::File { name }); + } + + Some(SampleNode::Root { + label, + children, + expanded: false, + }) + } + + fn scan_folder(name: &str, path: &Path) -> Option { + let entries = fs::read_dir(path).ok()?; + let mut children: Vec = Vec::new(); + + let mut files: Vec = Vec::new(); + for entry in entries.flatten() { + let ft = entry.file_type().ok(); + let entry_name = entry.file_name().to_string_lossy().into_owned(); + if ft.is_some_and(|t| t.is_file()) && is_audio_file(&entry_name) { + files.push(entry_name); + } + } + files.sort_by_key(|a| a.to_lowercase()); + + for file in files { + children.push(SampleNode::File { name: file }); + } + + if children.is_empty() { + return None; + } + + Some(SampleNode::Folder { + name: name.to_string(), + children, + expanded: false, + }) + } + + pub fn visible_entries(&self) -> Vec { + let mut out = Vec::new(); + for root in &self.roots { + root.flatten(0, "", 0, &mut out); + } + out + } + + fn node_at_mut(&mut self, visible_index: usize) -> Option<&mut SampleNode> { + let mut count = 0; + for root in &mut self.roots { + if let Some(node) = Self::walk_mut(root, visible_index, &mut count) { + return Some(node); + } + } + None + } + + fn walk_mut<'a>( + node: &'a mut SampleNode, + target: usize, + count: &mut usize, + ) -> Option<&'a mut SampleNode> { + if *count == target { + return Some(node); + } + *count += 1; + if node.expanded() { + let children = match node { + SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => { + children + } + SampleNode::File { .. } => return None, + }; + for child in children.iter_mut() { + if let Some(found) = Self::walk_mut(child, target, count) { + return Some(found); + } + } + } + None + } +} + +pub struct SampleBrowserState { + pub tree: SampleTree, + pub cursor: usize, + pub scroll_offset: usize, + pub search_query: String, + pub search_active: bool, + filtered: Option>, +} + +impl SampleBrowserState { + pub fn new(paths: &[PathBuf]) -> Self { + Self { + tree: SampleTree::from_paths(paths), + cursor: 0, + scroll_offset: 0, + search_query: String::new(), + search_active: false, + filtered: None, + } + } + + pub fn entries(&self) -> Vec { + if let Some(ref filtered) = self.filtered { + return filtered.clone(); + } + self.tree.visible_entries() + } + + pub fn current_entry(&self) -> Option { + let entries = self.entries(); + entries.into_iter().nth(self.cursor) + } + + pub fn visible_count(&self) -> usize { + if let Some(ref filtered) = self.filtered { + return filtered.len(); + } + self.tree.visible_entries().len() + } + + 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, visible_height: usize) { + let count = self.visible_count(); + if count == 0 { + return; + } + if self.cursor + 1 < count { + self.cursor += 1; + if self.cursor >= self.scroll_offset + visible_height { + self.scroll_offset = self.cursor - visible_height + 1; + } + } + } + + pub fn toggle_expand(&mut self) { + if self.filtered.is_some() { + return; + } + if let Some(node) = self.tree.node_at_mut(self.cursor) { + if node.is_expandable() { + let new_val = !node.expanded(); + node.set_expanded(new_val); + } + } + } + + pub fn collapse_at_cursor(&mut self) { + if self.filtered.is_some() { + return; + } + if let Some(node) = self.tree.node_at_mut(self.cursor) { + if node.expanded() { + node.set_expanded(false); + } + } + } + + pub fn activate_search(&mut self) { + self.search_active = true; + } + + pub fn update_search(&mut self) { + if self.search_query.is_empty() { + self.filtered = None; + } else { + let query = self.search_query.to_lowercase(); + let full = self.full_entries(); + let filtered: Vec = full + .into_iter() + .filter(|line| line.label.to_lowercase().contains(&query)) + .collect(); + self.filtered = Some(filtered); + } + self.cursor = 0; + self.scroll_offset = 0; + } + + pub fn clear_search(&mut self) { + self.search_query.clear(); + self.search_active = false; + self.filtered = None; + self.cursor = 0; + self.scroll_offset = 0; + } + + fn full_entries(&self) -> Vec { + let mut out = Vec::new(); + for root in &self.tree.roots { + Self::flatten_all(root, 0, "", 0, &mut out); + } + out + } + + fn flatten_all( + node: &SampleNode, + depth: u8, + parent_folder: &str, + file_index: usize, + out: &mut Vec, + ) { + let kind = match node { + SampleNode::Root { .. } => TreeLineKind::Root { expanded: true }, + SampleNode::Folder { .. } => TreeLineKind::Folder { expanded: true }, + SampleNode::File { .. } => TreeLineKind::File, + }; + out.push(TreeLine { + depth, + kind, + label: node.label().to_string(), + folder: parent_folder.to_string(), + index: file_index, + }); + let folder_name = node.label(); + let mut idx = 0; + for child in node.children() { + let child_idx = if matches!(child, SampleNode::File { .. }) { + let i = idx; + idx += 1; + i + } else { + 0 + }; + Self::flatten_all(child, depth + 1, folder_name, child_idx, out); + } + } +} + +fn is_audio_file(name: &str) -> bool { + let lower = name.to_lowercase(); + AUDIO_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) +} diff --git a/src/state/ui.rs b/src/state/ui.rs index 9881974..439cf56 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -19,6 +19,7 @@ pub struct UiState { pub doc_category: usize, pub show_title: bool, pub runtime_highlight: bool, + pub show_completion: bool, } impl Default for UiState { @@ -33,6 +34,7 @@ impl Default for UiState { doc_category: 0, show_title: true, runtime_highlight: false, + show_completion: true, } } } diff --git a/src/views/audio_view.rs b/src/views/audio_view.rs index 1587025..910be52 100644 --- a/src/views/audio_view.rs +++ b/src/views/audio_view.rs @@ -50,7 +50,7 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ Constraint::Length(devices_height), Constraint::Length(1), - Constraint::Length(8), + Constraint::Length(10), Constraint::Length(1), Constraint::Min(3), ]) @@ -131,18 +131,21 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let channels_focused = app.audio.focus == AudioFocus::Channels; let buffer_focused = app.audio.focus == AudioFocus::BufferSize; + let polyphony_focused = app.audio.focus == AudioFocus::Polyphony; let fps_focused = app.audio.focus == AudioFocus::RefreshRate; let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight; let scope_focused = app.audio.focus == AudioFocus::ShowScope; let spectrum_focused = app.audio.focus == AudioFocus::ShowSpectrum; + let completion_focused = app.audio.focus == AudioFocus::ShowCompletion; let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" }; let scope_text = if app.audio.config.show_scope { "On" } else { "Off" }; let spectrum_text = if app.audio.config.show_spectrum { "On" } else { "Off" }; + let completion_text = if app.ui.show_completion { "On" } else { "Off" }; let rows = vec![ Row::new(vec![ - Span::styled("Channels", label_style), + Span::styled("Output channels", label_style), render_selector( &format!("{}", app.audio.config.channels), channels_focused, @@ -151,7 +154,7 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { ), ]), Row::new(vec![ - Span::styled("Buffer", label_style), + Span::styled("Buffer size", label_style), render_selector( &format!("{}", app.audio.config.buffer_size), buffer_focused, @@ -160,7 +163,16 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { ), ]), Row::new(vec![ - Span::styled("FPS", label_style), + Span::styled("Max voices", label_style), + render_selector( + &format!("{}", app.audio.config.max_voices), + polyphony_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled("Refresh rate", label_style), render_selector( app.audio.config.refresh_rate.label(), fps_focused, @@ -169,19 +181,23 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { ), ]), Row::new(vec![ - Span::styled("Highlight", label_style), + Span::styled("Show highlight", label_style), render_selector(highlight_text, highlight_focused, highlight, normal), ]), Row::new(vec![ - Span::styled("Scope", label_style), + Span::styled("Show scope", label_style), render_selector(scope_text, scope_focused, highlight, normal), ]), Row::new(vec![ - Span::styled("Spectrum", label_style), + Span::styled("Show spectrum", label_style), render_selector(spectrum_text, spectrum_focused, highlight, normal), ]), Row::new(vec![ - Span::styled("Rate", label_style), + Span::styled("Completion", label_style), + render_selector(completion_text, completion_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Sample rate", label_style), Span::styled( format!("{:.0} Hz", app.audio.config.sample_rate), value_style, @@ -189,7 +205,7 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { ]), ]; - let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); + let table = Table::new(rows, [Constraint::Length(16), Constraint::Fill(1)]); frame.render_widget(table, content_area); } diff --git a/src/views/render.rs b/src/views/render.rs index a68db31..c35c0c2 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -8,9 +8,9 @@ use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::SourceSpan; use crate::page::Page; -use crate::state::{Modal, PatternField}; +use crate::state::{Modal, PanelFocus, PatternField, SidePanel}; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; -use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal}; +use crate::widgets::{ConfirmModal, ModalFrame, SampleBrowser, TextInputModal}; use super::{audio_view, doc_view, main_view, patterns_view, title_view}; @@ -55,17 +55,58 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq render_header(frame, app, link, snapshot, header_area); + let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() { + if body_area.width >= 120 { + let panel_width = body_area.width * 35 / 100; + let [main, side] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(panel_width), + ]) + .areas(body_area); + (main, Some(side)) + } else { + let panel_height = body_area.height * 40 / 100; + let [main, side] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(panel_height), + ]) + .areas(body_area); + (main, Some(side)) + } + } else { + (body_area, None) + }; + match app.page { - Page::Main => main_view::render(frame, app, snapshot, body_area), - Page::Patterns => patterns_view::render(frame, app, snapshot, body_area), - Page::Audio => audio_view::render(frame, app, link, body_area), - Page::Doc => doc_view::render(frame, app, body_area), + Page::Main => main_view::render(frame, app, snapshot, page_area), + Page::Patterns => patterns_view::render(frame, app, snapshot, page_area), + Page::Audio => audio_view::render(frame, app, link, page_area), + Page::Doc => doc_view::render(frame, app, page_area), + } + + if let Some(side_area) = panel_area { + render_side_panel(frame, app, side_area); } render_footer(frame, app, footer_area); render_modal(frame, app, snapshot, term); } +fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) { + let focused = app.panel.focus == PanelFocus::Side; + match &app.panel.side { + Some(SidePanel::SampleBrowser(state)) => { + let entries = state.entries(); + SampleBrowser::new(&entries, state.cursor) + .scroll_offset(state.scroll_offset) + .search(&state.search_query, state.search_active) + .focused(focused) + .render(frame, area); + } + None => {} + } +} + fn render_header( frame: &mut Frame, app: &App, @@ -373,11 +414,19 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(Color::Magenta) .render_centered(frame, term); } - Modal::AddSamplePath(path) => { - TextInputModal::new("Add Sample Path", path) - .hint("Enter directory path containing samples") - .width(60) + Modal::AddSamplePath(state) => { + use crate::widgets::FileBrowserModal; + let entries: Vec<(String, bool)> = state + .entries + .iter() + .map(|e| (e.name.clone(), e.is_dir)) + .collect(); + FileBrowserModal::new("Add Sample Path", &state.input, &entries) + .selected(state.selected) + .scroll_offset(state.scroll_offset) .border_color(Color::Magenta) + .width(60) + .height(18) .render_centered(frame, term); } Modal::Preview => { @@ -461,8 +510,6 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(border_color) .render_centered(frame, term); - let (cursor_row, cursor_col) = app.editor_ctx.text.cursor(); - let trace = if app.ui.runtime_highlight && app.playback.playing { let source = app.current_edit_pattern().resolve_source(app.editor_ctx.step); snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) @@ -470,7 +517,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term None }; - let text_lines = app.editor_ctx.text.lines(); + let text_lines = app.editor_ctx.editor.lines(); let mut line_offsets: Vec = Vec::with_capacity(text_lines.len()); let mut offset = 0; for line in text_lines.iter() { @@ -478,68 +525,19 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term offset += line.len() + 1; } - let lines: Vec = text_lines - .iter() - .enumerate() - .map(|(row, line)| { - let mut spans: Vec = Vec::new(); + let highlighter = |row: usize, line: &str| -> Vec<(Style, String)> { + let line_start = line_offsets[row]; + let (exec, sel) = match trace { + Some(t) => ( + adjust_spans_for_line(&t.executed_spans, line_start, line.len()), + adjust_spans_for_line(&t.selected_spans, line_start, line.len()), + ), + None => (Vec::new(), Vec::new()), + }; + highlight::highlight_line_with_runtime(line, &exec, &sel) + }; - let line_start = line_offsets[row]; - let (exec_spans, sel_spans) = if let Some(t) = trace { - ( - adjust_spans_for_line(&t.executed_spans, line_start, line.len()), - adjust_spans_for_line(&t.selected_spans, line_start, line.len()), - ) - } else { - (Vec::new(), Vec::new()) - }; - - let tokens = highlight::highlight_line_with_runtime(line, &exec_spans, &sel_spans); - - if row == cursor_row { - let mut col = 0; - for (style, text) in tokens { - let text_len = text.chars().count(); - if cursor_col >= col && cursor_col < col + text_len { - let before = - text.chars().take(cursor_col - col).collect::(); - let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' '); - let after = - text.chars().skip(cursor_col - col + 1).collect::(); - - if !before.is_empty() { - spans.push(Span::styled(before, style)); - } - spans.push(Span::styled( - cursor_char.to_string(), - Style::default().bg(Color::White).fg(Color::Black), - )); - if !after.is_empty() { - spans.push(Span::styled(after, style)); - } - } else { - spans.push(Span::styled(text, style)); - } - col += text_len; - } - if cursor_col >= col { - spans.push(Span::styled( - " ", - Style::default().bg(Color::White).fg(Color::Black), - )); - } - } else { - for (style, text) in tokens { - spans.push(Span::styled(text, style)); - } - } - - Line::from(spans) - }) - .collect(); - - let paragraph = Paragraph::new(lines); - frame.render_widget(paragraph, inner); + app.editor_ctx.editor.render(frame, inner, &highlighter); } } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index c14d8d1..f4daa5e 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,4 +1,4 @@ pub use cagire_ratatui::{ - ConfirmModal, FileBrowserModal, ModalFrame, Orientation, Scope, Spectrum, TextInputModal, - VuMeter, + ConfirmModal, FileBrowserModal, ModalFrame, Orientation, SampleBrowser, Scope, Spectrum, + TextInputModal, VuMeter, };