use ratatui::prelude::*; use ratatui::text::{Line, Span, Text}; use unicode_width::UnicodeWidthStr; /// Convert a markdown string into a `ratatui::Text`. /// /// This lightweight renderer supports common constructs (headings, lists, bold, /// italics, and inline code) and is designed to keep dependencies minimal for /// the OWLEN project. pub fn from_str(input: &str) -> Text<'static> { let mut lines = Vec::new(); let mut in_code_block = false; for raw_line in input.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim_start(); let indent = &line[..line.len() - trimmed.len()]; if trimmed.starts_with("```") { in_code_block = !in_code_block; continue; } if in_code_block { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.push(Span::styled( trimmed.to_string(), Style::default() .fg(Color::LightYellow) .add_modifier(Modifier::DIM), )); lines.push(Line::from(spans)); continue; } if trimmed.is_empty() { lines.push(Line::from(Vec::>::new())); continue; } if trimmed.starts_with('#') { let level = trimmed.chars().take_while(|c| *c == '#').count().min(6); let content = trimmed[level..].trim_start(); let mut style = Style::default().add_modifier(Modifier::BOLD); style = match level { 1 => style.fg(Color::LightCyan), 2 => style.fg(Color::Cyan), _ => style.fg(Color::LightBlue), }; let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.push(Span::styled(content.to_string(), style)); lines.push(Line::from(spans)); continue; } if let Some(rest) = trimmed.strip_prefix("- ") { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.push(Span::styled( "• ".to_string(), Style::default().fg(Color::LightGreen), )); spans.extend(parse_inline(rest)); lines.push(Line::from(spans)); continue; } if let Some(rest) = trimmed.strip_prefix("* ") { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.push(Span::styled( "• ".to_string(), Style::default().fg(Color::LightGreen), )); spans.extend(parse_inline(rest)); lines.push(Line::from(spans)); continue; } if let Some((number, rest)) = parse_ordered_item(trimmed) { let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.push(Span::styled( format!("{number}. "), Style::default().fg(Color::LightGreen), )); spans.extend(parse_inline(rest)); lines.push(Line::from(spans)); continue; } let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::raw(indent.to_string())); } spans.extend(parse_inline(trimmed)); lines.push(Line::from(spans)); } if input.is_empty() { lines.push(Line::from(Vec::>::new())); } Text::from(lines) } fn parse_ordered_item(line: &str) -> Option<(u32, &str)> { let mut parts = line.splitn(2, '.'); let number = parts.next()?.trim(); let rest = parts.next()?; if number.chars().all(|c| c.is_ascii_digit()) { let value = number.parse().ok()?; let rest = rest.trim_start(); Some((value, rest)) } else { None } } fn parse_inline(text: &str) -> Vec> { let mut spans = Vec::new(); let bytes = text.as_bytes(); let mut i = 0; let len = bytes.len(); let mut plain_start = 0; while i < len { if bytes[i] == b'`' { if let Some(offset) = text[i + 1..].find('`') { if i > plain_start { spans.push(Span::raw(text[plain_start..i].to_string())); } let content = &text[i + 1..i + 1 + offset]; spans.push(Span::styled( content.to_string(), Style::default() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD), )); i += offset + 2; plain_start = i; continue; } else { break; } } if bytes[i] == b'*' { if i + 1 < len && bytes[i + 1] == b'*' { if let Some(offset) = text[i + 2..].find("**") { if i > plain_start { spans.push(Span::raw(text[plain_start..i].to_string())); } let content = &text[i + 2..i + 2 + offset]; spans.push(Span::styled( content.to_string(), Style::default().add_modifier(Modifier::BOLD), )); i += offset + 4; plain_start = i; continue; } } else if let Some(offset) = text[i + 1..].find('*') { if i > plain_start { spans.push(Span::raw(text[plain_start..i].to_string())); } let content = &text[i + 1..i + 1 + offset]; spans.push(Span::styled( content.to_string(), Style::default().add_modifier(Modifier::ITALIC), )); i += offset + 2; plain_start = i; continue; } } if bytes[i] == b'_' { if i + 1 < len && bytes[i + 1] == b'_' { if let Some(offset) = text[i + 2..].find("__") { if i > plain_start { spans.push(Span::raw(text[plain_start..i].to_string())); } let content = &text[i + 2..i + 2 + offset]; spans.push(Span::styled( content.to_string(), Style::default().add_modifier(Modifier::BOLD), )); i += offset + 4; plain_start = i; continue; } } else if let Some(offset) = text[i + 1..].find('_') { if i > plain_start { spans.push(Span::raw(text[plain_start..i].to_string())); } let content = &text[i + 1..i + 1 + offset]; spans.push(Span::styled( content.to_string(), Style::default().add_modifier(Modifier::ITALIC), )); i += offset + 2; plain_start = i; continue; } } i += 1; } if plain_start < len { spans.push(Span::raw(text[plain_start..].to_string())); } if spans.is_empty() { spans.push(Span::raw(String::new())); } spans } #[allow(dead_code)] fn visual_length(spans: &[Span<'_>]) -> usize { spans .iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum() } #[cfg(test)] mod tests { use super::*; #[test] fn headings_are_bold() { let text = from_str("# Heading"); assert_eq!(text.lines.len(), 1); let line = &text.lines[0]; assert!( line.spans .iter() .any(|span| span.style.contains(Modifier::BOLD)) ); } #[test] fn inline_code_styled() { let text = from_str("Use `code` inline."); let styled = text .lines .iter() .flat_map(|line| &line.spans) .find(|span| span.content.as_ref() == "code") .cloned() .unwrap(); assert!(styled.style.contains(Modifier::BOLD)); } }