feat(tui): add markdown rendering support and toggle command

- 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.
This commit is contained in:
2025-10-14 01:35:13 +02:00
parent 99064b6c41
commit 498e6e61b6
24 changed files with 911 additions and 247 deletions

View File

@@ -0,0 +1,270 @@
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));
}
}