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(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}