Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:58:36.307Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

html_rendering.rs

665 lines · 22.0 KB · rust
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 &[start, end] in boundaries.array_windows::<2>() {
395            if start >= end {
396                continue;
397            }
398
399            let source_start = allocated_range.start + start.min(allocated_len);
400            let source_end = allocated_range.start + end.min(allocated_len);
401            if source_start >= source_end {
402                continue;
403            }
404
405            let mut refinement = TextStyleRefinement::default();
406            let mut has_refinement = false;
407
408            for (highlight_range, style) in &text.highlights {
409                if highlight_range.start < end && highlight_range.end > start {
410                    apply_html_highlight_style(&mut refinement, style);
411                    has_refinement = true;
412                }
413            }
414
415            let link = text.links.iter().find_map(|(link_range, link)| {
416                if link_range.start < end && link_range.end > start {
417                    Some(link.clone())
418                } else {
419                    None
420                }
421            });
422
423            if let Some(link) = link.as_ref() {
424                builder.push_link(link.clone(), source_start..source_end);
425                let link_style = self
426                    .style
427                    .link_callback
428                    .as_ref()
429                    .and_then(|callback| callback(link.as_ref(), cx))
430                    .unwrap_or_else(|| self.style.link.clone());
431                builder.push_text_style(link_style);
432            }
433
434            if has_refinement {
435                builder.push_text_style(refinement);
436            }
437
438            builder.push_text(&text_contents[start..end], source_start..source_end);
439
440            if has_refinement {
441                builder.pop_text_style();
442            }
443
444            if link.is_some() {
445                builder.pop_text_style();
446            }
447        }
448    }
449
450    fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
451        let Some(source) = self
452            .image_resolver
453            .as_ref()
454            .and_then(|resolve| resolve(image.dest_url.as_ref()))
455        else {
456            return;
457        };
458
459        self.push_markdown_image(
460            builder,
461            &image.source_range,
462            source,
463            image.dest_url.clone(),
464            image.alt_text.clone(),
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    use crate::WrapButtonVisibility;
563
564    fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
565        struct TestWindow;
566
567        impl Render for TestWindow {
568            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
569                div()
570            }
571        }
572
573        ensure_theme_initialized(cx);
574
575        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
576        let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
577        cx.run_until_parked();
578        let (rendered, _) = cx.draw(
579            Default::default(),
580            size(px(600.0), px(600.0)),
581            |_window, _cx| {
582                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
583                    CodeBlockRenderer::Default {
584                        copy_button_visibility: CopyButtonVisibility::Hidden,
585                        wrap_button_visibility: WrapButtonVisibility::Hidden,
586                        border: false,
587                    },
588                )
589            },
590        );
591        rendered.text
592    }
593
594    #[gpui::test]
595    fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
596        let rendered = render_markdown_text(
597            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
598            cx,
599        );
600
601        let rendered_lines = rendered
602            .lines
603            .iter()
604            .map(|line| line.layout.wrapped_text())
605            .collect::<Vec<_>>();
606
607        assert_eq!(
608            rendered_lines.concat().replace('\n', ""),
609            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
610        );
611    }
612
613    #[gpui::test]
614    fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
615        struct TestWindow;
616
617        impl Render for TestWindow {
618            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
619                div()
620            }
621        }
622
623        ensure_theme_initialized(cx);
624
625        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
626        let markdown = cx.new(|cx| {
627            Markdown::new_with_options(
628                "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
629                None,
630                None,
631                MarkdownOptions {
632                    parse_html: true,
633                    ..Default::default()
634                },
635                cx,
636            )
637        });
638        cx.run_until_parked();
639        let (rendered, _) = cx.draw(
640            Default::default(),
641            size(px(600.0), px(600.0)),
642            |_window, _cx| {
643                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
644                    CodeBlockRenderer::Default {
645                        copy_button_visibility: CopyButtonVisibility::Hidden,
646                        wrap_button_visibility: WrapButtonVisibility::Hidden,
647                        border: false,
648                    },
649                )
650            },
651        );
652
653        let rendered_lines = rendered
654            .text
655            .lines
656            .iter()
657            .map(|line| line.layout.wrapped_text())
658            .collect::<Vec<_>>();
659
660        assert_eq!(rendered_lines[0], "Hello");
661        assert_eq!(rendered_lines[1], "world");
662        assert!(rendered_lines.iter().any(|line| line.contains("item")));
663    }
664}
665
Served at tenant.openagents/omega Member data and write actions are omitted.