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

This commit is contained in:
2026-03-01 00:58:26 +01:00
parent 19bb3e0820
commit e73ee1eb1e
17 changed files with 196 additions and 73 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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);

View File

@@ -267,7 +267,7 @@ pub enum AppCommand {
delta: i32, delta: i32,
}, },
AudioTriggerRestart, AudioTriggerRestart,
RemoveLastSamplePath, RemoveSamplePath(usize),
AudioRefreshDevices, AudioRefreshDevices,
// Options page // Options page

View File

@@ -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

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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);
} }

View File

@@ -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());
} }

View File

@@ -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(),
} }
} }
} }

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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![