You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							273 lines
						
					
					
						
							9.7 KiB
						
					
					
				
			
		
		
	
	
							273 lines
						
					
					
						
							9.7 KiB
						
					
					
				| import re
 | |
| import pyray as rl
 | |
| from dataclasses import dataclass
 | |
| from enum import Enum
 | |
| from typing import Any
 | |
| from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
 | |
| from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
 | |
| from openpilot.system.ui.lib.wrap_text import wrap_text
 | |
| from openpilot.system.ui.widgets import Widget
 | |
| from openpilot.system.ui.widgets.button import Button, ButtonStyle
 | |
| from openpilot.system.ui.lib.text_measure import measure_text_cached
 | |
| 
 | |
| LIST_INDENT_PX = 40
 | |
| 
 | |
| 
 | |
| class ElementType(Enum):
 | |
|   H1 = "h1"
 | |
|   H2 = "h2"
 | |
|   H3 = "h3"
 | |
|   H4 = "h4"
 | |
|   H5 = "h5"
 | |
|   H6 = "h6"
 | |
|   P = "p"
 | |
|   B = "b"
 | |
|   UL = "ul"
 | |
|   LI = "li"
 | |
|   BR = "br"
 | |
| 
 | |
| 
 | |
| TAG_NAMES = '|'.join([t.value for t in ElementType])
 | |
| START_TAG_RE = re.compile(f'<({TAG_NAMES})>')
 | |
| END_TAG_RE = re.compile(f'</({TAG_NAMES})>')
 | |
| 
 | |
| 
 | |
| def is_tag(token: str) -> tuple[bool, bool, ElementType | None]:
 | |
|   supported_tag = bool(START_TAG_RE.fullmatch(token))
 | |
|   supported_end_tag = bool(END_TAG_RE.fullmatch(token))
 | |
|   tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None
 | |
|   return supported_tag, supported_end_tag, tag
 | |
| 
 | |
| 
 | |
| @dataclass
 | |
| class HtmlElement:
 | |
|   type: ElementType
 | |
|   content: str
 | |
|   font_size: int
 | |
|   font_weight: FontWeight
 | |
|   margin_top: int
 | |
|   margin_bottom: int
 | |
|   line_height: float = 0.9  # matches Qt visually, unsure why not default 1.2
 | |
|   indent_level: int = 0
 | |
| 
 | |
| 
 | |
| class HtmlRenderer(Widget):
 | |
|   def __init__(self, file_path: str | None = None, text: str | None = None,
 | |
|                text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False):
 | |
|     super().__init__()
 | |
|     self._text_color = text_color
 | |
|     self._center_text = center_text
 | |
|     self._normal_font = gui_app.font(FontWeight.NORMAL)
 | |
|     self._bold_font = gui_app.font(FontWeight.BOLD)
 | |
|     self._indent_level = 0
 | |
| 
 | |
|     if text_size is None:
 | |
|       text_size = {}
 | |
| 
 | |
|     # Base paragraph size (Qt stylesheet default is 48px in offroad alerts)
 | |
|     base_p_size = int(text_size.get(ElementType.P, 48))
 | |
| 
 | |
|     # Untagged text defaults to <p>
 | |
|     self.styles: dict[ElementType, dict[str, Any]] = {
 | |
|       ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16},
 | |
|       ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12},
 | |
|       ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10},
 | |
|       ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8},
 | |
|       ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6},
 | |
|       ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4},
 | |
|       ElementType.P: {"size": base_p_size, "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12},
 | |
|       ElementType.B: {"size": base_p_size, "weight": FontWeight.BOLD, "margin_top": 8, "margin_bottom": 12},
 | |
|       ElementType.LI: {"size": base_p_size, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6},
 | |
|       ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "margin_top": 0, "margin_bottom": 12},
 | |
|     }
 | |
| 
 | |
|     self.elements: list[HtmlElement] = []
 | |
|     if file_path is not None:
 | |
|       self.parse_html_file(file_path)
 | |
|     elif text is not None:
 | |
|       self.parse_html_content(text)
 | |
|     else:
 | |
|       raise ValueError("Either file_path or text must be provided")
 | |
| 
 | |
|   def parse_html_file(self, file_path: str) -> None:
 | |
|     with open(file_path, encoding='utf-8') as file:
 | |
|       content = file.read()
 | |
|     self.parse_html_content(content)
 | |
| 
 | |
|   def parse_html_content(self, html_content: str) -> None:
 | |
|     self.elements.clear()
 | |
| 
 | |
|     # Remove HTML comments
 | |
|     html_content = re.sub(r'<!--.*?-->', '', html_content, flags=re.DOTALL)
 | |
| 
 | |
|     # Remove DOCTYPE, html, head, body tags but keep their content
 | |
|     html_content = re.sub(r'<!DOCTYPE[^>]*>', '', html_content)
 | |
|     html_content = re.sub(r'</?(?:html|head|body)[^>]*>', '', html_content)
 | |
| 
 | |
|     # Parse HTML
 | |
|     tokens = re.findall(r'</[^>]+>|<[^>]+>|[^<\s]+', html_content)
 | |
| 
 | |
|     def close_tag():
 | |
|       nonlocal current_content
 | |
|       nonlocal current_tag
 | |
| 
 | |
|       # If no tag is set, default to paragraph so we don't lose text
 | |
|       if current_tag is None:
 | |
|         current_tag = ElementType.P
 | |
| 
 | |
|       text = ' '.join(current_content).strip()
 | |
|       current_content = []
 | |
|       if text:
 | |
|         if current_tag == ElementType.LI:
 | |
|           text = '• ' + text
 | |
|         self._add_element(current_tag, text)
 | |
| 
 | |
|     current_content: list[str] = []
 | |
|     current_tag: ElementType | None = None
 | |
|     for token in tokens:
 | |
|       is_start_tag, is_end_tag, tag = is_tag(token)
 | |
|       if tag is not None:
 | |
|         if tag == ElementType.BR:
 | |
|           # Close current tag and add a line break
 | |
|           close_tag()
 | |
|           self._add_element(ElementType.BR, "")
 | |
| 
 | |
|         elif is_start_tag or is_end_tag:
 | |
|           # Always add content regardless of opening or closing tag
 | |
|           close_tag()
 | |
| 
 | |
|           if is_start_tag:
 | |
|             current_tag = tag
 | |
|           else:
 | |
|             current_tag = None
 | |
| 
 | |
|         # increment after we add the content for the current tag
 | |
|         if tag == ElementType.UL:
 | |
|           self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1)
 | |
| 
 | |
|       else:
 | |
|         current_content.append(token)
 | |
| 
 | |
|     if current_content:
 | |
|       close_tag()
 | |
| 
 | |
|   def _add_element(self, element_type: ElementType, content: str) -> None:
 | |
|     style = self.styles[element_type]
 | |
| 
 | |
|     element = HtmlElement(
 | |
|       type=element_type,
 | |
|       content=content,
 | |
|       font_size=style["size"],
 | |
|       font_weight=style["weight"],
 | |
|       margin_top=style["margin_top"],
 | |
|       margin_bottom=style["margin_bottom"],
 | |
|       indent_level=self._indent_level,
 | |
|     )
 | |
| 
 | |
|     self.elements.append(element)
 | |
| 
 | |
|   def _render(self, rect: rl.Rectangle):
 | |
|     # TODO: speed up by removing duplicate calculations across renders
 | |
|     current_y = rect.y
 | |
|     padding = 20
 | |
|     content_width = rect.width - (padding * 2)
 | |
| 
 | |
|     for element in self.elements:
 | |
|       if element.type == ElementType.BR:
 | |
|         current_y += element.margin_bottom
 | |
|         continue
 | |
| 
 | |
|       current_y += element.margin_top
 | |
|       if current_y > rect.y + rect.height:
 | |
|         break
 | |
| 
 | |
|       if element.content:
 | |
|         font = self._get_font(element.font_weight)
 | |
|         wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
 | |
| 
 | |
|         for line in wrapped_lines:
 | |
|           # Use FONT_SCALE from wrapped raylib text functions to match what is drawn
 | |
|           if current_y < rect.y - element.font_size * FONT_SCALE:
 | |
|             current_y += element.font_size * FONT_SCALE * element.line_height
 | |
|             continue
 | |
| 
 | |
|           if current_y > rect.y + rect.height:
 | |
|             break
 | |
| 
 | |
|           if self._center_text:
 | |
|             text_width = measure_text_cached(font, line, element.font_size).x
 | |
|             text_x = rect.x + (rect.width - text_width) / 2
 | |
|           else:  # left align
 | |
|             text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX)
 | |
| 
 | |
|           rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color)
 | |
| 
 | |
|           current_y += element.font_size * FONT_SCALE * element.line_height
 | |
| 
 | |
|       # Apply bottom margin
 | |
|       current_y += element.margin_bottom
 | |
| 
 | |
|     return current_y - rect.y
 | |
| 
 | |
|   def get_total_height(self, content_width: int) -> float:
 | |
|     total_height = 0.0
 | |
|     padding = 20
 | |
|     usable_width = content_width - (padding * 2)
 | |
| 
 | |
|     for element in self.elements:
 | |
|       if element.type == ElementType.BR:
 | |
|         total_height += element.margin_bottom
 | |
|         continue
 | |
| 
 | |
|       total_height += element.margin_top
 | |
| 
 | |
|       if element.content:
 | |
|         font = self._get_font(element.font_weight)
 | |
|         wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
 | |
| 
 | |
|         for _ in wrapped_lines:
 | |
|           total_height += element.font_size * FONT_SCALE * element.line_height
 | |
| 
 | |
|       total_height += element.margin_bottom
 | |
| 
 | |
|     return total_height
 | |
| 
 | |
|   def _get_font(self, weight: FontWeight):
 | |
|     if weight == FontWeight.BOLD:
 | |
|       return self._bold_font
 | |
|     return self._normal_font
 | |
| 
 | |
| 
 | |
| class HtmlModal(Widget):
 | |
|   def __init__(self, file_path: str | None = None, text: str | None = None):
 | |
|     super().__init__()
 | |
|     self._content = HtmlRenderer(file_path=file_path, text=text)
 | |
|     self._scroll_panel = GuiScrollPanel()
 | |
|     self._ok_button = Button("OK", click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY)
 | |
| 
 | |
|   def _render(self, rect: rl.Rectangle):
 | |
|     margin = 50
 | |
|     content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2))
 | |
| 
 | |
|     button_height = 160
 | |
|     button_spacing = 20
 | |
|     scrollable_height = content_rect.height - button_height - button_spacing
 | |
| 
 | |
|     scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height)
 | |
| 
 | |
|     total_height = self._content.get_total_height(int(scrollable_rect.width))
 | |
|     scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height)
 | |
|     scroll_offset = self._scroll_panel.update(scrollable_rect, scroll_content_rect)
 | |
|     scroll_content_rect.y += scroll_offset
 | |
| 
 | |
|     rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
 | |
|     self._content.render(scroll_content_rect)
 | |
|     rl.end_scissor_mode()
 | |
| 
 | |
|     button_width = (rect.width - 3 * 50) // 3
 | |
|     button_x = content_rect.x + content_rect.width - button_width
 | |
|     button_y = content_rect.y + content_rect.height - button_height
 | |
|     button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
 | |
|     self._ok_button.render(button_rect)
 | |
| 
 | |
|     return -1
 | |
| 
 |