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