Skip to main content

oxicode_vtui/tui/ui/markdown/
mod.rs

1//! Minimal markdown → InlineSegment renderer with full inline styling.
2use anstyle::{Color as AnsiColorEnum, Effects, RgbColor};
3use oxicode_vtui_compat::ui_protocol::{InlineSegment, InlineTextStyle};
4use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5use std::sync::Arc;
6use syntect::easy::HighlightLines;
7use syntect::parsing::SyntaxSet;
8
9/// Parse markdown text into styled InlineSegment lines.
10pub fn render_markdown(text: &str) -> Vec<Vec<InlineSegment>> {
11    let mut opts = Options::empty();
12    opts.insert(Options::ENABLE_TABLES);
13    opts.insert(Options::ENABLE_STRIKETHROUGH);
14    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
15
16    let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
17    let mut cur: Vec<InlineSegment> = Vec::new();
18    let effects: Effects = Effects::default();
19
20    let mut code_buf: Option<CodeBlockState> = None;
21    let ss = SyntaxSet::load_defaults_newlines();
22
23    for event in Parser::new_ext(text, opts) {
24        // Code block capture mode
25        if let Some(ref mut cb) = code_buf {
26            match event {
27                Event::Text(t) => cb.code.push_str(&t),
28                Event::End(TagEnd::CodeBlock) => {
29                    let cb = code_buf.take().unwrap();
30                    flush_line(&mut cur, &mut lines);
31                    let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), &ss);
32                    lines.extend(block_lines);
33                    lines.push(Vec::new());
34                }
35                _ => {}
36            }
37            continue;
38        }
39
40        // Flat main match
41        match event {
42            // ── Code block ─────────────────────────────────────────────
43            Event::Start(Tag::CodeBlock(kind)) => {
44                flush_line(&mut cur, &mut lines);
45                let lang = match kind {
46                    CodeBlockKind::Fenced(l) => Some(l.to_string()),
47                    CodeBlockKind::Indented => None,
48                };
49                code_buf = Some(CodeBlockState {
50                    code: String::new(),
51                    lang,
52                });
53            }
54
55            // ── Block-level ────────────────────────────────────────────
56            Event::Start(Tag::Paragraph) => {}
57
58            Event::Start(Tag::Heading { level, .. }) => {
59                flush_line(&mut cur, &mut lines);
60                {
61                    let _ = effects.insert(Effects::BOLD);
62                };
63                {
64                    let _ = effects.insert(if level == HeadingLevel::H1 {
65                        Effects::UNDERLINE
66                    } else {
67                        Effects::default()
68                    });
69                }
70            }
71            Event::End(TagEnd::Heading(_)) => {
72                {
73                    let _ = effects.remove(Effects::BOLD | Effects::UNDERLINE);
74                };
75                flush_line(&mut cur, &mut lines);
76            }
77
78            Event::Start(Tag::BlockQuote(_)) => {
79                {
80                    let _ = effects.insert(Effects::DIMMED);
81                };
82            }
83            Event::End(TagEnd::BlockQuote(_)) => {
84                {
85                    let _ = effects.remove(Effects::DIMMED);
86                };
87            }
88
89            Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
90                flush_line(&mut cur, &mut lines);
91            }
92            Event::End(TagEnd::List(_)) => {
93                lines.push(Vec::new());
94            }
95
96            Event::Rule => {
97                flush_line(&mut cur, &mut lines);
98                lines.push(vec![InlineSegment {
99                    text: "\u{2500}".repeat(40),
100                    style: Arc::new(InlineTextStyle::default().dim()),
101                }]);
102            }
103
104            // ── Inline formatting ──────────────────────────────────────
105            Event::Start(Tag::Emphasis) => {
106                {
107                    let _ = effects.insert(Effects::ITALIC);
108                };
109            }
110            Event::End(TagEnd::Emphasis) => {
111                {
112                    let _ = effects.remove(Effects::ITALIC);
113                };
114            }
115
116            Event::Start(Tag::Strong) => {
117                {
118                    let _ = effects.insert(Effects::BOLD);
119                };
120            }
121            Event::End(TagEnd::Strong) => {
122                {
123                    let _ = effects.remove(Effects::BOLD);
124                };
125            }
126
127            Event::Start(Tag::Strikethrough) => {
128                {
129                    let _ = effects.insert(Effects::STRIKETHROUGH);
130                };
131            }
132            Event::End(TagEnd::Strikethrough) => {
133                {
134                    let _ = effects.remove(Effects::STRIKETHROUGH);
135                };
136            }
137
138            Event::Start(Tag::Link { .. }) => {
139                {
140                    let _ = effects.insert(Effects::UNDERLINE);
141                };
142            }
143            Event::End(TagEnd::Link) => {
144                {
145                    let _ = effects.remove(Effects::UNDERLINE);
146                };
147            }
148
149            // ── Text content ───────────────────────────────────────────
150            Event::Text(t) | Event::Html(t) => {
151                let style = apply_effects(InlineTextStyle::default(), effects);
152                let seg = InlineSegment {
153                    text: t.to_string(),
154                    style: Arc::new(style),
155                };
156                merge_or_push(&mut cur, seg);
157            }
158
159            Event::Code(t) => {
160                let seg = InlineSegment {
161                    text: t.to_string(),
162                    style: Arc::new(InlineTextStyle::default().bold()),
163                };
164                merge_or_push(&mut cur, seg);
165            }
166
167            Event::SoftBreak | Event::HardBreak => {
168                flush_line(&mut cur, &mut lines);
169            }
170
171            Event::FootnoteReference(t) => {
172                let seg = InlineSegment {
173                    text: format!("[^{}]", t),
174                    style: Arc::new(InlineTextStyle::default().dim()),
175                };
176                merge_or_push(&mut cur, seg);
177            }
178
179            _ => {}
180        }
181    }
182
183    flush_line(&mut cur, &mut lines);
184    lines
185}
186
187/// Render a code block with syntect syntax highlighting.
188pub fn render_code_block(
189    code: &str,
190    lang: Option<&str>,
191    ss: &SyntaxSet,
192) -> Vec<Vec<InlineSegment>> {
193    let syntax = lang
194        .and_then(|l| ss.find_syntax_by_token(l))
195        .unwrap_or_else(|| ss.find_syntax_plain_text());
196    let theme = syntect::highlighting::Theme::default();
197    #[allow(unused_mut)]
198    let mut h = HighlightLines::new(syntax, &theme);
199    let mut lines = Vec::new();
200
201    for line in syntect::util::LinesWithEndings::from(code) {
202        if let Ok(ranges) = h.highlight_line(line, ss) {
203            let segs: Vec<InlineSegment> = ranges
204                .into_iter()
205                .map(|(s, t)| {
206                    let fg = s.foreground;
207                    InlineSegment {
208                        text: t.to_string(),
209                        style: Arc::new(
210                            InlineTextStyle::default()
211                                .with_color(Some(AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)))),
212                        ),
213                    }
214                })
215                .collect();
216            lines.push(segs);
217        } else {
218            lines.push(vec![InlineSegment {
219                text: line.to_string(),
220                style: Arc::new(InlineTextStyle::default()),
221            }]);
222        }
223    }
224    lines
225}
226
227// ── Helpers ─────────────────────────────────────────────────────────────────
228
229struct CodeBlockState {
230    code: String,
231    lang: Option<String>,
232}
233
234fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
235    if !cur.is_empty() {
236        lines.push(std::mem::take(cur));
237    }
238}
239
240fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
241    if let Some(last) = cur.last_mut() {
242        if last.style == seg.style {
243            last.text.push_str(&seg.text);
244            return;
245        }
246    }
247    cur.push(seg);
248}
249
250fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
251    if effects.contains(Effects::BOLD) {
252        style = style.bold();
253    }
254    if effects.contains(Effects::ITALIC) {
255        style = style.italic();
256    }
257    if effects.contains(Effects::UNDERLINE) {
258        style = style.underline();
259    }
260    if effects.contains(Effects::DIMMED) {
261        style = style.dim();
262    }
263    if effects.contains(Effects::STRIKETHROUGH) {
264        style.effects |= Effects::STRIKETHROUGH;
265    }
266    style
267}