rich_text.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use anyhow::bail;
  4use futures::FutureExt;
  5use gpui::{
  6    elements::Text,
  7    fonts::{HighlightStyle, Underline, Weight},
  8    platform::{CursorStyle, MouseButton},
  9    AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
 10};
 11use language::{HighlightId, Language, LanguageRegistry};
 12use theme::{RichTextStyle, SyntaxTheme};
 13use util::RangeExt;
 14
 15#[derive(Debug, Clone, PartialEq, Eq)]
 16pub enum Highlight {
 17    Id(HighlightId),
 18    Highlight(HighlightStyle),
 19    Mention,
 20    SelfMention,
 21}
 22
 23impl From<HighlightStyle> for Highlight {
 24    fn from(style: HighlightStyle) -> Self {
 25        Self::Highlight(style)
 26    }
 27}
 28
 29impl From<HighlightId> for Highlight {
 30    fn from(style: HighlightId) -> Self {
 31        Self::Id(style)
 32    }
 33}
 34
 35#[derive(Debug, Clone)]
 36pub struct RichText {
 37    pub text: String,
 38    pub highlights: Vec<(Range<usize>, Highlight)>,
 39    pub region_ranges: Vec<Range<usize>>,
 40    pub regions: Vec<RenderedRegion>,
 41}
 42
 43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 44pub enum BackgroundKind {
 45    Code,
 46    /// A mention background for non-self user.
 47    Mention,
 48    SelfMention,
 49}
 50
 51#[derive(Debug, Clone, PartialEq, Eq)]
 52pub struct RenderedRegion {
 53    pub background_kind: Option<BackgroundKind>,
 54    pub link_url: Option<String>,
 55}
 56
 57/// Allows one to specify extra links to the rendered markdown, which can be used
 58/// for e.g. mentions.
 59pub struct Mention {
 60    pub range: Range<usize>,
 61    pub is_self_mention: bool,
 62}
 63
 64impl RichText {
 65    pub fn element<V: 'static>(
 66        &self,
 67        syntax: Arc<SyntaxTheme>,
 68        style: RichTextStyle,
 69        cx: &mut ViewContext<V>,
 70    ) -> AnyElement<V> {
 71        let mut region_id = 0;
 72        let view_id = cx.view_id();
 73
 74        let regions = self.regions.clone();
 75
 76        enum Markdown {}
 77        Text::new(self.text.clone(), style.text.clone())
 78            .with_highlights(
 79                self.highlights
 80                    .iter()
 81                    .filter_map(|(range, highlight)| {
 82                        let style = match highlight {
 83                            Highlight::Id(id) => id.style(&syntax)?,
 84                            Highlight::Highlight(style) => style.clone(),
 85                            Highlight::Mention => style.mention_highlight,
 86                            Highlight::SelfMention => style.self_mention_highlight,
 87                        };
 88                        Some((range.clone(), style))
 89                    })
 90                    .collect::<Vec<_>>(),
 91            )
 92            .with_custom_runs(self.region_ranges.clone(), move |ix, bounds, cx| {
 93                region_id += 1;
 94                let region = regions[ix].clone();
 95                if let Some(url) = region.link_url {
 96                    cx.scene().push_cursor_region(CursorRegion {
 97                        bounds,
 98                        style: CursorStyle::PointingHand,
 99                    });
100                    cx.scene().push_mouse_region(
101                        MouseRegion::new::<Markdown>(view_id, region_id, bounds)
102                            .on_click::<V, _>(MouseButton::Left, move |_, _, cx| {
103                                cx.platform().open_url(&url)
104                            }),
105                    );
106                }
107                if let Some(region_kind) = &region.background_kind {
108                    let background = match region_kind {
109                        BackgroundKind::Code => style.code_background,
110                        BackgroundKind::Mention => style.mention_background,
111                        BackgroundKind::SelfMention => style.self_mention_background,
112                    };
113                    if background.is_some() {
114                        cx.scene().push_quad(gpui::Quad {
115                            bounds,
116                            background,
117                            border: Default::default(),
118                            corner_radii: (2.0).into(),
119                        });
120                    }
121                }
122            })
123            .with_soft_wrap(true)
124            .into_any()
125    }
126
127    pub fn add_mention(
128        &mut self,
129        range: Range<usize>,
130        is_current_user: bool,
131        mention_style: HighlightStyle,
132    ) -> anyhow::Result<()> {
133        if range.end > self.text.len() {
134            bail!(
135                "Mention in range {range:?} is outside of bounds for a message of length {}",
136                self.text.len()
137            );
138        }
139
140        if is_current_user {
141            self.region_ranges.push(range.clone());
142            self.regions.push(RenderedRegion {
143                background_kind: Some(BackgroundKind::Mention),
144                link_url: None,
145            });
146        }
147        self.highlights
148            .push((range, Highlight::Highlight(mention_style)));
149        Ok(())
150    }
151}
152
153pub fn render_markdown_mut(
154    block: &str,
155    mut mentions: &[Mention],
156    language_registry: &Arc<LanguageRegistry>,
157    language: Option<&Arc<Language>>,
158    data: &mut RichText,
159) {
160    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
161
162    let mut bold_depth = 0;
163    let mut italic_depth = 0;
164    let mut link_url = None;
165    let mut current_language = None;
166    let mut list_stack = Vec::new();
167
168    let options = Options::all();
169    for (event, source_range) in Parser::new_ext(&block, options).into_offset_iter() {
170        let prev_len = data.text.len();
171        match event {
172            Event::Text(t) => {
173                if let Some(language) = &current_language {
174                    render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
175                } else {
176                    if let Some(mention) = mentions.first() {
177                        if source_range.contains_inclusive(&mention.range) {
178                            mentions = &mentions[1..];
179                            let range = (prev_len + mention.range.start - source_range.start)
180                                ..(prev_len + mention.range.end - source_range.start);
181                            data.highlights.push((
182                                range.clone(),
183                                if mention.is_self_mention {
184                                    Highlight::SelfMention
185                                } else {
186                                    Highlight::Mention
187                                },
188                            ));
189                            data.region_ranges.push(range);
190                            data.regions.push(RenderedRegion {
191                                background_kind: Some(if mention.is_self_mention {
192                                    BackgroundKind::SelfMention
193                                } else {
194                                    BackgroundKind::Mention
195                                }),
196                                link_url: None,
197                            });
198                        }
199                    }
200
201                    data.text.push_str(t.as_ref());
202                    let mut style = HighlightStyle::default();
203                    if bold_depth > 0 {
204                        style.weight = Some(Weight::BOLD);
205                    }
206                    if italic_depth > 0 {
207                        style.italic = Some(true);
208                    }
209                    if let Some(link_url) = link_url.clone() {
210                        data.region_ranges.push(prev_len..data.text.len());
211                        data.regions.push(RenderedRegion {
212                            link_url: Some(link_url),
213                            background_kind: None,
214                        });
215                        style.underline = Some(Underline {
216                            thickness: 1.0.into(),
217                            ..Default::default()
218                        });
219                    }
220
221                    if style != HighlightStyle::default() {
222                        let mut new_highlight = true;
223                        if let Some((last_range, last_style)) = data.highlights.last_mut() {
224                            if last_range.end == prev_len
225                                && last_style == &Highlight::Highlight(style)
226                            {
227                                last_range.end = data.text.len();
228                                new_highlight = false;
229                            }
230                        }
231                        if new_highlight {
232                            data.highlights
233                                .push((prev_len..data.text.len(), Highlight::Highlight(style)));
234                        }
235                    }
236                }
237            }
238            Event::Code(t) => {
239                data.text.push_str(t.as_ref());
240                data.region_ranges.push(prev_len..data.text.len());
241                if link_url.is_some() {
242                    data.highlights.push((
243                        prev_len..data.text.len(),
244                        Highlight::Highlight(HighlightStyle {
245                            underline: Some(Underline {
246                                thickness: 1.0.into(),
247                                ..Default::default()
248                            }),
249                            ..Default::default()
250                        }),
251                    ));
252                }
253                data.regions.push(RenderedRegion {
254                    background_kind: Some(BackgroundKind::Code),
255                    link_url: link_url.clone(),
256                });
257            }
258            Event::Start(tag) => match tag {
259                Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack),
260                Tag::Heading(_, _, _) => {
261                    new_paragraph(&mut data.text, &mut list_stack);
262                    bold_depth += 1;
263                }
264                Tag::CodeBlock(kind) => {
265                    new_paragraph(&mut data.text, &mut list_stack);
266                    current_language = if let CodeBlockKind::Fenced(language) = kind {
267                        language_registry
268                            .language_for_name(language.as_ref())
269                            .now_or_never()
270                            .and_then(Result::ok)
271                    } else {
272                        language.cloned()
273                    }
274                }
275                Tag::Emphasis => italic_depth += 1,
276                Tag::Strong => bold_depth += 1,
277                Tag::Link(_, url, _) => link_url = Some(url.to_string()),
278                Tag::List(number) => {
279                    list_stack.push((number, false));
280                }
281                Tag::Item => {
282                    let len = list_stack.len();
283                    if let Some((list_number, has_content)) = list_stack.last_mut() {
284                        *has_content = false;
285                        if !data.text.is_empty() && !data.text.ends_with('\n') {
286                            data.text.push('\n');
287                        }
288                        for _ in 0..len - 1 {
289                            data.text.push_str("  ");
290                        }
291                        if let Some(number) = list_number {
292                            data.text.push_str(&format!("{}. ", number));
293                            *number += 1;
294                            *has_content = false;
295                        } else {
296                            data.text.push_str("- ");
297                        }
298                    }
299                }
300                _ => {}
301            },
302            Event::End(tag) => match tag {
303                Tag::Heading(_, _, _) => bold_depth -= 1,
304                Tag::CodeBlock(_) => current_language = None,
305                Tag::Emphasis => italic_depth -= 1,
306                Tag::Strong => bold_depth -= 1,
307                Tag::Link(_, _, _) => link_url = None,
308                Tag::List(_) => drop(list_stack.pop()),
309                _ => {}
310            },
311            Event::HardBreak => data.text.push('\n'),
312            Event::SoftBreak => data.text.push(' '),
313            _ => {}
314        }
315    }
316}
317
318pub fn render_markdown(
319    block: String,
320    mentions: &[Mention],
321    language_registry: &Arc<LanguageRegistry>,
322    language: Option<&Arc<Language>>,
323) -> RichText {
324    let mut data = RichText {
325        text: Default::default(),
326        highlights: Default::default(),
327        region_ranges: Default::default(),
328        regions: Default::default(),
329    };
330
331    render_markdown_mut(&block, mentions, language_registry, language, &mut data);
332
333    data.text = data.text.trim().to_string();
334
335    data
336}
337
338pub fn render_code(
339    text: &mut String,
340    highlights: &mut Vec<(Range<usize>, Highlight)>,
341    content: &str,
342    language: &Arc<Language>,
343) {
344    let prev_len = text.len();
345    text.push_str(content);
346    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
347        highlights.push((
348            prev_len + range.start..prev_len + range.end,
349            Highlight::Id(highlight_id),
350        ));
351    }
352}
353
354pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
355    let mut is_subsequent_paragraph_of_list = false;
356    if let Some((_, has_content)) = list_stack.last_mut() {
357        if *has_content {
358            is_subsequent_paragraph_of_list = true;
359        } else {
360            *has_content = true;
361            return;
362        }
363    }
364
365    if !text.is_empty() {
366        if !text.ends_with('\n') {
367            text.push('\n');
368        }
369        text.push('\n');
370    }
371    for _ in 0..list_stack.len().saturating_sub(1) {
372        text.push_str("  ");
373    }
374    if is_subsequent_paragraph_of_list {
375        text.push_str("  ");
376    }
377}