diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea811..3441651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added +- Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion. +- Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`). + +### Improved +- Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections. +- CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+. + ## [0.0.8] - 2026-02-07 ### Fixed diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs index 9163302..9e4bbc6 100644 --- a/crates/ratatui/src/editor.rs +++ b/crates/ratatui/src/editor.rs @@ -44,6 +44,26 @@ impl CompletionState { } } +struct SampleFinderState { + query: String, + folders: Vec, + matches: Vec, + cursor: usize, + active: bool, +} + +impl SampleFinderState { + fn new() -> Self { + Self { + query: String::new(), + folders: Vec::new(), + matches: Vec::new(), + cursor: 0, + active: false, + } + } +} + struct SearchState { query: String, active: bool, @@ -61,6 +81,7 @@ impl SearchState { pub struct Editor { text: TextArea<'static>, completion: CompletionState, + sample_finder: SampleFinderState, search: SearchState, scroll_offset: Cell, } @@ -110,6 +131,7 @@ impl Editor { Self { text: TextArea::default(), completion: CompletionState::new(), + sample_finder: SampleFinderState::new(), search: SearchState::new(), scroll_offset: Cell::new(0), } @@ -118,6 +140,7 @@ impl Editor { pub fn set_content(&mut self, lines: Vec) { self.text = TextArea::new(lines); self.completion.active = false; + self.sample_finder.active = false; self.search.query.clear(); self.search.active = false; self.scroll_offset.set(0); @@ -226,6 +249,79 @@ impl Editor { } } + pub fn set_sample_folders(&mut self, folders: Vec) { + self.sample_finder.folders = folders; + } + + pub fn activate_sample_finder(&mut self) { + self.completion.active = false; + self.sample_finder.query.clear(); + self.sample_finder.cursor = 0; + self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect(); + self.sample_finder.active = true; + } + + pub fn dismiss_sample_finder(&mut self) { + self.sample_finder.active = false; + } + + pub fn sample_finder_active(&self) -> bool { + self.sample_finder.active + } + + pub fn sample_finder_input(&mut self, c: char) { + self.sample_finder.query.push(c); + self.update_sample_finder_matches(); + } + + pub fn sample_finder_backspace(&mut self) { + self.sample_finder.query.pop(); + self.update_sample_finder_matches(); + } + + pub fn sample_finder_next(&mut self) { + if self.sample_finder.cursor + 1 < self.sample_finder.matches.len() { + self.sample_finder.cursor += 1; + } + } + + pub fn sample_finder_prev(&mut self) { + if self.sample_finder.cursor > 0 { + self.sample_finder.cursor -= 1; + } + } + + pub fn accept_sample_finder(&mut self) { + if self.sample_finder.matches.is_empty() { + self.sample_finder.active = false; + return; + } + let idx = self.sample_finder.matches[self.sample_finder.cursor]; + let name = self.sample_finder.folders[idx].clone(); + self.text.insert_str(&name); + self.sample_finder.active = false; + } + + fn update_sample_finder_matches(&mut self) { + if self.sample_finder.query.is_empty() { + self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect(); + } else { + let mut scored: Vec<(usize, usize)> = self + .sample_finder + .folders + .iter() + .enumerate() + .filter_map(|(i, name)| fuzzy_match(&self.sample_finder.query, name).map(|s| (s, i))) + .collect(); + scored.sort_by_key(|(score, _)| *score); + self.sample_finder.matches = scored.into_iter().map(|(_, i)| i).collect(); + } + self.sample_finder.cursor = self + .sample_finder + .cursor + .min(self.sample_finder.matches.len().saturating_sub(1)); + } + pub fn input(&mut self, input: impl Into) { let input: tui_textarea::Input = input.into(); let has_modifier = input.ctrl || input.alt; @@ -261,7 +357,7 @@ impl Editor { } fn update_completion(&mut self) { - if !self.completion.enabled || self.completion.candidates.is_empty() { + if !self.completion.enabled || self.completion.candidates.is_empty() || self.sample_finder.active { return; } @@ -395,7 +491,9 @@ impl Editor { frame.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area); - if self.completion.active && !self.completion.matches.is_empty() { + if self.sample_finder.active && !self.sample_finder.matches.is_empty() { + self.render_sample_finder(frame, area, cursor_row - offset); + } else if self.completion.active && !self.completion.matches.is_empty() { self.render_completion(frame, area, cursor_row - offset); } } @@ -511,6 +609,98 @@ impl Editor { frame.render_widget(Paragraph::new(doc_lines), doc_area); } + + fn render_sample_finder(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) { + let t = theme::get(); + let max_visible: usize = 8; + let width: u16 = 24; + + let visible_count = self.sample_finder.matches.len().min(max_visible); + let total_height = visible_count as u16 + 1; // +1 for query line + + let (_, cursor_col) = self.text.cursor(); + let popup_x = (editor_area.x + cursor_col as u16) + .min(editor_area.x + editor_area.width.saturating_sub(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 area = Rect::new(popup_x, popup_y, width, total_height); + frame.render_widget(Clear, area); + + let bg_style = Style::default().bg(t.editor_widget.completion_bg); + let highlight_style = Style::default() + .fg(t.editor_widget.completion_selected) + .add_modifier(Modifier::BOLD); + let normal_style = Style::default().fg(t.editor_widget.completion_fg); + + let w = width as usize; + let mut lines: Vec = Vec::new(); + + let query_display = format!("/{}", self.sample_finder.query); + lines.push(Line::from(Span::styled( + format!("{query_display:= max_visible { + self.sample_finder.cursor - max_visible + 1 + } else { + 0 + }; + + for i in scroll_offset..scroll_offset + visible_count { + let idx = self.sample_finder.matches[i]; + let name = &self.sample_finder.folders[idx]; + let style = if i == self.sample_finder.cursor { + highlight_style + } else { + normal_style + }; + let prefix = if i == self.sample_finder.cursor { "> " } else { " " }; + let display = format!("{prefix}{name: Option { + let target_lower: Vec = target.to_lowercase().chars().collect(); + let query_lower: Vec = query.to_lowercase().chars().collect(); + let mut ti = 0; + let mut score = 0; + let mut prev_pos = 0; + for (qi, &qc) in query_lower.iter().enumerate() { + loop { + if ti >= target_lower.len() { + return None; + } + if target_lower[ti] == qc { + if qi > 0 { + score += ti - prev_pos; + } + prev_pos = ti; + ti += 1; + break; + } + ti += 1; + } + } + Some(score) } fn is_word_char(c: char) -> bool { diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index bee4114..1638f0f 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -16,7 +16,7 @@ mod waveform; pub use active_patterns::{ActivePatterns, MuteStatus}; pub use confirm::ConfirmModal; -pub use editor::{CompletionCandidate, Editor}; +pub use editor::{fuzzy_match, CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; pub use list_select::ListSelect; pub use modal::ModalFrame; diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs index 64e7c58..3cad4e3 100644 --- a/crates/ratatui/src/sample_browser.rs +++ b/crates/ratatui/src/sample_browser.rs @@ -158,12 +158,17 @@ impl<'a> SampleBrowser<'a> { Style::new().fg(icon_color) }; - let spans = vec![ + let mut spans = vec![ Span::raw(indent), Span::styled(icon, icon_style), Span::styled(&entry.label, label_style), ]; + if matches!(entry.kind, TreeLineKind::File) { + let idx_style = Style::new().fg(colors.browser.empty_text); + spans.push(Span::styled(format!(" {}", entry.index), idx_style)); + } + lines.push(Line::from(spans)); } diff --git a/src/app.rs b/src/app.rs index b53df25..ba9b881 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use crate::settings::Settings; use crate::state::{ AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics, Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, - PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState, + PlaybackState, ProjectState, SampleTree, StagedChange, StagedPropChange, UiState, }; const STEPS_PER_PAGE: usize = 32; @@ -328,6 +328,8 @@ impl App { self.editor_ctx .editor .set_completion_enabled(self.ui.show_completion); + let tree = SampleTree::from_paths(&self.audio.config.sample_paths); + self.editor_ctx.editor.set_sample_folders(tree.all_folder_names()); if self.editor_ctx.show_stack { crate::services::stack_preview::update_cache(&self.editor_ctx); } @@ -359,6 +361,8 @@ impl App { self.editor_ctx .editor .set_completion_enabled(self.ui.show_completion); + let tree = SampleTree::from_paths(&self.audio.config.sample_paths); + self.editor_ctx.editor.set_sample_folders(tree.all_folder_names()); self.editor_ctx.target = EditorTarget::Prelude; self.ui.modal = Modal::Editor; } diff --git a/src/input.rs b/src/input.rs index 0f9979f..725f16d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -494,6 +494,19 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { return InputResult::Continue; } + if editor.sample_finder_active() { + match key.code { + KeyCode::Esc => editor.dismiss_sample_finder(), + KeyCode::Tab | KeyCode::Enter => editor.accept_sample_finder(), + KeyCode::Backspace => editor.sample_finder_backspace(), + KeyCode::Char('n') if ctrl => editor.sample_finder_next(), + KeyCode::Char('p') if ctrl => editor.sample_finder_prev(), + KeyCode::Char(c) if !ctrl => editor.sample_finder_input(c), + _ => {} + } + return InputResult::Continue; + } + match key.code { KeyCode::Esc => { if editor.is_selecting() { @@ -525,12 +538,17 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::EvaluatePrelude); } }, + KeyCode::Char('b') if ctrl => { + editor.activate_sample_finder(); + } KeyCode::Char('f') if ctrl => { editor.activate_search(); } KeyCode::Char('n') if ctrl => { if editor.completion_active() { editor.completion_next(); + } else if editor.sample_finder_active() { + editor.sample_finder_next(); } else { editor.search_next(); } @@ -538,6 +556,8 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char('p') if ctrl => { if editor.completion_active() { editor.completion_prev(); + } else if editor.sample_finder_active() { + editor.sample_finder_prev(); } else { editor.search_prev(); } diff --git a/src/state/mod.rs b/src/state/mod.rs index aa5c89b..440c7a2 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -43,5 +43,5 @@ pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use mute::MuteState; pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange}; pub use project::ProjectState; -pub use sample_browser::SampleBrowserState; +pub use sample_browser::{SampleBrowserState, SampleTree}; pub use ui::{DictFocus, FlashKind, HelpFocus, UiState}; diff --git a/src/state/sample_browser.rs b/src/state/sample_browser.rs index 73a1b56..672991b 100644 --- a/src/state/sample_browser.rs +++ b/src/state/sample_browser.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; -use cagire_ratatui::{TreeLine, TreeLineKind}; +use cagire_ratatui::{fuzzy_match, TreeLine, TreeLineKind}; const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "ogg", "aiff", "aif", "mp3"]; @@ -208,6 +208,29 @@ impl SampleTree { }) } + pub fn all_folder_names(&self) -> Vec { + let mut names = Vec::new(); + for root in &self.roots { + Self::collect_folder_names(root, &mut names); + } + names.sort_by_key(|n| n.to_lowercase()); + names + } + + fn collect_folder_names(node: &SampleNode, out: &mut Vec) { + match node { + SampleNode::Root { children, .. } => { + for child in children { + Self::collect_folder_names(child, out); + } + } + SampleNode::Folder { name, .. } => { + out.push(name.clone()); + } + SampleNode::File { .. } => {} + } + } + pub fn visible_entries(&self) -> Vec { let mut out = Vec::new(); for root in &self.roots { @@ -558,31 +581,6 @@ impl SampleBrowserState { } } -fn fuzzy_match(query: &str, target: &str) -> Option { - let target_lower: Vec = target.to_lowercase().chars().collect(); - let query_lower: Vec = query.to_lowercase().chars().collect(); - let mut ti = 0; - let mut score = 0; - let mut prev_pos = 0; - for (qi, &qc) in query_lower.iter().enumerate() { - loop { - if ti >= target_lower.len() { - return None; - } - if target_lower[ti] == qc { - if qi > 0 { - score += ti - prev_pos; - } - prev_pos = ti; - ti += 1; - break; - } - ti += 1; - } - } - Some(score) -} - 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/views/render.rs b/src/views/render.rs index 7656709..7bc941b 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -333,14 +333,23 @@ fn render_header( let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0); let peers = link.peers(); let voices = app.metrics.active_voices; - let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} "); - let stats_style = Style::new() - .bg(theme.header.stats_bg) - .fg(theme.header.stats_fg); + let cpu_color = if cpu_pct >= 80.0 { + theme.flash.error_fg + } else if cpu_pct >= 50.0 { + theme.ui.accent + } else { + theme.header.stats_fg + }; + let dim = Style::new().bg(theme.header.stats_bg).fg(theme.header.stats_fg); + let stats_line = Line::from(vec![ + Span::styled(format!(" CPU {cpu_pct:.0}%"), dim.fg(cpu_color)), + Span::styled(format!(" V:{voices} L:{peers} "), dim), + ]); + let block_style = Style::new().bg(theme.header.stats_bg); frame.render_widget( - Paragraph::new(stats_text) - .block(Block::default().padding(pad).style(stats_style)) - .alignment(Alignment::Right), + Paragraph::new(stats_line) + .block(Block::default().padding(pad).style(block_style)) + .alignment(Alignment::Center), stats_area, ); } @@ -809,6 +818,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term Span::styled(" eval ", dim), Span::styled("C-f", key), Span::styled(" find ", dim), + Span::styled("C-b", key), + Span::styled(" samples ", dim), Span::styled("C-s", key), Span::styled(" stack ", dim), Span::styled("C-u", key), diff --git a/website/public/eight_pic.png b/website/public/eight_pic.png new file mode 100644 index 0000000..c952577 Binary files /dev/null and b/website/public/eight_pic.png differ diff --git a/website/public/fifth_pic.png b/website/public/fifth_pic.png new file mode 100644 index 0000000..9c4680d Binary files /dev/null and b/website/public/fifth_pic.png differ diff --git a/website/public/fourth_pic.png b/website/public/fourth_pic.png new file mode 100644 index 0000000..373eb2a Binary files /dev/null and b/website/public/fourth_pic.png differ diff --git a/website/public/ninth_pic.png b/website/public/ninth_pic.png new file mode 100644 index 0000000..93b8ca8 Binary files /dev/null and b/website/public/ninth_pic.png differ diff --git a/website/public/one_pic.png b/website/public/one_pic.png new file mode 100644 index 0000000..caae09f Binary files /dev/null and b/website/public/one_pic.png differ diff --git a/website/public/second_pic.png b/website/public/second_pic.png new file mode 100644 index 0000000..6fc79df Binary files /dev/null and b/website/public/second_pic.png differ diff --git a/website/public/seventh_pic.png b/website/public/seventh_pic.png new file mode 100644 index 0000000..f36c8fe Binary files /dev/null and b/website/public/seventh_pic.png differ diff --git a/website/public/sixth_pic.png b/website/public/sixth_pic.png new file mode 100644 index 0000000..4c9039d Binary files /dev/null and b/website/public/sixth_pic.png differ diff --git a/website/public/style.css b/website/public/style.css index dba4f59..bf17d05 100644 --- a/website/public/style.css +++ b/website/public/style.css @@ -103,7 +103,8 @@ li { margin: 0.1rem 0; } cursor: pointer; } -.example-cell video { +.example-cell video, +.example-cell img { width: 100%; height: 100%; object-fit: cover; diff --git a/website/public/third_pic.png b/website/public/third_pic.png new file mode 100644 index 0000000..a31947e Binary files /dev/null and b/website/public/third_pic.png differ diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index d884bc2..d50e5ff 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -32,15 +32,15 @@
-
-
-
-
-
-
-
-
-
+
Cagire screenshot 1
+
Cagire screenshot 2
+
Cagire screenshot 3
+
Cagire screenshot 4
+
Cagire screenshot 5
+
Cagire screenshot 6
+
Cagire screenshot 7
+
Cagire screenshot 8
+
Cagire screenshot 9

Download