openpilot is an open source driver assistance system. openpilot performs the functions of Automated Lane Centering and Adaptive Cruise Control for over 200 supported car makes and models.
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.
 
 
 
 
 
 

257 lines
8.8 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
LIST_INDENT_PX = 40
class ElementType(Enum):
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
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'</({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 = 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):
super().__init__()
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 <p>
self.styles: dict[ElementType, dict[str, Any]] = {
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.LI: {"size": 38, "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:
self._add_element(ElementType.BR, "")
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
# 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:
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
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