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
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
|
|
|