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