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:
270
crates/owlen-markdown/src/lib.rs
Normal file
270
crates/owlen-markdown/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user