use cagire_markdown::{CodeHighlighter, MarkdownTheme}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; use ratatui::Frame; #[cfg(not(feature = "desktop"))] use tui_big_text::{BigText, PixelSize}; use crate::app::App; use crate::model::docs::{get_topic, DocEntry, DOCS}; use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; use DocEntry::{Section, Topic}; struct AppTheme; impl MarkdownTheme for AppTheme { fn h1(&self) -> Style { Style::new() .fg(theme::get().markdown.h1) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) } fn h2(&self) -> Style { Style::new() .fg(theme::get().markdown.h2) .add_modifier(Modifier::BOLD) } fn h3(&self) -> Style { Style::new() .fg(theme::get().markdown.h3) .add_modifier(Modifier::BOLD) } fn text(&self) -> Style { Style::new().fg(theme::get().markdown.text) } fn code(&self) -> Style { Style::new().fg(theme::get().markdown.code) } fn code_border(&self) -> Style { Style::new().fg(theme::get().markdown.code_border) } fn link(&self) -> Style { Style::new() .fg(theme::get().markdown.link) .add_modifier(Modifier::UNDERLINED) } fn link_url(&self) -> Style { Style::new().fg(theme::get().markdown.link_url) } fn quote(&self) -> Style { Style::new().fg(theme::get().markdown.quote) } fn list(&self) -> Style { Style::new().fg(theme::get().markdown.list) } fn table_header_bg(&self) -> Color { theme::get().ui.surface } fn table_row_even(&self) -> Color { theme::get().table.row_even } fn table_row_odd(&self) -> Color { theme::get().table.row_odd } } struct ForthHighlighter; impl CodeHighlighter for ForthHighlighter { fn highlight(&self, line: &str) -> Vec<(Style, String)> { highlight::highlight_line(line) .into_iter() .map(|(s, t, _)| (s, t)) .collect() } } pub fn layout(area: Rect) -> [Rect; 2] { Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area) } pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [topics_area, content_area] = layout(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 focused = app.ui.help_focus == HelpFocus::Topics; let items: Vec = DOCS .iter() .map(|entry| match entry { Section(name) => CategoryItem { label: name, is_section: true, }, Topic(name, _) => CategoryItem { label: name, is_section: false, }, }) .collect(); CategoryList::new(&items, app.ui.help_topic) .focused(focused) .title("Topics") .selected_color(theme.dict.category_selected) .normal_color(theme.ui.text_primary) .render(frame, area); } const WELCOME_TOPIC: usize = 0; #[cfg(not(feature = "desktop"))] const BIG_TITLE_HEIGHT: u16 = 6; #[cfg(feature = "desktop")] const BIG_TITLE_HEIGHT: u16 = 3; 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); #[cfg(not(feature = "desktop"))] let big_title = BigText::builder() .pixel_size(PixelSize::Quadrant) .style(Style::new().fg(theme.markdown.h1).bold()) .lines(vec!["CAGIRE".into()]) .centered() .build(); #[cfg(feature = "desktop")] let big_title = Paragraph::new(RLine::from(Span::styled( "CAGIRE", Style::new().fg(theme.markdown.h1).bold(), ))) .alignment(ratatui::layout::Alignment::Center); let subtitle = Paragraph::new(RLine::from(Span::styled( "A Forth Sequencer", Style::new().fg(theme.ui.text_primary), ))) .alignment(ratatui::layout::Alignment::Center); #[cfg(not(feature = "desktop"))] let [big_area, subtitle_area] = Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area); #[cfg(feature = "desktop")] let [big_area, subtitle_area] = Layout::vertical([Constraint::Length(1), 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 = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter); 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, search, &app.ui.help_search_query, app.ui.help_search_active); 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.div_ceil(width) } } 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) }