Feat: documentation, UI/UX

This commit is contained in:
2026-03-01 19:09:52 +01:00
parent ecb559e556
commit db44f9b98e
57 changed files with 1531 additions and 615 deletions

26
src/README.md Normal file
View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,3 @@ pub mod dict_nav;
pub mod euclidean;
pub mod help_nav;
pub mod pattern_editor;
pub mod stack_preview;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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