rich_text.rs

  1use futures::FutureExt;
  2use gpui::{
  3    AnyElement, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement,
  4    SharedString, StyledText, UnderlineStyle, WindowContext,
  5};
  6use language::{HighlightId, Language, LanguageRegistry};
  7use std::{ops::Range, sync::Arc};
  8use theme::ActiveTheme;
  9use util::RangeExt;
 10
 11#[derive(Debug, Clone, PartialEq, Eq)]
 12pub enum Highlight {
 13    Code,
 14    Id(HighlightId),
 15    Highlight(HighlightStyle),
 16    Mention,
 17    SelfMention,
 18}
 19
 20impl From<HighlightStyle> for Highlight {
 21    fn from(style: HighlightStyle) -> Self {
 22        Self::Highlight(style)
 23    }
 24}
 25
 26impl From<HighlightId> for Highlight {
 27    fn from(style: HighlightId) -> Self {
 28        Self::Id(style)
 29    }
 30}
 31
 32#[derive(Debug, Clone)]
 33pub struct RichText {
 34    pub text: SharedString,
 35    pub highlights: Vec<(Range<usize>, Highlight)>,
 36    pub link_ranges: Vec<Range<usize>>,
 37    pub link_urls: Arc<[String]>,
 38}
 39
 40/// Allows one to specify extra links to the rendered markdown, which can be used
 41/// for e.g. mentions.
 42#[derive(Debug)]
 43pub struct Mention {
 44    pub range: Range<usize>,
 45    pub is_self_mention: bool,
 46}
 47
 48impl RichText {
 49    pub fn element(&self, id: ElementId, cx: &mut WindowContext) -> AnyElement {
 50        let theme = cx.theme();
 51        let code_background = theme.colors().surface_background;
 52
 53        InteractiveText::new(
 54            id,
 55            StyledText::new(self.text.clone()).with_highlights(
 56                &cx.text_style(),
 57                self.highlights.iter().map(|(range, highlight)| {
 58                    (
 59                        range.clone(),
 60                        match highlight {
 61                            Highlight::Code => HighlightStyle {
 62                                background_color: Some(code_background),
 63                                ..Default::default()
 64                            },
 65                            Highlight::Id(id) => HighlightStyle {
 66                                background_color: Some(code_background),
 67                                ..id.style(&theme.syntax()).unwrap_or_default()
 68                            },
 69                            Highlight::Highlight(highlight) => *highlight,
 70                            Highlight::Mention => HighlightStyle {
 71                                font_weight: Some(FontWeight::BOLD),
 72                                ..Default::default()
 73                            },
 74                            Highlight::SelfMention => HighlightStyle {
 75                                font_weight: Some(FontWeight::BOLD),
 76                                ..Default::default()
 77                            },
 78                        },
 79                    )
 80                }),
 81            ),
 82        )
 83        .on_click(self.link_ranges.clone(), {
 84            let link_urls = self.link_urls.clone();
 85            move |ix, cx| cx.open_url(&link_urls[ix])
 86        })
 87        .into_any_element()
 88    }
 89}
 90
 91pub fn render_markdown_mut(
 92    block: &str,
 93    mut mentions: &[Mention],
 94    language_registry: &Arc<LanguageRegistry>,
 95    language: Option<&Arc<Language>>,
 96    text: &mut String,
 97    highlights: &mut Vec<(Range<usize>, Highlight)>,
 98    link_ranges: &mut Vec<Range<usize>>,
 99    link_urls: &mut Vec<String>,
100) {
101    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
102
103    let mut bold_depth = 0;
104    let mut italic_depth = 0;
105    let mut link_url = None;
106    let mut current_language = None;
107    let mut list_stack = Vec::new();
108
109    let options = Options::all();
110    for (event, source_range) in Parser::new_ext(&block, options).into_offset_iter() {
111        let prev_len = text.len();
112        match event {
113            Event::Text(t) => {
114                if let Some(language) = &current_language {
115                    render_code(text, highlights, t.as_ref(), language);
116                } else {
117                    while let Some(mention) = mentions.first() {
118                        if !source_range.contains_inclusive(&mention.range) {
119                            break;
120                        }
121                        mentions = &mentions[1..];
122                        let range = (prev_len + mention.range.start - source_range.start)
123                            ..(prev_len + mention.range.end - source_range.start);
124                        highlights.push((
125                            range.clone(),
126                            if mention.is_self_mention {
127                                Highlight::SelfMention
128                            } else {
129                                Highlight::Mention
130                            },
131                        ));
132                    }
133
134                    text.push_str(t.as_ref());
135                    let mut style = HighlightStyle::default();
136                    if bold_depth > 0 {
137                        style.font_weight = Some(FontWeight::BOLD);
138                    }
139                    if italic_depth > 0 {
140                        style.font_style = Some(FontStyle::Italic);
141                    }
142                    if let Some(link_url) = link_url.clone() {
143                        link_ranges.push(prev_len..text.len());
144                        link_urls.push(link_url);
145                        style.underline = Some(UnderlineStyle {
146                            thickness: 1.0.into(),
147                            ..Default::default()
148                        });
149                    }
150
151                    if style != HighlightStyle::default() {
152                        let mut new_highlight = true;
153                        if let Some((last_range, last_style)) = highlights.last_mut() {
154                            if last_range.end == prev_len
155                                && last_style == &Highlight::Highlight(style)
156                            {
157                                last_range.end = text.len();
158                                new_highlight = false;
159                            }
160                        }
161                        if new_highlight {
162                            highlights.push((prev_len..text.len(), Highlight::Highlight(style)));
163                        }
164                    }
165                }
166            }
167            Event::Code(t) => {
168                text.push_str(t.as_ref());
169                if link_url.is_some() {
170                    highlights.push((
171                        prev_len..text.len(),
172                        Highlight::Highlight(HighlightStyle {
173                            underline: Some(UnderlineStyle {
174                                thickness: 1.0.into(),
175                                ..Default::default()
176                            }),
177                            ..Default::default()
178                        }),
179                    ));
180                }
181                if let Some(link_url) = link_url.clone() {
182                    link_ranges.push(prev_len..text.len());
183                    link_urls.push(link_url);
184                }
185            }
186            Event::Start(tag) => match tag {
187                Tag::Paragraph => new_paragraph(text, &mut list_stack),
188                Tag::Heading(_, _, _) => {
189                    new_paragraph(text, &mut list_stack);
190                    bold_depth += 1;
191                }
192                Tag::CodeBlock(kind) => {
193                    new_paragraph(text, &mut list_stack);
194                    current_language = if let CodeBlockKind::Fenced(language) = kind {
195                        language_registry
196                            .language_for_name(language.as_ref())
197                            .now_or_never()
198                            .and_then(Result::ok)
199                    } else {
200                        language.cloned()
201                    }
202                }
203                Tag::Emphasis => italic_depth += 1,
204                Tag::Strong => bold_depth += 1,
205                Tag::Link(_, url, _) => link_url = Some(url.to_string()),
206                Tag::List(number) => {
207                    list_stack.push((number, false));
208                }
209                Tag::Item => {
210                    let len = list_stack.len();
211                    if let Some((list_number, has_content)) = list_stack.last_mut() {
212                        *has_content = false;
213                        if !text.is_empty() && !text.ends_with('\n') {
214                            text.push('\n');
215                        }
216                        for _ in 0..len - 1 {
217                            text.push_str("  ");
218                        }
219                        if let Some(number) = list_number {
220                            text.push_str(&format!("{}. ", number));
221                            *number += 1;
222                            *has_content = false;
223                        } else {
224                            text.push_str("- ");
225                        }
226                    }
227                }
228                _ => {}
229            },
230            Event::End(tag) => match tag {
231                Tag::Heading(_, _, _) => bold_depth -= 1,
232                Tag::CodeBlock(_) => current_language = None,
233                Tag::Emphasis => italic_depth -= 1,
234                Tag::Strong => bold_depth -= 1,
235                Tag::Link(_, _, _) => link_url = None,
236                Tag::List(_) => drop(list_stack.pop()),
237                _ => {}
238            },
239            Event::HardBreak => text.push('\n'),
240            Event::SoftBreak => text.push(' '),
241            _ => {}
242        }
243    }
244}
245
246pub fn render_markdown(
247    block: String,
248    mentions: &[Mention],
249    language_registry: &Arc<LanguageRegistry>,
250    language: Option<&Arc<Language>>,
251) -> RichText {
252    let mut text = String::new();
253    let mut highlights = Vec::new();
254    let mut link_ranges = Vec::new();
255    let mut link_urls = Vec::new();
256    render_markdown_mut(
257        &block,
258        mentions,
259        language_registry,
260        language,
261        &mut text,
262        &mut highlights,
263        &mut link_ranges,
264        &mut link_urls,
265    );
266    text.truncate(text.trim_end().len());
267
268    RichText {
269        text: SharedString::from(text),
270        link_urls: link_urls.into(),
271        link_ranges,
272        highlights,
273    }
274}
275
276pub fn render_code(
277    text: &mut String,
278    highlights: &mut Vec<(Range<usize>, Highlight)>,
279    content: &str,
280    language: &Arc<Language>,
281) {
282    let prev_len = text.len();
283    text.push_str(content);
284    let mut offset = 0;
285    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
286        if range.start > offset {
287            highlights.push((prev_len + offset..prev_len + range.start, Highlight::Code));
288        }
289        highlights.push((
290            prev_len + range.start..prev_len + range.end,
291            Highlight::Id(highlight_id),
292        ));
293        offset = range.end;
294    }
295    if offset < content.len() {
296        highlights.push((prev_len + offset..prev_len + content.len(), Highlight::Code));
297    }
298}
299
300pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
301    let mut is_subsequent_paragraph_of_list = false;
302    if let Some((_, has_content)) = list_stack.last_mut() {
303        if *has_content {
304            is_subsequent_paragraph_of_list = true;
305        } else {
306            *has_content = true;
307            return;
308        }
309    }
310
311    if !text.is_empty() {
312        if !text.ends_with('\n') {
313            text.push('\n');
314        }
315        text.push('\n');
316    }
317    for _ in 0..list_stack.len().saturating_sub(1) {
318        text.push_str("  ");
319    }
320    if is_subsequent_paragraph_of_list {
321        text.push_str("  ");
322    }
323}