- Introduce new `owlen-markdown` crate that converts Markdown strings to `ratatui::Text` with headings, lists, bold/italic, and inline code. - Add `render_markdown` config option (default true) and expose it via `app.render_markdown_enabled()`. - Implement `:markdown [on|off]` command to toggle markdown rendering. - Update help overlay to document the new markdown toggle. - Adjust UI rendering to conditionally apply markdown styling based on the markdown flag and code mode. - Wire the new crate into `owlen-tui` Cargo.toml.
271 lines
8.5 KiB
Rust
271 lines
8.5 KiB
Rust
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::<Span<'static>>::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::<Span<'static>>::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<Span<'static>> {
|
|
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));
|
|
}
|
|
}
|