1use futures::FutureExt;
2use gpui::{
3 AnyElement, AnyView, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
4 IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, WindowContext,
5};
6use language::{HighlightId, Language, LanguageRegistry};
7use std::{ops::Range, sync::Arc};
8use theme::ActiveTheme;
9use ui::LinkPreview;
10use util::RangeExt;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Highlight {
14 Code,
15 Id(HighlightId),
16 InlineCode(bool),
17 Highlight(HighlightStyle),
18 Mention,
19 SelfMention,
20}
21
22impl From<HighlightStyle> for Highlight {
23 fn from(style: HighlightStyle) -> Self {
24 Self::Highlight(style)
25 }
26}
27
28impl From<HighlightId> for Highlight {
29 fn from(style: HighlightId) -> Self {
30 Self::Id(style)
31 }
32}
33
34#[derive(Clone)]
35pub struct RichText {
36 pub text: SharedString,
37 pub highlights: Vec<(Range<usize>, Highlight)>,
38 pub link_ranges: Vec<Range<usize>>,
39 pub link_urls: Arc<[String]>,
40
41 pub custom_ranges: Vec<Range<usize>>,
42 custom_ranges_tooltip_fn:
43 Option<Arc<dyn Fn(usize, Range<usize>, &mut WindowContext) -> Option<AnyView>>>,
44}
45
46/// Allows one to specify extra links to the rendered markdown, which can be used
47/// for e.g. mentions.
48#[derive(Debug)]
49pub struct Mention {
50 pub range: Range<usize>,
51 pub is_self_mention: bool,
52}
53
54impl RichText {
55 pub fn set_tooltip_builder_for_custom_ranges(
56 &mut self,
57 f: impl Fn(usize, Range<usize>, &mut WindowContext) -> Option<AnyView> + 'static,
58 ) {
59 self.custom_ranges_tooltip_fn = Some(Arc::new(f));
60 }
61
62 pub fn element(&self, id: ElementId, cx: &mut WindowContext) -> AnyElement {
63 let theme = cx.theme();
64 let code_background = theme.colors().surface_background;
65
66 InteractiveText::new(
67 id,
68 StyledText::new(self.text.clone()).with_highlights(
69 &cx.text_style(),
70 self.highlights.iter().map(|(range, highlight)| {
71 (
72 range.clone(),
73 match highlight {
74 Highlight::Code => HighlightStyle {
75 background_color: Some(code_background),
76 ..Default::default()
77 },
78 Highlight::Id(id) => HighlightStyle {
79 background_color: Some(code_background),
80 ..id.style(theme.syntax()).unwrap_or_default()
81 },
82 Highlight::InlineCode(link) => {
83 if *link {
84 HighlightStyle {
85 background_color: Some(code_background),
86 underline: Some(UnderlineStyle {
87 thickness: 1.0.into(),
88 ..Default::default()
89 }),
90 ..Default::default()
91 }
92 } else {
93 HighlightStyle {
94 background_color: Some(code_background),
95 ..Default::default()
96 }
97 }
98 }
99 Highlight::Highlight(highlight) => *highlight,
100 Highlight::Mention => HighlightStyle {
101 font_weight: Some(FontWeight::BOLD),
102 ..Default::default()
103 },
104 Highlight::SelfMention => HighlightStyle {
105 font_weight: Some(FontWeight::BOLD),
106 ..Default::default()
107 },
108 },
109 )
110 }),
111 ),
112 )
113 .on_click(self.link_ranges.clone(), {
114 let link_urls = self.link_urls.clone();
115 move |ix, cx| {
116 let url = &link_urls[ix];
117 if url.starts_with("http") {
118 cx.open_url(url);
119 }
120 }
121 })
122 .tooltip({
123 let link_ranges = self.link_ranges.clone();
124 let link_urls = self.link_urls.clone();
125 let custom_tooltip_ranges = self.custom_ranges.clone();
126 let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
127 move |idx, cx| {
128 for (ix, range) in link_ranges.iter().enumerate() {
129 if range.contains(&idx) {
130 return Some(LinkPreview::new(&link_urls[ix], cx));
131 }
132 }
133 for range in &custom_tooltip_ranges {
134 if range.contains(&idx) {
135 if let Some(f) = &custom_tooltip_fn {
136 return f(idx, range.clone(), cx);
137 }
138 }
139 }
140 None
141 }
142 })
143 .into_any_element()
144 }
145}
146
147#[allow(clippy::too_many_arguments)]
148pub fn render_markdown_mut(
149 block: &str,
150 mut mentions: &[Mention],
151 language_registry: &Arc<LanguageRegistry>,
152 language: Option<&Arc<Language>>,
153 text: &mut String,
154 highlights: &mut Vec<(Range<usize>, Highlight)>,
155 link_ranges: &mut Vec<Range<usize>>,
156 link_urls: &mut Vec<String>,
157) {
158 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
159
160 let mut bold_depth = 0;
161 let mut italic_depth = 0;
162 let mut strikethrough_depth = 0;
163 let mut link_url = None;
164 let mut current_language = None;
165 let mut list_stack = Vec::new();
166
167 let options = Options::all();
168 for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() {
169 let prev_len = text.len();
170 match event {
171 Event::Text(t) => {
172 if let Some(language) = ¤t_language {
173 render_code(text, highlights, t.as_ref(), language);
174 } else {
175 while let Some(mention) = mentions.first() {
176 if !source_range.contains_inclusive(&mention.range) {
177 break;
178 }
179 mentions = &mentions[1..];
180 let range = (prev_len + mention.range.start - source_range.start)
181 ..(prev_len + mention.range.end - source_range.start);
182 highlights.push((
183 range.clone(),
184 if mention.is_self_mention {
185 Highlight::SelfMention
186 } else {
187 Highlight::Mention
188 },
189 ));
190 }
191
192 text.push_str(t.as_ref());
193 let mut style = HighlightStyle::default();
194 if bold_depth > 0 {
195 style.font_weight = Some(FontWeight::BOLD);
196 }
197 if italic_depth > 0 {
198 style.font_style = Some(FontStyle::Italic);
199 }
200 if strikethrough_depth > 0 {
201 style.strikethrough = Some(StrikethroughStyle {
202 thickness: 1.0.into(),
203 ..Default::default()
204 });
205 }
206 let last_run_len = if let Some(link_url) = link_url.clone() {
207 link_ranges.push(prev_len..text.len());
208 link_urls.push(link_url);
209 style.underline = Some(UnderlineStyle {
210 thickness: 1.0.into(),
211 ..Default::default()
212 });
213 prev_len
214 } else {
215 // Manually scan for links
216 let mut finder = linkify::LinkFinder::new();
217 finder.kinds(&[linkify::LinkKind::Url]);
218 let mut last_link_len = prev_len;
219 for link in finder.links(&t) {
220 let start = link.start();
221 let end = link.end();
222 let range = (prev_len + start)..(prev_len + end);
223 link_ranges.push(range.clone());
224 link_urls.push(link.as_str().to_string());
225
226 // If there is a style before we match a link, we have to add this to the highlighted ranges
227 if style != HighlightStyle::default() && last_link_len < link.start() {
228 highlights.push((
229 last_link_len..link.start(),
230 Highlight::Highlight(style),
231 ));
232 }
233
234 highlights.push((
235 range,
236 Highlight::Highlight(HighlightStyle {
237 underline: Some(UnderlineStyle {
238 thickness: 1.0.into(),
239 ..Default::default()
240 }),
241 ..style
242 }),
243 ));
244
245 last_link_len = end;
246 }
247 last_link_len
248 };
249
250 if style != HighlightStyle::default() && last_run_len < text.len() {
251 let mut new_highlight = true;
252 if let Some((last_range, last_style)) = highlights.last_mut() {
253 if last_range.end == last_run_len
254 && last_style == &Highlight::Highlight(style)
255 {
256 last_range.end = text.len();
257 new_highlight = false;
258 }
259 }
260 if new_highlight {
261 highlights
262 .push((last_run_len..text.len(), Highlight::Highlight(style)));
263 }
264 }
265 }
266 }
267 Event::Code(t) => {
268 text.push_str(t.as_ref());
269 let is_link = link_url.is_some();
270
271 if let Some(link_url) = link_url.clone() {
272 link_ranges.push(prev_len..text.len());
273 link_urls.push(link_url);
274 }
275
276 highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
277 }
278 Event::Start(tag) => match tag {
279 Tag::Paragraph => new_paragraph(text, &mut list_stack),
280 Tag::Heading {
281 level: _,
282 id: _,
283 classes: _,
284 attrs: _,
285 } => {
286 new_paragraph(text, &mut list_stack);
287 bold_depth += 1;
288 }
289 Tag::CodeBlock(kind) => {
290 new_paragraph(text, &mut list_stack);
291 current_language = if let CodeBlockKind::Fenced(language) = kind {
292 language_registry
293 .language_for_name(language.as_ref())
294 .now_or_never()
295 .and_then(Result::ok)
296 } else {
297 language.cloned()
298 }
299 }
300 Tag::Emphasis => italic_depth += 1,
301 Tag::Strong => bold_depth += 1,
302 Tag::Strikethrough => strikethrough_depth += 1,
303 Tag::Link {
304 link_type: _,
305 dest_url,
306 title: _,
307 id: _,
308 } => link_url = Some(dest_url.to_string()),
309 Tag::List(number) => {
310 list_stack.push((number, false));
311 }
312 Tag::Item => {
313 let len = list_stack.len();
314 if let Some((list_number, has_content)) = list_stack.last_mut() {
315 *has_content = false;
316 if !text.is_empty() && !text.ends_with('\n') {
317 text.push('\n');
318 }
319 for _ in 0..len - 1 {
320 text.push_str(" ");
321 }
322 if let Some(number) = list_number {
323 text.push_str(&format!("{}. ", number));
324 *number += 1;
325 *has_content = false;
326 } else {
327 text.push_str("- ");
328 }
329 }
330 }
331 _ => {}
332 },
333 Event::End(tag) => match tag {
334 TagEnd::Heading(_) => bold_depth -= 1,
335 TagEnd::CodeBlock => current_language = None,
336 TagEnd::Emphasis => italic_depth -= 1,
337 TagEnd::Strong => bold_depth -= 1,
338 TagEnd::Strikethrough => strikethrough_depth -= 1,
339 TagEnd::Link => link_url = None,
340 TagEnd::List(_) => drop(list_stack.pop()),
341 _ => {}
342 },
343 Event::HardBreak => text.push('\n'),
344 Event::SoftBreak => text.push('\n'),
345 _ => {}
346 }
347 }
348}
349
350pub fn render_rich_text(
351 block: String,
352 mentions: &[Mention],
353 language_registry: &Arc<LanguageRegistry>,
354 language: Option<&Arc<Language>>,
355) -> RichText {
356 let mut text = String::new();
357 let mut highlights = Vec::new();
358 let mut link_ranges = Vec::new();
359 let mut link_urls = Vec::new();
360 render_markdown_mut(
361 &block,
362 mentions,
363 language_registry,
364 language,
365 &mut text,
366 &mut highlights,
367 &mut link_ranges,
368 &mut link_urls,
369 );
370 text.truncate(text.trim_end().len());
371
372 RichText {
373 text: SharedString::from(text),
374 link_urls: link_urls.into(),
375 link_ranges,
376 highlights,
377 custom_ranges: Vec::new(),
378 custom_ranges_tooltip_fn: None,
379 }
380}
381
382pub fn render_code(
383 text: &mut String,
384 highlights: &mut Vec<(Range<usize>, Highlight)>,
385 content: &str,
386 language: &Arc<Language>,
387) {
388 let prev_len = text.len();
389 text.push_str(content);
390 let mut offset = 0;
391 for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
392 if range.start > offset {
393 highlights.push((prev_len + offset..prev_len + range.start, Highlight::Code));
394 }
395 highlights.push((
396 prev_len + range.start..prev_len + range.end,
397 Highlight::Id(highlight_id),
398 ));
399 offset = range.end;
400 }
401 if offset < content.len() {
402 highlights.push((prev_len + offset..prev_len + content.len(), Highlight::Code));
403 }
404}
405
406pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
407 let mut is_subsequent_paragraph_of_list = false;
408 if let Some((_, has_content)) = list_stack.last_mut() {
409 if *has_content {
410 is_subsequent_paragraph_of_list = true;
411 } else {
412 *has_content = true;
413 return;
414 }
415 }
416
417 if !text.is_empty() {
418 if !text.ends_with('\n') {
419 text.push('\n');
420 }
421 text.push('\n');
422 }
423 for _ in 0..list_stack.len().saturating_sub(1) {
424 text.push_str(" ");
425 }
426 if is_subsequent_paragraph_of_list {
427 text.push_str(" ");
428 }
429}