use eframe::egui;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
pub fn render_markdown_preview(ui: &mut egui::Ui, markdown: &str) {
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
render_events(ui, &events);
}
fn render_events(ui: &mut egui::Ui, events: &[Event]) {
let mut i = 0;
let mut list_item_number = 0;
let mut in_ordered_list = false;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Heading { level, .. }) => {
let heading_level = *level;
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Heading(heading_level));
let font_size = match heading_level {
HeadingLevel::H1 => 30.0,
HeadingLevel::H2 => 24.0,
HeadingLevel::H3 => 20.0,
HeadingLevel::H4 => 18.0,
HeadingLevel::H5 => 16.0,
HeadingLevel::H6 => 14.0,
};
ui.add_space(10.0);
ui.label(egui::RichText::new(text).size(font_size).strong());
ui.add_space(5.0);
while i < events.len() {
if matches!(events[i], Event::End(TagEnd::Heading(_))) {
break;
}
i += 1;
}
}
Event::Start(Tag::Paragraph) => {
i += 1;
let rich_text = extract_rich_text(&events[i..], TagEnd::Paragraph);
ui.add_space(5.0);
ui.label(rich_text);
ui.add_space(5.0);
while i < events.len() {
if matches!(events[i], Event::End(TagEnd::Paragraph)) {
break;
}
i += 1;
}
}
Event::Start(Tag::List(first_number)) => {
in_ordered_list = first_number.is_some();
list_item_number = first_number.unwrap_or(0);
ui.add_space(5.0);
}
Event::End(TagEnd::List(_)) => {
in_ordered_list = false;
list_item_number = 0;
ui.add_space(5.0);
}
Event::Start(Tag::Item) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Item);
if in_ordered_list {
list_item_number += 1;
ui.horizontal(|ui| {
ui.label(format!("{list_item_number}."));
ui.label(text);
});
} else {
ui.horizontal(|ui| {
ui.label("•");
ui.label(text);
});
}
while i < events.len() {
if matches!(events[i], Event::End(TagEnd::Item)) {
break;
}
i += 1;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
let lang = match kind {
CodeBlockKind::Fenced(lang) => lang.to_string(),
CodeBlockKind::Indented => String::new(),
};
i += 1;
let code = extract_text_until_end(&events[i..], TagEnd::CodeBlock);
ui.add_space(5.0);
egui::Frame::NONE
.fill(ui.style().visuals.code_bg_color)
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
if !lang.is_empty() && !code.is_empty() {
render_highlighted_code(ui, &code, &lang);
} else {
ui.label(
egui::RichText::new(code)
.monospace()
.color(egui::Color32::from_rgb(200, 200, 200)),
);
}
});
ui.add_space(5.0);
while i < events.len() {
if matches!(events[i], Event::End(TagEnd::CodeBlock)) {
break;
}
i += 1;
}
}
Event::Code(code) => {
ui.label(
egui::RichText::new(code.as_ref())
.monospace()
.background_color(ui.style().visuals.code_bg_color),
);
}
Event::Rule => {
ui.add_space(5.0);
ui.separator();
ui.add_space(5.0);
}
_ => {}
}
i += 1;
}
}
fn extract_text_until_end(events: &[Event], end_tag: TagEnd) -> String {
let mut result = String::new();
for event in events {
match event {
Event::Text(text) => result.push_str(text),
Event::Code(code) => result.push_str(code),
Event::End(tag) if tag == &end_tag => break,
_ => {}
}
}
result
}
fn extract_rich_text(events: &[Event], end_tag: TagEnd) -> egui::RichText {
let mut result = String::new();
let mut is_bold = false;
let mut is_italic = false;
for event in events {
match event {
Event::Text(text) => result.push_str(text),
Event::Code(code) => {
result.push('`');
result.push_str(code);
result.push('`');
}
Event::Start(Tag::Strong) => is_bold = true,
Event::End(TagEnd::Strong) => is_bold = false,
Event::Start(Tag::Emphasis) => is_italic = true,
Event::End(TagEnd::Emphasis) => is_italic = false,
Event::End(tag) if tag == &end_tag => break,
_ => {}
}
}
let mut rich = egui::RichText::new(result);
if is_bold {
rich = rich.strong();
}
if is_italic {
rich = rich.italics();
}
rich
}
fn render_highlighted_code(ui: &mut egui::Ui, code: &str, lang: &str) {
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let syntax = ps
.find_syntax_by_extension(lang)
.or_else(|| ps.find_syntax_by_name(lang))
.or_else(|| ps.find_syntax_by_first_line(code))
.unwrap_or_else(|| ps.find_syntax_plain_text());
let theme = &ts.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
for line in LinesWithEndings::from(code) {
let ranges = highlighter.highlight_line(line, &ps).unwrap_or_default();
ui.horizontal(|ui| {
for (style, text) in ranges {
let color = style_to_color(style);
ui.label(egui::RichText::new(text).monospace().color(color));
}
});
}
}
fn style_to_color(style: Style) -> egui::Color32 {
egui::Color32::from_rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_heading() {
let markdown = "# Hello";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
assert!(matches!(events[0], Event::Start(Tag::Heading { .. })));
}
#[test]
fn test_parse_bold() {
let markdown = "**bold** text";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_strong = events
.iter()
.any(|e| matches!(e, Event::Start(Tag::Strong)));
assert!(has_strong);
}
#[test]
fn test_parse_list() {
let markdown = "* item 1\n* item 2";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_list = events
.iter()
.any(|e| matches!(e, Event::Start(Tag::List(_))));
assert!(has_list);
}
#[test]
fn test_parse_code_block() {
let markdown = "```rust\nfn main() {}\n```";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_code_block = events
.iter()
.any(|e| matches!(e, Event::Start(Tag::CodeBlock(_))));
assert!(has_code_block);
}
#[test]
fn test_extract_text_until_end() {
let events = vec![
Event::Text("Hello".into()),
Event::Text(" World".into()),
Event::End(TagEnd::Paragraph),
];
let text = extract_text_until_end(&events, TagEnd::Paragraph);
assert_eq!(text, "Hello World");
}
#[test]
fn test_parse_ordered_list() {
let markdown = "1. First\n2. Second\n3. Third";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_ordered = events
.iter()
.any(|e| matches!(e, Event::Start(Tag::List(Some(_)))));
assert!(has_ordered);
}
#[test]
fn test_parse_emphasis() {
let markdown = "*italic*";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_emphasis = events
.iter()
.any(|e| matches!(e, Event::Start(Tag::Emphasis)));
assert!(has_emphasis);
}
#[test]
fn test_parse_inline_code() {
let markdown = "`code`";
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
let has_code = events.iter().any(|e| matches!(e, Event::Code(_)));
assert!(has_code);
}
}