Skip to main content

ravenrs_widgets/
rich_text.rs

1//! Bold, italic, code and links in plain text, with the marks of chat apps.
2
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Color, CursorIcon, Insets, Quad, Rect, Role, Scene};
7use ravenrs_layout::{Align, Axis, LayoutStyle};
8use ravenrs_style::{Semantic, TextStyle, Weight};
9use ravenrs_text::TextAttrs;
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{Fill, Text, View};
13
14/// How a piece of a line is marked.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16struct Marks {
17    bold: bool,
18    italic: bool,
19    strike: bool,
20    code: bool,
21}
22
23#[cfg_attr(feature = "live", ravenrs_live::expose)]
24impl Marks {
25    fn with(self, mark: char) -> Self {
26        match mark {
27            '*' => Self { bold: true, ..self },
28            '_' => Self { italic: true, ..self },
29            '~' => Self { strike: true, ..self },
30            _ => Self { code: true, ..self },
31        }
32    }
33}
34
35/// A run of text with one look.
36#[derive(Clone, Debug, PartialEq, Eq)]
37struct Span {
38    text: String,
39    marks: Marks,
40    link: bool,
41}
42
43/// What a line starts with.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45enum LineKind {
46    Plain,
47    Item,
48    Quote,
49}
50
51/// What a text is made of, from top to bottom.
52#[derive(Clone, Debug, PartialEq, Eq)]
53enum Block {
54    Line(LineKind, Vec<Span>),
55    Code(String),
56}
57
58#[cfg_attr(feature = "live", ravenrs_live::expose)]
59impl Block {
60    fn is_plain(&self) -> bool {
61        match self {
62            Self::Line(LineKind::Plain, spans) => {
63                spans.iter().all(|span| span.marks == Marks::default() && !span.link)
64            }
65            _ => false,
66        }
67    }
68}
69
70const FENCE: &str = "```";
71
72fn parse(text: &str, marks: bool) -> Vec<Block> {
73    let mut blocks = Vec::new();
74    let mut rest = text;
75    if marks {
76        // Fences come first: they cross lines, and nothing inside them is a mark.
77        while let Some(open) = rest.find(FENCE) {
78            let inside = &rest[open + FENCE.len()..];
79            let Some(close) = inside.find(FENCE) else { break };
80            let code = inside[..close].trim_matches('\n');
81            if code.trim().is_empty() {
82                // Six backticks around nothing are six backticks.
83                let end = open + FENCE.len() * 2 + close;
84                parse_lines(&rest[..end], marks, &mut blocks);
85                rest = &rest[end..];
86                continue;
87            }
88            parse_lines(rest[..open].strip_suffix('\n').unwrap_or(&rest[..open]), marks, &mut blocks);
89            blocks.push(Block::Code(code.to_owned()));
90            let after = &inside[close + FENCE.len()..];
91            rest = after.strip_prefix('\n').unwrap_or(after);
92            if rest.is_empty() {
93                return blocks;
94            }
95        }
96    }
97    parse_lines(rest, marks, &mut blocks);
98    blocks
99}
100
101fn parse_lines(text: &str, marks: bool, blocks: &mut Vec<Block>) {
102    if text.is_empty() {
103        return;
104    }
105    for line in text.split('\n') {
106        let filled = |body: &&str| !body.trim().is_empty();
107        let (kind, body) = if !marks {
108            (LineKind::Plain, line)
109        } else if let Some(body) = line.strip_prefix("* ").or_else(|| line.strip_prefix("- ")).filter(filled)
110        {
111            (LineKind::Item, body)
112        } else if let Some(body) = line.strip_prefix("> ").filter(filled) {
113            (LineKind::Quote, body)
114        } else {
115            (LineKind::Plain, line)
116        };
117        let mut spans = Vec::new();
118        parse_inline(body, Marks::default(), marks, &mut spans);
119        blocks.push(Block::Line(kind, spans));
120    }
121}
122
123/// The length of the link starting `text`, if one does. Punctuation that ends a sentence is
124/// left out, or "see https://x/y." would lead to a page that does not exist. Only web
125/// addresses count, so that a message can never hand the app another kind of address.
126fn link_length(text: &str) -> Option<usize> {
127    let scheme = ["https://", "http://"].into_iter().find(|scheme| text.starts_with(scheme))?;
128    let end =
129        text.find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'')).unwrap_or(text.len());
130    let link = text[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}']);
131    (link.len() > scheme.len()).then_some(link.len())
132}
133
134/// A mark opens after the start, a space or punctuation, right against what it marks.
135fn opens(text: &str, at: usize, mark: char) -> bool {
136    let before = text[..at].chars().next_back();
137    let after = text[at + mark.len_utf8()..].chars().next();
138    before.is_none_or(|c| !c.is_alphanumeric() && c != mark)
139        && after.is_some_and(|c| !c.is_whitespace() && c != mark)
140}
141
142/// Where the mark opened at `at` closes: against the text, before the end, a space or
143/// punctuation. A code mark closes at the next backtick, whatever is around it.
144fn closes(text: &str, at: usize, mark: char) -> Option<usize> {
145    let start = at + mark.len_utf8();
146    text[start..].match_indices(mark).map(|(i, _)| start + i).find(|&i| {
147        if i == start {
148            return false;
149        }
150        if mark == '`' {
151            return true;
152        }
153        let before = text[..i].chars().next_back();
154        let after = text[i + mark.len_utf8()..].chars().next();
155        before.is_some_and(|c| !c.is_whitespace()) && after.is_none_or(|c| !c.is_alphanumeric())
156    })
157}
158
159fn push_span(spans: &mut Vec<Span>, text: &str, marks: Marks, link: bool) {
160    if !text.is_empty() {
161        spans.push(Span { text: text.to_owned(), marks, link });
162    }
163}
164
165fn parse_inline(text: &str, base: Marks, marks: bool, spans: &mut Vec<Span>) {
166    let (mut pending, mut at) = (0, 0);
167    while let Some(c) = text[at..].chars().next() {
168        // The link first and whole: a mark inside an address is not a mark.
169        if c == 'h'
170            && let Some(length) = link_length(&text[at..])
171        {
172            push_span(spans, &text[pending..at], base, false);
173            push_span(spans, &text[at..at + length], base, true);
174            at += length;
175            pending = at;
176            continue;
177        }
178        if marks
179            && matches!(c, '*' | '_' | '~' | '`')
180            && opens(text, at, c)
181            && let Some(close) = closes(text, at, c)
182        {
183            push_span(spans, &text[pending..at], base, false);
184            let inside = &text[at + 1..close];
185            if c == '`' {
186                push_span(spans, inside, base.with(c), false);
187            } else {
188                parse_inline(inside, base.with(c), true, spans);
189            }
190            at = close + 1;
191            pending = at;
192            continue;
193        }
194        at += c.len_utf8();
195    }
196    push_span(spans, &text[pending..], base, false);
197}
198
199/// The text a reader takes in, marks gone and one line per line.
200fn read_aloud(blocks: &[Block]) -> String {
201    let lines: Vec<String> = blocks
202        .iter()
203        .map(|block| match block {
204            Block::Line(_, spans) => spans.iter().map(|span| span.text.as_str()).collect(),
205            Block::Code(code) => code.clone(),
206        })
207        .collect();
208    lines.join("\n")
209}
210
211/// Text with a little formatting, written the way chat apps write it: `*bold*`, `_italic_`,
212/// `~struck~`, `` `code` ``, a fenced block of ```` ``` ````, lines that start with `* `, `- `
213/// or `> `, and web addresses, which become links.
214///
215/// It wraps to the width it is offered, between words, and a marked piece stays with the
216/// punctuation around it. Text with nothing to format is drawn as one [`Text`], for the same
217/// cost. Links take the keyboard: Tab reaches them, and Enter or Space opens them. It only
218/// recognizes `http` and `https`, and it hands the address to [`Self::on_link`] instead of
219/// opening anything.
220///
221/// `ravenrs-text` shapes one style per text and embeds neither an italic nor a monospace
222/// font, so italics are told apart by a muted color and code by a chip behind it.
223///
224/// ```
225/// use ravenrs_widgets::{RichText, Ui};
226///
227/// let text = RichText::new("*Ready:* see `report.pdf` at https://example.com/r")
228///     .on_link(|address| println!("open {address}"));
229/// let mut ui = Ui::new(text, ravenrs_style::Theme::raven_dark());
230/// ui.update();
231/// ```
232#[derive(Clone)]
233#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
234pub struct RichText {
235    text: String,
236    marks: bool,
237    style: TextStyle,
238    foreground: Fill,
239    on_link: Option<Rc<dyn Fn(String)>>,
240}
241
242impl fmt::Debug for RichText {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.debug_struct("RichText")
245            .field("text", &self.text)
246            .field("style", &self.style)
247            .finish_non_exhaustive()
248    }
249}
250
251#[cfg_attr(feature = "live", ravenrs_live::expose)]
252impl RichText {
253    /// Text that may have marks in it.
254    pub fn new(text: impl Into<String>) -> Self {
255        Self {
256            text: text.into(),
257            marks: true,
258            style: TextStyle::Body,
259            foreground: Semantic::Ink.into(),
260            on_link: None,
261        }
262    }
263
264    /// Takes every mark literally, and only finds the links: for text from somewhere that
265    /// does not use these marks.
266    #[must_use]
267    pub fn links_only(mut self) -> Self {
268        self.marks = false;
269        self
270    }
271
272    /// A step of the type scale (`Body` by default).
273    #[must_use]
274    pub fn style(mut self, style: TextStyle) -> Self {
275        self.style = style;
276        self
277    }
278
279    /// Text color, as a theme role (`Ink` by default) or a literal color.
280    #[must_use]
281    pub fn foreground(mut self, fill: impl Into<Fill>) -> Self {
282        self.foreground = fill.into();
283        self
284    }
285
286    /// Called with the address of a link when it is clicked, or pressed with Enter or Space.
287    /// Without it, links look like links but cannot be activated or focused.
288    #[must_use]
289    pub fn on_link(mut self, handler: impl Fn(String) + 'static) -> Self {
290        self.on_link = Some(Rc::new(handler));
291        self
292    }
293
294    /// The text without its marks, on one line: for a preview, such as in a list of
295    /// conversations.
296    pub fn plain_text(text: &str) -> String {
297        read_aloud(&parse(text, true)).split_whitespace().collect::<Vec<_>>().join(" ")
298    }
299}
300
301/// One piece of a word: words change look in the middle, as in `(*this*),`.
302struct Piece {
303    text: String,
304    marks: Marks,
305    link: bool,
306    /// The strike goes on over the space after it.
307    strike_on: bool,
308}
309
310/// Cuts a line at the places it can wrap. A chip of code or a link stays in one piece, unless
311/// it is too wide for a whole line, when its own text wraps.
312fn words(spans: &[Span]) -> Vec<Vec<Piece>> {
313    let mut words: Vec<Vec<Piece>> = Vec::new();
314    let mut glued = false;
315    for span in spans {
316        let piece = |text: &str, strike_on| Piece {
317            text: text.to_owned(),
318            marks: span.marks,
319            link: span.link,
320            strike_on,
321        };
322        if span.link || span.marks.code {
323            if glued && let Some(word) = words.last_mut() {
324                word.push(piece(&span.text, false));
325            } else {
326                words.push(vec![piece(&span.text, false)]);
327            }
328            glued = true;
329            continue;
330        }
331        let parts: Vec<&str> = span.text.split_whitespace().collect();
332        if parts.is_empty() {
333            glued = false;
334            continue;
335        }
336        let spaced_before = span.text.starts_with(char::is_whitespace);
337        for (i, part) in parts.iter().enumerate() {
338            let strike_on = span.marks.strike && i + 1 < parts.len();
339            if i == 0
340                && glued
341                && !spaced_before
342                && let Some(word) = words.last_mut()
343            {
344                word.push(piece(part, strike_on));
345            } else {
346                words.push(vec![piece(part, strike_on)]);
347            }
348        }
349        glued = !span.text.ends_with(char::is_whitespace);
350    }
351    words
352}
353
354/// The line drawn over or under a word, behind its glyphs.
355fn rule(frame: Rect, y: f32, color: Color, scene: &mut Scene) {
356    scene.push_quad(Quad::new(Rect::new(frame.x(), frame.y() + y, frame.width(), 1.0), color));
357}
358
359/// A link inside a line.
360struct LinkPiece {
361    text: String,
362    attrs: TextAttrs,
363    on_link: Option<Rc<dyn Fn(String)>>,
364}
365
366impl View for LinkPiece {
367    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
368        let theme = *cx.theme();
369        let active = self.on_link.is_some();
370        let role = if !active {
371            Semantic::Accent
372        } else if cx.is_pressed() {
373            Semantic::AccentPressed
374        } else if cx.is_hovered() {
375            Semantic::AccentHover
376        } else {
377            Semantic::Accent
378        };
379        // The accent is made to fill things. Drawn thin on any surface it needs some ink,
380        // which takes it the right way in either kind of theme, to stay readable.
381        let color = theme.color(role).lerp(theme.color(Semantic::Ink), 0.3);
382        let text = cx.text(&self.text, &self.attrs, color, LayoutStyle::leaf());
383        cx.semantics(text, Semantics::hidden());
384        let (words, attrs) = (self.text.clone(), self.attrs.clone());
385        let node = cx.container(
386            LayoutStyle { min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
387            &[text],
388            // Under each row, as wide as the row: an address too long for a line wraps by itself.
389            Paint::Rich(Rc::new(move |frame, paint| {
390                let mut layout = paint.text.layout(&words, &attrs);
391                layout.measure(Some(frame.width() + 0.5));
392                for line in layout.lines() {
393                    let row = Rect::new(frame.x(), frame.y() + line.top, line.width, line.height);
394                    rule(row, line.height - 3.0, color, paint.scene);
395                }
396            })),
397        );
398        if let Some(on_link) = &self.on_link {
399            let (on_link, address) = (on_link.clone(), self.text.clone());
400            cx.on_click(node, Rc::new(move || on_link(address.clone())));
401            cx.focusable(node, theme.radii.sm);
402            cx.cursor(node, CursorIcon::Pointer);
403            cx.semantics(node, Semantics::new(Role::Link).name(self.text.clone()));
404        }
405        node
406    }
407}
408
409#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
410impl RichText {
411    /// One word, made of one or more pieces, with the space that follows it.
412    fn word(
413        &self,
414        cx: &mut BuildCx<'_>,
415        pieces: &[Piece],
416        ink: Color,
417        space: f32,
418        next_link: &mut u64,
419    ) -> NodeId {
420        let theme = *cx.theme();
421        let base = crate::util::attrs(&theme, self.style, None);
422        let mut children = Vec::with_capacity(pieces.len());
423        let mut trailing = space;
424        for piece in pieces {
425            let mut attrs = base.clone();
426            if piece.marks.bold {
427                attrs.weight = Weight::SEMIBOLD.0;
428            }
429            if piece.link {
430                let key = *next_link;
431                *next_link += 1;
432                children.push(cx.keyed_child(
433                    key,
434                    &LinkPiece { text: piece.text.clone(), attrs, on_link: self.on_link.clone() },
435                ));
436                continue;
437            }
438            let color = if piece.marks.italic { theme.color(Semantic::InkMuted) } else { ink };
439            let text = cx.text(&piece.text, &attrs, color, LayoutStyle::leaf());
440            cx.semantics(text, Semantics::hidden());
441            let node = if piece.marks.code {
442                cx.container(
443                    LayoutStyle {
444                        padding: Insets::symmetric(0.0, theme.spacing.s1),
445                        min_width: Some(0.0),
446                        ..LayoutStyle::stack(Axis::Horizontal)
447                    },
448                    &[text],
449                    Paint::Quad(QuadPaint {
450                        corner_radius: theme.radii.sm,
451                        ..QuadPaint::new(theme.color(Semantic::Border))
452                    }),
453                )
454            } else if piece.marks.strike {
455                let middle = base.line_height / 2.0;
456                let reach = if piece.strike_on { space } else { 0.0 };
457                if piece.strike_on {
458                    trailing = 0.0;
459                }
460                cx.container(
461                    LayoutStyle {
462                        padding: Insets { right: reach, ..Insets::ZERO },
463                        min_width: Some(0.0),
464                        ..LayoutStyle::stack(Axis::Horizontal)
465                    },
466                    &[text],
467                    Paint::Custom(Rc::new(move |frame, scene| rule(frame, middle, color, scene))),
468                )
469            } else {
470                text
471            };
472            children.push(node);
473        }
474        cx.container(
475            LayoutStyle {
476                padding: Insets { right: trailing, ..Insets::ZERO },
477                min_width: Some(0.0),
478                ..LayoutStyle::stack(Axis::Horizontal)
479            },
480            &children,
481            Paint::None,
482        )
483    }
484
485    /// The words of a line, wrapping in whatever width it gets.
486    fn flow(&self, cx: &mut BuildCx<'_>, spans: &[Span], ink: Color, next_link: &mut u64) -> NodeId {
487        let attrs = crate::util::attrs(cx.theme(), self.style, None);
488        // Measured between letters, since a space at the end of a text may not count.
489        let one = cx.measure_text("n", &attrs).width;
490        let space = cx.measure_text("n n", &attrs).width - one * 2.0;
491        let words = words(spans);
492        let children: Vec<NodeId> =
493            words.iter().map(|pieces| self.word(cx, pieces, ink, space, next_link)).collect();
494        cx.container(
495            LayoutStyle {
496                wrap: true,
497                grow: 1.0,
498                min_width: Some(0.0),
499                // An empty line still takes its height, as it does in a text.
500                min_height: Some(attrs.line_height),
501                ..LayoutStyle::stack(Axis::Horizontal)
502            },
503            &children,
504            Paint::None,
505        )
506    }
507}
508
509impl View for RichText {
510    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
511        let blocks = parse(&self.text, self.marks);
512        if blocks.iter().all(Block::is_plain) {
513            return cx.child(&Text::new(self.text.clone()).style(self.style).foreground(self.foreground));
514        }
515        let theme = *cx.theme();
516        let ink = self.foreground.resolve(&theme);
517        let attrs = crate::util::attrs(&theme, self.style, None);
518        let mut next_link = 0;
519        let mut children = Vec::with_capacity(blocks.len());
520        for block in &blocks {
521            let node = match block {
522                Block::Line(LineKind::Plain, spans) => self.flow(cx, spans, ink, &mut next_link),
523                Block::Line(LineKind::Item, spans) => {
524                    let bullet = cx.text("•", &attrs, ink, LayoutStyle::leaf());
525                    cx.semantics(bullet, Semantics::hidden());
526                    let line = self.flow(cx, spans, ink, &mut next_link);
527                    cx.container(
528                        LayoutStyle {
529                            spacing: theme.spacing.s2,
530                            padding: Insets { left: theme.spacing.s1, ..Insets::ZERO },
531                            ..LayoutStyle::stack(Axis::Horizontal)
532                        },
533                        &[bullet, line],
534                        Paint::None,
535                    )
536                }
537                Block::Line(LineKind::Quote, spans) => {
538                    let bar = cx.container(
539                        LayoutStyle {
540                            width: Some(2.0),
541                            align_self: Some(Align::Stretch),
542                            ..LayoutStyle::leaf()
543                        },
544                        &[],
545                        Paint::Quad(QuadPaint {
546                            corner_radius: 1.0,
547                            ..QuadPaint::new(theme.color(Semantic::Border))
548                        }),
549                    );
550                    let line = self.flow(cx, spans, theme.color(Semantic::InkMuted), &mut next_link);
551                    cx.container(
552                        LayoutStyle { spacing: theme.spacing.s2, ..LayoutStyle::stack(Axis::Horizontal) },
553                        &[bar, line],
554                        Paint::None,
555                    )
556                }
557                Block::Code(code) => {
558                    let text = cx.text(code, &attrs, ink, LayoutStyle::leaf());
559                    cx.semantics(text, Semantics::hidden());
560                    cx.container(
561                        LayoutStyle {
562                            padding: Insets::symmetric(theme.spacing.s2, theme.spacing.s3),
563                            align_self: Some(Align::Stretch),
564                            ..LayoutStyle::stack(Axis::Vertical)
565                        },
566                        &[text],
567                        Paint::Quad(QuadPaint {
568                            corner_radius: theme.radii.md,
569                            ..QuadPaint::new(theme.color(Semantic::Border))
570                        }),
571                    )
572                }
573            };
574            children.push(node);
575        }
576        let node = cx.container(
577            LayoutStyle { align: Align::Stretch, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Vertical) },
578            &children,
579            Paint::None,
580        );
581        cx.semantics(node, Semantics::new(Role::Label).name(read_aloud(&blocks)));
582        node
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn line(text: &str) -> Vec<(String, Marks, bool)> {
591        let blocks = parse(text, true);
592        let Block::Line(_, spans) = &blocks[0] else { panic!("a line") };
593        spans.iter().map(|s| (s.text.clone(), s.marks, s.link)).collect()
594    }
595
596    fn marked(text: &str, marks: Marks) -> (String, Marks, bool) {
597        (text.to_owned(), marks, false)
598    }
599
600    const BOLD: Marks = Marks { bold: true, italic: false, strike: false, code: false };
601
602    #[test]
603    fn marks_nest_and_vanish_from_the_text() {
604        let both = Marks { italic: true, ..BOLD };
605        assert_eq!(line("*a _b_ c*"), [marked("a ", BOLD), marked("b", both), marked(" c", BOLD)]);
606        assert_eq!(RichText::plain_text("*a* _b_\n~c~ `d`"), "a b c d");
607    }
608
609    #[test]
610    fn a_mark_must_touch_what_it_marks_and_not_sit_inside_a_word() {
611        for literal in ["2 * 3 * 4", "snake_case_name", "a*b*c", "*not closed", "`` no"] {
612            let blocks = parse(literal, true);
613            assert!(blocks.iter().all(Block::is_plain), "{literal}");
614            assert_eq!(read_aloud(&blocks), literal);
615        }
616        assert_eq!(
617            line("(*yes*),"),
618            [marked("(", Marks::default()), marked("yes", BOLD), marked("),", Marks::default())]
619        );
620    }
621
622    #[test]
623    fn code_keeps_marks_literal_and_a_fence_crosses_lines() {
624        let code = Marks { code: true, ..Marks::default() };
625        assert_eq!(line("`*no*`"), [marked("*no*", code)]);
626        let blocks = parse("before\n```\nlet *a* = 1;\nb\n```\nafter", true);
627        assert_eq!(blocks[1], Block::Code("let *a* = 1;\nb".into()));
628        assert_eq!(blocks.len(), 3);
629        assert_eq!(read_aloud(&parse("``````", true)), "``````");
630    }
631
632    #[test]
633    fn links_are_whole_web_addresses_without_the_end_of_the_sentence() {
634        assert_eq!(
635            line("see https://x.io/a_b_c."),
636            [
637                marked("see ", Marks::default()),
638                ("https://x.io/a_b_c".to_owned(), Marks::default(), true),
639                marked(".", Marks::default()),
640            ]
641        );
642        for not_a_link in ["javascript:alert(1)", "file:///etc/passwd", "https://", "http"] {
643            assert!(parse(not_a_link, true).iter().all(Block::is_plain), "{not_a_link}");
644        }
645    }
646
647    #[test]
648    fn links_only_leaves_the_marks_alone() {
649        let blocks = parse("*a* https://x.io\n* b", false);
650        let Block::Line(LineKind::Plain, spans) = &blocks[0] else { panic!("a plain line") };
651        assert_eq!(spans[0].text, "*a* ");
652        assert!(spans[1].link);
653        assert!(matches!(&blocks[1], Block::Line(LineKind::Plain, _)));
654    }
655
656    #[test]
657    fn a_word_keeps_its_punctuation_and_a_chip_its_spaces() {
658        let blocks = parse("(*a*), `b c` d", true);
659        let Block::Line(_, spans) = &blocks[0] else { panic!("a line") };
660        let words = words(spans);
661        let read: Vec<Vec<&str>> =
662            words.iter().map(|word| word.iter().map(|p| p.text.as_str()).collect()).collect();
663        assert_eq!(read, [vec!["(", "a", "),"], vec!["b c"], vec!["d"]]);
664    }
665}