html_rendering.rs

  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                    &paragraph.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}