Skip to main content

qframe/widgets/
markdown.rs

1//! Markdown documents: guides, help screens and descriptions.
2
3use 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/// Inline formatting of a piece of text.
19#[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            // The first block of an item carries its number; later blocks of the item get a bullet.
77            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/// Rendered Markdown: headings with the accent pillar, wrapped paragraphs, lists, quotes and
164/// highlighted code blocks. Horizontal rules become space, never a drawn line.
165///
166/// The document is a text selection region: a mouse drag selects inside it (turn it off with
167/// [`NodeMut::selectable`](crate::widget::NodeMut::selectable)). Clean copies leave out the
168/// pillars of headings and quotes with the cell after them, and the padding and line numbers of
169/// code blocks.
170///
171/// Building one in `view` every frame is cheap: the last few documents parsed on a thread are
172/// remembered by their source text, together with how they were laid out at the last few widths,
173/// so an unchanged document is neither parsed nor wrapped again.
174///
175/// Style keys: `markdown-heading.h1|h2|h3` (`fg`, `bold`, `pillar`), `markdown-text`,
176/// `markdown-strong`, `markdown-emphasis`, `markdown-code`, `markdown-link`, `markdown-bullet`,
177/// `markdown-quote` (`fg`, `pillar`), `code` and `code-token.<kind>` for code blocks.
178#[derive(Clone)]
179pub struct Markdown {
180    document: Arc<Document>,
181}
182
183impl Markdown {
184    /// Parses `source`, or reuses the document parsed from the same source a moment ago.
185    #[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
205/// How many parsed documents a thread remembers. A screen shows a handful of documents at once
206/// (a guide, a reference, a note).
207const CACHED_DOCUMENTS: usize = 8;
208
209/// How many widths a document remembers its layout for. A scroll view may measure its content
210/// with and without room for its scrollbar before it paints.
211const CACHED_LAYOUTS: usize = 4;
212
213thread_local! {
214    /// The documents parsed last on this thread, most recently used first.
215    static DOCUMENTS: RefCell<Vec<Arc<Document>>> = const { RefCell::new(Vec::new()) };
216}
217
218/// A parsed source with the layouts computed for it.
219struct Document {
220    source: String,
221    blocks: Vec<Block>,
222    /// For every block, its inline texts joined into one string with the byte range of each
223    /// inline; empty for code blocks and rules.
224    texts: Vec<(String, Vec<Range<usize>>)>,
225    /// Layouts at recent widths, most recently used first.
226    layouts: Mutex<Vec<Arc<Layout>>>,
227}
228
229impl Document {
230    /// The document for `source`: the remembered one when this thread parsed the same source
231    /// recently, otherwise a freshly parsed one that is remembered in place of the oldest.
232    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    /// The document laid out `width` cells wide with code blocks padded by `code_padding`
260    /// (rows, columns); computed once per width and remembered.
261    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    /// Block `index` wrapped to `width` cells, starting at row `top`.
288    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
307/// A document laid out at one width.
308struct Layout {
309    width: u16,
310    code_padding: (u16, u16),
311    /// One entry per block, top to bottom.
312    blocks: Vec<Placed>,
313}
314
315impl Layout {
316    /// Rows the whole document takes.
317    fn height(&self) -> u16 {
318        self.blocks.last().map_or(0, |placed| placed.top.saturating_add(placed.height))
319    }
320}
321
322/// Where a block goes and how it wraps.
323struct Placed {
324    /// First row, counted from the top of the document.
325    top: u16,
326    height: u16,
327    /// Byte ranges into the block's joined text, one per wrapped line.
328    lines: Vec<Range<usize>>,
329    /// For a code block: the width its code was wrapped to, and the rows.
330    code: Option<(u16, Vec<CodeRow>)>,
331}
332
333impl Block {
334    /// Whether a blank row separates this block from the next one; list items stay together.
335    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
359/// Cells taken by a list marker: one for a bullet, the digits and dot for a number.
360fn 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    /// Draws the inline content of block `index` inside `area`, wrapped as `placed` says.
391    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
415/// Layers `over` onto `base`: colours and switched-on attributes of `over` win.
416fn 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        // Blocks are placed top to bottom, so the ones in the clip are one run: skip to its start
441        // and stop after its end.
442        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                    // The pillar and its gap are not part of the heading's text.
461                    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                        // Wrapped at this width by the layout already.
499                        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    /// Rows of `harness` that hold nothing but closing punctuation.
599    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        // A list step with inline code, at every width from a phone-narrow column to a wide one.
608        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        // A code span wider than the line breaks inside it, and the closing punctuation keeps the
621        // last character of the span company.
622        let badge = "1. Add a neutral badge: `ui.add(Badge::new(\"Paused\"))`.";
623        // Widths where a naive break leaves `).` alone on the last row (29) or a lone `.` (12).
624        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}