use minimad::{Composite, CompositeStyle, Compound, Line}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; 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::views::highlight; // To add a new help topic: drop a .md file in docs/ and add one line here. const DOCS: &[(&str, &str)] = &[ ("Welcome", include_str!("../../docs/welcome.md")), ("Audio Engine", include_str!("../../docs/audio_engine.md")), ("Keybindings", include_str!("../../docs/keybindings.md")), ("Sequencer", include_str!("../../docs/sequencer.md")), ("About", include_str!("../../docs/about.md")), ]; pub fn topic_count() -> usize { DOCS.len() } 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); render_content(frame, app, content_area); } fn render_topics(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = DOCS .iter() .enumerate() .map(|(i, (name, _))| { let selected = i == app.ui.help_topic; let style = if selected { Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::new().fg(Color::White) }; let prefix = if selected { "> " } 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); } const WELCOME_TOPIC: usize = 0; const BIG_TITLE_HEIGHT: u16 = 6; fn render_content(frame: &mut Frame, app: &App, area: Rect) { let (_, md) = DOCS[app.ui.help_topic]; 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().cyan().bold()) .lines(vec!["CAGIRE".into()]) .centered() .build(); let subtitle = Paragraph::new(RLine::from(Span::styled( "A Forth Sequencer", Style::new().fg(Color::White), ))) .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 search_bar_height: u16 = u16::from(has_search_bar); let visible_height = md_area.height.saturating_sub(6 + search_bar_height) as usize; let max_scroll = lines.len().saturating_sub(visible_height); let scroll = app.ui.help_scroll().min(max_scroll); let visible: Vec = lines .into_iter() .skip(scroll) .take(visible_height) .map(|line| { if has_query { highlight_line(line, &query_lower) } else { line } }) .collect(); 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 }; let para = Paragraph::new(visible) .block( Block::default() .borders(Borders::ALL) .padding(Padding::new(2, 2, 2, 2)), ) .wrap(Wrap { trim: false }); frame.render_widget(para, content_area); } fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let style = if app.ui.help_search_active { Style::new().fg(Color::Yellow) } else { Style::new().fg(Color::DarkGray) }; 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 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(Color::Yellow).fg(Color::Black); 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().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 { Style::new().fg(Color::Rgb(60, 60, 70)) } fn preprocess_underscores(md: &str) -> String { let mut out = String::with_capacity(md.len()); for line in md.lines() { 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 parse_markdown(md: &str) -> Vec> { let processed = preprocess_underscores(md); let text = minimad::Text::from(processed.as_str()); let mut lines = Vec::new(); let mut code_line_nr: usize = 0; for line in text.lines { match line { Line::Normal(composite) if composite.style == CompositeStyle::Code => { 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) => { code_line_nr = 0; lines.push(composite_to_line(composite)); } _ => { 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> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix.to_string(), 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 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); } let src = compound.src.to_string(); let link_style = Style::new() .fg(Color::Rgb(120, 200, 180)) .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(Color::Rgb(100, 100, 100)), )); } 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)); } }