Skip to main content

markdown/
entity.rs

1//! `Markdown`, `MarkdownOptions`, `CodeBlockRenderer`, `EscapeAction`/
2//! `MarkdownEscaper`, `impl Markdown`, `impl Focusable for Markdown`,
3//! `SelectMode`, `Selection`, `ParsedMarkdown`, `AutoscrollBehavior`.
4//!
5//! Design notes (stem from this workspace's minimal `language` crate — see
6//! `base/crates/language/src/language.rs`'s module docs — having no
7//! `LanguageRegistry`/async loading/`LanguageName` types):
8//! - Mermaid support is dropped entirely; fenced ` ```mermaid ` blocks render
9//!   as plain code.
10//! - `Markdown::new`/`new_with_options` take no `language_registry`/
11//!   `fallback_code_block_language` parameters. Fenced code blocks resolve a
12//!   language synchronously at *render* time (see
13//!   [`resolve_code_block_language`]) against a process-wide
14//!   `LazyLock<DefaultLanguageRegistry>` — the same pattern
15//!   `base/crates/ui/src/components/code_editor.rs` uses — instead of an
16//!   async per-document `LanguageRegistry` lookup populated during
17//!   background parsing. `ParsedMarkdown` therefore has no
18//!   `languages_by_name`/`languages_by_path` maps.
19//! - `first_code_block_language` returns `Option<&'static language::Language>`
20//!   (resolved via the same static registry) rather than `Option<Arc<Language>>`.
21//! - `images_by_source_offset` is never populated from `data:` URL image
22//!   sources (that would require decoding with the `base64` crate, which is
23//!   not a workspace dependency here). Base64-embedded images fall through to
24//!   `MarkdownElement::image_resolver` like any other image source, same as
25//!   remote URLs.
26//! - `copied_code_blocks` (a "just copied, show a flash" `HashSet` used so the
27//!   copy button can briefly change icon) is dropped: this crate's
28//!   `crate::controls::CopyButton` (see its module docs — no `boltz-ui`
29//!   dependency allowed) has no `custom_on_click` hook to observe a
30//!   post-copy state transition.
31
32use std::borrow::Cow;
33use std::collections::BTreeMap;
34use std::ops::Range;
35use std::sync::{Arc, LazyLock};
36
37use collections::{HashMap, HashSet};
38use gpui::{
39    App, AppContext as _, ClipboardItem, Context, Div, FocusHandle, Focusable, Image, Pixels,
40    Point, ScrollHandle, SharedString, Task, Window, actions, point,
41};
42use language::{DefaultLanguageRegistry, Language, LanguageRegistry};
43
44use crate::element::AnyDiv;
45use crate::parser::{
46    CodeBlockKind, CodeBlockMetadata, MarkdownEvent, MarkdownTag, ParsedMetadataBlock,
47    parse_links_only, parse_markdown_with_options,
48};
49use crate::rendered::{RenderedFootnoteRef, RenderedLink, RenderedText};
50
51actions!(
52    markdown,
53    [
54        /// Copies the selected text to the clipboard.
55        Copy,
56        /// Copies the selected text as markdown to the clipboard.
57        CopyAsMarkdown
58    ]
59);
60
61/// Process-wide language registry used to resolve fenced code-block
62/// languages at render time (see module docs).
63static LANGUAGES: LazyLock<DefaultLanguageRegistry> = LazyLock::new(DefaultLanguageRegistry::new);
64
65/// Maps a handful of common markdown fence info-string spellings (language
66/// names, as opposed to file extensions) to the extension
67/// [`DefaultLanguageRegistry`] indexes by. Intentionally small — only the
68/// grammars `DefaultLanguageRegistry` ships are worth aliasing.
69fn language_name_to_extension(name: &str) -> Option<&'static str> {
70    const ALIASES: &[(&str, &str)] = &[
71        ("rust", "rs"),
72        ("rs", "rs"),
73        ("javascript", "js"),
74        ("js", "js"),
75        ("jsx", "js"),
76        ("mjs", "js"),
77        ("cjs", "js"),
78        ("typescript", "ts"),
79        ("ts", "ts"),
80        ("mts", "ts"),
81        ("cts", "ts"),
82        ("tsx", "tsx"),
83        ("json", "json"),
84        ("json5", "json"),
85        ("jsonc", "json"),
86        ("markdown", "md"),
87        ("md", "md"),
88    ];
89    ALIASES
90        .iter()
91        .find(|(alias, _)| name.eq_ignore_ascii_case(alias))
92        .map(|(_, extension)| *extension)
93}
94
95/// Resolves a fenced code block's language against the process-wide
96/// [`LANGUAGES`] registry (see module docs for why this is a synchronous
97/// lookup rather than an async per-document `LanguageRegistry` lookup).
98pub(crate) fn resolve_code_block_language(kind: &CodeBlockKind) -> Option<&'static Language> {
99    let extension = match kind {
100        CodeBlockKind::FencedLang(name) => language_name_to_extension(name.as_ref())?,
101        CodeBlockKind::FencedSrc(path_range) => std::path::Path::new(path_range.path.as_ref())
102            .extension()
103            .and_then(|extension| extension.to_str())?,
104        CodeBlockKind::Fenced | CodeBlockKind::Indented => return None,
105    };
106    LANGUAGES.language_for_extension(extension)
107}
108
109pub struct Markdown {
110    source: SharedString,
111    pub(crate) selection: Selection,
112    pub(crate) pressed_link: Option<RenderedLink>,
113    pub(crate) pressed_footnote_ref: Option<RenderedFootnoteRef>,
114    pub(crate) autoscroll_request: Option<usize>,
115    active_root_block: Option<usize>,
116    pub(crate) parsed_markdown: ParsedMarkdown,
117    pub(crate) images_by_source_offset: HashMap<usize, Arc<Image>>,
118    should_reparse: bool,
119    pending_parse: Option<Task<()>>,
120    pub(crate) focus_handle: FocusHandle,
121    pub(crate) options: MarkdownOptions,
122    wrapped_code_blocks: HashSet<usize>,
123    code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
124    context_menu_link: Option<SharedString>,
125    context_menu_selected_text: Option<SharedString>,
126    context_menu_selected_markdown: Option<SharedString>,
127    pub(crate) search_highlights: Vec<Range<usize>>,
128    pub(crate) active_search_highlight: Option<usize>,
129}
130
131#[derive(Clone, Copy, Default)]
132pub struct MarkdownOptions {
133    pub parse_links_only: bool,
134    pub parse_html: bool,
135    pub parse_heading_slugs: bool,
136    pub render_metadata_blocks: bool,
137}
138
139pub enum CodeBlockRenderer {
140    Default {
141        copy_button_visibility: crate::style::CopyButtonVisibility,
142        wrap_button_visibility: crate::style::WrapButtonVisibility,
143        border: bool,
144    },
145    Custom {
146        render: CodeBlockRenderFn,
147        /// A function that can modify the parent container after the code block
148        /// content has been appended as a child element.
149        transform: Option<CodeBlockTransformFn>,
150    },
151}
152
153pub type CodeBlockRenderFn = Arc<
154    dyn Fn(
155        &CodeBlockKind,
156        &ParsedMarkdown,
157        Range<usize>,
158        CodeBlockMetadata,
159        &mut Window,
160        &App,
161    ) -> Div,
162>;
163
164pub type CodeBlockTransformFn =
165    Arc<dyn Fn(AnyDiv, Range<usize>, CodeBlockMetadata, &mut Window, &App) -> AnyDiv>;
166
167enum EscapeAction {
168    PassThrough,
169    Nbsp(usize),
170    DoubleNewline,
171    PrefixBackslash,
172}
173
174impl EscapeAction {
175    fn output_len(&self, c: char) -> usize {
176        match self {
177            Self::PassThrough => c.len_utf8(),
178            Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(),
179            Self::DoubleNewline => 2,
180            Self::PrefixBackslash => '\\'.len_utf8() + c.len_utf8(),
181        }
182    }
183
184    fn write_to(&self, c: char, output: &mut String) {
185        match self {
186            Self::PassThrough => output.push(c),
187            Self::Nbsp(count) => {
188                for _ in 0..*count {
189                    output.push('\u{00A0}');
190                }
191            }
192            Self::DoubleNewline => {
193                output.push('\n');
194                output.push('\n');
195            }
196            Self::PrefixBackslash => {
197                // '\\' is a single backslash in Rust, e.g. '|' -> '\|'
198                output.push('\\');
199                output.push(c);
200            }
201        }
202    }
203}
204
205struct MarkdownEscaper {
206    in_leading_whitespace: bool,
207}
208
209impl MarkdownEscaper {
210    const TAB_SIZE: usize = 4;
211
212    fn new() -> Self {
213        Self {
214            in_leading_whitespace: true,
215        }
216    }
217
218    fn next(&mut self, c: char) -> EscapeAction {
219        let action = if self.in_leading_whitespace && c == '\t' {
220            EscapeAction::Nbsp(Self::TAB_SIZE)
221        } else if self.in_leading_whitespace && c == ' ' {
222            EscapeAction::Nbsp(1)
223        } else if c == '\n' {
224            EscapeAction::DoubleNewline
225        } else if c.is_ascii_punctuation() {
226            EscapeAction::PrefixBackslash
227        } else {
228            EscapeAction::PassThrough
229        };
230
231        self.in_leading_whitespace =
232            c == '\n' || (self.in_leading_whitespace && (c == ' ' || c == '\t'));
233        action
234    }
235}
236
237impl Markdown {
238    pub fn new(source: SharedString, cx: &mut Context<Self>) -> Self {
239        Self::new_with_options(source, MarkdownOptions::default(), cx)
240    }
241
242    pub fn new_with_options(
243        source: SharedString,
244        options: MarkdownOptions,
245        cx: &mut Context<Self>,
246    ) -> Self {
247        let focus_handle = cx.focus_handle();
248
249        let mut this = Self {
250            source,
251            selection: Selection::default(),
252            pressed_link: None,
253            pressed_footnote_ref: None,
254            autoscroll_request: None,
255            active_root_block: None,
256            should_reparse: false,
257            images_by_source_offset: Default::default(),
258            parsed_markdown: ParsedMarkdown::default(),
259            pending_parse: None,
260            focus_handle,
261            options,
262            wrapped_code_blocks: HashSet::default(),
263            code_block_scroll_handles: BTreeMap::default(),
264            context_menu_link: None,
265            context_menu_selected_text: None,
266            context_menu_selected_markdown: None,
267            search_highlights: Vec::new(),
268            active_search_highlight: None,
269        };
270        this.parse(cx);
271        this
272    }
273
274    pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
275        Self::new_with_options(
276            source,
277            MarkdownOptions {
278                parse_links_only: true,
279                ..Default::default()
280            },
281            cx,
282        )
283    }
284
285    pub(crate) fn is_code_block_wrapped(&self, id: usize) -> bool {
286        self.wrapped_code_blocks.contains(&id)
287    }
288
289    pub(crate) fn toggle_code_block_wrap(&mut self, id: usize) {
290        if !self.wrapped_code_blocks.remove(&id) {
291            self.wrapped_code_blocks.insert(id);
292        }
293    }
294
295    pub(crate) fn code_block_scroll_handle(&mut self, id: usize) -> Option<ScrollHandle> {
296        (!self.is_code_block_wrapped(id)).then(|| {
297            self.code_block_scroll_handles
298                .entry(id)
299                .or_insert_with(ScrollHandle::new)
300                .clone()
301        })
302    }
303
304    pub(crate) fn retain_code_block_scroll_handles(&mut self, ids: &HashSet<usize>) {
305        self.code_block_scroll_handles
306            .retain(|id, _| ids.contains(id));
307    }
308
309    pub(crate) fn clear_code_block_scroll_handles(&mut self) {
310        self.code_block_scroll_handles.clear();
311    }
312
313    pub(crate) fn autoscroll_code_block(
314        &self,
315        source_index: usize,
316        cursor_position: Point<Pixels>,
317    ) {
318        let Some((_, scroll_handle)) = self
319            .code_block_scroll_handles
320            .range(..=source_index)
321            .next_back()
322        else {
323            return;
324        };
325
326        let bounds = scroll_handle.bounds();
327        if cursor_position.y < bounds.top() || cursor_position.y > bounds.bottom() {
328            return;
329        }
330
331        let horizontal_delta = if cursor_position.x < bounds.left() {
332            bounds.left() - cursor_position.x
333        } else if cursor_position.x > bounds.right() {
334            bounds.right() - cursor_position.x
335        } else {
336            return;
337        };
338
339        let offset = scroll_handle.offset();
340        scroll_handle.set_offset(point(offset.x + horizontal_delta, offset.y));
341    }
342
343    pub fn is_parsing(&self) -> bool {
344        self.pending_parse.is_some()
345    }
346
347    pub fn scroll_to_heading(&mut self, slug: &str, cx: &mut Context<Self>) -> Option<usize> {
348        if let Some(source_index) = self.parsed_markdown.heading_slugs.get(slug).copied() {
349            self.autoscroll_request = Some(source_index);
350            cx.notify();
351            Some(source_index)
352        } else {
353            None
354        }
355    }
356
357    pub fn source(&self) -> &SharedString {
358        &self.source
359    }
360
361    /// Resolves the first fenced code block's language, if any (see module
362    /// docs for the static-registry resolution this uses instead of an
363    /// `Arc<Language>`-based per-document resolution).
364    pub fn first_code_block_language(&self) -> Option<&'static Language> {
365        self.parsed_markdown.events.iter().find_map(|(_, event)| {
366            let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) = event else {
367                return None;
368            };
369            resolve_code_block_language(kind)
370        })
371    }
372
373    pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
374        self.source = SharedString::new(self.source.to_string() + text);
375        self.parse(cx);
376    }
377
378    pub fn replace(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
379        self.source = source.into();
380        self.parse(cx);
381    }
382
383    pub fn request_autoscroll_to_source_index(
384        &mut self,
385        source_index: usize,
386        cx: &mut Context<Self>,
387    ) {
388        self.autoscroll_request = Some(source_index);
389        cx.refresh_windows();
390    }
391
392    pub(crate) fn footnote_definition_content_start(&self, label: &SharedString) -> Option<usize> {
393        self.parsed_markdown
394            .footnote_definitions
395            .get(label)
396            .copied()
397    }
398
399    pub fn set_active_root_for_source_index(
400        &mut self,
401        source_index: Option<usize>,
402        cx: &mut Context<Self>,
403    ) {
404        let active_root_block =
405            source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index));
406        if self.active_root_block == active_root_block {
407            return;
408        }
409
410        self.active_root_block = active_root_block;
411        cx.notify();
412    }
413
414    pub(crate) fn active_root_block(&self) -> Option<usize> {
415        self.active_root_block
416    }
417
418    pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
419        if &source == self.source() {
420            return;
421        }
422        self.source = source;
423        self.selection = Selection::default();
424        self.autoscroll_request = None;
425        self.pending_parse = None;
426        self.should_reparse = false;
427        self.search_highlights.clear();
428        self.active_search_highlight = None;
429        // Don't clear parsed_markdown here - keep existing content visible until new parse completes
430        self.parse(cx);
431    }
432
433    #[cfg(any(test, feature = "test-support"))]
434    pub fn parsed_markdown(&self) -> &ParsedMarkdown {
435        &self.parsed_markdown
436    }
437
438    pub fn escape(s: &str) -> Cow<'_, str> {
439        let output_len: usize = {
440            let mut escaper = MarkdownEscaper::new();
441            s.chars().map(|c| escaper.next(c).output_len(c)).sum()
442        };
443
444        if output_len == s.len() {
445            return s.into();
446        }
447
448        let mut escaper = MarkdownEscaper::new();
449        let mut output = String::with_capacity(output_len);
450        for c in s.chars() {
451            escaper.next(c).write_to(c, &mut output);
452        }
453        output.into()
454    }
455
456    pub fn selected_text(&self) -> Option<String> {
457        if self.selection.end <= self.selection.start {
458            None
459        } else {
460            Some(self.source[self.selection.start..self.selection.end].to_string())
461        }
462    }
463
464    pub fn set_search_highlights(
465        &mut self,
466        highlights: Vec<Range<usize>>,
467        active: Option<usize>,
468        cx: &mut Context<Self>,
469    ) {
470        debug_assert!(
471            highlights
472                .windows(2)
473                .all(|ranges| (ranges[0].start, ranges[0].end) <= (ranges[1].start, ranges[1].end))
474        );
475        self.search_highlights = highlights;
476        self.active_search_highlight =
477            active.filter(|active| *active < self.search_highlights.len());
478        cx.notify();
479    }
480
481    pub fn clear_search_highlights(&mut self, cx: &mut Context<Self>) {
482        if !self.search_highlights.is_empty() || self.active_search_highlight.is_some() {
483            self.search_highlights.clear();
484            self.active_search_highlight = None;
485            cx.notify();
486        }
487    }
488
489    pub fn set_active_search_highlight(&mut self, active: Option<usize>, cx: &mut Context<Self>) {
490        let active = active.filter(|active| *active < self.search_highlights.len());
491        if self.active_search_highlight != active {
492            self.active_search_highlight = active;
493            cx.notify();
494        }
495    }
496
497    pub fn search_highlights(&self) -> &[Range<usize>] {
498        &self.search_highlights
499    }
500
501    pub fn active_search_highlight(&self) -> Option<usize> {
502        self.active_search_highlight
503    }
504
505    pub(crate) fn copy(&self, text: &RenderedText, _: &mut Window, cx: &mut Context<Self>) {
506        if self.selection.end <= self.selection.start {
507            return;
508        }
509        let text = text.text_for_range(self.selection.start..self.selection.end);
510        cx.write_to_clipboard(ClipboardItem::new_string(text));
511    }
512
513    pub(crate) fn copy_as_markdown(&mut self, _: &mut Window, cx: &mut Context<Self>) {
514        if let Some(text) = self.context_menu_selected_markdown.take() {
515            cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
516            return;
517        }
518        if self.selection.end <= self.selection.start {
519            return;
520        }
521        let text = self.source[self.selection.start..self.selection.end].to_string();
522        cx.write_to_clipboard(ClipboardItem::new_string(text));
523    }
524
525    pub(crate) fn capture_for_context_menu(
526        &mut self,
527        link: Option<SharedString>,
528        rendered_text: Option<&RenderedText>,
529    ) {
530        let range = self.selection.start..self.selection.end;
531        if range.end > range.start {
532            self.context_menu_selected_markdown =
533                Some(SharedString::new(&self.source[range.clone()]));
534            self.context_menu_selected_text = rendered_text
535                .map(|text| text.text_for_range(range))
536                .map(SharedString::new)
537                .or_else(|| self.context_menu_selected_markdown.clone());
538        } else {
539            self.context_menu_selected_markdown = None;
540            self.context_menu_selected_text = None;
541        }
542        self.context_menu_link = link;
543    }
544
545    /// Returns the URL of the link that was most recently right-clicked, if any.
546    /// This is set during a right-click mouse-down event and can be read by parent
547    /// views to include a "Copy Link" item in their context menus.
548    pub fn context_menu_link(&self) -> Option<&SharedString> {
549        self.context_menu_link.as_ref()
550    }
551
552    /// Returns the rendered (plain) text that was selected when the most recent
553    /// context menu invocation happened.
554    pub fn context_menu_selected_text(&self) -> Option<&SharedString> {
555        self.context_menu_selected_text.as_ref()
556    }
557
558    /// Returns the raw markdown source that was selected when the most recent
559    /// context menu invocation happened.
560    pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> {
561        self.context_menu_selected_markdown.as_ref()
562    }
563
564    fn parse(&mut self, cx: &mut Context<Self>) {
565        if self.source.is_empty() {
566            self.should_reparse = false;
567            self.pending_parse.take();
568            self.parsed_markdown = ParsedMarkdown {
569                source: self.source.clone(),
570                ..Default::default()
571            };
572            self.active_root_block = None;
573            self.images_by_source_offset.clear();
574            cx.notify();
575            cx.refresh_windows();
576            return;
577        }
578
579        if self.pending_parse.is_some() {
580            self.should_reparse = true;
581            return;
582        }
583        self.should_reparse = false;
584        self.pending_parse = Some(self.start_background_parse(cx));
585    }
586
587    fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
588        let source = self.source.clone();
589        let should_parse_links_only = self.options.parse_links_only;
590        let should_parse_html = self.options.parse_html;
591        let should_parse_heading_slugs = self.options.parse_heading_slugs;
592        let should_parse_metadata_blocks = self.options.render_metadata_blocks;
593
594        let parsed = cx.background_spawn(async move {
595            if should_parse_links_only {
596                return ParsedMarkdown {
597                    events: Arc::from(parse_links_only(source.as_ref())),
598                    source,
599                    root_block_starts: Arc::default(),
600                    html_blocks: BTreeMap::default(),
601                    metadata_blocks: BTreeMap::default(),
602                    heading_slugs: HashMap::default(),
603                    footnote_definitions: HashMap::default(),
604                };
605            }
606
607            let parsed = parse_markdown_with_options(
608                &source,
609                should_parse_html,
610                should_parse_heading_slugs,
611                should_parse_metadata_blocks,
612            );
613
614            ParsedMarkdown {
615                source,
616                events: Arc::from(parsed.events),
617                root_block_starts: Arc::from(parsed.root_block_starts),
618                html_blocks: parsed.html_blocks,
619                metadata_blocks: parsed.metadata_blocks,
620                heading_slugs: parsed.heading_slugs,
621                footnote_definitions: parsed.footnote_definitions,
622            }
623        });
624
625        cx.spawn(async move |this, cx| {
626            let parsed = parsed.await;
627
628            this.update(cx, |this, cx| {
629                this.parsed_markdown = parsed;
630                if this.active_root_block.is_some_and(|block_index| {
631                    block_index >= this.parsed_markdown.root_block_starts.len()
632                }) {
633                    this.active_root_block = None;
634                }
635                this.pending_parse.take();
636                if this.should_reparse {
637                    this.parse(cx);
638                }
639                cx.notify();
640                cx.refresh_windows();
641            })
642            .ok();
643        })
644    }
645}
646
647impl Focusable for Markdown {
648    fn focus_handle(&self, _cx: &App) -> FocusHandle {
649        self.focus_handle.clone()
650    }
651}
652
653#[derive(Debug, Default, Clone)]
654pub(crate) enum SelectMode {
655    #[default]
656    Character,
657    Word(Range<usize>),
658    Line(Range<usize>),
659    All,
660}
661
662#[derive(Clone, Default)]
663pub(crate) struct Selection {
664    pub(crate) start: usize,
665    pub(crate) end: usize,
666    pub(crate) reversed: bool,
667    pub(crate) pending: bool,
668    pub(crate) mode: SelectMode,
669}
670
671impl Selection {
672    pub(crate) fn set_head(&mut self, head: usize, rendered_text: &RenderedText) {
673        match &self.mode {
674            SelectMode::Character => {
675                if head < self.tail() {
676                    if !self.reversed {
677                        self.end = self.start;
678                        self.reversed = true;
679                    }
680                    self.start = head;
681                } else {
682                    if self.reversed {
683                        self.start = self.end;
684                        self.reversed = false;
685                    }
686                    self.end = head;
687                }
688            }
689            SelectMode::Word(original_range) | SelectMode::Line(original_range) => {
690                let head_range = if matches!(self.mode, SelectMode::Word(_)) {
691                    rendered_text.surrounding_word_range(head)
692                } else {
693                    rendered_text.surrounding_line_range(head)
694                };
695
696                if head < original_range.start {
697                    self.start = head_range.start;
698                    self.end = original_range.end;
699                    self.reversed = true;
700                } else if head >= original_range.end {
701                    self.start = original_range.start;
702                    self.end = head_range.end;
703                    self.reversed = false;
704                } else {
705                    self.start = original_range.start;
706                    self.end = original_range.end;
707                    self.reversed = false;
708                }
709            }
710            SelectMode::All => {
711                self.start = 0;
712                self.end = rendered_text
713                    .lines
714                    .last()
715                    .map(|line| line.source_end)
716                    .unwrap_or(0);
717                self.reversed = false;
718            }
719        }
720    }
721
722    pub(crate) fn tail(&self) -> usize {
723        if self.reversed { self.end } else { self.start }
724    }
725}
726
727#[derive(Debug, Clone, Default)]
728pub struct ParsedMarkdown {
729    pub source: SharedString,
730    pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
731    pub root_block_starts: Arc<[usize]>,
732    pub(crate) html_blocks: BTreeMap<usize, crate::parser::html::html_parser::ParsedHtmlBlock>,
733    pub(crate) metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
734    pub heading_slugs: HashMap<SharedString, usize>,
735    pub footnote_definitions: HashMap<SharedString, usize>,
736}
737
738impl ParsedMarkdown {
739    pub fn source(&self) -> &SharedString {
740        &self.source
741    }
742
743    pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
744        &self.events
745    }
746
747    pub fn root_block_starts(&self) -> &Arc<[usize]> {
748        &self.root_block_starts
749    }
750
751    pub fn root_block_for_source_index(&self, source_index: usize) -> Option<usize> {
752        if self.root_block_starts.is_empty() {
753            return None;
754        }
755
756        let partition = self
757            .root_block_starts
758            .partition_point(|block_start| *block_start <= source_index);
759
760        Some(partition.saturating_sub(1))
761    }
762}
763
764pub enum AutoscrollBehavior {
765    /// Propagate the request up the element tree for the nearest
766    /// scrollable ancestor (e.g. `List`) to handle.
767    Propagate,
768    /// Directly control a specific scroll handle.
769    Controlled(ScrollHandle),
770}