Fix: UI/UX
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m28s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m28s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
/.cache
|
/.cache
|
||||||
*.prof
|
*.prof
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
releases/
|
||||||
|
|
||||||
# Local cargo overrides (doux path patch)
|
# Local cargo overrides (doux path patch)
|
||||||
.cargo/config.local.toml
|
.cargo/config.local.toml
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
Cagire - A Forth-based music sequencer
|
# Cagire - A Forth-based music sequencer
|
||||||
Made by BuboBubo and his friends
|
|
||||||
======================================
|
## Installation
|
||||||
|
|
||||||
Installation
|
|
||||||
------------
|
|
||||||
Drag Cagire.app into the Applications folder.
|
Drag Cagire.app into the Applications folder.
|
||||||
|
|
||||||
Unquarantine
|
## Unquarantine
|
||||||
------------
|
|
||||||
Since this app is not signed with an Apple Developer certificate,
|
Since this app is not signed with an Apple Developer certificate,
|
||||||
macOS will block it from running. To fix this, open Terminal and run:
|
macOS will block it from running. Thanks Apple! To fix this, open
|
||||||
|
Terminal and run:
|
||||||
|
|
||||||
xattr -cr /Applications/Cagire.app
|
xattr -cr /Applications/Cagire.app
|
||||||
|
|
||||||
Support
|
## Support
|
||||||
-------
|
|
||||||
If you enjoy Cagire, consider supporting development:
|
If you enjoy this software, consider supporting development:
|
||||||
https://ko-fi.com/raphaelbubo
|
https://ko-fi.com/raphaelbubo
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::theme;
|
|||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
/// Node type in the sample tree.
|
/// Node type in the sample tree.
|
||||||
@@ -116,13 +116,13 @@ impl<'a> SampleBrowser<'a> {
|
|||||||
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||||
let height = area.height as usize;
|
let height = area.height as usize;
|
||||||
if self.entries.is_empty() {
|
if self.entries.is_empty() {
|
||||||
let msg = if self.search_query.is_empty() {
|
if self.search_query.is_empty() {
|
||||||
"No samples loaded"
|
self.render_empty_guide(frame, area, colors);
|
||||||
} else {
|
} else {
|
||||||
"No matches"
|
let line =
|
||||||
};
|
Line::from(Span::styled("No matches", Style::new().fg(colors.browser.empty_text)));
|
||||||
let line = Line::from(Span::styled(msg, Style::new().fg(colors.browser.empty_text)));
|
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,4 +179,47 @@ impl<'a> SampleBrowser<'a> {
|
|||||||
|
|
||||||
frame.render_widget(Paragraph::new(lines), area);
|
frame.render_widget(Paragraph::new(lines), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_empty_guide(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||||
|
let muted = Style::new().fg(colors.browser.empty_text);
|
||||||
|
let heading = Style::new().fg(colors.ui.text_primary);
|
||||||
|
let key = Style::new().fg(colors.hint.key);
|
||||||
|
let desc = Style::new().fg(colors.hint.text);
|
||||||
|
let code = Style::new().fg(colors.ui.accent);
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(" No samples loaded.", muted)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Load from the Engine page:", heading)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" F6 ", key),
|
||||||
|
Span::styled("Go to Engine page", desc),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" A ", key),
|
||||||
|
Span::styled("Add a sample folder", desc),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Organize samples like this:", heading)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" samples/", code)),
|
||||||
|
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} kick/", code)),
|
||||||
|
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} kick.wav", code)),
|
||||||
|
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} snare/", code)),
|
||||||
|
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} snare.wav", code)),
|
||||||
|
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} hats/", code)),
|
||||||
|
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} closed.wav", code)),
|
||||||
|
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} open.wav", code)),
|
||||||
|
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} pedal.wav", code)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" Folders become Forth words:", heading)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(" kick sound .", code)),
|
||||||
|
Line::from(Span::styled(" hats sound 2 n .", code)),
|
||||||
|
Line::from(Span::styled(" snare sound 0.5 speed .", code)),
|
||||||
|
];
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ The engine scans these directories and builds a registry of available samples. S
|
|||||||
|
|
||||||
```
|
```
|
||||||
samples/
|
samples/
|
||||||
├── kick.wav → "kick"
|
├── kick/ → "kick"
|
||||||
├── snare.wav → "snare"
|
│ └── kick.wav
|
||||||
|
├── snare/ → "snare"
|
||||||
|
│ └── snare.wav
|
||||||
└── hats/
|
└── hats/
|
||||||
├── closed.wav → "hats" n 0
|
├── closed.wav → "hats" n 0
|
||||||
├── open.wav → "hats" n 1
|
├── open.wav → "hats" n 1
|
||||||
└── pedal.wav → "hats" n 2
|
└── pedal.wav → "hats" n 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Folders at the root of your directory are used as the name of a sample bank. Folders create sample banks where each file gets an index. Files are sorted alphabetically and assigned indices starting from `0`.
|
Folders at the root of your sample directory become sample banks named after the folder. Each file within a folder gets an index. Files are sorted alphabetically and assigned indices starting from `0`.
|
||||||
|
|
||||||
## Playing Samples
|
## Playing Samples
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppCommand::AudioTriggerRestart => self.audio.trigger_restart(),
|
AppCommand::AudioTriggerRestart => self.audio.trigger_restart(),
|
||||||
AppCommand::RemoveLastSamplePath => self.audio.remove_last_sample_path(),
|
AppCommand::RemoveSamplePath(index) => self.audio.remove_sample_path(index),
|
||||||
AppCommand::AudioRefreshDevices => self.audio.refresh_devices(),
|
AppCommand::AudioRefreshDevices => self.audio.refresh_devices(),
|
||||||
|
|
||||||
// Options page
|
// Options page
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ impl App {
|
|||||||
channels: self.audio.config.channels,
|
channels: self.audio.config.channels,
|
||||||
buffer_size: self.audio.config.buffer_size,
|
buffer_size: self.audio.config.buffer_size,
|
||||||
max_voices: self.audio.config.max_voices,
|
max_voices: self.audio.config.max_voices,
|
||||||
|
sample_paths: self.audio.config.sample_paths.clone(),
|
||||||
},
|
},
|
||||||
display: crate::settings::DisplaySettings {
|
display: crate::settings::DisplaySettings {
|
||||||
fps: self.audio.config.refresh_rate.to_fps(),
|
fps: self.audio.config.refresh_rate.to_fps(),
|
||||||
|
|||||||
@@ -245,11 +245,12 @@ impl CagireDesktop {
|
|||||||
self.stream_error_rx = new_error_rx;
|
self.stream_error_rx = new_error_rx;
|
||||||
|
|
||||||
let mut restart_samples = Vec::new();
|
let mut restart_samples = Vec::new();
|
||||||
|
self.app.audio.config.sample_counts.clear();
|
||||||
for path in &self.app.audio.config.sample_paths {
|
for path in &self.app.audio.config.sample_paths {
|
||||||
let index = doux::sampling::scan_samples_dir(path);
|
let index = doux::sampling::scan_samples_dir(path);
|
||||||
|
self.app.audio.config.sample_counts.push(index.len());
|
||||||
restart_samples.extend(index);
|
restart_samples.extend(index);
|
||||||
}
|
}
|
||||||
self.app.audio.config.sample_count = restart_samples.len();
|
|
||||||
|
|
||||||
self.audio_sample_pos.store(0, Ordering::Release);
|
self.audio_sample_pos.store(0, Ordering::Release);
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ pub enum AppCommand {
|
|||||||
delta: i32,
|
delta: i32,
|
||||||
},
|
},
|
||||||
AudioTriggerRestart,
|
AudioTriggerRestart,
|
||||||
RemoveLastSamplePath,
|
RemoveSamplePath(usize),
|
||||||
AudioRefreshDevices,
|
AudioRefreshDevices,
|
||||||
|
|
||||||
// Options page
|
// Options page
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ pub fn init(args: InitArgs) -> Init {
|
|||||||
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
|
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.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
|
||||||
app.audio.config.max_voices = settings.audio.max_voices;
|
app.audio.config.max_voices = settings.audio.max_voices;
|
||||||
app.audio.config.sample_paths = args.samples;
|
app.audio.config.sample_paths = if args.samples.is_empty() {
|
||||||
|
settings.audio.sample_paths.clone()
|
||||||
|
} else {
|
||||||
|
args.samples
|
||||||
|
};
|
||||||
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
|
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
|
||||||
app.ui.runtime_highlight = settings.display.runtime_highlight;
|
app.ui.runtime_highlight = settings.display.runtime_highlight;
|
||||||
app.audio.config.show_scope = settings.display.show_scope;
|
app.audio.config.show_scope = settings.display.show_scope;
|
||||||
@@ -154,7 +158,7 @@ pub fn init(args: InitArgs) -> Init {
|
|||||||
let mut initial_samples = Vec::new();
|
let mut initial_samples = Vec::new();
|
||||||
for path in &app.audio.config.sample_paths {
|
for path in &app.audio.config.sample_paths {
|
||||||
let index = doux::sampling::scan_samples_dir(path);
|
let index = doux::sampling::scan_samples_dir(path);
|
||||||
app.audio.config.sample_count += index.len();
|
app.audio.config.sample_counts.push(index.len());
|
||||||
initial_samples.extend(index);
|
initial_samples.extend(index);
|
||||||
}
|
}
|
||||||
let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples
|
let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
EngineSection::Settings => {
|
EngineSection::Settings => {
|
||||||
ctx.dispatch(AppCommand::AudioSettingPrev);
|
ctx.dispatch(AppCommand::AudioSettingPrev);
|
||||||
}
|
}
|
||||||
|
EngineSection::Samples => {
|
||||||
|
ctx.app.audio.sample_list.move_up();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Down => match ctx.app.audio.section {
|
KeyCode::Down => match ctx.app.audio.section {
|
||||||
@@ -65,6 +68,10 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
EngineSection::Settings => {
|
EngineSection::Settings => {
|
||||||
ctx.dispatch(AppCommand::AudioSettingNext);
|
ctx.dispatch(AppCommand::AudioSettingNext);
|
||||||
}
|
}
|
||||||
|
EngineSection::Samples => {
|
||||||
|
let count = ctx.app.audio.config.sample_paths.len();
|
||||||
|
ctx.app.audio.sample_list.move_down(count);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::PageUp => {
|
KeyCode::PageUp => {
|
||||||
@@ -128,14 +135,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
KeyCode::Char('R') if !ctx.app.plugin_mode => {
|
KeyCode::Char('R') if !ctx.app.plugin_mode => {
|
||||||
ctx.dispatch(AppCommand::AudioTriggerRestart);
|
ctx.dispatch(AppCommand::AudioTriggerRestart);
|
||||||
}
|
}
|
||||||
KeyCode::Char('A') => {
|
KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
|
||||||
use crate::state::file_browser::FileBrowserState;
|
use crate::state::file_browser::FileBrowserState;
|
||||||
let state = FileBrowserState::new_load(String::new());
|
let state = FileBrowserState::new_load(String::new());
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
||||||
}
|
}
|
||||||
KeyCode::Char('D') => {
|
KeyCode::Char('D') => {
|
||||||
if ctx.app.audio.section == EngineSection::Samples {
|
if ctx.app.audio.section == EngineSection::Samples {
|
||||||
ctx.dispatch(AppCommand::RemoveLastSamplePath);
|
let cursor = ctx.app.audio.sample_list.cursor;
|
||||||
|
ctx.dispatch(AppCommand::RemoveSamplePath(cursor));
|
||||||
|
ctx.app.save_settings(ctx.link);
|
||||||
} else if !ctx.app.plugin_mode {
|
} else if !ctx.app.plugin_mode {
|
||||||
ctx.dispatch(AppCommand::AudioRefreshDevices);
|
ctx.dispatch(AppCommand::AudioRefreshDevices);
|
||||||
let out_count = ctx.app.audio.output_devices.len();
|
let out_count = ctx.app.audio.output_devices.len();
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ctrl {
|
if ctrl {
|
||||||
let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250));
|
let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000));
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
ctx.app.ui.minimap = minimap_timed;
|
ctx.app.ui.minimap = minimap_timed;
|
||||||
@@ -168,7 +168,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
KeyCode::F(7) => Some(Page::Script),
|
KeyCode::F(7) => Some(Page::Script),
|
||||||
_ => None,
|
_ => None,
|
||||||
} {
|
} {
|
||||||
ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250));
|
ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000));
|
||||||
ctx.dispatch(AppCommand::GoToPage(page));
|
ctx.dispatch(AppCommand::GoToPage(page));
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
@@ -224,21 +224,25 @@ fn load_project_samples(ctx: &mut InputContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut total_count = 0;
|
let mut total_count = 0;
|
||||||
|
let mut counts = Vec::new();
|
||||||
let mut all_preload_entries = Vec::new();
|
let mut all_preload_entries = Vec::new();
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let index = doux::sampling::scan_samples_dir(path);
|
let index = doux::sampling::scan_samples_dir(path);
|
||||||
let count = index.len();
|
let count = index.len();
|
||||||
total_count += count;
|
total_count += count;
|
||||||
|
counts.push(count);
|
||||||
for e in &index {
|
for e in &index {
|
||||||
all_preload_entries.push((e.name.clone(), e.path.clone()));
|
all_preload_entries.push((e.name.clone(), e.path.clone()));
|
||||||
}
|
}
|
||||||
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
|
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
|
||||||
|
} else {
|
||||||
|
counts.push(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.app.audio.config.sample_paths = paths;
|
ctx.app.audio.config.sample_paths = paths;
|
||||||
ctx.app.audio.config.sample_count = total_count;
|
ctx.app.audio.config.sample_counts = counts;
|
||||||
|
|
||||||
for path in &ctx.app.audio.config.sample_paths {
|
for path in &ctx.app.audio.config.sample_paths {
|
||||||
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {
|
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {
|
||||||
|
|||||||
@@ -245,8 +245,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) {
|
if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) {
|
||||||
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path));
|
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path));
|
||||||
}
|
}
|
||||||
ctx.app.audio.config.sample_count += count;
|
ctx.app.audio.add_sample_path(path, count);
|
||||||
ctx.app.audio.add_sample_path(path);
|
|
||||||
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
|
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
|
||||||
let sr = ctx.app.audio.config.sample_rate;
|
let sr = ctx.app.audio.config.sample_rate;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -256,6 +255,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
})
|
})
|
||||||
.expect("failed to spawn preload thread");
|
.expect("failed to spawn preload thread");
|
||||||
}
|
}
|
||||||
|
ctx.app.save_settings(ctx.link);
|
||||||
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
|
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
|
||||||
ctx.dispatch(AppCommand::CloseModal);
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,11 +134,12 @@ fn main() -> io::Result<()> {
|
|||||||
stream_error_rx = new_error_rx;
|
stream_error_rx = new_error_rx;
|
||||||
|
|
||||||
let mut restart_samples = Vec::new();
|
let mut restart_samples = Vec::new();
|
||||||
|
app.audio.config.sample_counts.clear();
|
||||||
for path in &app.audio.config.sample_paths {
|
for path in &app.audio.config.sample_paths {
|
||||||
let index = doux::sampling::scan_samples_dir(path);
|
let index = doux::sampling::scan_samples_dir(path);
|
||||||
|
app.audio.config.sample_counts.push(index.len());
|
||||||
restart_samples.extend(index);
|
restart_samples.extend(index);
|
||||||
}
|
}
|
||||||
app.audio.config.sample_count = restart_samples.len();
|
|
||||||
|
|
||||||
audio_sample_pos.store(0, Ordering::Relaxed);
|
audio_sample_pos.store(0, Ordering::Relaxed);
|
||||||
|
|
||||||
@@ -340,7 +341,7 @@ fn main() -> io::Result<()> {
|
|||||||
|| app.ui.modal_fx.borrow().is_some()
|
|| app.ui.modal_fx.borrow().is_some()
|
||||||
|| app.ui.title_fx.borrow().is_some()
|
|| app.ui.title_fx.borrow().is_some()
|
||||||
|| app.ui.nav_fx.borrow().is_some();
|
|| app.ui.nav_fx.borrow().is_some();
|
||||||
if app.playback.playing || had_event || app.ui.show_title || effects_active {
|
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() {
|
||||||
if app.ui.show_title {
|
if app.ui.show_title {
|
||||||
app.ui.sparkles.tick(terminal.get_frame().area());
|
app.ui.sparkles.tick(terminal.get_frame().area());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::state::{ColorScheme, MainLayout};
|
use crate::state::{ColorScheme, MainLayout};
|
||||||
@@ -30,6 +32,8 @@ pub struct AudioSettings {
|
|||||||
pub buffer_size: u32,
|
pub buffer_size: u32,
|
||||||
#[serde(default = "default_max_voices")]
|
#[serde(default = "default_max_voices")]
|
||||||
pub max_voices: usize,
|
pub max_voices: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sample_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_voices() -> usize { 32 }
|
fn default_max_voices() -> usize { 32 }
|
||||||
@@ -97,6 +101,7 @@ impl Default for AudioSettings {
|
|||||||
channels: 2,
|
channels: 2,
|
||||||
buffer_size: 512,
|
buffer_size: 512,
|
||||||
max_voices: 32,
|
max_voices: 32,
|
||||||
|
sample_paths: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pub struct AudioConfig {
|
|||||||
pub sample_rate: f32,
|
pub sample_rate: f32,
|
||||||
pub host_name: String,
|
pub host_name: String,
|
||||||
pub sample_paths: Vec<PathBuf>,
|
pub sample_paths: Vec<PathBuf>,
|
||||||
pub sample_count: usize,
|
pub sample_counts: Vec<usize>,
|
||||||
pub refresh_rate: RefreshRate,
|
pub refresh_rate: RefreshRate,
|
||||||
pub show_scope: bool,
|
pub show_scope: bool,
|
||||||
pub show_spectrum: bool,
|
pub show_spectrum: bool,
|
||||||
@@ -140,7 +140,7 @@ impl Default for AudioConfig {
|
|||||||
sample_rate: 44100.0,
|
sample_rate: 44100.0,
|
||||||
host_name: String::new(),
|
host_name: String::new(),
|
||||||
sample_paths: Vec::new(),
|
sample_paths: Vec::new(),
|
||||||
sample_count: 0,
|
sample_counts: Vec::new(),
|
||||||
refresh_rate: RefreshRate::default(),
|
refresh_rate: RefreshRate::default(),
|
||||||
show_scope: true,
|
show_scope: true,
|
||||||
show_spectrum: true,
|
show_spectrum: true,
|
||||||
@@ -275,6 +275,7 @@ pub struct AudioSettings {
|
|||||||
pub input_devices: Vec<AudioDeviceInfo>,
|
pub input_devices: Vec<AudioDeviceInfo>,
|
||||||
pub output_list: ListSelectState,
|
pub output_list: ListSelectState,
|
||||||
pub input_list: ListSelectState,
|
pub input_list: ListSelectState,
|
||||||
|
pub sample_list: ListSelectState,
|
||||||
pub restart_pending: bool,
|
pub restart_pending: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>,
|
pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>,
|
||||||
@@ -297,6 +298,10 @@ impl Default for AudioSettings {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
},
|
},
|
||||||
|
sample_list: ListSelectState {
|
||||||
|
cursor: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
},
|
||||||
restart_pending: false,
|
restart_pending: false,
|
||||||
error: None,
|
error: None,
|
||||||
sample_registry: None,
|
sample_registry: None,
|
||||||
@@ -321,6 +326,10 @@ impl AudioSettings {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
},
|
},
|
||||||
|
sample_list: ListSelectState {
|
||||||
|
cursor: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
},
|
||||||
restart_pending: false,
|
restart_pending: false,
|
||||||
error: None,
|
error: None,
|
||||||
sample_registry: None,
|
sample_registry: None,
|
||||||
@@ -429,14 +438,29 @@ impl AudioSettings {
|
|||||||
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_sample_path(&mut self, path: PathBuf) {
|
pub fn total_sample_count(&self) -> usize {
|
||||||
|
self.config.sample_counts.iter().sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_sample_path(&mut self, path: PathBuf, count: usize) {
|
||||||
if !self.config.sample_paths.contains(&path) {
|
if !self.config.sample_paths.contains(&path) {
|
||||||
self.config.sample_paths.push(path);
|
self.config.sample_paths.push(path);
|
||||||
|
self.config.sample_counts.push(count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_last_sample_path(&mut self) {
|
pub fn remove_sample_path(&mut self, index: usize) {
|
||||||
self.config.sample_paths.pop();
|
if index < self.config.sample_paths.len() {
|
||||||
|
self.config.sample_paths.remove(index);
|
||||||
|
self.config.sample_counts.remove(index);
|
||||||
|
let len = self.config.sample_paths.len();
|
||||||
|
if len == 0 {
|
||||||
|
self.sample_list.cursor = 0;
|
||||||
|
self.sample_list.scroll_offset = 0;
|
||||||
|
} else if self.sample_list.cursor >= len {
|
||||||
|
self.sample_list.cursor = len - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn trigger_restart(&mut self) {
|
pub fn trigger_restart(&mut self) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate section heights
|
// Calculate section heights
|
||||||
|
let intro_lines: usize = 3;
|
||||||
let plugin_mode = app.plugin_mode;
|
let plugin_mode = app.plugin_mode;
|
||||||
let devices_lines = if plugin_mode {
|
let devices_lines = if plugin_mode {
|
||||||
0
|
0
|
||||||
@@ -55,17 +56,19 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
devices_section_height(app) as usize
|
devices_section_height(app) as usize
|
||||||
};
|
};
|
||||||
let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows
|
let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows
|
||||||
let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1)
|
let sample_content = app.audio.config.sample_paths.len().max(2); // at least 2 for empty message
|
||||||
|
let samples_lines: usize = 2 + sample_content; // header(2) + content
|
||||||
|
|
||||||
let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with
|
let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with
|
||||||
let total_lines = devices_lines + settings_lines + samples_lines + sections_gap;
|
let total_lines = intro_lines + 1 + devices_lines + settings_lines + samples_lines + sections_gap;
|
||||||
|
|
||||||
let max_visible = padded.height as usize;
|
let max_visible = padded.height as usize;
|
||||||
|
|
||||||
// Calculate scroll offset based on focused section
|
// Calculate scroll offset based on focused section
|
||||||
let settings_start = if plugin_mode { 0 } else { devices_lines + 1 };
|
let intro_offset = intro_lines + 1;
|
||||||
|
let settings_start = if plugin_mode { intro_offset } else { intro_offset + devices_lines + 1 };
|
||||||
let (focus_start, focus_height) = match app.audio.section {
|
let (focus_start, focus_height) = match app.audio.section {
|
||||||
EngineSection::Devices => (0, devices_lines),
|
EngineSection::Devices => (intro_offset, devices_lines),
|
||||||
EngineSection::Settings => (settings_start, settings_lines),
|
EngineSection::Settings => (settings_start, settings_lines),
|
||||||
EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines),
|
EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines),
|
||||||
};
|
};
|
||||||
@@ -86,6 +89,29 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
|
|
||||||
let mut y = viewport_top - scroll_offset as i32;
|
let mut y = viewport_top - scroll_offset as i32;
|
||||||
|
|
||||||
|
// Intro text
|
||||||
|
let intro_top = y;
|
||||||
|
let intro_bottom = y + intro_lines as i32;
|
||||||
|
if intro_bottom > viewport_top && intro_top < viewport_bottom {
|
||||||
|
let clipped_y = intro_top.max(viewport_top) as u16;
|
||||||
|
let clipped_height =
|
||||||
|
(intro_bottom.min(viewport_bottom) - intro_top.max(viewport_top)) as u16;
|
||||||
|
let intro_area = Rect {
|
||||||
|
x: padded.x,
|
||||||
|
y: clipped_y,
|
||||||
|
width: padded.width,
|
||||||
|
height: clipped_height,
|
||||||
|
};
|
||||||
|
let dim = Style::new().fg(theme.engine.dim);
|
||||||
|
let intro = Paragraph::new(vec![
|
||||||
|
Line::from(Span::styled(" Audio devices, settings, and sample paths.", dim)),
|
||||||
|
Line::from(Span::styled(" Supports .wav, .ogg, .mp3 samples and .sf2 soundfonts.", dim)),
|
||||||
|
Line::from(Span::styled(" Press R to restart the audio engine after changes.", dim)),
|
||||||
|
]);
|
||||||
|
frame.render_widget(intro, intro_area);
|
||||||
|
}
|
||||||
|
y += intro_lines as i32 + 1;
|
||||||
|
|
||||||
// Devices section (skip in plugin mode)
|
// Devices section (skip in plugin mode)
|
||||||
if !plugin_mode {
|
if !plugin_mode {
|
||||||
let devices_top = y;
|
let devices_top = y;
|
||||||
@@ -495,21 +521,26 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let section_focused = app.audio.section == EngineSection::Samples;
|
let section_focused = app.audio.section == EngineSection::Samples;
|
||||||
|
|
||||||
let [header_area, content_area, _, hint_area] = Layout::vertical([
|
let [header_area, content_area] = Layout::vertical([
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
let path_count = app.audio.config.sample_paths.len();
|
let path_count = app.audio.config.sample_paths.len();
|
||||||
let sample_count = app.audio.config.sample_count;
|
let sample_count: usize = app.audio.total_sample_count();
|
||||||
let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed");
|
let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed");
|
||||||
render_section_header(frame, &header_text, section_focused, header_area);
|
render_section_header(frame, &header_text, section_focused, header_area);
|
||||||
|
|
||||||
let dim = Style::new().fg(theme.engine.dim);
|
let dim = Style::new().fg(theme.engine.dim);
|
||||||
let path_style = Style::new().fg(theme.engine.path);
|
let path_style = Style::new().fg(theme.engine.path);
|
||||||
|
let cursor_style = Style::new()
|
||||||
|
.fg(theme.engine.focused)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let cursor = app.audio.sample_list.cursor;
|
||||||
|
let scroll_offset = app.audio.sample_list.scroll_offset;
|
||||||
|
let visible_rows = content_area.height as usize;
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
if app.audio.config.sample_paths.is_empty() {
|
if app.audio.config.sample_paths.is_empty() {
|
||||||
@@ -522,35 +553,32 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
dim,
|
dim,
|
||||||
)));
|
)));
|
||||||
} else {
|
} else {
|
||||||
for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() {
|
for (i, path) in app
|
||||||
|
.audio
|
||||||
|
.config
|
||||||
|
.sample_paths
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(scroll_offset)
|
||||||
|
.take(visible_rows)
|
||||||
|
{
|
||||||
|
let is_cursor = section_focused && i == cursor;
|
||||||
|
let prefix = if is_cursor { "> " } else { " " };
|
||||||
|
let count = app.audio.config.sample_counts.get(i).copied().unwrap_or(0);
|
||||||
let path_str = path.to_string_lossy();
|
let path_str = path.to_string_lossy();
|
||||||
let display = truncate_name(&path_str, 40);
|
let count_str = format!(" ({count})");
|
||||||
|
let max_path = (content_area.width as usize)
|
||||||
|
.saturating_sub(prefix.len() + count_str.len());
|
||||||
|
let display = truncate_name(&path_str, max_path);
|
||||||
|
let style = if is_cursor { cursor_style } else { path_style };
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(format!(" {} ", i + 1), dim),
|
Span::styled(prefix.to_string(), if is_cursor { cursor_style } else { dim }),
|
||||||
Span::styled(display, path_style),
|
Span::styled(display, style),
|
||||||
|
Span::styled(count_str, dim),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
if path_count > 4 {
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
format!(" ... and {} more", path_count - 4),
|
|
||||||
dim,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(lines), content_area);
|
frame.render_widget(Paragraph::new(lines), content_area);
|
||||||
|
|
||||||
let hint_style = if section_focused {
|
|
||||||
Style::new().fg(theme.engine.hint_active)
|
|
||||||
} else {
|
|
||||||
Style::new().fg(theme.engine.hint_inactive)
|
|
||||||
};
|
|
||||||
let hint = Line::from(vec![
|
|
||||||
Span::styled("A", hint_style),
|
|
||||||
Span::styled(":add ", Style::new().fg(theme.engine.dim)),
|
|
||||||
Span::styled("D", hint_style),
|
|
||||||
Span::styled(":remove", Style::new().fg(theme.engine.dim)),
|
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(hint), hint_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> {
|
fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> {
|
||||||
|
|||||||
@@ -551,15 +551,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Page::Patterns => vec![
|
Page::Patterns => vec![
|
||||||
("Enter", "Select"),
|
("Enter", "Select"),
|
||||||
("Space", "Play"),
|
("Space", "Play"),
|
||||||
|
("c", "Commit"),
|
||||||
("r", "Rename"),
|
("r", "Rename"),
|
||||||
("?", "Keys"),
|
("?", "Keys"),
|
||||||
],
|
],
|
||||||
Page::Engine => vec![
|
Page::Engine => vec![
|
||||||
("Tab", "Section"),
|
("Tab", "Section"),
|
||||||
("←→", "Switch/Adjust"),
|
("←→", "Switch/Adjust"),
|
||||||
("Enter", "Select"),
|
("A", "Add Samples"),
|
||||||
|
("D", "Remove"),
|
||||||
("R", "Restart"),
|
("R", "Restart"),
|
||||||
("h", "Hush"),
|
|
||||||
("?", "Keys"),
|
("?", "Keys"),
|
||||||
],
|
],
|
||||||
Page::Options => vec![
|
Page::Options => vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user