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