This commit is contained in:
2026-01-23 01:18:40 +01:00
parent 1aad52eed1
commit 10e2812e4c
5 changed files with 157 additions and 91 deletions

View File

@@ -20,6 +20,8 @@ use crate::state::{
}; };
use crate::views::doc_view; use crate::views::doc_view;
const STEPS_PER_PAGE: usize = 32;
pub struct App { pub struct App {
pub project_state: ProjectState, pub project_state: ProjectState,
pub ui: UiState, pub ui: UiState,
@@ -148,8 +150,10 @@ impl App {
pub fn step_up(&mut self) { pub fn step_up(&mut self) {
let len = self.current_edit_pattern().length; let len = self.current_edit_pattern().length;
let num_rows = len.div_ceil(8); let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
let steps_per_row = len.div_ceil(num_rows); 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 { if self.editor_ctx.step >= steps_per_row {
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) { pub fn step_down(&mut self) {
let len = self.current_edit_pattern().length; let len = self.current_edit_pattern().length;
let num_rows = len.div_ceil(8); let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
let steps_per_row = len.div_ceil(num_rows); 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.editor_ctx.step = (self.editor_ctx.step + steps_per_row) % len;
self.load_step_to_editor(); self.load_step_to_editor();

View File

@@ -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!(), Modal::None => unreachable!(),
} }
InputResult::Continue 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(']') => ctx.dispatch(AppCommand::SpeedIncrease),
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)),
KeyCode::Delete | KeyCode::Backspace => { KeyCode::Delete | KeyCode::Backspace => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
let step = ctx.app.editor_ctx.step; let step = ctx.app.editor_ctx.step;
@@ -646,7 +655,7 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('t') => { KeyCode::Char('t') => {
let _ = ctx let _ = ctx
.audio_tx .audio_tx
.send(AudioCommand::Evaluate("sin 440 * 0.3".into())); .send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into()));
} }
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying); ctx.dispatch(AppCommand::TogglePlaying);

View File

@@ -39,4 +39,5 @@ pub enum Modal {
SetTempo(String), SetTempo(String),
AddSamplePath(String), AddSamplePath(String),
Editor, Editor,
Preview,
} }

View File

@@ -1,12 +1,10 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::views::highlight::{highlight_line, highlight_line_with_runtime};
use crate::widgets::{Orientation, Scope, VuMeter}; use crate::widgets::{Orientation, Scope, VuMeter};
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { 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); .areas(area);
let [scope_area, sequencer_area, preview_area] = Layout::vertical([ let [scope_area, sequencer_area] = Layout::vertical([
Constraint::Length(8), Constraint::Length(14),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(2),
]) ])
.areas(left_area); .areas(left_area);
render_scope(frame, app, scope_area); render_scope(frame, app, scope_area);
render_sequencer(frame, app, snapshot, sequencer_area); render_sequencer(frame, app, snapshot, sequencer_area);
render_step_preview(frame, app, snapshot, preview_area);
render_vu_meter(frame, app, vu_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) { fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
if area.width < 50 { if area.width < 50 {
let msg = Paragraph::new("Terminal too narrow") 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 pattern = app.current_edit_pattern();
let length = pattern.length; let length = pattern.length;
let num_rows = length.div_ceil(8); let page = app.editor_ctx.step / STEPS_PER_PAGE;
let steps_per_row = length.div_ceil(num_rows); 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 spacing = num_rows.saturating_sub(1) as u16;
let row_height = area.height.saturating_sub(spacing) / num_rows 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 { for row_idx in 0..num_rows {
let row_area = rows[row_idx * 2]; let row_area = rows[row_idx * 2];
let start_step = row_idx * steps_per_row; 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 cols_in_row = end_step - start_step;
let col_constraints: Vec<Constraint> = (0..cols_in_row * 2 - 1) let col_constraints: Vec<Constraint> = (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); let cols = Layout::horizontal(col_constraints).split(row_area);
for col_idx in 0..cols_in_row { 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 { if step_idx < length {
render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx); render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx);
} }
} }
} }
} }
fn render_tile( fn render_tile(
@@ -168,54 +171,3 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(vu, area); 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);
}

View File

@@ -6,9 +6,10 @@ use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot}; use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::forth::SourceSpan;
use crate::page::Page; use crate::page::Page;
use crate::state::{Modal, PatternField}; 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 crate::widgets::{ConfirmModal, ModalFrame, TextInputModal};
use super::{audio_view, doc_view, main_view, patterns_view, title_view}; 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 { let padded = Rect {
x: term.x + 1, x: term.x + 4,
y: term.y + 1, y: term.y + 1,
width: term.width.saturating_sub(2), width: term.width.saturating_sub(8),
height: term.height.saturating_sub(2), 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::Length(1), Constraint::Length(1),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(3), Constraint::Length(3),
]) ])
.areas(padded); .areas(padded);
@@ -130,7 +132,7 @@ fn render_header(
bank_area, 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 default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name); let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
let speed_info = if pattern.speed != PatternSpeed::Normal { let speed_info = if pattern.speed != PatternSpeed::Normal {
@@ -138,13 +140,20 @@ fn render_header(
} else { } else {
String::new() 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 let iter_info = snapshot
.get_iter(app.editor_ctx.bank, app.editor_ctx.pattern) .get_iter(app.editor_ctx.bank, app.editor_ctx.pattern)
.map(|iter| format!(" · #{}", iter + 1)) .map(|iter| format!(" · #{}", iter + 1))
.unwrap_or_default(); .unwrap_or_default();
let pattern_text = format!( let pattern_text = format!(
" {} · {} steps{}{} ", " {} · {} steps{}{}{} ",
pattern_name, pattern.length, speed_info, iter_info pattern_name, pattern.length, speed_info, page_info, iter_info
); );
let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White); let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White);
frame.render_widget( frame.render_widget(
@@ -194,28 +203,37 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
} else { } else {
let bindings: Vec<(&str, &str)> = match app.page { let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => vec![ Page::Main => vec![
("←→↑↓", "nav"), ("←→↑↓", "Navigate"),
("t", "toggle"), ("t", "Toggle"),
("Enter", "edit"), ("Enter", "Edit"),
("<>", "len"), ("p", "Preview"),
("[]", "spd"), ("Space", "Play"),
("f", "fill"), ("<>", "Length"),
("[]", "Speed"),
], ],
Page::Patterns => vec![ Page::Patterns => vec![
("←→↑↓", "nav"), ("←→↑↓", "Navigate"),
("Enter", "select"), ("Enter", "Select"),
("Space", "play"), ("Space", "Play"),
("Esc", "back"), ("Esc", "Back"),
("r", "Rename"),
("Del", "Reset"),
], ],
Page::Audio => vec![ Page::Audio => vec![
("q", "quit"), ("↑↓", "Navigate"),
("h", "hush"), ("←→", "Adjust"),
("p", "panic"), ("h", "Hush"),
("r", "reset"), ("p", "Panic"),
("t", "test"), ("r", "Reset"),
("C-←→", "page"), ("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(); 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), Style::new().fg(Color::Yellow),
)); ));
spans.push(Span::styled( spans.push(Span::styled(
format!(" {action}"), format!(":{action}"),
Style::new().fg(Color::Rgb(120, 125, 135)), 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) .border_color(Color::Magenta)
.render_centered(frame, term); .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<Line> = 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<Span> = 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 => { Modal::Editor => {
let width = (term.width * 80 / 100).max(40); let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10); let height = (term.height * 60 / 100).max(10);