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) = ®ion.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) = ¤t_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}