Skip to main content

gpui_base/text/
editor_host.rs

1//! Host API for external editors.
2//!
3//! An editor that lives outside gpui-kit keeps its own document model and
4//! hands gpui-kit a [`BlockNode`] tree, the same tree [`TextView`] renders.
5//! gpui-kit renders it and gives back, per text leaf, the geometry the editor
6//! needs for hit-testing, caret placement and platform input (IME) bounds.
7//!
8//! The boundary is deliberately narrow:
9//!
10//! - **In:** a [`BlockNode`] tree whose text leaves carry a host-chosen `u64`
11//!   key (see [`Paragraph::with_leaf_key`] and [`CodeBlock::with_leaf_key`]),
12//!   a [`RenderContext`], and per-key decoration ranges.
13//! - **Out:** one opaque [`LeafGeometry`] per key from
14//!   [`collect_leaf_geometry`].
15//!
16//! Nothing lower is exposed: no `TextLayout`, no layout internals, no sink
17//! trait, no retained-layout or projection caches.
18//!
19//! The host owns editing semantics, document revisions, accessibility and native
20//! input integration. This module provides horizontal TextView block rendering;
21//! it is not a complete visual-caret navigation or pagination API. Extend the
22//! public boundary additively while keeping rendering internals private.
23//!
24//! [`TextView`]: super::TextView
25
26use std::{cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc};
27
28use gpui::{
29    AnyElement, App, Bounds, ClickEvent, ElementId, HighlightStyle, Hsla, ImageSource,
30    InteractiveElement as _, IntoElement as _, ParentElement as _, Pixels, Point, SharedString,
31    SharedUri, TextLayout, Window, WrappedLineLayout, div, point, px,
32};
33use unicode_segmentation::UnicodeSegmentation as _;
34
35use super::{
36    TextViewDefaults, TextViewStyle,
37    document::{NodeRenderOptions, PrevBlock, flow_position},
38    editor_seam::{self, EditorSink},
39    format::markdown,
40    markdown_ext::MarkdownExtensions,
41    node::NodeContext,
42};
43
44pub use super::node::{
45    BlockNode, BlockState, CodeBlock, ColumnumnAlign as ColumnAlign, ImageNode, InlineNode,
46    LinkMark, Paragraph, Span, Table, TextMark,
47};
48
49/// Parses Markdown with the parser [`TextView`](super::TextView) uses,
50/// including the given extensions, into the block tree.
51pub fn parse_markdown(
52    source: &str,
53    extensions: Arc<MarkdownExtensions>,
54) -> Result<Vec<BlockNode>, SharedString> {
55    let mut cx = NodeContext {
56        markdown_extensions: extensions,
57        ..Default::default()
58    };
59    let parsed = markdown::parse(source, &mut cx)?;
60    Ok(Arc::unwrap_or_clone(parsed.blocks))
61}
62
63/// What [`BlockNode::render`] needs besides the node: the text style, code
64/// highlighter and Markdown extensions.
65#[derive(Clone)]
66pub struct RenderContext {
67    node: NodeContext,
68}
69
70impl RenderContext {
71    /// Creates a local rendering context without reading application globals.
72    /// The host supplies body typography through its container or
73    /// `with_body_font_size`.
74    pub fn new(style: TextViewStyle) -> Self {
75        Self {
76            node: NodeContext {
77                style: Arc::new(style),
78                ..Default::default()
79            },
80        }
81    }
82
83    /// Replaces code highlighting for this context only.
84    pub fn with_code_block_highlighter<F>(mut self, highlighter: F) -> Self
85    where
86        F: Fn(&CodeBlock) -> Vec<(Range<usize>, HighlightStyle)> + Send + Sync + 'static,
87    {
88        self.node.code_block_highlighter = Some(Arc::new(highlighter));
89        self
90    }
91
92    /// Resolves document image URIs. The host owns resource access policy.
93    pub fn with_image_source<F>(mut self, resolver: F) -> Self
94    where
95        F: Fn(&SharedUri) -> ImageSource + Send + Sync + 'static,
96    {
97        self.node.image_source = Some(Arc::new(resolver));
98        self
99    }
100
101    /// Handles link activation. Without a handler, links use App::open_url.
102    pub fn on_link_click<F>(mut self, handler: F) -> Self
103    where
104        F: Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync + 'static,
105    {
106        self.node.link_click_handler = Some(Arc::new(handler));
107        self
108    }
109
110    /// Registers a reference-link definition from the host's document.
111    /// Supply definitions for the entire document, including virtualized blocks.
112    pub fn with_link_reference(
113        mut self,
114        identifier: impl Into<SharedString>,
115        link: LinkMark,
116    ) -> Self {
117        self.node.link_refs.insert(identifier.into(), link);
118        self
119    }
120
121    /// The context TextView would start from: the global [`TextViewDefaults`]
122    /// style and code-block highlighter when set, else the theme's style.
123    pub fn from_app(cx: &App) -> Self {
124        let theme = crate::Theme::global(cx);
125        let defaults = cx.try_global::<TextViewDefaults>();
126        let style = defaults
127            .and_then(|defaults| defaults.style().cloned())
128            .unwrap_or_else(|| TextViewStyle::from_theme(&theme));
129        Self {
130            node: NodeContext {
131                style: Arc::new(style),
132                code_block_highlighter: defaults.and_then(|d| d.code_block_highlighter()),
133                ..Default::default()
134            },
135        }
136    }
137
138    /// Replaces the text style.
139    pub fn with_style(mut self, style: TextViewStyle) -> Self {
140        self.node.style = Arc::new(style);
141        self
142    }
143
144    /// Sets the Markdown extensions that render custom nodes.
145    pub fn with_markdown_extensions(mut self, extensions: Arc<MarkdownExtensions>) -> Self {
146        self.node.markdown_extensions = extensions;
147        self
148    }
149
150    /// Sets the font size body text inherits, which the block gaps and
151    /// heading sizes are relative to.
152    pub fn with_body_font_size(mut self, size: Pixels) -> Self {
153        self.node.body_font_size = size;
154        self
155    }
156
157    /// The text style blocks render with.
158    pub fn style(&self) -> &TextViewStyle {
159        &self.node.style
160    }
161}
162
163/// Where a block stands in the flow it is rendered in, which decides the gaps
164/// around it: the first block takes no gap above, the last none below, and a
165/// heading or rule counts the block before it.
166#[derive(Clone, Copy, Debug)]
167pub struct FlowPosition {
168    is_first: bool,
169    is_last: bool,
170    prev: PrevBlock,
171}
172
173impl FlowPosition {
174    /// The position of `blocks[ix]` among its siblings. Blocks that render
175    /// nothing (definitions, breaks) are skipped when looking for the block
176    /// before it.
177    pub fn at(blocks: &[&BlockNode], ix: usize, is_last: bool) -> Self {
178        let (is_first, prev) = flow_position(blocks, ix);
179        Self {
180            is_first,
181            is_last,
182            prev,
183        }
184    }
185
186    /// Overrides whether the block opens its flow, e.g. for a chunk of a
187    /// long list rendered as a list of its own.
188    pub fn first(mut self, is_first: bool) -> Self {
189        self.is_first = is_first;
190        self
191    }
192
193    /// Overrides whether the block closes its flow.
194    pub fn last(mut self, is_last: bool) -> Self {
195        self.is_last = is_last;
196        self
197    }
198}
199
200impl BlockNode {
201    /// Renders under a stable host-owned identity. Use a domain ID, not a list
202    /// position, when inserting, reordering or virtualizing blocks. Identity
203    /// scopes GPUI element state independently of the leaf keys used for geometry.
204    pub fn render_with_id(
205        &self,
206        id: impl Into<ElementId>,
207        flow: FlowPosition,
208        render_context: &RenderContext,
209        window: &mut Window,
210        cx: &mut App,
211    ) -> AnyElement {
212        div()
213            .id(id)
214            .child(self.render(0, flow, render_context, window, cx))
215            .into_any_element()
216    }
217
218    /// Renders the block as TextView does. `ix` identifies the block among
219    /// its siblings (element ids derive from it).
220    pub fn render(
221        &self,
222        ix: usize,
223        flow: FlowPosition,
224        cx: &RenderContext,
225        window: &mut Window,
226        app: &mut App,
227    ) -> AnyElement {
228        self.render_block(
229            NodeRenderOptions {
230                ix,
231                is_first: flow.is_first,
232                is_last: flow.is_last,
233                prev: flow.prev,
234                ..Default::default()
235            },
236            &cx.node,
237            window,
238            app,
239        )
240    }
241}
242
243/// Runs `f`, which should prepaint rendered blocks, and returns its result
244/// with the geometry every keyed text leaf reported meanwhile.
245///
246/// `decorations` maps a leaf key to byte ranges of that leaf's text (atoms
247/// count as one `U+FFFC` each) to paint behind the text: the host's selection,
248/// find matches, anything. Leaves without a key, or without an entry, paint as
249/// usual. Atom decorations cover the whole atom and paint over its content
250/// so selections remain visible over opaque images. Supply translucent colors
251/// for overlays. Ranges are half-open; empty ranges paint nothing.
252/// Scopes nest, and the previous one is restored even if `f` panics.
253///
254/// Keys must be unique within one collection scope. Nested scopes are isolated:
255/// an outer scope does not collect the inner scope's leaves. Only prepainted
256/// leaves are returned; absence is not evidence that a document leaf was removed.
257/// The host owns document revisions, virtualization, focus, native input and
258/// UTF-16/UTF-8 conversion for platform input APIs.
259pub fn collect_leaf_geometry<R>(
260    decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
261    f: impl FnOnce() -> R,
262) -> (R, HashMap<u64, LeafGeometry>) {
263    let collector = Rc::new(Collector {
264        pieces: RefCell::default(),
265        decorations,
266    });
267    let result = editor_seam::with_sink(collector.clone(), f);
268    let mut pieces = collector.pieces.take();
269    pieces.retain(|_, pieces| !pieces.is_empty());
270    let leaves = pieces
271        .into_iter()
272        .map(|(key, pieces)| (key, LeafGeometry { pieces }))
273        .collect();
274    (result, leaves)
275}
276
277struct Collector {
278    pieces: RefCell<HashMap<u64, Vec<Piece>>>,
279    decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
280}
281
282impl EditorSink for Collector {
283    fn report(&self, key: u64, layout: &TextLayout) {
284        self.pieces
285            .borrow_mut()
286            .insert(key, vec![Piece::Whole(LayoutSnapshot::new(layout))]);
287    }
288
289    fn begin_pieces(&self, key: u64) {
290        self.pieces.borrow_mut().insert(key, Vec::new());
291    }
292
293    fn report_text_piece(&self, key: u64, start: usize, layout: &TextLayout) {
294        self.pieces
295            .borrow_mut()
296            .entry(key)
297            .or_default()
298            .push(Piece::Text {
299                start,
300                layout: LayoutSnapshot::new(layout),
301            });
302    }
303
304    fn report_box_piece(&self, key: u64, range: Range<usize>, bounds: Bounds<Pixels>) {
305        self.pieces
306            .borrow_mut()
307            .entry(key)
308            .or_default()
309            .push(Piece::Atom { range, bounds });
310    }
311
312    fn report_empty_piece(&self, key: u64, start: usize, bounds: Bounds<Pixels>) {
313        self.pieces
314            .borrow_mut()
315            .entry(key)
316            .or_default()
317            .push(Piece::Empty { start, bounds });
318    }
319
320    fn backgrounds(&self, key: u64) -> Vec<(Range<usize>, Hsla)> {
321        self.decorations.get(&key).cloned().unwrap_or_default()
322    }
323}
324
325/// Copy mutable frame state, sharing only immutable shaped lines.
326#[derive(Clone)]
327struct LayoutSnapshot {
328    bounds: Bounds<Pixels>,
329    len: usize,
330    line_height: Pixels,
331    text: SharedString,
332    lines: Vec<Arc<WrappedLineLayout>>,
333}
334
335impl LayoutSnapshot {
336    fn new(layout: &TextLayout) -> Self {
337        Self {
338            bounds: layout.bounds(),
339            len: layout.len(),
340            line_height: layout.line_height(),
341            text: layout.text().into(),
342            lines: layout.line_layouts().into_iter().collect(),
343        }
344    }
345
346    fn bounds(&self) -> Bounds<Pixels> {
347        self.bounds
348    }
349    fn len(&self) -> usize {
350        self.len
351    }
352    fn line_height(&self) -> Pixels {
353        self.line_height
354    }
355    fn text(&self) -> &str {
356        &self.text
357    }
358    fn line_layouts(&self) -> &[Arc<WrappedLineLayout>] {
359        &self.lines
360    }
361
362    fn position_for_index(&self, index: usize) -> Option<Point<Pixels>> {
363        let mut origin = self.bounds.origin;
364        let mut start = 0;
365        for line in &self.lines {
366            if index <= start + line.len() {
367                return line
368                    .position_for_index(index - start, self.line_height)
369                    .map(|p| origin + p);
370            }
371            start += line.len() + 1;
372            origin.y += line.size(self.line_height).height;
373        }
374        None
375    }
376}
377
378/// How a leaf was laid out: one layout for the whole leaf, or (for a mixed
379/// paragraph rendered as a flow) single-line text fragments and atom boxes in
380/// leaf byte order.
381#[derive(Clone)]
382enum Piece {
383    Whole(LayoutSnapshot),
384    Text {
385        start: usize,
386        layout: LayoutSnapshot,
387    },
388    Empty {
389        start: usize,
390        bounds: Bounds<Pixels>,
391    },
392    Atom {
393        range: Range<usize>,
394        bounds: Bounds<Pixels>,
395    },
396}
397
398impl Piece {
399    fn bounds(&self) -> Bounds<Pixels> {
400        match self {
401            Piece::Whole(layout) | Piece::Text { layout, .. } => layout.bounds(),
402            Piece::Atom { bounds, .. } | Piece::Empty { bounds, .. } => *bounds,
403        }
404    }
405
406    fn range(&self) -> Range<usize> {
407        match self {
408            Piece::Whole(layout) => 0..layout.len(),
409            Piece::Text { start, layout } => *start..start + layout.len(),
410            Piece::Atom { range, .. } => range.clone(),
411            Piece::Empty { start, .. } => *start..*start,
412        }
413    }
414}
415
416/// Where one keyed text leaf was laid out in the last frame.
417///
418/// Indices are byte offsets into the leaf's text, where each inline atom
419/// (image, custom node) is one `U+FFFC` (3 bytes). Coordinates are window
420/// pixels and are valid for the frame that produced them. It is a value, not a
421/// handle: it owns its bounds and shares immutable shaped lines, never the
422/// renderer's mutable layout state. Later frames do not mutate this snapshot.
423/// The host must replace it after content, width, typography or scrolling changes.
424#[derive(Clone)]
425pub struct LeafGeometry {
426    pieces: Vec<Piece>,
427}
428
429impl LeafGeometry {
430    fn whole(&self) -> Option<&LayoutSnapshot> {
431        match self.pieces.as_slice() {
432            [Piece::Whole(layout)] => Some(layout),
433            _ => None,
434        }
435    }
436
437    /// The bounds of the leaf: the union of its pieces.
438    pub fn bounds(&self) -> Bounds<Pixels> {
439        self.pieces
440            .iter()
441            .map(Piece::bounds)
442            .reduce(|a, b| a.union(&b))
443            .unwrap_or_default()
444    }
445
446    /// The length of the leaf's text in bytes, atoms included.
447    pub fn len(&self) -> usize {
448        self.pieces
449            .iter()
450            .map(|piece| piece.range().end)
451            .max()
452            .unwrap_or(0)
453    }
454
455    /// Whether the leaf's text is empty (it still has a line box).
456    pub fn is_empty(&self) -> bool {
457        self.len() == 0
458    }
459
460    /// The height of a line.
461    pub fn line_height(&self) -> Pixels {
462        self.pieces
463            .iter()
464            .find_map(|piece| match piece {
465                Piece::Whole(layout) | Piece::Text { layout, .. } => Some(layout.line_height()),
466                Piece::Empty { bounds, .. } => Some(bounds.size.height),
467                Piece::Atom { .. } => None,
468            })
469            .unwrap_or_else(|| self.bounds().size.height)
470    }
471
472    /// The height of a caret: the font's ascent plus descent, not the whole
473    /// line box, which includes leading.
474    pub fn caret_height(&self) -> Pixels {
475        let line_height = self.line_height();
476        self.pieces
477            .iter()
478            .find_map(|piece| match piece {
479                Piece::Whole(layout) | Piece::Text { layout, .. } => {
480                    let line = layout.lines.first()?;
481                    let h = line.unwrapped_layout.ascent + line.unwrapped_layout.descent;
482                    (h > Pixels::ZERO).then_some(h)
483                }
484                Piece::Atom { .. } | Piece::Empty { .. } => None,
485            })
486            .unwrap_or(line_height * 0.85)
487            .min(line_height)
488    }
489
490    /// The top of a caret placed at byte `index`, in window coordinates.
491    pub fn position_for_index(&self, index: usize) -> Point<Pixels> {
492        if let Some(layout) = self.whole() {
493            return layout
494                .position_for_index(index.min(layout.len()))
495                .unwrap_or_else(|| self.bounds().origin);
496        }
497        // The piece the index starts, else the one it ends (a caret at a
498        // fragment boundary sits at the start of the next fragment).
499        let piece = self
500            .pieces
501            .iter()
502            .find(|p| p.range().contains(&index))
503            .or_else(|| self.pieces.iter().rev().find(|p| p.range().end == index))
504            .or(self.pieces.last());
505        let Some(piece) = piece else {
506            return self.bounds().origin;
507        };
508        match piece {
509            Piece::Whole(layout) | Piece::Text { layout, .. } => {
510                let local = index.saturating_sub(piece.range().start).min(layout.len());
511                layout
512                    .position_for_index(local)
513                    .unwrap_or_else(|| layout.bounds().origin)
514            }
515            Piece::Empty { bounds, .. } => bounds.origin,
516            Piece::Atom { range, bounds } => {
517                let x = if index >= range.end {
518                    bounds.right()
519                } else {
520                    bounds.left()
521                };
522                // Align with the text line: an atom taller than the line
523                // keeps the caret at its bottom line.
524                let y = (bounds.bottom() - self.line_height()).max(bounds.top());
525                point(x, y)
526            }
527        }
528    }
529
530    /// The byte index of the caret position closest to `at`: glyph halves
531    /// round to the nearer side, unlike `TextLayout::index_for_position`.
532    pub fn index_for_position(&self, at: Point<Pixels>) -> usize {
533        if let Some(layout) = self.whole() {
534            return whole_index_at(layout, at);
535        }
536        // The visual line under the point (pieces whose vertical extent
537        // holds it, else the nearest), then the horizontally nearest piece.
538        let dy = |p: &Piece| {
539            let b = p.bounds();
540            if at.y < b.top() {
541                b.top() - at.y
542            } else if at.y > b.bottom() {
543                at.y - b.bottom()
544            } else {
545                px(0.)
546            }
547        };
548        let dx = |p: &Piece| {
549            let b = p.bounds();
550            if at.x < b.left() {
551                b.left() - at.x
552            } else if at.x > b.right() {
553                at.x - b.right()
554            } else {
555                px(0.)
556            }
557        };
558        let Some(piece) = self.pieces.iter().min_by(|a, b| {
559            (dy(a), dx(a))
560                .partial_cmp(&(dy(b), dx(b)))
561                .unwrap_or(std::cmp::Ordering::Equal)
562        }) else {
563            return 0;
564        };
565        let index = match piece {
566            Piece::Whole(layout) => whole_index_at(layout, at),
567            // Fragments are one line: take the caret position nearest to the
568            // point (`closest_index_for_position` rounds up at an exact
569            // boundary inside scaled code text).
570            // Only grapheme boundaries are candidates: an interior byte of a
571            // multi-byte character maps to the next glyph's x, so it could
572            // tie with (and beat) the real boundary and split the character.
573            Piece::Text { start, layout } => {
574                let text = layout.text();
575                let nearest = text
576                    .grapheme_indices(true)
577                    .map(|(i, _)| i)
578                    .chain(std::iter::once(text.len()))
579                    .filter_map(|i| {
580                        layout
581                            .position_for_index(i)
582                            .map(|p| (i, (p.x - at.x).abs()))
583                    })
584                    .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
585                    .map_or(0, |(i, _)| i);
586                start + nearest
587            }
588            Piece::Empty { start, .. } => *start,
589            Piece::Atom { range, bounds } => {
590                if at.x < bounds.center().x {
591                    range.start
592                } else {
593                    range.end
594                }
595            }
596        };
597        index.min(self.len())
598    }
599
600    /// The byte range of the inline atom drawn under `at`, if any.
601    pub fn atom_at(&self, at: Point<Pixels>) -> Option<Range<usize>> {
602        self.pieces.iter().find_map(|piece| match piece {
603            Piece::Atom { range, bounds } if bounds.contains(&at) => Some(range.clone()),
604            _ => None,
605        })
606    }
607}
608
609/// The closest index in a whole-leaf layout for a window point.
610fn whole_index_at(layout: &LayoutSnapshot, at: Point<Pixels>) -> usize {
611    let bounds = layout.bounds();
612    let line_height = layout.line_height();
613    let mut top = bounds.top();
614    let mut start = 0;
615    let lines = layout.line_layouts();
616    for (ix, line) in lines.iter().enumerate() {
617        let height = line.size(line_height).height.max(line_height);
618        let is_last = ix + 1 == lines.len();
619        if at.y < top + height || is_last {
620            let local = point(
621                (at.x - bounds.left()).max(px(0.)),
622                (at.y - top).clamp(px(0.), (height - px(1.)).max(px(0.))),
623            );
624            let index = match line.closest_index_for_position(local, line_height) {
625                Ok(ix) | Err(ix) => ix,
626            };
627            return start + index;
628        }
629        top += height;
630        start += line.len() + 1;
631    }
632    0
633}
634
635#[cfg(test)]
636mod tests {
637    use std::cell::Cell;
638
639    use gpui::{
640        Context, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
641        Render, Styled as _, TestAppContext, VisualTestContext, div, rgb, rgba,
642    };
643
644    use super::*;
645    use crate::text::{InlineElement, MarkdownNode, markdown_ext::TestInlinePlugin};
646
647    type Geometries = Rc<RefCell<HashMap<u64, LeafGeometry>>>;
648
649    /// Stands in for an editor's root element: prepaints its child inside a
650    /// `collect_leaf_geometry` scope and keeps what the leaves reported.
651    struct Probe {
652        child: Option<AnyElement>,
653        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
654        out: Geometries,
655        /// Replaces `collect_leaf_geometry` with a custom sink.
656        sink: Option<Rc<dyn EditorSink>>,
657    }
658
659    impl IntoElement for Probe {
660        type Element = Self;
661        fn into_element(self) -> Self::Element {
662            self
663        }
664    }
665
666    impl Element for Probe {
667        type RequestLayoutState = AnyElement;
668        type PrepaintState = ();
669
670        fn id(&self) -> Option<ElementId> {
671            None
672        }
673
674        fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
675            None
676        }
677
678        fn request_layout(
679            &mut self,
680            _: Option<&GlobalElementId>,
681            _: Option<&InspectorElementId>,
682            window: &mut Window,
683            cx: &mut App,
684        ) -> (LayoutId, AnyElement) {
685            let mut child = self.child.take().unwrap();
686            (child.request_layout(window, cx), child)
687        }
688
689        fn prepaint(
690            &mut self,
691            _: Option<&GlobalElementId>,
692            _: Option<&InspectorElementId>,
693            _: Bounds<Pixels>,
694            child: &mut AnyElement,
695            window: &mut Window,
696            cx: &mut App,
697        ) {
698            if let Some(sink) = self.sink.take() {
699                editor_seam::with_sink(sink, || child.prepaint(window, cx));
700                return;
701            }
702            let decorations = std::mem::take(&mut self.decorations);
703            let (_, geometries) = collect_leaf_geometry(decorations, || child.prepaint(window, cx));
704            *self.out.borrow_mut() = geometries;
705        }
706
707        fn paint(
708            &mut self,
709            _: Option<&GlobalElementId>,
710            _: Option<&InspectorElementId>,
711            _: Bounds<Pixels>,
712            child: &mut AnyElement,
713            _: &mut (),
714            window: &mut Window,
715            cx: &mut App,
716        ) {
717            child.paint(window, cx);
718        }
719    }
720
721    struct Host {
722        blocks: Vec<BlockNode>,
723        extensions: Arc<MarkdownExtensions>,
724        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
725        out: Geometries,
726        width: Pixels,
727        sink: Option<Rc<dyn EditorSink>>,
728    }
729
730    impl Render for Host {
731        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
732            let rcx = RenderContext::from_app(cx)
733                .with_markdown_extensions(self.extensions.clone())
734                .with_body_font_size(px(16.));
735            let refs: Vec<&BlockNode> = self.blocks.iter().collect();
736            let children: Vec<AnyElement> = refs
737                .iter()
738                .enumerate()
739                .map(|(ix, block)| {
740                    let flow = FlowPosition::at(&refs, ix, ix + 1 == refs.len());
741                    block.render(ix, flow, &rcx, window, cx)
742                })
743                .collect();
744            Probe {
745                child: Some(div().w(self.width).children(children).into_any_element()),
746                decorations: self.decorations.clone(),
747                out: self.out.clone(),
748                sink: self.sink.clone(),
749            }
750        }
751    }
752
753    fn draw(
754        cx: &mut TestAppContext,
755        blocks: Vec<BlockNode>,
756        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
757        extensions: Arc<MarkdownExtensions>,
758        width: Pixels,
759    ) -> HashMap<u64, LeafGeometry> {
760        draw_and_inspect(cx, blocks, decorations, extensions, width, |_| ()).0
761    }
762
763    fn draw_and_inspect<R>(
764        cx: &mut TestAppContext,
765        blocks: Vec<BlockNode>,
766        decorations: HashMap<u64, Vec<(Range<usize>, Hsla)>>,
767        extensions: Arc<MarkdownExtensions>,
768        width: Pixels,
769        inspect: impl FnOnce(&mut Window) -> R,
770    ) -> (HashMap<u64, LeafGeometry>, R) {
771        cx.update(crate::init);
772        let out = Geometries::default();
773        let (_, vcx) = cx.add_window_view({
774            let out = out.clone();
775            move |_, _| Host {
776                blocks,
777                extensions,
778                decorations,
779                out,
780                width,
781                sink: None,
782            }
783        });
784        settle(vcx);
785        let inspected = vcx.update(|window, _| inspect(window));
786        (out.take(), inspected)
787    }
788
789    fn settle(cx: &mut VisualTestContext) {
790        cx.run_until_parked();
791        cx.update(|window, cx| window.draw(cx).clear(cx));
792        cx.run_until_parked();
793    }
794
795    fn plain(text: &str) -> Paragraph {
796        let mut paragraph = Paragraph::default();
797        paragraph.push(InlineNode::new(text.to_string()));
798        paragraph
799    }
800
801    fn atom_plugin() -> Arc<MarkdownExtensions> {
802        Arc::new(
803            MarkdownExtensions::default().plugin(TestInlinePlugin::new("atom").render_with(
804                |_, _, _, _| {
805                    Some(InlineElement::new(div().w(px(24.)).h(px(16.))).with_baseline(px(12.)))
806                },
807            )),
808        )
809    }
810
811    fn atom() -> InlineNode {
812        InlineNode::custom(MarkdownNode::new("atom", ()).text("\u{FFFC}"))
813    }
814
815    #[gpui::test]
816    fn keyed_leaves_report_geometry_and_unkeyed_leaves_do_not(cx: &mut TestAppContext) {
817        let blocks = vec![
818            BlockNode::Heading {
819                level: 1,
820                children: plain("Title").with_leaf_key(1),
821                span: None,
822            },
823            BlockNode::Paragraph(plain("first").with_leaf_key(2)),
824            BlockNode::Paragraph(plain("not an editor leaf")),
825            BlockNode::CodeBlock(
826                CodeBlock::new("let x = 1;".into(), Some("rust".into()), None::<Span>)
827                    .with_leaf_key(3),
828            ),
829            BlockNode::Table(Table::new(
830                vec![
831                    vec![plain("a").with_leaf_key(4), plain("b").with_leaf_key(5)],
832                    vec![plain("c").with_leaf_key(6), plain("d").with_leaf_key(7)],
833                ],
834                vec![ColumnAlign::None, ColumnAlign::Right],
835            )),
836            BlockNode::List {
837                children: vec![BlockNode::ListItem {
838                    children: vec![BlockNode::Paragraph(plain("item").with_leaf_key(8))],
839                    spread: false,
840                    checked: None,
841                    span: None,
842                }],
843                ordered: false,
844                start: None,
845                span: None,
846            },
847        ];
848        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
849        let mut keys: Vec<u64> = geometries.keys().copied().collect();
850        keys.sort_unstable();
851        assert_eq!(keys, (1..=8).collect::<Vec<_>>());
852        for key in [1, 2, 3, 8] {
853            assert!(geometries[&key].bounds().size.height > px(0.), "leaf {key}");
854        }
855        assert_eq!(geometries[&1].len(), "Title".len());
856        assert_eq!(geometries[&3].len(), "let x = 1;".len());
857        // Document order is top to bottom.
858        let top = |key: u64| geometries[&key].bounds().top();
859        assert!(top(1) < top(2) && top(2) < top(3) && top(3) < top(4) && top(7) < top(8));
860        // Cells of one row share a line; the second column sits to the right.
861        assert_eq!(top(4), top(5));
862        assert!(geometries[&5].bounds().left() > geometries[&4].bounds().right() - px(1.));
863    }
864
865    #[gpui::test]
866    fn wrapped_leaf_maps_indices_and_positions(cx: &mut TestAppContext) {
867        let text = "the quick brown fox jumps over the lazy dog and keeps running far away";
868        let blocks = vec![BlockNode::Paragraph(plain(text).with_leaf_key(1))];
869        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(120.));
870        let leaf = &geometries[&1];
871        assert_eq!(leaf.len(), text.len());
872        assert!(!leaf.is_empty());
873        let line = leaf.line_height();
874        assert!(line > px(0.) && leaf.caret_height() <= line);
875        let mut last_y = px(-1.);
876        let mut lines = std::collections::BTreeSet::new();
877        for index in 0..=text.len() {
878            let caret = leaf.position_for_index(index);
879            assert!(caret.y >= last_y, "carets never move up");
880            last_y = caret.y;
881            lines.insert((caret.y.as_f32() * 100.) as i64);
882            // Hit-testing at a caret lands on that caret (at a soft wrap the
883            // end of a line and the start of the next share a caret index).
884            let hit = leaf.index_for_position(point(caret.x, caret.y + line / 2.));
885            assert_eq!(
886                leaf.position_for_index(hit),
887                caret,
888                "index {index} hit {hit}"
889            );
890        }
891        assert!(lines.len() >= 3, "the text wraps onto several lines");
892        // Far outside the leaf clamps to its ends.
893        assert_eq!(leaf.index_for_position(point(px(-500.), px(-500.))), 0);
894        assert_eq!(
895            leaf.index_for_position(point(px(5000.), px(5000.))),
896            text.len()
897        );
898    }
899
900    #[gpui::test]
901    fn mixed_text_and_atoms_report_boxes(cx: &mut TestAppContext) {
902        let mut paragraph = Paragraph::default();
903        paragraph.push(InlineNode::new("ab"));
904        paragraph.push(atom());
905        paragraph.push(InlineNode::new("cd"));
906        let atom_len = '\u{FFFC}'.len_utf8();
907        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
908        let geometries = draw(cx, blocks, HashMap::new(), atom_plugin(), px(300.));
909        let leaf = &geometries[&1];
910        assert_eq!(leaf.len(), 2 + atom_len + 2);
911        // The box is hit-testable, and only the box.
912        let range = 2..2 + atom_len;
913        let before = leaf.position_for_index(2);
914        let after = leaf.position_for_index(2 + atom_len);
915        assert!(after.x > before.x, "the caret moves across the atom");
916        let inside = point(
917            (before.x + after.x) / 2.,
918            before.y + leaf.line_height() / 2.,
919        );
920        assert_eq!(leaf.atom_at(inside), Some(range.clone()));
921        assert_eq!(leaf.atom_at(point(before.x - px(8.), inside.y)), None);
922        // Clicking either half of the atom puts the caret on its near edge.
923        let quarter = |t: f32| point(before.x + (after.x - before.x) * t, inside.y);
924        assert_eq!(leaf.index_for_position(quarter(0.25)), range.start);
925        assert_eq!(leaf.index_for_position(quarter(0.75)), range.end);
926        // Text on both sides keeps its own indices.
927        let end = leaf.position_for_index(leaf.len());
928        assert_eq!(
929            leaf.index_for_position(point(end.x + px(40.), inside.y)),
930            leaf.len()
931        );
932        assert_eq!(leaf.index_for_position(point(px(-40.), inside.y)), 0);
933    }
934
935    #[gpui::test]
936    fn mixed_flow_hits_land_on_grapheme_boundaries(cx: &mut TestAppContext) {
937        // Accented, CJK and emoji text (a skin-tone sequence is one grapheme
938        // of two chars) on both sides of atoms, so the leaf is a mixed flow.
939        let parts = ["éa", "\u{FFFC}", "中文", "\u{FFFC}", "👍🏽x"];
940        let mut paragraph = Paragraph::default();
941        for part in parts {
942            if part == "\u{FFFC}" {
943                paragraph.push(atom());
944            } else {
945                paragraph.push(InlineNode::new(part));
946            }
947        }
948        let text = parts.concat();
949        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
950        let geometries = draw(cx, blocks, HashMap::new(), atom_plugin(), px(400.));
951        let leaf = &geometries[&1];
952        assert_eq!(leaf.len(), text.len());
953        let boundaries: Vec<usize> = text
954            .grapheme_indices(true)
955            .map(|(i, _)| i)
956            .chain(std::iter::once(text.len()))
957            .collect();
958
959        // Sweep the whole line: every hit is a grapheme boundary that an
960        // edit can use.
961        let bounds = leaf.bounds();
962        let y = bounds.top() + leaf.line_height() / 2.;
963        let mut x = bounds.left() - px(4.);
964        while x <= bounds.right() + px(4.) {
965            let index = leaf.index_for_position(point(x, y));
966            assert!(
967                boundaries.contains(&index),
968                "hit at {x:?} returned {index}, inside a grapheme of {text:?}"
969            );
970            let mut edited = text.clone();
971            edited.insert(index, '|');
972            x += px(0.5);
973        }
974
975        // The caret x of each boundary hits that boundary, never a byte
976        // inside the character before it (`é`: offsets 1 and 2 share an x).
977        for &index in &boundaries {
978            let at = leaf.position_for_index(index);
979            assert_eq!(
980                leaf.index_for_position(point(at.x, y)),
981                index,
982                "caret position of {index} in {text:?}"
983            );
984        }
985    }
986
987    #[gpui::test]
988    fn mixed_flow_keeps_empty_lines_and_trailing_breaks(cx: &mut TestAppContext) {
989        let mut paragraph = Paragraph::default();
990        paragraph.push(atom());
991        paragraph.push(InlineNode::new("a\n\n"));
992        let mut breaks = Paragraph::default();
993        breaks.push(atom());
994        breaks.push(InlineNode::new("\n\n"));
995        let leaves = draw(
996            cx,
997            vec![
998                BlockNode::Paragraph(paragraph.with_leaf_key(1)),
999                BlockNode::Paragraph(breaks.with_leaf_key(2)),
1000            ],
1001            HashMap::new(),
1002            atom_plugin(),
1003            px(300.),
1004        );
1005        let leaf = &leaves[&1];
1006        assert_eq!(leaf.len(), 6);
1007        let a = leaf.position_for_index(3);
1008        let empty = leaf.position_for_index(5);
1009        let end = leaf.position_for_index(6);
1010        assert!(a.y < empty.y && empty.y < end.y);
1011        for index in [5, 6] {
1012            let caret = leaf.position_for_index(index);
1013            assert_eq!(
1014                leaf.index_for_position(point(
1015                    caret.x + px(20.),
1016                    caret.y + leaf.line_height() / 2.
1017                )),
1018                index
1019            );
1020        }
1021        assert!(leaf.bounds().bottom() >= end.y + leaf.line_height());
1022        let breaks = &leaves[&2];
1023        assert_eq!(breaks.len(), 5);
1024        assert!(breaks.line_height() < breaks.bounds().size.height);
1025        assert!(breaks.position_for_index(5).y > breaks.position_for_index(4).y);
1026    }
1027
1028    #[gpui::test]
1029    fn atom_decorations_are_painted_for_custom_nodes_and_images(cx: &mut TestAppContext) {
1030        let color: Hsla = rgba(0x12345680).into();
1031        let mut custom = Paragraph::default();
1032        custom.push(atom());
1033        let mut image = Paragraph::default();
1034        image.push(InlineNode::image(ImageNode {
1035            url: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'/>".into(),
1036            width: Some(px(20.).into()), height: Some(px(20.).into()),
1037            ..Default::default()
1038        }));
1039        let blocks = vec![
1040            BlockNode::Paragraph(custom.with_leaf_key(1)),
1041            BlockNode::Paragraph(image.with_leaf_key(2)),
1042        ];
1043        let decorations = HashMap::from([
1044            (1, vec![(0..3, color), (1..1, color)]),
1045            (2, vec![(0..3, color), (3..6, color)]),
1046        ]);
1047        let (_, painted) =
1048            draw_and_inspect(cx, blocks, decorations, atom_plugin(), px(300.), |window| {
1049                let background: gpui::Background = color.into();
1050                window
1051                    .painted_quads()
1052                    .into_iter()
1053                    .filter(|quad| quad.background == background)
1054                    .count()
1055            });
1056        assert_eq!(
1057            painted, 2,
1058            "one overlay per atom; empty and adjacent ranges paint nothing"
1059        );
1060    }
1061
1062    #[gpui::test]
1063    fn rekeying_cloned_leaves_preserves_each_identity(cx: &mut TestAppContext) {
1064        let original = plain("shared source").with_leaf_key(1);
1065        let copy = original.clone().with_leaf_key(2);
1066        let code = CodeBlock::new("let x = 1;".into(), None, None::<Span>).with_leaf_key(3);
1067        let copy_code = code.clone().with_leaf_key(4);
1068        let leaves = draw(
1069            cx,
1070            vec![
1071                BlockNode::Paragraph(original),
1072                BlockNode::Paragraph(copy),
1073                BlockNode::CodeBlock(code),
1074                BlockNode::CodeBlock(copy_code),
1075            ],
1076            HashMap::new(),
1077            Default::default(),
1078            px(300.),
1079        );
1080        assert_eq!(leaves.len(), 4);
1081        assert_eq!(leaves[&1].len(), "shared source".len());
1082        assert_eq!(leaves[&3].len(), "let x = 1;".len());
1083        assert!(leaves[&1].bounds().top() < leaves[&2].bounds().top());
1084        assert!(leaves[&3].bounds().top() < leaves[&4].bounds().top());
1085    }
1086
1087    #[gpui::test]
1088    fn geometry_is_an_immutable_snapshot(cx: &mut TestAppContext) {
1089        cx.update(crate::init);
1090        let out = Geometries::default();
1091        let (host, vcx) = cx.add_window_view({
1092            let out = out.clone();
1093            move |_, _| Host {
1094                blocks: vec![BlockNode::Paragraph(
1095                    plain("a long sentence that wraps when the viewport gets narrower")
1096                        .with_leaf_key(1),
1097                )],
1098                extensions: Default::default(),
1099                decorations: HashMap::new(),
1100                out,
1101                width: px(600.),
1102                sink: None,
1103            }
1104        });
1105        settle(vcx);
1106        let old = out.borrow()[&1].clone();
1107        let before = old.bounds();
1108        vcx.update(|_, cx| {
1109            host.update(cx, |host, cx| {
1110                host.width = px(80.);
1111                cx.notify();
1112            })
1113        });
1114        settle(vcx);
1115        assert!(out.borrow()[&1].bounds().size.height > before.size.height);
1116        assert_eq!(old.bounds(), before);
1117        assert_eq!(old.position_for_index(0), before.origin);
1118    }
1119
1120    #[gpui::test]
1121    fn empty_keyed_leaf_has_a_line_box_and_empty_unkeyed_leaf_stays_empty(cx: &mut TestAppContext) {
1122        let blocks = vec![
1123            BlockNode::Paragraph(Paragraph::default()),
1124            BlockNode::Paragraph(Paragraph::default().with_leaf_key(9)),
1125        ];
1126        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
1127        assert_eq!(geometries.len(), 1);
1128        let leaf = &geometries[&9];
1129        assert!(leaf.is_empty());
1130        assert!(leaf.bounds().size.height > px(0.));
1131        assert_eq!(leaf.position_for_index(0).x, leaf.bounds().left());
1132        assert_eq!(
1133            leaf.index_for_position(point(px(50.), leaf.bounds().top())),
1134            0
1135        );
1136    }
1137
1138    #[gpui::test]
1139    fn image_only_leaf_reports_its_box(cx: &mut TestAppContext) {
1140        let image = ImageNode {
1141            url: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'/>".into(),
1142            width: Some(px(20.).into()),
1143            height: Some(px(20.).into()),
1144            ..Default::default()
1145        };
1146        let mut paragraph = Paragraph::default();
1147        paragraph.push(InlineNode::image(image));
1148        let blocks = vec![BlockNode::Paragraph(paragraph.with_leaf_key(1))];
1149        let geometries = draw(cx, blocks, HashMap::new(), Default::default(), px(300.));
1150        let leaf = &geometries[&1];
1151        let atom_len = '\u{FFFC}'.len_utf8();
1152        assert_eq!(leaf.len(), atom_len);
1153        let inside = leaf.bounds().center();
1154        assert_eq!(leaf.atom_at(inside), Some(0..atom_len));
1155    }
1156
1157    /// Records which leaves ask for backgrounds, to prove decorations are
1158    /// requested for keyed leaves only.
1159    #[derive(Default)]
1160    struct Recorder {
1161        asked: RefCell<Vec<u64>>,
1162    }
1163
1164    impl EditorSink for Recorder {
1165        fn report(&self, _: u64, _: &TextLayout) {}
1166        fn begin_pieces(&self, _: u64) {}
1167        fn report_text_piece(&self, _: u64, _: usize, _: &TextLayout) {}
1168        fn report_box_piece(&self, _: u64, _: Range<usize>, _: Bounds<Pixels>) {}
1169        fn report_empty_piece(&self, _: u64, _: usize, _: Bounds<Pixels>) {}
1170        fn backgrounds(&self, key: u64) -> Vec<(Range<usize>, Hsla)> {
1171            self.asked.borrow_mut().push(key);
1172            vec![(0..1, rgb(0xff0000).into())]
1173        }
1174    }
1175
1176    #[gpui::test]
1177    fn decorations_are_requested_per_keyed_leaf(cx: &mut TestAppContext) {
1178        cx.update(crate::init);
1179        let recorder = Rc::new(Recorder::default());
1180        let mut mixed = Paragraph::default();
1181        mixed.push(InlineNode::new("ab"));
1182        mixed.push(atom());
1183        let blocks = vec![
1184            BlockNode::Paragraph(plain("one").with_leaf_key(1)),
1185            BlockNode::Paragraph(plain("unkeyed")),
1186            BlockNode::Paragraph(mixed.with_leaf_key(2)),
1187        ];
1188        let (_, vcx) = cx.add_window_view({
1189            let sink: Rc<dyn EditorSink> = recorder.clone();
1190            move |_, _| Host {
1191                blocks,
1192                extensions: atom_plugin(),
1193                decorations: HashMap::new(),
1194                out: Geometries::default(),
1195                width: px(300.),
1196                sink: Some(sink),
1197            }
1198        });
1199        settle(vcx);
1200        let asked = recorder.asked.borrow();
1201        assert!(asked.contains(&1) && asked.contains(&2), "{asked:?}");
1202        assert!(asked.iter().all(|key| *key == 1 || *key == 2), "{asked:?}");
1203    }
1204
1205    #[gpui::test]
1206    fn decorated_leaves_still_report_geometry(cx: &mut TestAppContext) {
1207        let blocks = vec![BlockNode::Paragraph(plain("hello world").with_leaf_key(1))];
1208        let decorations = HashMap::from([(
1209            1,
1210            vec![
1211                (0..5, rgba(0x3366ff80).into()),
1212                (6..11, rgb(0xffcc00).into()),
1213            ],
1214        )]);
1215        let plain_geometry = draw(
1216            cx,
1217            blocks.clone(),
1218            HashMap::new(),
1219            Default::default(),
1220            px(300.),
1221        );
1222        let decorated = draw(cx, blocks, decorations, Default::default(), px(300.));
1223        assert_eq!(
1224            decorated[&1].bounds().size,
1225            plain_geometry[&1].bounds().size,
1226            "decorations paint behind the text and never change layout"
1227        );
1228    }
1229
1230    #[test]
1231    fn collect_leaf_geometry_nests_and_restores_on_panic() {
1232        let seen = Cell::new(0);
1233        let (inner_ran, geometries) = collect_leaf_geometry(HashMap::new(), || {
1234            assert!(editor_seam::current().is_some());
1235            let outer = editor_seam::current().unwrap();
1236            collect_leaf_geometry(HashMap::new(), || seen.set(seen.get() + 1));
1237            let after = editor_seam::current().unwrap();
1238            assert!(Rc::ptr_eq(&outer, &after), "the outer scope is restored");
1239            true
1240        });
1241        assert!(inner_ran && geometries.is_empty() && seen.get() == 1);
1242        assert!(editor_seam::current().is_none());
1243
1244        let result = std::panic::catch_unwind(|| {
1245            collect_leaf_geometry(HashMap::new(), || panic!("boom"));
1246        });
1247        assert!(result.is_err());
1248        assert!(editor_seam::current().is_none(), "cleared after a panic");
1249    }
1250
1251    #[test]
1252    fn parse_markdown_reads_what_an_editor_round_trips() {
1253        let source = "```rust title=\"x\"\nfn main() {}\n```\n\n\
1254                      | a | b | c | d |\n|---|:--|:-:|--:|\n| 1 | 2 | 3 | 4 |\n\n\
1255                      [ref]: https://example.com \"Title\"\n";
1256        let blocks = parse_markdown(source, Default::default()).unwrap();
1257        let code = blocks
1258            .iter()
1259            .find_map(|block| match block {
1260                BlockNode::CodeBlock(code) => Some(code),
1261                _ => None,
1262            })
1263            .expect("code block");
1264        assert_eq!(code.lang().as_deref(), Some("rust"));
1265        assert_eq!(code.meta().as_deref(), Some("title=\"x\""));
1266        let table = blocks
1267            .iter()
1268            .find_map(|block| match block {
1269                BlockNode::Table(table) => Some(table),
1270                _ => None,
1271            })
1272            .expect("table");
1273        assert_eq!(
1274            table.column_aligns(),
1275            [
1276                ColumnAlign::None,
1277                ColumnAlign::Left,
1278                ColumnAlign::Center,
1279                ColumnAlign::Right
1280            ]
1281        );
1282        let cells: Vec<Vec<String>> = table
1283            .rows()
1284            .map(|row| {
1285                row.map(|cell| cell.children().iter().map(|c| c.text()).collect())
1286                    .collect()
1287            })
1288            .collect();
1289        assert_eq!(cells, [["a", "b", "c", "d"], ["1", "2", "3", "4"]]);
1290        assert!(blocks.iter().any(|block| matches!(
1291            block,
1292            BlockNode::Definition { identifier, .. } if identifier.as_ref() == "ref"
1293        )));
1294        assert!(
1295            blocks
1296                .iter()
1297                .all(|block| block.span().is_some() || matches!(block, BlockNode::Unknown))
1298        );
1299    }
1300
1301    #[test]
1302    fn parse_markdown_matches_text_view_parse() {
1303        let source = "# Title\n\nSome **bold** and `code` and ==mark==.\n\n- one\n- two\n";
1304        let mut cx = NodeContext::default();
1305        let expected = markdown::parse(source, &mut cx).unwrap();
1306        let blocks = parse_markdown(source, Default::default()).unwrap();
1307        assert_eq!(blocks, *expected.blocks);
1308    }
1309
1310    #[test]
1311    fn table_and_paragraph_builders_read_back() {
1312        let mut paragraph = Paragraph::default();
1313        paragraph.push(InlineNode::new("hi").marks(vec![(0..2, TextMark::default().bold())]));
1314        assert_eq!(paragraph.children()[0].text(), "hi");
1315        assert!(paragraph.children()[0].mark_runs()[0].1.bold);
1316        assert!(paragraph.children()[0].as_image().is_none());
1317        assert!(paragraph.children()[0].as_custom().is_none());
1318        let table = Table::new(vec![vec![paragraph]], vec![ColumnAlign::Center]);
1319        assert_eq!(table.rows().count(), 1);
1320        assert_eq!(table.column_aligns(), [ColumnAlign::Center]);
1321    }
1322}