diff --git a/Cargo.toml b/Cargo.toml index ec38a91..aaff0b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ cagire-forth = { path = "crates/forth" } cagire-markdown = { path = "crates/markdown" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } -doux = { path = "/Users/bubo/doux", features = ["native"] } +doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] } rusty_link = "0.4" ratatui = "0.30" crossterm = "0.29" diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4e71c4d..487dbe5 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -283,6 +283,7 @@ pub struct Pattern { pub length: usize, pub speed: PatternSpeed, pub name: Option, + pub description: Option, pub quantization: LaunchQuantization, pub sync_mode: SyncMode, pub follow_up: FollowUp, @@ -317,6 +318,8 @@ struct SparsePattern { speed: PatternSpeed, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, #[serde(default, skip_serializing_if = "is_default_quantization")] quantization: LaunchQuantization, #[serde(default, skip_serializing_if = "is_default_sync_mode")] @@ -342,6 +345,8 @@ struct LegacyPattern { #[serde(default)] name: Option, #[serde(default)] + description: Option, + #[serde(default)] quantization: LaunchQuantization, #[serde(default)] sync_mode: SyncMode, @@ -370,6 +375,7 @@ impl Serialize for Pattern { length: self.length, speed: self.speed, name: self.name.clone(), + description: self.description.clone(), quantization: self.quantization, sync_mode: self.sync_mode, follow_up: self.follow_up, @@ -405,6 +411,7 @@ impl<'de> Deserialize<'de> for Pattern { length: sparse.length, speed: sparse.speed, name: sparse.name, + description: sparse.description, quantization: sparse.quantization, sync_mode: sparse.sync_mode, follow_up: sparse.follow_up, @@ -415,6 +422,7 @@ impl<'de> Deserialize<'de> for Pattern { length: legacy.length, speed: legacy.speed, name: legacy.name, + description: legacy.description, quantization: legacy.quantization, sync_mode: legacy.sync_mode, follow_up: legacy.follow_up, @@ -430,6 +438,7 @@ impl Default for Pattern { length: DEFAULT_LENGTH, speed: PatternSpeed::default(), name: None, + description: None, quantization: LaunchQuantization::default(), sync_mode: SyncMode::default(), follow_up: FollowUp::default(), diff --git a/docs/tutorials/soundfont.md b/docs/tutorials/soundfont.md new file mode 100644 index 0000000..81f5537 --- /dev/null +++ b/docs/tutorials/soundfont.md @@ -0,0 +1,111 @@ +# Soundfonts + +General MIDI playback via SF2 soundfont files. Native only -- not available in the WASM build. + +## Setup + +Drop an `.sf2` file into one of your samples directories. The engine finds and loads it automatically when Cagire starts. Only one soundfont is active at a time. + +## Playing + +Use `gm` as the sound source. The `n` parameter selects a program by name or number (0-127): + +```forth +gm s piano n . ;; acoustic piano +gm s strings n c4 note . ;; strings playing middle C +gm s 0 n e4 note . ;; program 0 (piano) playing E4 +``` + +## Drums + +Drums live on a separate bank. Use `drums` or `percussion` as the `n` value. Each MIDI note triggers a different instrument: + +```forth +gm s drums n 36 note . ;; kick +gm s drums n 38 note . ;; snare +gm s drums n 42 note . ;; closed hi-hat +gm s percussion n 49 note . ;; crash cymbal +``` + +## Envelope + +The soundfont embeds ADSR envelope data per preset. The engine applies it automatically. Override any parameter explicitly: + +```forth +gm s piano n 0.01 attack 0.3 decay . +gm s strings n 0.5 attack 2.0 release . +``` + +If you set `attack`, `decay`, `sustain`, or `release`, your value wins. Unspecified parameters keep the soundfont default. + +## Effects + +All standard engine parameters work on GM voices. Filter, distort, spatialize: + +```forth +gm s bass n 800 lpf 0.3 verb . +gm s epiano n 0.5 delay 1.5 distort . +gm s choir n 0.8 pan 2000 hpf . +``` + +## Preset Names + +| # | Name | # | Name | # | Name | # | Name | +|---|------|---|------|---|------|---|------| +| 0 | piano | 24 | guitar | 48 | strings | 72 | piccolo | +| 1 | brightpiano | 25 | steelguitar | 49 | slowstrings | 73 | flute | +| 4 | epiano | 26 | jazzguitar | 52 | choir | 74 | recorder | +| 6 | harpsichord | 27 | cleangt | 56 | trumpet | 75 | panflute | +| 7 | clavinet | 29 | overdrive | 57 | trombone | 79 | whistle | +| 8 | celesta | 30 | distgt | 58 | tuba | 80 | ocarina | +| 9 | glockenspiel | 33 | bass | 60 | horn | 81 | lead | +| 10 | musicbox | 34 | pickbass | 61 | brass | 82 | sawlead | +| 11 | vibraphone | 35 | fretless | 64 | sopranosax | 89 | pad | +| 12 | marimba | 36 | slapbass | 65 | altosax | 90 | warmpad | +| 13 | xylophone | 38 | synthbass | 66 | tenorsax | 91 | polysynth | +| 14 | bells | 40 | violin | 67 | barisax | 104 | sitar | +| 16 | organ | 41 | viola | 68 | oboe | 105 | banjo | +| 19 | churchorgan | 42 | cello | 70 | bassoon | 108 | kalimba | +| 21 | accordion | 43 | contrabass | 71 | clarinet | 114 | steeldrum | +| 22 | harmonica | 45 | pizzicato | | | | | +| | | 46 | harp | | | | | +| | | 47 | timpani | | | | | + +Drums are on a separate bank: use `drums` or `percussion` as the `n` value. + +## Examples + +A simple GM drum pattern across four steps: + +```forth +;; step 1: kick +gm s drums n 36 note . + +;; step 2: closed hat +gm s drums n 42 note 0.6 gain . + +;; step 3: snare +gm s drums n 38 note . + +;; step 4: closed hat +gm s drums n 42 note 0.6 gain . +``` + +Layer piano chords with randomness: + +```forth +gm s piano n +c4 e4 g4 3 choose note +0.3 0.8 rand gain +0.1 0.4 rand verb +. +``` + +A bass line with envelope override: + +```forth +gm s bass n +c2 e2 g2 a2 4 cycle note +0.01 attack 0.2 decay 0.0 sustain +. +``` diff --git a/plugins/cagire-plugins/Cargo.toml b/plugins/cagire-plugins/Cargo.toml index f540c15..bd4f408 100644 --- a/plugins/cagire-plugins/Cargo.toml +++ b/plugins/cagire-plugins/Cargo.toml @@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer cagire-forth = { path = "../../crates/forth" } cagire-project = { path = "../../crates/project" } cagire-ratatui = { path = "../../crates/ratatui" } -doux = { path = "/Users/bubo/doux", features = ["native"] } +doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] } nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" } egui_ratatui = "2.1" diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index aaac8ad..cdfd60e 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -139,6 +139,13 @@ impl App { } => { self.project_state.project.banks[bank].patterns[pattern].name = name; } + AppCommand::DescribePattern { + bank, + pattern, + description, + } => { + self.project_state.project.banks[bank].patterns[pattern].description = description; + } AppCommand::RenameStep { bank, pattern, @@ -179,6 +186,7 @@ impl App { bank, pattern, name, + description, length, speed, quantization, @@ -189,6 +197,7 @@ impl App { (bank, pattern), StagedPropChange { name, + description, length, speed, quantization, diff --git a/src/app/mod.rs b/src/app/mod.rs index a64b2dd..b3fd056 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -187,6 +187,7 @@ impl App { pattern, field: PatternPropsField::default(), name: pat.name.clone().unwrap_or_default(), + description: pat.description.clone().unwrap_or_default(), length: pat.length.to_string(), speed: pat.speed, quantization: pat.quantization, diff --git a/src/app/staging.rs b/src/app/staging.rs index 0f890f2..c7e68e0 100644 --- a/src/app/staging.rs +++ b/src/app/staging.rs @@ -74,6 +74,7 @@ impl App { for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() { let pat = self.project_state.project.pattern_at_mut(bank, pattern); pat.name = props.name; + pat.description = props.description; if let Some(len) = props.length { pat.set_length(len); } diff --git a/src/app/undo.rs b/src/app/undo.rs index ea46033..d1bd3bc 100644 --- a/src/app/undo.rs +++ b/src/app/undo.rs @@ -32,7 +32,8 @@ impl App { let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) } - AppCommand::RenameStep { bank, pattern, .. } => { + AppCommand::RenameStep { bank, pattern, .. } + | AppCommand::DescribePattern { bank, pattern, .. } => { let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) } diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index a40cd78..d58005a 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -263,6 +263,7 @@ impl CagireDesktop { restart_samples, Arc::clone(&self.audio_sample_pos), new_error_tx, + &self.app.audio.config.sample_paths, ) { Ok((new_stream, info, new_analysis, registry)) => { self._stream = Some(new_stream); diff --git a/src/commands.rs b/src/commands.rs index 58300e5..60616cb 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -125,6 +125,11 @@ pub enum AppCommand { step: usize, name: Option, }, + DescribePattern { + bank: usize, + pattern: usize, + description: Option, + }, Save(PathBuf), Load(PathBuf), @@ -142,6 +147,7 @@ pub enum AppCommand { bank: usize, pattern: usize, name: Option, + description: Option, length: Option, speed: PatternSpeed, quantization: LaunchQuantization, diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 1963893..0315ca0 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -294,6 +294,7 @@ pub fn build_stream( initial_samples: Vec, audio_sample_pos: Arc, error_tx: Sender, + sample_paths: &[std::path::PathBuf], ) -> Result< ( Stream, @@ -338,6 +339,17 @@ pub fn build_stream( let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); engine.sample_index = initial_samples; + + for path in sample_paths { + if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) { + if let Err(e) = engine.load_soundfont(&sf2_path) { + eprintln!("Failed to load soundfont: {e}"); + } else { + break; + } + } + } + let registry = Arc::clone(&engine.sample_registry); let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer); @@ -382,6 +394,11 @@ pub fn build_stream( AudioCommand::LoadSamples(samples) => { engine.sample_index.extend(samples); } + AudioCommand::LoadSoundfont(path) => { + if let Err(e) = engine.load_soundfont(&path) { + eprintln!("Failed to load soundfont: {e}"); + } + } } } diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index cd8c565..653fc28 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -53,6 +53,7 @@ pub enum AudioCommand { Hush, Panic, LoadSamples(Vec), + LoadSoundfont(std::path::PathBuf), } #[derive(Clone, Debug)] diff --git a/src/init.rs b/src/init.rs index 0fab46b..c5c7325 100644 --- a/src/init.rs +++ b/src/init.rs @@ -210,6 +210,7 @@ pub fn init(args: InitArgs) -> Init { initial_samples, Arc::clone(&audio_sample_pos), stream_error_tx, + &app.audio.config.sample_paths, ) { Ok((s, info, analysis, registry)) => { app.audio.config.sample_rate = info.sample_rate; diff --git a/src/input/main_page.rs b/src/input/main_page.rs index bd94d75..4ea3ca2 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -214,11 +214,13 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool } KeyCode::Char('m') => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); - ctx.dispatch(AppCommand::StageMute { bank, pattern }); + ctx.app.mute.toggle_mute(bank, pattern); + ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('x') => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); - ctx.dispatch(AppCommand::StageSolo { bank, pattern }); + ctx.app.mute.toggle_solo(bank, pattern); + ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('M') => { ctx.dispatch(AppCommand::ClearMutes); diff --git a/src/input/mod.rs b/src/input/mod.rs index 6bbe8a1..8b6f0a8 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -207,6 +207,13 @@ fn load_project_samples(ctx: &mut InputContext) { ctx.app.audio.config.sample_paths = paths; ctx.app.audio.config.sample_count = total_count; + for path in &ctx.app.audio.config.sample_paths { + if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) { + let _ = ctx.audio_tx.load().send(AudioCommand::LoadSoundfont(sf2_path)); + break; + } + } + if total_count > 0 { if let Some(registry) = ctx.app.audio.sample_registry.clone() { let sr = ctx.app.audio.config.sample_rate; diff --git a/src/input/modal.rs b/src/input/modal.rs index 24eb4e7..aadd85c 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -204,6 +204,9 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input .map(|e| (e.name.clone(), e.path.clone())) .collect(); let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSamples(index)); + if let Some(sf2_path) = doux::soundfont::find_sf2_file(&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); if let Some(registry) = ctx.app.audio.sample_registry.clone() { @@ -376,6 +379,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input pattern, field, name, + description, length, speed, quantization, @@ -423,6 +427,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input }, KeyCode::Char(c) => match field { PatternPropsField::Name => name.push(c), + PatternPropsField::Description => description.push(c), PatternPropsField::Length if c.is_ascii_digit() => length.push(c), _ => {} }, @@ -430,6 +435,9 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input PatternPropsField::Name => { name.pop(); } + PatternPropsField::Description => { + description.pop(); + } PatternPropsField::Length => { length.pop(); } @@ -441,6 +449,11 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input } else { Some(name.clone()) }; + let desc_val = if description.is_empty() { + None + } else { + Some(description.clone()) + }; let length_val = length.parse().ok(); let speed_val = *speed; let quant_val = *quantization; @@ -450,6 +463,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input bank, pattern, name: name_val, + description: desc_val, length: length_val, speed: speed_val, quantization: quant_val, @@ -625,5 +639,8 @@ fn rename_command(target: &RenameTarget, name: Option) -> AppCommand { RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep { bank: *bank, pattern: *pattern, step: *step, name, }, + RenameTarget::DescribePattern { bank, pattern } => AppCommand::DescribePattern { + bank: *bank, pattern: *pattern, description: name, + }, } } diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index 28164f0..7185d64 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -229,6 +229,22 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); } } + KeyCode::Char('d') => { + if ctx.app.patterns_nav.column == PatternsColumn::Patterns + && !ctx.app.patterns_nav.has_selection() + { + let bank = ctx.app.patterns_nav.bank_cursor; + let pattern = ctx.app.patterns_nav.pattern_cursor; + let current_desc = ctx.app.project_state.project.banks[bank].patterns[pattern] + .description + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::Rename { + target: RenameTarget::DescribePattern { bank, pattern }, + name: current_desc, + })); + } + } KeyCode::Char('m') => { let bank = ctx.app.patterns_nav.bank_cursor; for pattern in ctx.app.patterns_nav.selected_patterns() { diff --git a/src/main.rs b/src/main.rs index 99ce51d..42f473a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,7 @@ fn main() -> io::Result<()> { restart_samples, Arc::clone(&audio_sample_pos), new_error_tx, + &app.audio.config.sample_paths, ) { Ok((new_stream, info, new_analysis, registry)) => { _stream = Some(new_stream); diff --git a/src/model/docs.rs b/src/model/docs.rs index e0bc63a..58bd0c9 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -123,6 +123,10 @@ pub const DOCS: &[DocEntry] = &[ "Recording", include_str!("../../docs/tutorials/recording.md"), ), + Topic( + "Soundfonts", + include_str!("../../docs/tutorials/soundfont.md"), + ), ]; pub fn topic_count() -> usize { diff --git a/src/state/editor.rs b/src/state/editor.rs index 521f58f..60cd499 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -20,6 +20,7 @@ pub enum PatternField { pub enum PatternPropsField { #[default] Name, + Description, Length, Speed, Quantization, @@ -32,7 +33,8 @@ pub enum PatternPropsField { impl PatternPropsField { pub fn next(&self, follow_up_is_chain: bool) -> Self { match self { - Self::Name => Self::Length, + Self::Name => Self::Description, + Self::Description => Self::Length, Self::Length => Self::Speed, Self::Speed => Self::Quantization, Self::Quantization => Self::SyncMode, @@ -47,7 +49,8 @@ impl PatternPropsField { pub fn prev(&self, follow_up_is_chain: bool) -> Self { match self { Self::Name => Self::Name, - Self::Length => Self::Name, + Self::Description => Self::Name, + Self::Length => Self::Description, Self::Speed => Self::Length, Self::Quantization => Self::Speed, Self::SyncMode => Self::Quantization, diff --git a/src/state/modal.rs b/src/state/modal.rs index 3910701..8888c22 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -35,6 +35,7 @@ pub enum RenameTarget { Bank { bank: usize }, Pattern { bank: usize, pattern: usize }, Step { bank: usize, pattern: usize, step: usize }, + DescribePattern { bank: usize, pattern: usize }, } impl RenameTarget { @@ -43,6 +44,9 @@ impl RenameTarget { Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1), Self::Pattern { bank, pattern } => format!("Rename {}", model::bp_label(*bank, *pattern)), Self::Step { step, .. } => format!("Name Step {:02}", step + 1), + Self::DescribePattern { bank, pattern } => { + format!("Describe {}", model::bp_label(*bank, *pattern)) + } } } } @@ -73,6 +77,7 @@ pub enum Modal { pattern: usize, field: PatternPropsField, name: String, + description: String, length: String, speed: PatternSpeed, quantization: LaunchQuantization, diff --git a/src/state/playback.rs b/src/state/playback.rs index 13f1f05..fb5308e 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -17,6 +17,7 @@ pub enum StagedMuteChange { pub struct StagedPropChange { pub name: Option, + pub description: Option, pub length: Option, pub speed: PatternSpeed, pub quantization: LaunchQuantization, diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index 5eecc49..dbe70e8 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -55,13 +55,17 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati } Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); + bindings.push(("Shift+↑↓", "Select", "Extend selection")); + bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down")); bindings.push(("Enter", "Select", "Select pattern for editing")); if !plugin_mode { bindings.push(("Space", "Play", "Toggle pattern playback")); } bindings.push(("Esc", "Back", "Clear staged or go back")); bindings.push(("c", "Commit", "Commit staged changes")); + bindings.push(("p", "Stage play", "Stage pattern play toggle")); bindings.push(("r", "Rename", "Rename bank/pattern")); + bindings.push(("d", "Describe", "Add description to pattern")); bindings.push(("e", "Properties", "Edit pattern properties")); bindings.push(("m", "Mute", "Stage mute for pattern")); bindings.push(("x", "Solo", "Stage solo for pattern")); @@ -70,6 +74,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); bindings.push(("Del", "Reset", "Reset bank/pattern")); + bindings.push(("Ctrl+Z", "Undo", "Undo last action")); + bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action")); } Page::Engine => { bindings.push(("Tab", "Section", "Next section")); diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 202e460..c91e40d 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -496,13 +496,22 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a height: 1, }; - let label = format!( + let right_label = format!( "{} · {}", pattern.quantization.label(), pattern.sync_mode.label() ); let w = detail_area.width as usize; - let padded_label = format!("{label:>w$}"); + let label = if let Some(desc) = &pattern.description { + let right_len = right_label.chars().count(); + let max_desc = w.saturating_sub(right_len + 1); + let truncated: String = desc.chars().take(max_desc).collect(); + let pad = w.saturating_sub(truncated.chars().count() + right_len); + format!("{truncated}{}{right_label}", " ".repeat(pad)) + } else { + format!("{right_label:>w$}") + }; + let padded_label = label; let filled_width = if is_playing { let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0); @@ -725,6 +734,7 @@ fn render_properties( let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; let name = pattern.name.as_deref().unwrap_or("-"); + let desc = pattern.description.as_deref().unwrap_or("-"); let content_count = pattern.content_step_count(); let steps_label = format!("{}/{}", content_count, pattern.length); let speed_label = pattern.speed.label(); @@ -739,6 +749,10 @@ fn render_properties( Span::styled(" Name ", label_style), Span::styled(name, value_style), ]), + Line::from(vec![ + Span::styled(" Desc ", label_style), + Span::styled(desc, value_style), + ]), Line::from(vec![ Span::styled(" Steps ", label_style), Span::styled(steps_label, value_style), diff --git a/src/views/render.rs b/src/views/render.rs index a6e477a..c532ed7 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -650,6 +650,7 @@ fn render_modal( pattern, field, name, + description, length, speed, quantization, @@ -660,7 +661,7 @@ fn render_modal( use crate::state::PatternPropsField; let is_chain = matches!(follow_up, FollowUp::Chain { .. }); - let modal_height = if is_chain { 16 } else { 14 }; + let modal_height = if is_chain { 18 } else { 16 }; let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) .width(50) @@ -678,6 +679,7 @@ fn render_modal( }; let mut fields: Vec<(&str, String, bool)> = vec![ ("Name", name.clone(), *field == PatternPropsField::Name), + ("Desc", description.clone(), *field == PatternPropsField::Description), ("Length", length.clone(), *field == PatternPropsField::Length), ("Speed", speed_label, *field == PatternPropsField::Speed), ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization),