Feat: add hidden mode and new documentation
Some checks failed
Deploy Website / deploy (push) Failing after 29s
Some checks failed
Deploy Website / deploy (push) Failing after 29s
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
//! Clipboard operations on steps, patterns, and banks.
|
||||
|
||||
use crate::model;
|
||||
use crate::services::clipboard;
|
||||
use crate::state::FlashKind;
|
||||
@@ -273,6 +275,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert linked steps into independent copies.
|
||||
pub fn harden_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices = self.selected_steps();
|
||||
@@ -342,6 +345,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
/// Paste steps as linked references to the originals.
|
||||
pub fn link_paste_steps(&mut self) {
|
||||
let Some(copied) = self.editor_ctx.copied_steps.take() else {
|
||||
self.ui.set_status("Nothing copied".to_string());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::model::bp_label;
|
||||
use crate::page::Page;
|
||||
use crate::services::{dict_nav, euclidean, help_nav, pattern_editor};
|
||||
use crate::state::{undo::UndoEntry, FlashKind, Modal, StagedPropChange};
|
||||
|
||||
@@ -215,23 +216,33 @@ impl App {
|
||||
|
||||
// Page navigation
|
||||
AppCommand::PageLeft => {
|
||||
self.auto_save_script_on_leave();
|
||||
self.page.left();
|
||||
self.auto_load_script_on_arrive();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageRight => {
|
||||
self.auto_save_script_on_leave();
|
||||
self.page.right();
|
||||
self.auto_load_script_on_arrive();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageUp => {
|
||||
self.auto_save_script_on_leave();
|
||||
self.page.up();
|
||||
self.auto_load_script_on_arrive();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageDown => {
|
||||
self.auto_save_script_on_leave();
|
||||
self.page.down();
|
||||
self.auto_load_script_on_arrive();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::GoToPage(page) => {
|
||||
self.auto_save_script_on_leave();
|
||||
self.page = page;
|
||||
self.auto_load_script_on_arrive();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
|
||||
@@ -464,6 +475,35 @@ impl App {
|
||||
AppCommand::SavePrelude => self.save_prelude(),
|
||||
AppCommand::EvaluatePrelude => self.evaluate_prelude(link),
|
||||
AppCommand::ClosePreludeEditor => self.close_prelude_editor(),
|
||||
|
||||
// Periodic script
|
||||
AppCommand::OpenScriptModal(field) => self.open_script_modal(field),
|
||||
AppCommand::SetScriptSpeed(speed) => {
|
||||
self.project_state.project.script_speed = speed;
|
||||
self.script_editor.dirty = true;
|
||||
}
|
||||
AppCommand::SetScriptLength(len) => {
|
||||
self.project_state.project.script_length = len.clamp(1, 256);
|
||||
self.script_editor.dirty = true;
|
||||
}
|
||||
AppCommand::ScriptSave => self.save_script_from_editor(),
|
||||
AppCommand::ScriptEvaluate => self.evaluate_script_page(link),
|
||||
AppCommand::ToggleScriptStack => {
|
||||
self.script_editor.show_stack = !self.script_editor.show_stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_save_script_on_leave(&mut self) {
|
||||
if self.page == Page::Script {
|
||||
self.save_script_from_editor();
|
||||
self.script_editor.focused = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_load_script_on_arrive(&mut self) {
|
||||
if self.page == Page::Script {
|
||||
self.load_script_to_editor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Pattern and step editing operations (toggle, length, speed, delete, reset).
|
||||
|
||||
use crate::services::pattern_editor;
|
||||
use crate::state::FlashKind;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::page::Page;
|
||||
use crate::state::{
|
||||
undo::UndoHistory, AudioSettings, EditorContext, LiveKeyState, Metrics, Modal, MuteState,
|
||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
||||
ProjectState, UiState,
|
||||
ProjectState, ScriptEditorState, UiState,
|
||||
};
|
||||
|
||||
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
||||
@@ -49,6 +49,7 @@ pub struct App {
|
||||
|
||||
pub page: Page,
|
||||
pub editor_ctx: EditorContext,
|
||||
pub script_editor: ScriptEditorState,
|
||||
|
||||
pub patterns_nav: PatternsNav,
|
||||
|
||||
@@ -104,6 +105,7 @@ impl App {
|
||||
|
||||
page: Page::default(),
|
||||
editor_ctx: EditorContext::default(),
|
||||
script_editor: ScriptEditorState::default(),
|
||||
|
||||
patterns_nav: PatternsNav::default(),
|
||||
|
||||
@@ -169,6 +171,18 @@ impl App {
|
||||
self.project_state.project.pattern_at(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn open_script_modal(&mut self, field: crate::state::ScriptField) {
|
||||
use crate::state::ScriptField;
|
||||
let current = match field {
|
||||
ScriptField::Speed => self.project_state.project.script_speed.label().to_string(),
|
||||
ScriptField::Length => self.project_state.project.script_length.to_string(),
|
||||
};
|
||||
self.ui.modal = Modal::SetScript {
|
||||
field,
|
||||
input: current,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
||||
let current = match field {
|
||||
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Step and bank/pattern cursor navigation.
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Project and settings save/load.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -10,6 +12,7 @@ use crate::state::StagedChange;
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// Persist user preferences (audio, display, link, MIDI) via confy.
|
||||
pub fn save_settings(&self, link: &LinkState) {
|
||||
let settings = Settings {
|
||||
audio: crate::settings::AudioSettings {
|
||||
@@ -72,8 +75,12 @@ impl App {
|
||||
settings.save();
|
||||
}
|
||||
|
||||
/// Flush the editor, capture playing state, and write the project file.
|
||||
pub fn save(&mut self, path: PathBuf, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
self.save_editor_to_step();
|
||||
if self.page == crate::page::Page::Script {
|
||||
self.save_script_from_editor();
|
||||
}
|
||||
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
||||
self.project_state.project.tempo = link.tempo();
|
||||
self.project_state.project.playing_patterns = snapshot
|
||||
@@ -105,6 +112,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the current project, reset undo/variables, recompile, and restore playing patterns.
|
||||
fn apply_project(&mut self, project: model::Project, label: String, link: &LinkState) {
|
||||
let tempo = project.tempo;
|
||||
let playing = project.playing_patterns.clone();
|
||||
@@ -122,6 +130,8 @@ impl App {
|
||||
self.dict.lock().clear();
|
||||
|
||||
self.evaluate_prelude(link);
|
||||
self.load_script_to_editor();
|
||||
self.script_editor.dirty = true;
|
||||
|
||||
for (bank, pattern) in playing {
|
||||
self.playback.queued_changes.push(StagedChange {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Forth script compilation, evaluation, and editor ↔ step synchronization.
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
|
||||
use crate::engine::LinkState;
|
||||
@@ -8,6 +10,7 @@ use crate::state::{EditorTarget, FlashKind, Modal, SampleTree};
|
||||
use super::{App, COMPLETION_CANDIDATES};
|
||||
|
||||
impl App {
|
||||
/// Build a `StepContext` for evaluating a script outside the sequencer.
|
||||
pub(super) fn create_step_context(&self, step_idx: usize, link: &LinkState) -> StepContext<'static> {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let speed = self
|
||||
@@ -37,6 +40,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the current step's script into the editor widget.
|
||||
pub(super) fn load_step_to_editor(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
if let Some(script) = pattern_editor::get_step_script(
|
||||
@@ -63,6 +67,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the editor widget's content back to the current step.
|
||||
pub fn save_editor_to_step(&mut self) {
|
||||
let text = self.editor_ctx.editor.content();
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
@@ -76,6 +81,7 @@ impl App {
|
||||
self.project_state.mark_dirty(change.bank, change.pattern);
|
||||
}
|
||||
|
||||
/// Switch the editor to the project prelude script.
|
||||
pub fn open_prelude_editor(&mut self) {
|
||||
let prelude = &self.project_state.project.prelude;
|
||||
let lines: Vec<String> = if prelude.is_empty() {
|
||||
@@ -104,6 +110,7 @@ impl App {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
|
||||
/// Evaluate the project prelude to seed variables and definitions.
|
||||
pub fn evaluate_prelude(&mut self, link: &LinkState) {
|
||||
let prelude = &self.project_state.project.prelude;
|
||||
if prelude.trim().is_empty() {
|
||||
@@ -121,6 +128,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a script and immediately send its audio commands.
|
||||
pub fn execute_script_oneshot(
|
||||
&self,
|
||||
script: &str,
|
||||
@@ -137,6 +145,7 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compile (evaluate) the current step's script to check for errors.
|
||||
pub fn compile_current_step(&mut self, link: &LinkState) {
|
||||
let step_idx = self.editor_ctx.step;
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
@@ -162,6 +171,49 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the project's periodic script into the script editor.
|
||||
pub fn load_script_to_editor(&mut self) {
|
||||
let script = &self.project_state.project.script;
|
||||
let lines: Vec<String> = if script.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
script.lines().map(String::from).collect()
|
||||
};
|
||||
self.script_editor.editor.set_content(lines);
|
||||
self.script_editor.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
||||
self.script_editor
|
||||
.editor
|
||||
.set_completion_enabled(self.ui.show_completion);
|
||||
let tree = SampleTree::from_paths(&self.audio.config.sample_paths);
|
||||
self.script_editor.editor.set_sample_folders(tree.all_folder_names());
|
||||
}
|
||||
|
||||
/// Write the script editor content back to the project.
|
||||
pub fn save_script_from_editor(&mut self) {
|
||||
let text = self.script_editor.editor.content();
|
||||
self.project_state.project.script = text;
|
||||
self.script_editor.dirty = true;
|
||||
}
|
||||
|
||||
/// Evaluate the script page content to check for errors.
|
||||
pub fn evaluate_script_page(&mut self, link: &LinkState) {
|
||||
let script = self.script_editor.editor.content();
|
||||
if script.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = self.create_step_context(0, link);
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
Ok(_) => {
|
||||
self.ui.flash("Script compiled", 150, FlashKind::Info);
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Script error: {e}"), 300, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile all steps in the current pattern to warm up definitions.
|
||||
pub fn compile_all_steps(&mut self, link: &LinkState) {
|
||||
let pattern_len = self.current_edit_pattern().length;
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Sends pattern data, mute state, and queued start/stop changes to the sequencer thread.
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
|
||||
use crate::engine::{PatternChange, PatternSnapshot, SeqCommand, StepSnapshot};
|
||||
@@ -5,6 +7,7 @@ use crate::engine::{PatternChange, PatternSnapshot, SeqCommand, StepSnapshot};
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// Drain staged start/stop changes and send them to the sequencer.
|
||||
pub fn flush_queued_changes(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
||||
for staged in self.playback.queued_changes.drain(..) {
|
||||
match staged.change {
|
||||
@@ -34,6 +37,19 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
/// Send the periodic script to the sequencer if dirty.
|
||||
pub fn flush_dirty_script(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
||||
if self.script_editor.dirty {
|
||||
self.script_editor.dirty = false;
|
||||
let _ = cmd_tx.send(SeqCommand::ScriptUpdate {
|
||||
script: self.project_state.project.script.clone(),
|
||||
speed: self.project_state.project.script_speed,
|
||||
length: self.project_state.project.script_length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot and send all dirty patterns to the sequencer. Returns true if any were sent.
|
||||
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) -> bool {
|
||||
let dirty = self.project_state.take_dirty();
|
||||
let had_dirty = !dirty.is_empty();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Staging area for pattern start/stop, mute/solo, and prop changes before commit.
|
||||
|
||||
use crate::engine::{PatternChange, SequencerSnapshot};
|
||||
use crate::model::bp_label;
|
||||
use crate::state::StagedChange;
|
||||
@@ -5,6 +7,7 @@ use crate::state::StagedChange;
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// Toggle a pattern's staged state: unstage if already staged, else stage start or stop.
|
||||
pub fn stage_pattern_toggle(
|
||||
&mut self,
|
||||
bank: usize,
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::page::Page;
|
||||
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind};
|
||||
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
|
||||
|
||||
pub enum AppCommand {
|
||||
// Undo/Redo
|
||||
@@ -302,4 +302,12 @@ pub enum AppCommand {
|
||||
DismissOnboarding,
|
||||
ResetOnboarding,
|
||||
GoToHelpTopic(usize),
|
||||
|
||||
// Periodic script
|
||||
OpenScriptModal(ScriptField),
|
||||
SetScriptSpeed(PatternSpeed),
|
||||
SetScriptLength(usize),
|
||||
ScriptSave,
|
||||
ScriptEvaluate,
|
||||
ToggleScriptStack,
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ pub enum SeqCommand {
|
||||
muted: std::collections::HashSet<(usize, usize)>,
|
||||
soloed: std::collections::HashSet<(usize, usize)>,
|
||||
},
|
||||
ScriptUpdate {
|
||||
script: String,
|
||||
speed: crate::model::PatternSpeed,
|
||||
length: usize,
|
||||
},
|
||||
StopAll,
|
||||
ResetScriptState,
|
||||
Shutdown,
|
||||
@@ -166,6 +171,7 @@ pub struct SharedSequencerState {
|
||||
pub event_count: usize,
|
||||
pub tempo: f64,
|
||||
pub beat: f64,
|
||||
pub script_trace: Option<ExecutionTrace>,
|
||||
}
|
||||
|
||||
pub struct SequencerSnapshot {
|
||||
@@ -174,6 +180,7 @@ pub struct SequencerSnapshot {
|
||||
pub event_count: usize,
|
||||
pub tempo: f64,
|
||||
pub beat: f64,
|
||||
script_trace: Option<ExecutionTrace>,
|
||||
}
|
||||
|
||||
impl From<&SharedSequencerState> for SequencerSnapshot {
|
||||
@@ -184,6 +191,7 @@ impl From<&SharedSequencerState> for SequencerSnapshot {
|
||||
event_count: s.event_count,
|
||||
tempo: s.tempo,
|
||||
beat: s.beat,
|
||||
script_trace: s.script_trace.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +205,7 @@ impl SequencerSnapshot {
|
||||
event_count: 0,
|
||||
tempo: 0.0,
|
||||
beat: 0.0,
|
||||
script_trace: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +245,10 @@ impl SequencerSnapshot {
|
||||
pub fn get_trace(&self, bank: usize, pattern: usize, step: usize) -> Option<&ExecutionTrace> {
|
||||
self.step_traces.get(&(bank, pattern, step))
|
||||
}
|
||||
|
||||
pub fn script_trace(&self) -> Option<&ExecutionTrace> {
|
||||
self.script_trace.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SequencerHandle {
|
||||
@@ -555,6 +568,12 @@ pub struct SequencerState {
|
||||
soloed: std::collections::HashSet<(usize, usize)>,
|
||||
last_tempo: f64,
|
||||
last_beat: f64,
|
||||
script_text: String,
|
||||
script_speed: crate::model::PatternSpeed,
|
||||
script_length: usize,
|
||||
script_frontier: f64,
|
||||
script_step: usize,
|
||||
script_trace: Option<ExecutionTrace>,
|
||||
}
|
||||
|
||||
impl SequencerState {
|
||||
@@ -586,6 +605,12 @@ impl SequencerState {
|
||||
soloed: std::collections::HashSet::new(),
|
||||
last_tempo: 0.0,
|
||||
last_beat: 0.0,
|
||||
script_text: String::new(),
|
||||
script_speed: crate::model::PatternSpeed::default(),
|
||||
script_length: 16,
|
||||
script_frontier: -1.0,
|
||||
script_step: 0,
|
||||
script_trace: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +695,11 @@ impl SequencerState {
|
||||
self.audio_state.flush_midi_notes = true;
|
||||
}
|
||||
}
|
||||
SeqCommand::ScriptUpdate { script, speed, length } => {
|
||||
self.script_text = script;
|
||||
self.script_speed = speed;
|
||||
self.script_length = length;
|
||||
}
|
||||
SeqCommand::StopAll => {
|
||||
// Flush pending updates so cache stays current for future launches
|
||||
for ((bank, pattern), snapshot) in self.pending_updates.drain() {
|
||||
@@ -728,6 +758,20 @@ impl SequencerState {
|
||||
input.mouse_down,
|
||||
);
|
||||
|
||||
self.execute_periodic_script(
|
||||
input.beat,
|
||||
frontier,
|
||||
lookahead_end,
|
||||
input.tempo,
|
||||
input.quantum,
|
||||
input.fill,
|
||||
input.nudge_secs,
|
||||
input.engine_time,
|
||||
input.mouse_x,
|
||||
input.mouse_y,
|
||||
input.mouse_down,
|
||||
);
|
||||
|
||||
let new_tempo = self.read_tempo_variable(steps.any_step_fired);
|
||||
self.apply_follow_ups();
|
||||
|
||||
@@ -754,6 +798,9 @@ impl SequencerState {
|
||||
}
|
||||
}
|
||||
self.audio_state.prev_beat = -1.0;
|
||||
self.script_frontier = -1.0;
|
||||
self.script_step = 0;
|
||||
self.script_trace = None;
|
||||
self.buf_audio_commands.clear();
|
||||
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
||||
TickOutput {
|
||||
@@ -985,6 +1032,87 @@ impl SequencerState {
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute_periodic_script(
|
||||
&mut self,
|
||||
beat: f64,
|
||||
frontier: f64,
|
||||
lookahead_end: f64,
|
||||
tempo: f64,
|
||||
quantum: f64,
|
||||
fill: bool,
|
||||
nudge_secs: f64,
|
||||
engine_time: f64,
|
||||
mouse_x: f64,
|
||||
mouse_y: f64,
|
||||
mouse_down: f64,
|
||||
) {
|
||||
if self.script_text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let script_frontier = if self.script_frontier < 0.0 {
|
||||
frontier.max(0.0)
|
||||
} else {
|
||||
self.script_frontier
|
||||
};
|
||||
|
||||
let speed_mult = self.script_speed.multiplier();
|
||||
let fire_beats = substeps_in_window(script_frontier, lookahead_end, speed_mult);
|
||||
|
||||
for step_beat in fire_beats {
|
||||
let beat_delta = step_beat - beat;
|
||||
let time_delta = if tempo > 0.0 {
|
||||
(beat_delta / tempo) * 60.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let event_time = Some(engine_time + time_delta);
|
||||
|
||||
let step_in_cycle = self.script_step % self.script_length;
|
||||
|
||||
if step_in_cycle == 0 {
|
||||
let ctx = StepContext {
|
||||
step: 0,
|
||||
beat: step_beat,
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
tempo,
|
||||
phase: step_beat % quantum,
|
||||
slot: 0,
|
||||
runs: self.script_step / self.script_length,
|
||||
iter: self.script_step / self.script_length,
|
||||
speed: speed_mult,
|
||||
fill,
|
||||
nudge_secs,
|
||||
cc_access: self.cc_access.as_deref(),
|
||||
speed_key: "",
|
||||
mouse_x,
|
||||
mouse_y,
|
||||
mouse_down,
|
||||
};
|
||||
|
||||
let mut trace = ExecutionTrace::default();
|
||||
if let Ok(cmds) =
|
||||
self.script_engine
|
||||
.evaluate_with_trace(&self.script_text, &ctx, &mut trace)
|
||||
{
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.script_trace = Some(trace);
|
||||
}
|
||||
self.script_step += 1;
|
||||
}
|
||||
|
||||
self.script_frontier = lookahead_end;
|
||||
}
|
||||
|
||||
fn read_tempo_variable(&self, any_step_fired: bool) -> Option<f64> {
|
||||
if !any_step_fired {
|
||||
return None;
|
||||
@@ -1056,6 +1184,7 @@ impl SequencerState {
|
||||
event_count: self.event_count,
|
||||
tempo: self.last_tempo,
|
||||
beat: self.last_beat,
|
||||
script_trace: self.script_trace.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod mouse;
|
||||
pub(crate) mod options_page;
|
||||
mod panel;
|
||||
mod patterns_page;
|
||||
mod script_page;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::Sender;
|
||||
@@ -85,6 +86,7 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
|
||||
match (key.code, key.kind) {
|
||||
_ if !matches!(ctx.app.ui.modal, Modal::None) => false,
|
||||
_ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => false,
|
||||
(KeyCode::Char('f'), KeyEventKind::Press) => {
|
||||
ctx.dispatch(AppCommand::ToggleLiveKeysFill);
|
||||
true
|
||||
@@ -134,6 +136,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::F(4) => Some(Page::Help),
|
||||
KeyCode::F(5) => Some(Page::Main),
|
||||
KeyCode::F(6) => Some(Page::Engine),
|
||||
KeyCode::F(7) => Some(Page::Script),
|
||||
_ => None,
|
||||
} {
|
||||
ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250));
|
||||
@@ -148,6 +151,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
Page::Options => options_page::handle_options_page(ctx, key),
|
||||
Page::Help => help_page::handle_help_page(ctx, key),
|
||||
Page::Dict => help_page::handle_dict_page(ctx, key),
|
||||
Page::Script => script_page::handle_script_page(ctx, key),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::engine::SeqCommand;
|
||||
use crate::model::{FollowUp, PatternSpeed};
|
||||
use crate::state::{
|
||||
ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField,
|
||||
PatternPropsField, RenameTarget,
|
||||
PatternPropsField, RenameTarget, ScriptField,
|
||||
};
|
||||
|
||||
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
@@ -141,6 +141,44 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
KeyCode::Char(c) => input.push(c),
|
||||
_ => {}
|
||||
},
|
||||
Modal::SetScript { field, input } => match key.code {
|
||||
KeyCode::Enter => {
|
||||
let field = *field;
|
||||
match field {
|
||||
ScriptField::Length => {
|
||||
if let Ok(len) = input.parse::<usize>() {
|
||||
ctx.dispatch(AppCommand::SetScriptLength(len));
|
||||
let new_len = ctx.app.project_state.project.script_length;
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"Script length set to {new_len}"
|
||||
)));
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::SetStatus("Invalid length".to_string()));
|
||||
}
|
||||
}
|
||||
ScriptField::Speed => {
|
||||
if let Some(speed) = PatternSpeed::from_label(input) {
|
||||
ctx.dispatch(AppCommand::SetScriptSpeed(speed));
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"Script speed set to {}",
|
||||
speed.label()
|
||||
)));
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::SetStatus(
|
||||
"Invalid speed (try 1/3, 2/5, 1x, 2x)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
}
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
KeyCode::Backspace => {
|
||||
input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => input.push(c),
|
||||
_ => {}
|
||||
},
|
||||
Modal::JumpToStep(input) => match key.code {
|
||||
KeyCode::Enter => {
|
||||
if let Ok(step) = input.parse::<usize>() {
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::state::{
|
||||
DeviceKind, DictFocus, EngineSection, HelpFocus, MinimapMode, Modal, OptionsFocus,
|
||||
PatternsColumn, SettingKind,
|
||||
};
|
||||
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view};
|
||||
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, script_view};
|
||||
|
||||
use super::InputContext;
|
||||
|
||||
@@ -28,9 +28,11 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
||||
MouseEventKind::Down(MouseButton::Left) => handle_click(ctx, col, row, term),
|
||||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
|
||||
handle_editor_drag(ctx, col, row, term);
|
||||
handle_script_editor_drag(ctx, col, row, term);
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
ctx.app.editor_ctx.mouse_selecting = false;
|
||||
ctx.app.script_editor.mouse_selecting = false;
|
||||
}
|
||||
MouseEventKind::ScrollUp => handle_scroll(ctx, col, row, term, true),
|
||||
MouseEventKind::ScrollDown => handle_scroll(ctx, col, row, term, false),
|
||||
@@ -176,6 +178,14 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
||||
ctx.dispatch(AppCommand::StepDown);
|
||||
}
|
||||
}
|
||||
Page::Script => {
|
||||
let [editor_area, _] = script_view::layout(body);
|
||||
if contains(editor_area, col, row) {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let code = if up { KeyCode::Up } else { KeyCode::Down };
|
||||
ctx.app.script_editor.editor.input(KeyEvent::new(code, KeyModifiers::empty()));
|
||||
}
|
||||
}
|
||||
Page::Help => {
|
||||
let [topics_area, content_area] = help_view::layout(body);
|
||||
if contains(topics_area, col, row) {
|
||||
@@ -305,6 +315,7 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect)
|
||||
Page::Options => " OPTIONS ",
|
||||
Page::Help => " HELP ",
|
||||
Page::Dict => " DICT ",
|
||||
Page::Script => " SCRIPT ",
|
||||
};
|
||||
let badge_end = block_inner.x + badge_text.len() as u16;
|
||||
if col < badge_end {
|
||||
@@ -345,6 +356,7 @@ fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect) {
|
||||
Page::Dict => handle_dict_click(ctx, col, row, page_area),
|
||||
Page::Options => handle_options_click(ctx, col, row, page_area),
|
||||
Page::Engine => handle_engine_click(ctx, col, row, page_area),
|
||||
Page::Script => handle_script_click(ctx, col, row, page_area),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +709,84 @@ fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect)
|
||||
|
||||
// --- Engine page ---
|
||||
|
||||
fn handle_script_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
||||
let [editor_area, _] = script_view::layout(area);
|
||||
if contains(editor_area, col, row) {
|
||||
ctx.app.script_editor.focused = true;
|
||||
handle_script_editor_mouse(ctx, col, row, area, false);
|
||||
} else {
|
||||
ctx.app.script_editor.focused = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn script_editor_text_area(area: Rect) -> Rect {
|
||||
let [editor_area, _] = script_view::layout(area);
|
||||
// Block with borders → inner
|
||||
let inner = Rect {
|
||||
x: editor_area.x + 1,
|
||||
y: editor_area.y + 1,
|
||||
width: editor_area.width.saturating_sub(2),
|
||||
height: editor_area.height.saturating_sub(2),
|
||||
};
|
||||
// Editor takes all but last row (hint line)
|
||||
let editor_height = inner.height.saturating_sub(1);
|
||||
Rect::new(inner.x, inner.y, inner.width, editor_height)
|
||||
}
|
||||
|
||||
fn handle_script_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
|
||||
if ctx.app.script_editor.mouse_selecting {
|
||||
let padded = padded(term);
|
||||
let (_header, body, _footer) = top_level_layout(padded);
|
||||
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
||||
if body.width >= 120 {
|
||||
let panel_width = body.width * 35 / 100;
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
||||
.split(body)[0]
|
||||
} else {
|
||||
let panel_height = body.height * 40 / 100;
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
||||
.split(body)[0]
|
||||
}
|
||||
} else {
|
||||
body
|
||||
};
|
||||
handle_script_editor_mouse(ctx, col, row, page_area, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_script_editor_mouse(
|
||||
ctx: &mut InputContext,
|
||||
col: u16,
|
||||
row: u16,
|
||||
area: Rect,
|
||||
dragging: bool,
|
||||
) {
|
||||
let text_area = script_editor_text_area(area);
|
||||
|
||||
if col < text_area.x
|
||||
|| col >= text_area.x + text_area.width
|
||||
|| row < text_area.y
|
||||
|| row >= text_area.y + text_area.height
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let scroll = ctx.app.script_editor.editor.scroll_offset();
|
||||
let text_row = (row - text_area.y) + scroll;
|
||||
let text_col = col - text_area.x;
|
||||
|
||||
if dragging {
|
||||
if !ctx.app.script_editor.editor.is_selecting() {
|
||||
ctx.app.script_editor.editor.start_selection();
|
||||
}
|
||||
} else {
|
||||
ctx.app.script_editor.mouse_selecting = true;
|
||||
ctx.app.script_editor.editor.cancel_selection();
|
||||
}
|
||||
|
||||
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
|
||||
}
|
||||
|
||||
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
||||
let [left_col, _, _] = engine_view::layout(area);
|
||||
|
||||
|
||||
69
src/input/script_page.rs
Normal file
69
src/input/script_page.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::{ConfirmAction, Modal, ScriptField};
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
|
||||
pub fn handle_script_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
if ctx.app.script_editor.focused {
|
||||
handle_focused(ctx, key)
|
||||
} else {
|
||||
handle_unfocused(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_focused(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
match (ctrl, key.code) {
|
||||
(_, KeyCode::Esc) => {
|
||||
ctx.dispatch(AppCommand::ScriptSave);
|
||||
ctx.app.script_editor.focused = false;
|
||||
}
|
||||
(true, KeyCode::Char('e')) => {
|
||||
ctx.dispatch(AppCommand::ScriptSave);
|
||||
ctx.dispatch(AppCommand::ScriptEvaluate);
|
||||
}
|
||||
(true, KeyCode::Char('s')) => {
|
||||
ctx.dispatch(AppCommand::ToggleScriptStack);
|
||||
}
|
||||
_ => {
|
||||
ctx.app.script_editor.editor.input(key);
|
||||
}
|
||||
}
|
||||
InputResult::Continue
|
||||
}
|
||||
|
||||
fn handle_unfocused(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
ctx.app.script_editor.focused = true;
|
||||
}
|
||||
KeyCode::Char('q') if !ctx.app.plugin_mode => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
||||
action: ConfirmAction::Quit,
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char(' ') if !ctx.app.plugin_mode => {
|
||||
ctx.dispatch(AppCommand::TogglePlaying);
|
||||
ctx.playing
|
||||
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
||||
}
|
||||
KeyCode::Char('s') => super::open_save(ctx),
|
||||
KeyCode::Char('l') => super::open_load(ctx),
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
KeyCode::Char('L') => {
|
||||
ctx.dispatch(AppCommand::OpenScriptModal(ScriptField::Length));
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
ctx.dispatch(AppCommand::OpenScriptModal(ScriptField::Speed));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
}
|
||||
@@ -281,6 +281,7 @@ fn main() -> io::Result<()> {
|
||||
app.metrics.event_count = seq_snapshot.event_count;
|
||||
|
||||
app.flush_dirty_patterns(&sequencer.cmd_tx);
|
||||
app.flush_dirty_script(&sequencer.cmd_tx);
|
||||
app.flush_queued_changes(&sequencer.cmd_tx);
|
||||
|
||||
let had_event = event::poll(Duration::from_millis(
|
||||
@@ -322,6 +323,8 @@ fn main() -> io::Result<()> {
|
||||
if app.editor_ctx.show_stack {
|
||||
services::stack_preview::update_cache(&app.editor_ctx);
|
||||
}
|
||||
} else if app.page == page::Page::Script && app.script_editor.focused {
|
||||
app.script_editor.editor.insert_str(&text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -92,5 +92,14 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Script => &[(
|
||||
"Write a Forth script that runs periodically during playback, independent of the step sequencer. Use this for autonomous sound generation, drones, generative sequences, or anything that doesn't fit a fixed step grid.",
|
||||
&[
|
||||
("Esc", "save & back"),
|
||||
("Ctrl+E", "evaluate script"),
|
||||
("[ ]", "adjust speed"),
|
||||
("Ctrl+S", "toggle stack preview"),
|
||||
],
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
31
src/page.rs
31
src/page.rs
@@ -7,10 +7,11 @@ pub enum Page {
|
||||
Help,
|
||||
Dict,
|
||||
Options,
|
||||
Script,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// All pages for iteration
|
||||
/// All pages for iteration (grid pages only — Script excluded)
|
||||
pub const ALL: &'static [Page] = &[
|
||||
Page::Main,
|
||||
Page::Patterns,
|
||||
@@ -28,6 +29,7 @@ impl Page {
|
||||
/// col 0 col 1 col 2
|
||||
/// row 0 Dict Patterns Options
|
||||
/// row 1 Help Sequencer Engine
|
||||
/// Script lives outside the grid at (1, 2)
|
||||
pub const fn grid_pos(self) -> (i8, i8) {
|
||||
match self {
|
||||
Page::Dict => (0, 0),
|
||||
@@ -36,10 +38,11 @@ impl Page {
|
||||
Page::Main => (1, 1),
|
||||
Page::Options => (2, 0),
|
||||
Page::Engine => (2, 1),
|
||||
Page::Script => (1, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find page at grid position, if any
|
||||
/// Find page at grid position, if any (grid pages only)
|
||||
pub fn at_pos(col: i8, row: i8) -> Option<Page> {
|
||||
Self::ALL.iter().copied().find(|p| p.grid_pos() == (col, row))
|
||||
}
|
||||
@@ -53,10 +56,15 @@ impl Page {
|
||||
Page::Help => "Help",
|
||||
Page::Dict => "Dict",
|
||||
Page::Options => "Options",
|
||||
Page::Script => "Script",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left(&mut self) {
|
||||
if *self == Page::Script {
|
||||
*self = Page::Help;
|
||||
return;
|
||||
}
|
||||
let (col, row) = self.grid_pos();
|
||||
for offset in 1..=Self::GRID_SIZE.0 {
|
||||
let new_col = (col - offset).rem_euclid(Self::GRID_SIZE.0);
|
||||
@@ -68,6 +76,10 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn right(&mut self) {
|
||||
if *self == Page::Script {
|
||||
*self = Page::Engine;
|
||||
return;
|
||||
}
|
||||
let (col, row) = self.grid_pos();
|
||||
for offset in 1..=Self::GRID_SIZE.0 {
|
||||
let new_col = (col + offset).rem_euclid(Self::GRID_SIZE.0);
|
||||
@@ -79,6 +91,10 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn up(&mut self) {
|
||||
if *self == Page::Script {
|
||||
*self = Page::Main;
|
||||
return;
|
||||
}
|
||||
let (col, row) = self.grid_pos();
|
||||
if let Some(page) = Self::at_pos(col, row - 1) {
|
||||
*self = page;
|
||||
@@ -87,6 +103,11 @@ impl Page {
|
||||
|
||||
pub fn down(&mut self) {
|
||||
let (col, row) = self.grid_pos();
|
||||
// From Main (1,1), going down reaches Script
|
||||
if *self == Page::Main {
|
||||
*self = Page::Script;
|
||||
return;
|
||||
}
|
||||
if let Some(page) = Self::at_pos(col, row + 1) {
|
||||
*self = page;
|
||||
}
|
||||
@@ -100,6 +121,12 @@ impl Page {
|
||||
Page::Help => Some(0), // "Welcome"
|
||||
Page::Dict => Some(7), // "About Forth"
|
||||
Page::Options => None,
|
||||
Page::Script => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this page appears in the navigation minimap grid.
|
||||
pub const fn visible_in_minimap(self) -> bool {
|
||||
!matches!(self, Page::Script)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ pub enum PatternField {
|
||||
Speed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScriptField {
|
||||
Speed,
|
||||
Length,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PatternPropsField {
|
||||
#[default]
|
||||
@@ -155,3 +161,25 @@ impl Default for EditorContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScriptEditorState {
|
||||
pub editor: Editor,
|
||||
pub show_stack: bool,
|
||||
pub stack_cache: RefCell<Option<StackCache>>,
|
||||
pub dirty: bool,
|
||||
pub focused: bool,
|
||||
pub mouse_selecting: bool,
|
||||
}
|
||||
|
||||
impl Default for ScriptEditorState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor: Editor::new(),
|
||||
show_stack: false,
|
||||
stack_cache: RefCell::new(None),
|
||||
dirty: false,
|
||||
focused: true,
|
||||
mouse_selecting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, S
|
||||
pub use color_scheme::ColorScheme;
|
||||
pub use editor::{
|
||||
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
|
||||
PatternPropsField, StackCache,
|
||||
PatternPropsField, ScriptEditorState, ScriptField, StackCache,
|
||||
};
|
||||
pub use live_keys::LiveKeyState;
|
||||
pub use modal::{ConfirmAction, Modal, RenameTarget};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::model::{self, FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
|
||||
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField, ScriptField};
|
||||
use crate::state::file_browser::FileBrowserState;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
@@ -69,6 +69,10 @@ pub enum Modal {
|
||||
field: PatternField,
|
||||
input: String,
|
||||
},
|
||||
SetScript {
|
||||
field: ScriptField,
|
||||
input: String,
|
||||
},
|
||||
SetTempo(String),
|
||||
JumpToStep(String),
|
||||
AddSamplePath(Box<FileBrowserState>),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::page::Page;
|
||||
|
||||
pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
let mut bindings = vec![
|
||||
("F1–F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
|
||||
("F1–F7", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine/Script"),
|
||||
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
|
||||
];
|
||||
if !plugin_mode {
|
||||
@@ -128,6 +128,14 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
|
||||
bindings.push(("Ctrl+F", "Search", "Activate search"));
|
||||
bindings.push(("Esc", "Clear", "Clear search"));
|
||||
}
|
||||
Page::Script => {
|
||||
bindings.push(("Enter", "Focus", "Focus editor for typing"));
|
||||
bindings.push(("Esc", "Unfocus", "Unfocus editor to use page keybindings"));
|
||||
bindings.push(("Ctrl+E", "Evaluate", "Compile and check for errors (focused)"));
|
||||
bindings.push(("S", "Set Speed", "Set script speed via text input (unfocused)"));
|
||||
bindings.push(("L", "Set Length", "Set script length via text input (unfocused)"));
|
||||
bindings.push(("Ctrl+S", "Stack", "Toggle stack preview (focused)"));
|
||||
}
|
||||
}
|
||||
|
||||
bindings
|
||||
|
||||
@@ -87,7 +87,7 @@ fn render_top_layout(
|
||||
render_sequencer(frame, app, snapshot, areas[idx]);
|
||||
}
|
||||
|
||||
fn render_audio_viz(frame: &mut Frame, app: &App, area: Rect) {
|
||||
pub(crate) fn render_audio_viz(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let mut panels: Vec<VizPanel> = Vec::new();
|
||||
if app.audio.config.show_scope { panels.push(VizPanel::Scope); }
|
||||
if app.audio.config.show_spectrum { panels.push(VizPanel::Spectrum); }
|
||||
@@ -491,7 +491,7 @@ fn viz_gain(data: &[f32], config: &crate::state::audio::AudioConfig) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
|
||||
pub(crate) fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -507,7 +507,7 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientati
|
||||
frame.render_widget(scope, inner);
|
||||
}
|
||||
|
||||
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||
pub(crate) fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -525,7 +525,7 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(spectrum, inner);
|
||||
}
|
||||
|
||||
fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) {
|
||||
pub(crate) fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -600,7 +600,7 @@ fn render_script_preview(
|
||||
frame.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn render_prelude_preview(
|
||||
pub(crate) fn render_prelude_preview(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
user_words: &HashSet<String>,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod main_view;
|
||||
pub mod options_view;
|
||||
pub mod patterns_view;
|
||||
mod render;
|
||||
pub mod script_view;
|
||||
pub mod title_view;
|
||||
|
||||
pub use render::{horizontal_padding, render};
|
||||
|
||||
@@ -25,7 +25,8 @@ use crate::widgets::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, script_view,
|
||||
title_view,
|
||||
};
|
||||
|
||||
fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
|
||||
@@ -188,6 +189,7 @@ pub fn render(
|
||||
Page::Options => options_view::render(frame, app, link, page_area),
|
||||
Page::Help => help_view::render(frame, app, page_area),
|
||||
Page::Dict => dict_view::render(frame, app, page_area),
|
||||
Page::Script => script_view::render(frame, app, snapshot, page_area),
|
||||
}
|
||||
|
||||
if let Some(side_area) = panel_area {
|
||||
@@ -202,6 +204,7 @@ pub fn render(
|
||||
if app.ui.show_minimap() {
|
||||
let tiles: Vec<NavTile> = Page::ALL
|
||||
.iter()
|
||||
.filter(|p| p.visible_in_minimap())
|
||||
.map(|p| {
|
||||
let (col, row) = p.grid_pos();
|
||||
NavTile {
|
||||
@@ -449,6 +452,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
Page::Options => " OPTIONS ",
|
||||
Page::Help => " HELP ",
|
||||
Page::Dict => " DICT ",
|
||||
Page::Script => " SCRIPT ",
|
||||
};
|
||||
|
||||
let content = if let Some(ref msg) = app.ui.status_message {
|
||||
@@ -509,6 +513,13 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("/", "Search"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Script => vec![
|
||||
("Esc", "Save & Back"),
|
||||
("C-e", "Eval"),
|
||||
("[ ]", "Speed"),
|
||||
("C-s", "Stack"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
};
|
||||
|
||||
let page_width = page_indicator.chars().count();
|
||||
@@ -608,6 +619,18 @@ fn render_modal(
|
||||
.border_color(theme.modal.confirm)
|
||||
.render_centered(frame, term)
|
||||
}
|
||||
Modal::SetScript { field, input } => {
|
||||
use crate::state::ScriptField;
|
||||
let (title, hint) = match field {
|
||||
ScriptField::Length => ("Set Script Length (1-256)", "Enter number"),
|
||||
ScriptField::Speed => ("Set Script Speed", "e.g. 1/3, 2/5, 1x, 2x"),
|
||||
};
|
||||
TextInputModal::new(title, input)
|
||||
.hint(hint)
|
||||
.width(45)
|
||||
.border_color(theme.modal.confirm)
|
||||
.render_centered(frame, term)
|
||||
}
|
||||
Modal::JumpToStep(input) => {
|
||||
let pattern_len = app.current_edit_pattern().length;
|
||||
let title = format!("Jump to Step (1-{})", pattern_len);
|
||||
|
||||
166
src/views/script_view.rs
Normal file
166
src/views/script_view.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::engine::SequencerSnapshot;
|
||||
use crate::model::SourceSpan;
|
||||
use crate::theme;
|
||||
use crate::views::highlight;
|
||||
use crate::views::render::{adjust_resolved_for_line, adjust_spans_for_line};
|
||||
use crate::widgets::hint_line;
|
||||
|
||||
pub fn layout(area: Rect) -> [Rect; 2] {
|
||||
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]).areas(area)
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let [editor_area, sidebar_area] = layout(area);
|
||||
|
||||
render_editor(frame, app, snapshot, editor_area);
|
||||
render_sidebar(frame, app, sidebar_area);
|
||||
}
|
||||
|
||||
fn render_editor(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let focused = app.script_editor.focused;
|
||||
let speed_label = app.project_state.project.script_speed.label();
|
||||
let length = app.project_state.project.script_length;
|
||||
let title = format!(" Periodic Script ({speed_label}, {length} steps) ");
|
||||
|
||||
let border_color = if focused { theme.modal.editor } else { theme.ui.border };
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(Style::new().fg(border_color));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let editor_height = inner.height.saturating_sub(1);
|
||||
let editor_area = Rect::new(inner.x, inner.y, inner.width, editor_height);
|
||||
let hint_area = Rect::new(inner.x, inner.y + editor_height, inner.width, 1);
|
||||
|
||||
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
|
||||
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
snapshot.script_trace()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_lines = app.script_editor.editor.lines();
|
||||
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
|
||||
let mut offset = 0;
|
||||
for line in text_lines.iter() {
|
||||
line_offsets.push(offset);
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| t.resolved.iter().map(|(s, v)| (*s, v.display())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
|
||||
let line_start = line_offsets[row];
|
||||
let (exec, sel, res) = match trace {
|
||||
Some(t) => (
|
||||
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
|
||||
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
|
||||
adjust_resolved_for_line(&resolved_display, line_start, line.len()),
|
||||
),
|
||||
None => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, &user_words)
|
||||
};
|
||||
|
||||
app.script_editor.editor.render(frame, editor_area, &highlighter);
|
||||
|
||||
if !focused {
|
||||
let hints = hint_line(&[
|
||||
("Enter", "edit"),
|
||||
("S", "speed"),
|
||||
("L", "length"),
|
||||
("s", "save"),
|
||||
("l", "load"),
|
||||
("?", "keys"),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
} else if app.script_editor.show_stack {
|
||||
let stack_text = app
|
||||
.script_editor
|
||||
.stack_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| c.result.clone())
|
||||
.unwrap_or_else(|| "Stack: []".to_string());
|
||||
let hints = hint_line(&[("Esc", "unfocus"), ("C-e", "eval"), ("C-s", "hide stack")]);
|
||||
let [hint_left, stack_right] = Layout::horizontal([
|
||||
Constraint::Length(hints.width() as u16),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(hint_area);
|
||||
frame.render_widget(Paragraph::new(hints), hint_left);
|
||||
let dim = Style::default().fg(theme.hint.text);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
|
||||
stack_right,
|
||||
);
|
||||
} else {
|
||||
let hints = hint_line(&[
|
||||
("Esc", "unfocus"),
|
||||
("C-e", "eval"),
|
||||
("C-s", "stack"),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
use crate::widgets::Orientation;
|
||||
|
||||
let mut constraints = Vec::new();
|
||||
if app.audio.config.show_scope {
|
||||
constraints.push(Constraint::Fill(1));
|
||||
}
|
||||
if app.audio.config.show_spectrum {
|
||||
constraints.push(Constraint::Fill(1));
|
||||
}
|
||||
if app.audio.config.show_lissajous {
|
||||
constraints.push(Constraint::Fill(1));
|
||||
}
|
||||
let has_prelude = !app.project_state.project.prelude.trim().is_empty();
|
||||
if has_prelude {
|
||||
constraints.push(Constraint::Fill(1));
|
||||
}
|
||||
if constraints.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let areas: Vec<Rect> = Layout::vertical(&constraints).split(area).to_vec();
|
||||
let mut idx = 0;
|
||||
|
||||
if app.audio.config.show_scope {
|
||||
super::main_view::render_scope(frame, app, areas[idx], Orientation::Horizontal);
|
||||
idx += 1;
|
||||
}
|
||||
if app.audio.config.show_spectrum {
|
||||
super::main_view::render_spectrum(frame, app, areas[idx]);
|
||||
idx += 1;
|
||||
}
|
||||
if app.audio.config.show_lissajous {
|
||||
super::main_view::render_lissajous(frame, app, areas[idx]);
|
||||
idx += 1;
|
||||
}
|
||||
if has_prelude {
|
||||
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
|
||||
super::main_view::render_prelude_preview(frame, app, &user_words, areas[idx]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user