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.
329 lines
11 KiB
329 lines
11 KiB
import json
|
|
import pyray as rl
|
|
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.scroll_panel import GuiScrollPanel
|
|
from openpilot.system.ui.lib.wrap_text import wrap_text
|
|
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
|
from openpilot.system.ui.lib.application import gui_app, FontWeight, Widget
|
|
|
|
|
|
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_TEXT = rl.BLACK
|
|
SNOOZE_BG = rl.Color(79, 79, 79, 255)
|
|
TEXT = rl.WHITE
|
|
|
|
|
|
class AlertConstants:
|
|
BUTTON_SIZE = (400, 125)
|
|
SNOOZE_BUTTON_SIZE = (550, 125)
|
|
REBOOT_BUTTON_SIZE = (600, 125)
|
|
MARGIN = 50
|
|
SPACING = 30
|
|
FONT_SIZE = 48
|
|
BORDER_RADIUS = 30
|
|
ALERT_HEIGHT = 120
|
|
ALERT_SPACING = 20
|
|
|
|
|
|
@dataclass
|
|
class AlertData:
|
|
key: str
|
|
text: str
|
|
severity: int
|
|
visible: bool = False
|
|
|
|
|
|
class AbstractAlert(Widget, ABC):
|
|
def __init__(self, has_reboot_btn: bool = False):
|
|
self.params = Params()
|
|
self.has_reboot_btn = has_reboot_btn
|
|
self.dismiss_callback: Callable | None = None
|
|
|
|
self.dismiss_btn_rect = rl.Rectangle(0, 0, *AlertConstants.BUTTON_SIZE)
|
|
self.snooze_btn_rect = rl.Rectangle(0, 0, *AlertConstants.SNOOZE_BUTTON_SIZE)
|
|
self.reboot_btn_rect = rl.Rectangle(0, 0, *AlertConstants.REBOOT_BUTTON_SIZE)
|
|
|
|
self.snooze_visible = False
|
|
self.content_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self.scroll_panel_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self.scroll_panel = GuiScrollPanel()
|
|
|
|
def set_dismiss_callback(self, callback: Callable):
|
|
self.dismiss_callback = callback
|
|
|
|
@abstractmethod
|
|
def refresh(self) -> bool:
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_content_height(self) -> float:
|
|
pass
|
|
|
|
def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool:
|
|
# TODO: fix scroll_panel.is_click_valid()
|
|
if not mouse_clicked:
|
|
return False
|
|
|
|
if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect):
|
|
if self.dismiss_callback:
|
|
self.dismiss_callback()
|
|
return True
|
|
|
|
if self.snooze_visible and rl.check_collision_point_rec(mouse_pos, self.snooze_btn_rect):
|
|
self.params.put_bool("SnoozeUpdate", True)
|
|
if self.dismiss_callback:
|
|
self.dismiss_callback()
|
|
return True
|
|
|
|
if self.has_reboot_btn and rl.check_collision_point_rec(mouse_pos, self.reboot_btn_rect):
|
|
HARDWARE.reboot()
|
|
return True
|
|
|
|
return False
|
|
|
|
def render(self, rect: rl.Rectangle):
|
|
rl.draw_rectangle_rounded(rect, AlertConstants.BORDER_RADIUS / rect.width, 10, AlertColors.BACKGROUND)
|
|
|
|
footer_height = AlertConstants.BUTTON_SIZE[1] + 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.handle_scroll(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.y,
|
|
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_SIZE[1]
|
|
font = gui_app.font(FontWeight.MEDIUM)
|
|
|
|
self.dismiss_btn_rect.x = rect.x + AlertConstants.MARGIN
|
|
self.dismiss_btn_rect.y = footer_y
|
|
rl.draw_rectangle_rounded(self.dismiss_btn_rect, 0.3, 10, AlertColors.BUTTON)
|
|
|
|
text = "Close"
|
|
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
|
|
text_x = self.dismiss_btn_rect.x + (AlertConstants.BUTTON_SIZE[0] - text_width) // 2
|
|
text_y = self.dismiss_btn_rect.y + (AlertConstants.BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
|
|
rl.draw_text_ex(
|
|
font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT
|
|
)
|
|
|
|
if self.snooze_visible:
|
|
self.snooze_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.SNOOZE_BUTTON_SIZE[0]
|
|
self.snooze_btn_rect.y = footer_y
|
|
rl.draw_rectangle_rounded(self.snooze_btn_rect, 0.3, 10, AlertColors.SNOOZE_BG)
|
|
|
|
text = "Snooze Update"
|
|
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
|
|
text_x = self.snooze_btn_rect.x + (AlertConstants.SNOOZE_BUTTON_SIZE[0] - text_width) // 2
|
|
text_y = self.snooze_btn_rect.y + (AlertConstants.SNOOZE_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
|
|
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.TEXT)
|
|
|
|
elif self.has_reboot_btn:
|
|
self.reboot_btn_rect.x = rect.x + rect.width - AlertConstants.MARGIN - AlertConstants.REBOOT_BUTTON_SIZE[0]
|
|
self.reboot_btn_rect.y = footer_y
|
|
rl.draw_rectangle_rounded(self.reboot_btn_rect, 0.3, 10, AlertColors.BUTTON)
|
|
|
|
text = "Reboot and Update"
|
|
text_width = measure_text_cached(font, text, AlertConstants.FONT_SIZE).x
|
|
text_x = self.reboot_btn_rect.x + (AlertConstants.REBOOT_BUTTON_SIZE[0] - text_width) // 2
|
|
text_y = self.reboot_btn_rect.y + (AlertConstants.REBOOT_BUTTON_SIZE[1] - AlertConstants.FONT_SIZE) // 2
|
|
rl.draw_text_ex(
|
|
font, text, rl.Vector2(int(text_x), int(text_y)), AlertConstants.FONT_SIZE, 0, AlertColors.BUTTON_TEXT
|
|
)
|
|
|
|
|
|
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
|
|
|
|
for alert_data in self.sorted_alerts:
|
|
text = ""
|
|
bytes_data = self.params.get(alert_data.key)
|
|
|
|
if bytes_data:
|
|
try:
|
|
alert_json = json.loads(bytes_data)
|
|
text = alert_json.get("text", "").replace("{}", alert_json.get("extra", ""))
|
|
except json.JSONDecodeError:
|
|
text = ""
|
|
|
|
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
|
|
|
|
self.snooze_visible = connectivity_needed
|
|
return active_count
|
|
|
|
def get_content_height(self) -> float:
|
|
if not self.sorted_alerts:
|
|
return 0
|
|
|
|
total_height = 20
|
|
font = gui_app.font(FontWeight.NORMAL)
|
|
|
|
for alert_data in self.sorted_alerts:
|
|
if not alert_data.visible:
|
|
continue
|
|
|
|
text_width = int(self.content_rect.width - 90)
|
|
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 + 5)
|
|
alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT)
|
|
total_height += 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 = []
|
|
try:
|
|
with open("../selfdrived/alerts_offroad.json", "rb") as f:
|
|
alerts_config = json.load(f)
|
|
for key, config in sorted(alerts_config.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)
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
pass
|
|
|
|
def _render_content(self, content_rect: rl.Rectangle):
|
|
y_offset = 20
|
|
font = gui_app.font(FontWeight.NORMAL)
|
|
|
|
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 - 90)
|
|
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 + 5)
|
|
alert_item_height = max(text_height + 40, AlertConstants.ALERT_HEIGHT)
|
|
|
|
alert_rect = rl.Rectangle(
|
|
content_rect.x + 10,
|
|
content_rect.y + y_offset,
|
|
content_rect.width - 30,
|
|
alert_item_height,
|
|
)
|
|
|
|
rl.draw_rectangle_rounded(alert_rect, 0.2, 10, bg_color)
|
|
|
|
text_x = alert_rect.x + 30
|
|
text_y = alert_rect.y + 20
|
|
|
|
for i, line in enumerate(wrapped_lines):
|
|
rl.draw_text_ex(
|
|
font,
|
|
line,
|
|
rl.Vector2(text_x, text_y + i * (AlertConstants.FONT_SIZE + 5)),
|
|
AlertConstants.FONT_SIZE,
|
|
0,
|
|
AlertColors.TEXT,
|
|
)
|
|
|
|
y_offset += 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
|
|
|
|
def refresh(self) -> bool:
|
|
update_available: bool = self.params.get_bool("UpdateAvailable")
|
|
if update_available:
|
|
self.release_notes = self.params.get("UpdaterNewReleaseNotes", encoding='utf-8')
|
|
self._cached_content_height = 0
|
|
|
|
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.NORMAL), 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):
|
|
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(no_notes_text, int(text_x), int(text_y), AlertConstants.FONT_SIZE, AlertColors.TEXT)
|
|
|