This commit is contained in:
2026-01-21 17:05:30 +01:00
commit 67322381c3
59 changed files with 10421 additions and 0 deletions

381
src/views/audio_view.rs Normal file
View File

@@ -0,0 +1,381 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
use ratatui::Frame;
use crate::app::App;
use crate::engine::LinkState;
use crate::state::AudioFocus;
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [left_col, _, right_col] = Layout::horizontal([
Constraint::Percentage(52),
Constraint::Length(2),
Constraint::Percentage(48),
])
.areas(area);
render_audio_section(frame, app, left_col);
render_link_section(frame, app, link, right_col);
}
fn truncate_name(name: &str, max_len: usize) -> String {
if name.len() > max_len {
format!("{}...", &name[..max_len.saturating_sub(3)])
} else {
name.to_string()
}
}
fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Audio ")
.border_style(Style::new().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let padded = Rect {
x: inner.x + 1,
y: inner.y + 1,
width: inner.width.saturating_sub(2),
height: inner.height.saturating_sub(1),
};
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(6),
Constraint::Length(1),
Constraint::Min(3),
])
.areas(padded);
render_devices(frame, app, devices_area);
render_settings(frame, app, settings_area);
render_samples(frame, app, samples_area);
}
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
frame.render_widget(Paragraph::new("Devices").style(header_style), header_area);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
let output_name = truncate_name(app.audio.current_output_device_name(), 35);
let input_name = truncate_name(app.audio.current_input_device_name(), 35);
let output_focused = app.audio.focus == AudioFocus::OutputDevice;
let input_focused = app.audio.focus == AudioFocus::InputDevice;
let rows = vec![
Row::new(vec![
Span::styled("Output", label_style),
render_selector(&output_name, output_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Input", label_style),
render_selector(&input_name, input_focused, highlight, normal),
]),
];
let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
frame.render_widget(table, content_area);
}
fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
frame.render_widget(Paragraph::new("Settings").style(header_style), header_area);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
let value_style = Style::new().fg(Color::Rgb(180, 180, 190));
let channels_focused = app.audio.focus == AudioFocus::Channels;
let buffer_focused = app.audio.focus == AudioFocus::BufferSize;
let fps_focused = app.audio.focus == AudioFocus::RefreshRate;
let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight;
let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" };
let rows = vec![
Row::new(vec![
Span::styled("Channels", label_style),
render_selector(
&format!("{}", app.audio.config.channels),
channels_focused,
highlight,
normal,
),
]),
Row::new(vec![
Span::styled("Buffer", label_style),
render_selector(
&format!("{}", app.audio.config.buffer_size),
buffer_focused,
highlight,
normal,
),
]),
Row::new(vec![
Span::styled("FPS", label_style),
render_selector(
app.audio.config.refresh_rate.label(),
fps_focused,
highlight,
normal,
),
]),
Row::new(vec![
Span::styled("Highlight", label_style),
render_selector(highlight_text, highlight_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Rate", label_style),
Span::styled(
format!("{:.0} Hz", app.audio.config.sample_rate),
value_style,
),
]),
];
let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
frame.render_widget(table, content_area);
}
fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let samples_focused = app.audio.focus == AudioFocus::SamplePaths;
let header_text = format!(
"Samples {} paths · {} indexed",
app.audio.config.sample_paths.len(),
app.audio.config.sample_count
);
let header_line = if samples_focused {
Line::from(vec![
Span::styled("Samples ", header_style),
Span::styled(
format!(
"{} paths · {} indexed",
app.audio.config.sample_paths.len(),
app.audio.config.sample_count
),
highlight,
),
])
} else {
Line::from(Span::styled(header_text, header_style))
};
frame.render_widget(Paragraph::new(header_line), header_area);
let dim = Style::new().fg(Color::Rgb(80, 85, 95));
let path_style = Style::new().fg(Color::Rgb(120, 125, 135));
let mut lines: Vec<Line> = Vec::new();
for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() {
let path_str = path.to_string_lossy();
let display = truncate_name(&path_str, 45);
lines.push(Line::from(vec![
Span::styled(format!(" {} ", i + 1), dim),
Span::styled(display, path_style),
]));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" No sample paths configured",
dim,
)));
}
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Ableton Link ")
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let padded = Rect {
x: inner.x + 1,
y: inner.y + 1,
width: inner.width.saturating_sub(2),
height: inner.height.saturating_sub(1),
};
let [status_area, _, config_area, _, info_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(padded);
render_link_status(frame, link, status_area);
render_link_config(frame, app, link, config_area);
render_link_info(frame, link, info_area);
}
fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) {
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD);
let peer_text = if enabled {
if peers == 0 {
"No peers".to_string()
} else if peers == 1 {
"1 peer".to_string()
} else {
format!("{peers} peers")
}
} else {
String::new()
};
let lines = vec![
Line::from(Span::styled(status_text, status_style)),
Line::from(Span::styled(
peer_text,
Style::new().fg(Color::Rgb(120, 125, 135)),
)),
];
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
}
fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
frame.render_widget(
Paragraph::new("Configuration").style(header_style),
header_area,
);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled;
let startstop_focused = app.audio.focus == AudioFocus::StartStopSync;
let quantum_focused = app.audio.focus == AudioFocus::Quantum;
let enabled_text = if link.is_enabled() { "On" } else { "Off" };
let startstop_text = if link.is_start_stop_sync_enabled() {
"On"
} else {
"Off"
};
let quantum_text = format!("{:.0}", link.quantum());
let rows = vec![
Row::new(vec![
Span::styled("Enabled", label_style),
render_selector(enabled_text, enabled_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Start/Stop", label_style),
render_selector(startstop_text, startstop_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Quantum", label_style),
render_selector(&quantum_text, quantum_focused, highlight, normal),
]),
];
let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]);
frame.render_widget(table, content_area);
}
fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
frame.render_widget(Paragraph::new("Session").style(header_style), header_area);
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
let value_style = Style::new().fg(Color::Rgb(180, 180, 190));
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let tempo = link.tempo();
let beat = link.beat();
let phase = link.phase();
let rows = vec![
Row::new(vec![
Span::styled("Tempo", label_style),
Span::styled(format!("{tempo:.1} BPM"), tempo_style),
]),
Row::new(vec![
Span::styled("Beat", label_style),
Span::styled(format!("{beat:.2}"), value_style),
]),
Row::new(vec![
Span::styled("Phase", label_style),
Span::styled(format!("{phase:.2}"), value_style),
]),
];
let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]);
frame.render_widget(table, content_area);
}
fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> {
let style = if focused { highlight } else { normal };
if focused {
Span::styled(format!("< {value} >"), style)
} else {
Span::styled(format!(" {value} "), style)
}
}

266
src/views/doc_view.rs Normal file
View File

@@ -0,0 +1,266 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::model::forth::{Word, WordCompile, WORDS};
const STATIC_DOCS: &[(&str, &str)] = &[
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
];
const TOPICS: &[&str] = &["Keybindings", "Forth Reference", "Sequencer"];
const CATEGORIES: &[&str] = &[
"Stack",
"Arithmetic",
"Comparison",
"Logic",
"Sound",
"Variables",
"Randomness",
"Probability",
"Context",
"Music",
"Time",
"Parameters",
];
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
render_topics(frame, app, topics_area);
let topic = TOPICS[app.ui.doc_topic];
if topic == "Forth Reference" {
render_forth_reference(frame, app, content_area);
} else {
render_markdown_content(frame, app, content_area, topic);
}
}
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = TOPICS
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.doc_topic {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.doc_topic { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
frame.render_widget(list, area);
}
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
let md = STATIC_DOCS
.iter()
.find(|(name, _)| *name == topic)
.map(|(_, content)| *content)
.unwrap_or("");
let lines = parse_markdown(md);
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = app.ui.doc_scroll.min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.collect();
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
frame.render_widget(para, area);
}
fn render_forth_reference(frame: &mut Frame, app: &App, area: Rect) {
let [cat_area, words_area] =
Layout::horizontal([Constraint::Length(14), Constraint::Fill(1)]).areas(area);
render_categories(frame, app, cat_area);
render_words(frame, app, words_area);
}
fn render_categories(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = CATEGORIES
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.doc_category {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.doc_category { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Category"));
frame.render_widget(list, area);
}
fn render_words(frame: &mut Frame, app: &App, area: Rect) {
let category = CATEGORIES[app.ui.doc_category];
let words: Vec<&Word> = WORDS
.iter()
.filter(|w| word_category(w.name, &w.compile) == category)
.collect();
let word_style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD);
let stack_style = Style::new().fg(Color::Magenta);
let desc_style = Style::new().fg(Color::White);
let example_style = Style::new().fg(Color::Rgb(150, 150, 150));
let mut lines: Vec<RLine> = Vec::new();
for word in &words {
lines.push(RLine::from(vec![
Span::styled(format!("{:<14}", word.name), word_style),
Span::styled(format!("{:<18}", word.stack), stack_style),
Span::styled(word.desc.to_string(), desc_style),
]));
lines.push(RLine::from(vec![
Span::raw(" "),
Span::styled(format!("e.g. {}", word.example), example_style),
]));
lines.push(RLine::from(""));
}
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = app.ui.doc_scroll.min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.collect();
let title = format!("{category} ({} words)", words.len());
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(para, area);
}
fn word_category(name: &str, compile: &WordCompile) -> &'static str {
const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const ARITH: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max",
];
const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="];
const LOGIC: &[&str] = &["and", "or", "not"];
const SOUND: &[&str] = &["sound", "s", "emit"];
const VAR: &[&str] = &["get", "set"];
const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"];
const MUSIC: &[&str] = &["mtof", "ftom"];
const TIME: &[&str] = &[
"at", "window", "pop", "div", "each", "tempo!", "[", "]", "?",
];
match compile {
WordCompile::Simple if STACK.contains(&name) => "Stack",
WordCompile::Simple if ARITH.contains(&name) => "Arithmetic",
WordCompile::Simple if CMP.contains(&name) => "Comparison",
WordCompile::Simple if LOGIC.contains(&name) => "Logic",
WordCompile::Simple if SOUND.contains(&name) => "Sound",
WordCompile::Alias(_) => "Sound",
WordCompile::Simple if VAR.contains(&name) => "Variables",
WordCompile::Simple if RAND.contains(&name) => "Randomness",
WordCompile::Probability(_) => "Probability",
WordCompile::Context(_) => "Context",
WordCompile::Simple if MUSIC.contains(&name) => "Music",
WordCompile::Simple if TIME.contains(&name) => "Time",
WordCompile::Param => "Parameters",
_ => "Other",
}
}
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
let text = minimad::Text::from(md);
let mut lines = Vec::new();
for line in text.lines {
match line {
Line::Normal(composite) => {
lines.push(composite_to_line(composite));
}
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
lines.push(RLine::from(""));
}
}
}
lines
}
fn composite_to_line(composite: Composite) -> RLine<'static> {
let base_style = match composite.style {
CompositeStyle::Header(1) => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
CompositeStyle::Code => Style::new().fg(Color::Green),
CompositeStyle::Paragraph => Style::new().fg(Color::White),
};
let prefix = match composite.style {
CompositeStyle::ListItem(_) => "",
CompositeStyle::Quote => "",
_ => "",
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_string(), base_style));
}
for compound in composite.compounds {
spans.push(compound_to_span(compound, base_style));
}
RLine::from(spans)
}
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
let mut style = base;
if compound.bold {
style = style.add_modifier(Modifier::BOLD);
}
if compound.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if compound.code {
style = Style::new().fg(Color::Green);
}
if compound.strikeout {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
Span::styled(compound.src.to_string(), style)
}
pub fn topic_count() -> usize {
TOPICS.len()
}
pub fn category_count() -> usize {
CATEGORIES.len()
}

299
src/views/highlight.rs Normal file
View File

@@ -0,0 +1,299 @@
use ratatui::style::{Color, Modifier, Style};
use crate::model::SourceSpan;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Number,
String,
Comment,
Keyword,
StackOp,
Operator,
Sound,
Param,
Context,
Default,
}
impl TokenKind {
pub fn style(self) -> Style {
match self {
TokenKind::Number => Style::default().fg(Color::Rgb(255, 180, 100)),
TokenKind::String => Style::default().fg(Color::Rgb(150, 220, 150)),
TokenKind::Comment => Style::default().fg(Color::Rgb(100, 100, 100)),
TokenKind::Keyword => Style::default().fg(Color::Rgb(220, 120, 220)),
TokenKind::StackOp => Style::default().fg(Color::Rgb(120, 180, 220)),
TokenKind::Operator => Style::default().fg(Color::Rgb(200, 200, 130)),
TokenKind::Sound => Style::default().fg(Color::Rgb(100, 220, 200)),
TokenKind::Param => Style::default().fg(Color::Rgb(180, 150, 220)),
TokenKind::Context => Style::default().fg(Color::Rgb(220, 180, 120)),
TokenKind::Default => Style::default().fg(Color::Rgb(200, 200, 200)),
}
}
}
pub struct Token {
pub start: usize,
pub end: usize,
pub kind: TokenKind,
}
const STACK_OPS: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const OPERATORS: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and",
"or", "not",
];
const KEYWORDS: &[&str] = &[
"if", "else", "then", "emit", "get", "set", "rand", "rrand", "seed", "cycle", "choose",
"chance", "[", "]",
];
const SOUND: &[&str] = &["sound", "s"];
const CONTEXT: &[&str] = &[
"step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs",
];
const PARAMS: &[&str] = &[
"time",
"repeat",
"dur",
"gate",
"freq",
"detune",
"speed",
"glide",
"pw",
"spread",
"mult",
"warp",
"mirror",
"harmonics",
"timbre",
"morph",
"begin",
"end",
"gain",
"postgain",
"velocity",
"pan",
"attack",
"decay",
"sustain",
"release",
"lpf",
"lpq",
"lpe",
"lpa",
"lpd",
"lps",
"lpr",
"hpf",
"hpq",
"hpe",
"hpa",
"hpd",
"hps",
"hpr",
"bpf",
"bpq",
"bpe",
"bpa",
"bpd",
"bps",
"bpr",
"ftype",
"penv",
"patt",
"pdec",
"psus",
"prel",
"vib",
"vibmod",
"vibshape",
"fm",
"fmh",
"fmshape",
"fme",
"fma",
"fmd",
"fms",
"fmr",
"am",
"amdepth",
"amshape",
"rm",
"rmdepth",
"rmshape",
"phaser",
"phaserdepth",
"phasersweep",
"phasercenter",
"flanger",
"flangerdepth",
"flangerfeedback",
"chorus",
"chorusdepth",
"chorusdelay",
"comb",
"combfreq",
"combfeedback",
"combdamp",
"coarse",
"crush",
"fold",
"wrap",
"distort",
"distortvol",
"delay",
"delaytime",
"delayfeedback",
"delaytype",
"verb",
"verbdecay",
"verbdamp",
"verbpredelay",
"verbdiff",
"voice",
"orbit",
"note",
"size",
"n",
"cut",
"reset",
];
pub fn tokenize_line(line: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = line.char_indices().peekable();
while let Some((start, c)) = chars.next() {
if c.is_whitespace() {
continue;
}
if c == '(' {
let end = line.len();
let comment_end = line[start..]
.find(')')
.map(|i| start + i + 1)
.unwrap_or(end);
tokens.push(Token {
start,
end: comment_end,
kind: TokenKind::Comment,
});
while let Some((i, _)) = chars.peek() {
if *i >= comment_end {
break;
}
chars.next();
}
continue;
}
if c == '"' {
let mut end = start + 1;
for (i, ch) in chars.by_ref() {
end = i + ch.len_utf8();
if ch == '"' {
break;
}
}
tokens.push(Token {
start,
end,
kind: TokenKind::String,
});
continue;
}
let mut end = start + c.len_utf8();
while let Some((i, ch)) = chars.peek() {
if ch.is_whitespace() {
break;
}
end = *i + ch.len_utf8();
chars.next();
}
let word = &line[start..end];
let kind = classify_word(word);
tokens.push(Token { start, end, kind });
}
tokens
}
fn classify_word(word: &str) -> TokenKind {
if word.parse::<f64>().is_ok() || word.parse::<i64>().is_ok() {
return TokenKind::Number;
}
if STACK_OPS.contains(&word) {
return TokenKind::StackOp;
}
if OPERATORS.contains(&word) {
return TokenKind::Operator;
}
if KEYWORDS.contains(&word) {
return TokenKind::Keyword;
}
if SOUND.contains(&word) {
return TokenKind::Sound;
}
if CONTEXT.contains(&word) {
return TokenKind::Context;
}
if PARAMS.contains(&word) {
return TokenKind::Param;
}
TokenKind::Default
}
pub fn highlight_line(line: &str) -> Vec<(Style, String)> {
highlight_line_with_runtime(line, &[])
}
pub fn highlight_line_with_runtime(line: &str, runtime_spans: &[SourceSpan]) -> Vec<(Style, String)> {
let tokens = tokenize_line(line);
let mut result = Vec::new();
let mut last_end = 0;
let runtime_bg = Color::Rgb(80, 60, 20);
for token in tokens {
if token.start > last_end {
result.push((
TokenKind::Default.style(),
line[last_end..token.start].to_string(),
));
}
let is_runtime = runtime_spans
.iter()
.any(|span| overlaps(token.start, token.end, span.start, span.end));
let mut style = token.kind.style();
if is_runtime {
style = style.bg(runtime_bg).add_modifier(Modifier::BOLD);
}
result.push((style, line[token.start..token.end].to_string()));
last_end = token.end;
}
if last_end < line.len() {
result.push((TokenKind::Default.style(), line[last_end..].to_string()));
}
result
}
fn overlaps(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool {
a_start < b_end && b_start < a_end
}

201
src/views/main_view.rs Normal file
View File

@@ -0,0 +1,201 @@
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) {
let [left_area, _spacer, vu_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Length(8),
])
.areas(area);
let [scope_area, sequencer_area, preview_area] = Layout::vertical([
Constraint::Length(8),
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);
}
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
if area.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, area);
return;
}
let pattern = app.current_edit_pattern();
let length = pattern.length;
let num_rows = match length {
0..=8 => 1,
9..=16 => 2,
17..=24 => 3,
_ => 4,
};
let steps_per_row = length.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;
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
.map(|i| {
if i % 2 == 0 {
Constraint::Length(row_height)
} else {
Constraint::Length(1)
}
})
.collect();
let rows = Layout::vertical(row_constraints).split(area);
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 cols_in_row = end_step - start_step;
let col_constraints: Vec<Constraint> = (0..cols_in_row * 2 - 1)
.map(|i| {
if i % 2 == 0 {
Constraint::Fill(1)
} else if i == cols_in_row - 1 {
Constraint::Length(2)
} else {
Constraint::Length(1)
}
})
.collect();
let cols = Layout::horizontal(col_constraints).split(row_area);
for col_idx in 0..cols_in_row {
let step_idx = start_step + col_idx;
if step_idx < length {
render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx);
}
}
}
}
fn render_tile(
frame: &mut Frame,
area: Rect,
app: &App,
snapshot: &SequencerSnapshot,
step_idx: usize,
) {
let pattern = app.current_edit_pattern();
let step = pattern.step(step_idx);
let is_active = step.map(|s| s.active).unwrap_or(false);
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
let is_selected = step_idx == app.editor_ctx.step;
let is_playing = if app.playback.playing {
snapshot.get_step(app.editor_ctx.bank, app.editor_ctx.pattern) == Some(step_idx)
} else {
false
};
let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) {
(true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White),
(true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black),
(false, true, true, true) => (Color::Rgb(180, 140, 220), Color::Black),
(false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black),
(false, true, false, true) => (Color::Rgb(90, 70, 120), Color::White),
(false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White),
(false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black),
(false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
};
let symbol = if is_playing {
"".to_string()
} else if let Some(source) = step.and_then(|s| s.source) {
format!("{:02}", source + 1)
} else {
format!("{:02}", step_idx + 1)
};
let tile = Paragraph::new(symbol)
.alignment(Alignment::Center)
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
frame.render_widget(tile, area);
}
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Horizontal)
.color(Color::Green);
frame.render_widget(scope, area);
}
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
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)
} 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);
}

9
src/views/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod audio_view;
pub mod doc_view;
pub mod highlight;
pub mod main_view;
pub mod patterns_view;
mod render;
pub mod title_view;
pub use render::render;

297
src/views/patterns_view.rs Normal file
View File

@@ -0,0 +1,297 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::state::PatternsColumn;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let [banks_area, gap, patterns_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
render_banks(frame, app, snapshot, banks_area);
// gap is just empty space
let _ = gap;
render_patterns(frame, app, snapshot, patterns_area);
}
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
};
let title = Paragraph::new("Banks")
.style(Style::new().fg(title_color))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(title, title_area);
let banks_with_playback: Vec<usize> = snapshot
.active_patterns
.iter()
.map(|p| p.bank)
.collect();
let banks_with_queued: Vec<usize> = app
.playback
.queued_changes
.iter()
.filter_map(|c| match c {
crate::engine::PatternChange::Start { bank, .. } => Some(*bank),
_ => None,
})
.collect();
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
let is_selected = idx == app.patterns_nav.bank_cursor;
let is_edit = idx == app.editor_ctx.bank;
let is_playing = banks_with_playback.contains(&idx);
let is_queued = banks_with_queued.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let name = app.project_state.project.banks[idx]
.name
.as_deref()
.unwrap_or("");
let label = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued {
style.add_modifier(Modifier::BOLD)
} else {
style
};
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
let text_area = Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, text_area);
}
}
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
use crate::model::PatternSpeed;
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
};
let bank = app.patterns_nav.bank_cursor;
let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title_text = match bank_name {
Some(name) => format!("Patterns ({name})"),
None => format!("Patterns (Bank {:02})", bank + 1),
};
let title = Paragraph::new(title_text)
.style(Style::new().fg(title_color))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(title, title_area);
let playing_patterns: Vec<usize> = snapshot
.active_patterns
.iter()
.filter(|p| p.bank == bank)
.map(|p| p.pattern)
.collect();
let queued_to_play: Vec<usize> = app
.playback
.queued_changes
.iter()
.filter_map(|c| match c {
crate::engine::PatternChange::Start {
bank: b, pattern, ..
} if *b == bank => Some(*pattern),
_ => None,
})
.collect();
let queued_to_stop: Vec<usize> = app
.playback
.queued_changes
.iter()
.filter_map(|c| match c {
crate::engine::PatternChange::Stop {
bank: b,
pattern,
} if *b == bank => Some(*pattern),
_ => None,
})
.collect();
let edit_pattern = if app.editor_ctx.bank == bank {
Some(app.editor_ctx.pattern)
} else {
None
};
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
let is_selected = idx == app.patterns_nav.pattern_cursor;
let is_edit = edit_pattern == Some(idx);
let is_playing = playing_patterns.contains(&idx);
let is_queued_play = queued_to_play.contains(&idx);
let is_queued_stop = queued_to_stop.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) {
(true, _, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "),
(false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
};
let pattern = &app.project_state.project.banks[bank].patterns[idx];
let name = pattern.name.as_deref().unwrap_or("");
let length = pattern.length;
let speed = pattern.speed;
let base_style = Style::new().bg(bg).fg(fg);
let bold_style = base_style.add_modifier(Modifier::BOLD);
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
// Split row into columns: [index+name] [length] [speed]
let speed_width: u16 = 14; // "Speed: 1/4x "
let length_width: u16 = 13; // "Length: 16 "
let name_width = row_area
.width
.saturating_sub(speed_width + length_width + 2);
let [name_area, length_area, speed_area] = Layout::horizontal([
Constraint::Length(name_width),
Constraint::Length(length_width),
Constraint::Length(speed_width),
])
.areas(Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let name_style = if is_playing || is_queued_play {
bold_style
} else {
base_style
};
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area);
// Column 2: length
let length_line = Line::from(vec![
Span::styled("Length: ", bold_style),
Span::styled(format!("{length}"), base_style),
]);
frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default)
if speed != PatternSpeed::Normal {
let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style),
]);
frame.render_widget(Paragraph::new(speed_line), speed_area);
}
}
}

448
src/views/render.rs Normal file
View File

@@ -0,0 +1,448 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot};
use crate::page::Page;
use crate::state::{Modal, PatternField};
use crate::views::highlight;
use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal};
use super::{audio_view, doc_view, main_view, patterns_view, title_view};
pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) {
let term = frame.area();
if app.ui.show_title {
title_view::render(frame, term);
return;
}
let padded = Rect {
x: term.x + 1,
y: term.y + 1,
width: term.width.saturating_sub(2),
height: term.height.saturating_sub(2),
};
let [header_area, _padding, body_area, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(3),
])
.areas(padded);
render_header(frame, app, link, snapshot, header_area);
match app.page {
Page::Main => main_view::render(frame, app, snapshot, body_area),
Page::Patterns => patterns_view::render(frame, app, snapshot, body_area),
Page::Audio => audio_view::render(frame, app, link, body_area),
Page::Doc => doc_view::render(frame, app, body_area),
}
render_footer(frame, app, footer_area);
render_modal(frame, app, snapshot, term);
}
fn render_header(
frame: &mut Frame,
app: &App,
link: &LinkState,
snapshot: &SequencerSnapshot,
area: Rect,
) {
use crate::model::PatternSpeed;
let bank = &app.project_state.project.banks[app.editor_ctx.bank];
let pattern = &bank.patterns[app.editor_ctx.pattern];
// Layout: [Transport] [Live] [Tempo] [Bank] [Pattern] [Stats]
let [transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] =
Layout::horizontal([
Constraint::Min(12),
Constraint::Length(9),
Constraint::Min(14),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
])
.areas(area);
// Transport block
let (transport_bg, transport_text) = if app.playback.playing {
(Color::Rgb(30, 80, 30), " ▶ PLAYING ")
} else {
(Color::Rgb(80, 30, 30), " ■ STOPPED ")
};
let transport_style = Style::new().bg(transport_bg).fg(Color::White);
frame.render_widget(
Paragraph::new(transport_text)
.style(transport_style)
.alignment(Alignment::Center),
transport_area,
);
// Fill indicator
let fill = app.live_keys.fill();
let fill_style = if fill {
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100))
} else {
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70))
};
frame.render_widget(
Paragraph::new(if fill { "F" } else { "·" })
.style(fill_style)
.alignment(Alignment::Center),
live_area,
);
// Tempo block
let tempo_style = Style::new()
.bg(Color::Rgb(60, 30, 60))
.fg(Color::White)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(format!(" {:.1} BPM ", link.tempo()))
.style(tempo_style)
.alignment(Alignment::Center),
tempo_area,
);
// Bank block
let bank_name = bank
.name
.as_deref()
.map(|n| format!(" {n} "))
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White);
frame.render_widget(
Paragraph::new(bank_name)
.style(bank_style)
.alignment(Alignment::Center),
bank_area,
);
// Pattern block (name + length + speed + 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 {
format!(" · {}", pattern.speed.label())
} 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
);
let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White);
frame.render_widget(
Paragraph::new(pattern_text)
.style(pattern_style)
.alignment(Alignment::Center),
pattern_area,
);
// Stats block
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let peers = link.peers();
let voices = app.metrics.active_voices;
let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} ");
let stats_style = Style::new()
.bg(Color::Rgb(35, 35, 40))
.fg(Color::Rgb(150, 150, 160));
frame.render_widget(
Paragraph::new(stats_text)
.style(stats_style)
.alignment(Alignment::Right),
stats_area,
);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
let available_width = inner.width as usize;
let page_indicator = match app.page {
Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS]",
Page::Audio => "[AUDIO]",
Page::Doc => "[DOC]",
};
let content = if let Some(ref msg) = app.ui.status_message {
Line::from(vec![
Span::styled(
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" "),
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
])
} else {
let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => vec![
("←→↑↓", "nav"),
("t", "toggle"),
("Enter", "edit"),
("<>", "len"),
("[]", "spd"),
("f", "fill"),
],
Page::Patterns => vec![
("←→↑↓", "nav"),
("Enter", "select"),
("Space", "play"),
("Esc", "back"),
],
Page::Audio => vec![
("q", "quit"),
("h", "hush"),
("p", "panic"),
("r", "reset"),
("t", "test"),
("C-←→", "page"),
],
Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")],
};
let page_width = page_indicator.chars().count();
let bindings_content_width: usize = bindings
.iter()
.map(|(k, a)| k.chars().count() + 1 + a.chars().count())
.sum();
let n = bindings.len();
let total_content = page_width + bindings_content_width;
let total_gaps = available_width.saturating_sub(total_content);
let gap_count = n + 1;
let base_gap = total_gaps / gap_count;
let extra = total_gaps % gap_count;
let mut spans = vec![
Span::styled(
page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
),
Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })),
];
for (i, (key, action)) in bindings.into_iter().enumerate() {
spans.push(Span::styled(
key.to_string(),
Style::new().fg(Color::Yellow),
));
spans.push(Span::styled(
format!(" {action}"),
Style::new().fg(Color::Rgb(120, 125, 135)),
));
if i < n - 1 {
let gap = base_gap + if i + 1 < extra { 1 } else { 0 };
spans.push(Span::raw(" ".repeat(gap)));
}
}
Line::from(spans)
};
let footer = Paragraph::new(content).block(block);
frame.render_widget(footer, area);
}
fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) {
match &app.ui.modal {
Modal::None => {}
Modal::ConfirmQuit { selected } => {
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
}
Modal::ConfirmDeleteStep { step, selected, .. } => {
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
.render_centered(frame, term);
}
Modal::ConfirmResetPattern {
pattern, selected, ..
} => {
ConfirmModal::new(
"Confirm",
&format!("Reset pattern {}?", pattern + 1),
*selected,
)
.render_centered(frame, term);
}
Modal::ConfirmResetBank { bank, selected } => {
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
.render_centered(frame, term);
}
Modal::SaveAs(path) => {
TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path)
.width(60)
.border_color(Color::Green)
.render_centered(frame, term);
}
Modal::LoadFrom(path) => {
TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path)
.width(60)
.border_color(Color::Blue)
.render_centered(frame, term);
}
Modal::RenameBank { bank, name } => {
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
.width(40)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::RenamePattern {
bank,
pattern,
name,
} => {
TextInputModal::new(
&format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
name,
)
.width(40)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::SetPattern { field, input } => {
let (title, hint) = match field {
PatternField::Length => ("Set Length (2-32)", "Enter number"),
PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"),
};
TextInputModal::new(title, input)
.hint(hint)
.width(45)
.border_color(Color::Yellow)
.render_centered(frame, term);
}
Modal::SetTempo(input) => {
TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::AddSamplePath(path) => {
TextInputModal::new("Add Sample Path", path)
.hint("Enter directory path containing samples")
.width(60)
.border_color(Color::Magenta)
.render_centered(frame, term);
}
Modal::Editor => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let step_num = app.editor_ctx.step + 1;
let border_color = if app.ui.is_flashing() {
Color::Green
} else {
Color::Rgb(100, 160, 180)
};
let inner = ModalFrame::new(&format!("Step {step_num:02} Script"))
.width(width)
.height(height)
.border_color(border_color)
.render_centered(frame, term);
let (cursor_row, cursor_col) = app.editor_ctx.text.cursor();
let runtime_spans = if app.ui.runtime_highlight && app.playback.playing {
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern)
} else {
None
};
let text_lines = app.editor_ctx.text.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 lines: Vec<Line> = text_lines
.iter()
.enumerate()
.map(|(row, line)| {
let mut spans: Vec<Span> = Vec::new();
let line_start = line_offsets[row];
let line_end = line_start + line.len();
let adjusted_spans: Vec<crate::model::SourceSpan> = runtime_spans
.map(|rs| {
rs.iter()
.filter_map(|s| {
if s.start < line_end && s.end > line_start {
Some(crate::model::SourceSpan {
start: s.start.saturating_sub(line_start),
end: s.end.saturating_sub(line_start).min(line.len()),
})
} else {
None
}
})
.collect()
})
.unwrap_or_default();
let tokens = highlight::highlight_line_with_runtime(line, &adjusted_spans);
if row == cursor_row {
let mut col = 0;
for (style, text) in tokens {
let text_len = text.chars().count();
if cursor_col >= col && cursor_col < col + text_len {
let before =
text.chars().take(cursor_col - col).collect::<String>();
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
let after =
text.chars().skip(cursor_col - col + 1).collect::<String>();
if !before.is_empty() {
spans.push(Span::styled(before, style));
}
spans.push(Span::styled(
cursor_char.to_string(),
Style::default().bg(Color::White).fg(Color::Black),
));
if !after.is_empty() {
spans.push(Span::styled(after, style));
}
} else {
spans.push(Span::styled(text, style));
}
col += text_len;
}
if cursor_col >= col {
spans.push(Span::styled(
" ",
Style::default().bg(Color::White).fg(Color::Black),
));
}
} else {
for (style, text) in tokens {
spans.push(Span::styled(text, style));
}
}
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
}
}

51
src/views/title_view.rs Normal file
View File

@@ -0,0 +1,51 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame, area: Rect) {
let title_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
let subtitle_style = Style::new().fg(Color::White);
let dim_style = Style::new()
.fg(Color::Rgb(120, 125, 135))
.add_modifier(Modifier::DIM);
let link_style = Style::new().fg(Color::Rgb(100, 160, 180));
let lines = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled("seq", title_style)),
Line::from(""),
Line::from(Span::styled("A Forth Music Sequencer", subtitle_style)),
Line::from(""),
Line::from(""),
Line::from(Span::styled("by BuboBubo", dim_style)),
Line::from(Span::styled("Raphael Maurice Forment", dim_style)),
Line::from(""),
Line::from(Span::styled("https://raphaelforment.fr", link_style)),
Line::from(""),
Line::from(""),
Line::from(Span::styled("AGPL-3.0", dim_style)),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(Span::styled(
"Press any key to continue",
Style::new().fg(Color::DarkGray),
)),
];
let text_height = lines.len() as u16;
let vertical_padding = area.height.saturating_sub(text_height) / 2;
let [_, center_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(text_height),
Constraint::Fill(1),
])
.areas(area);
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(paragraph, center_area);
}