Feat: prelude and new words

This commit is contained in:
2026-02-05 00:58:53 +01:00
parent b75b9562af
commit 53fb3eb759
21 changed files with 533 additions and 29 deletions

View File

@@ -18,8 +18,8 @@ use crate::page::Page;
use crate::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor};
use crate::settings::Settings;
use crate::state::{
AudioSettings, CyclicEnum, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics,
Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState,
};
@@ -164,8 +164,12 @@ impl App {
self.project_state.mark_all_dirty();
}
pub fn toggle_playing(&mut self) {
pub fn toggle_playing(&mut self, link: &LinkState) {
let was_playing = self.playback.playing;
self.playback.toggle();
if !was_playing && self.playback.playing {
self.evaluate_prelude(link);
}
}
pub fn tempo_up(&self, link: &LinkState) {
@@ -338,6 +342,58 @@ impl App {
self.project_state.mark_dirty(change.bank, change.pattern);
}
pub fn open_prelude_editor(&mut self) {
let prelude = &self.project_state.project.prelude;
let lines: Vec<String> = if prelude.is_empty() {
vec![String::new()]
} else {
prelude.lines().map(String::from).collect()
};
self.editor_ctx.editor.set_content(lines);
let candidates = model::WORDS
.iter()
.map(|w| cagire_ratatui::CompletionCandidate {
name: w.name.to_string(),
signature: w.stack.to_string(),
description: w.desc.to_string(),
example: w.example.to_string(),
})
.collect();
self.editor_ctx.editor.set_candidates(candidates);
self.editor_ctx
.editor
.set_completion_enabled(self.ui.show_completion);
self.editor_ctx.target = EditorTarget::Prelude;
self.ui.modal = Modal::Editor;
}
pub fn save_prelude(&mut self) {
let text = self.editor_ctx.editor.content();
self.project_state.project.prelude = text;
}
pub fn close_prelude_editor(&mut self) {
self.editor_ctx.target = EditorTarget::Step;
self.load_step_to_editor();
}
pub fn evaluate_prelude(&mut self, link: &LinkState) {
let prelude = &self.project_state.project.prelude;
if prelude.trim().is_empty() {
return;
}
let ctx = self.create_step_context(0, link);
match self.script_engine.evaluate(prelude, &ctx) {
Ok(_) => {
self.ui.flash("Prelude evaluated", 150, FlashKind::Info);
}
Err(e) => {
self.ui
.flash(&format!("Prelude error: {e}"), 300, FlashKind::Error);
}
}
}
pub fn execute_script_oneshot(
&self,
script: &str,
@@ -614,6 +670,8 @@ impl App {
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.evaluate_prelude(link);
for (bank, pattern) in playing {
self.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
@@ -862,7 +920,7 @@ impl App {
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
match cmd {
// Playback
AppCommand::TogglePlaying => self.toggle_playing(),
AppCommand::TogglePlaying => self.toggle_playing(link),
AppCommand::TempoUp => self.tempo_up(link),
AppCommand::TempoDown => self.tempo_down(link),
@@ -1278,6 +1336,12 @@ impl App {
FlashKind::Success,
);
}
// Prelude
AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
AppCommand::SavePrelude => self.save_prelude(),
AppCommand::EvaluatePrelude => self.evaluate_prelude(link),
AppCommand::ClosePreludeEditor => self.close_prelude_editor(),
}
}

View File

@@ -221,4 +221,10 @@ pub enum AppCommand {
steps: usize,
rotation: usize,
},
// Prelude
OpenPreludeEditor,
SavePrelude,
EvaluatePrelude,
ClosePreludeEditor,
}

View File

@@ -269,6 +269,8 @@ pub struct SequencerConfig {
pub audio_sample_pos: Arc<AtomicU64>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub cc_access: Option<Arc<dyn CcAccess>>,
pub variables: Variables,
pub dict: Dictionary,
#[cfg(feature = "desktop")]
pub mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")]
@@ -301,6 +303,8 @@ pub fn spawn_sequencer(
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
let shared_state_clone = Arc::clone(&shared_state);
let variables = config.variables;
let dict = config.dict;
#[cfg(feature = "desktop")]
let mouse_x = config.mouse_x;
#[cfg(feature = "desktop")]
@@ -335,6 +339,8 @@ pub fn spawn_sequencer(
config.audio_sample_pos,
config.sample_rate,
config.cc_access,
variables,
dict,
#[cfg(feature = "desktop")]
mouse_x,
#[cfg(feature = "desktop")]
@@ -529,6 +535,7 @@ pub(crate) struct SequencerState {
event_count: usize,
script_engine: ScriptEngine,
variables: Variables,
dict: Dictionary,
speed_overrides: HashMap<(usize, usize), f64>,
key_cache: KeyCache,
buf_audio_commands: Vec<TimestampedCommand>,
@@ -547,7 +554,7 @@ impl SequencerState {
rng: Rng,
cc_access: Option<Arc<dyn CcAccess>>,
) -> Self {
let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng);
let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), rng);
Self {
audio_state: AudioState::new(),
pattern_cache: PatternCache::new(),
@@ -557,6 +564,7 @@ impl SequencerState {
event_count: 0,
script_engine,
variables,
dict,
speed_overrides: HashMap::with_capacity(MAX_PATTERNS),
key_cache: KeyCache::new(),
buf_audio_commands: Vec::with_capacity(32),
@@ -663,12 +671,9 @@ impl SequencerState {
self.audio_state.flush_midi_notes = true;
}
SeqCommand::ResetScriptState => {
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
self.script_engine =
ScriptEngine::new(Arc::clone(&variables), dict, rng);
self.variables = variables;
// Clear shared state instead of replacing - preserves sharing with app
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.speed_overrides.clear();
}
SeqCommand::Shutdown => {}
@@ -1070,6 +1075,8 @@ fn sequencer_loop(
audio_sample_pos: Arc<AtomicU64>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
cc_access: Option<Arc<dyn CcAccess>>,
variables: Variables,
dict: Dictionary,
#[cfg(feature = "desktop")] mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_y: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_down: Arc<AtomicU32>,
@@ -1078,8 +1085,6 @@ fn sequencer_loop(
set_realtime_priority();
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);

View File

@@ -133,6 +133,8 @@ pub fn init(args: InitArgs) -> Init {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc<dyn model::CcAccess>),
variables: Arc::clone(&app.variables),
dict: Arc::clone(&app.dict),
#[cfg(feature = "desktop")]
mouse_x: Arc::clone(&mouse_x),
#[cfg(feature = "desktop")]

View File

@@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
use crate::model::PatternSpeed;
use crate::page::Page;
use crate::state::{
CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus,
PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, OptionsFocus,
PanelFocus, PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
};
pub enum InputResult {
@@ -488,14 +488,31 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} else if editor.completion_active() {
editor.dismiss_completion();
} else {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
match ctx.app.editor_ctx.target {
EditorTarget::Step => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
ctx.dispatch(AppCommand::ClosePreludeEditor);
}
}
ctx.dispatch(AppCommand::CloseModal);
}
}
KeyCode::Char('e') if ctrl => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
match ctx.app.editor_ctx.target {
EditorTarget::Step => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
}
}
}
KeyCode::Char('f') if ctrl => {
editor.activate_search();
@@ -1082,6 +1099,12 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('d') => {
ctx.dispatch(AppCommand::OpenPreludeEditor);
}
KeyCode::Char('D') => {
ctx.dispatch(AppCommand::EvaluatePrelude);
}
_ => {}
}
InputResult::Continue

View File

@@ -27,6 +27,7 @@ pub const DOCS: &[DocEntry] = &[
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Creating Words", include_str!("../../docs/definitions.md")),
Topic("The Prelude", include_str!("../../docs/prelude.md")),
Topic("Oddities", include_str!("../../docs/oddities.md")),
// Audio Engine
Section("Audio Engine"),

View File

@@ -3,6 +3,13 @@ use std::ops::RangeInclusive;
use cagire_ratatui::Editor;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum EditorTarget {
#[default]
Step,
Prelude,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PatternField {
Length,
@@ -76,6 +83,7 @@ pub struct EditorContext {
pub copied_steps: Option<CopiedSteps>,
pub show_stack: bool,
pub stack_cache: RefCell<Option<StackCache>>,
pub target: EditorTarget,
}
#[derive(Clone)]
@@ -125,6 +133,7 @@ impl Default for EditorContext {
copied_steps: None,
show_stack: false,
stack_cache: RefCell::new(None),
target: EditorTarget::default(),
}
}
}

View File

@@ -32,8 +32,8 @@ pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
pub use color_scheme::ColorScheme;
pub use editor::{
CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField,
StackCache,
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
PatternPropsField, StackCache,
};
pub use live_keys::LiveKeyState;
pub use modal::Modal;

View File

@@ -37,6 +37,12 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
bindings.push(("m", "Mute", "Stage mute for current pattern"));
bindings.push(("x", "Solo", "Stage solo for current pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("d", "Prelude", "Edit prelude script"));
bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
@@ -46,6 +52,10 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("c", "Commit", "Commit staged changes"));
bindings.push(("r", "Rename", "Rename bank/pattern"));
bindings.push(("e", "Properties", "Edit pattern properties"));
bindings.push(("m", "Mute", "Stage mute for pattern"));
bindings.push(("x", "Solo", "Stage solo for pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
bindings.push(("Del", "Reset", "Reset bank/pattern"));

View File

@@ -11,7 +11,7 @@ use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::SourceSpan;
use crate::page::Page;
use crate::state::{
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel,
EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel,
};
use crate::theme;
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
@@ -629,8 +629,6 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, 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 step = app.current_edit_pattern().step(app.editor_ctx.step);
let flash_kind = app.ui.flash_kind();
let border_color = match flash_kind {
@@ -640,10 +638,17 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
None => theme.modal.editor,
};
let title = if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
format!("Step {step_num:02}: {name}")
} else {
format!("Step {step_num:02} Script")
let title = match app.editor_ctx.target {
EditorTarget::Prelude => "Prelude".to_string(),
EditorTarget::Step => {
let step_num = app.editor_ctx.step + 1;
let step = app.current_edit_pattern().step(app.editor_ctx.step);
if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
format!("Step {step_num:02}: {name}")
} else {
format!("Step {step_num:02} Script")
}
}
};
let inner = ModalFrame::new(&title)
@@ -652,7 +657,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.border_color(border_color)
.render_centered(frame, term);
let trace = if app.ui.runtime_highlight && app.playback.playing {
let trace = if app.ui.runtime_highlight
&& app.playback.playing
&& app.editor_ctx.target == EditorTarget::Step
{
let source = app
.current_edit_pattern()
.resolve_source(app.editor_ctx.step);