html_rendering.rs

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