1165 lines
42 KiB
Rust
1165 lines
42 KiB
Rust
use arc_swap::ArcSwap;
|
|
use parking_lot::Mutex;
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use rand::rngs::StdRng;
|
|
use rand::SeedableRng;
|
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
|
|
use ratatui::Frame;
|
|
|
|
use crate::app::App;
|
|
use crate::engine::{LinkState, SequencerSnapshot};
|
|
use crate::model::{SourceSpan, StepContext, Value};
|
|
use crate::page::Page;
|
|
use crate::state::{
|
|
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache,
|
|
};
|
|
use crate::theme;
|
|
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
|
use crate::widgets::{
|
|
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
|
|
};
|
|
use cagire_forth::Forth;
|
|
|
|
use super::{
|
|
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
|
};
|
|
|
|
fn compute_stack_display(
|
|
lines: &[String],
|
|
editor: &cagire_ratatui::Editor,
|
|
cache: &std::cell::RefCell<Option<StackCache>>,
|
|
) -> String {
|
|
let cursor_line = editor.cursor().0;
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if i > cursor_line {
|
|
break;
|
|
}
|
|
line.hash(&mut hasher);
|
|
}
|
|
let lines_hash = hasher.finish();
|
|
|
|
if let Some(ref c) = *cache.borrow() {
|
|
if c.cursor_line == cursor_line && c.lines_hash == lines_hash {
|
|
return c.result.clone();
|
|
}
|
|
}
|
|
|
|
let partial: Vec<&str> = lines
|
|
.iter()
|
|
.take(cursor_line + 1)
|
|
.map(|s| s.as_str())
|
|
.collect();
|
|
let script = partial.join("\n");
|
|
|
|
let result = if script.trim().is_empty() {
|
|
"Stack: []".to_string()
|
|
} else {
|
|
let vars = Arc::new(ArcSwap::from_pointee(HashMap::new()));
|
|
let dict = Arc::new(Mutex::new(HashMap::new()));
|
|
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
|
|
let forth = Forth::new(vars, dict, rng);
|
|
|
|
let ctx = StepContext {
|
|
step: 0,
|
|
beat: 0.0,
|
|
bank: 0,
|
|
pattern: 0,
|
|
tempo: 120.0,
|
|
phase: 0.0,
|
|
slot: 0,
|
|
runs: 0,
|
|
iter: 0,
|
|
speed: 1.0,
|
|
fill: false,
|
|
nudge_secs: 0.0,
|
|
cc_access: None,
|
|
speed_key: "",
|
|
chain_key: "",
|
|
#[cfg(feature = "desktop")]
|
|
mouse_x: 0.5,
|
|
#[cfg(feature = "desktop")]
|
|
mouse_y: 0.5,
|
|
#[cfg(feature = "desktop")]
|
|
mouse_down: 0.0,
|
|
};
|
|
|
|
match forth.evaluate(&script, &ctx) {
|
|
Ok(_) => {
|
|
let stack = forth.stack();
|
|
let formatted: Vec<String> = stack.iter().map(format_value).collect();
|
|
format!("Stack: [{}]", formatted.join(" "))
|
|
}
|
|
Err(e) => format!("Error: {e}"),
|
|
}
|
|
};
|
|
|
|
*cache.borrow_mut() = Some(StackCache {
|
|
cursor_line,
|
|
lines_hash,
|
|
result: result.clone(),
|
|
});
|
|
|
|
result
|
|
}
|
|
|
|
fn format_value(v: &Value) -> String {
|
|
match v {
|
|
Value::Int(n, _) => n.to_string(),
|
|
Value::Float(f, _) => {
|
|
if f.fract() == 0.0 && f.abs() < 1_000_000.0 {
|
|
format!("{f:.1}")
|
|
} else {
|
|
format!("{f:.4}")
|
|
}
|
|
}
|
|
Value::Str(s, _) => format!("\"{s}\""),
|
|
Value::Quotation(..) => "[...]".to_string(),
|
|
Value::CycleList(items) => {
|
|
let inner: Vec<String> = items.iter().map(format_value).collect();
|
|
format!("({})", inner.join(" "))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn adjust_spans_for_line(
|
|
spans: &[SourceSpan],
|
|
line_start: usize,
|
|
line_len: usize,
|
|
) -> Vec<SourceSpan> {
|
|
spans
|
|
.iter()
|
|
.filter_map(|s| {
|
|
if s.end <= line_start || s.start >= line_start + line_len {
|
|
return None;
|
|
}
|
|
Some(SourceSpan {
|
|
start: s.start.max(line_start) - line_start,
|
|
end: (s.end.min(line_start + line_len)) - line_start,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
|
let term = frame.area();
|
|
|
|
let theme = theme::get();
|
|
let bg_color = theme.ui.bg;
|
|
|
|
let blank = " ".repeat(term.width as usize);
|
|
let lines: Vec<Line> = (0..term.height).map(|_| Line::raw(&blank)).collect();
|
|
frame.render_widget(
|
|
Paragraph::new(lines).style(Style::default().bg(bg_color)),
|
|
term,
|
|
);
|
|
|
|
if app.ui.show_title {
|
|
title_view::render(frame, term, &app.ui);
|
|
return;
|
|
}
|
|
|
|
let padded = Rect {
|
|
x: term.x + 4,
|
|
y: term.y + 1,
|
|
width: term.width.saturating_sub(8),
|
|
height: term.height.saturating_sub(2),
|
|
};
|
|
|
|
let [header_area, _padding, body_area, _bottom_padding, footer_area] = Layout::vertical([
|
|
Constraint::Length(header_height(padded.width)),
|
|
Constraint::Length(1),
|
|
Constraint::Fill(1),
|
|
Constraint::Length(1),
|
|
Constraint::Length(3),
|
|
])
|
|
.areas(padded);
|
|
|
|
render_header(frame, app, link, snapshot, header_area);
|
|
|
|
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
|
|
if body_area.width >= 120 {
|
|
let panel_width = body_area.width * 35 / 100;
|
|
let [main, side] =
|
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
|
.areas(body_area);
|
|
(main, Some(side))
|
|
} else {
|
|
let panel_height = body_area.height * 40 / 100;
|
|
let [main, side] =
|
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
|
.areas(body_area);
|
|
(main, Some(side))
|
|
}
|
|
} else {
|
|
(body_area, None)
|
|
};
|
|
|
|
match app.page {
|
|
Page::Main => main_view::render(frame, app, snapshot, page_area),
|
|
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
|
|
Page::Engine => engine_view::render(frame, app, page_area),
|
|
Page::Options => options_view::render(frame, app, link, page_area),
|
|
Page::Help => help_view::render(frame, app, page_area),
|
|
Page::Dict => dict_view::render(frame, app, page_area),
|
|
}
|
|
|
|
if let Some(side_area) = panel_area {
|
|
render_side_panel(frame, app, side_area);
|
|
}
|
|
|
|
render_footer(frame, app, footer_area);
|
|
render_modal(frame, app, snapshot, term);
|
|
|
|
let show_minimap = app
|
|
.ui
|
|
.minimap_until
|
|
.map(|until| Instant::now() < until)
|
|
.unwrap_or(false);
|
|
|
|
if show_minimap {
|
|
let tiles: Vec<NavTile> = Page::ALL
|
|
.iter()
|
|
.map(|p| {
|
|
let (col, row) = p.grid_pos();
|
|
NavTile {
|
|
col,
|
|
row,
|
|
name: p.name(),
|
|
}
|
|
})
|
|
.collect();
|
|
let selected = app.page.grid_pos();
|
|
NavMinimap::new(&tiles, selected).render_centered(frame, term);
|
|
}
|
|
}
|
|
|
|
fn header_height(width: u16) -> u16 {
|
|
if width >= 80 {
|
|
1
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
|
|
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
|
|
let focused = app.panel.focus == PanelFocus::Side;
|
|
match &app.panel.side {
|
|
Some(SidePanel::SampleBrowser(state)) => {
|
|
let entries = state.entries();
|
|
SampleBrowser::new(&entries, state.cursor)
|
|
.scroll_offset(state.scroll_offset)
|
|
.search(&state.search_query, state.search_active)
|
|
.focused(focused)
|
|
.render(frame, area);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
fn render_header(
|
|
frame: &mut Frame,
|
|
app: &App,
|
|
link: &LinkState,
|
|
snapshot: &SequencerSnapshot,
|
|
area: Rect,
|
|
) {
|
|
use crate::model::PatternSpeed;
|
|
|
|
let theme = theme::get();
|
|
let bank = &app.project_state.project.banks[app.editor_ctx.bank];
|
|
let pattern = &bank.patterns[app.editor_ctx.pattern];
|
|
|
|
let (transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area) =
|
|
if area.height == 1 {
|
|
let [t, l, tp, b, p, s] = Layout::horizontal([
|
|
Constraint::Min(12),
|
|
Constraint::Length(9),
|
|
Constraint::Min(14),
|
|
Constraint::Fill(1),
|
|
Constraint::Fill(2),
|
|
Constraint::Min(20),
|
|
])
|
|
.areas(area);
|
|
(t, l, tp, b, p, s)
|
|
} else {
|
|
let [line1, line2] =
|
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
|
|
|
let [t, l, tp, s] = Layout::horizontal([
|
|
Constraint::Min(12),
|
|
Constraint::Length(9),
|
|
Constraint::Fill(1),
|
|
Constraint::Min(20),
|
|
])
|
|
.areas(line1);
|
|
|
|
let [b, p] =
|
|
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(2)]).areas(line2);
|
|
|
|
(t, l, tp, b, p, s)
|
|
};
|
|
|
|
// Transport block
|
|
let (transport_bg, transport_text) = if app.playback.playing {
|
|
(theme.status.playing_bg, " ▶ PLAYING ")
|
|
} else {
|
|
(theme.status.stopped_bg, " ■ STOPPED ")
|
|
};
|
|
let transport_style = Style::new().bg(transport_bg).fg(theme.ui.text_primary);
|
|
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_fg = if fill {
|
|
theme.status.fill_on
|
|
} else {
|
|
theme.status.fill_off
|
|
};
|
|
let fill_style = Style::new().bg(theme.status.fill_bg).fg(fill_fg);
|
|
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(theme.header.tempo_bg)
|
|
.fg(theme.ui.text_primary)
|
|
.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(theme.header.bank_bg)
|
|
.fg(theme.ui.text_primary);
|
|
frame.render_widget(
|
|
Paragraph::new(bank_name)
|
|
.style(bank_style)
|
|
.alignment(Alignment::Center),
|
|
bank_area,
|
|
);
|
|
|
|
// Pattern block (name + length + speed + page + iter)
|
|
let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
|
|
let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
|
|
let speed_info = if pattern.speed != PatternSpeed::NORMAL {
|
|
format!(" · {}", pattern.speed.label())
|
|
} else {
|
|
String::new()
|
|
};
|
|
let total_pages = pattern.length.div_ceil(32);
|
|
let page_info = if total_pages > 1 {
|
|
let current_page = app.editor_ctx.step / 32 + 1;
|
|
format!(" · {current_page}/{total_pages}")
|
|
} else {
|
|
String::new()
|
|
};
|
|
let iter_info = snapshot
|
|
.get_iter(app.editor_ctx.bank, app.editor_ctx.pattern)
|
|
.map(|iter| format!(" · #{}", iter + 1))
|
|
.unwrap_or_default();
|
|
let pattern_text = format!(
|
|
" {} · {} steps{}{}{} ",
|
|
pattern_name, pattern.length, speed_info, page_info, iter_info
|
|
);
|
|
let pattern_style = Style::new()
|
|
.bg(theme.header.pattern_bg)
|
|
.fg(theme.ui.text_primary);
|
|
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(theme.header.stats_bg)
|
|
.fg(theme.header.stats_fg);
|
|
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 theme = theme::get();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::new().fg(theme.ui.border));
|
|
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::Engine => " ENGINE ",
|
|
Page::Options => " OPTIONS ",
|
|
Page::Help => " HELP ",
|
|
Page::Dict => " DICT ",
|
|
};
|
|
|
|
let content = if let Some(ref msg) = app.ui.status_message {
|
|
Line::from(vec![
|
|
Span::styled(
|
|
page_indicator.to_string(),
|
|
Style::new().bg(theme.view_badge.bg).fg(theme.view_badge.fg),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled(msg.clone(), Style::new().fg(theme.modal.confirm)),
|
|
])
|
|
} else {
|
|
let bindings: Vec<(&str, &str)> = match app.page {
|
|
Page::Main => vec![
|
|
("Space", "Play"),
|
|
("Enter", "Edit"),
|
|
("t", "Toggle"),
|
|
("Tab", "Samples"),
|
|
("?", "Keys"),
|
|
],
|
|
Page::Patterns => vec![
|
|
("Enter", "Select"),
|
|
("Space", "Play"),
|
|
("r", "Rename"),
|
|
("?", "Keys"),
|
|
],
|
|
Page::Engine => vec![
|
|
("Tab", "Section"),
|
|
("←→", "Switch/Adjust"),
|
|
("↑↓", "Navigate"),
|
|
("Enter", "Select"),
|
|
("A", "Add path"),
|
|
("?", "Keys"),
|
|
],
|
|
Page::Options => vec![
|
|
("Tab", "Next"),
|
|
("←→", "Toggle"),
|
|
("Space", "Play"),
|
|
("?", "Keys"),
|
|
],
|
|
Page::Help => vec![
|
|
("↑↓", "Scroll"),
|
|
("Tab", "Topic"),
|
|
("PgUp/Dn", "Page"),
|
|
("/", "Search"),
|
|
("?", "Keys"),
|
|
],
|
|
Page::Dict => vec![
|
|
("Tab", "Focus"),
|
|
("↑↓", "Navigate"),
|
|
("/", "Search"),
|
|
("?", "Keys"),
|
|
],
|
|
};
|
|
|
|
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().bg(theme.view_badge.bg).fg(theme.view_badge.fg),
|
|
),
|
|
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(theme.hint.key),
|
|
));
|
|
spans.push(Span::styled(
|
|
format!(": {action}"),
|
|
Style::new().fg(theme.hint.text),
|
|
));
|
|
|
|
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) {
|
|
let theme = theme::get();
|
|
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::ConfirmDeleteSteps {
|
|
steps, selected, ..
|
|
} => {
|
|
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
|
|
let label = format!("Delete steps {}?", nums.join(", "));
|
|
ConfirmModal::new("Confirm", &label, *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::FileBrowser(state) => {
|
|
use crate::state::file_browser::FileBrowserMode;
|
|
use crate::widgets::FileBrowserModal;
|
|
let (title, border_color) = match state.mode {
|
|
FileBrowserMode::Save => ("Save As", theme.flash.success_fg),
|
|
FileBrowserMode::Load => ("Load From", theme.browser.directory),
|
|
};
|
|
let entries: Vec<(String, bool, bool)> = state
|
|
.entries
|
|
.iter()
|
|
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
|
.collect();
|
|
FileBrowserModal::new(title, &state.input, &entries)
|
|
.selected(state.selected)
|
|
.scroll_offset(state.scroll_offset)
|
|
.border_color(border_color)
|
|
.width(60)
|
|
.height(18)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::RenameBank { bank, name } => {
|
|
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
|
|
.width(40)
|
|
.border_color(theme.modal.rename)
|
|
.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(theme.modal.rename)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::RenameStep { step, name, .. } => {
|
|
TextInputModal::new(&format!("Name Step {:02}", step + 1), name)
|
|
.width(40)
|
|
.border_color(theme.modal.input)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::SetPattern { field, input } => {
|
|
let (title, hint) = match field {
|
|
PatternField::Length => ("Set Length (1-128)", "Enter number"),
|
|
PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
|
|
};
|
|
TextInputModal::new(title, input)
|
|
.hint(hint)
|
|
.width(45)
|
|
.border_color(theme.modal.confirm)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::SetTempo(input) => {
|
|
TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
|
.hint("Enter BPM")
|
|
.width(30)
|
|
.border_color(theme.modal.rename)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::AddSamplePath(state) => {
|
|
use crate::widgets::FileBrowserModal;
|
|
let entries: Vec<(String, bool, bool)> = state
|
|
.entries
|
|
.iter()
|
|
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
|
.collect();
|
|
FileBrowserModal::new("Add Sample Path", &state.input, &entries)
|
|
.selected(state.selected)
|
|
.scroll_offset(state.scroll_offset)
|
|
.border_color(theme.modal.rename)
|
|
.width(60)
|
|
.height(18)
|
|
.render_centered(frame, term);
|
|
}
|
|
Modal::Preview => {
|
|
let width = (term.width * 80 / 100).max(40);
|
|
let height = (term.height * 80 / 100).max(10);
|
|
|
|
let pattern = app.current_edit_pattern();
|
|
let step_idx = app.editor_ctx.step;
|
|
let step = pattern.step(step_idx);
|
|
let source_idx = step.and_then(|s| s.source);
|
|
let step_name = step.and_then(|s| s.name.as_ref());
|
|
|
|
let title = match (source_idx, step_name) {
|
|
(Some(src), Some(name)) => {
|
|
format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1)
|
|
}
|
|
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
|
|
(Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1),
|
|
(None, None) => format!("Step {:02}", step_idx + 1),
|
|
};
|
|
|
|
let inner = ModalFrame::new(&title)
|
|
.width(width)
|
|
.height(height)
|
|
.border_color(theme.modal.preview)
|
|
.render_centered(frame, term);
|
|
|
|
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
|
if script.is_empty() {
|
|
let empty = Paragraph::new("(empty)")
|
|
.alignment(Alignment::Center)
|
|
.style(Style::new().fg(theme.ui.text_dim));
|
|
let centered_area = Rect {
|
|
y: inner.y + inner.height / 2,
|
|
height: 1,
|
|
..inner
|
|
};
|
|
frame.render_widget(empty, centered_area);
|
|
} else {
|
|
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
|
let source = pattern.resolve_source(step_idx);
|
|
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut line_start = 0usize;
|
|
let lines: Vec<Line> = script
|
|
.lines()
|
|
.map(|line_str| {
|
|
let tokens = if let Some(t) = trace {
|
|
let exec = adjust_spans_for_line(
|
|
&t.executed_spans,
|
|
line_start,
|
|
line_str.len(),
|
|
);
|
|
let sel = adjust_spans_for_line(
|
|
&t.selected_spans,
|
|
line_start,
|
|
line_str.len(),
|
|
);
|
|
highlight_line_with_runtime(line_str, &exec, &sel)
|
|
} else {
|
|
highlight_line(line_str)
|
|
};
|
|
line_start += line_str.len() + 1;
|
|
let spans: Vec<Span> = tokens
|
|
.into_iter()
|
|
.map(|(style, text)| Span::styled(text, style))
|
|
.collect();
|
|
Line::from(spans)
|
|
})
|
|
.collect();
|
|
|
|
let paragraph = Paragraph::new(lines);
|
|
frame.render_widget(paragraph, inner);
|
|
}
|
|
}
|
|
Modal::Editor => {
|
|
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 step = app.current_edit_pattern().step(app.editor_ctx.step);
|
|
|
|
let flash_kind = app.ui.flash_kind();
|
|
let border_color = match flash_kind {
|
|
Some(FlashKind::Error) => theme.flash.error_fg,
|
|
Some(FlashKind::Info) => theme.ui.text_primary,
|
|
Some(FlashKind::Success) => theme.flash.success_fg,
|
|
None => theme.modal.editor,
|
|
};
|
|
|
|
let title = if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
|
|
format!("Step {step_num:02}: {name}")
|
|
} else {
|
|
format!("Step {step_num:02} Script")
|
|
};
|
|
|
|
let inner = ModalFrame::new(&title)
|
|
.width(width)
|
|
.height(height)
|
|
.border_color(border_color)
|
|
.render_centered(frame, term);
|
|
|
|
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
|
let source = app
|
|
.current_edit_pattern()
|
|
.resolve_source(app.editor_ctx.step);
|
|
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let text_lines = app.editor_ctx.editor.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 highlighter = |row: usize, line: &str| -> Vec<(Style, String)> {
|
|
let line_start = line_offsets[row];
|
|
let (exec, sel) = match trace {
|
|
Some(t) => (
|
|
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
|
|
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
|
|
),
|
|
None => (Vec::new(), Vec::new()),
|
|
};
|
|
highlight::highlight_line_with_runtime(line, &exec, &sel)
|
|
};
|
|
|
|
let show_search = app.editor_ctx.editor.search_active()
|
|
|| !app.editor_ctx.editor.search_query().is_empty();
|
|
|
|
let reserved_lines = 1 + if show_search { 1 } else { 0 };
|
|
let editor_height = inner.height.saturating_sub(reserved_lines);
|
|
|
|
let mut y = inner.y;
|
|
|
|
let search_area = if show_search {
|
|
let area = Rect::new(inner.x, y, inner.width, 1);
|
|
y += 1;
|
|
Some(area)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
|
|
y += editor_height;
|
|
|
|
let hint_area = Rect::new(inner.x, y, inner.width, 1);
|
|
|
|
if let Some(sa) = search_area {
|
|
let style = if app.editor_ctx.editor.search_active() {
|
|
Style::default().fg(theme.search.active)
|
|
} else {
|
|
Style::default().fg(theme.search.inactive)
|
|
};
|
|
let cursor = if app.editor_ctx.editor.search_active() {
|
|
"_"
|
|
} else {
|
|
""
|
|
};
|
|
let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor);
|
|
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa);
|
|
}
|
|
|
|
if let Some(kind) = flash_kind {
|
|
let bg = match kind {
|
|
FlashKind::Error => theme.flash.error_bg,
|
|
FlashKind::Info => theme.flash.info_bg,
|
|
FlashKind::Success => theme.flash.success_bg,
|
|
};
|
|
let flash_block = Block::default().style(Style::default().bg(bg));
|
|
frame.render_widget(flash_block, editor_area);
|
|
}
|
|
app.editor_ctx
|
|
.editor
|
|
.render(frame, editor_area, &highlighter);
|
|
|
|
let dim = Style::default().fg(theme.hint.text);
|
|
let key = Style::default().fg(theme.hint.key);
|
|
|
|
if app.editor_ctx.editor.search_active() {
|
|
let hint = Line::from(vec![
|
|
Span::styled("Enter", key),
|
|
Span::styled(" confirm ", dim),
|
|
Span::styled("Esc", key),
|
|
Span::styled(" cancel", dim),
|
|
]);
|
|
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
|
} else if app.editor_ctx.show_stack {
|
|
let stack_text = compute_stack_display(
|
|
text_lines,
|
|
&app.editor_ctx.editor,
|
|
&app.editor_ctx.stack_cache,
|
|
);
|
|
let hint = Line::from(vec![
|
|
Span::styled("Esc", key),
|
|
Span::styled(" save ", dim),
|
|
Span::styled("C-e", key),
|
|
Span::styled(" eval ", dim),
|
|
Span::styled("C-s", key),
|
|
Span::styled(" hide", dim),
|
|
]);
|
|
let [hint_left, stack_right] = Layout::horizontal([
|
|
Constraint::Length(hint.width() as u16),
|
|
Constraint::Fill(1),
|
|
])
|
|
.areas(hint_area);
|
|
frame.render_widget(Paragraph::new(hint), hint_left);
|
|
frame.render_widget(
|
|
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
|
|
stack_right,
|
|
);
|
|
} else {
|
|
let hint = Line::from(vec![
|
|
Span::styled("Esc", key),
|
|
Span::styled(" save ", dim),
|
|
Span::styled("C-e", key),
|
|
Span::styled(" eval ", dim),
|
|
Span::styled("C-f", key),
|
|
Span::styled(" find ", dim),
|
|
Span::styled("C-s", key),
|
|
Span::styled(" stack ", dim),
|
|
Span::styled("C-u", key),
|
|
Span::styled("/", dim),
|
|
Span::styled("C-r", key),
|
|
Span::styled(" undo/redo", dim),
|
|
]);
|
|
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
|
}
|
|
}
|
|
Modal::PatternProps {
|
|
bank,
|
|
pattern,
|
|
field,
|
|
name,
|
|
length,
|
|
speed,
|
|
quantization,
|
|
sync_mode,
|
|
} => {
|
|
use crate::state::PatternPropsField;
|
|
|
|
let width = 50u16;
|
|
let height = 12u16;
|
|
let x = (term.width.saturating_sub(width)) / 2;
|
|
let y = (term.height.saturating_sub(height)) / 2;
|
|
let area = Rect::new(x, y, width, height);
|
|
|
|
let block = Block::bordered()
|
|
.title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
|
|
.border_style(Style::default().fg(theme.modal.input));
|
|
|
|
let inner = block.inner(area);
|
|
frame.render_widget(Clear, area);
|
|
frame.render_widget(block, area);
|
|
|
|
let fields = [
|
|
("Name", name.as_str(), *field == PatternPropsField::Name),
|
|
(
|
|
"Length",
|
|
length.as_str(),
|
|
*field == PatternPropsField::Length,
|
|
),
|
|
("Speed", &speed.label(), *field == PatternPropsField::Speed),
|
|
(
|
|
"Quantization",
|
|
quantization.label(),
|
|
*field == PatternPropsField::Quantization,
|
|
),
|
|
(
|
|
"Sync Mode",
|
|
sync_mode.label(),
|
|
*field == PatternPropsField::SyncMode,
|
|
),
|
|
];
|
|
|
|
for (i, (label, value, selected)) in fields.iter().enumerate() {
|
|
let y = inner.y + i as u16;
|
|
if y >= inner.y + inner.height {
|
|
break;
|
|
}
|
|
|
|
let (label_style, value_style) = if *selected {
|
|
(
|
|
Style::default()
|
|
.fg(theme.hint.key)
|
|
.add_modifier(Modifier::BOLD),
|
|
Style::default()
|
|
.fg(theme.ui.text_primary)
|
|
.bg(theme.ui.surface),
|
|
)
|
|
} else {
|
|
(
|
|
Style::default().fg(theme.ui.text_muted),
|
|
Style::default().fg(theme.ui.text_primary),
|
|
)
|
|
};
|
|
|
|
let label_area = Rect::new(inner.x + 1, y, 14, 1);
|
|
let value_area = Rect::new(inner.x + 16, y, inner.width.saturating_sub(18), 1);
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(format!("{label}:")).style(label_style),
|
|
label_area,
|
|
);
|
|
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
|
}
|
|
|
|
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
|
let hint_line = Line::from(vec![
|
|
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" nav ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("←→", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" change ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("Enter", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" save ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("Esc", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
|
]);
|
|
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
|
}
|
|
Modal::KeybindingsHelp { scroll } => {
|
|
let width = (term.width * 80 / 100).clamp(60, 100);
|
|
let height = (term.height * 80 / 100).max(15);
|
|
|
|
let title = format!("Keybindings — {}", app.page.name());
|
|
let inner = ModalFrame::new(&title)
|
|
.width(width)
|
|
.height(height)
|
|
.border_color(theme.modal.editor)
|
|
.render_centered(frame, term);
|
|
|
|
let bindings = super::keybindings::bindings_for(app.page);
|
|
let visible_rows = inner.height.saturating_sub(2) as usize;
|
|
|
|
let rows: Vec<Row> = bindings
|
|
.iter()
|
|
.enumerate()
|
|
.skip(*scroll)
|
|
.take(visible_rows)
|
|
.map(|(i, (key, name, desc))| {
|
|
let bg = if i % 2 == 0 {
|
|
theme.table.row_even
|
|
} else {
|
|
theme.table.row_odd
|
|
};
|
|
Row::new(vec![
|
|
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
|
|
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
|
|
Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
|
|
])
|
|
.style(Style::default().bg(bg))
|
|
})
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
[
|
|
Constraint::Length(14),
|
|
Constraint::Length(12),
|
|
Constraint::Fill(1),
|
|
],
|
|
)
|
|
.column_spacing(2);
|
|
|
|
let table_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y,
|
|
width: inner.width,
|
|
height: inner.height.saturating_sub(1),
|
|
};
|
|
frame.render_widget(table, table_area);
|
|
|
|
let hint_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y + inner.height.saturating_sub(1),
|
|
width: inner.width,
|
|
height: 1,
|
|
};
|
|
let keybind_hint = Line::from(vec![
|
|
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" scroll ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("PgUp/Dn", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" page ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("Esc/?", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" close", Style::default().fg(theme.hint.text)),
|
|
]);
|
|
frame.render_widget(
|
|
Paragraph::new(keybind_hint).alignment(Alignment::Right),
|
|
hint_area,
|
|
);
|
|
}
|
|
Modal::EuclideanDistribution {
|
|
source_step,
|
|
field,
|
|
pulses,
|
|
steps,
|
|
rotation,
|
|
..
|
|
} => {
|
|
let width = 50u16;
|
|
let height = 11u16;
|
|
let x = (term.width.saturating_sub(width)) / 2;
|
|
let y = (term.height.saturating_sub(height)) / 2;
|
|
let area = Rect::new(x, y, width, height);
|
|
|
|
let block = Block::bordered()
|
|
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
|
|
.border_style(Style::default().fg(theme.modal.input));
|
|
|
|
let inner = block.inner(area);
|
|
frame.render_widget(Clear, area);
|
|
|
|
// Fill background with theme color
|
|
let bg_fill = " ".repeat(area.width as usize);
|
|
for row in 0..area.height {
|
|
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
|
frame.render_widget(
|
|
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
|
|
line_area,
|
|
);
|
|
}
|
|
|
|
frame.render_widget(block, area);
|
|
|
|
let fields = [
|
|
(
|
|
"Pulses",
|
|
pulses.as_str(),
|
|
*field == EuclideanField::Pulses,
|
|
),
|
|
("Steps", steps.as_str(), *field == EuclideanField::Steps),
|
|
(
|
|
"Rotation",
|
|
rotation.as_str(),
|
|
*field == EuclideanField::Rotation,
|
|
),
|
|
];
|
|
|
|
for (i, (label, value, selected)) in fields.iter().enumerate() {
|
|
let row_y = inner.y + i as u16;
|
|
if row_y >= inner.y + inner.height {
|
|
break;
|
|
}
|
|
|
|
let (label_style, value_style) = if *selected {
|
|
(
|
|
Style::default()
|
|
.fg(theme.hint.key)
|
|
.add_modifier(Modifier::BOLD),
|
|
Style::default()
|
|
.fg(theme.ui.text_primary)
|
|
.bg(theme.ui.surface),
|
|
)
|
|
} else {
|
|
(
|
|
Style::default().fg(theme.ui.text_muted),
|
|
Style::default().fg(theme.ui.text_primary),
|
|
)
|
|
};
|
|
|
|
let label_area = Rect::new(inner.x + 1, row_y, 14, 1);
|
|
let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1);
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(format!("{label}:")).style(label_style),
|
|
label_area,
|
|
);
|
|
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
|
}
|
|
|
|
let preview_y = inner.y + 4;
|
|
if preview_y < inner.y + inner.height {
|
|
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
|
let steps_val: usize = steps.parse().unwrap_or(0);
|
|
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
|
let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
|
|
let preview_line = Line::from(vec![
|
|
Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
|
|
Span::styled(preview, Style::default().fg(theme.modal.input)),
|
|
]);
|
|
let preview_area =
|
|
Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
|
|
frame.render_widget(Paragraph::new(preview_line), preview_area);
|
|
}
|
|
|
|
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
|
let hint_line = Line::from(vec![
|
|
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" nav ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("←→", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" adjust ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("Enter", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" apply ", Style::default().fg(theme.hint.text)),
|
|
Span::styled("Esc", Style::default().fg(theme.hint.key)),
|
|
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
|
]);
|
|
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
|
if pulses == 0 || steps == 0 || pulses > steps {
|
|
return "[invalid]".to_string();
|
|
}
|
|
|
|
let mut pattern = vec![false; steps];
|
|
for i in 0..pulses {
|
|
let pos = (i * steps) / pulses;
|
|
pattern[pos] = true;
|
|
}
|
|
|
|
if rotation > 0 {
|
|
pattern.rotate_left(rotation % steps);
|
|
}
|
|
|
|
let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
|
|
format!("[{}]", chars.join(" "))
|
|
}
|