use minimad::{Composite, CompositeStyle, Compound, Line, TableRow}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap}; use ratatui::Frame; use tui_big_text::{BigText, PixelSize}; use crate::app::App; use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; enum DocEntry { Section(&'static str), Topic(&'static str, &'static str), } use DocEntry::{Section, Topic}; const DOCS: &[DocEntry] = &[ // Getting Started Section("Getting Started"), Topic("Welcome", include_str!("../../docs/welcome.md")), Topic("Moving Around", include_str!("../../docs/navigation.md")), Topic( "How Does It Work?", include_str!("../../docs/how_it_works.md"), ), Topic( "Banks & Patterns", include_str!("../../docs/banks_patterns.md"), ), Topic("Stage / Commit", include_str!("../../docs/staging.md")), Topic("Using the Sequencer", include_str!("../../docs/grid.md")), // Forth fundamentals Section("Forth"), Topic("The Dictionary", include_str!("../../docs/dictionary.md")), Topic("The Stack", include_str!("../../docs/stack.md")), Topic("Arithmetic", include_str!("../../docs/arithmetic.md")), Topic("Comparison", include_str!("../../docs/comparison.md")), Topic("Logic", include_str!("../../docs/logic.md")), // Sound generation Section("Sounds"), Topic("Emitting", include_str!("../../docs/emitting.md")), Topic("Samples", include_str!("../../docs/samples.md")), Topic("Oscillators", include_str!("../../docs/oscillators.md")), Topic("Wavetables", include_str!("../../docs/wavetables.md")), // Sound shaping Section("Shaping"), Topic("Envelopes", include_str!("../../docs/envelopes.md")), Topic( "Pitch Envelope", include_str!("../../docs/pitch_envelope.md"), ), Topic("Filters", include_str!("../../docs/filters.md")), Topic( "Ladder Filters", include_str!("../../docs/ladder_filters.md"), ), // Movement and modulation Section("Movement"), Topic("LFO & Ramps", include_str!("../../docs/lfo.md")), Topic("Modulation", include_str!("../../docs/modulation.md")), Topic("Vibrato", include_str!("../../docs/vibrato.md")), // Effects Section("Effects"), Topic("Delay & Reverb", include_str!("../../docs/delay_reverb.md")), Topic("Mod FX", include_str!("../../docs/mod_fx.md")), Topic("EQ & Stereo", include_str!("../../docs/eq_stereo.md")), Topic("Lo-fi", include_str!("../../docs/lofi.md")), // Variation and randomness Section("Variation"), Topic("Randomness", include_str!("../../docs/randomness.md")), Topic("Probability", include_str!("../../docs/probability.md")), Topic("Selection", include_str!("../../docs/selection.md")), // Timing Section("Timing"), Topic("Context", include_str!("../../docs/context.md")), Topic("Cycles", include_str!("../../docs/cycles.md")), Topic("Timing", include_str!("../../docs/timing.md")), Topic("Patterns", include_str!("../../docs/patterns.md")), Topic("Chaining", include_str!("../../docs/chaining.md")), // Music theory Section("Music"), Topic("Notes", include_str!("../../docs/notes.md")), Topic("Scales", include_str!("../../docs/scales.md")), Topic("Chords", include_str!("../../docs/chords.md")), Topic("Generators", include_str!("../../docs/generators.md")), // Advanced Section("Advanced"), Topic("Variables", include_str!("../../docs/variables.md")), Topic("Conditionals", include_str!("../../docs/conditionals.md")), Topic("Custom Words", include_str!("../../docs/definitions.md")), Topic("Ableton Link", include_str!("../../docs/link.md")), // Reference Section("Reference"), Topic("Audio Engine", include_str!("../../docs/audio_engine.md")), Topic("Keybindings", include_str!("../../docs/keybindings.md")), Topic("Sequencer", include_str!("../../docs/sequencer.md")), // Archive - old files to sort Section("Archive"), Topic("Sound Basics", include_str!("../../docs/sound_basics.md")), Topic("Parameters", include_str!("../../docs/parameters.md")), Topic("Tempo & Speed", include_str!("../../docs/tempo.md")), Topic("Effects (old)", include_str!("../../docs/effects.md")), ]; pub fn topic_count() -> usize { DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count() } fn get_topic(index: usize) -> Option<(&'static str, &'static str)> { DOCS.iter() .filter_map(|e| match e { Topic(name, content) => Some((*name, *content)), Section(_) => None, }) .nth(index) } pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [topics_area, content_area] = Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area); render_topics(frame, app, topics_area); render_content(frame, app, content_area); } fn render_topics(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let visible_height = area.height.saturating_sub(2) as usize; let total_items = DOCS.len(); // Find the visual index of the selected topic (including sections) let selected_visual_idx = { let mut visual = 0; let mut topic_count = 0; for entry in DOCS.iter() { if let Topic(_, _) = entry { if topic_count == app.ui.help_topic { break; } topic_count += 1; } visual += 1; } visual }; // Calculate scroll to keep selection visible (centered when possible) let scroll = if selected_visual_idx < visible_height / 2 { 0 } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { total_items.saturating_sub(visible_height) } else { selected_visual_idx.saturating_sub(visible_height / 2) }; // Count topics before the scroll offset to track topic_idx correctly let mut topic_idx = DOCS .iter() .take(scroll) .filter(|e| matches!(e, Topic(_, _))) .count(); let items: Vec = DOCS .iter() .skip(scroll) .take(visible_height) .map(|entry| match entry { Section(name) => { let style = Style::new().fg(theme.ui.text_dim); ListItem::new(format!("─ {name} ─")).style(style) } Topic(name, _) => { let selected = topic_idx == app.ui.help_topic; let style = if selected { Style::new() .fg(theme.dict.category_selected) .add_modifier(Modifier::BOLD) } else { Style::new().fg(theme.ui.text_primary) }; let prefix = if selected { "> " } else { " " }; topic_idx += 1; ListItem::new(format!("{prefix}{name}")).style(style) } }) .collect(); let focused = app.ui.help_focus == HelpFocus::Topics; let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; let list = List::new(items).block( Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title("Topics"), ); frame.render_widget(list, area); } const WELCOME_TOPIC: usize = 0; const BIG_TITLE_HEIGHT: u16 = 6; fn render_content(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let Some((_, md)) = get_topic(app.ui.help_topic) else { return; }; let is_welcome = app.ui.help_topic == WELCOME_TOPIC; let md_area = if is_welcome { let [title_area, rest] = Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)]) .areas(area); let big_title = BigText::builder() .pixel_size(PixelSize::Quadrant) .style(Style::new().fg(theme.markdown.h1).bold()) .lines(vec!["CAGIRE".into()]) .centered() .build(); let subtitle = Paragraph::new(RLine::from(Span::styled( "A Forth Sequencer", Style::new().fg(theme.ui.text_primary), ))) .alignment(ratatui::layout::Alignment::Center); let [big_area, subtitle_area] = Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area); frame.render_widget(big_title, big_area); frame.render_widget(subtitle, subtitle_area); rest } else { area }; let query = &app.ui.help_search_query; let has_query = !query.is_empty(); let query_lower = query.to_lowercase(); let lines = parse_markdown(md); let has_search_bar = app.ui.help_search_active || has_query; let content_area = if has_search_bar { let [content, search] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area); render_search_bar(frame, app, search); content } else { md_area }; // Calculate dimensions: 2 borders + 4 padding (2 left + 2 right) let content_width = content_area.width.saturating_sub(6) as usize; // 2 borders + 4 padding (2 top + 2 bottom) let visible_height = content_area.height.saturating_sub(6) as usize; // Calculate total wrapped line count for accurate max_scroll let total_wrapped: usize = lines .iter() .map(|l| wrapped_line_count(l, content_width)) .sum(); let max_scroll = total_wrapped.saturating_sub(visible_height); let scroll = app.ui.help_scroll().min(max_scroll); let lines: Vec = if has_query { lines .into_iter() .map(|line| highlight_line(line, &query_lower)) .collect() } else { lines }; let focused = app.ui.help_focus == HelpFocus::Content; let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; let para = Paragraph::new(lines) .scroll((scroll as u16, 0)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .padding(Padding::new(2, 2, 2, 2)), ) .wrap(Wrap { trim: false }); frame.render_widget(para, content_area); } fn wrapped_line_count(line: &RLine, width: usize) -> usize { let char_count: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); if char_count == 0 || width == 0 { 1 } else { (char_count + width - 1) / width } } fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let style = if app.ui.help_search_active { Style::new().fg(theme.search.active) } else { Style::new().fg(theme.search.inactive) }; let cursor = if app.ui.help_search_active { "█" } else { "" }; let text = format!(" /{}{cursor}", app.ui.help_search_query); frame.render_widget(Paragraph::new(text).style(style), area); } fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { let theme = theme::get(); let mut result: Vec> = Vec::new(); for span in line.spans { let lower = span.content.to_lowercase(); if !lower.contains(query) { result.push(span); continue; } let content = span.content.to_string(); let base_style = span.style; let hl_style = base_style .bg(theme.search.match_bg) .fg(theme.search.match_fg); let mut start = 0; let lower_bytes = lower.as_bytes(); let query_bytes = query.as_bytes(); while let Some(pos) = find_bytes(&lower_bytes[start..], query_bytes) { let abs = start + pos; if abs > start { result.push(Span::styled(content[start..abs].to_string(), base_style)); } result.push(Span::styled( content[abs..abs + query.len()].to_string(), hl_style, )); start = abs + query.len(); } if start < content.len() { result.push(Span::styled(content[start..].to_string(), base_style)); } } RLine::from(result) } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } /// Find first line matching query across all topics. Returns (topic_index, line_index). pub fn find_match(query: &str) -> Option<(usize, usize)> { let query = query.to_lowercase(); for (topic_idx, (_, content)) in DOCS .iter() .filter_map(|e| match e { Topic(name, content) => Some((*name, *content)), Section(_) => None, }) .enumerate() { for (line_idx, line) in content.lines().enumerate() { if line.to_lowercase().contains(&query) { return Some((topic_idx, line_idx)); } } } None } fn code_border_style() -> Style { let theme = theme::get(); Style::new().fg(theme.markdown.code_border) } fn preprocess_markdown(md: &str) -> String { let mut out = String::with_capacity(md.len()); for line in md.lines() { // Convert dash list markers to asterisks (minimad only recognizes *) let line = convert_dash_lists(line); let mut result = String::with_capacity(line.len()); let mut chars = line.char_indices().peekable(); let bytes = line.as_bytes(); while let Some((i, c)) = chars.next() { if c == '`' { result.push(c); for (_, ch) in chars.by_ref() { result.push(ch); if ch == '`' { break; } } continue; } if c == '_' { let before_is_space = i == 0 || bytes[i - 1] == b' '; if before_is_space { if let Some(end) = line[i + 1..].find('_') { let inner = &line[i + 1..i + 1 + end]; if !inner.is_empty() { result.push('*'); result.push_str(inner); result.push('*'); for _ in 0..end { chars.next(); } chars.next(); // skip closing _ continue; } } } } result.push(c); } out.push_str(&result); out.push('\n'); } out } fn convert_dash_lists(line: &str) -> String { let trimmed = line.trim_start(); if trimmed.starts_with("- ") { let indent = line.len() - trimmed.len(); format!("{}* {}", " ".repeat(indent), &trimmed[2..]) } else { line.to_string() } } fn parse_markdown(md: &str) -> Vec> { let processed = preprocess_markdown(md); let text = minimad::Text::from(processed.as_str()); let mut lines = Vec::new(); let mut code_line_nr: usize = 0; let mut table_buffer: Vec = Vec::new(); let flush_table = |buf: &mut Vec, out: &mut Vec>| { if buf.is_empty() { return; } let col_widths = compute_column_widths(buf); for (row_idx, row) in buf.drain(..).enumerate() { out.push(render_table_row(row, row_idx, &col_widths)); } }; for line in text.lines { match line { Line::Normal(composite) if composite.style == CompositeStyle::Code => { flush_table(&mut table_buffer, &mut lines); code_line_nr += 1; let raw: String = composite .compounds .iter() .map(|c: &minimad::Compound| c.src) .collect(); let mut spans = vec![ Span::styled(format!(" {code_line_nr:>2} "), code_border_style()), Span::styled("│ ", code_border_style()), ]; spans.extend( highlight::highlight_line(&raw) .into_iter() .map(|(style, text)| Span::styled(text, style)), ); lines.push(RLine::from(spans)); } Line::Normal(composite) => { flush_table(&mut table_buffer, &mut lines); code_line_nr = 0; lines.push(composite_to_line(composite)); } Line::TableRow(row) => { code_line_nr = 0; table_buffer.push(row); } Line::TableRule(_) => { // Skip the separator line (---|---|---) } _ => { flush_table(&mut table_buffer, &mut lines); code_line_nr = 0; lines.push(RLine::from("")); } } } flush_table(&mut table_buffer, &mut lines); lines } fn cell_text_width(cell: &Composite) -> usize { cell.compounds.iter().map(|c| c.src.chars().count()).sum() } fn compute_column_widths(rows: &[TableRow]) -> Vec { let mut widths: Vec = Vec::new(); for row in rows { for (i, cell) in row.cells.iter().enumerate() { let w = cell_text_width(cell); if i >= widths.len() { widths.push(w); } else if w > widths[i] { widths[i] = w; } } } widths } fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLine<'static> { let theme = theme::get(); let is_header = row_idx == 0; let bg = if is_header { theme.ui.surface } else if row_idx % 2 == 0 { theme.table.row_even } else { theme.table.row_odd }; let base_style = if is_header { Style::new() .fg(theme.markdown.text) .bg(bg) .add_modifier(Modifier::BOLD) } else { Style::new().fg(theme.markdown.text).bg(bg) }; let sep_style = Style::new().fg(theme.markdown.code_border).bg(bg); let mut spans: Vec> = Vec::new(); for (i, cell) in row.cells.into_iter().enumerate() { if i > 0 { spans.push(Span::styled(" │ ", sep_style)); } let target_width = col_widths.get(i).copied().unwrap_or(0); let cell_width = cell .compounds .iter() .map(|c| c.src.chars().count()) .sum::(); for compound in cell.compounds { compound_to_spans(compound, base_style, &mut spans); } let padding = target_width.saturating_sub(cell_width); if padding > 0 { spans.push(Span::styled(" ".repeat(padding), base_style)); } } RLine::from(spans) } fn composite_to_line(composite: Composite) -> RLine<'static> { let theme = theme::get(); let base_style = match composite.style { CompositeStyle::Header(1) => Style::new() .fg(theme.markdown.h1) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), CompositeStyle::Header(2) => Style::new() .fg(theme.markdown.h2) .add_modifier(Modifier::BOLD), CompositeStyle::Header(_) => Style::new() .fg(theme.markdown.h3) .add_modifier(Modifier::BOLD), CompositeStyle::ListItem(_) => Style::new().fg(theme.markdown.list), CompositeStyle::Quote => Style::new().fg(theme.markdown.quote), CompositeStyle::Code => Style::new().fg(theme.markdown.code), CompositeStyle::Paragraph => Style::new().fg(theme.markdown.text), }; let prefix: String = match composite.style { CompositeStyle::ListItem(depth) => { let indent = " ".repeat(depth as usize); format!("{indent}• ") } CompositeStyle::Quote => " │ ".to_string(), _ => String::new(), }; let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix, base_style)); } for compound in composite.compounds { compound_to_spans(compound, base_style, &mut spans); } RLine::from(spans) } fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec>) { let theme = theme::get(); 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(theme.markdown.code); } if compound.strikeout { style = style.add_modifier(Modifier::CROSSED_OUT); } let src = compound.src.to_string(); let link_style = Style::new() .fg(theme.markdown.link) .add_modifier(Modifier::UNDERLINED); let mut rest = src.as_str(); while let Some(start) = rest.find('[') { let after_bracket = &rest[start + 1..]; if let Some(text_end) = after_bracket.find("](") { let url_start = start + 1 + text_end + 2; if let Some(url_end) = rest[url_start..].find(')') { if start > 0 { out.push(Span::styled(rest[..start].to_string(), style)); } let text = &rest[start + 1..start + 1 + text_end]; let url = &rest[url_start..url_start + url_end]; if text == url { out.push(Span::styled(url.to_string(), link_style)); } else { out.push(Span::styled(text.to_string(), link_style)); out.push(Span::styled( format!(" ({url})"), Style::new().fg(theme.markdown.link_url), )); } rest = &rest[url_start + url_end + 1..]; continue; } } out.push(Span::styled(rest[..start + 1].to_string(), style)); rest = &rest[start + 1..]; } if !rest.is_empty() { out.push(Span::styled(rest.to_string(), style)); } }