Feat: documentation, UI/UX
This commit is contained in:
26
src/README.md
Normal file
26
src/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# cagire (main application)
|
||||
|
||||
Terminal UI application — ties together the Forth VM, audio engine, and project model.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `app/` | `App` struct and submodules: dispatch, editing, navigation, persistence, scripting, sequencer, clipboard, staging, undo |
|
||||
| `engine/` | Audio engine: `sequencer`, `audio`, `link` (Ableton Link), `dispatcher`, `realtime`, `timing` |
|
||||
| `input/` | Keyboard/mouse handling: per-page handlers, modal input, `InputContext` |
|
||||
| `views/` | Pure rendering functions taking `&App` |
|
||||
| `state/` | UI state modules (audio, editor, modals, panels, playback, ...) |
|
||||
| `services/` | Domain logic: clipboard, dict navigation, euclidean, help navigation, pattern editor, stack preview |
|
||||
| `model/` | Domain models: docs, categories, onboarding, script |
|
||||
| `commands` | `AppCommand` enum (~150 variants) |
|
||||
| `page` | `Page` navigation enum |
|
||||
| `midi` | MIDI I/O (up to 4 inputs/outputs) |
|
||||
| `settings` | Confy-based persistent settings |
|
||||
|
||||
## Key Types
|
||||
|
||||
- **`App`** — Central application state, coordinates all subsystems
|
||||
- **`AppCommand`** — Enum of all user actions, dispatched via `App::dispatch()`
|
||||
- **`InputContext`** — Holds `&mut App` + channel senders, bridges input to commands
|
||||
- **`Page`** — 3x2 page grid (Dict, Patterns, Options, Help, Main, Engine)
|
||||
@@ -308,12 +308,6 @@ impl App {
|
||||
self.ui.show_title = false;
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::ToggleEditorStack => {
|
||||
self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
|
||||
if self.editor_ctx.show_stack {
|
||||
crate::services::stack_preview::update_cache(&self.editor_ctx);
|
||||
}
|
||||
}
|
||||
AppCommand::SetColorScheme(scheme) => {
|
||||
self.ui.color_scheme = scheme;
|
||||
let palette = scheme.to_palette();
|
||||
@@ -494,9 +488,6 @@ impl App {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ impl App {
|
||||
.set_completion_enabled(self.ui.show_completion);
|
||||
let tree = SampleTree::from_paths(&self.audio.config.sample_paths);
|
||||
self.editor_ctx.editor.set_sample_folders(tree.all_folder_names());
|
||||
if self.editor_ctx.show_stack {
|
||||
crate::services::stack_preview::update_cache(&self.editor_ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,20 +126,33 @@ impl App {
|
||||
}
|
||||
|
||||
/// Evaluate a script and immediately send its audio commands.
|
||||
/// Returns collected `print` output, if any.
|
||||
pub fn execute_script_oneshot(
|
||||
&self,
|
||||
script: &str,
|
||||
link: &LinkState,
|
||||
audio_tx: &arc_swap::ArcSwap<Sender<crate::engine::AudioCommand>>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Option<String>, String> {
|
||||
let ctx = self.create_step_context(self.editor_ctx.step, link);
|
||||
let cmds = self.script_engine.evaluate(script, &ctx)?;
|
||||
let mut print_output = String::new();
|
||||
for cmd in cmds {
|
||||
if let Some(text) = cmd.strip_prefix("print:") {
|
||||
if !print_output.is_empty() {
|
||||
print_output.push(' ');
|
||||
}
|
||||
print_output.push_str(text);
|
||||
continue;
|
||||
}
|
||||
let _ = audio_tx
|
||||
.load()
|
||||
.send(crate::engine::AudioCommand::Evaluate { cmd, time: None });
|
||||
}
|
||||
Ok(())
|
||||
Ok(if print_output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(print_output)
|
||||
})
|
||||
}
|
||||
|
||||
/// Compile (evaluate) the current step's script to check for errors.
|
||||
|
||||
@@ -221,7 +221,6 @@ pub enum AppCommand {
|
||||
// UI state
|
||||
ClearMinimap,
|
||||
HideTitle,
|
||||
ToggleEditorStack,
|
||||
SetColorScheme(ColorScheme),
|
||||
SetHueRotation(f32),
|
||||
ToggleRuntimeHighlight,
|
||||
@@ -317,5 +316,4 @@ pub enum AppCommand {
|
||||
SetScriptLength(usize),
|
||||
ScriptSave,
|
||||
ScriptEvaluate,
|
||||
ToggleScriptStack,
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ impl SpectrumAnalyzer {
|
||||
let avg = sum / (hi - lo) as f32;
|
||||
let amplitude = avg / (FFT_SIZE as f32 / 4.0);
|
||||
let db = 20.0 * amplitude.max(1e-10).log10();
|
||||
*mag = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
|
||||
*mag = ((db + 80.0) / 80.0).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
output.write(&bands);
|
||||
|
||||
@@ -171,6 +171,7 @@ pub struct SharedSequencerState {
|
||||
pub tempo: f64,
|
||||
pub beat: f64,
|
||||
pub script_trace: Option<ExecutionTrace>,
|
||||
pub print_output: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SequencerSnapshot {
|
||||
@@ -180,6 +181,7 @@ pub struct SequencerSnapshot {
|
||||
pub tempo: f64,
|
||||
pub beat: f64,
|
||||
script_trace: Option<ExecutionTrace>,
|
||||
pub print_output: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&SharedSequencerState> for SequencerSnapshot {
|
||||
@@ -191,6 +193,7 @@ impl From<&SharedSequencerState> for SequencerSnapshot {
|
||||
tempo: s.tempo,
|
||||
beat: s.beat,
|
||||
script_trace: s.script_trace.clone(),
|
||||
print_output: s.print_output.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +208,7 @@ impl SequencerSnapshot {
|
||||
tempo: 0.0,
|
||||
beat: 0.0,
|
||||
script_trace: None,
|
||||
print_output: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +577,7 @@ pub struct SequencerState {
|
||||
script_frontier: f64,
|
||||
script_step: usize,
|
||||
script_trace: Option<ExecutionTrace>,
|
||||
print_output: Option<String>,
|
||||
}
|
||||
|
||||
impl SequencerState {
|
||||
@@ -610,6 +615,7 @@ impl SequencerState {
|
||||
script_frontier: -1.0,
|
||||
script_step: 0,
|
||||
script_trace: None,
|
||||
print_output: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,6 +823,7 @@ impl SequencerState {
|
||||
self.script_frontier = -1.0;
|
||||
self.script_step = 0;
|
||||
self.script_trace = None;
|
||||
self.print_output = None;
|
||||
self.buf_audio_commands.clear();
|
||||
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
||||
TickOutput {
|
||||
@@ -922,6 +929,7 @@ impl SequencerState {
|
||||
) -> StepResult {
|
||||
self.buf_audio_commands.clear();
|
||||
self.buf_completed_iterations.clear();
|
||||
let mut print_cleared = false;
|
||||
let mut result = StepResult {
|
||||
any_step_fired: false,
|
||||
};
|
||||
@@ -1012,12 +1020,26 @@ impl SequencerState {
|
||||
std::mem::take(&mut trace),
|
||||
);
|
||||
|
||||
if !print_cleared {
|
||||
self.print_output = None;
|
||||
print_cleared = true;
|
||||
}
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
if let Some(text) = cmd.strip_prefix("print:") {
|
||||
match &mut self.print_output {
|
||||
Some(existing) => {
|
||||
existing.push(' ');
|
||||
existing.push_str(text);
|
||||
}
|
||||
None => self.print_output = Some(text.to_string()),
|
||||
}
|
||||
} else {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1113,12 +1135,23 @@ impl SequencerState {
|
||||
self.script_engine
|
||||
.evaluate_with_trace(&self.script_text, &ctx, &mut trace)
|
||||
{
|
||||
self.print_output = None;
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
if let Some(text) = cmd.strip_prefix("print:") {
|
||||
match &mut self.print_output {
|
||||
Some(existing) => {
|
||||
existing.push(' ');
|
||||
existing.push_str(text);
|
||||
}
|
||||
None => self.print_output = Some(text.to_string()),
|
||||
}
|
||||
} else {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.script_trace = Some(trace);
|
||||
@@ -1201,6 +1234,7 @@ impl SequencerState {
|
||||
tempo: self.last_tempo,
|
||||
beat: self.last_beat,
|
||||
script_trace: self.script_trace.clone(),
|
||||
print_output: self.print_output.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
|
||||
}
|
||||
KeyCode::Esc if ctx.app.ui.help_focused_block.is_some() => {
|
||||
ctx.app.ui.help_focused_block = None;
|
||||
ctx.app.ui.help_block_output = None;
|
||||
}
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
|
||||
KeyCode::Left if ctx.app.ui.help_focus == HelpFocus::Topics => {
|
||||
@@ -106,6 +107,7 @@ fn navigate_code_block(ctx: &mut InputContext, forward: bool) {
|
||||
let scroll_to = parsed.code_blocks[next].start_line.saturating_sub(2);
|
||||
drop(cache);
|
||||
ctx.app.ui.help_focused_block = Some(next);
|
||||
ctx.app.ui.help_block_output = None;
|
||||
*ctx.app.ui.help_scroll_mut() = scroll_to;
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ fn execute_focused_block(ctx: &mut InputContext) {
|
||||
let Some(parsed) = cache[ctx.app.ui.help_topic].as_ref() else {
|
||||
return;
|
||||
};
|
||||
let idx = ctx.app.ui.help_focused_block.expect("block focused in code nav");
|
||||
let idx = ctx.app.ui.help_focused_block.unwrap();
|
||||
let Some(block) = parsed.code_blocks.get(idx) else {
|
||||
return;
|
||||
};
|
||||
@@ -126,11 +128,17 @@ fn execute_focused_block(ctx: &mut InputContext) {
|
||||
.map(|l| l.split(" => ").next().unwrap_or(l))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let topic = ctx.app.ui.help_topic;
|
||||
let block_idx = ctx.app.ui.help_focused_block.expect("block focused in code nav");
|
||||
match ctx
|
||||
.app
|
||||
.execute_script_oneshot(&cleaned, ctx.link, ctx.audio_tx)
|
||||
{
|
||||
Ok(()) => ctx.app.ui.flash("Executed", 100, FlashKind::Info),
|
||||
Ok(Some(output)) => {
|
||||
ctx.app.ui.flash(&output, 200, FlashKind::Info);
|
||||
ctx.app.ui.help_block_output = Some((topic, block_idx, output));
|
||||
}
|
||||
Ok(None) => ctx.app.ui.flash("Executed", 100, FlashKind::Info),
|
||||
Err(e) => ctx
|
||||
.app
|
||||
.ui
|
||||
@@ -153,6 +161,7 @@ fn collapse_help_section(ctx: &mut InputContext) {
|
||||
}
|
||||
ctx.app.ui.help_on_section = Some(section);
|
||||
ctx.app.ui.help_focused_block = None;
|
||||
ctx.app.ui.help_block_output = None;
|
||||
}
|
||||
|
||||
fn expand_help_section(ctx: &mut InputContext) {
|
||||
@@ -167,6 +176,7 @@ fn expand_help_section(ctx: &mut InputContext) {
|
||||
ctx.app.ui.help_topic = first;
|
||||
}
|
||||
ctx.app.ui.help_focused_block = None;
|
||||
ctx.app.ui.help_block_output = None;
|
||||
}
|
||||
|
||||
fn collapse_dict_section(ctx: &mut InputContext) {
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
||||
.app
|
||||
.execute_script_oneshot(script, ctx.link, ctx.audio_tx)
|
||||
{
|
||||
Ok(()) => ctx
|
||||
Ok(_) => ctx
|
||||
.app
|
||||
.ui
|
||||
.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
|
||||
@@ -158,6 +158,12 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
}
|
||||
|
||||
// F11 — hidden Script page (no minimap flash)
|
||||
if key.code == KeyCode::F(11) {
|
||||
ctx.dispatch(AppCommand::GoToPage(Page::Script));
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
if let Some(page) = match key.code {
|
||||
KeyCode::F(1) => Some(Page::Dict),
|
||||
KeyCode::F(2) => Some(Page::Patterns),
|
||||
@@ -165,7 +171,6 @@ 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(1000));
|
||||
|
||||
@@ -357,16 +357,13 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
editor.search_prev();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') if ctrl => {
|
||||
ctx.dispatch(AppCommand::ToggleEditorStack);
|
||||
}
|
||||
KeyCode::Char('r') if ctrl => {
|
||||
let script = ctx.app.editor_ctx.editor.lines().join("\n");
|
||||
match ctx
|
||||
.app
|
||||
.execute_script_oneshot(&script, ctx.link, ctx.audio_tx)
|
||||
{
|
||||
Ok(()) => ctx
|
||||
Ok(_) => ctx
|
||||
.app
|
||||
.ui
|
||||
.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
@@ -413,9 +410,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.app.editor_ctx.show_stack {
|
||||
crate::services::stack_preview::update_cache(&ctx.app.editor_ctx);
|
||||
}
|
||||
}
|
||||
Modal::PatternProps {
|
||||
bank,
|
||||
|
||||
@@ -26,9 +26,6 @@ fn handle_focused(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::ScriptSave);
|
||||
ctx.dispatch(AppCommand::ScriptEvaluate);
|
||||
}
|
||||
(true, KeyCode::Char('s')) => {
|
||||
ctx.dispatch(AppCommand::ToggleScriptStack);
|
||||
}
|
||||
_ => {
|
||||
ctx.app.script_editor.editor.input(key);
|
||||
}
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -114,6 +114,8 @@ fn main() -> io::Result<()> {
|
||||
terminal.clear()?;
|
||||
|
||||
let mut last_frame = Instant::now();
|
||||
let mut was_playing = false;
|
||||
let mut last_stop_time = Instant::now();
|
||||
|
||||
loop {
|
||||
if app.audio.restart_pending {
|
||||
@@ -216,6 +218,10 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
|
||||
app.playback.playing = playing.load(Ordering::Relaxed);
|
||||
if was_playing && !app.playback.playing {
|
||||
last_stop_time = Instant::now();
|
||||
}
|
||||
was_playing = app.playback.playing;
|
||||
|
||||
while let Ok(midi_cmd) = midi_rx.try_recv() {
|
||||
match midi_cmd {
|
||||
@@ -325,9 +331,6 @@ fn main() -> io::Result<()> {
|
||||
Event::Paste(text) => {
|
||||
if matches!(app.ui.modal, state::Modal::Editor) {
|
||||
app.editor_ctx.editor.insert_str(&text);
|
||||
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);
|
||||
}
|
||||
@@ -345,7 +348,9 @@ fn main() -> io::Result<()> {
|
||||
|| app.ui.modal_fx.borrow().is_some()
|
||||
|| app.ui.title_fx.borrow().is_some()
|
||||
|| app.ui.nav_fx.borrow().is_some();
|
||||
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() {
|
||||
let cursor_pulse = app.page == page::Page::Main && !app.ui.performance_mode && !app.playback.playing;
|
||||
let audio_cooldown = !app.playback.playing && last_stop_time.elapsed() < Duration::from_secs(1);
|
||||
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() || cursor_pulse || audio_cooldown {
|
||||
if app.ui.show_title {
|
||||
app.ui.sparkles.tick(terminal.get_frame().area());
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ pub const DOCS: &[DocEntry] = &[
|
||||
"Control Flow",
|
||||
include_str!("../../docs/forth/control_flow.md"),
|
||||
),
|
||||
Topic("Brackets", include_str!("../../docs/forth/brackets.md")),
|
||||
Topic("Cycling", include_str!("../../docs/forth/cycling.md")),
|
||||
Topic("The Prelude", include_str!("../../docs/forth/prelude.md")),
|
||||
Topic(
|
||||
"Cagire vs Classic",
|
||||
@@ -131,6 +133,10 @@ pub const DOCS: &[DocEntry] = &[
|
||||
"Sharing",
|
||||
include_str!("../../docs/tutorials/sharing.md"),
|
||||
),
|
||||
Topic(
|
||||
"Periodic Script",
|
||||
include_str!("../../docs/tutorials/periodic_script.md"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cagire_forth::{Dictionary, ExecutionTrace, Forth, Rng, StepContext, Value, Variables};
|
||||
use cagire_forth::{Dictionary, ExecutionTrace, Forth, Rng, StepContext, Variables};
|
||||
|
||||
pub struct ScriptEngine {
|
||||
forth: Forth,
|
||||
@@ -27,8 +27,4 @@ impl ScriptEngine {
|
||||
pub fn clear_global_params(&self) {
|
||||
self.forth.clear_global_params();
|
||||
}
|
||||
|
||||
pub fn stack(&self) -> Vec<Value> {
|
||||
self.forth.stack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ pub fn next_topic(ui: &mut UiState, n: usize) {
|
||||
let next = (cur + n) % items.len();
|
||||
apply_selection(ui, items[next]);
|
||||
ui.help_focused_block = None;
|
||||
ui.help_block_output = None;
|
||||
}
|
||||
|
||||
pub fn prev_topic(ui: &mut UiState, n: usize) {
|
||||
@@ -95,6 +96,7 @@ pub fn prev_topic(ui: &mut UiState, n: usize) {
|
||||
let next = (cur + items.len() - (n % items.len())) % items.len();
|
||||
apply_selection(ui, items[next]);
|
||||
ui.help_focused_block = None;
|
||||
ui.help_block_output = None;
|
||||
}
|
||||
|
||||
pub fn scroll_down(ui: &mut UiState, n: usize) {
|
||||
|
||||
@@ -3,4 +3,3 @@ pub mod dict_nav;
|
||||
pub mod euclidean;
|
||||
pub mod help_nav;
|
||||
pub mod pattern_editor;
|
||||
pub mod stack_preview;
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::model::{ScriptEngine, StepContext, Value};
|
||||
use crate::state::{EditorContext, StackCache};
|
||||
|
||||
pub fn update_cache(editor_ctx: &EditorContext) {
|
||||
let lines = editor_ctx.editor.lines();
|
||||
let cursor_line = editor_ctx.editor.cursor().0;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i > cursor_line {
|
||||
break;
|
||||
}
|
||||
line.hash(&mut hasher);
|
||||
}
|
||||
let lines_hash = hasher.finish();
|
||||
|
||||
if let Some(ref c) = *editor_ctx.stack_cache.borrow() {
|
||||
if c.cursor_line == cursor_line && c.lines_hash == lines_hash {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let partial: Vec<&str> = lines
|
||||
.iter()
|
||||
.take(cursor_line + 1)
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
let script = partial.join("\n");
|
||||
|
||||
let result = if script.trim().is_empty() {
|
||||
"Stack: []".to_string()
|
||||
} else {
|
||||
let vars = Arc::new(ArcSwap::from_pointee(HashMap::new()));
|
||||
let dict = Arc::new(Mutex::new(HashMap::new()));
|
||||
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
|
||||
let engine = ScriptEngine::new(vars, dict, rng);
|
||||
|
||||
let ctx = StepContext {
|
||||
step: 0,
|
||||
beat: 0.0,
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
tempo: 120.0,
|
||||
phase: 0.0,
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
iter: 0,
|
||||
speed: 1.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_access: None,
|
||||
speed_key: "",
|
||||
mouse_x: 0.5,
|
||||
mouse_y: 0.5,
|
||||
mouse_down: 0.0,
|
||||
};
|
||||
|
||||
match engine.evaluate(&script, &ctx) {
|
||||
Ok(_) => {
|
||||
let stack = engine.stack();
|
||||
let formatted: Vec<String> = stack.iter().map(format_value).collect();
|
||||
format!("Stack: [{}]", formatted.join(" "))
|
||||
}
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
};
|
||||
|
||||
*editor_ctx.stack_cache.borrow_mut() = Some(StackCache {
|
||||
cursor_line,
|
||||
lines_hash,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
fn format_value(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Int(n, _) => n.to_string(),
|
||||
Value::Float(f, _) => {
|
||||
if f.fract() == 0.0 && f.abs() < 1_000_000.0 {
|
||||
format!("{f:.1}")
|
||||
} else {
|
||||
format!("{f:.4}")
|
||||
}
|
||||
}
|
||||
Value::Str(s, _) => format!("\"{s}\""),
|
||||
Value::Quotation(..) => "[...]".to_string(),
|
||||
Value::CycleList(items) | Value::ArpList(items) => {
|
||||
let inner: Vec<String> = items.iter().map(format_value).collect();
|
||||
format!("({})", inner.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::Cell;
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use cagire_ratatui::Editor;
|
||||
@@ -101,20 +101,11 @@ pub struct EditorContext {
|
||||
pub editor: Editor,
|
||||
pub selection_anchor: Option<usize>,
|
||||
pub copied_steps: Option<CopiedSteps>,
|
||||
pub show_stack: bool,
|
||||
pub stack_cache: RefCell<Option<StackCache>>,
|
||||
pub target: EditorTarget,
|
||||
pub steps_per_page: Cell<usize>,
|
||||
pub mouse_selecting: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StackCache {
|
||||
pub cursor_line: usize,
|
||||
pub lines_hash: u64,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CopiedSteps {
|
||||
pub bank: usize,
|
||||
@@ -153,8 +144,6 @@ impl Default for EditorContext {
|
||||
editor: Editor::new(),
|
||||
selection_anchor: None,
|
||||
copied_steps: None,
|
||||
show_stack: false,
|
||||
stack_cache: RefCell::new(None),
|
||||
target: EditorTarget::default(),
|
||||
steps_per_page: Cell::new(32),
|
||||
mouse_selecting: false,
|
||||
@@ -164,8 +153,6 @@ 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,
|
||||
@@ -175,8 +162,6 @@ 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,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub use audio::{AudioSettings, DeviceKind, EngineSection, LinkSetting, MainLayou
|
||||
pub use color_scheme::ColorScheme;
|
||||
pub use editor::{
|
||||
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
|
||||
PatternPropsField, ScriptEditorState, ScriptField, StackCache,
|
||||
PatternPropsField, ScriptEditorState, ScriptField,
|
||||
};
|
||||
pub use live_keys::LiveKeyState;
|
||||
pub use modal::{ConfirmAction, Modal, RenameTarget};
|
||||
|
||||
@@ -51,6 +51,7 @@ pub struct UiState {
|
||||
pub help_search_active: bool,
|
||||
pub help_search_query: String,
|
||||
pub help_focused_block: Option<usize>,
|
||||
pub help_block_output: Option<(usize, usize, String)>,
|
||||
pub help_parsed: RefCell<Vec<Option<ParsedMarkdown>>>,
|
||||
pub dict_focus: DictFocus,
|
||||
pub dict_category: usize,
|
||||
@@ -102,6 +103,7 @@ impl Default for UiState {
|
||||
help_search_active: false,
|
||||
help_search_query: String::new(),
|
||||
help_focused_block: None,
|
||||
help_block_output: None,
|
||||
help_parsed: RefCell::new(
|
||||
(0..crate::model::docs::topic_count())
|
||||
.map(|_| None)
|
||||
|
||||
@@ -228,8 +228,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let mut lines = parsed.lines.clone();
|
||||
|
||||
// Highlight focused code block with background tint
|
||||
if let Some(block_idx) = app.ui.help_focused_block {
|
||||
// Highlight focused code block with background tint and border
|
||||
let border_lines_inserted = if let Some(block_idx) = app.ui.help_focused_block {
|
||||
if let Some(block) = parsed.code_blocks.get(block_idx) {
|
||||
let tint_bg = theme.ui.surface;
|
||||
for line in lines.iter_mut().take(block.end_line).skip(block.start_line) {
|
||||
@@ -242,6 +242,31 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
*span = Span::styled(span.content.clone(), style);
|
||||
}
|
||||
}
|
||||
let border = "─".repeat(content_width.saturating_sub(1));
|
||||
let border_line = RLine::from(Span::styled(
|
||||
format!(" {border}"),
|
||||
Style::new().fg(theme.ui.accent),
|
||||
));
|
||||
lines.insert(block.end_line, border_line.clone());
|
||||
lines.insert(block.start_line, border_line);
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Insert print output line after the executed code block
|
||||
if let Some((topic, block_idx, ref output)) = app.ui.help_block_output {
|
||||
if topic == app.ui.help_topic {
|
||||
if let Some(block) = parsed.code_blocks.get(block_idx) {
|
||||
let output_line = RLine::from(vec![
|
||||
Span::styled(" → ", Style::new().fg(theme.markdown.code_border)),
|
||||
Span::styled(output.clone(), Style::new().fg(theme.markdown.code)),
|
||||
]);
|
||||
lines.insert(block.end_line + border_lines_inserted, output_line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Main page view — sequencer grid, visualizations (scope/spectrum), script previews.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
@@ -361,6 +363,26 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn pulse_phase() -> f32 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
let t = EPOCH.get_or_init(Instant::now).elapsed().as_secs_f32();
|
||||
(1.0 + (t * std::f32::consts::TAU).sin()) * 0.5
|
||||
}
|
||||
|
||||
fn pulse_color(color: Color) -> Color {
|
||||
let Color::Rgb(r, g, b) = color else { return color };
|
||||
let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
|
||||
let amp = pulse_phase() * 0.12;
|
||||
let shift = |c: u8| -> u8 {
|
||||
if lum < 128.0 {
|
||||
(c as f32 + (255.0 - c as f32) * amp) as u8
|
||||
} else {
|
||||
(c as f32 * (1.0 - amp)) as u8
|
||||
}
|
||||
};
|
||||
Color::Rgb(shift(r), shift(g), shift(b))
|
||||
}
|
||||
|
||||
fn render_tile(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -417,6 +439,7 @@ fn render_tile(
|
||||
(false, false, _, _, true) => (theme.selection.in_range, theme.selection.cursor_fg),
|
||||
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
||||
};
|
||||
let bg = if is_selected && !is_playing { pulse_color(bg) } else { bg };
|
||||
|
||||
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
||||
frame.render_widget(bg_fill, area);
|
||||
|
||||
@@ -197,7 +197,7 @@ pub fn render(
|
||||
}
|
||||
|
||||
if !perf {
|
||||
render_footer(frame, app, footer_area);
|
||||
render_footer(frame, app, snapshot, footer_area);
|
||||
}
|
||||
let modal_area = render_modal(frame, app, snapshot, term);
|
||||
|
||||
@@ -512,7 +512,7 @@ fn render_header(
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -539,6 +539,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
Span::raw(" "),
|
||||
Span::styled(msg.clone(), Style::new().fg(theme.modal.confirm)),
|
||||
])
|
||||
} else if let Some(ref text) = snapshot.print_output {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
page_indicator.to_string(),
|
||||
Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(text.clone(), Style::new().fg(theme.hint.text)),
|
||||
])
|
||||
} else {
|
||||
let bindings: Vec<(&str, &str)> = match app.page {
|
||||
Page::Main => vec![
|
||||
@@ -593,7 +602,6 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("Esc", "Save & Back"),
|
||||
("C-e", "Eval"),
|
||||
("[ ]", "Speed"),
|
||||
("C-s", "Stack"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
};
|
||||
@@ -1071,33 +1079,12 @@ fn render_modal_editor(
|
||||
if app.editor_ctx.editor.search_active() {
|
||||
let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
} else if app.editor_ctx.show_stack {
|
||||
let stack_text = app
|
||||
.editor_ctx
|
||||
.stack_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| c.result.clone())
|
||||
.unwrap_or_else(|| "Stack: []".to_string());
|
||||
let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
|
||||
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", "save"),
|
||||
("C-e", "eval"),
|
||||
("C-f", "find"),
|
||||
("C-b", "samples"),
|
||||
("C-s", "stack"),
|
||||
("C-u", "/"),
|
||||
("C-r", "undo/redo"),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
|
||||
@@ -93,31 +92,10 @@ fn render_editor(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
|
||||
("?", "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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user