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: