Feat: add hidden mode and new documentation
Some checks failed
Deploy Website / deploy (push) Failing after 29s

This commit is contained in:
2026-02-26 12:31:56 +01:00
parent 8af17c01d8
commit b728b38d6e
95 changed files with 1055 additions and 286 deletions

View File

@@ -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());

View File

@@ -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();
}
}
}

View File

@@ -1,3 +1,5 @@
//! Pattern and step editing operations (toggle, length, speed, delete, reset).
use crate::services::pattern_editor;
use crate::state::FlashKind;

View File

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

View File

@@ -1,3 +1,5 @@
//! Step and bank/pattern cursor navigation.
use super::App;
impl App {

View File

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

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
_ => {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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![
("F1F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
("F1F7", "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

View File

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

View File

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

View File

@@ -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
View 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]);
}
}