Skip to main content

markdown/
render.rs

1//! [`Doc`] → gpui elements.
2//!
3//! Numbers drive layout (sizes, line heights, paddings — the constants here);
4//! colors are paint, read from [`Theme`]. Blocks are a flat list, so nesting is
5//! left padding rather than nested containers, and the gap between two blocks
6//! is decided by the pair: list items sit tight, everything else breathes.
7//!
8//! Ported from zeronsh/comet (MIT) and rebuilt against the flat block model.
9
10use std::{
11    cell::RefCell,
12    collections::HashMap,
13    hash::{DefaultHasher, Hash, Hasher},
14    ops::Range,
15    path::Path,
16    rc::Rc,
17};
18
19use gpui::{
20    AnyElement, App, BorderStyle, Bounds, CursorStyle, ElementId, FontStyle, FontWeight, Hsla,
21    ImageSource, InteractiveText, MouseButton, ObjectFit, Pixels, Point, SharedString,
22    StrikethroughStyle, StyledImage as _, StyledText, TextLayout, TextRun, UnderlineStyle, Window,
23    canvas, div, font, img, point, prelude::*, px, quad, size,
24};
25use theme::{TextStyle, Theme, Typeset};
26
27use crate::{
28    block,
29    doc::{Align, Block, BlockKind, Doc, Form, Mark, Part, QuoteKind, Text},
30    layout::Layout,
31    preview,
32    select::{Cursor, Selection},
33    typography::Typography,
34};
35
36/// Space between two ordinary blocks, and the tighter space inside a list.
37mod code;
38mod column;
39mod layouts;
40mod media;
41mod table;
42mod text;
43
44pub use code::*;
45use column::*;
46pub use layouts::*;
47use media::*;
48use table::*;
49pub use text::*;
50
51const BLOCK_GAP: f32 = 12.0;
52const LIST_GAP: f32 = 4.0;
53/// One indent level. Wide enough to clear a marker and read as a level.
54const INDENT_WIDTH: f32 = 22.0;
55/// The marker column of a list row.
56const MARKER_WIDTH: f32 = 18.0;
57const MARKER_GAP: f32 = 8.0;
58/// What a fence holds its code in, inside its border.
59const CODE_PADDING_X: f32 = 12.0;
60const CODE_PADDING_Y: f32 = 10.0;
61/// What a fence with no info string calls itself, in its header and in a
62/// picker — one spelling, so the label and the menu row cannot disagree.
63pub const PLAIN_LANGUAGE: &str = "Plain";
64/// Width of the caret. Wider than a hairline, because it has to read at a
65/// glance against the text it sits in.
66const CARET_WIDTH: f32 = 1.5;
67/// Inline code's wash is a rounded quad painted under the glyphs: a run's
68/// `background_color` can only ever be a square box.
69const INLINE_CODE_RADIUS: f32 = 4.5;
70const INLINE_CODE_PAD_X: f32 = 2.0;
71const INLINE_CODE_INSET_Y: f32 = 2.0;
72/// A mention's hover card.
73const MENTION_CARD_WIDTH: f32 = 320.0;
74/// Bookmark metrics. Notion's card: 180px of image beside the text, and a
75/// height that fits a title, two lines of blurb and a footer. A cover moves
76/// that image above the text and gives it the card's full width.
77const CARD_HEIGHT: f32 = 116.0;
78const CARD_IMAGE_WIDTH: f32 = 180.0;
79const CARD_COVER_HEIGHT: f32 = 200.0;
80const CARD_PADDING: f32 = 14.0;
81const CARD_BORDER: f32 = 1.0;
82const CARD_ICON: f32 = 16.0;
83const CARD_COVER: f32 = 44.0;
84/// Image metrics.
85const IMAGE_EMPTY_HEIGHT: f32 = 52.0;
86const CAPTION_GAP: f32 = 4.0;
87/// What an image with no URL yet says, and what its caption says while empty.
88const IMAGE_EMPTY: &str = "Add an image";
89const CAPTION_HINT: &str = "Write a caption";
90/// Table metrics. The design is frameless: hairlines between rows are the only
91/// chrome — no outer box, no header fill, no rounding.
92const TABLE_CELL_PADDING: f32 = 12.0;
93const TABLE_DIVIDER: f32 = 1.0;
94/// Floor for a column's max-content share, so a short column ("1k") beside a
95/// prose column keeps a readable width.
96const TABLE_MIN_COLUMN_CONTENT: f32 = 48.0;
97/// Narrowest a column wraps down to before the table scrolls instead.
98const TABLE_MIN_COLUMN_WIDTH: f32 = 96.0;
99
100/// What an image's one authored string is doing on the page.
101///
102/// SwiftUI keeps three things apart — `accessibilityLabel` for a reader that
103/// cannot see, `.help` for the pointer, and a caption you compose out of a
104/// `Text` under the picture. Markdown has one slot for all three, so this says
105/// which of them it is playing here rather than in the document, where it is
106/// the same string either way.
107///
108/// A named choice rather than a `bool`, so a surface that wants a third answer
109/// gets a variant instead of a second flag.
110#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
111pub enum Caption {
112    /// Under the picture, where a caret can sit in it. The editor's shape.
113    #[default]
114    Shown,
115    /// Kept by the document and painted nowhere — a picture on its own.
116    Hidden,
117}
118
119/// Whether a fence paints the button that copies its text.
120///
121/// A named choice rather than a `bool`, the way [`Caption`] is.
122#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
123pub enum CopyButton {
124    /// Floating at the top right of the band, on the pointer and off it.
125    #[default]
126    Shown,
127    /// Painted nowhere. A document with this and no [`Editing::toggle`] holds
128    /// no listener at all.
129    Hidden,
130}
131
132/// A range the caller wants washed, and which wash it gets.
133///
134/// None of what a comment or a highlight *says* is here: the caller keeps it
135/// and hands over the range, the way it hands over a [`crate::Preview`]. A
136/// closed set rather than a color, so the environment keeps deciding the paint.
137#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
138#[non_exhaustive]
139pub enum Annotation {
140    /// A thread still waiting on someone.
141    #[default]
142    Open,
143    /// Answered, and kept for the record.
144    Resolved,
145    /// The one whose thread the reader has in front of them.
146    Active,
147    /// A reader's highlight, in the wash [`crate::set_highlight_paint`]
148    /// gives its colour.
149    Highlight(crate::HighlightColor),
150    /// A find hit.
151    Match,
152    /// The find hit the reader is on.
153    Current,
154}
155
156impl Annotation {
157    fn wash(self, theme: &Theme, highlight: crate::HighlightPaint) -> Hsla {
158        match self {
159            Self::Open => theme.warning.opacity(0.20),
160            Self::Resolved => theme.warning.opacity(0.08),
161            Self::Active => theme.warning.opacity(0.38),
162            Self::Highlight(color) => highlight(color, theme),
163            Self::Match => theme.accent.opacity(0.22),
164            Self::Current => theme.accent.opacity(0.48),
165        }
166    }
167}
168
169/// Handed the block whose checkbox was clicked — see [`Toggle::Handled`].
170///
171/// Shared rather than borrowed: the press listener it is cloned into outlives
172/// the frame that built it.
173pub type OnToggle = Rc<dyn Fn(usize, &mut Window, &mut App)>;
174
175/// Handed the image block whose picture was clicked — see [`Editing::image`].
176pub type OnImage = Rc<dyn Fn(usize, &mut Window, &mut App)>;
177
178/// Who answers a press on a task block's checkbox.
179///
180/// Either variant paints the box as a control — the pointer over it is a hand.
181/// [`Editing::toggle`] left unset paints it as a mark, and the press goes
182/// wherever it would on any other glyph.
183#[derive(Clone)]
184pub enum Toggle {
185    /// The box takes the press, stops it, and calls this with the block it
186    /// belongs to. For a caller holding the [`Doc`] it renders itself.
187    Handled(OnToggle),
188    /// The box takes no press. The caller hit-tests
189    /// [`BlockLayouts::checkbox_bounds`] in its own handler, which is what an
190    /// editor does: the press it swallows is the one that also takes focus and
191    /// closes an open menu, and the toggle belongs in the undo history beside
192    /// the rest of its edits.
193    HitTested,
194}
195
196/// What an editor paints over a document.
197///
198/// One value rather than six parameters, and the reason it is public: the
199/// caret, the selection, the comment washes and the layout sink all arrive
200/// together or not at all, and a read-only [`render`] sets none of them.
201#[derive(Clone)]
202pub struct Editing<'a> {
203    /// The caret and what it has selected. `None` paints neither — a document
204    /// nobody is editing.
205    pub selection: Option<Selection>,
206    /// The blink's lit half. A caret painted on every frame reads as frozen,
207    /// and the phase belongs to whoever owns the focus.
208    pub caret_on: bool,
209    /// Filled as the document paints, for a caller resolving clicks against it.
210    ///
211    /// Given, only the blocks near the part of the window the document shows
212    /// are built, and it answers for those alone. The rest are placed at their
213    /// last measured height, or a guess at one, and held in it across frames.
214    pub layouts: Option<&'a BlockLayouts>,
215    /// Ranges washed under the text, in the order given.
216    pub annotations: &'a [(Selection, Annotation)],
217    /// Shown on the caret's block while it holds nothing.
218    pub placeholder: Option<SharedString>,
219    pub caption: Caption,
220    /// What to set the document in. `None` takes the installed
221    /// [`Typography`] — a caller sizing one document apart from the rest
222    /// passes [`Typography::scaled`].
223    pub typography: Option<Typography>,
224    /// Makes a task block's checkbox a control, and says who answers the
225    /// press. `None` paints a mark.
226    pub toggle: Option<Toggle>,
227    /// Makes a picture a control: a click on it calls this with its block. A
228    /// press and release more than a couple of pixels apart is a drag and
229    /// calls nothing. The press still reaches whatever is under the picture.
230    pub image: Option<OnImage>,
231    /// Whether a fence offers to copy itself.
232    pub copy: CopyButton,
233    /// The directory a relative image path is joined onto. `None` leaves it
234    /// relative, which gpui reads against the process's working directory.
235    pub base: Option<&'a Path>,
236    /// Blocks built wherever they are, alongside the ones near the part of the
237    /// window the document shows — source lines, for [`render_source`]. The
238    /// caret's and the selection anchor's are built without being named. Only
239    /// read with `layouts` given.
240    pub keep: &'a [usize],
241    /// The scroll container the document sits in. When blocks above the text
242    /// showing come out taller or shorter than they were placed at, its offset
243    /// moves by the difference. Only read with `layouts` given.
244    pub scroll: Option<&'a gpui::ScrollHandle>,
245}
246
247impl Default for Editing<'_> {
248    fn default() -> Self {
249        Self {
250            selection: None,
251            // Lit, so that a caller setting a selection and nothing else gets a
252            // caret rather than a mystery.
253            caret_on: true,
254            layouts: None,
255            annotations: &[],
256            placeholder: None,
257            caption: Caption::default(),
258            typography: None,
259            toggle: None,
260            image: None,
261            copy: CopyButton::default(),
262            base: None,
263            keep: &[],
264            scroll: None,
265        }
266    }
267}
268
269/// What the editor needs painted into one text: which text it is, where the
270/// caret sits, and where to record the layout a click resolves against.
271///
272/// One bundle rather than four parameters threaded through every block arm —
273/// a read-only render builds it with no caret and no sink, and pays nothing.
274#[derive(Clone, Copy)]
275struct Overlay<'a> {
276    block: usize,
277    part: Part,
278    selection: Option<Selection>,
279    caret_on: bool,
280    layouts: Option<&'a BlockLayouts>,
281    /// Ranges washed under the text, in the order the caller gave them.
282    annotations: &'a [(Selection, Annotation)],
283    /// Shown on the caret's block while it holds nothing. The renderer is the
284    /// only thing that knows where that text sits, so the string comes to it.
285    placeholder: Option<&'a SharedString>,
286    caption: Caption,
287    /// Borrowed so [`Overlay`] stays `Copy` — the clone is made at the one
288    /// press listener that needs an owned handle.
289    toggle: Option<&'a Toggle>,
290    image: Option<&'a OnImage>,
291    copy: CopyButton,
292    base: Option<&'a Path>,
293    highlight: crate::HighlightPaint,
294}
295
296impl<'a> Overlay<'a> {
297    fn at(self, part: Part) -> Self {
298        Self { part, ..self }
299    }
300
301    fn here(&self) -> Cursor {
302        Cursor::new(self.block, self.part, 0)
303    }
304
305    /// The caret to paint: where it is, and only on the blink's lit half.
306    ///
307    /// Separate from [`Self::caret`] because the blink must not reach anything
308    /// but the quad — a block whose paint depends on holding the caret would
309    /// otherwise swap itself out twice a second.
310    fn caret_painted(&self) -> Option<usize> {
311        self.caret_on.then(|| self.caret()).flatten()
312    }
313
314    /// The caret's byte offset, if the head is in *this* text.
315    fn caret(&self) -> Option<usize> {
316        self.selection
317            .map(|selection| selection.head)
318            .filter(|head| head.block == self.block && head.part == self.part)
319            .map(|head| head.offset)
320    }
321
322    /// The selected slice of this text, clipped to it.
323    fn selected(&self, len: usize) -> Option<Range<usize>> {
324        self.clip(self.selection?, len)
325    }
326
327    /// The annotated slices of this text, already resolved to their paint —
328    /// the wash goes into a `move` closure that the theme does not travel into.
329    fn annotated(&self, len: usize, theme: &Theme) -> Vec<(Range<usize>, Hsla)> {
330        self.annotations
331            .iter()
332            .filter_map(|(range, kind)| {
333                Some((self.clip(*range, len)?, kind.wash(theme, self.highlight)))
334            })
335            .collect()
336    }
337
338    /// A range clipped to this text, and `None` when it does not reach it.
339    ///
340    /// The comparison is on `(block, part)` alone: a range covers this text
341    /// entirely when it starts before and ends after, and the offsets only
342    /// matter at the two ends.
343    fn clip(&self, selection: Selection, len: usize) -> Option<Range<usize>> {
344        if selection.is_collapsed() {
345            return None;
346        }
347        let (start, end) = selection.ordered();
348        let here = self.here();
349        let (first, last) = (
350            Cursor::new(start.block, start.part, 0),
351            Cursor::new(end.block, end.part, 0),
352        );
353        if here < first || here > last {
354            return None;
355        }
356        let from = if here == first { start.offset } else { 0 };
357        let to = if here == last { end.offset } else { len };
358        (from < to).then_some(from..to.min(len))
359    }
360
361    /// Whether a block painting something a caret cannot enter — a rule, a
362    /// picture — falls inside the selection, and so should show that it is
363    /// going to be taken.
364    fn covers_block(&self) -> bool {
365        let Some(selection) = self.selection.filter(|s| !s.is_collapsed()) else {
366            return false;
367        };
368        let (start, end) = selection.ordered();
369        start.block < self.block && self.block < end.block
370    }
371}
372
373/// What gpui loads for an image URL as written in a document.
374///
375/// Anything with `://` is fetched as it stands. Anything else is a file: an
376/// absolute path as it stands, a relative one joined onto `base` when there is
377/// one.
378pub fn image_source(url: &str, base: Option<&Path>) -> ImageSource {
379    if url.contains("://") {
380        return SharedString::from(url.to_string()).into();
381    }
382    // gpui reads a file only from a `PathBuf` — handed a string it looks for
383    // an asset built into the binary and paints nothing.
384    match base {
385        Some(base) => base.join(url).into(),
386        None => std::path::PathBuf::from(url).into(),
387    }
388}
389
390/// Parse and render in one step — the common case for read-only content.
391pub fn markdown(source: &str, window: &mut Window, cx: &mut App) -> AnyElement {
392    let doc = crate::parse_with(source, &crate::Marks::of(cx));
393    render(&doc, Caption::default(), window, cx)
394}
395
396/// Render a document.
397pub fn render(doc: &Doc, caption: Caption, window: &mut Window, cx: &mut App) -> AnyElement {
398    render_with(
399        doc,
400        Editing {
401            caption,
402            ..Editing::default()
403        },
404        window,
405        cx,
406    )
407}
408
409/// Render a document with a caret and a selection in it.
410///
411/// Both are paint-time concerns and nothing else: they read their positions off
412/// the shaped text's own layout handle, the same way the inline-code wash does,
413/// so nothing about layout depends on where the caret sits. An editor supplies
414/// the selection and owns the focus and the keys; painting a caret and a few
415/// quads is not worth a second renderer.
416pub fn render_with(doc: &Doc, editing: Editing, window: &mut Window, cx: &mut App) -> AnyElement {
417    let Editing {
418        selection,
419        caret_on,
420        layouts,
421        annotations,
422        placeholder,
423        caption,
424        typography,
425        toggle,
426        image,
427        copy,
428        base,
429        keep,
430        scroll,
431    } = editing;
432    // Cloned once so the theme is readable while `cx` stays free for the
433    // element state the copy button needs.
434    let theme = Theme::of(cx).clone();
435    let typography = typography.unwrap_or_else(|| Typography::of(cx));
436    let highlight = crate::marks::highlight_paint_of(cx);
437    let gaps: Vec<Pixels> = doc
438        .blocks
439        .iter()
440        .enumerate()
441        .map(|(ix, block)| {
442            px(match doc.blocks.get(ix.wrapping_sub(1)) {
443                None => 0.0,
444                Some(previous) if tight(previous, block) => LIST_GAP,
445                Some(_) => BLOCK_GAP,
446            })
447        })
448        .collect();
449
450    let Some(layouts) = layouts else {
451        let mut column = div().flex().flex_col();
452        for (ix, block) in doc.blocks.iter().enumerate() {
453            let overlay = Overlay {
454                block: ix,
455                part: Part::Body,
456                selection,
457                caret_on,
458                layouts: None,
459                annotations,
460                placeholder: placeholder.as_ref(),
461                caption,
462                toggle: toggle.as_ref(),
463                image: image.as_ref(),
464                copy,
465                base,
466                highlight,
467            };
468            column = column
469                .child(block_box(block, overlay, &typography, &theme, window, cx).mt(gaps[ix]));
470        }
471        return column.into_any_element();
472    };
473
474    let keys: Vec<u64> = doc
475        .blocks
476        .iter()
477        .map(|block| block_key(block, &typography))
478        .collect();
479    layouts.prune(&keys);
480    let guesses: Vec<Guess> = doc
481        .blocks
482        .iter()
483        .map(|block| guess(block, &typography))
484        .collect();
485    let mut kept = keep.to_vec();
486    kept.extend(selection.map(|selection| selection.head.block));
487    kept.extend(selection.map(|selection| selection.anchor.block));
488    let owned = Owned {
489        blocks: doc.blocks.clone(),
490        selection,
491        caret_on,
492        layouts: layouts.clone(),
493        annotations: annotations.to_vec(),
494        placeholder,
495        caption,
496        toggle,
497        image,
498        copy,
499        base: base.map(Path::to_path_buf),
500        highlight,
501        typography,
502        theme,
503    };
504    Column {
505        layouts: layouts.clone(),
506        keys: keys.into(),
507        gaps: gaps.into(),
508        guesses: guesses.into(),
509        keep: kept,
510        scroll: scroll.cloned(),
511        item_of: Box::new(|at| at.block),
512        build: Box::new(move |ix, window, cx| {
513            let overlay = Overlay {
514                block: ix,
515                part: Part::Body,
516                selection: owned.selection,
517                caret_on: owned.caret_on,
518                layouts: Some(&owned.layouts),
519                annotations: &owned.annotations,
520                placeholder: owned.placeholder.as_ref(),
521                caption: owned.caption,
522                toggle: owned.toggle.as_ref(),
523                image: owned.image.as_ref(),
524                copy: owned.copy,
525                base: owned.base.as_deref(),
526                highlight: owned.highlight,
527            };
528            block_box(
529                &owned.blocks[ix],
530                overlay,
531                &owned.typography,
532                &owned.theme,
533                window,
534                cx,
535            )
536            .into_any_element()
537        }),
538    }
539    .into_any_element()
540}
541
542/// What [`Column`] builds a block from, owned so it can build one at prepaint.
543struct Owned {
544    blocks: Vec<Block>,
545    selection: Option<Selection>,
546    caret_on: bool,
547    layouts: BlockLayouts,
548    annotations: Vec<(Selection, Annotation)>,
549    placeholder: Option<SharedString>,
550    caption: Caption,
551    toggle: Option<Toggle>,
552    image: Option<OnImage>,
553    copy: CopyButton,
554    base: Option<std::path::PathBuf>,
555    highlight: crate::HighlightPaint,
556    typography: Typography,
557    theme: Theme,
558}
559
560/// A block's box: its indent outside, and inside it the recorder and the
561/// block itself.
562fn block_box(
563    block: &Block,
564    overlay: Overlay,
565    typography: &Typography,
566    theme: &Theme,
567    window: &mut Window,
568    cx: &mut App,
569) -> gpui::Div {
570    let ix = overlay.block;
571    // The block's own box, recorded for a gutter handle and a drop target.
572    // A rule and an image hold no text, so a layout would not find them.
573    let frame = overlay.layouts.map(|layouts| {
574        let layouts = layouts.clone();
575        canvas(
576            move |bounds, _, _| layouts.record_block(ix, bounds),
577            |_, _, _, _| (),
578        )
579        .absolute()
580        .size_full()
581    });
582    // The indent sits on the outside and the recorder on the inside, so what
583    // is recorded is the box the block's text actually occupies. Recorded
584    // outside the padding, every level answered with the same left edge, and a
585    // gutter handle placed from it stayed at the margin while the block it
586    // belongs to moved right.
587    div()
588        .w_full()
589        .pl(px(block.indent as f32 * INDENT_WIDTH))
590        .child(
591            div()
592                .w_full()
593                .relative()
594                .children(frame)
595                // What a caret cannot enter still has to show it is inside the
596                // selection, or a rule between two paragraphs looks untouched
597                // right up until it disappears.
598                .when(overlay.covers_block() && block.opaque(), |el| {
599                    el.rounded(px(4.0)).bg(theme.selection)
600                })
601                .child(block_element(block, overlay, typography, theme, window, cx)),
602        )
603}
604
605/// What a block's height is cached under: its content and the type it is set
606/// in, so an edit elsewhere that shifts its index keeps the height.
607fn block_key(block: &Block, typography: &Typography) -> u64 {
608    let mut hasher = DefaultHasher::new();
609    block.hash(&mut hasher);
610    typography.body.size().to_bits().hash(&mut hasher);
611    typography.body.line_height().to_bits().hash(&mut hasher);
612    hasher.finish()
613}
614
615/// What a block is placed at before it has ever been built.
616fn guess(block: &Block, typography: &Typography) -> Guess {
617    let body = px(typography.body.line_height());
618    let prose = |chars: usize, line: Pixels| Guess {
619        chars,
620        line,
621        rows: 0,
622        extra: px(0.0),
623        indent: px(0.0),
624    };
625    let guess = match &block.kind {
626        BlockKind::Paragraph(text)
627        | BlockKind::Bullet(text)
628        | BlockKind::Ordered { text, .. }
629        | BlockKind::Task { text, .. }
630        | BlockKind::Quote { text, .. } => prose(text.text.len(), body),
631        BlockKind::Heading { level, text } => prose(
632            text.text.len(),
633            px(typography.heading(*level).line_height()),
634        ),
635        BlockKind::Code { code, .. } => Guess {
636            rows: code.text.lines().count().max(1),
637            line: px(typography.code.line_height()),
638            extra: px(2.0 * CODE_PADDING_Y) + body,
639            ..prose(0, body)
640        },
641        BlockKind::Table { rows, .. } => Guess {
642            rows: rows.len() + 1,
643            extra: px(2.0 * TABLE_CELL_PADDING + TABLE_DIVIDER) * (rows.len() + 1) as f32,
644            ..prose(0, body)
645        },
646        BlockKind::Image { alt, .. } => Guess {
647            extra: px(240.0),
648            ..prose(alt.text.len(), px(typography.caption.line_height()))
649        },
650        BlockKind::Bookmark { .. } => Guess {
651            extra: body * 3.0,
652            ..prose(0, body)
653        },
654        BlockKind::Rule => Guess {
655            extra: body,
656            ..prose(0, px(0.0))
657        },
658    };
659    Guess {
660        indent: px(block.indent as f32 * INDENT_WIDTH),
661        ..guess
662    }
663}
664
665/// Whether two adjacent blocks belong to the same list and should sit close.
666fn tight(previous: &Block, next: &Block) -> bool {
667    let marker = |block: &Block| {
668        matches!(
669            block.kind,
670            BlockKind::Bullet(_) | BlockKind::Ordered { .. } | BlockKind::Task { .. }
671        )
672    };
673    marker(previous) && (marker(next) || next.indent > previous.indent)
674}
675
676fn block_element(
677    block: &Block,
678    overlay: Overlay,
679    typography: &Typography,
680    theme: &Theme,
681    window: &mut Window,
682    cx: &mut App,
683) -> AnyElement {
684    let body = overlay.at(Part::Body);
685    match &block.kind {
686        BlockKind::Paragraph(text) => text_element(
687            text,
688            typography.body.size(),
689            typography.body.line_height(),
690            FontWeight::NORMAL,
691            body,
692            theme,
693            cx,
694        ),
695        BlockKind::Heading { level, text } => {
696            let heading = typography.heading(*level);
697            text_element(
698                text,
699                heading.size(),
700                heading.line_height(),
701                heading.weight,
702                body,
703                theme,
704                cx,
705            )
706        }
707        BlockKind::Bullet(text) => {
708            marker_row(disc(typography, theme), text, body, typography, theme, cx)
709        }
710        BlockKind::Ordered { number, text } => marker_row(
711            div()
712                .flex_none()
713                .w(px(MARKER_WIDTH))
714                .text_size(px(typography.body.size()))
715                .line_height(px(typography.body.line_height()))
716                .text_color(theme.text_muted)
717                .child(SharedString::from(format!("{number}.")))
718                .into_any_element(),
719            text,
720            body,
721            typography,
722            theme,
723            cx,
724        ),
725        BlockKind::Task { checked, text } => marker_row(
726            checkbox(*checked, overlay, typography, theme),
727            text,
728            body,
729            typography,
730            theme,
731            cx,
732        ),
733        BlockKind::Quote { kind, text } => div()
734            .border_l_2()
735            .border_color(kind.map_or(theme.border_strong, |kind| alert_color(kind, theme)))
736            .pl(px(12.0))
737            .pr(px(10.0))
738            .py(px(2.0))
739            .text_color(theme.text_muted)
740            .children(kind.map(|kind| {
741                div()
742                    .pb(px(2.0))
743                    .text_size(px(typography.body.size()))
744                    .line_height(px(typography.body.line_height()))
745                    .font_weight(FontWeight::SEMIBOLD)
746                    .text_color(alert_color(kind, theme))
747                    .child(kind.label())
748            }))
749            .child(text_element(
750                text,
751                typography.body.size(),
752                typography.body.line_height(),
753                FontWeight::NORMAL,
754                body,
755                theme,
756                cx,
757            ))
758            .into_any_element(),
759        BlockKind::Code { language, code } => {
760            let overlay = overlay.at(Part::Code);
761            // The caret in the fence gives the source back. A painted block is
762            // still an editable one, and typing into it otherwise edits what
763            // the reader cannot see.
764            let painted = overlay
765                .caret()
766                .is_none()
767                .then(|| block::render(language.as_deref(), &code.text, window, cx))
768                .flatten();
769            match painted {
770                // Painted, there is no text under the selection to carry it —
771                // the wash an opaque block gets at the container comes here.
772                Some(element) => div()
773                    .when(overlay.covers_block(), |el| {
774                        el.rounded(px(4.0)).bg(theme.selection)
775                    })
776                    .child(element)
777                    .into_any_element(),
778                None => code_block(
779                    language.as_deref(),
780                    &code.text,
781                    overlay,
782                    typography,
783                    theme,
784                    window,
785                    cx,
786                ),
787            }
788        }
789        BlockKind::Image { url, alt, width } => {
790            image(url, alt, *width, overlay, typography, theme, cx)
791        }
792        BlockKind::Bookmark { url, form } => {
793            bookmark(overlay.block, url, *form, typography, theme, cx)
794        }
795        BlockKind::Table {
796            align,
797            header,
798            rows,
799        } => table(align, header, rows, overlay, typography, theme, window, cx),
800        BlockKind::Rule => div()
801            .h(px(1.0))
802            .w_full()
803            .bg(theme.border)
804            .into_any_element(),
805    }
806}
807
808/// A real 5px disc rather than the "•" glyph, which reads too small at body size.
809fn disc(typography: &Typography, theme: &Theme) -> AnyElement {
810    div()
811        .flex_none()
812        .w(px(MARKER_WIDTH))
813        .h(px(typography.body.line_height()))
814        .flex()
815        .items_center()
816        .child(
817            div()
818                .ml(px(1.0))
819                .w(px(5.0))
820                .h(px(5.0))
821                .rounded_full()
822                .bg(theme.text_faint),
823        )
824        .into_any_element()
825}
826
827fn checkbox(checked: bool, overlay: Overlay, typography: &Typography, theme: &Theme) -> AnyElement {
828    let ix = overlay.block;
829    let mut box_ = div()
830        .relative()
831        .w(px(13.0))
832        .h(px(13.0))
833        .rounded(px(3.5))
834        .border_1()
835        .flex()
836        .items_center()
837        .justify_center();
838    box_ = if checked {
839        box_.bg(theme.solid)
840            .border_color(theme.solid)
841            .text_style(TextStyle::Caption)
842            .text_color(theme.on_solid)
843            .child("✓")
844    } else {
845        box_.border_color(theme.border_strong)
846    };
847    // The box's own bounds rather than the marker column's: a caller hit-tests
848    // these to tell a toggle from a caret placed in the gutter beside it.
849    box_ = box_.children(overlay.layouts.map(|layouts| {
850        let layouts = layouts.clone();
851        canvas(
852            move |bounds, _, _| layouts.record_checkbox(ix, bounds),
853            |_, _, _, _| (),
854        )
855        .absolute()
856        .size_full()
857    }));
858    // The cursor answers to either variant: a box an editor hit-tests is as
859    // pressable as one the renderer listens to, and only the pointer says so.
860    if overlay.toggle.is_some() {
861        box_ = box_.cursor_pointer();
862    }
863    if let Some(Toggle::Handled(toggle)) = overlay.toggle.cloned() {
864        box_ = box_.on_mouse_down(MouseButton::Left, move |_, window, cx| {
865            // Stopped, or the press goes on to whatever placed a caret
866            // under it and the toggle reads as a click that moved the
867            // caret as well.
868            cx.stop_propagation();
869            toggle(ix, window, cx);
870        });
871    }
872
873    div()
874        .flex_none()
875        .w(px(MARKER_WIDTH))
876        .h(px(typography.body.line_height()))
877        .flex()
878        .items_center()
879        .child(box_)
880        .into_any_element()
881}
882
883/// What an alert paints its rule and its label in.
884fn alert_color(kind: QuoteKind, theme: &Theme) -> Hsla {
885    match kind {
886        QuoteKind::Note => theme.accent,
887        QuoteKind::Tip => theme.success,
888        QuoteKind::Important => theme.busy,
889        QuoteKind::Warning => theme.warning,
890        QuoteKind::Caution => theme.danger,
891    }
892}
893
894fn marker_row(
895    marker: AnyElement,
896    text: &Text,
897    overlay: Overlay,
898    typography: &Typography,
899    theme: &Theme,
900    cx: &App,
901) -> AnyElement {
902    div()
903        .flex()
904        .flex_row()
905        .gap(px(MARKER_GAP))
906        .child(marker)
907        .child(div().flex_1().min_w_0().child(text_element(
908            text,
909            typography.body.size(),
910            typography.body.line_height(),
911            FontWeight::NORMAL,
912            overlay,
913            theme,
914            cx,
915        )))
916        .into_any_element()
917}