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::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
501
502    fn ensure_theme_initialized(cx: &mut TestAppContext) {
503        cx.update(|cx| {
504            if !cx.has_global::<settings::SettingsStore>() {
505                settings::init(cx);
506            }
507            if !cx.has_global::<theme::GlobalTheme>() {
508                theme_settings::init(theme::LoadThemes::JustBase, cx);
509            }
510        });
511    }
512
513    fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
514        struct TestWindow;
515
516        impl Render for TestWindow {
517            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
518                div()
519            }
520        }
521
522        ensure_theme_initialized(cx);
523
524        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
525        let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
526        cx.run_until_parked();
527        let (rendered, _) = cx.draw(
528            Default::default(),
529            size(px(600.0), px(600.0)),
530            |_window, _cx| {
531                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
532                    CodeBlockRenderer::Default {
533                        copy_button: false,
534                        copy_button_on_hover: false,
535                        border: false,
536                    },
537                )
538            },
539        );
540        rendered.text
541    }
542
543    #[gpui::test]
544    fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
545        let rendered = render_markdown_text(
546            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
547            cx,
548        );
549
550        let rendered_lines = rendered
551            .lines
552            .iter()
553            .map(|line| line.layout.wrapped_text())
554            .collect::<Vec<_>>();
555
556        assert_eq!(
557            rendered_lines.concat().replace('\n', ""),
558            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
559        );
560    }
561
562    #[gpui::test]
563    fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
564        struct TestWindow;
565
566        impl Render for TestWindow {
567            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
568                div()
569            }
570        }
571
572        ensure_theme_initialized(cx);
573
574        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
575        let markdown = cx.new(|cx| {
576            Markdown::new_with_options(
577                "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
578                None,
579                None,
580                MarkdownOptions {
581                    parse_html: true,
582                    ..Default::default()
583                },
584                cx,
585            )
586        });
587        cx.run_until_parked();
588        let (rendered, _) = cx.draw(
589            Default::default(),
590            size(px(600.0), px(600.0)),
591            |_window, _cx| {
592                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
593                    CodeBlockRenderer::Default {
594                        copy_button: false,
595                        copy_button_on_hover: false,
596                        border: false,
597                    },
598                )
599            },
600        );
601
602        let rendered_lines = rendered
603            .text
604            .lines
605            .iter()
606            .map(|line| line.layout.wrapped_text())
607            .collect::<Vec<_>>();
608
609        assert_eq!(rendered_lines[0], "Hello");
610        assert_eq!(rendered_lines[1], "world");
611        assert!(rendered_lines.iter().any(|line| line.contains("item")));
612    }
613}