diff --git a/src/app.rs b/src/app.rs index 3343021..ff96d11 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,6 +20,8 @@ use crate::state::{ }; use crate::views::doc_view; +const STEPS_PER_PAGE: usize = 32; + pub struct App { pub project_state: ProjectState, pub ui: UiState, @@ -148,8 +150,10 @@ impl App { pub fn step_up(&mut self) { let len = self.current_edit_pattern().length; - let num_rows = len.div_ceil(8); - let steps_per_row = len.div_ceil(num_rows); + let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE; + let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start; + let num_rows = steps_on_page.div_ceil(8); + let steps_per_row = steps_on_page.div_ceil(num_rows); if self.editor_ctx.step >= steps_per_row { self.editor_ctx.step -= steps_per_row; @@ -161,8 +165,10 @@ impl App { pub fn step_down(&mut self) { let len = self.current_edit_pattern().length; - let num_rows = len.div_ceil(8); - let steps_per_row = len.div_ceil(num_rows); + let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE; + let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start; + let num_rows = steps_on_page.div_ceil(8); + let steps_per_row = steps_on_page.div_ceil(num_rows); self.editor_ctx.step = (self.editor_ctx.step + steps_per_row) % len; self.load_step_to_editor(); diff --git a/src/input.rs b/src/input.rs index 325ad31..e8d6856 100644 --- a/src/input.rs +++ b/src/input.rs @@ -373,6 +373,14 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } } } + Modal::Preview => match key.code { + KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), + KeyCode::Right => ctx.dispatch(AppCommand::NextStep), + KeyCode::Up => ctx.dispatch(AppCommand::StepUp), + KeyCode::Down => ctx.dispatch(AppCommand::StepDown), + _ => {} + }, Modal::None => unreachable!(), } InputResult::Continue @@ -465,6 +473,7 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), + KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)), KeyCode::Delete | KeyCode::Backspace => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let step = ctx.app.editor_ctx.step; @@ -646,7 +655,7 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char('t') => { let _ = ctx .audio_tx - .send(AudioCommand::Evaluate("sin 440 * 0.3".into())); + .send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into())); } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); diff --git a/src/state/modal.rs b/src/state/modal.rs index c5ebb8a..be2c772 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -39,4 +39,5 @@ pub enum Modal { SetTempo(String), AddSamplePath(String), Editor, + Preview, } diff --git a/src/views/main_view.rs b/src/views/main_view.rs index b49cbdf..ef96d6b 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -1,12 +1,10 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; -use crate::views::highlight::{highlight_line, highlight_line_with_runtime}; use crate::widgets::{Orientation, Scope, VuMeter}; pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { @@ -17,19 +15,19 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar ]) .areas(area); - let [scope_area, sequencer_area, preview_area] = Layout::vertical([ - Constraint::Length(8), + let [scope_area, sequencer_area] = Layout::vertical([ + Constraint::Length(14), Constraint::Fill(1), - Constraint::Length(2), ]) .areas(left_area); render_scope(frame, app, scope_area); render_sequencer(frame, app, snapshot, sequencer_area); - render_step_preview(frame, app, snapshot, preview_area); render_vu_meter(frame, app, vu_area); } +const STEPS_PER_PAGE: usize = 32; + fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { if area.width < 50 { let msg = Paragraph::new("Terminal too narrow") @@ -41,8 +39,12 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, let pattern = app.current_edit_pattern(); let length = pattern.length; - let num_rows = length.div_ceil(8); - let steps_per_row = length.div_ceil(num_rows); + let page = app.editor_ctx.step / STEPS_PER_PAGE; + let page_start = page * STEPS_PER_PAGE; + let steps_on_page = (page_start + STEPS_PER_PAGE).min(length) - page_start; + + let num_rows = steps_on_page.div_ceil(8); + let steps_per_row = steps_on_page.div_ceil(num_rows); let spacing = num_rows.saturating_sub(1) as u16; let row_height = area.height.saturating_sub(spacing) / num_rows as u16; @@ -61,7 +63,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, for row_idx in 0..num_rows { let row_area = rows[row_idx * 2]; let start_step = row_idx * steps_per_row; - let end_step = (start_step + steps_per_row).min(length); + let end_step = (start_step + steps_per_row).min(steps_on_page); let cols_in_row = end_step - start_step; let col_constraints: Vec = (0..cols_in_row * 2 - 1) @@ -78,12 +80,13 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, let cols = Layout::horizontal(col_constraints).split(row_area); for col_idx in 0..cols_in_row { - let step_idx = start_step + col_idx; + let step_idx = page_start + start_step + col_idx; if step_idx < length { render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx); } } } + } fn render_tile( @@ -168,54 +171,3 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(vu, area); } -fn render_step_preview(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { - let pattern = app.current_edit_pattern(); - let step_idx = app.editor_ctx.step; - let step = pattern.step(step_idx); - - let [title_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); - - let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); - let source_idx = step.and_then(|s| s.source); - - let title = if let Some(src) = source_idx { - format!(" Step {:02} → {:02} ", step_idx + 1, src + 1) - } else { - format!(" Step {:02} ", step_idx + 1) - }; - let title_color = if is_linked { - Color::Rgb(180, 140, 220) - } else { - Color::Rgb(120, 125, 135) - }; - let title_p = Paragraph::new(title).style(Style::new().fg(title_color)); - frame.render_widget(title_p, title_area); - - let script = pattern.resolve_script(step_idx).unwrap_or(""); - if script.is_empty() { - let empty = Paragraph::new(" (empty)").style(Style::new().fg(Color::Rgb(80, 85, 95))); - frame.render_widget(empty, content_area); - return; - } - - let runtime_spans = if app.ui.runtime_highlight && app.playback.playing { - snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, step_idx) - } else { - None - }; - - let spans: Vec<_> = if let Some(traces) = runtime_spans { - highlight_line_with_runtime(script, traces) - } else { - highlight_line(script) - } - .into_iter() - .map(|(style, text)| ratatui::text::Span::styled(text, style)) - .collect(); - let mut line_spans = vec![ratatui::text::Span::raw(" ")]; - line_spans.extend(spans); - let line = Line::from(line_spans); - let paragraph = Paragraph::new(line); - frame.render_widget(paragraph, content_area); -} diff --git a/src/views/render.rs b/src/views/render.rs index f155722..e1f4153 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -6,9 +6,10 @@ use ratatui::Frame; use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; +use crate::model::forth::SourceSpan; use crate::page::Page; use crate::state::{Modal, PatternField}; -use crate::views::highlight; +use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal}; use super::{audio_view, doc_view, main_view, patterns_view, title_view}; @@ -25,16 +26,17 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq } let padded = Rect { - x: term.x + 1, + x: term.x + 4, y: term.y + 1, - width: term.width.saturating_sub(2), + width: term.width.saturating_sub(8), height: term.height.saturating_sub(2), }; - let [header_area, _padding, body_area, footer_area] = Layout::vertical([ + let [header_area, _padding, body_area, _bottom_padding, footer_area] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), + Constraint::Length(1), Constraint::Length(3), ]) .areas(padded); @@ -130,7 +132,7 @@ fn render_header( bank_area, ); - // Pattern block (name + length + speed + iter) + // Pattern block (name + length + speed + page + iter) let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1); let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name); let speed_info = if pattern.speed != PatternSpeed::Normal { @@ -138,13 +140,20 @@ fn render_header( } else { String::new() }; + let total_pages = pattern.length.div_ceil(32); + let page_info = if total_pages > 1 { + let current_page = app.editor_ctx.step / 32 + 1; + format!(" · {current_page}/{total_pages}") + } else { + String::new() + }; let iter_info = snapshot .get_iter(app.editor_ctx.bank, app.editor_ctx.pattern) .map(|iter| format!(" · #{}", iter + 1)) .unwrap_or_default(); let pattern_text = format!( - " {} · {} steps{}{} ", - pattern_name, pattern.length, speed_info, iter_info + " {} · {} steps{}{}{} ", + pattern_name, pattern.length, speed_info, page_info, iter_info ); let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White); frame.render_widget( @@ -194,28 +203,37 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } else { let bindings: Vec<(&str, &str)> = match app.page { Page::Main => vec![ - ("←→↑↓", "nav"), - ("t", "toggle"), - ("Enter", "edit"), - ("<>", "len"), - ("[]", "spd"), - ("f", "fill"), + ("←→↑↓", "Navigate"), + ("t", "Toggle"), + ("Enter", "Edit"), + ("p", "Preview"), + ("Space", "Play"), + ("<>", "Length"), + ("[]", "Speed"), ], Page::Patterns => vec![ - ("←→↑↓", "nav"), - ("Enter", "select"), - ("Space", "play"), - ("Esc", "back"), + ("←→↑↓", "Navigate"), + ("Enter", "Select"), + ("Space", "Play"), + ("Esc", "Back"), + ("r", "Rename"), + ("Del", "Reset"), ], Page::Audio => vec![ - ("q", "quit"), - ("h", "hush"), - ("p", "panic"), - ("r", "reset"), - ("t", "test"), - ("C-←→", "page"), + ("↑↓", "Navigate"), + ("←→", "Adjust"), + ("h", "Hush"), + ("p", "Panic"), + ("r", "Reset"), + ("t", "Test"), + ("Space", "Play"), + ], + Page::Doc => vec![ + ("↑↓", "Scroll"), + ("←→", "Category"), + ("Tab", "Topic"), + ("PgUp/Dn", "Page"), ], - Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")], }; let page_width = page_indicator.chars().count(); @@ -245,7 +263,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Style::new().fg(Color::Yellow), )); spans.push(Span::styled( - format!(" {action}"), + format!(":{action}"), Style::new().fg(Color::Rgb(120, 125, 135)), )); @@ -342,6 +360,86 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(Color::Magenta) .render_centered(frame, term); } + Modal::Preview => { + let width = (term.width * 80 / 100).max(40); + let height = (term.height * 80 / 100).max(10); + + let pattern = app.current_edit_pattern(); + let step_idx = app.editor_ctx.step; + let step = pattern.step(step_idx); + let source_idx = step.and_then(|s| s.source); + + let title = if let Some(src) = source_idx { + format!("Step {:02} → {:02}", step_idx + 1, src + 1) + } else { + format!("Step {:02}", step_idx + 1) + }; + + let inner = ModalFrame::new(&title) + .width(width) + .height(height) + .border_color(Color::Rgb(120, 125, 135)) + .render_centered(frame, term); + + let script = pattern.resolve_script(step_idx).unwrap_or(""); + if script.is_empty() { + let empty = Paragraph::new("(empty)") + .alignment(Alignment::Center) + .style(Style::new().fg(Color::Rgb(80, 85, 95))); + let centered_area = Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }; + frame.render_widget(empty, centered_area); + } else { + let runtime_spans = if app.ui.runtime_highlight && app.playback.playing { + snapshot.get_trace( + app.editor_ctx.bank, + app.editor_ctx.pattern, + step_idx, + ) + } else { + None + }; + + let mut offset = 0usize; + let lines: Vec = script + .lines() + .map(|line_str| { + let tokens = if let Some(traces) = runtime_spans { + let shifted: Vec<_> = traces + .iter() + .filter_map(|s| { + let start = s.start.saturating_sub(offset); + let end = s.end.saturating_sub(offset); + if end > 0 && start < line_str.len() { + Some(SourceSpan { + start: start.min(line_str.len()), + end: end.min(line_str.len()), + }) + } else { + None + } + }) + .collect(); + highlight_line_with_runtime(line_str, &shifted) + } else { + highlight_line(line_str) + }; + offset += line_str.len() + 1; + let spans: Vec = tokens + .into_iter() + .map(|(style, text)| Span::styled(text, style)) + .collect(); + Line::from(spans) + }) + .collect(); + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, inner); + } + } Modal::Editor => { let width = (term.width * 80 / 100).max(40); let height = (term.height * 60 / 100).max(10);