1use std::cell::RefCell;
4use std::fmt;
5use std::ops::Range;
6use std::sync::{Arc, Mutex, PoisonError};
7
8use pulldown_cmark::{CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd};
9
10use super::cells;
11use super::code_view::{CodeRow, code_rows, gutter_width, padding_decoration, paint_rows};
12use super::highlight::Language;
13use crate::geometry::{Rect, Size, clamp_u16};
14use crate::style::CellStyle;
15use crate::text;
16use crate::widget::{MeasureCx, PaintCx, Widget};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20struct Format {
21 strong: bool,
22 emphasis: bool,
23 code: bool,
24 link: bool,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct Inline {
29 text: String,
30 format: Format,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34enum Block {
35 Heading(u8, Vec<Inline>),
36 Paragraph(Vec<Inline>),
37 Item { depth: u16, marker: Option<u64>, content: Vec<Inline> },
38 Quote(Vec<Inline>),
39 Code { language: Language, text: String },
40 Rule,
41}
42
43fn parse(source: &str) -> Vec<Block> {
44 let mut blocks = Vec::new();
45 let mut inlines: Vec<Inline> = Vec::new();
46 let mut format = Format::default();
47 let mut lists: Vec<Option<u64>> = Vec::new();
48 let mut item_marker: Option<Option<u64>> = None;
49 let mut heading: Option<u8> = None;
50 let mut quote_depth = 0;
51 let mut code: Option<(Language, String)> = None;
52
53 let push_text = |inlines: &mut Vec<Inline>, text: &str, format: Format| {
54 if let Some(last) = inlines.last_mut()
55 && last.format == format
56 {
57 last.text.push_str(text);
58 return;
59 }
60 inlines.push(Inline { text: text.to_owned(), format });
61 };
62
63 let flush = |blocks: &mut Vec<Block>,
64 inlines: &mut Vec<Inline>,
65 lists: &[Option<u64>],
66 item_marker: &mut Option<Option<u64>>,
67 heading: Option<u8>,
68 quote_depth: u32| {
69 if inlines.is_empty() {
70 return;
71 }
72 let content = std::mem::take(inlines);
73 let block = if let Some(level) = heading {
74 Block::Heading(level, content)
75 } else if item_marker.is_some() || !lists.is_empty() {
76 let depth = u16::try_from(lists.len().saturating_sub(1)).unwrap_or(u16::MAX);
78 Block::Item { depth, marker: item_marker.take().flatten(), content }
79 } else if quote_depth > 0 {
80 Block::Quote(content)
81 } else {
82 Block::Paragraph(content)
83 };
84 blocks.push(block);
85 };
86
87 for event in Parser::new_ext(source, Options::ENABLE_STRIKETHROUGH) {
88 match event {
89 MdEvent::Start(Tag::Heading { level, .. }) => {
90 heading = Some(match level {
91 HeadingLevel::H1 => 1,
92 HeadingLevel::H2 => 2,
93 _ => 3,
94 });
95 }
96 MdEvent::End(TagEnd::Heading(_)) => {
97 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
98 heading = None;
99 }
100 MdEvent::Start(Tag::List(start)) => {
101 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
102 lists.push(start);
103 }
104 MdEvent::End(TagEnd::List(_)) => {
105 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
106 lists.pop();
107 }
108 MdEvent::Start(Tag::Item) => {
109 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
110 let marker = lists.last_mut().and_then(|list| {
111 let current = *list;
112 if let Some(n) = list {
113 *n += 1;
114 }
115 current
116 });
117 item_marker = Some(marker);
118 }
119 MdEvent::End(TagEnd::Item | TagEnd::Paragraph | TagEnd::BlockQuote(_)) => {
120 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
121 if let MdEvent::End(TagEnd::BlockQuote(_)) = event {
122 quote_depth = quote_depth.saturating_sub(1);
123 }
124 }
125 MdEvent::Start(Tag::BlockQuote(_)) => quote_depth += 1,
126 MdEvent::Start(Tag::CodeBlock(kind)) => {
127 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
128 let language = match kind {
129 CodeBlockKind::Fenced(tag) => Language::from_tag(&tag),
130 CodeBlockKind::Indented => Language::Plain,
131 };
132 code = Some((language, String::new()));
133 }
134 MdEvent::End(TagEnd::CodeBlock) => {
135 if let Some((language, text)) = code.take() {
136 blocks.push(Block::Code { language, text: text.trim_end_matches('\n').to_owned() });
137 }
138 }
139 MdEvent::Start(Tag::Strong) => format.strong = true,
140 MdEvent::End(TagEnd::Strong) => format.strong = false,
141 MdEvent::Start(Tag::Emphasis) => format.emphasis = true,
142 MdEvent::End(TagEnd::Emphasis) => format.emphasis = false,
143 MdEvent::Start(Tag::Link { .. }) => format.link = true,
144 MdEvent::End(TagEnd::Link) => format.link = false,
145 MdEvent::Text(text) => match &mut code {
146 Some((_, body)) => body.push_str(&text),
147 None => push_text(&mut inlines, &text, format),
148 },
149 MdEvent::Code(text) => push_text(&mut inlines, &text, Format { code: true, ..format }),
150 MdEvent::SoftBreak => push_text(&mut inlines, " ", format),
151 MdEvent::HardBreak => push_text(&mut inlines, "\n", format),
152 MdEvent::Rule => {
153 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
154 blocks.push(Block::Rule);
155 }
156 _ => {}
157 }
158 }
159 flush(&mut blocks, &mut inlines, &lists, &mut item_marker, heading, quote_depth);
160 blocks
161}
162
163#[derive(Clone)]
179pub struct Markdown {
180 document: Arc<Document>,
181}
182
183impl Markdown {
184 #[must_use]
186 pub fn new(source: &str) -> Self {
187 Self { document: Document::cached(source) }
188 }
189}
190
191impl fmt::Debug for Markdown {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.debug_struct("Markdown").field("blocks", &self.document.blocks).finish()
194 }
195}
196
197impl PartialEq for Markdown {
198 fn eq(&self, other: &Self) -> bool {
199 Arc::ptr_eq(&self.document, &other.document) || self.document.blocks == other.document.blocks
200 }
201}
202
203impl Eq for Markdown {}
204
205const CACHED_DOCUMENTS: usize = 8;
208
209const CACHED_LAYOUTS: usize = 4;
212
213thread_local! {
214 static DOCUMENTS: RefCell<Vec<Arc<Document>>> = const { RefCell::new(Vec::new()) };
216}
217
218struct Document {
220 source: String,
221 blocks: Vec<Block>,
222 texts: Vec<(String, Vec<Range<usize>>)>,
225 layouts: Mutex<Vec<Arc<Layout>>>,
227}
228
229impl Document {
230 fn cached(source: &str) -> Arc<Self> {
233 DOCUMENTS.with_borrow_mut(|documents| {
234 let document = match documents.iter().position(|document| document.source == source) {
235 Some(index) => documents.remove(index),
236 None => Arc::new(Self::parse(source)),
237 };
238 documents.insert(0, Arc::clone(&document));
239 documents.truncate(CACHED_DOCUMENTS);
240 document
241 })
242 }
243
244 fn parse(source: &str) -> Self {
245 let blocks = parse(source);
246 let texts = blocks
247 .iter()
248 .map(|block| match block {
249 Block::Heading(_, content)
250 | Block::Paragraph(content)
251 | Block::Quote(content)
252 | Block::Item { content, .. } => joined(content),
253 Block::Code { .. } | Block::Rule => (String::new(), Vec::new()),
254 })
255 .collect();
256 Self { source: source.to_owned(), blocks, texts, layouts: Mutex::new(Vec::new()) }
257 }
258
259 fn layout(&self, width: u16, code_padding: (u16, u16)) -> Arc<Layout> {
262 let mut layouts = self.layouts.lock().unwrap_or_else(PoisonError::into_inner);
263 let key = (width, code_padding);
264 let layout = match layouts.iter().position(|layout| (layout.width, layout.code_padding) == key) {
265 Some(index) => layouts.remove(index),
266 None => Arc::new(self.lay_out(width, code_padding)),
267 };
268 layouts.insert(0, Arc::clone(&layout));
269 layouts.truncate(CACHED_LAYOUTS);
270 layout
271 }
272
273 fn lay_out(&self, width: u16, code_padding: (u16, u16)) -> Layout {
274 let mut rows = 0u16;
275 let mut blocks = Vec::with_capacity(self.blocks.len());
276 for (index, block) in self.blocks.iter().enumerate() {
277 let placed = self.place(index, width, code_padding, rows);
278 rows = rows.saturating_add(placed.height);
279 if block.gap_after(self.blocks.get(index + 1)) && index + 1 < self.blocks.len() {
280 rows = rows.saturating_add(1);
281 }
282 blocks.push(placed);
283 }
284 Layout { width, code_padding, blocks }
285 }
286
287 fn place(&self, index: usize, width: u16, code_padding: (u16, u16), top: u16) -> Placed {
289 let block = &self.blocks[index];
290 let (rows, lines, code) = match block {
291 Block::Heading(..) | Block::Paragraph(_) | Block::Quote(_) | Block::Item { .. } => {
292 let lines = text::wrap_ranges(&self.texts[index].0, width.saturating_sub(indent_of(block)));
293 (lines.len(), lines, None)
294 }
295 Block::Code { language, text } => {
296 let sides = code_padding.1.saturating_mul(2).saturating_add(gutter_width(text));
297 let inner = width.saturating_sub(sides).max(1);
298 let rows = code_rows(text, *language, inner);
299 (rows.len() + usize::from(code_padding.0.saturating_mul(2)), Vec::new(), Some((inner, rows)))
300 }
301 Block::Rule => (0, Vec::new(), None),
302 };
303 Placed { top, height: clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)), lines, code }
304 }
305}
306
307struct Layout {
309 width: u16,
310 code_padding: (u16, u16),
311 blocks: Vec<Placed>,
313}
314
315impl Layout {
316 fn height(&self) -> u16 {
318 self.blocks.last().map_or(0, |placed| placed.top.saturating_add(placed.height))
319 }
320}
321
322struct Placed {
324 top: u16,
326 height: u16,
327 lines: Vec<Range<usize>>,
329 code: Option<(u16, Vec<CodeRow>)>,
331}
332
333impl Block {
334 fn gap_after(&self, next: Option<&Self>) -> bool {
336 !(matches!(self, Self::Item { .. }) && matches!(next, Some(Self::Item { .. })))
337 }
338}
339
340fn joined(content: &[Inline]) -> (String, Vec<Range<usize>>) {
341 let mut out = String::new();
342 let mut ranges = Vec::new();
343 for inline in content {
344 let start = out.len();
345 out.push_str(&inline.text);
346 ranges.push(start..out.len());
347 }
348 (out, ranges)
349}
350
351fn indent_of(block: &Block) -> u16 {
352 match block {
353 Block::Heading(..) | Block::Quote(_) => 2,
354 Block::Item { depth, marker, .. } => cells::sum([2, depth.saturating_mul(2), marker_width(*marker), 1]),
355 Block::Paragraph(_) | Block::Code { .. } | Block::Rule => 0,
356 }
357}
358
359fn marker_width(marker: Option<u64>) -> u16 {
361 match marker {
362 None => 1,
363 Some(n) => text::width(&n.to_string()).saturating_add(1),
364 }
365}
366
367impl Markdown {
368 fn inline_styles(cx: &mut PaintCx<'_>, content: &[Inline], base: CellStyle) -> Vec<CellStyle> {
369 content
370 .iter()
371 .map(|inline| {
372 let mut style = base;
373 if inline.format.strong {
374 style = merge(style, cx.style("markdown-strong", None, &[]).text());
375 }
376 if inline.format.emphasis {
377 style = merge(style, cx.style("markdown-emphasis", None, &[]).text());
378 }
379 if inline.format.link {
380 style = merge(style, cx.style("markdown-link", None, &[]).text());
381 }
382 if inline.format.code {
383 style = merge(style, cx.style("markdown-code", None, &[]).text());
384 }
385 style
386 })
387 .collect()
388 }
389
390 fn paint_inline(&self, cx: &mut PaintCx<'_>, area: Rect, index: usize, placed: &Placed, base: CellStyle) {
392 let (Block::Heading(_, content)
393 | Block::Paragraph(content)
394 | Block::Quote(content)
395 | Block::Item { content, .. }) = &self.document.blocks[index]
396 else {
397 return;
398 };
399 let (text, ranges) = &self.document.texts[index];
400 let styles = Self::inline_styles(cx, content, base);
401 for (row, line) in placed.lines.iter().enumerate() {
402 let y = area.y + i32::try_from(row).unwrap_or(0);
403 let mut x = area.x;
404 for (range, style) in ranges.iter().zip(&styles) {
405 let start = range.start.max(line.start);
406 let end = range.end.min(line.end);
407 if start < end {
408 x += i32::from(cx.text(x, y, &text[start..end], *style, area.width));
409 }
410 }
411 }
412 }
413}
414
415fn merge(base: CellStyle, over: CellStyle) -> CellStyle {
417 CellStyle {
418 fg: over.fg.or(base.fg),
419 bg: over.bg.or(base.bg),
420 bold: base.bold || over.bold,
421 italic: base.italic || over.italic,
422 underline: base.underline || over.underline,
423 dim: base.dim || over.dim,
424 }
425}
426
427impl<Msg: 'static> Widget<Msg> for Markdown {
428 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
429 let code_padding = cx.env().theme().style("code", None, &[]).pair("padding").unwrap_or((1, 2));
430 let height = self.document.layout(available.width, code_padding).height();
431 Size::new(available.width, height.min(available.height))
432 }
433
434 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
435 cx.selectable(area);
436 let code_style = cx.style("code", None, &[]);
437 let code_padding = code_style.padding();
438 let layout = self.document.layout(area.width, (code_padding.top, code_padding.left));
439 let clip = cx.clip();
440 let bottom = |placed: &Placed| area.y + i32::from(placed.top) + i32::from(placed.height);
443 let first = layout.blocks.partition_point(|placed| bottom(placed) < clip.y);
444 for (index, placed) in layout.blocks.iter().enumerate().skip(first) {
445 let height = placed.height;
446 let rect = Rect::new(area.x, area.y + i32::from(placed.top), area.width, height);
447 if rect.y >= clip.bottom() {
448 break;
449 }
450 let block = &self.document.blocks[index];
451 let indent = indent_of(block);
452 let body = Rect::new(rect.x + i32::from(indent), rect.y, rect.width.saturating_sub(indent), height);
453 match block {
454 Block::Heading(level, _) => {
455 let variant = format!("h{level}");
456 let style = cx.style("markdown-heading", Some(&variant), &[]);
457 if let Some(color) = style.color("pillar") {
458 cx.pillar(rect.x, rect.y, color);
459 }
460 cx.decoration(Rect::new(rect.x, rect.y, indent, height));
462 self.paint_inline(cx, body, index, placed, style.text());
463 }
464 Block::Paragraph(_) => {
465 let style = cx.style("markdown-text", None, &[]).text();
466 self.paint_inline(cx, body, index, placed, style);
467 }
468 Block::Quote(_) => {
469 let style = cx.style("markdown-quote", None, &[]);
470 if let Some(color) = style.color("pillar") {
471 for row in 0..height {
472 cx.pillar(rect.x, rect.y + i32::from(row), color);
473 }
474 }
475 cx.decoration(Rect::new(rect.x, rect.y, indent, height));
476 self.paint_inline(cx, body, index, placed, style.text());
477 }
478 Block::Item { depth, marker, .. } => {
479 let bullet_style = cx.style("markdown-bullet", None, &[]).text();
480 let bullet = match marker {
481 None => cx.env().icons().glyph("bullet").into_owned(),
482 Some(n) => format!("{n}."),
483 };
484 let bullet_x = rect.x + 2 + i32::from(*depth) * 2;
485 cx.text(bullet_x, rect.y, &bullet, bullet_style, marker_width(*marker));
486 let style = cx.style("markdown-text", None, &[]).text();
487 self.paint_inline(cx, body, index, placed, style);
488 }
489 Block::Code { language, text } => {
490 if let Some(bg) = code_style.text().bg {
491 cx.clear(rect, bg);
492 }
493 let inner = rect.inset(code_padding);
494 padding_decoration(cx, rect, inner);
495 let gutter = gutter_width(text);
496 let width = inner.width.saturating_sub(gutter).max(1);
497 match &placed.code {
498 Some((wrapped, rows)) if *wrapped == width => paint_rows(cx, inner, rows, gutter),
500 _ => paint_rows(cx, inner, &code_rows(text, *language, width), gutter),
501 }
502 }
503 Block::Rule => {}
504 }
505 }
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::runtime::{App, Command, Harness};
513 use crate::widget::View;
514
515 struct Demo(&'static str);
516
517 impl App for Demo {
518 type Msg = ();
519 fn update(&mut self, _: ()) -> Command<()> {
520 Command::none()
521 }
522 fn view(&self, ui: &mut View<'_, ()>) {
523 ui.add(Markdown::new(self.0)).fill();
524 }
525 }
526
527 #[test]
528 fn documents_are_shared_by_source_and_the_cache_stays_bounded() {
529 let first = Markdown::new("## Deploys\n\nAll green.");
530 let again = Markdown::new("## Deploys\n\nAll green.");
531 assert!(Arc::ptr_eq(&first.document, &again.document), "the same source is parsed once");
532 assert_ne!(first, Markdown::new("## Deploys\n\nTwo failed."));
533 for n in 0..CACHED_DOCUMENTS * 3 {
534 let _ = Markdown::new(&format!("Release {n}"));
535 }
536 assert_eq!(DOCUMENTS.with_borrow(Vec::len), CACHED_DOCUMENTS);
537 let fresh = Markdown::new("## Deploys\n\nAll green.");
538 assert!(!Arc::ptr_eq(&first.document, &fresh.document), "the oldest documents are forgotten");
539 assert_eq!(first, fresh, "a document parsed again is equal");
540 for width in 10..20 {
541 let _ = first.document.layout(width, (1, 2));
542 }
543 assert_eq!(first.document.layouts.lock().map(|layouts| layouts.len()).ok(), Some(CACHED_LAYOUTS));
544 }
545
546 #[test]
547 fn remembered_layouts_paint_exactly_like_fresh_ones() {
548 let source = "## Rollout\n\nThe **api** image `quvyta/api:2.4` rolls out to every region in turn.\n\n\
549 1. drain the old pods\n2. start the new ones\n\n> Watch the error rate.\n\n\
550 ```rust\nfn main() { println!(\"deploying quvyta/api:2.4 to every region\"); }\n```\n";
551 let mut h = Harness::new(Demo(source), 30, 24);
552 h.resize(52, 24).resize(41, 24).resize(30, 24).resize(52, 24);
553 for width in [30, 41, 52] {
554 let remembered = h.resize(width, 24).html("markdown");
555 DOCUMENTS.with_borrow_mut(Vec::clear);
556 let fresh = Harness::new(Demo(source), width, 24);
557 assert_eq!(remembered, fresh.html("markdown"), "width {width}");
558 }
559 }
560
561 #[test]
562 fn parses_blocks_and_inlines() {
563 let blocks = parse(
564 "## When to use\n\nPress **Enter** or `space`.\n\n- one\n- two\n\n1. first\n\n---\n\n> note\n\n```toml\nbg = 1\n```\n",
565 );
566 assert!(matches!(&blocks[0], Block::Heading(2, _)));
567 let Block::Paragraph(inlines) = &blocks[1] else { panic!("paragraph") };
568 assert!(inlines.iter().any(|i| i.text == "Enter" && i.format.strong));
569 assert!(inlines.iter().any(|i| i.text == "space" && i.format.code));
570 assert!(matches!(blocks[2], Block::Item { depth: 0, marker: None, .. }));
571 assert!(matches!(blocks[4], Block::Item { marker: Some(1), .. }));
572 assert!(matches!(blocks[5], Block::Rule));
573 assert!(matches!(blocks[6], Block::Quote(_)));
574 assert!(matches!(&blocks[7], Block::Code { language: Language::Toml, text } if text == "bg = 1"));
575 }
576
577 #[test]
578 fn later_blocks_of_an_item_get_a_bullet_and_long_numbers_show_whole() {
579 let blocks = parse("1. first\n\n more\n");
580 assert!(matches!(blocks[0], Block::Item { depth: 0, marker: Some(1), .. }));
581 assert!(matches!(blocks[1], Block::Item { depth: 0, marker: None, .. }), "{blocks:?}");
582 let h = Harness::new(Demo("99. deploy\n100. verify\n\n notes"), 24, 5);
583 assert_eq!(h.screen(), " 99. deploy\n 100. verify\n • notes\n\n\n");
584 }
585
586 #[test]
587 fn renders_without_ascii_decoration() {
588 let h = Harness::new(
589 Demo("## Usage\n\nA widget that **wraps** nicely here.\n\n- first\n- second\n\n---\n\nEnd"),
590 24,
591 12,
592 );
593 assert_eq!(h.screen(), "▌ Usage\n\nA widget that wraps\nnicely here.\n\n • first\n • second\n\n\nEnd\n\n\n");
594 let theme = h.env().theme();
595 assert_eq!(h.fg(0, 0), theme.style("markdown-heading", Some("h2"), &[]).paint("pillar").map(|p| p.at(0.0)));
596 }
597
598 fn lone_punctuation(harness: &Harness<Demo>) -> Vec<String> {
600 let screen = harness.screen();
601 let rows = screen.lines().map(str::trim).filter(|row| !row.is_empty());
602 rows.filter(|row| row.chars().all(|c| ".,;:)".contains(c))).map(str::to_owned).collect()
603 }
604
605 #[test]
606 fn punctuation_after_inline_code_never_wraps_alone() {
607 let step =
609 "2. Write an enum of everything that can happen: `Msg`. Every button, field and list sends one of these.";
610 for width in 10..=110 {
611 let h = Harness::new(Demo(step), width, 30);
612 assert_eq!(lone_punctuation(&h), Vec::<String>::new(), "width {width}:\n{}", h.screen());
613 }
614 let h = Harness::new(Demo(step), 54, 4);
615 assert_eq!(
616 h.screen(),
617 " 2. Write an enum of everything that can happen: Msg.\n Every button, field and list sends one of these.\n\n\n"
618 );
619
620 let badge = "1. Add a neutral badge: `ui.add(Badge::new(\"Paused\"))`.";
623 let h = Harness::new(Demo(badge), 29, 3);
625 assert_eq!(h.screen(), " 1. Add a neutral badge: ui.\n add(Badge::new(\"Pause\n d\")).\n");
626 let theme = h.env().theme();
627 let code = theme.style("markdown-code", None, &[]).paint("fg").map(|p| p.at(0.0));
628 let text = theme.style("markdown-text", None, &[]).paint("fg").map(|p| p.at(0.0));
629 assert_eq!((h.fg(5, 2), h.fg(8, 2)), (code, code), "the carried-over code keeps its style");
630 assert_eq!(h.fg(9, 2), text, "the full stop is plain text");
631 assert_ne!(text, code);
632 for width in 10..=60 {
633 let h = Harness::new(Demo(badge), width, 12);
634 assert_eq!(lone_punctuation(&h), Vec::<String>::new(), "width {width}:\n{}", h.screen());
635 }
636 }
637}