Init
This commit is contained in:
381
src/views/audio_view.rs
Normal file
381
src/views/audio_view.rs
Normal 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
266
src/views/doc_view.rs
Normal 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
299
src/views/highlight.rs
Normal 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
201
src/views/main_view.rs
Normal 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
9
src/views/mod.rs
Normal 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
297
src/views/patterns_view.rs
Normal 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
448
src/views/render.rs
Normal 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
51
src/views/title_view.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user