diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee725c3..0fcfa88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,8 +165,11 @@ jobs: mkdir -p pkg-root/Applications pkg-root/usr/local/bin cp -R Cagire.app pkg-root/Applications/ cp cagire pkg-root/usr/local/bin/ + pkgbuild --analyze --root pkg-root component.plist + plutil -replace BundleIsRelocatable -bool NO component.plist pkgbuild --root pkg-root --identifier com.sova.cagire \ --version "$VERSION" --install-location / \ + --component-plist component.plist \ "Cagire-${VERSION}-universal.pkg" - name: Upload universal CLI diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baf3d2..aeea811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,12 @@ All notable changes to this project will be documented in this file. ## [0.0.8] - 2026-02-07 +### Fixed +- macOS `.pkg` installer bundle relocation: disabled `BundleIsRelocatable` so `Cagire.app` always installs to `/Applications/` instead of being redirected to an existing bundle location. + ### Added +- Syntax highlighting for user-defined Forth words: words created with `: name ... ;` now render with a distinct color in both the editor and step preview, instead of dimmed gray. +- Multi-selection in Patterns view: Shift+Up/Down selects adjacent ranges of banks or patterns using anchor-based selection. Works with copy/paste (Ctrl+C/V), reset (Delete), toggle play (`p`), mute (`m`), and solo (`x`). Selection is column-scoped and clears on plain arrows, column switch, or Esc. Single-only actions (rename, pattern props, enter) are disabled during multi-selection. - Audio-rate modulation DSL: LFO words (`lfo`, `tlfo`, `wlfo`, `qlfo` for sine/triangle/sawtooth/square), transition envelopes (`slide`, `expslide`, `sslide` for linear/exponential/smooth), random modulation (`jit`, `sjit`, `drunk` for random hold/smooth random/drunk walk), and multi-segment envelope (`env`). These produce modulation strings consumed by parameter words for continuous audio-rate control. - Feedback delay FX words: `feedback`/`fb` (level), `fbtime`/`fbt` (delay time), `fbdamp`/`fbd` (damping), `fblfo` (LFO rate), `fblfodepth` (LFO depth), `fblfoshape` (LFO shape). - `bounce` word: ping-pong cycle through n items by step runs (e.g., `60 64 67 72 4 bounce` → 60 64 67 72 67 64 60 64...). diff --git a/crates/ratatui/src/theme/catppuccin_latte.rs b/crates/ratatui/src/theme/catppuccin_latte.rs index 71e9577..201bfd5 100644 --- a/crates/ratatui/src/theme/catppuccin_latte.rs +++ b/crates/ratatui/src/theme/catppuccin_latte.rs @@ -151,6 +151,7 @@ pub fn theme() -> ThemeColors { variable: (pink, Color::Rgb(245, 220, 240)), vary: (yellow, Color::Rgb(245, 235, 210)), generator: (teal, Color::Rgb(210, 240, 235)), + user_defined: (maroon, Color::Rgb(245, 225, 230)), default: (subtext0, mantle), }, table: TableColors { diff --git a/crates/ratatui/src/theme/catppuccin_mocha.rs b/crates/ratatui/src/theme/catppuccin_mocha.rs index 2723200..51a53a6 100644 --- a/crates/ratatui/src/theme/catppuccin_mocha.rs +++ b/crates/ratatui/src/theme/catppuccin_mocha.rs @@ -151,6 +151,7 @@ pub fn theme() -> ThemeColors { variable: (pink, Color::Rgb(55, 40, 55)), vary: (yellow, Color::Rgb(55, 50, 35)), generator: (teal, Color::Rgb(35, 55, 50)), + user_defined: (maroon, Color::Rgb(55, 35, 40)), default: (subtext0, mantle), }, table: TableColors { diff --git a/crates/ratatui/src/theme/dracula.rs b/crates/ratatui/src/theme/dracula.rs index ac61abe..0930c4e 100644 --- a/crates/ratatui/src/theme/dracula.rs +++ b/crates/ratatui/src/theme/dracula.rs @@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors { variable: (pink, Color::Rgb(80, 50, 65)), vary: (yellow, Color::Rgb(70, 70, 45)), generator: (cyan, Color::Rgb(45, 70, 65)), + user_defined: (orange, Color::Rgb(65, 55, 40)), default: (comment, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/eden.rs b/crates/ratatui/src/theme/eden.rs index 4389cc4..94caa45 100644 --- a/crates/ratatui/src/theme/eden.rs +++ b/crates/ratatui/src/theme/eden.rs @@ -148,6 +148,7 @@ pub fn theme() -> ThemeColors { variable: (purple, Color::Rgb(26, 22, 28)), vary: (yellow, Color::Rgb(30, 30, 12)), generator: (cyan, Color::Rgb(16, 30, 26)), + user_defined: (orange, Color::Rgb(30, 22, 10)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/ember.rs b/crates/ratatui/src/theme/ember.rs index 0a4908b..0e54243 100644 --- a/crates/ratatui/src/theme/ember.rs +++ b/crates/ratatui/src/theme/ember.rs @@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors { variable: (purple, Color::Rgb(32, 24, 28)), vary: (yellow, Color::Rgb(40, 30, 12)), generator: (cyan, Color::Rgb(22, 32, 30)), + user_defined: (purple, Color::Rgb(28, 20, 26)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/fairyfloss.rs b/crates/ratatui/src/theme/fairyfloss.rs index f1e53ae..320833b 100644 --- a/crates/ratatui/src/theme/fairyfloss.rs +++ b/crates/ratatui/src/theme/fairyfloss.rs @@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors { variable: (lavender, Color::Rgb(85, 75, 110)), vary: (yellow, Color::Rgb(100, 95, 60)), generator: (mint, Color::Rgb(70, 95, 95)), + user_defined: (coral, Color::Rgb(100, 75, 75)), default: (fg_dim, dark), }, table: TableColors { diff --git a/crates/ratatui/src/theme/georges.rs b/crates/ratatui/src/theme/georges.rs index 25854a2..ca3f1b1 100644 --- a/crates/ratatui/src/theme/georges.rs +++ b/crates/ratatui/src/theme/georges.rs @@ -150,6 +150,7 @@ pub fn theme() -> ThemeColors { variable: (violet, Color::Rgb(24, 8, 24)), vary: (yellow, Color::Rgb(30, 30, 14)), generator: (cyan, Color::Rgb(20, 30, 28)), + user_defined: (orange, Color::Rgb(30, 20, 10)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/gruvbox_dark.rs b/crates/ratatui/src/theme/gruvbox_dark.rs index 4bbb6ff..27ec11e 100644 --- a/crates/ratatui/src/theme/gruvbox_dark.rs +++ b/crates/ratatui/src/theme/gruvbox_dark.rs @@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors { variable: (purple, Color::Rgb(65, 50, 55)), vary: (yellow, Color::Rgb(70, 65, 40)), generator: (aqua, Color::Rgb(45, 60, 50)), + user_defined: (orange, Color::Rgb(60, 45, 30)), default: (fg3, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/hot_dog_stand.rs b/crates/ratatui/src/theme/hot_dog_stand.rs index 6a7fd6f..1c9a28b 100644 --- a/crates/ratatui/src/theme/hot_dog_stand.rs +++ b/crates/ratatui/src/theme/hot_dog_stand.rs @@ -142,6 +142,7 @@ pub fn theme() -> ThemeColors { variable: (white, muted_red), vary: (gold, muted_red), generator: (yellow, muted_red), + user_defined: (gold, Color::Rgb(100, 70, 0)), default: (light_yellow, darker_red), }, table: TableColors { diff --git a/crates/ratatui/src/theme/kanagawa.rs b/crates/ratatui/src/theme/kanagawa.rs index 954c0d9..1b3fc17 100644 --- a/crates/ratatui/src/theme/kanagawa.rs +++ b/crates/ratatui/src/theme/kanagawa.rs @@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors { variable: (autumn_green, Color::Rgb(45, 55, 45)), vary: (carp_yellow, Color::Rgb(65, 60, 50)), generator: (spring_blue, Color::Rgb(45, 55, 65)), + user_defined: (sakura_pink, Color::Rgb(55, 40, 50)), default: (fg_dim, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/letz_light.rs b/crates/ratatui/src/theme/letz_light.rs index 97537a3..e00618d 100644 --- a/crates/ratatui/src/theme/letz_light.rs +++ b/crates/ratatui/src/theme/letz_light.rs @@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors { variable: (keyword, Color::Rgb(240, 225, 240)), vary: (Color::Rgb(180, 140, 20), Color::Rgb(250, 240, 210)), generator: (function, Color::Rgb(215, 240, 235)), + user_defined: (preproc, Color::Rgb(240, 230, 215)), default: (text_dim, off_white), }, table: TableColors { diff --git a/crates/ratatui/src/theme/mod.rs b/crates/ratatui/src/theme/mod.rs index 08b24ef..6237da5 100644 --- a/crates/ratatui/src/theme/mod.rs +++ b/crates/ratatui/src/theme/mod.rs @@ -227,6 +227,7 @@ pub struct SyntaxColors { pub variable: (Color, Color), pub vary: (Color, Color), pub generator: (Color, Color), + pub user_defined: (Color, Color), pub default: (Color, Color), } diff --git a/crates/ratatui/src/theme/monochrome_black.rs b/crates/ratatui/src/theme/monochrome_black.rs index 3849937..a4145c2 100644 --- a/crates/ratatui/src/theme/monochrome_black.rs +++ b/crates/ratatui/src/theme/monochrome_black.rs @@ -144,6 +144,7 @@ pub fn theme() -> ThemeColors { variable: (medium, Color::Rgb(30, 30, 30)), vary: (dim, Color::Rgb(25, 25, 25)), generator: (bright, Color::Rgb(45, 45, 45)), + user_defined: (medium, Color::Rgb(35, 35, 35)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/monochrome_white.rs b/crates/ratatui/src/theme/monochrome_white.rs index 9604338..7808249 100644 --- a/crates/ratatui/src/theme/monochrome_white.rs +++ b/crates/ratatui/src/theme/monochrome_white.rs @@ -144,6 +144,7 @@ pub fn theme() -> ThemeColors { variable: (medium, Color::Rgb(230, 230, 230)), vary: (dim, Color::Rgb(235, 235, 235)), generator: (dark, Color::Rgb(215, 215, 215)), + user_defined: (medium, Color::Rgb(225, 225, 225)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/monokai.rs b/crates/ratatui/src/theme/monokai.rs index e761704..28a703a 100644 --- a/crates/ratatui/src/theme/monokai.rs +++ b/crates/ratatui/src/theme/monokai.rs @@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors { variable: (green, Color::Rgb(55, 75, 45)), vary: (yellow, Color::Rgb(70, 65, 45)), generator: (blue, Color::Rgb(50, 70, 70)), + user_defined: (orange, Color::Rgb(60, 50, 30)), default: (fg_dim, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/nord.rs b/crates/ratatui/src/theme/nord.rs index 79fddbc..f941612 100644 --- a/crates/ratatui/src/theme/nord.rs +++ b/crates/ratatui/src/theme/nord.rs @@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors { variable: (aurora_purple, Color::Rgb(60, 50, 60)), vary: (aurora_yellow, Color::Rgb(65, 60, 45)), generator: (frost0, Color::Rgb(45, 60, 55)), + user_defined: (aurora_orange, Color::Rgb(60, 50, 45)), default: (snow_storm0, polar_night1), }, table: TableColors { diff --git a/crates/ratatui/src/theme/pitch_black.rs b/crates/ratatui/src/theme/pitch_black.rs index 2063592..b7a41dd 100644 --- a/crates/ratatui/src/theme/pitch_black.rs +++ b/crates/ratatui/src/theme/pitch_black.rs @@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors { variable: (purple, Color::Rgb(40, 25, 50)), vary: (yellow, Color::Rgb(50, 45, 20)), generator: (cyan, Color::Rgb(20, 45, 40)), + user_defined: (orange, Color::Rgb(40, 25, 10)), default: (fg_dim, bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/rose_pine.rs b/crates/ratatui/src/theme/rose_pine.rs index 97218ff..b05d5e6 100644 --- a/crates/ratatui/src/theme/rose_pine.rs +++ b/crates/ratatui/src/theme/rose_pine.rs @@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors { variable: (pine, Color::Rgb(35, 50, 55)), vary: (subtle, Color::Rgb(60, 55, 55)), generator: (foam, Color::Rgb(40, 55, 60)), + user_defined: (love, Color::Rgb(55, 35, 45)), default: (fg_dim, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/tokyo_night.rs b/crates/ratatui/src/theme/tokyo_night.rs index 45c5ab0..2ad7879 100644 --- a/crates/ratatui/src/theme/tokyo_night.rs +++ b/crates/ratatui/src/theme/tokyo_night.rs @@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors { variable: (green, Color::Rgb(50, 60, 45)), vary: (yellow, Color::Rgb(70, 60, 45)), generator: (cyan, Color::Rgb(45, 60, 75)), + user_defined: (orange, Color::Rgb(60, 50, 35)), default: (fg_dim, darker_bg), }, table: TableColors { diff --git a/crates/ratatui/src/theme/transform.rs b/crates/ratatui/src/theme/transform.rs index c8bf34a..f56c6f4 100644 --- a/crates/ratatui/src/theme/transform.rs +++ b/crates/ratatui/src/theme/transform.rs @@ -211,6 +211,7 @@ pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors { variable: rotate_color_pair(theme.syntax.variable, degrees), vary: rotate_color_pair(theme.syntax.vary, degrees), generator: rotate_color_pair(theme.syntax.generator, degrees), + user_defined: rotate_color_pair(theme.syntax.user_defined, degrees), default: rotate_color_pair(theme.syntax.default, degrees), }, table: TableColors { diff --git a/src/app.rs b/src/app.rs index db7b0f0..b53df25 100644 --- a/src/app.rs +++ b/src/app.rs @@ -58,8 +58,8 @@ pub struct App { pub rng: Rng, pub live_keys: Arc, pub clipboard: Option, - pub copied_pattern: Option, - pub copied_bank: Option, + pub copied_patterns: Option>, + pub copied_banks: Option>, pub audio: AudioSettings, pub options: OptionsState, @@ -100,8 +100,8 @@ impl App { live_keys, script_engine, clipboard: arboard::Clipboard::new().ok(), - copied_pattern: None, - copied_bank: None, + copied_patterns: None, + copied_banks: None, audio: AudioSettings::default(), options: OptionsState::default(), @@ -682,12 +682,17 @@ impl App { } pub fn copy_pattern(&mut self, bank: usize, pattern: usize) { - self.copied_pattern = Some(clipboard::copy_pattern(&self.project_state.project, bank, pattern)); + self.copied_patterns = Some(vec![clipboard::copy_pattern( + &self.project_state.project, + bank, + pattern, + )]); self.ui.flash("Pattern copied", 150, FlashKind::Success); } pub fn paste_pattern(&mut self, bank: usize, pattern: usize) { - if let Some(src) = self.copied_pattern.clone() { + if let Some(src) = self.copied_patterns.as_ref().and_then(|v| v.first()) { + let src = src.clone(); clipboard::paste_pattern(&mut self.project_state.project, bank, pattern, &src); self.project_state.mark_dirty(bank, pattern); if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { @@ -697,14 +702,55 @@ impl App { } } + pub fn copy_patterns(&mut self, bank: usize, patterns: &[usize]) { + self.copied_patterns = Some(clipboard::copy_patterns( + &self.project_state.project, + bank, + patterns, + )); + let n = patterns.len(); + self.ui.flash( + &format!("{n} pattern{} copied", if n == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + + pub fn paste_patterns(&mut self, bank: usize, start: usize) { + if let Some(sources) = self.copied_patterns.clone() { + let count = clipboard::paste_patterns( + &mut self.project_state.project, + bank, + start, + &sources, + ); + for i in 0..count { + self.project_state.mark_dirty(bank, start + i); + } + if self.editor_ctx.bank == bank { + self.load_step_to_editor(); + } + self.ui.flash( + &format!("{count} pattern{} pasted", if count == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + } + pub fn copy_bank(&mut self, bank: usize) { - self.copied_bank = Some(clipboard::copy_bank(&self.project_state.project, bank)); + self.copied_banks = Some(vec![clipboard::copy_bank( + &self.project_state.project, + bank, + )]); self.ui.flash("Bank copied", 150, FlashKind::Success); } pub fn paste_bank(&mut self, bank: usize) { - if let Some(src) = self.copied_bank.clone() { - let pat_count = clipboard::paste_bank(&mut self.project_state.project, bank, &src); + if let Some(src) = self.copied_banks.as_ref().and_then(|v| v.first()) { + let src = src.clone(); + let pat_count = + clipboard::paste_bank(&mut self.project_state.project, bank, &src); for pattern in 0..pat_count { self.project_state.mark_dirty(bank, pattern); } @@ -715,6 +761,79 @@ impl App { } } + pub fn copy_banks(&mut self, banks: &[usize]) { + self.copied_banks = Some(clipboard::copy_banks( + &self.project_state.project, + banks, + )); + let n = banks.len(); + self.ui.flash( + &format!("{n} bank{} copied", if n == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + + pub fn paste_banks(&mut self, start: usize) { + if let Some(sources) = self.copied_banks.clone() { + let count = clipboard::paste_banks( + &mut self.project_state.project, + start, + &sources, + ); + for i in 0..count { + let bank = start + i; + for pattern in 0..model::MAX_PATTERNS { + self.project_state.mark_dirty(bank, pattern); + } + } + if (start..start + count).contains(&self.editor_ctx.bank) { + self.load_step_to_editor(); + } + self.ui.flash( + &format!("{count} bank{} pasted", if count == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + } + + pub fn reset_patterns(&mut self, bank: usize, patterns: &[usize]) { + for &pattern in patterns { + let edit = + pattern_editor::reset_pattern(&mut self.project_state.project, bank, pattern); + self.project_state.mark_dirty(edit.bank, edit.pattern); + } + if self.editor_ctx.bank == bank && patterns.contains(&self.editor_ctx.pattern) { + self.load_step_to_editor(); + } + let n = patterns.len(); + self.ui.flash( + &format!("{n} pattern{} reset", if n == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + + pub fn reset_banks(&mut self, banks: &[usize]) { + for &bank in banks { + let pat_count = + pattern_editor::reset_bank(&mut self.project_state.project, bank); + for pattern in 0..pat_count { + self.project_state.mark_dirty(bank, pattern); + } + } + if banks.contains(&self.editor_ctx.bank) { + self.load_step_to_editor(); + } + let n = banks.len(); + self.ui.flash( + &format!("{n} bank{} reset", if n == 1 { "" } else { "s" }), + 150, + FlashKind::Success, + ); + } + pub fn harden_steps(&mut self) { let (bank, pattern) = self.current_bank_pattern(); let indices = self.selected_steps(); @@ -944,12 +1063,30 @@ impl App { AppCommand::PastePattern { bank, pattern } => { self.paste_pattern(bank, pattern); } + AppCommand::CopyPatterns { bank, patterns } => { + self.copy_patterns(bank, &patterns); + } + AppCommand::PastePatterns { bank, start } => { + self.paste_patterns(bank, start); + } AppCommand::CopyBank { bank } => { self.copy_bank(bank); } AppCommand::PasteBank { bank } => { self.paste_bank(bank); } + AppCommand::CopyBanks { banks } => { + self.copy_banks(&banks); + } + AppCommand::PasteBanks { start } => { + self.paste_banks(start); + } + AppCommand::ResetPatterns { bank, patterns } => { + self.reset_patterns(bank, &patterns); + } + AppCommand::ResetBanks { banks } => { + self.reset_banks(&banks); + } // Clipboard AppCommand::HardenSteps => self.harden_steps(), @@ -1090,11 +1227,6 @@ impl App { AppCommand::PatternsBack => { self.page.down(); } - AppCommand::PatternsTogglePlay => { - let bank = self.patterns_nav.selected_bank(); - let pattern = self.patterns_nav.selected_pattern(); - self.stage_pattern_toggle(bank, pattern, snapshot); - } // Mute/Solo (staged) AppCommand::StageMute { bank, pattern } => { diff --git a/src/commands.rs b/src/commands.rs index c4dd221..74e18a5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -60,12 +60,33 @@ pub enum AppCommand { bank: usize, pattern: usize, }, + CopyPatterns { + bank: usize, + patterns: Vec, + }, + PastePatterns { + bank: usize, + start: usize, + }, CopyBank { bank: usize, }, PasteBank { bank: usize, }, + CopyBanks { + banks: Vec, + }, + PasteBanks { + start: usize, + }, + ResetPatterns { + bank: usize, + patterns: Vec, + }, + ResetBanks { + banks: Vec, + }, // Clipboard HardenSteps, @@ -153,7 +174,6 @@ pub enum AppCommand { PatternsCursorDown, PatternsEnter, PatternsBack, - PatternsTogglePlay, // Mute/Solo (staged) StageMute { bank: usize, pattern: usize }, diff --git a/src/input.rs b/src/input.rs index af25e59..0f9979f 100644 --- a/src/input.rs +++ b/src/input.rs @@ -754,6 +754,70 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } + Modal::ConfirmResetPatterns { + bank, + patterns, + selected: _, + } => { + let (bank, patterns) = (*bank, patterns.clone()); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::ResetPatterns { bank, patterns }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmResetPatterns { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_reset = + if let Modal::ConfirmResetPatterns { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_reset { + ctx.dispatch(AppCommand::ResetPatterns { bank, patterns }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } + Modal::ConfirmResetBanks { banks, selected: _ } => { + let banks = banks.clone(); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::ResetBanks { banks }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmResetBanks { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_reset = + if let Modal::ConfirmResetBanks { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_reset { + ctx.dispatch(AppCommand::ResetBanks { banks }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } Modal::None => unreachable!(), } InputResult::Continue @@ -1142,14 +1206,55 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { use crate::state::PatternsColumn; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); match key.code { + KeyCode::Up if shift => { + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + if ctx.app.patterns_nav.bank_anchor.is_none() { + ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor); + } + } + PatternsColumn::Patterns => { + if ctx.app.patterns_nav.pattern_anchor.is_none() { + ctx.app.patterns_nav.pattern_anchor = + Some(ctx.app.patterns_nav.pattern_cursor); + } + } + } + ctx.app.patterns_nav.move_up_clamped(); + } + KeyCode::Down if shift => { + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + if ctx.app.patterns_nav.bank_anchor.is_none() { + ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor); + } + } + PatternsColumn::Patterns => { + if ctx.app.patterns_nav.pattern_anchor.is_none() { + ctx.app.patterns_nav.pattern_anchor = + Some(ctx.app.patterns_nav.pattern_cursor); + } + } + } + ctx.app.patterns_nav.move_down_clamped(); + } + KeyCode::Up => { + ctx.app.patterns_nav.clear_selection(); + ctx.dispatch(AppCommand::PatternsCursorUp); + } + KeyCode::Down => { + ctx.app.patterns_nav.clear_selection(); + ctx.dispatch(AppCommand::PatternsCursorDown); + } KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft), KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), - KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp), - KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), KeyCode::Esc => { - if !ctx.app.playback.staged_changes.is_empty() + if ctx.app.patterns_nav.has_selection() { + ctx.app.patterns_nav.clear_selection(); + } else if !ctx.app.playback.staged_changes.is_empty() || !ctx.app.playback.staged_mute_changes.is_empty() || !ctx.app.playback.staged_prop_changes.is_empty() { @@ -1158,10 +1263,17 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::PatternsBack); } } - KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), + KeyCode::Enter => { + if !ctx.app.patterns_nav.has_selection() { + ctx.dispatch(AppCommand::PatternsEnter); + } + } KeyCode::Char('p') => { if ctx.app.patterns_nav.column == PatternsColumn::Patterns { - ctx.dispatch(AppCommand::PatternsTogglePlay); + let bank = ctx.app.patterns_nav.bank_cursor; + for pattern in ctx.app.patterns_nav.selected_patterns() { + ctx.app.stage_pattern_toggle(bank, pattern, ctx.snapshot); + } } } KeyCode::Char(' ') => { @@ -1184,11 +1296,21 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { - ctx.dispatch(AppCommand::CopyBank { bank }); + let banks = ctx.app.patterns_nav.selected_banks(); + if banks.len() > 1 { + ctx.dispatch(AppCommand::CopyBanks { banks }); + } else { + ctx.dispatch(AppCommand::CopyBank { bank }); + } } PatternsColumn::Patterns => { - let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::CopyPattern { bank, pattern }); + let patterns = ctx.app.patterns_nav.selected_patterns(); + if patterns.len() > 1 { + ctx.dispatch(AppCommand::CopyPatterns { bank, patterns }); + } else { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::CopyPattern { bank, pattern }); + } } } } @@ -1196,11 +1318,27 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { - ctx.dispatch(AppCommand::PasteBank { bank }); + if ctx.app.copied_banks.as_ref().is_some_and(|v| v.len() > 1) { + ctx.dispatch(AppCommand::PasteBanks { start: bank }); + } else { + ctx.dispatch(AppCommand::PasteBank { bank }); + } } PatternsColumn::Patterns => { let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::PastePattern { bank, pattern }); + if ctx + .app + .copied_patterns + .as_ref() + .is_some_and(|v| v.len() > 1) + { + ctx.dispatch(AppCommand::PastePatterns { + bank, + start: pattern, + }); + } else { + ctx.dispatch(AppCommand::PastePattern { bank, pattern }); + } } } } @@ -1208,50 +1346,72 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { - bank, - selected: false, - })); + let banks = ctx.app.patterns_nav.selected_banks(); + if banks.len() > 1 { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBanks { + banks, + selected: false, + })); + } else { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { + bank, + selected: false, + })); + } } PatternsColumn::Patterns => { - let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { - bank, - pattern, - selected: false, - })); + let patterns = ctx.app.patterns_nav.selected_patterns(); + if patterns.len() > 1 { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPatterns { + bank, + patterns, + selected: false, + })); + } else { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { + bank, + pattern, + selected: false, + })); + } } } } KeyCode::Char('r') => { - let bank = ctx.app.patterns_nav.bank_cursor; - match ctx.app.patterns_nav.column { - PatternsColumn::Banks => { - let current_name = ctx.app.project_state.project.banks[bank] - .name - .clone() - .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { - bank, - name: current_name, - })); - } - PatternsColumn::Patterns => { - let pattern = ctx.app.patterns_nav.pattern_cursor; - let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] - .name - .clone() - .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { - bank, - pattern, - name: current_name, - })); + if !ctx.app.patterns_nav.has_selection() { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + let current_name = ctx.app.project_state.project.banks[bank] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { + bank, + name: current_name, + })); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + let current_name = ctx.app.project_state.project.banks[bank].patterns + [pattern] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { + bank, + pattern, + name: current_name, + })); + } } } } KeyCode::Char('e') if !ctrl => { - if ctx.app.patterns_nav.column == PatternsColumn::Patterns { + 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; ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); @@ -1259,13 +1419,15 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } KeyCode::Char('m') => { let bank = ctx.app.patterns_nav.bank_cursor; - let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::StageMute { bank, pattern }); + for pattern in ctx.app.patterns_nav.selected_patterns() { + ctx.dispatch(AppCommand::StageMute { bank, pattern }); + } } KeyCode::Char('x') => { let bank = ctx.app.patterns_nav.bank_cursor; - let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::StageSolo { bank, pattern }); + for pattern in ctx.app.patterns_nav.selected_patterns() { + ctx.dispatch(AppCommand::StageSolo { bank, pattern }); + } } KeyCode::Char('M') => { ctx.dispatch(AppCommand::ClearMutes); diff --git a/src/services/clipboard.rs b/src/services/clipboard.rs index 7b2818b..bfb7cca 100644 --- a/src/services/clipboard.rs +++ b/src/services/clipboard.rs @@ -24,6 +24,33 @@ pub fn paste_pattern( project.banks[bank].patterns[pattern] = pat; } +pub fn copy_patterns(project: &Project, bank: usize, indices: &[usize]) -> Vec { + indices + .iter() + .map(|&i| project.banks[bank].patterns[i].clone()) + .collect() +} + +pub fn paste_patterns( + project: &mut Project, + bank: usize, + start: usize, + sources: &[Pattern], +) -> usize { + let mut count = 0; + for (i, src) in sources.iter().enumerate() { + let target = start + i; + if target >= crate::model::MAX_PATTERNS { + break; + } + let mut pat = src.clone(); + pat.name = annotate_copy_name(&src.name); + project.banks[bank].patterns[target] = pat; + count += 1; + } + count +} + pub fn copy_bank(project: &Project, bank: usize) -> Bank { project.banks[bank].clone() } @@ -35,6 +62,28 @@ pub fn paste_bank(project: &mut Project, bank: usize, source: &Bank) -> usize { project.banks[bank].patterns.len() } +pub fn copy_banks(project: &Project, indices: &[usize]) -> Vec { + indices + .iter() + .map(|&i| project.banks[i].clone()) + .collect() +} + +pub fn paste_banks(project: &mut Project, start: usize, sources: &[Bank]) -> usize { + let mut count = 0; + for (i, src) in sources.iter().enumerate() { + let target = start + i; + if target >= crate::model::MAX_BANKS { + break; + } + let mut b = src.clone(); + b.name = annotate_copy_name(&src.name); + project.banks[target] = b; + count += 1; + } + count +} + pub fn copy_steps( project: &Project, bank: usize, diff --git a/src/state/modal.rs b/src/state/modal.rs index 17dfa93..1a8eac9 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -29,6 +29,15 @@ pub enum Modal { bank: usize, selected: bool, }, + ConfirmResetPatterns { + bank: usize, + patterns: Vec, + selected: bool, + }, + ConfirmResetBanks { + banks: Vec, + selected: bool, + }, FileBrowser(Box), RenameBank { bank: usize, diff --git a/src/state/patterns_nav.rs b/src/state/patterns_nav.rs index 1df7053..c504655 100644 --- a/src/state/patterns_nav.rs +++ b/src/state/patterns_nav.rs @@ -1,3 +1,5 @@ +use std::ops::RangeInclusive; + use crate::model::{MAX_BANKS, MAX_PATTERNS}; #[derive(Clone, Copy, PartialEq, Eq, Default)] @@ -12,14 +14,18 @@ pub struct PatternsNav { pub column: PatternsColumn, pub bank_cursor: usize, pub pattern_cursor: usize, + pub bank_anchor: Option, + pub pattern_anchor: Option, } impl PatternsNav { pub fn move_left(&mut self) { + self.clear_selection(); self.column = PatternsColumn::Banks; } pub fn move_right(&mut self) { + self.clear_selection(); self.column = PatternsColumn::Patterns; } @@ -45,6 +51,28 @@ impl PatternsNav { } } + pub fn move_up_clamped(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = self.bank_cursor.saturating_sub(1); + } + PatternsColumn::Patterns => { + self.pattern_cursor = self.pattern_cursor.saturating_sub(1); + } + } + } + + pub fn move_down_clamped(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = (self.bank_cursor + 1).min(MAX_BANKS - 1); + } + PatternsColumn::Patterns => { + self.pattern_cursor = (self.pattern_cursor + 1).min(MAX_PATTERNS - 1); + } + } + } + pub fn selected_bank(&self) -> usize { self.bank_cursor } @@ -52,4 +80,44 @@ impl PatternsNav { pub fn selected_pattern(&self) -> usize { self.pattern_cursor } + + pub fn bank_selection_range(&self) -> Option> { + let anchor = self.bank_anchor?; + let a = anchor.min(self.bank_cursor); + let b = anchor.max(self.bank_cursor); + Some(a..=b) + } + + pub fn pattern_selection_range(&self) -> Option> { + let anchor = self.pattern_anchor?; + let a = anchor.min(self.pattern_cursor); + let b = anchor.max(self.pattern_cursor); + Some(a..=b) + } + + pub fn selected_banks(&self) -> Vec { + match self.bank_selection_range() { + Some(range) => range.collect(), + None => vec![self.bank_cursor], + } + } + + pub fn selected_patterns(&self) -> Vec { + match self.pattern_selection_range() { + Some(range) => range.collect(), + None => vec![self.pattern_cursor], + } + } + + pub fn clear_selection(&mut self) { + self.bank_anchor = None; + self.pattern_anchor = None; + } + + pub fn has_selection(&self) -> bool { + match self.column { + PatternsColumn::Banks => self.bank_anchor.is_some(), + PatternsColumn::Patterns => self.pattern_anchor.is_some(), + } + } } diff --git a/src/views/highlight.rs b/src/views/highlight.rs index 28d91dd..cb71eab 100644 --- a/src/views/highlight.rs +++ b/src/views/highlight.rs @@ -1,8 +1,13 @@ +use std::collections::HashSet; +use std::sync::LazyLock; + use ratatui::style::{Modifier, Style}; use crate::model::{lookup_word, SourceSpan, WordCompile}; use crate::theme; +static EMPTY_SET: LazyLock> = LazyLock::new(HashSet::new); + #[derive(Clone, Copy, PartialEq, Eq)] pub enum TokenKind { Number, @@ -20,6 +25,7 @@ pub enum TokenKind { Emit, Vary, Generator, + UserDefined, Default, } @@ -42,6 +48,7 @@ impl TokenKind { TokenKind::Variable => theme.syntax.variable, TokenKind::Vary => theme.syntax.vary, TokenKind::Generator => theme.syntax.generator, + TokenKind::UserDefined => theme.syntax.user_defined, TokenKind::Default => theme.syntax.default, }; let style = Style::default().fg(fg).bg(bg); @@ -114,7 +121,7 @@ const INTERVALS: &[&str] = &[ "M14", "P15", ]; -pub fn tokenize_line(line: &str) -> Vec { +pub fn tokenize_line(line: &str, user_words: &HashSet) -> Vec { let mut tokens = Vec::new(); let mut chars = line.char_indices().peekable(); @@ -160,14 +167,14 @@ pub fn tokenize_line(line: &str) -> Vec { } let word = &line[start..end]; - let (kind, varargs) = classify_word(word); + let (kind, varargs) = classify_word(word, user_words); tokens.push(Token { start, end, kind, varargs }); } tokens } -fn classify_word(word: &str) -> (TokenKind, bool) { +fn classify_word(word: &str, user_words: &HashSet) -> (TokenKind, bool) { if word.parse::().is_ok() || word.parse::().is_ok() { return (TokenKind::Number, false); } @@ -188,19 +195,24 @@ fn classify_word(word: &str) -> (TokenKind, bool) { return (TokenKind::Variable, false); } + if user_words.contains(word) { + return (TokenKind::UserDefined, false); + } + (TokenKind::Default, false) } pub fn highlight_line(line: &str) -> Vec<(Style, String)> { - highlight_line_with_runtime(line, &[], &[]) + highlight_line_with_runtime(line, &[], &[], &EMPTY_SET) } pub fn highlight_line_with_runtime( line: &str, executed_spans: &[SourceSpan], selected_spans: &[SourceSpan], + user_words: &HashSet, ) -> Vec<(Style, String)> { - let tokens = tokenize_line(line); + let tokens = tokenize_line(line, user_words); let mut result = Vec::new(); let mut last_end = 0; let gap_style = TokenKind::gap_style(); diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 0879f29..6288581 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -90,6 +90,11 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area let is_edit = idx == app.editor_ctx.bank; let is_playing = banks_with_playback.contains(&idx); let is_staged = banks_with_staged.contains(&idx); + let is_in_range = is_focused + && app + .patterns_nav + .bank_selection_range() + .is_some_and(|r| r.contains(&idx)); // Check if any pattern in this bank is muted/soloed (applied) let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p)); @@ -102,6 +107,8 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area let (bg, fg, prefix) = if is_cursor { (theme.selection.cursor, theme.selection.cursor_fg, "") + } else if is_in_range { + (theme.selection.in_range_bg, theme.selection.in_range_fg, "") } else if is_playing { if has_staged_mute_solo { (theme.list.staged_play_bg, theme.list.staged_play_fg, ">*") @@ -277,6 +284,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let is_playing = playing_patterns.contains(&idx); let is_staged_play = staged_to_play.contains(&idx); let is_staged_stop = staged_to_stop.contains(&idx); + let is_in_range = is_focused + && app + .patterns_nav + .pattern_selection_range() + .is_some_and(|r| r.contains(&idx)); // Current applied mute/solo state let is_muted = app.mute.is_muted(bank, idx); @@ -294,6 +306,8 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let (bg, fg, prefix) = if is_cursor { (theme.selection.cursor, theme.selection.cursor_fg, "") + } else if is_in_range { + (theme.selection.in_range_bg, theme.selection.in_range_fg, "") } else if is_playing { // Playing patterns if is_staged_stop { diff --git a/src/views/render.rs b/src/views/render.rs index 940a481..7656709 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::time::{Duration, Instant}; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; @@ -14,7 +15,7 @@ use crate::state::{ EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, }; use crate::theme; -use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; +use crate::views::highlight::{self, highlight_line_with_runtime}; use crate::widgets::{ ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, }; @@ -460,6 +461,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) -> Option { let theme = theme::get(); + let user_words: HashSet = app.dict.lock().keys().cloned().collect(); let inner = match &app.ui.modal { Modal::None => return None, Modal::ConfirmQuit { selected } => { @@ -488,6 +490,16 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) .render_centered(frame, term) } + Modal::ConfirmResetPatterns { + patterns, selected, .. + } => { + let label = format!("Reset {} patterns?", patterns.len()); + ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) + } + Modal::ConfirmResetBanks { banks, selected } => { + let label = format!("Reset {} banks?", banks.len()); + ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) + } Modal::FileBrowser(state) => { use crate::state::file_browser::FileBrowserMode; use crate::widgets::FileBrowserModal; @@ -621,9 +633,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term line_start, line_str.len(), ); - highlight_line_with_runtime(line_str, &exec, &sel) + highlight_line_with_runtime(line_str, &exec, &sel, &user_words) } else { - highlight_line(line_str) + highlight_line_with_runtime(line_str, &[], &[], &user_words) }; line_start += line_str.len() + 1; let spans: Vec = tokens @@ -700,7 +712,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ), None => (Vec::new(), Vec::new()), }; - highlight::highlight_line_with_runtime(line, &exec, &sel) + highlight::highlight_line_with_runtime(line, &exec, &sel, &user_words) }; let show_search = app.editor_ctx.editor.search_active()