diff --git a/system/ui/widgets/html_render.py b/system/ui/widgets/html_render.py index db30833f19..b032df4d9f 100644 --- a/system/ui/widgets/html_render.py +++ b/system/ui/widgets/html_render.py @@ -9,6 +9,8 @@ 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 +LIST_INDENT_PX = 40 + class ElementType(Enum): H1 = "h1" @@ -18,39 +20,60 @@ class ElementType(Enum): H5 = "h5" H6 = "h6" P = "p" + 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'') + + +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 - color: rl.Color margin_top: int margin_bottom: int line_height: float = 1.2 + indent_level: int = 0 class HtmlRenderer(Widget): - def __init__(self, file_path: str | None = None, text: str | None = None): + def __init__(self, file_path: str | None = None, text: str | None = None, + text_size: dict | None = None, text_color: rl.Color = rl.WHITE): super().__init__() - self.elements: list[HtmlElement] = [] + self._text_color = text_color 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 = {} + + # Untagged text defaults to

self.styles: dict[ElementType, dict[str, Any]] = { - ElementType.H1: {"size": 68, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 16}, - ElementType.H2: {"size": 60, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 24, "margin_bottom": 12}, - ElementType.H3: {"size": 52, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 10}, - ElementType.H4: {"size": 48, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 16, "margin_bottom": 8}, - ElementType.H5: {"size": 44, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 12, "margin_bottom": 6}, - ElementType.H6: {"size": 40, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 10, "margin_bottom": 4}, - ElementType.P: {"size": 38, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 8, "margin_bottom": 12}, - ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "color": rl.BLACK, "margin_top": 0, "margin_bottom": 12}, + ElementType.H1: {"size": 68, "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16}, + ElementType.H2: {"size": 60, "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12}, + ElementType.H3: {"size": 52, "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10}, + ElementType.H4: {"size": 48, "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8}, + ElementType.H5: {"size": 44, "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6}, + ElementType.H6: {"size": 40, "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4}, + ElementType.P: {"size": text_size.get(ElementType.P, 38), "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12}, + 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: @@ -73,25 +96,48 @@ class HtmlRenderer(Widget): html_content = re.sub(r']*>', '', html_content) html_content = re.sub(r']*>', '', html_content) - # Find all HTML elements - pattern = r'<(h[1-6]|p)(?:[^>]*)>(.*?)|' - matches = re.finditer(pattern, html_content, re.DOTALL | re.IGNORECASE) + # Parse HTML + tokens = re.findall(r']+>|<[^>]+>|[^<\s]+', html_content) - for match in matches: - if match.group(0).lower().startswith(' tags - self._add_element(ElementType.BR, "") - else: - tag = match.group(1).lower() - content = match.group(2).strip() + 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: + self._add_element(ElementType.BR, "") - # Clean up content - remove extra whitespace - content = re.sub(r'\s+', ' ', content) - content = content.strip() + elif tag == ElementType.UL: + self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1) + + elif is_start_tag or is_end_tag: + # Always add content regardless of opening or closing tag + close_tag() + + # TODO: reset to None if end tag? + if is_start_tag: + current_tag = tag + + else: + current_content.append(token) - if content: # Only add non-empty elements - element_type = ElementType(tag) - self._add_element(element_type, content) + if current_content: + close_tag() def _add_element(self, element_type: ElementType, content: str) -> None: style = self.styles[element_type] @@ -101,9 +147,9 @@ class HtmlRenderer(Widget): content=content, font_size=style["size"], font_weight=style["weight"], - color=style["color"], margin_top=style["margin_top"], margin_bottom=style["margin_bottom"], + indent_level=self._indent_level, ) self.elements.append(element) @@ -134,7 +180,8 @@ class HtmlRenderer(Widget): if current_y > rect.y + rect.height: break - rl.draw_text_ex(font, line, rl.Vector2(rect.x + padding, current_y), element.font_size, 0, rl.WHITE) + 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 * element.line_height