oxicode_vtui/tui/ui/markdown/
mod.rs1use 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, LazyLock};
6use syntect::easy::HighlightLines;
7use syntect::highlighting::ThemeSet;
8use syntect::parsing::SyntaxSet;
9use unicode_width::UnicodeWidthStr;
10
11static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
13static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
14
15pub fn render_markdown(text: &str) -> Vec<Vec<InlineSegment>> {
17 let mut opts = Options::empty();
18 opts.insert(Options::ENABLE_TABLES);
19 opts.insert(Options::ENABLE_STRIKETHROUGH);
20 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
21
22 let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
23 let mut cur: Vec<InlineSegment> = Vec::new();
24 let mut effects: Effects = Effects::default();
25
26 let mut code_buf: Option<CodeBlockState> = None;
27 let mut table_buf: Option<TableState> = None;
28 let mut list_stack: Vec<ListLevel> = Vec::new();
29 let ss = &*SYNTAX_SET;
30
31 for event in Parser::new_ext(text, opts) {
32 if let Some(tb) = &mut table_buf {
33 match event {
34 Event::Text(t) | Event::Html(t) | Event::Code(t) => tb.current_cell.push_str(&t),
35 Event::End(TagEnd::TableCell) => {
36 tb.current_row.push(std::mem::take(&mut tb.current_cell));
37 }
38 Event::End(TagEnd::TableRow) => {
39 tb.rows.push(std::mem::take(&mut tb.current_row));
40 }
41 Event::End(TagEnd::TableHead) => {
42 if !tb.rows.is_empty() && tb.header.is_empty() {
45 tb.header = tb.rows.remove(0);
46 }
47 }
48 Event::End(TagEnd::Table) => {
49 let tb = table_buf.take().unwrap();
50 let table_lines = render_table(&tb.header, &tb.rows);
51 lines.extend(table_lines);
52 lines.push(Vec::new());
53 }
54 _ => {}
55 }
56 continue;
57 }
58
59 if let Some(cb) = &mut code_buf {
61 match event {
62 Event::Text(t) => cb.code.push_str(&t),
63 Event::End(TagEnd::CodeBlock) => {
64 let cb = code_buf.take().unwrap();
65 flush_line(&mut cur, &mut lines);
66 let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), ss);
67 lines.extend(block_lines);
68 lines.push(Vec::new());
69 }
70 _ => {}
71 }
72 continue;
73 }
74
75 match event {
77 Event::Start(Tag::CodeBlock(kind)) => {
79 flush_line(&mut cur, &mut lines);
80 let lang = match kind {
81 CodeBlockKind::Fenced(l) => Some(l.to_string()),
82 CodeBlockKind::Indented => None,
83 };
84 code_buf = Some(CodeBlockState {
85 code: String::new(),
86 lang,
87 });
88 }
89
90 Event::Start(Tag::Table(_)) => {
92 flush_line(&mut cur, &mut lines);
93 table_buf = Some(TableState::default());
94 }
95
96 Event::Start(Tag::List(start)) => {
98 list_stack.push(ListLevel {
99 is_ordered: start.is_some(),
100 index: start.map(|n| n.saturating_sub(1)).unwrap_or(0),
101 });
102 }
103 Event::End(TagEnd::List(_)) => {
104 list_stack.pop();
105 }
106 Event::Start(Tag::Item) => {
107 flush_line(&mut cur, &mut lines);
108 let depth = list_stack.len();
109 if let Some(top) = list_stack.last_mut() {
110 let indent = " ".repeat((depth.saturating_sub(1)) * 2);
111 let marker = if top.is_ordered {
112 let n = top.index + 1;
113 top.index += 1;
114 format!("{}{}. ", indent, n)
115 } else {
116 format!("{}• ", indent)
117 };
118 let seg = InlineSegment {
119 text: marker,
120 style: Arc::new(InlineTextStyle::default()),
121 };
122 merge_or_push(&mut cur, seg);
123 }
124 }
125
126 Event::Start(Tag::Paragraph) => {}
128
129 Event::Start(Tag::Heading { level, .. }) => {
130 flush_line(&mut cur, &mut lines);
131 {
132 effects = effects.insert(Effects::BOLD);
133 };
134 {
135 effects = effects.insert(if level == HeadingLevel::H1 {
136 Effects::UNDERLINE
137 } else {
138 Effects::default()
139 });
140 }
141 }
142 Event::End(TagEnd::Heading(_)) => {
143 {
144 effects = effects.remove(Effects::BOLD | Effects::UNDERLINE);
145 };
146 flush_line(&mut cur, &mut lines);
147 }
148
149 Event::Start(Tag::BlockQuote(_)) => {
150 {
151 effects = effects.insert(Effects::DIMMED);
152 };
153 }
154 Event::End(TagEnd::BlockQuote(_)) => {
155 {
156 effects = effects.remove(Effects::DIMMED);
157 };
158 }
159
160 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
161 flush_line(&mut cur, &mut lines);
162 }
163
164 Event::Rule => {
165 flush_line(&mut cur, &mut lines);
166 lines.push(vec![InlineSegment {
167 text: "\u{2500}".repeat(40),
168 style: Arc::new(InlineTextStyle::default().dim()),
169 }]);
170 }
171
172 Event::Start(Tag::Emphasis) => {
174 {
175 effects = effects.insert(Effects::ITALIC);
176 };
177 }
178 Event::End(TagEnd::Emphasis) => {
179 {
180 effects = effects.remove(Effects::ITALIC);
181 };
182 }
183
184 Event::Start(Tag::Strong) => {
185 {
186 effects = effects.insert(Effects::BOLD);
187 };
188 }
189 Event::End(TagEnd::Strong) => {
190 {
191 effects = effects.remove(Effects::BOLD);
192 };
193 }
194
195 Event::Start(Tag::Strikethrough) => {
196 {
197 effects = effects.insert(Effects::STRIKETHROUGH);
198 };
199 }
200 Event::End(TagEnd::Strikethrough) => {
201 {
202 effects = effects.remove(Effects::STRIKETHROUGH);
203 };
204 }
205
206 Event::Start(Tag::Link { .. }) => {
207 {
208 effects = effects.insert(Effects::UNDERLINE);
209 };
210 }
211 Event::End(TagEnd::Link) => {
212 {
213 effects = effects.remove(Effects::UNDERLINE);
214 };
215 }
216
217 Event::Text(t) | Event::Html(t) => {
219 let style = apply_effects(InlineTextStyle::default(), effects);
220 let seg = InlineSegment {
221 text: t.to_string(),
222 style: Arc::new(style),
223 };
224 merge_or_push(&mut cur, seg);
225 }
226
227 Event::Code(t) => {
228 let seg = InlineSegment {
229 text: t.to_string(),
230 style: Arc::new(InlineTextStyle::default().bold()),
231 };
232 merge_or_push(&mut cur, seg);
233 }
234
235 Event::SoftBreak | Event::HardBreak => {
236 flush_line(&mut cur, &mut lines);
237 }
238
239 Event::FootnoteReference(t) => {
240 let seg = InlineSegment {
241 text: format!("[^{}]", t),
242 style: Arc::new(InlineTextStyle::default().dim()),
243 };
244 merge_or_push(&mut cur, seg);
245 }
246
247 _ => {}
248 }
249 }
250
251 flush_line(&mut cur, &mut lines);
252 lines
253}
254
255pub fn render_code_block(
257 code: &str,
258 lang: Option<&str>,
259 ss: &SyntaxSet,
260) -> Vec<Vec<InlineSegment>> {
261 let syntax = lang
262 .and_then(|l| ss.find_syntax_by_token(l))
263 .unwrap_or_else(|| ss.find_syntax_plain_text());
264 let theme = THEME_SET
269 .themes
270 .get(crate::get_active_syntax_theme())
271 .or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
272 .cloned()
273 .unwrap_or_default();
274 #[allow(unused_mut)]
275 let mut h = HighlightLines::new(syntax, &theme);
276 let mut lines = Vec::new();
277
278 for line in syntect::util::LinesWithEndings::from(code) {
279 if let Ok(ranges) = h.highlight_line(line, ss) {
280 let segs: Vec<InlineSegment> = ranges
281 .into_iter()
282 .map(|(s, t)| {
283 let fg = s.foreground;
284 InlineSegment {
285 text: t.to_string(),
286 style: Arc::new(
287 InlineTextStyle::default()
288 .with_color(Some(AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)))),
289 ),
290 }
291 })
292 .collect();
293 lines.push(segs);
294 } else {
295 lines.push(vec![InlineSegment {
296 text: line.to_string(),
297 style: Arc::new(InlineTextStyle::default()),
298 }]);
299 }
300 }
301 lines
302}
303
304fn render_table(header: &[String], rows: &[Vec<String>]) -> Vec<Vec<InlineSegment>> {
306 let num_cols = std::cmp::max(
307 header.len(),
308 rows.iter().map(|r| r.len()).max().unwrap_or(0),
309 );
310 if num_cols == 0 {
311 return Vec::new();
312 }
313
314 let mut col_width: Vec<usize> = vec![0; num_cols];
316 for (c, cell) in header.iter().enumerate() {
317 col_width[c] = std::cmp::max(col_width[c], cell.width());
318 }
319 for row in rows {
320 for (c, cell) in row.iter().enumerate() {
321 col_width[c] = std::cmp::max(col_width[c], cell.width());
322 }
323 }
324
325 let mut out: Vec<Vec<InlineSegment>> = Vec::new();
326
327 let top = format!(
329 "┌{}┐",
330 col_width
331 .iter()
332 .map(|w| "─".repeat(w + 2))
333 .collect::<Vec<_>>()
334 .join("┬")
335 );
336 let mid = format!(
337 "├{}┤",
338 col_width
339 .iter()
340 .map(|w| "─".repeat(w + 2))
341 .collect::<Vec<_>>()
342 .join("┼")
343 );
344 let bot = format!(
345 "└{}┘",
346 col_width
347 .iter()
348 .map(|w| "─".repeat(w + 2))
349 .collect::<Vec<_>>()
350 .join("┴")
351 );
352
353 let plain = |s: String| {
354 vec![InlineSegment {
355 text: s,
356 style: Arc::new(InlineTextStyle::default()),
357 }]
358 };
359 let bold = |s: String| {
360 vec![InlineSegment {
361 text: s,
362 style: Arc::new(InlineTextStyle::default().bold()),
363 }]
364 };
365
366 out.push(plain(top));
367
368 let header_line = format_row(header, &col_width, num_cols);
370 out.push(bold(header_line));
371 out.push(plain(mid));
372
373 for row in rows {
375 out.push(plain(format_row(row, &col_width, num_cols)));
376 }
377
378 out.push(plain(bot));
379 out
380}
381
382fn format_row(cells: &[String], col_width: &[usize], num_cols: usize) -> String {
383 let mut parts: Vec<String> = Vec::with_capacity(num_cols);
384 for (c, &w) in col_width.iter().enumerate() {
385 let text = cells.get(c).map(String::as_str).unwrap_or("");
386 let pad = w.saturating_sub(text.width());
389 parts.push(format!(" {}{} ", text, " ".repeat(pad)));
390 }
391 format!("│{}│", parts.join("│"))
392}
393struct CodeBlockState {
396 code: String,
397 lang: Option<String>,
398}
399
400#[derive(Default)]
401struct TableState {
402 header: Vec<String>,
403 rows: Vec<Vec<String>>,
404 current_cell: String,
405 current_row: Vec<String>,
406}
407
408struct ListLevel {
409 is_ordered: bool,
410 index: u64,
411}
412
413fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
414 if !cur.is_empty() {
415 lines.push(std::mem::take(cur));
416 }
417}
418
419fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
420 if let Some(last) = cur.last_mut() {
421 if last.style == seg.style {
422 last.text.push_str(&seg.text);
423 return;
424 }
425 }
426 cur.push(seg);
427}
428
429fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
430 if effects.contains(Effects::BOLD) {
431 style = style.bold();
432 }
433 if effects.contains(Effects::ITALIC) {
434 style = style.italic();
435 }
436 if effects.contains(Effects::UNDERLINE) {
437 style = style.underline();
438 }
439 if effects.contains(Effects::DIMMED) {
440 style = style.dim();
441 }
442 if effects.contains(Effects::STRIKETHROUGH) {
443 style.effects |= Effects::STRIKETHROUGH;
444 }
445 style
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn line_text(line: &[InlineSegment]) -> String {
453 line.iter().map(|s| s.text.as_str()).collect()
454 }
455
456 #[test]
457 fn unordered_list_has_markers() {
458 let out = render_markdown("- a\n- b\n");
459 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
461 let line_a = combined
462 .iter()
463 .find(|l| l.contains('a'))
464 .expect("line with 'a'");
465 let line_b = combined
466 .iter()
467 .find(|l| l.contains('b'))
468 .expect("line with 'b'");
469 assert!(line_a.contains('\u{2022}'), "missing bullet in: {line_a:?}");
470 assert!(line_b.contains('\u{2022}'), "missing bullet in: {line_b:?}");
471 }
472
473 #[test]
474 fn ordered_list_has_numbers() {
475 let out = render_markdown("1. first\n2. second\n");
476 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
477 let has_one = combined
478 .iter()
479 .any(|l| l.contains("1.") && l.contains("first"));
480 let has_two = combined
481 .iter()
482 .any(|l| l.contains("2.") && l.contains("second"));
483 assert!(has_one, "missing '1.' marker in {combined:?}");
484 assert!(has_two, "missing '2.' marker in {combined:?}");
485 }
486
487 #[test]
488 fn table_renders_borders() {
489 let md = "| h1 | h2 |\n|----|----|\n| a | b |\n| c | d |\n";
490 let out = render_markdown(md);
491 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
492 let bar_lines = combined.iter().filter(|l| l.contains('\u{2502}')).count();
493 assert!(bar_lines >= 3, "expected ≥3 lines with │, got {combined:?}");
494 let has_top_or_bottom = combined
495 .iter()
496 .any(|l| l.contains('\u{250C}') || l.contains('\u{2514}'));
497 assert!(
498 has_top_or_bottom,
499 "expected ┌ or └ in output, got {combined:?}"
500 );
501 }
502
503 #[test]
504 fn inline_still_works() {
505 let out = render_markdown("**bold**");
506 let bold_found = out.iter().any(|line| {
507 line.iter()
508 .any(|seg| seg.style.effects.contains(anstyle::Effects::BOLD))
509 });
510 assert!(bold_found, "expected BOLD effect in rendered segments");
511 }
512 #[test]
513 fn table_cell_keeps_inline_code() {
514 let md = "| type | example |\n|------|----------|\n| foo | `bar` |\n";
517 let out = render_markdown(md);
518 let joined: String = out
519 .iter()
520 .map(|l| line_text(l))
521 .collect::<Vec<_>>()
522 .join("\n");
523 assert!(
524 joined.contains("bar"),
525 "inline code `bar` dropped from table cell: {joined:?}"
526 );
527 }
528
529 #[test]
530 fn table_cjk_columns_align() {
531 let md = "| a | b |\n|---|----|\n| 中 | x |\n| 1 | yy |\n";
534 let out = render_markdown(md);
535 let rows: Vec<String> = out
536 .iter()
537 .map(|l| line_text(l))
538 .filter(|l| l.starts_with('\u{2502}'))
539 .collect();
540 let widths: Vec<usize> = rows
541 .iter()
542 .map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
543 .collect();
544 let first = widths[0];
545 assert!(
546 widths.iter().all(|&w| w == first),
547 "CJK column misalignment — row display widths differ: {widths:?}\n{rows:?}"
548 );
549 }
550}