1use std::ops::Range;
2
3use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle};
4use pulldown_cmark::Alignment;
5use ui::prelude::*;
6
7use crate::html::html_parser::{
8 HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock,
9 ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow,
10 ParsedHtmlText,
11};
12use crate::{MarkdownElement, MarkdownElementBuilder};
13
14pub(crate) struct HtmlSourceAllocator {
15 source_range: Range<usize>,
16 next_source_index: usize,
17}
18
19impl HtmlSourceAllocator {
20 pub(crate) fn new(source_range: Range<usize>) -> Self {
21 Self {
22 next_source_index: source_range.start,
23 source_range,
24 }
25 }
26
27 pub(crate) fn allocate(&mut self, requested_len: usize) -> Range<usize> {
28 let remaining = self.source_range.end.saturating_sub(self.next_source_index);
29 let len = requested_len.min(remaining);
30 let start = self.next_source_index;
31 let end = start + len;
32 self.next_source_index = end;
33 start..end
34 }
35}
36
37impl MarkdownElement {
38 pub(crate) fn render_html_block(
39 &self,
40 block: &ParsedHtmlBlock,
41 builder: &mut MarkdownElementBuilder,
42 markdown_end: usize,
43 cx: &mut App,
44 ) {
45 let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone());
46 self.render_html_elements(
47 &block.children,
48 &mut source_allocator,
49 builder,
50 markdown_end,
51 cx,
52 );
53 }
54
55 fn render_html_elements(
56 &self,
57 elements: &[ParsedHtmlElement],
58 source_allocator: &mut HtmlSourceAllocator,
59 builder: &mut MarkdownElementBuilder,
60 markdown_end: usize,
61 cx: &mut App,
62 ) {
63 for element in elements {
64 self.render_html_element(element, source_allocator, builder, markdown_end, cx);
65 }
66 }
67
68 fn render_html_element(
69 &self,
70 element: &ParsedHtmlElement,
71 source_allocator: &mut HtmlSourceAllocator,
72 builder: &mut MarkdownElementBuilder,
73 markdown_end: usize,
74 cx: &mut App,
75 ) {
76 let Some(source_range) = element.source_range() else {
77 return;
78 };
79
80 match element {
81 ParsedHtmlElement::Paragraph(paragraph) => {
82 self.push_markdown_paragraph(builder, &source_range, markdown_end);
83 self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end);
84 builder.pop_div();
85 }
86 ParsedHtmlElement::Heading(heading) => {
87 self.push_markdown_heading(
88 builder,
89 heading.level,
90 &heading.source_range,
91 markdown_end,
92 );
93 self.render_html_paragraph(
94 &heading.contents,
95 source_allocator,
96 builder,
97 cx,
98 markdown_end,
99 );
100 self.pop_markdown_heading(builder);
101 }
102 ParsedHtmlElement::List(list) => {
103 self.render_html_list(list, source_allocator, builder, markdown_end, cx);
104 }
105 ParsedHtmlElement::BlockQuote(block_quote) => {
106 self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end);
107 self.render_html_elements(
108 &block_quote.children,
109 source_allocator,
110 builder,
111 markdown_end,
112 cx,
113 );
114 self.pop_markdown_block_quote(builder);
115 }
116 ParsedHtmlElement::Table(table) => {
117 self.render_html_table(table, source_allocator, builder, markdown_end, cx);
118 }
119 ParsedHtmlElement::Image(image) => {
120 self.render_html_image(image, builder);
121 }
122 }
123 }
124
125 fn render_html_list(
126 &self,
127 list: &ParsedHtmlList,
128 source_allocator: &mut HtmlSourceAllocator,
129 builder: &mut MarkdownElementBuilder,
130 markdown_end: usize,
131 cx: &mut App,
132 ) {
133 builder.push_div(div().pl_2p5(), &list.source_range, markdown_end);
134
135 for list_item in &list.items {
136 let bullet = match list_item.item_type {
137 ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix(
138 order as usize,
139 list.ordered,
140 list.depth.saturating_sub(1) as usize,
141 ),
142 ParsedHtmlListItemType::Unordered => {
143 html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize)
144 }
145 };
146
147 self.push_markdown_list_item(
148 builder,
149 div().child(bullet).into_any_element(),
150 &list_item.source_range,
151 markdown_end,
152 );
153 self.render_html_elements(
154 &list_item.content,
155 source_allocator,
156 builder,
157 markdown_end,
158 cx,
159 );
160 self.pop_markdown_list_item(builder);
161 }
162
163 builder.pop_div();
164 }
165
166 fn render_html_table(
167 &self,
168 table: &ParsedHtmlTable,
169 source_allocator: &mut HtmlSourceAllocator,
170 builder: &mut MarkdownElementBuilder,
171 markdown_end: usize,
172 cx: &mut App,
173 ) {
174 if let Some(caption) = &table.caption {
175 builder.push_div(
176 div().when(!self.style.height_is_multiple_of_line_height, |el| {
177 el.mb_2().line_height(rems(1.3))
178 }),
179 &table.source_range,
180 markdown_end,
181 );
182 self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end);
183 builder.pop_div();
184 }
185
186 let actual_header_column_count = html_table_columns_count(&table.header);
187 let actual_body_column_count = html_table_columns_count(&table.body);
188 let max_column_count = actual_header_column_count.max(actual_body_column_count);
189
190 if max_column_count == 0 {
191 return;
192 }
193
194 let total_rows = table.header.len() + table.body.len();
195 let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
196
197 builder.push_div(
198 div()
199 .id(("html-table", table.source_range.start))
200 .grid()
201 .grid_cols(max_column_count as u16)
202 .when(self.style.table_columns_min_size, |this| {
203 this.grid_cols_min_content(max_column_count as u16)
204 })
205 .when(!self.style.table_columns_min_size, |this| {
206 this.grid_cols(max_column_count as u16)
207 })
208 .w_full()
209 .mb_2()
210 .border(px(1.5))
211 .border_color(cx.theme().colors().border)
212 .rounded_sm()
213 .overflow_hidden(),
214 &table.source_range,
215 markdown_end,
216 );
217
218 for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() {
219 let mut column_index = 0;
220
221 for cell in &row.columns {
222 while column_index < max_column_count && grid_occupied[row_index][column_index] {
223 column_index += 1;
224 }
225
226 if column_index >= max_column_count {
227 break;
228 }
229
230 let max_span = max_column_count.saturating_sub(column_index);
231 let mut cell_div = div()
232 .col_span(cell.col_span.min(max_span) as u16)
233 .row_span(cell.row_span.min(total_rows - row_index) as u16)
234 .when(column_index > 0, |this| this.border_l_1())
235 .when(row_index > 0, |this| this.border_t_1())
236 .border_color(cx.theme().colors().border)
237 .px_2()
238 .py_1()
239 .when(cell.is_header, |this| {
240 this.bg(cx.theme().colors().title_bar_background)
241 })
242 .when(!cell.is_header && row_index % 2 == 1, |this| {
243 this.bg(cx.theme().colors().panel_background)
244 });
245
246 cell_div = match cell.alignment {
247 Alignment::Center => cell_div.items_center(),
248 Alignment::Right => cell_div.items_end(),
249 _ => cell_div,
250 };
251
252 builder.push_div(cell_div, &table.source_range, markdown_end);
253 self.render_html_paragraph(
254 &cell.children,
255 source_allocator,
256 builder,
257 cx,
258 markdown_end,
259 );
260 builder.pop_div();
261
262 for row_offset in 0..cell.row_span {
263 for column_offset in 0..cell.col_span {
264 if row_index + row_offset < total_rows
265 && column_index + column_offset < max_column_count
266 {
267 grid_occupied[row_index + row_offset][column_index + column_offset] =
268 true;
269 }
270 }
271 }
272
273 column_index += cell.col_span;
274 }
275
276 while column_index < max_column_count {
277 if grid_occupied[row_index][column_index] {
278 column_index += 1;
279 continue;
280 }
281
282 builder.push_div(
283 div()
284 .when(column_index > 0, |this| this.border_l_1())
285 .when(row_index > 0, |this| this.border_t_1())
286 .border_color(cx.theme().colors().border)
287 .when(row_index % 2 == 1, |this| {
288 this.bg(cx.theme().colors().panel_background)
289 }),
290 &table.source_range,
291 markdown_end,
292 );
293 builder.pop_div();
294 column_index += 1;
295 }
296 }
297
298 builder.pop_div();
299 }
300
301 fn render_html_paragraph(
302 &self,
303 paragraph: &HtmlParagraph,
304 source_allocator: &mut HtmlSourceAllocator,
305 builder: &mut MarkdownElementBuilder,
306 cx: &mut App,
307 _markdown_end: usize,
308 ) {
309 for chunk in paragraph {
310 match chunk {
311 HtmlParagraphChunk::Text(text) => {
312 self.render_html_text(text, source_allocator, builder, cx);
313 }
314 HtmlParagraphChunk::Image(image) => {
315 self.render_html_image(image, builder);
316 }
317 }
318 }
319 }
320
321 fn render_html_text(
322 &self,
323 text: &ParsedHtmlText,
324 source_allocator: &mut HtmlSourceAllocator,
325 builder: &mut MarkdownElementBuilder,
326 cx: &mut App,
327 ) {
328 let text_contents = text.contents.as_ref();
329 if text_contents.is_empty() {
330 return;
331 }
332
333 let allocated_range = source_allocator.allocate(text_contents.len());
334 let allocated_len = allocated_range.end.saturating_sub(allocated_range.start);
335
336 let mut boundaries = vec![0, text_contents.len()];
337 for (range, _) in &text.highlights {
338 boundaries.push(range.start);
339 boundaries.push(range.end);
340 }
341 for (range, _) in &text.links {
342 boundaries.push(range.start);
343 boundaries.push(range.end);
344 }
345 boundaries.sort_unstable();
346 boundaries.dedup();
347
348 for segment in boundaries.windows(2) {
349 let start = segment[0];
350 let end = segment[1];
351 if start >= end {
352 continue;
353 }
354
355 let source_start = allocated_range.start + start.min(allocated_len);
356 let source_end = allocated_range.start + end.min(allocated_len);
357 if source_start >= source_end {
358 continue;
359 }
360
361 let mut refinement = TextStyleRefinement::default();
362 let mut has_refinement = false;
363
364 for (highlight_range, style) in &text.highlights {
365 if highlight_range.start < end && highlight_range.end > start {
366 apply_html_highlight_style(&mut refinement, style);
367 has_refinement = true;
368 }
369 }
370
371 let link = text.links.iter().find_map(|(link_range, link)| {
372 if link_range.start < end && link_range.end > start {
373 Some(link.clone())
374 } else {
375 None
376 }
377 });
378
379 if let Some(link) = link.as_ref() {
380 builder.push_link(link.clone(), source_start..source_end);
381 let link_style = self
382 .style
383 .link_callback
384 .as_ref()
385 .and_then(|callback| callback(link.as_ref(), cx))
386 .unwrap_or_else(|| self.style.link.clone());
387 builder.push_text_style(link_style);
388 }
389
390 if has_refinement {
391 builder.push_text_style(refinement);
392 }
393
394 builder.push_text(&text_contents[start..end], source_start..source_end);
395
396 if has_refinement {
397 builder.pop_text_style();
398 }
399
400 if link.is_some() {
401 builder.pop_text_style();
402 }
403 }
404 }
405
406 fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
407 let Some(source) = self
408 .image_resolver
409 .as_ref()
410 .and_then(|resolve| resolve(image.dest_url.as_ref()))
411 else {
412 return;
413 };
414
415 self.push_markdown_image(
416 builder,
417 &image.source_range,
418 source,
419 image.width,
420 image.height,
421 );
422 }
423}
424
425fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) {
426 if style.weight != FontWeight::default() {
427 refinement.font_weight = Some(style.weight);
428 }
429
430 if style.oblique {
431 refinement.font_style = Some(FontStyle::Oblique);
432 } else if style.italic {
433 refinement.font_style = Some(FontStyle::Italic);
434 }
435
436 if style.underline {
437 refinement.underline = Some(UnderlineStyle {
438 thickness: px(1.),
439 color: None,
440 ..Default::default()
441 });
442 }
443
444 if style.strikethrough {
445 refinement.strikethrough = Some(StrikethroughStyle {
446 thickness: px(1.),
447 color: None,
448 });
449 }
450}
451
452fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
453 let index = order.saturating_sub(1);
454 const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
455 const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
456 const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"];
457
458 if ordered {
459 match depth {
460 0 => format!("{}. ", order),
461 1 => format!(
462 "{}. ",
463 NUMBERED_PREFIXES_1
464 .chars()
465 .nth(index % NUMBERED_PREFIXES_1.len())
466 .unwrap()
467 ),
468 _ => format!(
469 "{}. ",
470 NUMBERED_PREFIXES_2
471 .chars()
472 .nth(index % NUMBERED_PREFIXES_2.len())
473 .unwrap()
474 ),
475 }
476 } else {
477 let depth = depth.min(BULLETS.len() - 1);
478 format!("{} ", BULLETS[depth])
479 }
480}
481
482fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize {
483 let mut actual_column_count = 0;
484 for row in rows {
485 actual_column_count = actual_column_count.max(
486 row.columns
487 .iter()
488 .map(|column| column.col_span)
489 .sum::<usize>(),
490 );
491 }
492 actual_column_count
493}
494
495#[cfg(test)]
496mod tests {
497 use gpui::{TestAppContext, size};
498 use ui::prelude::*;
499
500 use crate::{
501 CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
502 MarkdownStyle,
503 };
504
505 fn ensure_theme_initialized(cx: &mut TestAppContext) {
506 cx.update(|cx| {
507 if !cx.has_global::<settings::SettingsStore>() {
508 settings::init(cx);
509 }
510 if !cx.has_global::<theme::GlobalTheme>() {
511 theme_settings::init(theme::LoadThemes::JustBase, cx);
512 }
513 });
514 }
515
516 fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
517 struct TestWindow;
518
519 impl Render for TestWindow {
520 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
521 div()
522 }
523 }
524
525 ensure_theme_initialized(cx);
526
527 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
528 let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
529 cx.run_until_parked();
530 let (rendered, _) = cx.draw(
531 Default::default(),
532 size(px(600.0), px(600.0)),
533 |_window, _cx| {
534 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
535 CodeBlockRenderer::Default {
536 copy_button_visibility: CopyButtonVisibility::Hidden,
537 border: false,
538 },
539 )
540 },
541 );
542 rendered.text
543 }
544
545 #[gpui::test]
546 fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
547 let rendered = render_markdown_text(
548 "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
549 cx,
550 );
551
552 let rendered_lines = rendered
553 .lines
554 .iter()
555 .map(|line| line.layout.wrapped_text())
556 .collect::<Vec<_>>();
557
558 assert_eq!(
559 rendered_lines.concat().replace('\n', ""),
560 "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
561 );
562 }
563
564 #[gpui::test]
565 fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
566 struct TestWindow;
567
568 impl Render for TestWindow {
569 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
570 div()
571 }
572 }
573
574 ensure_theme_initialized(cx);
575
576 let (_, cx) = cx.add_window_view(|_, _| TestWindow);
577 let markdown = cx.new(|cx| {
578 Markdown::new_with_options(
579 "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
580 None,
581 None,
582 MarkdownOptions {
583 parse_html: true,
584 ..Default::default()
585 },
586 cx,
587 )
588 });
589 cx.run_until_parked();
590 let (rendered, _) = cx.draw(
591 Default::default(),
592 size(px(600.0), px(600.0)),
593 |_window, _cx| {
594 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
595 CodeBlockRenderer::Default {
596 copy_button_visibility: CopyButtonVisibility::Hidden,
597 border: false,
598 },
599 )
600 },
601 );
602
603 let rendered_lines = rendered
604 .text
605 .lines
606 .iter()
607 .map(|line| line.layout.wrapped_text())
608 .collect::<Vec<_>>();
609
610 assert_eq!(rendered_lines[0], "Hello");
611 assert_eq!(rendered_lines[1], "world");
612 assert!(rendered_lines.iter().any(|line| line.contains("item")));
613 }
614}