openpilot/selfdrive/ui/widgets/offroad_alerts.py
mouxangithub 6f2bd6efa3 feat(ui): 将字体权重统一调整为 CHINA 以支持中文字体渲染
将多个 UI 组件中的字体权重从原有的 `MEDIUM`、`NORMAL`、`BOLD` 等值统一修改为 `CHINA`,
以确保界面文本能够正确使用中文字体进行显示。同时调整了部分字号和标签样式,提升中文环境下的展示效果。
2025-11-20 11:43:26 +08:00

345 lines
12 KiB
Python

import pyray as rl
from enum import IntEnum
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from openpilot.common.params import Params
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
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.html_render import HtmlRenderer
from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
class AlertColors:
HIGH_SEVERITY = rl.Color(226, 44, 44, 255)
LOW_SEVERITY = rl.Color(41, 41, 41, 255)
BACKGROUND = rl.Color(57, 57, 57, 255)
BUTTON = rl.WHITE
BUTTON_PRESSED = rl.Color(200, 200, 200, 255)
BUTTON_TEXT = rl.BLACK
SNOOZE_BG = rl.Color(79, 79, 79, 255)
SNOOZE_BG_PRESSED = rl.Color(100, 100, 100, 255)
TEXT = rl.WHITE
class AlertConstants:
MIN_BUTTON_WIDTH = 400
BUTTON_HEIGHT = 125
MARGIN = 50
SPACING = 30
FONT_SIZE = 48
BORDER_RADIUS = 30 * 2 # matches Qt's 30px
ALERT_HEIGHT = 120
ALERT_SPACING = 10
ALERT_INSET = 60
@dataclass
class AlertData:
key: str
text: str
severity: int
visible: bool = False
class ButtonStyle(IntEnum):
LIGHT = 0
DARK = 1
class ActionButton(Widget):
def __init__(self, text: str | Callable[[], str], style: ButtonStyle = ButtonStyle.LIGHT,
min_width: int = AlertConstants.MIN_BUTTON_WIDTH):
super().__init__()
self._text = text
self._style = style
self._min_width = min_width
self._font = gui_app.font(FontWeight.CHINA)
@property
def text(self) -> str:
return self._text() if callable(self._text) else self._text
def _render(self, _):
text_size = measure_text_cached(gui_app.font(FontWeight.CHINA), self.text, AlertConstants.FONT_SIZE)
self._rect.width = max(text_size.x + 60 * 2, self._min_width)
self._rect.height = AlertConstants.BUTTON_HEIGHT
roundness = AlertConstants.BORDER_RADIUS / self._rect.height
bg_color = AlertColors.BUTTON if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG
if self.is_pressed:
bg_color = AlertColors.BUTTON_PRESSED if self._style == ButtonStyle.LIGHT else AlertColors.SNOOZE_BG_PRESSED
rl.draw_rectangle_rounded(self._rect, roundness, 10, bg_color)
# center text
color = rl.WHITE if self._style == ButtonStyle.DARK else rl.BLACK
text_x = int(self._rect.x + (self._rect.width - text_size.x) // 2)
text_y = int(self._rect.y + (self._rect.height - text_size.y) // 2)
rl.draw_text_ex(self._font, self.text, rl.Vector2(text_x, text_y), AlertConstants.FONT_SIZE, 0, color)
class AbstractAlert(Widget, ABC):
def __init__(self, has_reboot_btn: bool = False):
super().__init__()
self.params = Params()
self.has_reboot_btn = has_reboot_btn
self.dismiss_callback: Callable | None = None
def snooze_callback():
self.params.put_bool("SnoozeUpdate", True)
if self.dismiss_callback:
self.dismiss_callback()
def excessive_actuation_callback():
self.params.remove("Offroad_ExcessiveActuation")
if self.dismiss_callback:
self.dismiss_callback()
self.dismiss_btn = ActionButton(lambda: tr("Close"))
self.snooze_btn = ActionButton(lambda: tr("Snooze Update"), style=ButtonStyle.DARK)
self.snooze_btn.set_click_callback(snooze_callback)
self.excessive_actuation_btn = ActionButton(lambda: tr("Acknowledge Excessive Actuation"), style=ButtonStyle.DARK, min_width=800)
self.excessive_actuation_btn.set_click_callback(excessive_actuation_callback)
self.reboot_btn = ActionButton(lambda: tr("Reboot and Update"), min_width=600)
self.reboot_btn.set_click_callback(lambda: HARDWARE.reboot())
# TODO: just use a Scroller?
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0)
self.scroll_panel = GuiScrollPanel()
def show_event(self):
self.scroll_panel.set_offset(0)
def set_dismiss_callback(self, callback: Callable):
self.dismiss_callback = callback
self.dismiss_btn.set_click_callback(self.dismiss_callback)
@abstractmethod
def refresh(self) -> bool:
pass
@abstractmethod
def get_content_height(self) -> float:
pass
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.height, 10, AlertColors.BACKGROUND)
footer_height = AlertConstants.BUTTON_HEIGHT + AlertConstants.SPACING
content_height = rect.height - 2 * AlertConstants.MARGIN - footer_height
self.content_rect = rl.Rectangle(
rect.x + AlertConstants.MARGIN,
rect.y + AlertConstants.MARGIN,
rect.width - 2 * AlertConstants.MARGIN,
content_height,
)
self.scroll_panel_rect = rl.Rectangle(
self.content_rect.x, self.content_rect.y, self.content_rect.width, self.content_rect.height
)
self._render_scrollable_content()
self._render_footer(rect)
def _render_scrollable_content(self):
content_total_height = self.get_content_height()
content_bounds = rl.Rectangle(0, 0, self.scroll_panel_rect.width, content_total_height)
scroll_offset = self.scroll_panel.update(self.scroll_panel_rect, content_bounds)
rl.begin_scissor_mode(
int(self.scroll_panel_rect.x),
int(self.scroll_panel_rect.y),
int(self.scroll_panel_rect.width),
int(self.scroll_panel_rect.height),
)
content_rect_with_scroll = rl.Rectangle(
self.scroll_panel_rect.x,
self.scroll_panel_rect.y + scroll_offset,
self.scroll_panel_rect.width,
content_total_height,
)
self._render_content(content_rect_with_scroll)
rl.end_scissor_mode()
@abstractmethod
def _render_content(self, content_rect: rl.Rectangle):
pass
def _render_footer(self, rect: rl.Rectangle):
footer_y = rect.y + rect.height - AlertConstants.MARGIN - AlertConstants.BUTTON_HEIGHT
dismiss_x = rect.x + AlertConstants.MARGIN
self.dismiss_btn.set_position(dismiss_x, footer_y)
self.dismiss_btn.render()
if self.has_reboot_btn:
reboot_x = rect.x + rect.width - AlertConstants.MARGIN - self.reboot_btn.rect.width
self.reboot_btn.set_position(reboot_x, footer_y)
self.reboot_btn.render()
elif self.excessive_actuation_btn.is_visible:
actuation_x = rect.x + rect.width - AlertConstants.MARGIN - self.excessive_actuation_btn.rect.width
self.excessive_actuation_btn.set_position(actuation_x, footer_y)
self.excessive_actuation_btn.render()
elif self.snooze_btn.is_visible:
snooze_x = rect.x + rect.width - AlertConstants.MARGIN - self.snooze_btn.rect.width
self.snooze_btn.set_position(snooze_x, footer_y)
self.snooze_btn.render()
class OffroadAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=False)
self.sorted_alerts: list[AlertData] = []
def refresh(self):
if not self.sorted_alerts:
self._build_alerts()
active_count = 0
connectivity_needed = False
excessive_actuation = False
for alert_data in self.sorted_alerts:
text = ""
alert_json = self.params.get(alert_data.key)
if alert_json:
text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
alert_data.text = text
alert_data.visible = bool(text)
if alert_data.visible:
active_count += 1
if alert_data.key == "Offroad_ConnectivityNeeded" and alert_data.visible:
connectivity_needed = True
if alert_data.key == "Offroad_ExcessiveActuation" and alert_data.visible:
excessive_actuation = True
self.excessive_actuation_btn.set_visible(excessive_actuation)
self.snooze_btn.set_visible(connectivity_needed and not excessive_actuation)
return active_count
def get_content_height(self) -> float:
if not self.sorted_alerts:
return 0
total_height = 20
font = gui_app.font(FontWeight.CHINA)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
text_width = int(self.content_rect.width - (AlertConstants.ALERT_INSET * 2))
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE)
alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT)
total_height += round(alert_item_height + AlertConstants.ALERT_SPACING)
if total_height > 20:
total_height = total_height - AlertConstants.ALERT_SPACING + 20
return total_height
def _build_alerts(self):
self.sorted_alerts = []
for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
severity = config.get("severity", 0)
alert_data = AlertData(key=key, text="", severity=severity)
self.sorted_alerts.append(alert_data)
def _render_content(self, content_rect: rl.Rectangle):
y_offset = AlertConstants.ALERT_SPACING
font = gui_app.font(FontWeight.CHINA)
for alert_data in self.sorted_alerts:
if not alert_data.visible:
continue
bg_color = AlertColors.HIGH_SEVERITY if alert_data.severity > 0 else AlertColors.LOW_SEVERITY
text_width = int(content_rect.width - (AlertConstants.ALERT_INSET * 2))
wrapped_lines = wrap_text(font, alert_data.text, AlertConstants.FONT_SIZE, text_width)
line_count = len(wrapped_lines)
text_height = line_count * (AlertConstants.FONT_SIZE * FONT_SCALE)
alert_item_height = max(text_height + (AlertConstants.ALERT_INSET * 2), AlertConstants.ALERT_HEIGHT)
alert_rect = rl.Rectangle(
content_rect.x + 10,
content_rect.y + y_offset,
content_rect.width - 30,
alert_item_height,
)
roundness = AlertConstants.BORDER_RADIUS / min(alert_rect.height, alert_rect.width)
rl.draw_rectangle_rounded(alert_rect, roundness, 10, bg_color)
text_x = alert_rect.x + AlertConstants.ALERT_INSET
text_y = alert_rect.y + AlertConstants.ALERT_INSET
for i, line in enumerate(wrapped_lines):
rl.draw_text_ex(
font,
line,
rl.Vector2(text_x, text_y + i * AlertConstants.FONT_SIZE * FONT_SCALE),
AlertConstants.FONT_SIZE,
0,
AlertColors.TEXT,
)
y_offset += round(alert_item_height + AlertConstants.ALERT_SPACING)
class UpdateAlert(AbstractAlert):
def __init__(self):
super().__init__(has_reboot_btn=True)
self.release_notes = ""
self._wrapped_release_notes = ""
self._cached_content_height: float = 0.0
self._html_renderer = HtmlRenderer(text="")
def refresh(self) -> bool:
update_available: bool = self.params.get_bool("UpdateAvailable")
no_release_notes = "<h2>" + tr("No release notes available.") + "</h2>"
if update_available:
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
def get_content_height(self) -> float:
if not self.release_notes:
return 100
if self._cached_content_height == 0:
self._wrapped_release_notes = self.release_notes
size = measure_text_cached(gui_app.font(FontWeight.CHINA), self._wrapped_release_notes, AlertConstants.FONT_SIZE)
self._cached_content_height = max(size.y + 60, 100)
return self._cached_content_height
def _render_content(self, content_rect: rl.Rectangle):
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)