Skip to repository content665 lines · 22.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:56:08.633Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
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 ¶graph.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