rich_text.rs

  1use std::{ops::Range, sync::Arc};
  2
  3use futures::FutureExt;
  4use gpui::{
  5    color::Color,
  6    elements::Text,
  7    fonts::{HighlightStyle, TextStyle, Underline, Weight},
  8    platform::{CursorStyle, MouseButton},
  9    AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
 10};
 11use language::{HighlightId, Language, LanguageRegistry};
 12use theme::SyntaxTheme;
 13
 14#[derive(Debug, Clone, PartialEq, Eq)]
 15pub enum Highlight {
 16    Id(HighlightId),
 17    Highlight(HighlightStyle),
 18}
 19
 20#[derive(Debug, Clone)]
 21pub struct RichText {
 22    pub text: String,
 23    pub highlights: Vec<(Range<usize>, Highlight)>,
 24    pub region_ranges: Vec<Range<usize>>,
 25    pub regions: Vec<RenderedRegion>,
 26}
 27
 28#[derive(Debug, Clone)]
 29pub struct RenderedRegion {
 30    code: bool,
 31    link_url: Option<String>,
 32}
 33
 34impl RichText {
 35    pub fn element<V: 'static>(
 36        &self,
 37        syntax: Arc<SyntaxTheme>,
 38        style: TextStyle,
 39        code_span_background_color: Color,
 40        cx: &mut ViewContext<V>,
 41    ) -> AnyElement<V> {
 42        let mut region_id = 0;
 43        let view_id = cx.view_id();
 44
 45        let regions = self.regions.clone();
 46
 47        enum Markdown {}
 48        Text::new(self.text.clone(), style.clone())
 49            .with_highlights(
 50                self.highlights
 51                    .iter()
 52                    .filter_map(|(range, highlight)| {
 53                        let style = match highlight {
 54                            Highlight::Id(id) => id.style(&syntax)?,
 55                            Highlight::Highlight(style) => style.clone(),
 56                        };
 57                        Some((range.clone(), style))
 58                    })
 59                    .collect::<Vec<_>>(),
 60            )
 61            .with_custom_runs(self.region_ranges.clone(), move |ix, bounds, cx| {
 62                region_id += 1;
 63                let region = regions[ix].clone();
 64                if let Some(url) = region.link_url {
 65                    cx.scene().push_cursor_region(CursorRegion {
 66                        bounds,
 67                        style: CursorStyle::PointingHand,
 68                    });
 69                    cx.scene().push_mouse_region(
 70                        MouseRegion::new::<Markdown>(view_id, region_id, bounds)
 71                            .on_click::<V, _>(MouseButton::Left, move |_, _, cx| {
 72                                cx.platform().open_url(&url)
 73                            }),
 74                    );
 75                }
 76                if region.code {
 77                    cx.scene().push_quad(gpui::Quad {
 78                        bounds,
 79                        background: Some(code_span_background_color),
 80                        border: Default::default(),
 81                        corner_radii: (2.0).into(),
 82                    });
 83                }
 84            })
 85            .with_soft_wrap(true)
 86            .into_any()
 87    }
 88}
 89
 90pub fn render_markdown_mut(
 91    block: &str,
 92    language_registry: &Arc<LanguageRegistry>,
 93    language: Option<&Arc<Language>>,
 94    data: &mut RichText,
 95) {
 96    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
 97
 98    let mut bold_depth = 0;
 99    let mut italic_depth = 0;
100    let mut link_url = None;
101    let mut current_language = None;
102    let mut list_stack = Vec::new();
103
104    for event in Parser::new_ext(&block, Options::all()) {
105        let prev_len = data.text.len();
106        match event {
107            Event::Text(t) => {
108                if let Some(language) = &current_language {
109                    render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
110                } else {
111                    data.text.push_str(t.as_ref());
112
113                    let mut style = HighlightStyle::default();
114                    if bold_depth > 0 {
115                        style.weight = Some(Weight::BOLD);
116                    }
117                    if italic_depth > 0 {
118                        style.italic = Some(true);
119                    }
120                    if let Some(link_url) = link_url.clone() {
121                        data.region_ranges.push(prev_len..data.text.len());
122                        data.regions.push(RenderedRegion {
123                            link_url: Some(link_url),
124                            code: false,
125                        });
126                        style.underline = Some(Underline {
127                            thickness: 1.0.into(),
128                            ..Default::default()
129                        });
130                    }
131
132                    if style != HighlightStyle::default() {
133                        let mut new_highlight = true;
134                        if let Some((last_range, last_style)) = data.highlights.last_mut() {
135                            if last_range.end == prev_len
136                                && last_style == &Highlight::Highlight(style)
137                            {
138                                last_range.end = data.text.len();
139                                new_highlight = false;
140                            }
141                        }
142                        if new_highlight {
143                            data.highlights
144                                .push((prev_len..data.text.len(), Highlight::Highlight(style)));
145                        }
146                    }
147                }
148            }
149            Event::Code(t) => {
150                data.text.push_str(t.as_ref());
151                data.region_ranges.push(prev_len..data.text.len());
152                if link_url.is_some() {
153                    data.highlights.push((
154                        prev_len..data.text.len(),
155                        Highlight::Highlight(HighlightStyle {
156                            underline: Some(Underline {
157                                thickness: 1.0.into(),
158                                ..Default::default()
159                            }),
160                            ..Default::default()
161                        }),
162                    ));
163                }
164                data.regions.push(RenderedRegion {
165                    code: true,
166                    link_url: link_url.clone(),
167                });
168            }
169            Event::Start(tag) => match tag {
170                Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack),
171                Tag::Heading(_, _, _) => {
172                    new_paragraph(&mut data.text, &mut list_stack);
173                    bold_depth += 1;
174                }
175                Tag::CodeBlock(kind) => {
176                    new_paragraph(&mut data.text, &mut list_stack);
177                    current_language = if let CodeBlockKind::Fenced(language) = kind {
178                        language_registry
179                            .language_for_name(language.as_ref())
180                            .now_or_never()
181                            .and_then(Result::ok)
182                    } else {
183                        language.cloned()
184                    }
185                }
186                Tag::Emphasis => italic_depth += 1,
187                Tag::Strong => bold_depth += 1,
188                Tag::Link(_, url, _) => link_url = Some(url.to_string()),
189                Tag::List(number) => {
190                    list_stack.push((number, false));
191                }
192                Tag::Item => {
193                    let len = list_stack.len();
194                    if let Some((list_number, has_content)) = list_stack.last_mut() {
195                        *has_content = false;
196                        if !data.text.is_empty() && !data.text.ends_with('\n') {
197                            data.text.push('\n');
198                        }
199                        for _ in 0..len - 1 {
200                            data.text.push_str("  ");
201                        }
202                        if let Some(number) = list_number {
203                            data.text.push_str(&format!("{}. ", number));
204                            *number += 1;
205                            *has_content = false;
206                        } else {
207                            data.text.push_str("- ");
208                        }
209                    }
210                }
211                _ => {}
212            },
213            Event::End(tag) => match tag {
214                Tag::Heading(_, _, _) => bold_depth -= 1,
215                Tag::CodeBlock(_) => current_language = None,
216                Tag::Emphasis => italic_depth -= 1,
217                Tag::Strong => bold_depth -= 1,
218                Tag::Link(_, _, _) => link_url = None,
219                Tag::List(_) => drop(list_stack.pop()),
220                _ => {}
221            },
222            Event::HardBreak => data.text.push('\n'),
223            Event::SoftBreak => data.text.push(' '),
224            _ => {}
225        }
226    }
227}
228
229pub fn render_markdown(
230    block: String,
231    language_registry: &Arc<LanguageRegistry>,
232    language: Option<&Arc<Language>>,
233) -> RichText {
234    let mut data = RichText {
235        text: Default::default(),
236        highlights: Default::default(),
237        region_ranges: Default::default(),
238        regions: Default::default(),
239    };
240
241    render_markdown_mut(&block, language_registry, language, &mut data);
242
243    data.text = data.text.trim().to_string();
244
245    data
246}
247
248pub fn render_code(
249    text: &mut String,
250    highlights: &mut Vec<(Range<usize>, Highlight)>,
251    content: &str,
252    language: &Arc<Language>,
253) {
254    let prev_len = text.len();
255    text.push_str(content);
256    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
257        highlights.push((
258            prev_len + range.start..prev_len + range.end,
259            Highlight::Id(highlight_id),
260        ));
261    }
262}
263
264pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
265    let mut is_subsequent_paragraph_of_list = false;
266    if let Some((_, has_content)) = list_stack.last_mut() {
267        if *has_content {
268            is_subsequent_paragraph_of_list = true;
269        } else {
270            *has_content = true;
271            return;
272        }
273    }
274
275    if !text.is_empty() {
276        if !text.ends_with('\n') {
277            text.push('\n');
278        }
279        text.push('\n');
280    }
281    for _ in 0..list_stack.len().saturating_sub(1) {
282        text.push_str("  ");
283    }
284    if is_subsequent_paragraph_of_list {
285        text.push_str("  ");
286    }
287}