flesh out sequencer
This commit is contained in:
@@ -14,7 +14,7 @@ ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
cpal = "0.15"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rhai = { version = "1.24", features = ["sync"] }
|
||||
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
227
seq/src/app.rs
227
seq/src/app.rs
@@ -15,8 +15,8 @@ use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables};
|
||||
use crate::page::Page;
|
||||
use crate::services::pattern_editor;
|
||||
use crate::state::{
|
||||
AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsViewLevel,
|
||||
PlaybackState, ProjectState, UiState,
|
||||
AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsNav, PlaybackState,
|
||||
ProjectState, UiState,
|
||||
};
|
||||
use crate::views::doc_view;
|
||||
|
||||
@@ -28,8 +28,7 @@ pub struct App {
|
||||
pub page: Page,
|
||||
pub editor_ctx: EditorContext,
|
||||
|
||||
pub patterns_view_level: PatternsViewLevel,
|
||||
pub patterns_cursor: usize,
|
||||
pub patterns_nav: PatternsNav,
|
||||
|
||||
pub metrics: Metrics,
|
||||
pub sample_pool_mb: f32,
|
||||
@@ -55,8 +54,7 @@ impl App {
|
||||
page: Page::default(),
|
||||
editor_ctx: EditorContext::default(),
|
||||
|
||||
patterns_view_level: PatternsViewLevel::default(),
|
||||
patterns_cursor: 0,
|
||||
patterns_nav: PatternsNav::default(),
|
||||
|
||||
metrics: Metrics::default(),
|
||||
sample_pool_mb: 0.0,
|
||||
@@ -253,17 +251,22 @@ impl App {
|
||||
tempo: link.tempo(),
|
||||
phase: link.phase(),
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
};
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
Ok(cmd) => {
|
||||
Ok(cmds) => {
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(step_idx)
|
||||
{
|
||||
step.command = Some(cmd);
|
||||
step.command = if cmds.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cmds.join("\n"))
|
||||
};
|
||||
}
|
||||
self.ui.flash("Script compiled", 150);
|
||||
}
|
||||
@@ -314,16 +317,21 @@ impl App {
|
||||
tempo: link.tempo(),
|
||||
phase: 0.0,
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
};
|
||||
|
||||
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) {
|
||||
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(step_idx)
|
||||
{
|
||||
step.command = Some(cmd);
|
||||
step.command = if cmds.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cmds.join("\n"))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,6 +437,7 @@ impl App {
|
||||
|
||||
pub fn save(&mut self, path: PathBuf) {
|
||||
self.save_editor_to_step();
|
||||
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
||||
match model::save(&self.project_state.project, &path) {
|
||||
Ok(()) => {
|
||||
self.ui.set_status(format!("Saved: {}", path.display()));
|
||||
@@ -459,22 +468,60 @@ impl App {
|
||||
|
||||
pub fn copy_step(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let script = pattern_editor::get_step_script(
|
||||
&self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
self.editor_ctx.step,
|
||||
);
|
||||
let step = self.editor_ctx.step;
|
||||
let script =
|
||||
pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step);
|
||||
|
||||
if let Some(script) = script {
|
||||
if let Some(clip) = &mut self.clipboard {
|
||||
if clip.set_text(&script).is_ok() {
|
||||
self.editor_ctx.copied_step = Some(crate::state::CopiedStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
});
|
||||
self.ui.set_status("Copied".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) {
|
||||
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
||||
for s in &mut pat.steps {
|
||||
if s.source == Some(step) {
|
||||
s.source = None;
|
||||
s.script.clear();
|
||||
s.command = None;
|
||||
}
|
||||
}
|
||||
|
||||
let change = pattern_editor::set_step_script(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
String::new(),
|
||||
);
|
||||
if let Some(s) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(step)
|
||||
{
|
||||
s.command = None;
|
||||
s.source = None;
|
||||
}
|
||||
self.project_state.mark_dirty(change.bank, change.pattern);
|
||||
if self.editor_ctx.bank == bank
|
||||
&& self.editor_ctx.pattern == pattern
|
||||
&& self.editor_ctx.step == step
|
||||
{
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Step deleted", 150);
|
||||
}
|
||||
|
||||
pub fn paste_step(&mut self, link: &LinkState) {
|
||||
let text = self
|
||||
.clipboard
|
||||
@@ -496,6 +543,86 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn link_paste_step(&mut self) {
|
||||
let Some(copied) = self.editor_ctx.copied_step else {
|
||||
self.ui.set_status("Nothing copied".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let step = self.editor_ctx.step;
|
||||
|
||||
if copied.bank != bank || copied.pattern != pattern {
|
||||
self.ui
|
||||
.set_status("Can only link within same pattern".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
if copied.step == step {
|
||||
self.ui.set_status("Cannot link step to itself".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let source_step = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.step(copied.step);
|
||||
if source_step.map(|s| s.source.is_some()).unwrap_or(false) {
|
||||
self.ui
|
||||
.set_status("Cannot link to a linked step".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(s) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(step)
|
||||
{
|
||||
s.source = Some(copied.step);
|
||||
s.script.clear();
|
||||
s.command = None;
|
||||
}
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.ui
|
||||
.flash(&format!("Linked to step {:02}", copied.step + 1), 150);
|
||||
}
|
||||
|
||||
pub fn harden_step(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let step = self.editor_ctx.step;
|
||||
|
||||
let resolved_script = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.resolve_script(step)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let Some(script) = resolved_script else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(s) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(step)
|
||||
{
|
||||
if s.source.is_none() {
|
||||
self.ui.set_status("Step is not linked".to_string());
|
||||
return;
|
||||
}
|
||||
s.source = None;
|
||||
s.script = script;
|
||||
}
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.ui.flash("Step hardened", 150);
|
||||
}
|
||||
|
||||
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
||||
let current = match field {
|
||||
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
||||
@@ -566,10 +693,19 @@ impl App {
|
||||
AppCommand::SaveEditorToStep => self.save_editor_to_step(),
|
||||
AppCommand::CompileCurrentStep => self.compile_current_step(link),
|
||||
AppCommand::CompileAllSteps => self.compile_all_steps(link),
|
||||
AppCommand::DeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
} => {
|
||||
self.delete_step(bank, pattern, step);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
AppCommand::CopyStep => self.copy_step(),
|
||||
AppCommand::PasteStep => self.paste_step(link),
|
||||
AppCommand::LinkPasteStep => self.link_paste_step(),
|
||||
AppCommand::HardenStep => self.harden_step(),
|
||||
|
||||
// Pattern playback
|
||||
AppCommand::QueueSlotChange(change) => {
|
||||
@@ -600,7 +736,18 @@ impl App {
|
||||
message,
|
||||
duration_ms,
|
||||
} => self.ui.flash(&message, duration_ms),
|
||||
AppCommand::OpenModal(modal) => self.ui.modal = modal,
|
||||
AppCommand::OpenModal(modal) => {
|
||||
if matches!(modal, Modal::Editor) {
|
||||
// If current step is a shallow copy, navigate to source step
|
||||
let pattern = &self.project_state.project.banks[self.editor_ctx.bank].patterns
|
||||
[self.editor_ctx.pattern];
|
||||
if let Some(source) = pattern.steps[self.editor_ctx.step].source {
|
||||
self.editor_ctx.step = source;
|
||||
}
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.modal = modal;
|
||||
}
|
||||
AppCommand::CloseModal => self.ui.modal = Modal::None,
|
||||
AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field),
|
||||
|
||||
@@ -629,39 +776,32 @@ impl App {
|
||||
|
||||
// Patterns view
|
||||
AppCommand::PatternsCursorLeft => {
|
||||
self.patterns_cursor = (self.patterns_cursor + 15) % 16;
|
||||
self.patterns_nav.move_left();
|
||||
}
|
||||
AppCommand::PatternsCursorRight => {
|
||||
self.patterns_cursor = (self.patterns_cursor + 1) % 16;
|
||||
self.patterns_nav.move_right();
|
||||
}
|
||||
AppCommand::PatternsCursorUp => {
|
||||
self.patterns_cursor = (self.patterns_cursor + 12) % 16;
|
||||
self.patterns_nav.move_up();
|
||||
}
|
||||
AppCommand::PatternsCursorDown => {
|
||||
self.patterns_cursor = (self.patterns_cursor + 4) % 16;
|
||||
self.patterns_nav.move_down();
|
||||
}
|
||||
AppCommand::PatternsEnter => {
|
||||
let bank = self.patterns_nav.selected_bank();
|
||||
let pattern = self.patterns_nav.selected_pattern();
|
||||
self.select_edit_bank(bank);
|
||||
self.select_edit_pattern(pattern);
|
||||
self.page.down();
|
||||
}
|
||||
AppCommand::PatternsBack => {
|
||||
self.page.down();
|
||||
}
|
||||
AppCommand::PatternsTogglePlay => {
|
||||
let bank = self.patterns_nav.selected_bank();
|
||||
let pattern = self.patterns_nav.selected_pattern();
|
||||
self.toggle_pattern_playback(bank, pattern, snapshot);
|
||||
}
|
||||
AppCommand::PatternsEnter => match self.patterns_view_level {
|
||||
PatternsViewLevel::Banks => {
|
||||
let bank = self.patterns_cursor;
|
||||
self.patterns_view_level = PatternsViewLevel::Patterns { bank };
|
||||
self.patterns_cursor = 0;
|
||||
}
|
||||
PatternsViewLevel::Patterns { bank } => {
|
||||
let pattern = self.patterns_cursor;
|
||||
self.select_edit_bank(bank);
|
||||
self.select_edit_pattern(pattern);
|
||||
self.patterns_view_level = PatternsViewLevel::Banks;
|
||||
self.patterns_cursor = 0;
|
||||
self.page.down();
|
||||
}
|
||||
},
|
||||
AppCommand::PatternsBack => match self.patterns_view_level {
|
||||
PatternsViewLevel::Banks => self.page.down(),
|
||||
PatternsViewLevel::Patterns { .. } => {
|
||||
self.patterns_view_level = PatternsViewLevel::Banks;
|
||||
self.patterns_cursor = 0;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +839,7 @@ impl App {
|
||||
.map(|s| StepSnapshot {
|
||||
active: s.active,
|
||||
script: s.script.clone(),
|
||||
source: s.source,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
@@ -40,10 +40,17 @@ pub enum AppCommand {
|
||||
SaveEditorToStep,
|
||||
CompileCurrentStep,
|
||||
CompileAllSteps,
|
||||
DeleteStep {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
step: usize,
|
||||
},
|
||||
|
||||
// Clipboard
|
||||
CopyStep,
|
||||
PasteStep,
|
||||
LinkPasteStep,
|
||||
HardenStep,
|
||||
|
||||
// Pattern playback
|
||||
QueueSlotChange(SlotChange),
|
||||
@@ -95,4 +102,5 @@ pub enum AppCommand {
|
||||
PatternsCursorDown,
|
||||
PatternsEnter,
|
||||
PatternsBack,
|
||||
PatternsTogglePlay,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
@@ -57,6 +58,7 @@ pub struct PatternSnapshot {
|
||||
pub struct StepSnapshot {
|
||||
pub active: bool,
|
||||
pub script: String,
|
||||
pub source: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -229,6 +231,51 @@ impl PatternCache {
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternSnapshot {
|
||||
fn resolve_source(&self, index: usize) -> usize {
|
||||
let mut current = index;
|
||||
for _ in 0..self.steps.len() {
|
||||
if let Some(step) = self.steps.get(current) {
|
||||
if let Some(source) = step.source {
|
||||
current = source;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
} else {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn resolve_script(&self, index: usize) -> Option<&str> {
|
||||
let source_idx = self.resolve_source(index);
|
||||
self.steps.get(source_idx).map(|s| s.script.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
type StepKey = (usize, usize, usize);
|
||||
|
||||
struct RunsCounter {
|
||||
counts: HashMap<StepKey, usize>,
|
||||
}
|
||||
|
||||
impl RunsCounter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
counts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_and_increment(&mut self, bank: usize, pattern: usize, step: usize) -> usize {
|
||||
let key = (bank, pattern, step);
|
||||
let count = self.counts.entry(key).or_insert(0);
|
||||
let current = *count;
|
||||
*count += 1;
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
fn sequencer_loop(
|
||||
cmd_rx: Receiver<SeqCommand>,
|
||||
audio_tx: Sender<AudioCommand>,
|
||||
@@ -244,6 +291,7 @@ fn sequencer_loop(
|
||||
let script_engine = ScriptEngine::new(variables, rng);
|
||||
let mut audio_state = AudioState::new();
|
||||
let mut pattern_cache = PatternCache::new();
|
||||
let mut runs_counter = RunsCounter::new();
|
||||
|
||||
loop {
|
||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||
@@ -332,7 +380,15 @@ fn sequencer_loop(
|
||||
slot_steps[slot_idx].store(step_idx, Ordering::Relaxed);
|
||||
|
||||
if let Some(step) = pattern.steps.get(step_idx) {
|
||||
if step.active && !step.script.trim().is_empty() {
|
||||
let resolved_script = pattern.resolve_script(step_idx);
|
||||
let has_script = resolved_script
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if step.active && has_script {
|
||||
let source_idx = pattern.resolve_source(step_idx);
|
||||
let runs =
|
||||
runs_counter.get_and_increment(slot.bank, slot.pattern, source_idx);
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat,
|
||||
@@ -341,15 +397,20 @@ fn sequencer_loop(
|
||||
tempo,
|
||||
phase: beat % quantum,
|
||||
slot: slot_idx,
|
||||
runs,
|
||||
};
|
||||
if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx) {
|
||||
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
|
||||
Ok(()) => {
|
||||
event_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
return;
|
||||
if let Some(script) = resolved_script {
|
||||
if let Ok(cmds) = script_engine.evaluate(script, &ctx) {
|
||||
for cmd in cmds {
|
||||
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
|
||||
Ok(()) => {
|
||||
event_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
266
seq/src/input.rs
266
seq/src/input.rs
@@ -9,7 +9,7 @@ use crate::commands::AppCommand;
|
||||
use crate::engine::{AudioCommand, LinkState, SequencerSnapshot};
|
||||
use crate::model::PatternSpeed;
|
||||
use crate::page::Page;
|
||||
use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel};
|
||||
use crate::state::{AudioFocus, Modal, PatternField};
|
||||
|
||||
pub enum InputResult {
|
||||
Continue,
|
||||
@@ -31,6 +31,11 @@ impl<'a> InputContext<'a> {
|
||||
}
|
||||
|
||||
pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
if ctx.app.ui.show_title {
|
||||
ctx.app.ui.show_title = false;
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
ctx.dispatch(AppCommand::ClearStatus);
|
||||
|
||||
if matches!(ctx.app.ui.modal, Modal::None) {
|
||||
@@ -59,6 +64,49 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Modal::ConfirmDeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
selected: _,
|
||||
} => {
|
||||
let (bank, pattern, step) = (*bank, *pattern, *step);
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
ctx.dispatch(AppCommand::DeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
});
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
KeyCode::Left | KeyCode::Right => {
|
||||
if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal {
|
||||
*selected = !*selected;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let do_delete =
|
||||
if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal {
|
||||
*selected
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if do_delete {
|
||||
ctx.dispatch(AppCommand::DeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
});
|
||||
}
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::SaveAs(path) => match key.code {
|
||||
KeyCode::Enter => {
|
||||
let save_path = PathBuf::from(path.as_str());
|
||||
@@ -77,6 +125,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let load_path = PathBuf::from(path.as_str());
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
ctx.dispatch(AppCommand::Load(load_path));
|
||||
load_project_samples(ctx);
|
||||
}
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
KeyCode::Backspace => {
|
||||
@@ -204,6 +253,23 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::Char(c) => path.push(c),
|
||||
_ => {}
|
||||
},
|
||||
Modal::Editor => {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
ctx.dispatch(AppCommand::SaveEditorToStep);
|
||||
ctx.dispatch(AppCommand::CompileCurrentStep);
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
KeyCode::Char('e') if ctrl => {
|
||||
ctx.dispatch(AppCommand::SaveEditorToStep);
|
||||
ctx.dispatch(AppCommand::CompileCurrentStep);
|
||||
}
|
||||
_ => {
|
||||
ctx.app.editor_ctx.text.input(Event::Key(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::None => unreachable!(),
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -243,64 +309,73 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
|
||||
match ctx.app.editor_ctx.focus {
|
||||
Focus::Sequencer => match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
ctx.dispatch(AppCommand::TogglePlaying);
|
||||
ctx.playing
|
||||
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
||||
}
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::ToggleFocus),
|
||||
KeyCode::Left => ctx.dispatch(AppCommand::PrevStep),
|
||||
KeyCode::Right => ctx.dispatch(AppCommand::NextStep),
|
||||
KeyCode::Up => ctx.dispatch(AppCommand::StepUp),
|
||||
KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::ToggleStep),
|
||||
KeyCode::Char('s') => {
|
||||
let default = ctx
|
||||
.app
|
||||
.project_state
|
||||
.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "project.buboseq".to_string());
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(default)));
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(String::new())));
|
||||
}
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
|
||||
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
|
||||
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
|
||||
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
|
||||
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
||||
KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease),
|
||||
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
|
||||
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
|
||||
KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep),
|
||||
KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep),
|
||||
_ => {}
|
||||
},
|
||||
Focus::Editor => match key.code {
|
||||
KeyCode::Tab | KeyCode::Esc => ctx.dispatch(AppCommand::ToggleFocus),
|
||||
KeyCode::Char('e') if ctrl => {
|
||||
ctx.dispatch(AppCommand::SaveEditorToStep);
|
||||
ctx.dispatch(AppCommand::CompileCurrentStep);
|
||||
}
|
||||
_ => {
|
||||
ctx.app.editor_ctx.text.input(Event::Key(key));
|
||||
}
|
||||
},
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
ctx.dispatch(AppCommand::TogglePlaying);
|
||||
ctx.playing
|
||||
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
||||
}
|
||||
KeyCode::Left => ctx.dispatch(AppCommand::PrevStep),
|
||||
KeyCode::Right => ctx.dispatch(AppCommand::NextStep),
|
||||
KeyCode::Up => ctx.dispatch(AppCommand::StepUp),
|
||||
KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)),
|
||||
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep),
|
||||
KeyCode::Char('s') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new())));
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep),
|
||||
KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep),
|
||||
KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep),
|
||||
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep),
|
||||
KeyCode::Char('l') => {
|
||||
let default_dir = ctx
|
||||
.app
|
||||
.project_state
|
||||
.file_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| {
|
||||
let mut s = p.display().to_string();
|
||||
if !s.ends_with('/') {
|
||||
s.push('/');
|
||||
}
|
||||
s
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir)));
|
||||
}
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
|
||||
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
|
||||
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
|
||||
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
|
||||
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
||||
KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease),
|
||||
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
|
||||
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
|
||||
KeyCode::Delete | KeyCode::Backspace => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
let step = ctx.app.editor_ctx.step;
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
step,
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
}
|
||||
|
||||
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
use crate::state::PatternsColumn;
|
||||
|
||||
match key.code {
|
||||
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
|
||||
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
|
||||
@@ -308,42 +383,39 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
|
||||
KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
|
||||
KeyCode::Char(' ') => {
|
||||
if let PatternsViewLevel::Patterns { bank } = ctx.app.patterns_view_level {
|
||||
let pattern = ctx.app.patterns_cursor;
|
||||
ctx.dispatch(AppCommand::TogglePatternPlayback { bank, pattern });
|
||||
}
|
||||
}
|
||||
KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay),
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('r') => match ctx.app.patterns_view_level {
|
||||
PatternsViewLevel::Banks => {
|
||||
let bank = ctx.app.patterns_cursor;
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
PatternsViewLevel::Patterns { bank } => {
|
||||
let pattern = ctx.app.patterns_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,
|
||||
}));
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -421,3 +493,29 @@ fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
InputResult::Continue
|
||||
}
|
||||
|
||||
fn load_project_samples(ctx: &mut InputContext) {
|
||||
let paths = ctx.app.project_state.project.sample_paths.clone();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut total_count = 0;
|
||||
for path in &paths {
|
||||
if path.is_dir() {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
let count = index.len();
|
||||
total_count += count;
|
||||
let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index));
|
||||
}
|
||||
}
|
||||
|
||||
ctx.app.audio.config.sample_paths = paths;
|
||||
ctx.app.audio.config.sample_count = total_count;
|
||||
|
||||
if total_count > 0 {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"Loaded {total_count} samples from project"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -12,6 +12,8 @@ const VERSION: u8 = 1;
|
||||
struct ProjectFile {
|
||||
version: u8,
|
||||
banks: Vec<Bank>,
|
||||
#[serde(default)]
|
||||
sample_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl From<&Project> for ProjectFile {
|
||||
@@ -19,13 +21,17 @@ impl From<&Project> for ProjectFile {
|
||||
Self {
|
||||
version: VERSION,
|
||||
banks: project.banks.clone(),
|
||||
sample_paths: project.sample_paths.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectFile> for Project {
|
||||
fn from(file: ProjectFile) -> Self {
|
||||
Self { banks: file.banks }
|
||||
Self {
|
||||
banks: file.banks,
|
||||
sample_paths: file.sample_paths,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
788
seq/src/model/forth.rs
Normal file
788
seq/src/model/forth.rs
Normal file
@@ -0,0 +1,788 @@
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng as RngTrait, SeedableRng};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct StepContext {
|
||||
pub step: usize,
|
||||
pub beat: f64,
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
pub tempo: f64,
|
||||
pub phase: f64,
|
||||
pub slot: usize,
|
||||
pub runs: usize,
|
||||
}
|
||||
|
||||
pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
|
||||
pub type Rng = Arc<Mutex<StdRng>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Value {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(String),
|
||||
Cmd(Vec<(String, String)>),
|
||||
Param(String, String),
|
||||
Marker,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn as_float(&self) -> Result<f64, String> {
|
||||
match self {
|
||||
Value::Float(f) => Ok(*f),
|
||||
Value::Int(i) => Ok(*i as f64),
|
||||
_ => Err("expected number".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_int(&self) -> Result<i64, String> {
|
||||
match self {
|
||||
Value::Int(i) => Ok(*i),
|
||||
Value::Float(f) => Ok(*f as i64),
|
||||
_ => Err("expected number".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> Result<&str, String> {
|
||||
match self {
|
||||
Value::Str(s) => Ok(s),
|
||||
_ => Err("expected string".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_truthy(&self) -> bool {
|
||||
match self {
|
||||
Value::Int(i) => *i != 0,
|
||||
Value::Float(f) => *f != 0.0,
|
||||
Value::Str(s) => !s.is_empty(),
|
||||
Value::Cmd(_) => true,
|
||||
Value::Param(_, _) => true,
|
||||
Value::Marker => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_marker(&self) -> bool {
|
||||
matches!(self, Value::Marker)
|
||||
}
|
||||
|
||||
fn is_param(&self) -> bool {
|
||||
matches!(self, Value::Param(_, _))
|
||||
}
|
||||
|
||||
fn to_param_string(&self) -> String {
|
||||
match self {
|
||||
Value::Int(i) => i.to_string(),
|
||||
Value::Float(f) => f.to_string(),
|
||||
Value::Str(s) => s.clone(),
|
||||
Value::Cmd(_) | Value::Param(_, _) | Value::Marker => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
PushInt(i64),
|
||||
PushFloat(f64),
|
||||
PushStr(String),
|
||||
Dup,
|
||||
Drop,
|
||||
Swap,
|
||||
Over,
|
||||
Rot,
|
||||
Nip,
|
||||
Tuck,
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
Div,
|
||||
Mod,
|
||||
Neg,
|
||||
Abs,
|
||||
Min,
|
||||
Max,
|
||||
Eq,
|
||||
Ne,
|
||||
Lt,
|
||||
Gt,
|
||||
Le,
|
||||
Ge,
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
BranchIfZero(usize),
|
||||
Branch(usize),
|
||||
NewCmd,
|
||||
SetParam(String),
|
||||
Emit,
|
||||
Get,
|
||||
Set,
|
||||
GetContext(String),
|
||||
Rand,
|
||||
Rrand,
|
||||
Seed,
|
||||
Cycle,
|
||||
Choose,
|
||||
Chance,
|
||||
Maybe,
|
||||
Wait,
|
||||
ListStart,
|
||||
ListEnd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Token {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(String),
|
||||
Word(String),
|
||||
}
|
||||
|
||||
fn tokenize(input: &str) -> Vec<Token> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '"' {
|
||||
chars.next();
|
||||
let mut s = String::new();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch == '"' {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
s.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
tokens.push(Token::Str(s));
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '(' {
|
||||
while let Some(&ch) = chars.peek() {
|
||||
chars.next();
|
||||
if ch == ')' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut word = String::new();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
word.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
|
||||
if let Ok(i) = word.parse::<i64>() {
|
||||
tokens.push(Token::Int(i));
|
||||
} else if let Ok(f) = word.parse::<f64>() {
|
||||
tokens.push(Token::Float(f));
|
||||
} else {
|
||||
tokens.push(Token::Word(word));
|
||||
}
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
const PARAMS: &[&str] = &[
|
||||
"time",
|
||||
"repeat",
|
||||
"dur",
|
||||
"gate",
|
||||
"freq",
|
||||
"detune",
|
||||
"speed",
|
||||
"glide",
|
||||
"pw",
|
||||
"spread",
|
||||
"mult",
|
||||
"warp",
|
||||
"mirror",
|
||||
"harmonics",
|
||||
"timbre",
|
||||
"morph",
|
||||
"begin",
|
||||
"end",
|
||||
"gain",
|
||||
"postgain",
|
||||
"velocity",
|
||||
"pan",
|
||||
"attack",
|
||||
"decay",
|
||||
"sustain",
|
||||
"release",
|
||||
"lpf",
|
||||
"lpq",
|
||||
"lpe",
|
||||
"lpa",
|
||||
"lpd",
|
||||
"lps",
|
||||
"lpr",
|
||||
"hpf",
|
||||
"hpq",
|
||||
"hpe",
|
||||
"hpa",
|
||||
"hpd",
|
||||
"hps",
|
||||
"hpr",
|
||||
"bpf",
|
||||
"bpq",
|
||||
"bpe",
|
||||
"bpa",
|
||||
"bpd",
|
||||
"bps",
|
||||
"bpr",
|
||||
"ftype",
|
||||
"penv",
|
||||
"patt",
|
||||
"pdec",
|
||||
"psus",
|
||||
"prel",
|
||||
"vib",
|
||||
"vibmod",
|
||||
"vibshape",
|
||||
"fm",
|
||||
"fmh",
|
||||
"fmshape",
|
||||
"fme",
|
||||
"fma",
|
||||
"fmd",
|
||||
"fms",
|
||||
"fmr",
|
||||
"am",
|
||||
"amdepth",
|
||||
"amshape",
|
||||
"rm",
|
||||
"rmdepth",
|
||||
"rmshape",
|
||||
"phaser",
|
||||
"phaserdepth",
|
||||
"phasersweep",
|
||||
"phasercenter",
|
||||
"flanger",
|
||||
"flangerdepth",
|
||||
"flangerfeedback",
|
||||
"chorus",
|
||||
"chorusdepth",
|
||||
"chorusdelay",
|
||||
"comb",
|
||||
"combfreq",
|
||||
"combfeedback",
|
||||
"combdamp",
|
||||
"coarse",
|
||||
"crush",
|
||||
"fold",
|
||||
"wrap",
|
||||
"distort",
|
||||
"distortvol",
|
||||
"delay",
|
||||
"delaytime",
|
||||
"delayfeedback",
|
||||
"delaytype",
|
||||
"verb",
|
||||
"verbdecay",
|
||||
"verbdamp",
|
||||
"verbpredelay",
|
||||
"verbdiff",
|
||||
"voice",
|
||||
"orbit",
|
||||
"note",
|
||||
"size",
|
||||
"n",
|
||||
"cut",
|
||||
"reset",
|
||||
];
|
||||
|
||||
fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
|
||||
let mut ops = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < tokens.len() {
|
||||
match &tokens[i] {
|
||||
Token::Int(n) => ops.push(Op::PushInt(*n)),
|
||||
Token::Float(f) => ops.push(Op::PushFloat(*f)),
|
||||
Token::Str(s) => ops.push(Op::PushStr(s.clone())),
|
||||
Token::Word(w) => {
|
||||
let word = w.as_str();
|
||||
match word {
|
||||
"dup" => ops.push(Op::Dup),
|
||||
"drop" => ops.push(Op::Drop),
|
||||
"swap" => ops.push(Op::Swap),
|
||||
"over" => ops.push(Op::Over),
|
||||
"rot" => ops.push(Op::Rot),
|
||||
"nip" => ops.push(Op::Nip),
|
||||
"tuck" => ops.push(Op::Tuck),
|
||||
"+" => ops.push(Op::Add),
|
||||
"-" => ops.push(Op::Sub),
|
||||
"*" => ops.push(Op::Mul),
|
||||
"/" => ops.push(Op::Div),
|
||||
"mod" => ops.push(Op::Mod),
|
||||
"neg" => ops.push(Op::Neg),
|
||||
"abs" => ops.push(Op::Abs),
|
||||
"min" => ops.push(Op::Min),
|
||||
"max" => ops.push(Op::Max),
|
||||
"=" => ops.push(Op::Eq),
|
||||
"<>" => ops.push(Op::Ne),
|
||||
"<" => ops.push(Op::Lt),
|
||||
">" => ops.push(Op::Gt),
|
||||
"<=" => ops.push(Op::Le),
|
||||
">=" => ops.push(Op::Ge),
|
||||
"and" => ops.push(Op::And),
|
||||
"or" => ops.push(Op::Or),
|
||||
"not" => ops.push(Op::Not),
|
||||
"sound" | "s" => ops.push(Op::NewCmd),
|
||||
"emit" => ops.push(Op::Emit),
|
||||
"get" => ops.push(Op::Get),
|
||||
"set" => ops.push(Op::Set),
|
||||
"rand" => ops.push(Op::Rand),
|
||||
"rrand" => ops.push(Op::Rrand),
|
||||
"seed" => ops.push(Op::Seed),
|
||||
"cycle" => ops.push(Op::Cycle),
|
||||
"choose" => ops.push(Op::Choose),
|
||||
"chance" => ops.push(Op::Chance),
|
||||
"?" => ops.push(Op::Maybe),
|
||||
"always" => {
|
||||
ops.push(Op::PushFloat(1.0));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"never" => {
|
||||
ops.push(Op::PushFloat(0.0));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"often" => {
|
||||
ops.push(Op::PushFloat(0.75));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"sometimes" => {
|
||||
ops.push(Op::PushFloat(0.5));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"rarely" => {
|
||||
ops.push(Op::PushFloat(0.25));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"almostNever" => {
|
||||
ops.push(Op::PushFloat(0.1));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"almostAlways" => {
|
||||
ops.push(Op::PushFloat(0.9));
|
||||
ops.push(Op::Maybe);
|
||||
}
|
||||
"wait" => ops.push(Op::Wait),
|
||||
"[" => ops.push(Op::ListStart),
|
||||
"]" => ops.push(Op::ListEnd),
|
||||
"step" => ops.push(Op::GetContext("step".into())),
|
||||
"beat" => ops.push(Op::GetContext("beat".into())),
|
||||
"bank" => ops.push(Op::GetContext("bank".into())),
|
||||
"pattern" => ops.push(Op::GetContext("pattern".into())),
|
||||
"tempo" => ops.push(Op::GetContext("tempo".into())),
|
||||
"phase" => ops.push(Op::GetContext("phase".into())),
|
||||
"slot" => ops.push(Op::GetContext("slot".into())),
|
||||
"runs" => ops.push(Op::GetContext("runs".into())),
|
||||
"if" => {
|
||||
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
|
||||
i += consumed;
|
||||
if else_ops.is_empty() {
|
||||
ops.push(Op::BranchIfZero(then_ops.len()));
|
||||
ops.extend(then_ops);
|
||||
} else {
|
||||
ops.push(Op::BranchIfZero(then_ops.len() + 1));
|
||||
ops.extend(then_ops);
|
||||
ops.push(Op::Branch(else_ops.len()));
|
||||
ops.extend(else_ops);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if PARAMS.contains(&word) {
|
||||
ops.push(Op::SetParam(word.into()));
|
||||
} else {
|
||||
return Err(format!("unknown word: {word}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(ops)
|
||||
}
|
||||
|
||||
fn compile_if(tokens: &[Token]) -> Result<(Vec<Op>, Vec<Op>, usize), String> {
|
||||
let mut depth = 1;
|
||||
let mut else_pos = None;
|
||||
let mut then_pos = None;
|
||||
|
||||
for (i, tok) in tokens.iter().enumerate() {
|
||||
if let Token::Word(w) = tok {
|
||||
match w.as_str() {
|
||||
"if" => depth += 1,
|
||||
"else" if depth == 1 => else_pos = Some(i),
|
||||
"then" => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
then_pos = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let then_pos = then_pos.ok_or("missing 'then'")?;
|
||||
|
||||
let (then_ops, else_ops) = if let Some(ep) = else_pos {
|
||||
let then_ops = compile(&tokens[..ep])?;
|
||||
let else_ops = compile(&tokens[ep + 1..then_pos])?;
|
||||
(then_ops, else_ops)
|
||||
} else {
|
||||
let then_ops = compile(&tokens[..then_pos])?;
|
||||
(then_ops, Vec::new())
|
||||
};
|
||||
|
||||
Ok((then_ops, else_ops, then_pos + 1))
|
||||
}
|
||||
|
||||
pub struct Forth {
|
||||
vars: Variables,
|
||||
rng: Rng,
|
||||
}
|
||||
|
||||
impl Forth {
|
||||
pub fn new(vars: Variables, rng: Rng) -> Self {
|
||||
Self { vars, rng }
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
|
||||
if script.trim().is_empty() {
|
||||
return Err("empty script".into());
|
||||
}
|
||||
|
||||
let tokens = tokenize(script);
|
||||
let ops = compile(&tokens)?;
|
||||
self.execute(&ops, ctx)
|
||||
}
|
||||
|
||||
fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result<Vec<String>, String> {
|
||||
let mut stack: Vec<Value> = Vec::new();
|
||||
let mut outputs: Vec<String> = Vec::new();
|
||||
let mut time_offset: f64 = 0.0;
|
||||
let mut pc = 0;
|
||||
|
||||
while pc < ops.len() {
|
||||
match &ops[pc] {
|
||||
Op::PushInt(n) => stack.push(Value::Int(*n)),
|
||||
Op::PushFloat(f) => stack.push(Value::Float(*f)),
|
||||
Op::PushStr(s) => stack.push(Value::Str(s.clone())),
|
||||
|
||||
Op::Dup => {
|
||||
let v = stack.last().ok_or("stack underflow")?.clone();
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Drop => {
|
||||
stack.pop().ok_or("stack underflow")?;
|
||||
}
|
||||
Op::Swap => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
stack.swap(len - 1, len - 2);
|
||||
}
|
||||
Op::Over => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack[len - 2].clone();
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Rot => {
|
||||
let len = stack.len();
|
||||
if len < 3 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack.remove(len - 3);
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Nip => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
stack.remove(len - 2);
|
||||
}
|
||||
Op::Tuck => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack[len - 1].clone();
|
||||
stack.insert(len - 2, v);
|
||||
}
|
||||
|
||||
Op::Add => binary_op(&mut stack, |a, b| a + b)?,
|
||||
Op::Sub => binary_op(&mut stack, |a, b| a - b)?,
|
||||
Op::Mul => binary_op(&mut stack, |a, b| a * b)?,
|
||||
Op::Div => binary_op(&mut stack, |a, b| a / b)?,
|
||||
Op::Mod => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let a = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
stack.push(Value::Int(a % b));
|
||||
}
|
||||
Op::Neg => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
match v {
|
||||
Value::Int(i) => stack.push(Value::Int(-i)),
|
||||
Value::Float(f) => stack.push(Value::Float(-f)),
|
||||
_ => return Err("expected number".into()),
|
||||
}
|
||||
}
|
||||
Op::Abs => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
match v {
|
||||
Value::Int(i) => stack.push(Value::Int(i.abs())),
|
||||
Value::Float(f) => stack.push(Value::Float(f.abs())),
|
||||
_ => return Err("expected number".into()),
|
||||
}
|
||||
}
|
||||
Op::Min => binary_op(&mut stack, |a, b| a.min(b))?,
|
||||
Op::Max => binary_op(&mut stack, |a, b| a.max(b))?,
|
||||
|
||||
Op::Eq => cmp_op(&mut stack, |a, b| (a - b).abs() < f64::EPSILON)?,
|
||||
Op::Ne => cmp_op(&mut stack, |a, b| (a - b).abs() >= f64::EPSILON)?,
|
||||
Op::Lt => cmp_op(&mut stack, |a, b| a < b)?,
|
||||
Op::Gt => cmp_op(&mut stack, |a, b| a > b)?,
|
||||
Op::Le => cmp_op(&mut stack, |a, b| a <= b)?,
|
||||
Op::Ge => cmp_op(&mut stack, |a, b| a >= b)?,
|
||||
|
||||
Op::And => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
stack.push(Value::Int(if a && b { 1 } else { 0 }));
|
||||
}
|
||||
Op::Or => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
stack.push(Value::Int(if a || b { 1 } else { 0 }));
|
||||
}
|
||||
Op::Not => {
|
||||
let v = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
stack.push(Value::Int(if v { 0 } else { 1 }));
|
||||
}
|
||||
|
||||
Op::BranchIfZero(offset) => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
if !v.is_truthy() {
|
||||
pc += offset;
|
||||
}
|
||||
}
|
||||
Op::Branch(offset) => {
|
||||
pc += offset;
|
||||
}
|
||||
|
||||
Op::NewCmd => {
|
||||
let name = stack.pop().ok_or("stack underflow")?;
|
||||
let name = name.as_str()?;
|
||||
stack.push(Value::Cmd(vec![("sound".into(), name.into())]));
|
||||
}
|
||||
Op::SetParam(param) => {
|
||||
let val = stack.pop().ok_or("stack underflow")?;
|
||||
stack.push(Value::Param(param.clone(), val.to_param_string()));
|
||||
}
|
||||
Op::Emit => {
|
||||
let mut params = Vec::new();
|
||||
while let Some(v) = stack.last() {
|
||||
if v.is_param() {
|
||||
if let Value::Param(k, v) = stack.pop().unwrap() {
|
||||
params.push((k, v));
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
params.reverse();
|
||||
let cmd = stack.pop().ok_or("stack underflow")?;
|
||||
if let Value::Cmd(mut pairs) = cmd {
|
||||
pairs.extend(params);
|
||||
if time_offset > 0.0 {
|
||||
pairs.push(("delta".into(), time_offset.to_string()));
|
||||
}
|
||||
outputs.push(format_cmd(&pairs));
|
||||
} else {
|
||||
return Err("expected command".into());
|
||||
}
|
||||
}
|
||||
|
||||
Op::Get => {
|
||||
let name = stack.pop().ok_or("stack underflow")?;
|
||||
let name = name.as_str()?;
|
||||
let vars = self.vars.lock().unwrap();
|
||||
let val = vars.get(name).cloned().unwrap_or(Value::Int(0));
|
||||
stack.push(val);
|
||||
}
|
||||
Op::Set => {
|
||||
let name = stack.pop().ok_or("stack underflow")?;
|
||||
let name = name.as_str()?.to_string();
|
||||
let val = stack.pop().ok_or("stack underflow")?;
|
||||
self.vars.lock().unwrap().insert(name, val);
|
||||
}
|
||||
|
||||
Op::GetContext(name) => {
|
||||
let val = match name.as_str() {
|
||||
"step" => Value::Int(ctx.step as i64),
|
||||
"beat" => Value::Float(ctx.beat),
|
||||
"bank" => Value::Int(ctx.bank as i64),
|
||||
"pattern" => Value::Int(ctx.pattern as i64),
|
||||
"tempo" => Value::Float(ctx.tempo),
|
||||
"phase" => Value::Float(ctx.phase),
|
||||
"slot" => Value::Int(ctx.slot as i64),
|
||||
"runs" => Value::Int(ctx.runs as i64),
|
||||
_ => Value::Int(0),
|
||||
};
|
||||
stack.push(val);
|
||||
}
|
||||
|
||||
Op::Rand => {
|
||||
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let val = self.rng.lock().unwrap().gen_range(min..max);
|
||||
stack.push(Value::Float(val));
|
||||
}
|
||||
Op::Rrand => {
|
||||
let max = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let min = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let val = self.rng.lock().unwrap().gen_range(min..=max);
|
||||
stack.push(Value::Int(val));
|
||||
}
|
||||
Op::Seed => {
|
||||
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
||||
}
|
||||
|
||||
Op::Cycle => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count == 0 {
|
||||
return Err("cycle count must be > 0".into());
|
||||
}
|
||||
if stack.len() < count {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let start = stack.len() - count;
|
||||
let values: Vec<Value> = stack.drain(start..).collect();
|
||||
let idx = ctx.runs % count;
|
||||
stack.push(values[idx].clone());
|
||||
}
|
||||
|
||||
Op::Choose => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count == 0 {
|
||||
return Err("choose count must be > 0".into());
|
||||
}
|
||||
if stack.len() < count {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let start = stack.len() - count;
|
||||
let values: Vec<Value> = stack.drain(start..).collect();
|
||||
let idx = self.rng.lock().unwrap().gen_range(0..count);
|
||||
stack.push(values[idx].clone());
|
||||
}
|
||||
|
||||
Op::Chance => {
|
||||
let prob = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let val: f64 = self.rng.lock().unwrap().gen();
|
||||
stack.push(Value::Int(if val < prob { 1 } else { 0 }));
|
||||
}
|
||||
|
||||
Op::Maybe => {
|
||||
let prob = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let param = stack.pop().ok_or("stack underflow")?;
|
||||
if !param.is_param() {
|
||||
return Err("? requires a param".into());
|
||||
}
|
||||
let val: f64 = self.rng.lock().unwrap().gen();
|
||||
if val < prob {
|
||||
stack.push(param);
|
||||
}
|
||||
}
|
||||
|
||||
Op::Wait => {
|
||||
let duration = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
time_offset += duration;
|
||||
}
|
||||
|
||||
Op::ListStart => {
|
||||
stack.push(Value::Marker);
|
||||
}
|
||||
|
||||
Op::ListEnd => {
|
||||
let mut count = 0;
|
||||
let mut values = Vec::new();
|
||||
while let Some(v) = stack.pop() {
|
||||
if v.is_marker() {
|
||||
break;
|
||||
}
|
||||
values.push(v);
|
||||
count += 1;
|
||||
}
|
||||
values.reverse();
|
||||
for v in values {
|
||||
stack.push(v);
|
||||
}
|
||||
stack.push(Value::Int(count));
|
||||
}
|
||||
}
|
||||
pc += 1;
|
||||
}
|
||||
|
||||
if outputs.is_empty() {
|
||||
if let Some(Value::Cmd(pairs)) = stack.pop() {
|
||||
outputs.push(format_cmd(&pairs));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(f64, f64) -> f64,
|
||||
{
|
||||
let b = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let a = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let result = f(a, b);
|
||||
if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
|
||||
stack.push(Value::Int(result as i64));
|
||||
} else {
|
||||
stack.push(Value::Float(result));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmp_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(f64, f64) -> bool,
|
||||
{
|
||||
let b = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let a = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
stack.push(Value::Int(if f(a, b) { 1 } else { 0 }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_cmd(pairs: &[(String, String)]) -> String {
|
||||
let parts: Vec<String> = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
|
||||
format!("/{}", parts.join("/"))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod file;
|
||||
mod forth;
|
||||
mod project;
|
||||
mod script;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
|
||||
@@ -83,6 +85,8 @@ pub struct Step {
|
||||
pub script: String,
|
||||
#[serde(skip)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for Step {
|
||||
@@ -91,6 +95,7 @@ impl Default for Step {
|
||||
active: true,
|
||||
script: String::new(),
|
||||
command: None,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +137,27 @@ impl Pattern {
|
||||
}
|
||||
self.length = length;
|
||||
}
|
||||
|
||||
pub fn resolve_source(&self, index: usize) -> usize {
|
||||
let mut current = index;
|
||||
for _ in 0..self.steps.len() {
|
||||
if let Some(step) = self.steps.get(current) {
|
||||
if let Some(source) = step.source {
|
||||
current = source;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
} else {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
pub fn resolve_script(&self, index: usize) -> Option<&str> {
|
||||
let source_idx = self.resolve_source(index);
|
||||
self.steps.get(source_idx).map(|s| s.script.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -153,12 +179,15 @@ impl Default for Bank {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub banks: Vec<Bank>,
|
||||
#[serde(default)]
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
||||
sample_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,274 +1,19 @@
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng as RngTrait, SeedableRng};
|
||||
use rhai::{Dynamic, Engine, Scope};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use super::forth::Forth;
|
||||
|
||||
pub type Variables = Arc<Mutex<HashMap<String, Dynamic>>>;
|
||||
pub type Rng = Arc<Mutex<StdRng>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cmd {
|
||||
pairs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
fn new() -> Self {
|
||||
Self { pairs: vec![] }
|
||||
}
|
||||
|
||||
fn with(sound: &str) -> Self {
|
||||
let mut cmd = Self::new();
|
||||
cmd.pairs.push(("sound".into(), sound.into()));
|
||||
cmd
|
||||
}
|
||||
|
||||
fn with_dur_f(sound: &str, dur: f64) -> Self {
|
||||
let mut cmd = Self::with(sound);
|
||||
cmd.pairs.push(("dur".into(), dur.to_string()));
|
||||
cmd
|
||||
}
|
||||
|
||||
fn with_dur_i(sound: &str, dur: i64) -> Self {
|
||||
let mut cmd = Self::with(sound);
|
||||
cmd.pairs.push(("dur".into(), dur.to_string()));
|
||||
cmd
|
||||
}
|
||||
|
||||
fn set(&mut self, key: &str, val: &str) -> Self {
|
||||
self.pairs.push((key.into(), val.into()));
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Cmd {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let parts: Vec<String> = self.pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
|
||||
write!(f, "/{}", parts.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StepContext {
|
||||
pub step: usize,
|
||||
pub beat: f64,
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
pub tempo: f64,
|
||||
pub phase: f64,
|
||||
pub slot: usize,
|
||||
}
|
||||
pub use super::forth::{Rng, StepContext, Variables};
|
||||
|
||||
pub struct ScriptEngine {
|
||||
engine: Engine,
|
||||
forth: Forth,
|
||||
}
|
||||
|
||||
impl ScriptEngine {
|
||||
pub fn new(vars: Variables, rng: Rng) -> Self {
|
||||
let mut engine = Engine::new();
|
||||
engine.set_max_expr_depths(64, 32);
|
||||
|
||||
register_cmd(&mut engine);
|
||||
|
||||
let vars_for_set = Arc::clone(&vars);
|
||||
let vars_for_get = Arc::clone(&vars);
|
||||
|
||||
engine.register_fn("set", move |name: &str, value: Dynamic| {
|
||||
vars_for_set.lock().unwrap().insert(name.to_string(), value);
|
||||
});
|
||||
|
||||
engine.register_fn("get", move |name: &str| -> Dynamic {
|
||||
vars_for_get
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or(Dynamic::UNIT)
|
||||
});
|
||||
|
||||
let rng_rand_ff = Arc::clone(&rng);
|
||||
let rng_rand_ii = Arc::clone(&rng);
|
||||
let rng_rrand_ff = Arc::clone(&rng);
|
||||
let rng_rrand_ii = Arc::clone(&rng);
|
||||
let rng_seed = Arc::clone(&rng);
|
||||
|
||||
engine.register_fn("rand", move |min: f64, max: f64| -> f64 {
|
||||
rng_rand_ff.lock().unwrap().gen_range(min..max)
|
||||
});
|
||||
engine.register_fn("rand", move |min: i64, max: i64| -> f64 {
|
||||
rng_rand_ii
|
||||
.lock()
|
||||
.unwrap()
|
||||
.gen_range(min as f64..max as f64)
|
||||
});
|
||||
|
||||
engine.register_fn("rrand", move |min: f64, max: f64| -> i64 {
|
||||
rng_rrand_ff
|
||||
.lock()
|
||||
.unwrap()
|
||||
.gen_range(min as i64..=max as i64)
|
||||
});
|
||||
engine.register_fn("rrand", move |min: i64, max: i64| -> i64 {
|
||||
rng_rrand_ii.lock().unwrap().gen_range(min..=max)
|
||||
});
|
||||
|
||||
engine.register_fn("seed", move |s: i64| {
|
||||
*rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
||||
});
|
||||
|
||||
Self { engine }
|
||||
Self {
|
||||
forth: Forth::new(vars, rng),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<String, String> {
|
||||
if script.trim().is_empty() {
|
||||
return Err("empty script".to_string());
|
||||
}
|
||||
|
||||
let mut scope = Scope::new();
|
||||
scope.push("step", ctx.step as i64);
|
||||
scope.push("beat", ctx.beat);
|
||||
scope.push("bank", ctx.bank as i64);
|
||||
scope.push("pattern", ctx.pattern as i64);
|
||||
scope.push("tempo", ctx.tempo);
|
||||
scope.push("phase", ctx.phase);
|
||||
scope.push("slot", ctx.slot as i64);
|
||||
|
||||
if let Ok(cmd) = self.engine.eval_with_scope::<Cmd>(&mut scope, script) {
|
||||
return Ok(cmd.to_string());
|
||||
}
|
||||
|
||||
self.engine
|
||||
.eval_with_scope::<String>(&mut scope, script)
|
||||
.map_err(|e| e.to_string())
|
||||
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<Vec<String>, String> {
|
||||
self.forth.evaluate(script, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
fn register_cmd(engine: &mut Engine) {
|
||||
engine.register_type_with_name::<Cmd>("Cmd");
|
||||
engine.register_fn("sound", Cmd::with);
|
||||
engine.register_fn("sound", Cmd::with_dur_f);
|
||||
engine.register_fn("sound", Cmd::with_dur_i);
|
||||
engine.register_fn("s", Cmd::with);
|
||||
engine.register_fn("s", Cmd::with_dur_f);
|
||||
engine.register_fn("s", Cmd::with_dur_i);
|
||||
|
||||
macro_rules! reg_both {
|
||||
($($name:expr),*) => {
|
||||
$(
|
||||
engine.register_fn($name, |c: &mut Cmd, v: f64| c.set($name, &v.to_string()));
|
||||
engine.register_fn($name, |c: &mut Cmd, v: i64| c.set($name, &v.to_string()));
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
reg_both!(
|
||||
"time",
|
||||
"repeat",
|
||||
"dur",
|
||||
"gate",
|
||||
"freq",
|
||||
"detune",
|
||||
"speed",
|
||||
"glide",
|
||||
"pw",
|
||||
"spread",
|
||||
"mult",
|
||||
"warp",
|
||||
"mirror",
|
||||
"harmonics",
|
||||
"timbre",
|
||||
"morph",
|
||||
"begin",
|
||||
"end",
|
||||
"gain",
|
||||
"postgain",
|
||||
"velocity",
|
||||
"pan",
|
||||
"attack",
|
||||
"decay",
|
||||
"sustain",
|
||||
"release",
|
||||
"lpf",
|
||||
"lpq",
|
||||
"lpe",
|
||||
"lpa",
|
||||
"lpd",
|
||||
"lps",
|
||||
"lpr",
|
||||
"hpf",
|
||||
"hpq",
|
||||
"hpe",
|
||||
"hpa",
|
||||
"hpd",
|
||||
"hps",
|
||||
"hpr",
|
||||
"bpf",
|
||||
"bpq",
|
||||
"bpe",
|
||||
"bpa",
|
||||
"bpd",
|
||||
"bps",
|
||||
"bpr",
|
||||
"ftype",
|
||||
"penv",
|
||||
"patt",
|
||||
"pdec",
|
||||
"psus",
|
||||
"prel",
|
||||
"vib",
|
||||
"vibmod",
|
||||
"vibshape",
|
||||
"fm",
|
||||
"fmh",
|
||||
"fmshape",
|
||||
"fme",
|
||||
"fma",
|
||||
"fmd",
|
||||
"fms",
|
||||
"fmr",
|
||||
"am",
|
||||
"amdepth",
|
||||
"amshape",
|
||||
"rm",
|
||||
"rmdepth",
|
||||
"rmshape",
|
||||
"phaser",
|
||||
"phaserdepth",
|
||||
"phasersweep",
|
||||
"phasercenter",
|
||||
"flanger",
|
||||
"flangerdepth",
|
||||
"flangerfeedback",
|
||||
"chorus",
|
||||
"chorusdepth",
|
||||
"chorusdelay",
|
||||
"comb",
|
||||
"combfreq",
|
||||
"combfeedback",
|
||||
"combdamp",
|
||||
"coarse",
|
||||
"crush",
|
||||
"fold",
|
||||
"wrap",
|
||||
"distort",
|
||||
"distortvol",
|
||||
"delay",
|
||||
"delaytime",
|
||||
"delayfeedback",
|
||||
"delaytype",
|
||||
"verb",
|
||||
"verbdecay",
|
||||
"verbdamp",
|
||||
"verbpredelay",
|
||||
"verbdiff",
|
||||
"voice",
|
||||
"orbit",
|
||||
"note",
|
||||
"size",
|
||||
"n",
|
||||
"cut"
|
||||
);
|
||||
|
||||
engine.register_fn("reset", |c: &mut Cmd, v: bool| {
|
||||
c.set("reset", if v { "1" } else { "0" })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ pub struct EditorContext {
|
||||
pub step: usize,
|
||||
pub focus: Focus,
|
||||
pub text: TextArea<'static>,
|
||||
pub copied_step: Option<CopiedStep>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CopiedStep {
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
pub step: usize,
|
||||
}
|
||||
|
||||
impl Default for EditorContext {
|
||||
@@ -28,6 +36,7 @@ impl Default for EditorContext {
|
||||
step: 0,
|
||||
focus: Focus::Sequencer,
|
||||
text: TextArea::default(),
|
||||
copied_step: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ pub mod project;
|
||||
pub mod ui;
|
||||
|
||||
pub use audio::{AudioFocus, AudioSettings, Metrics};
|
||||
pub use editor::{EditorContext, Focus, PatternField};
|
||||
pub use editor::{CopiedStep, EditorContext, Focus, PatternField};
|
||||
pub use modal::Modal;
|
||||
pub use patterns_nav::PatternsViewLevel;
|
||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||
pub use playback::PlaybackState;
|
||||
pub use project::ProjectState;
|
||||
pub use ui::UiState;
|
||||
|
||||
@@ -6,6 +6,12 @@ pub enum Modal {
|
||||
ConfirmQuit {
|
||||
selected: bool,
|
||||
},
|
||||
ConfirmDeleteStep {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
step: usize,
|
||||
selected: bool,
|
||||
},
|
||||
SaveAs(String),
|
||||
LoadFrom(String),
|
||||
RenameBank {
|
||||
@@ -22,4 +28,5 @@ pub enum Modal {
|
||||
input: String,
|
||||
},
|
||||
AddSamplePath(String),
|
||||
Editor,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PatternsViewLevel {
|
||||
pub enum PatternsColumn {
|
||||
#[default]
|
||||
Banks,
|
||||
Patterns {
|
||||
bank: usize,
|
||||
},
|
||||
Patterns,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct PatternsNav {
|
||||
pub column: PatternsColumn,
|
||||
pub bank_cursor: usize,
|
||||
pub pattern_cursor: usize,
|
||||
}
|
||||
|
||||
impl PatternsNav {
|
||||
pub fn move_left(&mut self) {
|
||||
self.column = PatternsColumn::Banks;
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
self.column = PatternsColumn::Patterns;
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
match self.column {
|
||||
PatternsColumn::Banks => {
|
||||
self.bank_cursor = (self.bank_cursor + 15) % 16;
|
||||
}
|
||||
PatternsColumn::Patterns => {
|
||||
self.pattern_cursor = (self.pattern_cursor + 15) % 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
match self.column {
|
||||
PatternsColumn::Banks => {
|
||||
self.bank_cursor = (self.bank_cursor + 1) % 16;
|
||||
}
|
||||
PatternsColumn::Patterns => {
|
||||
self.pattern_cursor = (self.pattern_cursor + 1) % 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_bank(&self) -> usize {
|
||||
self.bank_cursor
|
||||
}
|
||||
|
||||
pub fn selected_pattern(&self) -> usize {
|
||||
self.pattern_cursor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub struct UiState {
|
||||
pub modal: Modal,
|
||||
pub doc_topic: usize,
|
||||
pub doc_scroll: usize,
|
||||
pub show_title: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -18,6 +19,7 @@ impl Default for UiState {
|
||||
modal: Modal::None,
|
||||
doc_topic: 0,
|
||||
doc_scroll: 0,
|
||||
show_title: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
277
seq/src/views/highlight.rs
Normal file
277
seq/src/views/highlight.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use ratatui::style::{Color, Style};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenKind {
|
||||
Number,
|
||||
String,
|
||||
Comment,
|
||||
Keyword,
|
||||
StackOp,
|
||||
Operator,
|
||||
Sound,
|
||||
Param,
|
||||
Context,
|
||||
Default,
|
||||
}
|
||||
|
||||
impl TokenKind {
|
||||
pub fn style(self) -> Style {
|
||||
match self {
|
||||
TokenKind::Number => Style::default().fg(Color::Rgb(255, 180, 100)),
|
||||
TokenKind::String => Style::default().fg(Color::Rgb(150, 220, 150)),
|
||||
TokenKind::Comment => Style::default().fg(Color::Rgb(100, 100, 100)),
|
||||
TokenKind::Keyword => Style::default().fg(Color::Rgb(220, 120, 220)),
|
||||
TokenKind::StackOp => Style::default().fg(Color::Rgb(120, 180, 220)),
|
||||
TokenKind::Operator => Style::default().fg(Color::Rgb(200, 200, 130)),
|
||||
TokenKind::Sound => Style::default().fg(Color::Rgb(100, 220, 200)),
|
||||
TokenKind::Param => Style::default().fg(Color::Rgb(180, 150, 220)),
|
||||
TokenKind::Context => Style::default().fg(Color::Rgb(220, 180, 120)),
|
||||
TokenKind::Default => Style::default().fg(Color::Rgb(200, 200, 200)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Token {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: TokenKind,
|
||||
}
|
||||
|
||||
const STACK_OPS: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
|
||||
const OPERATORS: &[&str] = &[
|
||||
"+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and",
|
||||
"or", "not",
|
||||
];
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"if", "else", "then", "emit", "get", "set", "rand", "rrand", "seed", "cycle", "choose",
|
||||
"chance", "[", "]",
|
||||
];
|
||||
const SOUND: &[&str] = &["sound", "s"];
|
||||
const CONTEXT: &[&str] = &[
|
||||
"step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs",
|
||||
];
|
||||
const PARAMS: &[&str] = &[
|
||||
"time",
|
||||
"repeat",
|
||||
"dur",
|
||||
"gate",
|
||||
"freq",
|
||||
"detune",
|
||||
"speed",
|
||||
"glide",
|
||||
"pw",
|
||||
"spread",
|
||||
"mult",
|
||||
"warp",
|
||||
"mirror",
|
||||
"harmonics",
|
||||
"timbre",
|
||||
"morph",
|
||||
"begin",
|
||||
"end",
|
||||
"gain",
|
||||
"postgain",
|
||||
"velocity",
|
||||
"pan",
|
||||
"attack",
|
||||
"decay",
|
||||
"sustain",
|
||||
"release",
|
||||
"lpf",
|
||||
"lpq",
|
||||
"lpe",
|
||||
"lpa",
|
||||
"lpd",
|
||||
"lps",
|
||||
"lpr",
|
||||
"hpf",
|
||||
"hpq",
|
||||
"hpe",
|
||||
"hpa",
|
||||
"hpd",
|
||||
"hps",
|
||||
"hpr",
|
||||
"bpf",
|
||||
"bpq",
|
||||
"bpe",
|
||||
"bpa",
|
||||
"bpd",
|
||||
"bps",
|
||||
"bpr",
|
||||
"ftype",
|
||||
"penv",
|
||||
"patt",
|
||||
"pdec",
|
||||
"psus",
|
||||
"prel",
|
||||
"vib",
|
||||
"vibmod",
|
||||
"vibshape",
|
||||
"fm",
|
||||
"fmh",
|
||||
"fmshape",
|
||||
"fme",
|
||||
"fma",
|
||||
"fmd",
|
||||
"fms",
|
||||
"fmr",
|
||||
"am",
|
||||
"amdepth",
|
||||
"amshape",
|
||||
"rm",
|
||||
"rmdepth",
|
||||
"rmshape",
|
||||
"phaser",
|
||||
"phaserdepth",
|
||||
"phasersweep",
|
||||
"phasercenter",
|
||||
"flanger",
|
||||
"flangerdepth",
|
||||
"flangerfeedback",
|
||||
"chorus",
|
||||
"chorusdepth",
|
||||
"chorusdelay",
|
||||
"comb",
|
||||
"combfreq",
|
||||
"combfeedback",
|
||||
"combdamp",
|
||||
"coarse",
|
||||
"crush",
|
||||
"fold",
|
||||
"wrap",
|
||||
"distort",
|
||||
"distortvol",
|
||||
"delay",
|
||||
"delaytime",
|
||||
"delayfeedback",
|
||||
"delaytype",
|
||||
"verb",
|
||||
"verbdecay",
|
||||
"verbdamp",
|
||||
"verbpredelay",
|
||||
"verbdiff",
|
||||
"voice",
|
||||
"orbit",
|
||||
"note",
|
||||
"size",
|
||||
"n",
|
||||
"cut",
|
||||
"reset",
|
||||
];
|
||||
|
||||
pub fn tokenize_line(line: &str) -> Vec<Token> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = line.char_indices().peekable();
|
||||
|
||||
while let Some((start, c)) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '(' {
|
||||
let end = line.len();
|
||||
let comment_end = line[start..]
|
||||
.find(')')
|
||||
.map(|i| start + i + 1)
|
||||
.unwrap_or(end);
|
||||
tokens.push(Token {
|
||||
start,
|
||||
end: comment_end,
|
||||
kind: TokenKind::Comment,
|
||||
});
|
||||
while let Some((i, _)) = chars.peek() {
|
||||
if *i >= comment_end {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '"' {
|
||||
let mut end = start + 1;
|
||||
while let Some((i, ch)) = chars.next() {
|
||||
end = i + ch.len_utf8();
|
||||
if ch == '"' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokens.push(Token {
|
||||
start,
|
||||
end,
|
||||
kind: TokenKind::String,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut end = start + c.len_utf8();
|
||||
while let Some((i, ch)) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
end = *i + ch.len_utf8();
|
||||
chars.next();
|
||||
}
|
||||
|
||||
let word = &line[start..end];
|
||||
let kind = classify_word(word);
|
||||
tokens.push(Token { start, end, kind });
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn classify_word(word: &str) -> TokenKind {
|
||||
if word.parse::<f64>().is_ok() || word.parse::<i64>().is_ok() {
|
||||
return TokenKind::Number;
|
||||
}
|
||||
|
||||
if STACK_OPS.contains(&word) {
|
||||
return TokenKind::StackOp;
|
||||
}
|
||||
|
||||
if OPERATORS.contains(&word) {
|
||||
return TokenKind::Operator;
|
||||
}
|
||||
|
||||
if KEYWORDS.contains(&word) {
|
||||
return TokenKind::Keyword;
|
||||
}
|
||||
|
||||
if SOUND.contains(&word) {
|
||||
return TokenKind::Sound;
|
||||
}
|
||||
|
||||
if CONTEXT.contains(&word) {
|
||||
return TokenKind::Context;
|
||||
}
|
||||
|
||||
if PARAMS.contains(&word) {
|
||||
return TokenKind::Param;
|
||||
}
|
||||
|
||||
TokenKind::Default
|
||||
}
|
||||
|
||||
pub fn highlight_line(line: &str) -> Vec<(Style, String)> {
|
||||
let tokens = tokenize_line(line);
|
||||
let mut result = Vec::new();
|
||||
let mut last_end = 0;
|
||||
|
||||
for token in tokens {
|
||||
if token.start > last_end {
|
||||
result.push((
|
||||
TokenKind::Default.style(),
|
||||
line[last_end..token.start].to_string(),
|
||||
));
|
||||
}
|
||||
result.push((token.kind.style(), line[token.start..token.end].to_string()));
|
||||
last_end = token.end;
|
||||
}
|
||||
|
||||
if last_end < line.len() {
|
||||
result.push((TokenKind::Default.style(), line[last_end..].to_string()));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::engine::SequencerSnapshot;
|
||||
use crate::state::Focus;
|
||||
use crate::views::highlight::highlight_line;
|
||||
use crate::widgets::{Orientation, Scope, VuMeter};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
@@ -16,32 +17,22 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let [seq_area, editor_area] =
|
||||
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
|
||||
let [sequencer_area, preview_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area);
|
||||
|
||||
render_sequencer(frame, app, snapshot, seq_area);
|
||||
render_editor(frame, app, editor_area);
|
||||
render_sequencer(frame, app, snapshot, sequencer_area);
|
||||
render_step_preview(frame, app, preview_area);
|
||||
render_scope(frame, app, scope_area);
|
||||
render_vu_meter(frame, app, vu_area);
|
||||
}
|
||||
|
||||
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let focus_indicator = if app.editor_ctx.focus == Focus::Sequencer {
|
||||
"*"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
|
||||
let border_style = if app.editor_ctx.focus == Focus::Sequencer {
|
||||
Style::new().fg(Color::Rgb(100, 160, 180))
|
||||
} else {
|
||||
Style::new().fg(Color::Rgb(70, 75, 85))
|
||||
};
|
||||
let border_style = Style::new().fg(Color::Rgb(100, 160, 180));
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(format!("Sequencer{focus_indicator}"));
|
||||
.title("Sequencer");
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
@@ -116,6 +107,7 @@ fn render_tile(
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step = pattern.step(step_idx);
|
||||
let is_active = step.map(|s| s.active).unwrap_or(false);
|
||||
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
|
||||
let is_selected = step_idx == app.editor_ctx.step;
|
||||
|
||||
let playing_slot = if app.playback.playing {
|
||||
@@ -132,17 +124,21 @@ fn render_tile(
|
||||
|
||||
let is_playing = playing_slot.is_some();
|
||||
|
||||
let (bg, fg) = match (is_playing, is_active, is_selected) {
|
||||
(true, true, _) => (Color::Rgb(195, 85, 65), Color::White),
|
||||
(true, false, _) => (Color::Rgb(180, 120, 45), Color::Black),
|
||||
(false, true, true) => (Color::Rgb(0, 220, 180), Color::Black),
|
||||
(false, true, false) => (Color::Rgb(45, 106, 95), Color::White),
|
||||
(false, false, true) => (Color::Rgb(80, 180, 255), Color::Black),
|
||||
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
|
||||
let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) {
|
||||
(true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White),
|
||||
(true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black),
|
||||
(false, true, true, true) => (Color::Rgb(180, 140, 220), Color::Black),
|
||||
(false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black),
|
||||
(false, true, false, true) => (Color::Rgb(90, 70, 120), Color::White),
|
||||
(false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White),
|
||||
(false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black),
|
||||
(false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
|
||||
};
|
||||
|
||||
let symbol = if is_playing {
|
||||
"▶".to_string()
|
||||
} else if let Some(source) = step.and_then(|s| s.source) {
|
||||
format!("→{:02}", source + 1)
|
||||
} else {
|
||||
format!("{:02}", step_idx + 1)
|
||||
};
|
||||
@@ -154,40 +150,6 @@ fn render_tile(
|
||||
frame.render_widget(tile, area);
|
||||
}
|
||||
|
||||
fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focus_indicator = if app.editor_ctx.focus == Focus::Editor {
|
||||
"*"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
|
||||
let border_style = if app.ui.is_flashing() {
|
||||
Style::new().fg(Color::Green)
|
||||
} else if app.editor_ctx.focus == Focus::Editor {
|
||||
Style::new().fg(Color::Rgb(100, 160, 180))
|
||||
} else {
|
||||
Style::new().fg(Color::Rgb(70, 75, 85))
|
||||
};
|
||||
|
||||
let step_num = app.editor_ctx.step + 1;
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.title(format!("Step {step_num:02} Script{focus_indicator}"));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let cursor_style = if app.editor_ctx.focus == Focus::Editor {
|
||||
Style::new().bg(Color::White).fg(Color::Black)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
app.editor_ctx.text.set_cursor_style(cursor_style);
|
||||
|
||||
frame.render_widget(&app.editor_ctx.text, inner);
|
||||
}
|
||||
|
||||
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -213,3 +175,45 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
||||
frame.render_widget(vu, inner);
|
||||
}
|
||||
|
||||
fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step_idx = app.editor_ctx.step;
|
||||
let step = pattern.step(step_idx);
|
||||
|
||||
let [title_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
|
||||
let source_idx = step.and_then(|s| s.source);
|
||||
|
||||
let title = if let Some(src) = source_idx {
|
||||
format!(" Step {:02} → {:02} ", step_idx + 1, src + 1)
|
||||
} else {
|
||||
format!(" Step {:02} ", step_idx + 1)
|
||||
};
|
||||
let title_color = if is_linked {
|
||||
Color::Rgb(180, 140, 220)
|
||||
} else {
|
||||
Color::Rgb(120, 125, 135)
|
||||
};
|
||||
let title_p = Paragraph::new(title).style(Style::new().fg(title_color));
|
||||
frame.render_widget(title_p, title_area);
|
||||
|
||||
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
||||
if script.is_empty() {
|
||||
let empty = Paragraph::new(" (empty)").style(Style::new().fg(Color::Rgb(80, 85, 95)));
|
||||
frame.render_widget(empty, content_area);
|
||||
return;
|
||||
}
|
||||
|
||||
let spans: Vec<_> = highlight_line(script)
|
||||
.into_iter()
|
||||
.map(|(style, text)| ratatui::text::Span::styled(text, style))
|
||||
.collect();
|
||||
let mut line_spans = vec![ratatui::text::Span::raw(" ")];
|
||||
line_spans.extend(spans);
|
||||
let line = Line::from(line_spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, content_area);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod audio_view;
|
||||
pub mod doc_view;
|
||||
pub mod highlight;
|
||||
pub mod main_view;
|
||||
pub mod patterns_view;
|
||||
mod render;
|
||||
pub mod title_view;
|
||||
|
||||
pub use render::render;
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::engine::SequencerSnapshot;
|
||||
use crate::state::PatternsViewLevel;
|
||||
use crate::state::PatternsColumn;
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
match app.patterns_view_level {
|
||||
PatternsViewLevel::Banks => render_banks(frame, app, snapshot, area),
|
||||
PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, snapshot, area, bank),
|
||||
}
|
||||
let [banks_area, patterns_area] =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||
|
||||
render_banks(frame, app, snapshot, banks_area);
|
||||
render_patterns(frame, app, snapshot, patterns_area);
|
||||
}
|
||||
|
||||
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
||||
let border_color = if is_focused {
|
||||
Color::Rgb(100, 160, 180)
|
||||
} else {
|
||||
Color::Rgb(70, 75, 85)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
|
||||
.border_style(Style::new().fg(border_color))
|
||||
.title("Banks");
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.width < 50 {
|
||||
let msg = Paragraph::new("Terminal too narrow")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
let banks_with_playback: Vec<usize> = snapshot
|
||||
.slot_data
|
||||
.iter()
|
||||
@@ -39,57 +38,85 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
.map(|s| s.bank)
|
||||
.collect();
|
||||
|
||||
let bank_names: Vec<Option<&str>> = app
|
||||
.project_state
|
||||
.project
|
||||
.banks
|
||||
let banks_with_queued: Vec<usize> = app
|
||||
.playback
|
||||
.queued_changes
|
||||
.iter()
|
||||
.map(|b| b.name.as_deref())
|
||||
.filter_map(|c| match c {
|
||||
crate::engine::SlotChange::Add { bank, .. } => Some(*bank),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
render_grid(
|
||||
frame,
|
||||
inner,
|
||||
app.patterns_cursor,
|
||||
app.editor_ctx.bank,
|
||||
&banks_with_playback,
|
||||
&bank_names,
|
||||
);
|
||||
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
|
||||
let row_areas = Layout::vertical(rows).split(inner);
|
||||
|
||||
for idx in 0..16 {
|
||||
if idx >= row_areas.len() {
|
||||
break;
|
||||
}
|
||||
let row_area = row_areas[idx];
|
||||
|
||||
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
|
||||
let is_selected = idx == app.patterns_nav.bank_cursor;
|
||||
let is_edit = idx == app.editor_ctx.bank;
|
||||
let is_playing = banks_with_playback.contains(&idx);
|
||||
let is_queued = banks_with_queued.contains(&idx);
|
||||
|
||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) {
|
||||
(true, _, _) => (Color::Cyan, Color::Black, ""),
|
||||
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
|
||||
(false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
|
||||
(false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
|
||||
(false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
|
||||
(false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
|
||||
};
|
||||
|
||||
let name = app.project_state.project.banks[idx]
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let label = if name.is_empty() {
|
||||
format!("{}{:02}", prefix, idx + 1)
|
||||
} else {
|
||||
format!("{}{:02} {}", prefix, idx + 1, name)
|
||||
};
|
||||
|
||||
let style = Style::new().bg(bg).fg(fg);
|
||||
let style = if is_playing || is_queued {
|
||||
style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
style
|
||||
};
|
||||
|
||||
let para = Paragraph::new(label).style(style);
|
||||
frame.render_widget(para, row_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_patterns(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
area: Rect,
|
||||
bank: usize,
|
||||
) {
|
||||
let bank_name = app.project_state.project.banks[bank].name.as_deref();
|
||||
let title_text = match bank_name {
|
||||
Some(name) => format!("{name} › Patterns"),
|
||||
None => format!("Bank {:02} › Patterns", bank + 1),
|
||||
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
|
||||
let border_color = if is_focused {
|
||||
Color::Rgb(100, 160, 180)
|
||||
} else {
|
||||
Color::Rgb(70, 75, 85)
|
||||
};
|
||||
|
||||
let bank = app.patterns_nav.bank_cursor;
|
||||
let bank_name = app.project_state.project.banks[bank].name.as_deref();
|
||||
let title = match bank_name {
|
||||
Some(name) => format!("Patterns ({name})"),
|
||||
None => format!("Patterns (Bank {:02})", bank + 1),
|
||||
};
|
||||
let title = Line::from(vec![
|
||||
Span::raw(title_text),
|
||||
Span::styled(" [Esc]←", Style::new().fg(Color::Rgb(120, 125, 135))),
|
||||
]);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)))
|
||||
.border_style(Style::new().fg(border_color))
|
||||
.title(title);
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.width < 50 {
|
||||
let msg = Paragraph::new("Terminal too narrow")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
|
||||
frame.render_widget(msg, inner);
|
||||
return;
|
||||
}
|
||||
|
||||
let playing_patterns: Vec<usize> = snapshot
|
||||
.slot_data
|
||||
.iter()
|
||||
@@ -97,173 +124,85 @@ fn render_patterns(
|
||||
.map(|s| s.pattern)
|
||||
.collect();
|
||||
|
||||
let edit_pattern = if app.editor_ctx.bank == bank {
|
||||
app.editor_ctx.pattern
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
|
||||
let pattern_names: Vec<Option<&str>> = app.project_state.project.banks[bank]
|
||||
.patterns
|
||||
let queued_to_play: Vec<usize> = app
|
||||
.playback
|
||||
.queued_changes
|
||||
.iter()
|
||||
.map(|p| p.name.as_deref())
|
||||
.filter_map(|c| match c {
|
||||
crate::engine::SlotChange::Add {
|
||||
bank: b, pattern, ..
|
||||
} if *b == bank => Some(*pattern),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
render_pattern_grid(
|
||||
frame,
|
||||
app,
|
||||
snapshot,
|
||||
inner,
|
||||
bank,
|
||||
app.patterns_cursor,
|
||||
edit_pattern,
|
||||
&playing_patterns,
|
||||
&pattern_names,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_grid(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
cursor: usize,
|
||||
edit_pos: usize,
|
||||
playing_positions: &[usize],
|
||||
names: &[Option<&str>],
|
||||
) {
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
for row in 0..4 {
|
||||
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
|
||||
for col in 0..4 {
|
||||
let idx = row * 4 + col;
|
||||
let is_cursor = idx == cursor;
|
||||
let is_edit = idx == edit_pos;
|
||||
let is_playing = playing_positions.contains(&idx);
|
||||
|
||||
let (bg, fg) = match (is_cursor, is_edit, is_playing) {
|
||||
(true, _, _) => (Color::Cyan, Color::Black),
|
||||
(false, true, _) => (Color::Rgb(45, 106, 95), Color::White),
|
||||
(false, false, true) => (Color::Rgb(45, 80, 45), Color::Green),
|
||||
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
|
||||
};
|
||||
|
||||
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
||||
let number = format!("{:02}", idx + 1);
|
||||
let cell = cols[col];
|
||||
|
||||
// Fill background
|
||||
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
|
||||
|
||||
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
|
||||
let center_y = cell.y + cell.height / 2;
|
||||
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
|
||||
|
||||
if name.is_empty() {
|
||||
// Number centered
|
||||
frame.render_widget(
|
||||
Paragraph::new(number)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||
center_area,
|
||||
);
|
||||
} else {
|
||||
// Number centered at top
|
||||
frame.render_widget(
|
||||
Paragraph::new(number)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
|
||||
top_area,
|
||||
);
|
||||
// Name centered in middle
|
||||
frame.render_widget(
|
||||
Paragraph::new(name)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||
center_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pattern_grid(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
area: Rect,
|
||||
bank: usize,
|
||||
cursor: usize,
|
||||
edit_pos: usize,
|
||||
playing_positions: &[usize],
|
||||
names: &[Option<&str>],
|
||||
) {
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
for row in 0..4 {
|
||||
let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]);
|
||||
for col in 0..4 {
|
||||
let idx = row * 4 + col;
|
||||
let is_cursor = idx == cursor;
|
||||
let is_edit = idx == edit_pos;
|
||||
let is_playing = playing_positions.contains(&idx);
|
||||
let queued = app.is_pattern_queued(bank, idx, snapshot);
|
||||
|
||||
let (bg, fg, prefix) = match (is_cursor, is_playing, queued) {
|
||||
(true, _, _) => (Color::Cyan, Color::Black, ""),
|
||||
(false, true, Some(false)) => (Color::Rgb(120, 90, 30), Color::Yellow, "×"),
|
||||
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "▶"),
|
||||
(false, false, Some(true)) => (Color::Rgb(80, 80, 45), Color::Yellow, "?"),
|
||||
(false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
|
||||
(false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135), ""),
|
||||
};
|
||||
|
||||
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
||||
let number = format!("{}{:02}", prefix, idx + 1);
|
||||
let cell = cols[col];
|
||||
|
||||
// Fill background
|
||||
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
|
||||
|
||||
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
|
||||
let center_y = cell.y + cell.height / 2;
|
||||
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
|
||||
|
||||
if name.is_empty() {
|
||||
// Number centered
|
||||
frame.render_widget(
|
||||
Paragraph::new(number)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||
center_area,
|
||||
);
|
||||
} else {
|
||||
// Number centered at top
|
||||
frame.render_widget(
|
||||
Paragraph::new(number)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
|
||||
top_area,
|
||||
);
|
||||
// Name centered in middle
|
||||
frame.render_widget(
|
||||
Paragraph::new(name)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||
center_area,
|
||||
);
|
||||
let queued_to_stop: Vec<usize> = app
|
||||
.playback
|
||||
.queued_changes
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
crate::engine::SlotChange::Remove { slot } => {
|
||||
let s = snapshot.slot_data[*slot];
|
||||
if s.active && s.bank == bank {
|
||||
Some(s.pattern)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let edit_pattern = if app.editor_ctx.bank == bank {
|
||||
Some(app.editor_ctx.pattern)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
|
||||
let row_areas = Layout::vertical(rows).split(inner);
|
||||
|
||||
for idx in 0..16 {
|
||||
if idx >= row_areas.len() {
|
||||
break;
|
||||
}
|
||||
let row_area = row_areas[idx];
|
||||
|
||||
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
|
||||
let is_selected = idx == app.patterns_nav.pattern_cursor;
|
||||
let is_edit = edit_pattern == Some(idx);
|
||||
let is_playing = playing_patterns.contains(&idx);
|
||||
let is_queued_play = queued_to_play.contains(&idx);
|
||||
let is_queued_stop = queued_to_stop.contains(&idx);
|
||||
|
||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) {
|
||||
(true, _, _, _) => (Color::Cyan, Color::Black, ""),
|
||||
(false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "),
|
||||
(false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
|
||||
(false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
|
||||
(false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
|
||||
(false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
|
||||
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
|
||||
};
|
||||
|
||||
let name = app.project_state.project.banks[bank].patterns[idx]
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let label = if name.is_empty() {
|
||||
format!("{}{:02}", prefix, idx + 1)
|
||||
} else {
|
||||
format!("{}{:02} {}", prefix, idx + 1, name)
|
||||
};
|
||||
|
||||
let style = Style::new().bg(bg).fg(fg);
|
||||
let style = if is_playing || is_queued_play {
|
||||
style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
style
|
||||
};
|
||||
|
||||
let para = Paragraph::new(label).style(style);
|
||||
frame.render_widget(para, row_area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
@@ -8,17 +8,25 @@ use crate::app::App;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::page::Page;
|
||||
use crate::state::{Modal, PatternField};
|
||||
use crate::widgets::{ConfirmModal, TextInputModal};
|
||||
use crate::views::highlight;
|
||||
use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal};
|
||||
|
||||
use super::{audio_view, doc_view, main_view, patterns_view};
|
||||
use super::{audio_view, doc_view, main_view, patterns_view, title_view};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
let term = frame.area();
|
||||
|
||||
if app.ui.show_title {
|
||||
title_view::render(frame, term);
|
||||
return;
|
||||
}
|
||||
|
||||
let [header_area, body_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.areas(frame.area());
|
||||
.areas(term);
|
||||
|
||||
render_header(frame, app, link, header_area);
|
||||
|
||||
@@ -30,12 +38,12 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
|
||||
}
|
||||
|
||||
render_footer(frame, app, footer_area);
|
||||
render_modal(frame, app);
|
||||
render_modal(frame, app, term);
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let [left_area, right_area] =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||
let [top_row, bottom_row] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let play_symbol = if app.playback.playing { "▶" } else { "■" };
|
||||
let play_color = if app.playback.playing {
|
||||
@@ -53,8 +61,30 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
Color::Green
|
||||
};
|
||||
|
||||
let left_spans = vec![
|
||||
Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
|
||||
let pattern = app
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
|
||||
|
||||
let top_spans = vec![
|
||||
Span::styled(play_symbol, Style::new().fg(play_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:.1} BPM", link.tempo()),
|
||||
Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("CPU {cpu_pct:3.0}%"), Style::new().fg(cpu_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("V:{}", app.metrics.active_voices),
|
||||
Style::new().fg(Color::Cyan),
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(Line::from(top_spans)), top_row);
|
||||
|
||||
let bottom_spans = vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"B{:02}:P{:02}",
|
||||
@@ -64,43 +94,18 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(play_symbol, Style::new().fg(play_color)),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
|
||||
|
||||
let pattern = app
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
|
||||
let right_spans = vec![
|
||||
Span::styled(
|
||||
format!("L:{:02}", pattern.length),
|
||||
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("S:{}", pattern.speed.label()),
|
||||
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:.1} BPM", link.tempo()),
|
||||
Style::new().fg(Color::Magenta),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("V:{}", app.metrics.active_voices),
|
||||
Style::new().fg(Color::Cyan),
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
|
||||
right_area,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(Line::from(bottom_spans)), bottom_row);
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -128,16 +133,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
),
|
||||
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":nav "),
|
||||
Span::styled("t", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":toggle "),
|
||||
Span::styled("Enter", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":edit "),
|
||||
Span::styled("<>", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":len "),
|
||||
Span::styled("[]", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":spd "),
|
||||
Span::styled("Tab", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":focus "),
|
||||
Span::styled("s/l", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":save/load "),
|
||||
Span::styled("C-↑", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":patterns"),
|
||||
Span::raw(":save/load"),
|
||||
]),
|
||||
Page::Patterns => Line::from(vec![
|
||||
Span::styled(
|
||||
@@ -190,13 +195,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(footer, area);
|
||||
}
|
||||
|
||||
fn render_modal(frame: &mut Frame, app: &App) {
|
||||
let term = frame.area();
|
||||
fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
|
||||
match &app.ui.modal {
|
||||
Modal::None => {}
|
||||
Modal::ConfirmQuit { selected } => {
|
||||
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
|
||||
}
|
||||
Modal::ConfirmDeleteStep { step, selected, .. } => {
|
||||
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
|
||||
.render_centered(frame, term);
|
||||
}
|
||||
Modal::SaveAs(path) => {
|
||||
TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path)
|
||||
.width(60)
|
||||
@@ -246,5 +254,79 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
||||
.border_color(Color::Magenta)
|
||||
.render_centered(frame, term);
|
||||
}
|
||||
Modal::Editor => {
|
||||
let width = (term.width * 80 / 100).max(40);
|
||||
let height = (term.height * 60 / 100).max(10);
|
||||
let step_num = app.editor_ctx.step + 1;
|
||||
|
||||
let border_color = if app.ui.is_flashing() {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Rgb(100, 160, 180)
|
||||
};
|
||||
|
||||
let inner = ModalFrame::new(&format!("Step {step_num:02} Script"))
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let (cursor_row, cursor_col) = app.editor_ctx.text.cursor();
|
||||
|
||||
let lines: Vec<Line> = app
|
||||
.editor_ctx
|
||||
.text
|
||||
.lines()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, line)| {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
let tokens = highlight::highlight_line(line);
|
||||
|
||||
if row == cursor_row {
|
||||
let mut col = 0;
|
||||
for (style, text) in tokens {
|
||||
let text_len = text.chars().count();
|
||||
if cursor_col >= col && cursor_col < col + text_len {
|
||||
let before =
|
||||
text.chars().take(cursor_col - col).collect::<String>();
|
||||
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
|
||||
let after =
|
||||
text.chars().skip(cursor_col - col + 1).collect::<String>();
|
||||
|
||||
if !before.is_empty() {
|
||||
spans.push(Span::styled(before, style));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
cursor_char.to_string(),
|
||||
Style::default().bg(Color::White).fg(Color::Black),
|
||||
));
|
||||
if !after.is_empty() {
|
||||
spans.push(Span::styled(after, style));
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::styled(text, style));
|
||||
}
|
||||
col += text_len;
|
||||
}
|
||||
if cursor_col >= col {
|
||||
spans.push(Span::styled(
|
||||
" ",
|
||||
Style::default().bg(Color::White).fg(Color::Black),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
for (style, text) in tokens {
|
||||
spans.push(Span::styled(text, style));
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
seq/src/views/title_view.rs
Normal file
51
seq/src/views/title_view.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
pub fn render(frame: &mut Frame, area: Rect) {
|
||||
let title_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
|
||||
let subtitle_style = Style::new().fg(Color::White);
|
||||
let dim_style = Style::new()
|
||||
.fg(Color::Rgb(120, 125, 135))
|
||||
.add_modifier(Modifier::DIM);
|
||||
let link_style = Style::new().fg(Color::Rgb(100, 160, 180));
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("seq", title_style)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("A Forth Music Sequencer", subtitle_style)),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("by BuboBubo", dim_style)),
|
||||
Line::from(Span::styled("Raphael Maurice Forment", dim_style)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("https://raphaelforment.fr", link_style)),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("AGPL-3.0", dim_style)),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Press any key to continue",
|
||||
Style::new().fg(Color::DarkGray),
|
||||
)),
|
||||
];
|
||||
|
||||
let text_height = lines.len() as u16;
|
||||
let vertical_padding = area.height.saturating_sub(text_height) / 2;
|
||||
|
||||
let [_, center_area, _] = Layout::vertical([
|
||||
Constraint::Length(vertical_padding),
|
||||
Constraint::Length(text_height),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
|
||||
frame.render_widget(paragraph, center_area);
|
||||
}
|
||||
@@ -3,6 +3,10 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
const DB_MIN: f32 = -48.0;
|
||||
const DB_MAX: f32 = 3.0;
|
||||
const DB_RANGE: f32 = DB_MAX - DB_MIN;
|
||||
|
||||
pub struct VuMeter {
|
||||
left: f32,
|
||||
right: f32,
|
||||
@@ -13,10 +17,22 @@ impl VuMeter {
|
||||
Self { left, right }
|
||||
}
|
||||
|
||||
fn level_to_color(level: f32) -> Color {
|
||||
if level > 0.9 {
|
||||
fn amplitude_to_db(amp: f32) -> f32 {
|
||||
if amp <= 0.0 {
|
||||
DB_MIN
|
||||
} else {
|
||||
(20.0 * amp.log10()).clamp(DB_MIN, DB_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn db_to_normalized(db: f32) -> f32 {
|
||||
(db - DB_MIN) / DB_RANGE
|
||||
}
|
||||
|
||||
fn row_to_color(row_position: f32) -> Color {
|
||||
if row_position > 0.9 {
|
||||
Color::Red
|
||||
} else if level > 0.7 {
|
||||
} else if row_position > 0.75 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
@@ -26,40 +42,38 @@ impl VuMeter {
|
||||
|
||||
impl Widget for VuMeter {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width < 2 || area.height == 0 {
|
||||
if area.width < 3 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let height = area.height as usize;
|
||||
let left_col = area.x;
|
||||
let right_col = area.x + area.width - 1;
|
||||
let half_width = area.width / 2;
|
||||
let gap = 1u16;
|
||||
|
||||
let left_level = (self.left.clamp(0.0, 1.0) * height as f32) as usize;
|
||||
let right_level = (self.right.clamp(0.0, 1.0) * height as f32) as usize;
|
||||
let left_db = Self::amplitude_to_db(self.left);
|
||||
let right_db = Self::amplitude_to_db(self.right);
|
||||
let left_norm = Self::db_to_normalized(left_db);
|
||||
let right_norm = Self::db_to_normalized(right_db);
|
||||
|
||||
let left_rows = (left_norm * height as f32).round() as usize;
|
||||
let right_rows = (right_norm * height as f32).round() as usize;
|
||||
|
||||
for row in 0..height {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let level_at_row = (row as f32 + 0.5) / height as f32;
|
||||
let color = Self::level_to_color(level_at_row);
|
||||
let row_position = (row as f32 + 0.5) / height as f32;
|
||||
let color = Self::row_to_color(row_position);
|
||||
|
||||
if row < left_level {
|
||||
buf[(left_col, y)].set_char('█').set_fg(color);
|
||||
} else {
|
||||
buf[(left_col, y)].set_char('░').set_fg(Color::DarkGray);
|
||||
for col in 0..half_width.saturating_sub(gap) {
|
||||
let x = area.x + col;
|
||||
if row < left_rows {
|
||||
buf[(x, y)].set_char(' ').set_bg(color);
|
||||
}
|
||||
}
|
||||
|
||||
if row < right_level {
|
||||
buf[(right_col, y)].set_char('█').set_fg(color);
|
||||
} else {
|
||||
buf[(right_col, y)].set_char('░').set_fg(Color::DarkGray);
|
||||
}
|
||||
}
|
||||
|
||||
if area.width > 2 {
|
||||
for row in 0..height {
|
||||
let y = area.y + row as u16;
|
||||
for x in (area.x + 1)..(area.x + area.width - 1) {
|
||||
buf[(x, y)].set_char(' ');
|
||||
for col in 0..half_width.saturating_sub(gap) {
|
||||
let x = area.x + half_width + gap + col;
|
||||
if x < area.x + area.width && row < right_rows {
|
||||
buf[(x, y)].set_char(' ').set_bg(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user