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, Padding, Paragraph, Wrap}; use ratatui::Frame; 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")), ("Keybindings", include_str!("../../docs/keybindings.md")), ("Sequencer", include_str!("../../docs/sequencer.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); } fn render_content(frame: &mut Frame, app: &App, area: Rect) { let (name, md) = DOCS[app.ui.help_topic]; 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 = 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(area); render_search_bar(frame, app, search); content } else { area }; let para = Paragraph::new(visible) .block( Block::default() .borders(Borders::ALL) .title(name) .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 parse_markdown(md: &str) -> Vec> { let text = minimad::Text::from(md); 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| &*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 { 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) }