Feat: comfort features

This commit is contained in:
2026-02-08 00:46:56 +01:00
parent 20c32ce0d8
commit 8ffe2c22c7
31 changed files with 578 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,8 +58,8 @@ pub struct App {
pub rng: Rng,
pub live_keys: Arc<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>,
pub copied_pattern: Option<Pattern>,
pub copied_bank: Option<Bank>,
pub copied_patterns: Option<Vec<Pattern>>,
pub copied_banks: Option<Vec<Bank>>,
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 } => {

View File

@@ -60,12 +60,33 @@ pub enum AppCommand {
bank: usize,
pattern: usize,
},
CopyPatterns {
bank: usize,
patterns: Vec<usize>,
},
PastePatterns {
bank: usize,
start: usize,
},
CopyBank {
bank: usize,
},
PasteBank {
bank: usize,
},
CopyBanks {
banks: Vec<usize>,
},
PasteBanks {
start: usize,
},
ResetPatterns {
bank: usize,
patterns: Vec<usize>,
},
ResetBanks {
banks: Vec<usize>,
},
// Clipboard
HardenSteps,
@@ -153,7 +174,6 @@ pub enum AppCommand {
PatternsCursorDown,
PatternsEnter,
PatternsBack,
PatternsTogglePlay,
// Mute/Solo (staged)
StageMute { bank: usize, pattern: usize },

View File

@@ -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,36 +1296,78 @@ 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 => {
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 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 });
}
}
}
}
KeyCode::Char('v') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
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;
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 });
}
}
}
}
KeyCode::Delete | KeyCode::Backspace => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
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 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,
@@ -1223,7 +1377,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
}
}
}
KeyCode::Char('r') => {
if !ctx.app.patterns_nav.has_selection() {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
@@ -1238,7 +1394,8 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern]
let current_name = ctx.app.project_state.project.banks[bank].patterns
[pattern]
.name
.clone()
.unwrap_or_default();
@@ -1250,8 +1407,11 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
}
}
}
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,14 +1419,16 @@ 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;
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;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);

View File

@@ -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<Pattern> {
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<Bank> {
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,

View File

@@ -29,6 +29,15 @@ pub enum Modal {
bank: usize,
selected: bool,
},
ConfirmResetPatterns {
bank: usize,
patterns: Vec<usize>,
selected: bool,
},
ConfirmResetBanks {
banks: Vec<usize>,
selected: bool,
},
FileBrowser(Box<FileBrowserState>),
RenameBank {
bank: usize,

View File

@@ -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<usize>,
pub pattern_anchor: Option<usize>,
}
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<RangeInclusive<usize>> {
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<RangeInclusive<usize>> {
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<usize> {
match self.bank_selection_range() {
Some(range) => range.collect(),
None => vec![self.bank_cursor],
}
}
pub fn selected_patterns(&self) -> Vec<usize> {
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(),
}
}
}

View File

@@ -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<HashSet<String>> = 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<Token> {
pub fn tokenize_line(line: &str, user_words: &HashSet<String>) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = line.char_indices().peekable();
@@ -160,14 +167,14 @@ pub fn tokenize_line(line: &str) -> Vec<Token> {
}
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<String>) -> (TokenKind, bool) {
if word.parse::<f64>().is_ok() || word.parse::<i64>().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<String>,
) -> 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();

View File

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

View File

@@ -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<Rect> {
let theme = theme::get();
let user_words: HashSet<String> = 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<Span> = 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()