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>, ) -> 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 = 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 = items.iter().map(format_value).collect(); format!("({})", inner.join(" ")) } } } fn adjust_spans_for_line( spans: &[SourceSpan], line_start: usize, line_len: usize, ) -> Vec { 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 = (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 = 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 = 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 = 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 = 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 = 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 = 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(" ")) }