From 2337704602fe492741afbe856f19118ae562f196 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 3 Oct 2025 21:47:53 -0700 Subject: [PATCH] raylib: release notes are drawn with HTML renderer (#36245) * stash * ok chatter is useful for once * draw text outside tags * hmm * undo that shit * i don't like this chatgpt * Revert "i don't like this chatgpt" This reverts commit 5b511911d81242457bfb5fc808a9b9f35fe9f7a2. * more robust parsing (works with missing tags, markdown.py actually had bug) + add indent level * the html looks weird but is correct - the old parser didn't handle it * clean up * some * move out * clean up * oh this was wrong * draft * rm that * fix * fix indentation for new driving model * clean up * some clean up * more clean up * more clean up * and this * cmt * ok this is egregious mypy --- selfdrive/ui/layouts/settings/software.py | 4 +- selfdrive/ui/widgets/offroad_alerts.py | 27 +++----- system/ui/lib/application.py | 2 +- system/ui/widgets/html_render.py | 8 ++- system/ui/widgets/list_view.py | 75 +++++++++++------------ 5 files changed, 55 insertions(+), 61 deletions(-) diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 7440512a62..7aebc609f8 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -80,7 +80,7 @@ class SoftwareLayout(Widget): current_desc = ui_state.params.get("UpdaterCurrentDescription") or "" current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace") self._version_item.action_item.set_text(current_desc) - self._version_item.description = current_release_notes + self._version_item.set_description(current_release_notes) # Update download button visibility and state self._download_btn.set_visible(ui_state.is_offroad()) @@ -125,7 +125,7 @@ class SoftwareLayout(Widget): new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace") self._install_btn.action_item.set_text("INSTALL") self._install_btn.action_item.set_value(new_desc) - self._install_btn.description = new_release_notes + self._install_btn.set_description(new_release_notes) # Enable install button for testing (like Qt showEvent) self._install_btn.action_item.set_enabled(True) else: diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py index 444688b7a0..1045971636 100644 --- a/selfdrive/ui/widgets/offroad_alerts.py +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -14,6 +14,9 @@ from openpilot.system.ui.widgets.html_render import HtmlRenderer from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS +NO_RELEASE_NOTES = "

No release notes available.

" + + class AlertColors: HIGH_SEVERITY = rl.Color(226, 44, 44, 255) LOW_SEVERITY = rl.Color(41, 41, 41, 255) @@ -307,13 +310,16 @@ class UpdateAlert(AbstractAlert): self.release_notes = "" self._wrapped_release_notes = "" self._cached_content_height: float = 0.0 - self._html_renderer: HtmlRenderer | None = None + self._html_renderer = HtmlRenderer(text="") def refresh(self) -> bool: update_available: bool = self.params.get_bool("UpdateAvailable") if update_available: - self.release_notes = self.params.get("UpdaterNewReleaseNotes") + self.release_notes = (self.params.get("UpdaterNewReleaseNotes") or b"").decode("utf8").strip() + self._html_renderer.parse_html_content(self.release_notes or NO_RELEASE_NOTES) self._cached_content_height = 0 + else: + self._html_renderer.parse_html_content(NO_RELEASE_NOTES) return update_available @@ -329,18 +335,5 @@ class UpdateAlert(AbstractAlert): return self._cached_content_height def _render_content(self, content_rect: rl.Rectangle): - if self.release_notes: - rl.draw_text_ex( - gui_app.font(FontWeight.NORMAL), - self._wrapped_release_notes, - rl.Vector2(content_rect.x + 30, content_rect.y + 30), - AlertConstants.FONT_SIZE, - 0.0, - AlertColors.TEXT, - ) - else: - no_notes_text = "No release notes available." - text_width = rl.measure_text(no_notes_text, AlertConstants.FONT_SIZE) - text_x = content_rect.x + (content_rect.width - text_width) // 2 - text_y = content_rect.y + 50 - rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), no_notes_text, (int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT) + notes_rect = rl.Rectangle(content_rect.x + 30, content_rect.y + 30, content_rect.width - 60, content_rect.height - 60) + self._html_renderer.render(notes_rect) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 6c703a516e..a42b1d1129 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -331,7 +331,7 @@ class GuiApplication: for layout in KEYBOARD_LAYOUTS.values(): all_chars.update(key for row in layout for key in row) all_chars = "".join(all_chars) - all_chars += "–✓×°§" + all_chars += "–✓×°§•" codepoint_count = rl.ffi.new("int *", 1) codepoints = rl.load_codepoints(all_chars, codepoint_count) diff --git a/system/ui/widgets/html_render.py b/system/ui/widgets/html_render.py index f7dc9e9344..2c3eeb3793 100644 --- a/system/ui/widgets/html_render.py +++ b/system/ui/widgets/html_render.py @@ -70,6 +70,7 @@ class HtmlRenderer(Widget): 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}, } @@ -122,9 +123,6 @@ class HtmlRenderer(Widget): if tag == ElementType.BR: self._add_element(ElementType.BR, "") - 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() @@ -133,6 +131,10 @@ class HtmlRenderer(Widget): 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) diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 7877325492..056aa2d90b 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -4,10 +4,10 @@ from collections.abc import Callable from abc import ABC from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.text_measure import measure_text_cached -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.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT +from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType ITEM_BASE_WIDTH = 600 ITEM_BASE_HEIGHT = 170 @@ -258,7 +258,7 @@ class ListItem(Widget): super().__init__() self.title = title self.icon = icon - self.description = description + self._description = description self.description_visible = description_visible self.callback = callback self.action_item = action_item @@ -267,11 +267,12 @@ class ListItem(Widget): self._font = gui_app.font(FontWeight.NORMAL) self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None + self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, + text_color=ITEM_DESC_TEXT_COLOR) + self.set_description(self.description) + # Cached properties for performance - self._prev_max_width: int = 0 - self._wrapped_description: str | None = None - self._prev_description: str | None = None - self._description_height: float = 0 + self._prev_description: str | None = self.description def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) @@ -295,9 +296,15 @@ class ListItem(Widget): if self.description: self.description_visible = not self.description_visible - content_width = self.get_content_width(int(self._rect.width - ITEM_PADDING * 2)) + content_width = int(self._rect.width - ITEM_PADDING * 2) self._rect.height = self.get_item_height(self._font, content_width) + def _update_state(self): + # Detect changes if description is callback + new_description = self.description + if new_description != self._prev_description: + self.set_description(new_description) + def _render(self, _): if not self.is_visible: return @@ -323,16 +330,16 @@ class ListItem(Widget): rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) # Draw description if visible - current_description = self.get_description() - if self.description_visible and current_description and self._wrapped_description: - rl.draw_text_ex( - self._font, - self._wrapped_description, - rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET), - ITEM_DESC_FONT_SIZE, - 0, - ITEM_DESC_TEXT_COLOR, + if self.description_visible: + content_width = int(self._rect.width - ITEM_PADDING * 2) + description_height = self._html_renderer.get_total_height(content_width) + description_rect = rl.Rectangle( + self._rect.x + ITEM_PADDING, + self._rect.y + ITEM_DESC_V_OFFSET, + content_width, + description_height ) + self._html_renderer.render(description_rect) # Draw right item if present if self.action_item: @@ -343,33 +350,25 @@ class ListItem(Widget): if self.callback: self.callback() - def get_description(self): - return _resolve_value(self.description, None) + def set_description(self, description: str | Callable[[], str] | None): + self._description = description + new_desc = self.description + self._html_renderer.parse_html_content(new_desc) + self._prev_description = new_desc + + @property + def description(self): + return _resolve_value(self._description, "") def get_item_height(self, font: rl.Font, max_width: int) -> float: if not self.is_visible: return 0 - current_description = self.get_description() - if self.description_visible and current_description: - if ( - not self._wrapped_description - or current_description != self._prev_description - or max_width != self._prev_max_width - ): - self._prev_max_width = max_width - self._prev_description = current_description - - wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width) - self._wrapped_description = "\n".join(wrapped_lines) - self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 - return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING - return ITEM_BASE_HEIGHT - - def get_content_width(self, total_width: int) -> int: - if self.action_item and self.action_item.rect.width > 0: - return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING - return total_width + height = float(ITEM_BASE_HEIGHT) + if self.description_visible: + description_height = self._html_renderer.get_total_height(max_width) + height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING + return height def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: if not self.action_item: