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