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