WIP: half broken
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -5,3 +5,4 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
tui-textarea = "0.7"
|
||||
|
||||
392
crates/ratatui/src/editor.rs
Normal file
392
crates/ratatui/src/editor.rs
Normal file
@@ -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<CompletionCandidate>,
|
||||
matches: Vec<usize>,
|
||||
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<String>) {
|
||||
self.text = TextArea::new(lines);
|
||||
self.completion.active = false;
|
||||
}
|
||||
|
||||
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
|
||||
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<tui_textarea::Input>) {
|
||||
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<usize> = 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<Line> = self
|
||||
.text
|
||||
.lines()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, line)| {
|
||||
let tokens = highlighter(row, line);
|
||||
let mut spans: Vec<Span> = 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::<String>();
|
||||
let cursor_char =
|
||||
text.chars().nth(cursor_col - col).unwrap_or(' ');
|
||||
let after =
|
||||
text.chars().skip(cursor_col - col + 1).collect::<String>();
|
||||
|
||||
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<Line> = (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:<width$}", width = list_width as usize - 2);
|
||||
Line::from(Span::styled(display, style.bg(Color::Rgb(30, 30, 40))))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fill remaining height with empty bg lines
|
||||
let mut all_list_lines = list_lines;
|
||||
for _ in visible_count as u16..total_height {
|
||||
all_list_lines.push(Line::from(Span::styled(
|
||||
" ".repeat(list_width as usize),
|
||||
bg_style,
|
||||
)));
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(all_list_lines), list_area);
|
||||
|
||||
// Doc panel
|
||||
let doc_area = Rect::new(popup_x + list_width, popup_y, doc_width, total_height);
|
||||
frame.render_widget(Clear, doc_area);
|
||||
|
||||
let selected_idx = self.completion.matches[self.completion.cursor];
|
||||
let candidate = &self.completion.candidates[selected_idx];
|
||||
|
||||
let name_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
let desc_style = Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
let example_style = Style::default()
|
||||
.fg(Color::Rgb(120, 200, 160))
|
||||
.bg(Color::Rgb(30, 30, 40));
|
||||
|
||||
let w = doc_width as usize;
|
||||
let mut doc_lines: Vec<Line> = Vec::new();
|
||||
|
||||
let header = format!(" {} {}", candidate.name, candidate.signature);
|
||||
doc_lines.push(Line::from(Span::styled(
|
||||
format!("{header:<w$}"),
|
||||
name_style,
|
||||
)));
|
||||
|
||||
let desc = format!(" {}", candidate.description);
|
||||
doc_lines.push(Line::from(Span::styled(
|
||||
format!("{desc:<w$}"),
|
||||
desc_style,
|
||||
)));
|
||||
|
||||
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
|
||||
|
||||
if !candidate.example.is_empty() {
|
||||
let ex = format!(" {}", candidate.example);
|
||||
doc_lines.push(Line::from(Span::styled(
|
||||
format!("{ex:<w$}"),
|
||||
example_style,
|
||||
)));
|
||||
}
|
||||
|
||||
for _ in doc_lines.len() as u16..total_height {
|
||||
doc_lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(doc_lines), doc_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#')
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
170
crates/ratatui/src/sample_browser.rs
Normal file
170
crates/ratatui/src/sample_browser.rs
Normal file
@@ -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<Line> = 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);
|
||||
}
|
||||
}
|
||||
23
src/app.rs
23
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<Bank>,
|
||||
|
||||
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,
|
||||
|
||||
@@ -155,6 +155,7 @@ pub struct AudioStreamConfig {
|
||||
pub output_device: Option<String>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
151
src/input.rs
151
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);
|
||||
}
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
KeyCode::Backspace => {
|
||||
path.pop();
|
||||
}
|
||||
KeyCode::Char(c) => path.push(c),
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
KeyCode::Tab => state.autocomplete(),
|
||||
KeyCode::Left => state.go_up(),
|
||||
KeyCode::Right => state.enter_selected(),
|
||||
KeyCode::Up => state.select_prev(14),
|
||||
KeyCode::Down => state.select_next(14),
|
||||
KeyCode::Backspace => state.backspace(),
|
||||
KeyCode::Char(c) => {
|
||||
state.input.push(c);
|
||||
state.refresh_entries();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Modal::Editor => {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,14 +15,20 @@ pub struct AudioSettings {
|
||||
pub input_device: Option<String>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct AudioConfig {
|
||||
pub input_device: Option<String>,
|
||||
pub channels: u16,
|
||||
pub buffer_size: u32,
|
||||
pub max_voices: usize,
|
||||
pub sample_rate: f32,
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<CopiedStep>,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Default for EditorContext {
|
||||
pattern: 0,
|
||||
step: 0,
|
||||
focus: Focus::Sequencer,
|
||||
text: TextArea::default(),
|
||||
editor: Editor::new(),
|
||||
copied_step: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("/"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,7 +37,7 @@ pub enum Modal {
|
||||
input: String,
|
||||
},
|
||||
SetTempo(String),
|
||||
AddSamplePath(String),
|
||||
AddSamplePath(FileBrowserState),
|
||||
Editor,
|
||||
Preview,
|
||||
}
|
||||
|
||||
28
src/state/panel.rs
Normal file
28
src/state/panel.rs
Normal file
@@ -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<SidePanel>,
|
||||
pub focus: PanelFocus,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Default for PanelState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
side: None,
|
||||
focus: PanelFocus::Main,
|
||||
visible: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
414
src/state/sample_browser.rs
Normal file
414
src/state/sample_browser.rs
Normal file
@@ -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<SampleNode>,
|
||||
expanded: bool,
|
||||
},
|
||||
Folder {
|
||||
name: String,
|
||||
children: Vec<SampleNode>,
|
||||
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<TreeLine>) {
|
||||
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<SampleNode>,
|
||||
}
|
||||
|
||||
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<SampleNode> {
|
||||
let entries = match fs::read_dir(path) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut files: Vec<String> = 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<SampleNode> {
|
||||
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<String> = 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<SampleNode> {
|
||||
let entries = fs::read_dir(path).ok()?;
|
||||
let mut children: Vec<SampleNode> = Vec::new();
|
||||
|
||||
let mut files: Vec<String> = 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<TreeLine> {
|
||||
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<Vec<TreeLine>>,
|
||||
}
|
||||
|
||||
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<TreeLine> {
|
||||
if let Some(ref filtered) = self.filtered {
|
||||
return filtered.clone();
|
||||
}
|
||||
self.tree.visible_entries()
|
||||
}
|
||||
|
||||
pub fn current_entry(&self) -> Option<TreeLine> {
|
||||
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<TreeLine> = 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<TreeLine> {
|
||||
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<TreeLine>,
|
||||
) {
|
||||
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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> = 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<Line> = text_lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, line)| {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
|
||||
let highlighter = |row: usize, line: &str| -> Vec<(Style, String)> {
|
||||
let line_start = line_offsets[row];
|
||||
let (exec_spans, sel_spans) = if let Some(t) = trace {
|
||||
(
|
||||
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()),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
highlight::highlight_line_with_runtime(line, &exec, &sel)
|
||||
};
|
||||
|
||||
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::<String>();
|
||||
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
|
||||
let after =
|
||||
text.chars().skip(cursor_col - col + 1).collect::<String>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user