Feat: comfort features

This commit is contained in:
2026-02-08 00:46:56 +01:00
parent c7fabf3424
commit af6016b9a9
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 mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/ cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/ 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 \ pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \ --version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg" "Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI - 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 ## [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 ### 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. - 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). - 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...). - `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)), variable: (pink, Color::Rgb(245, 220, 240)),
vary: (yellow, Color::Rgb(245, 235, 210)), vary: (yellow, Color::Rgb(245, 235, 210)),
generator: (teal, Color::Rgb(210, 240, 235)), generator: (teal, Color::Rgb(210, 240, 235)),
user_defined: (maroon, Color::Rgb(245, 225, 230)),
default: (subtext0, mantle), default: (subtext0, mantle),
}, },
table: TableColors { table: TableColors {

View File

@@ -151,6 +151,7 @@ pub fn theme() -> ThemeColors {
variable: (pink, Color::Rgb(55, 40, 55)), variable: (pink, Color::Rgb(55, 40, 55)),
vary: (yellow, Color::Rgb(55, 50, 35)), vary: (yellow, Color::Rgb(55, 50, 35)),
generator: (teal, Color::Rgb(35, 55, 50)), generator: (teal, Color::Rgb(35, 55, 50)),
user_defined: (maroon, Color::Rgb(55, 35, 40)),
default: (subtext0, mantle), default: (subtext0, mantle),
}, },
table: TableColors { table: TableColors {

View File

@@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (pink, Color::Rgb(80, 50, 65)), variable: (pink, Color::Rgb(80, 50, 65)),
vary: (yellow, Color::Rgb(70, 70, 45)), vary: (yellow, Color::Rgb(70, 70, 45)),
generator: (cyan, Color::Rgb(45, 70, 65)), generator: (cyan, Color::Rgb(45, 70, 65)),
user_defined: (orange, Color::Rgb(65, 55, 40)),
default: (comment, darker_bg), default: (comment, darker_bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -148,6 +148,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(26, 22, 28)), variable: (purple, Color::Rgb(26, 22, 28)),
vary: (yellow, Color::Rgb(30, 30, 12)), vary: (yellow, Color::Rgb(30, 30, 12)),
generator: (cyan, Color::Rgb(16, 30, 26)), generator: (cyan, Color::Rgb(16, 30, 26)),
user_defined: (orange, Color::Rgb(30, 22, 10)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(32, 24, 28)), variable: (purple, Color::Rgb(32, 24, 28)),
vary: (yellow, Color::Rgb(40, 30, 12)), vary: (yellow, Color::Rgb(40, 30, 12)),
generator: (cyan, Color::Rgb(22, 32, 30)), generator: (cyan, Color::Rgb(22, 32, 30)),
user_defined: (purple, Color::Rgb(28, 20, 26)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (lavender, Color::Rgb(85, 75, 110)), variable: (lavender, Color::Rgb(85, 75, 110)),
vary: (yellow, Color::Rgb(100, 95, 60)), vary: (yellow, Color::Rgb(100, 95, 60)),
generator: (mint, Color::Rgb(70, 95, 95)), generator: (mint, Color::Rgb(70, 95, 95)),
user_defined: (coral, Color::Rgb(100, 75, 75)),
default: (fg_dim, dark), default: (fg_dim, dark),
}, },
table: TableColors { table: TableColors {

View File

@@ -150,6 +150,7 @@ pub fn theme() -> ThemeColors {
variable: (violet, Color::Rgb(24, 8, 24)), variable: (violet, Color::Rgb(24, 8, 24)),
vary: (yellow, Color::Rgb(30, 30, 14)), vary: (yellow, Color::Rgb(30, 30, 14)),
generator: (cyan, Color::Rgb(20, 30, 28)), generator: (cyan, Color::Rgb(20, 30, 28)),
user_defined: (orange, Color::Rgb(30, 20, 10)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(65, 50, 55)), variable: (purple, Color::Rgb(65, 50, 55)),
vary: (yellow, Color::Rgb(70, 65, 40)), vary: (yellow, Color::Rgb(70, 65, 40)),
generator: (aqua, Color::Rgb(45, 60, 50)), generator: (aqua, Color::Rgb(45, 60, 50)),
user_defined: (orange, Color::Rgb(60, 45, 30)),
default: (fg3, darker_bg), default: (fg3, darker_bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -142,6 +142,7 @@ pub fn theme() -> ThemeColors {
variable: (white, muted_red), variable: (white, muted_red),
vary: (gold, muted_red), vary: (gold, muted_red),
generator: (yellow, muted_red), generator: (yellow, muted_red),
user_defined: (gold, Color::Rgb(100, 70, 0)),
default: (light_yellow, darker_red), default: (light_yellow, darker_red),
}, },
table: TableColors { table: TableColors {

View File

@@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors {
variable: (autumn_green, Color::Rgb(45, 55, 45)), variable: (autumn_green, Color::Rgb(45, 55, 45)),
vary: (carp_yellow, Color::Rgb(65, 60, 50)), vary: (carp_yellow, Color::Rgb(65, 60, 50)),
generator: (spring_blue, Color::Rgb(45, 55, 65)), generator: (spring_blue, Color::Rgb(45, 55, 65)),
user_defined: (sakura_pink, Color::Rgb(55, 40, 50)),
default: (fg_dim, darker_bg), default: (fg_dim, darker_bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -147,6 +147,7 @@ pub fn theme() -> ThemeColors {
variable: (keyword, Color::Rgb(240, 225, 240)), variable: (keyword, Color::Rgb(240, 225, 240)),
vary: (Color::Rgb(180, 140, 20), Color::Rgb(250, 240, 210)), vary: (Color::Rgb(180, 140, 20), Color::Rgb(250, 240, 210)),
generator: (function, Color::Rgb(215, 240, 235)), generator: (function, Color::Rgb(215, 240, 235)),
user_defined: (preproc, Color::Rgb(240, 230, 215)),
default: (text_dim, off_white), default: (text_dim, off_white),
}, },
table: TableColors { table: TableColors {

View File

@@ -227,6 +227,7 @@ pub struct SyntaxColors {
pub variable: (Color, Color), pub variable: (Color, Color),
pub vary: (Color, Color), pub vary: (Color, Color),
pub generator: (Color, Color), pub generator: (Color, Color),
pub user_defined: (Color, Color),
pub default: (Color, Color), pub default: (Color, Color),
} }

View File

@@ -144,6 +144,7 @@ pub fn theme() -> ThemeColors {
variable: (medium, Color::Rgb(30, 30, 30)), variable: (medium, Color::Rgb(30, 30, 30)),
vary: (dim, Color::Rgb(25, 25, 25)), vary: (dim, Color::Rgb(25, 25, 25)),
generator: (bright, Color::Rgb(45, 45, 45)), generator: (bright, Color::Rgb(45, 45, 45)),
user_defined: (medium, Color::Rgb(35, 35, 35)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -144,6 +144,7 @@ pub fn theme() -> ThemeColors {
variable: (medium, Color::Rgb(230, 230, 230)), variable: (medium, Color::Rgb(230, 230, 230)),
vary: (dim, Color::Rgb(235, 235, 235)), vary: (dim, Color::Rgb(235, 235, 235)),
generator: (dark, Color::Rgb(215, 215, 215)), generator: (dark, Color::Rgb(215, 215, 215)),
user_defined: (medium, Color::Rgb(225, 225, 225)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (green, Color::Rgb(55, 75, 45)), variable: (green, Color::Rgb(55, 75, 45)),
vary: (yellow, Color::Rgb(70, 65, 45)), vary: (yellow, Color::Rgb(70, 65, 45)),
generator: (blue, Color::Rgb(50, 70, 70)), generator: (blue, Color::Rgb(50, 70, 70)),
user_defined: (orange, Color::Rgb(60, 50, 30)),
default: (fg_dim, darker_bg), default: (fg_dim, darker_bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -145,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (aurora_purple, Color::Rgb(60, 50, 60)), variable: (aurora_purple, Color::Rgb(60, 50, 60)),
vary: (aurora_yellow, Color::Rgb(65, 60, 45)), vary: (aurora_yellow, Color::Rgb(65, 60, 45)),
generator: (frost0, Color::Rgb(45, 60, 55)), generator: (frost0, Color::Rgb(45, 60, 55)),
user_defined: (aurora_orange, Color::Rgb(60, 50, 45)),
default: (snow_storm0, polar_night1), default: (snow_storm0, polar_night1),
}, },
table: TableColors { table: TableColors {

View File

@@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(40, 25, 50)), variable: (purple, Color::Rgb(40, 25, 50)),
vary: (yellow, Color::Rgb(50, 45, 20)), vary: (yellow, Color::Rgb(50, 45, 20)),
generator: (cyan, Color::Rgb(20, 45, 40)), generator: (cyan, Color::Rgb(20, 45, 40)),
user_defined: (orange, Color::Rgb(40, 25, 10)),
default: (fg_dim, bg), default: (fg_dim, bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (pine, Color::Rgb(35, 50, 55)), variable: (pine, Color::Rgb(35, 50, 55)),
vary: (subtle, Color::Rgb(60, 55, 55)), vary: (subtle, Color::Rgb(60, 55, 55)),
generator: (foam, Color::Rgb(40, 55, 60)), generator: (foam, Color::Rgb(40, 55, 60)),
user_defined: (love, Color::Rgb(55, 35, 45)),
default: (fg_dim, darker_bg), default: (fg_dim, darker_bg),
}, },
table: TableColors { table: TableColors {

View File

@@ -146,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (green, Color::Rgb(50, 60, 45)), variable: (green, Color::Rgb(50, 60, 45)),
vary: (yellow, Color::Rgb(70, 60, 45)), vary: (yellow, Color::Rgb(70, 60, 45)),
generator: (cyan, Color::Rgb(45, 60, 75)), generator: (cyan, Color::Rgb(45, 60, 75)),
user_defined: (orange, Color::Rgb(60, 50, 35)),
default: (fg_dim, darker_bg), default: (fg_dim, darker_bg),
}, },
table: TableColors { 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), variable: rotate_color_pair(theme.syntax.variable, degrees),
vary: rotate_color_pair(theme.syntax.vary, degrees), vary: rotate_color_pair(theme.syntax.vary, degrees),
generator: rotate_color_pair(theme.syntax.generator, 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), default: rotate_color_pair(theme.syntax.default, degrees),
}, },
table: TableColors { table: TableColors {

View File

@@ -58,8 +58,8 @@ pub struct App {
pub rng: Rng, pub rng: Rng,
pub live_keys: Arc<LiveKeyState>, pub live_keys: Arc<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub copied_pattern: Option<Pattern>, pub copied_patterns: Option<Vec<Pattern>>,
pub copied_bank: Option<Bank>, pub copied_banks: Option<Vec<Bank>>,
pub audio: AudioSettings, pub audio: AudioSettings,
pub options: OptionsState, pub options: OptionsState,
@@ -100,8 +100,8 @@ impl App {
live_keys, live_keys,
script_engine, script_engine,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),
copied_pattern: None, copied_patterns: None,
copied_bank: None, copied_banks: None,
audio: AudioSettings::default(), audio: AudioSettings::default(),
options: OptionsState::default(), options: OptionsState::default(),
@@ -682,12 +682,17 @@ impl App {
} }
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) { 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); self.ui.flash("Pattern copied", 150, FlashKind::Success);
} }
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) { 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); clipboard::paste_pattern(&mut self.project_state.project, bank, pattern, &src);
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == 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) { 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); self.ui.flash("Bank copied", 150, FlashKind::Success);
} }
pub fn paste_bank(&mut self, bank: usize) { pub fn paste_bank(&mut self, bank: usize) {
if let Some(src) = self.copied_bank.clone() { if let Some(src) = self.copied_banks.as_ref().and_then(|v| v.first()) {
let pat_count = clipboard::paste_bank(&mut self.project_state.project, bank, &src); let src = src.clone();
let pat_count =
clipboard::paste_bank(&mut self.project_state.project, bank, &src);
for pattern in 0..pat_count { for pattern in 0..pat_count {
self.project_state.mark_dirty(bank, pattern); 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) { pub fn harden_steps(&mut self) {
let (bank, pattern) = self.current_bank_pattern(); let (bank, pattern) = self.current_bank_pattern();
let indices = self.selected_steps(); let indices = self.selected_steps();
@@ -944,12 +1063,30 @@ impl App {
AppCommand::PastePattern { bank, pattern } => { AppCommand::PastePattern { bank, pattern } => {
self.paste_pattern(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 } => { AppCommand::CopyBank { bank } => {
self.copy_bank(bank); self.copy_bank(bank);
} }
AppCommand::PasteBank { bank } => { AppCommand::PasteBank { bank } => {
self.paste_bank(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 // Clipboard
AppCommand::HardenSteps => self.harden_steps(), AppCommand::HardenSteps => self.harden_steps(),
@@ -1090,11 +1227,6 @@ impl App {
AppCommand::PatternsBack => { AppCommand::PatternsBack => {
self.page.down(); 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) // Mute/Solo (staged)
AppCommand::StageMute { bank, pattern } => { AppCommand::StageMute { bank, pattern } => {

View File

@@ -60,12 +60,33 @@ pub enum AppCommand {
bank: usize, bank: usize,
pattern: usize, pattern: usize,
}, },
CopyPatterns {
bank: usize,
patterns: Vec<usize>,
},
PastePatterns {
bank: usize,
start: usize,
},
CopyBank { CopyBank {
bank: usize, bank: usize,
}, },
PasteBank { PasteBank {
bank: usize, bank: usize,
}, },
CopyBanks {
banks: Vec<usize>,
},
PasteBanks {
start: usize,
},
ResetPatterns {
bank: usize,
patterns: Vec<usize>,
},
ResetBanks {
banks: Vec<usize>,
},
// Clipboard // Clipboard
HardenSteps, HardenSteps,
@@ -153,7 +174,6 @@ pub enum AppCommand {
PatternsCursorDown, PatternsCursorDown,
PatternsEnter, PatternsEnter,
PatternsBack, PatternsBack,
PatternsTogglePlay,
// Mute/Solo (staged) // Mute/Solo (staged)
StageMute { bank: usize, pattern: usize }, 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!(), Modal::None => unreachable!(),
} }
InputResult::Continue InputResult::Continue
@@ -1142,14 +1206,55 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::PatternsColumn; use crate::state::PatternsColumn;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code { 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::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc => { 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_mute_changes.is_empty()
|| !ctx.app.playback.staged_prop_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); 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') => { KeyCode::Char('p') => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns { 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(' ') => { KeyCode::Char(' ') => {
@@ -1184,11 +1296,21 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let bank = ctx.app.patterns_nav.bank_cursor; let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column { match ctx.app.patterns_nav.column {
PatternsColumn::Banks => { 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 => { PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor; let patterns = ctx.app.patterns_nav.selected_patterns();
ctx.dispatch(AppCommand::CopyPattern { bank, pattern }); 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; let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column { match ctx.app.patterns_nav.column {
PatternsColumn::Banks => { 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 => { PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor; 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; let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column { match ctx.app.patterns_nav.column {
PatternsColumn::Banks => { PatternsColumn::Banks => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { let banks = ctx.app.patterns_nav.selected_banks();
bank, if banks.len() > 1 {
selected: false, ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBanks {
})); banks,
selected: false,
}));
} else {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank {
bank,
selected: false,
}));
}
} }
PatternsColumn::Patterns => { PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor; let patterns = ctx.app.patterns_nav.selected_patterns();
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { if patterns.len() > 1 {
bank, ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPatterns {
pattern, bank,
selected: false, 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') => { KeyCode::Char('r') => {
let bank = ctx.app.patterns_nav.bank_cursor; if !ctx.app.patterns_nav.has_selection() {
match ctx.app.patterns_nav.column { let bank = ctx.app.patterns_nav.bank_cursor;
PatternsColumn::Banks => { match ctx.app.patterns_nav.column {
let current_name = ctx.app.project_state.project.banks[bank] PatternsColumn::Banks => {
.name let current_name = ctx.app.project_state.project.banks[bank]
.clone() .name
.unwrap_or_default(); .clone()
ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { .unwrap_or_default();
bank, ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank {
name: current_name, bank,
})); name: current_name,
} }));
PatternsColumn::Patterns => { }
let pattern = ctx.app.patterns_nav.pattern_cursor; PatternsColumn::Patterns => {
let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] let pattern = ctx.app.patterns_nav.pattern_cursor;
.name let current_name = ctx.app.project_state.project.banks[bank].patterns
.clone() [pattern]
.unwrap_or_default(); .name
ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { .clone()
bank, .unwrap_or_default();
pattern, ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern {
name: current_name, bank,
})); pattern,
name: current_name,
}));
}
} }
} }
} }
KeyCode::Char('e') if !ctrl => { 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 bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor; let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
@@ -1259,13 +1419,15 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
KeyCode::Char('m') => { KeyCode::Char('m') => {
let bank = ctx.app.patterns_nav.bank_cursor; 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 }); ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
} }
KeyCode::Char('x') => { KeyCode::Char('x') => {
let bank = ctx.app.patterns_nav.bank_cursor; 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 }); ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
} }
KeyCode::Char('M') => { KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes); ctx.dispatch(AppCommand::ClearMutes);

View File

@@ -24,6 +24,33 @@ pub fn paste_pattern(
project.banks[bank].patterns[pattern] = pat; 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 { pub fn copy_bank(project: &Project, bank: usize) -> Bank {
project.banks[bank].clone() 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() 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( pub fn copy_steps(
project: &Project, project: &Project,
bank: usize, bank: usize,

View File

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

View File

@@ -1,3 +1,5 @@
use std::ops::RangeInclusive;
use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::model::{MAX_BANKS, MAX_PATTERNS};
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]
@@ -12,14 +14,18 @@ pub struct PatternsNav {
pub column: PatternsColumn, pub column: PatternsColumn,
pub bank_cursor: usize, pub bank_cursor: usize,
pub pattern_cursor: usize, pub pattern_cursor: usize,
pub bank_anchor: Option<usize>,
pub pattern_anchor: Option<usize>,
} }
impl PatternsNav { impl PatternsNav {
pub fn move_left(&mut self) { pub fn move_left(&mut self) {
self.clear_selection();
self.column = PatternsColumn::Banks; self.column = PatternsColumn::Banks;
} }
pub fn move_right(&mut self) { pub fn move_right(&mut self) {
self.clear_selection();
self.column = PatternsColumn::Patterns; 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 { pub fn selected_bank(&self) -> usize {
self.bank_cursor self.bank_cursor
} }
@@ -52,4 +80,44 @@ impl PatternsNav {
pub fn selected_pattern(&self) -> usize { pub fn selected_pattern(&self) -> usize {
self.pattern_cursor 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 ratatui::style::{Modifier, Style};
use crate::model::{lookup_word, SourceSpan, WordCompile}; use crate::model::{lookup_word, SourceSpan, WordCompile};
use crate::theme; use crate::theme;
static EMPTY_SET: LazyLock<HashSet<String>> = LazyLock::new(HashSet::new);
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind { pub enum TokenKind {
Number, Number,
@@ -20,6 +25,7 @@ pub enum TokenKind {
Emit, Emit,
Vary, Vary,
Generator, Generator,
UserDefined,
Default, Default,
} }
@@ -42,6 +48,7 @@ impl TokenKind {
TokenKind::Variable => theme.syntax.variable, TokenKind::Variable => theme.syntax.variable,
TokenKind::Vary => theme.syntax.vary, TokenKind::Vary => theme.syntax.vary,
TokenKind::Generator => theme.syntax.generator, TokenKind::Generator => theme.syntax.generator,
TokenKind::UserDefined => theme.syntax.user_defined,
TokenKind::Default => theme.syntax.default, TokenKind::Default => theme.syntax.default,
}; };
let style = Style::default().fg(fg).bg(bg); let style = Style::default().fg(fg).bg(bg);
@@ -114,7 +121,7 @@ const INTERVALS: &[&str] = &[
"M14", "P15", "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 tokens = Vec::new();
let mut chars = line.char_indices().peekable(); 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 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.push(Token { start, end, kind, varargs });
} }
tokens 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() { if word.parse::<f64>().is_ok() || word.parse::<i64>().is_ok() {
return (TokenKind::Number, false); return (TokenKind::Number, false);
} }
@@ -188,19 +195,24 @@ fn classify_word(word: &str) -> (TokenKind, bool) {
return (TokenKind::Variable, false); return (TokenKind::Variable, false);
} }
if user_words.contains(word) {
return (TokenKind::UserDefined, false);
}
(TokenKind::Default, false) (TokenKind::Default, false)
} }
pub fn highlight_line(line: &str) -> Vec<(Style, String)> { 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( pub fn highlight_line_with_runtime(
line: &str, line: &str,
executed_spans: &[SourceSpan], executed_spans: &[SourceSpan],
selected_spans: &[SourceSpan], selected_spans: &[SourceSpan],
user_words: &HashSet<String>,
) -> Vec<(Style, String)> { ) -> Vec<(Style, String)> {
let tokens = tokenize_line(line); let tokens = tokenize_line(line, user_words);
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_end = 0; let mut last_end = 0;
let gap_style = TokenKind::gap_style(); 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_edit = idx == app.editor_ctx.bank;
let is_playing = banks_with_playback.contains(&idx); let is_playing = banks_with_playback.contains(&idx);
let is_staged = banks_with_staged.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) // 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)); 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 { let (bg, fg, prefix) = if is_cursor {
(theme.selection.cursor, theme.selection.cursor_fg, "") (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 { } else if is_playing {
if has_staged_mute_solo { if has_staged_mute_solo {
(theme.list.staged_play_bg, theme.list.staged_play_fg, ">*") (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_playing = playing_patterns.contains(&idx);
let is_staged_play = staged_to_play.contains(&idx); let is_staged_play = staged_to_play.contains(&idx);
let is_staged_stop = staged_to_stop.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 // Current applied mute/solo state
let is_muted = app.mute.is_muted(bank, idx); 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 { let (bg, fg, prefix) = if is_cursor {
(theme.selection.cursor, theme.selection.cursor_fg, "") (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 { } else if is_playing {
// Playing patterns // Playing patterns
if is_staged_stop { if is_staged_stop {

View File

@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
@@ -14,7 +15,7 @@ use crate::state::{
EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel,
}; };
use crate::theme; 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::{ use crate::widgets::{
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, 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> { fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) -> Option<Rect> {
let theme = theme::get(); let theme = theme::get();
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let inner = match &app.ui.modal { let inner = match &app.ui.modal {
Modal::None => return None, Modal::None => return None,
Modal::ConfirmQuit { selected } => { 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) ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
.render_centered(frame, term) .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) => { Modal::FileBrowser(state) => {
use crate::state::file_browser::FileBrowserMode; use crate::state::file_browser::FileBrowserMode;
use crate::widgets::FileBrowserModal; use crate::widgets::FileBrowserModal;
@@ -621,9 +633,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
line_start, line_start,
line_str.len(), line_str.len(),
); );
highlight_line_with_runtime(line_str, &exec, &sel) highlight_line_with_runtime(line_str, &exec, &sel, &user_words)
} else { } else {
highlight_line(line_str) highlight_line_with_runtime(line_str, &[], &[], &user_words)
}; };
line_start += line_str.len() + 1; line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens 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()), 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() let show_search = app.editor_ctx.editor.search_active()