ui: implement home layout with fully functional offroad alerts (#35468)

implement home layout with offroad alerts
pull/35474/head
Dean Lee 2 weeks ago committed by GitHub
parent cb22be6079
commit 6b59f67ab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 213
      selfdrive/ui/layouts/home.py
  2. 328
      selfdrive/ui/widgets/offroad_alerts.py

@ -1,17 +1,212 @@
import time
import pyray as rl
from openpilot.system.ui.lib.label import gui_text_box
from collections.abc import Callable
from enum import IntEnum
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR
HEADER_HEIGHT = 80
HEAD_BUTTON_FONT_SIZE = 40
CONTENT_MARGIN = 40
SPACING = 25
RIGHT_COLUMN_WIDTH = 750
REFRESH_INTERVAL = 10.0
PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
class HomeLayoutState(IntEnum):
HOME = 0
UPDATE = 1
ALERTS = 2
class HomeLayout:
def __init__(self):
pass
self.params = Params()
self.update_alert = UpdateAlert()
self.offroad_alert = OffroadAlert()
self.current_state = HomeLayoutState.HOME
self.last_refresh = 0
self.settings_callback: callable | None = None
self.update_available = False
self.alert_count = 0
self.header_rect = rl.Rectangle(0, 0, 0, 0)
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.left_column_rect = rl.Rectangle(0, 0, 0, 0)
self.right_column_rect = rl.Rectangle(0, 0, 0, 0)
self.update_notif_rect = rl.Rectangle(0, 0, 200, HEADER_HEIGHT - 10)
self.alert_notif_rect = rl.Rectangle(0, 0, 220, HEADER_HEIGHT - 10)
self._setup_callbacks()
def _setup_callbacks(self):
self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
def set_settings_callback(self, callback: Callable):
self.settings_callback = callback
def _set_state(self, state: HomeLayoutState):
self.current_state = state
def render(self, rect: rl.Rectangle):
gui_text_box(
rect,
"Demo Home Layout",
font_size=170,
color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
self._update_layout_rects(rect)
current_time = time.time()
if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh()
self.last_refresh = current_time
self._handle_input()
self._render_header()
# Render content based on current state
if self.current_state == HomeLayoutState.HOME:
self._render_home_content()
elif self.current_state == HomeLayoutState.UPDATE:
self._render_update_view()
elif self.current_state == HomeLayoutState.ALERTS:
self._render_alerts_view()
def _update_layout_rects(self, rect: rl.Rectangle):
self.header_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, HEADER_HEIGHT
)
content_y = rect.y + CONTENT_MARGIN + HEADER_HEIGHT + SPACING
content_height = rect.height - CONTENT_MARGIN - HEADER_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle(
rect.x + CONTENT_MARGIN, content_y, rect.width - 2 * CONTENT_MARGIN, content_height
)
left_width = self.content_rect.width - RIGHT_COLUMN_WIDTH - SPACING
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
self.right_column_rect = rl.Rectangle(
self.content_rect.x + left_width + SPACING, self.content_rect.y, RIGHT_COLUMN_WIDTH, self.content_rect.height
)
self.update_notif_rect.x = self.header_rect.x
self.update_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2
notif_x = self.header_rect.x + (220 if self.update_available else 0)
self.alert_notif_rect.x = notif_x
self.alert_notif_rect.y = self.header_rect.y + (self.header_rect.height - 60) // 2
def _handle_input(self):
if not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE)
return
if self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
self._set_state(HomeLayoutState.ALERTS)
return
# Content area input handling
if self.current_state == HomeLayoutState.UPDATE:
self.update_alert.handle_input(mouse_pos, True)
elif self.current_state == HomeLayoutState.ALERTS:
self.offroad_alert.handle_input(mouse_pos, True)
def _render_header(self):
font = gui_app.font(FontWeight.MEDIUM)
# Update notification button
if self.update_available:
# Highlight if currently viewing updates
highlight_color = rl.Color(255, 140, 40, 255) if self.current_state == HomeLayoutState.UPDATE else rl.Color(255, 102, 0, 255)
rl.draw_rectangle_rounded(self.update_notif_rect, 0.3, 10, highlight_color)
text = "UPDATE"
text_width = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE).x
text_x = self.update_notif_rect.x + (self.update_notif_rect.width - text_width) // 2
text_y = self.update_notif_rect.y + (self.update_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
# Alert notification button
if self.alert_count > 0:
# Highlight if currently viewing alerts
highlight_color = rl.Color(255, 70, 70, 255) if self.current_state == HomeLayoutState.ALERTS else rl.Color(226, 44, 44, 255)
rl.draw_rectangle_rounded(self.alert_notif_rect, 0.3, 10, highlight_color)
alert_text = f"{self.alert_count} ALERT{'S' if self.alert_count > 1 else ''}"
text_width = measure_text_cached(font, alert_text, HEAD_BUTTON_FONT_SIZE).x
text_x = self.alert_notif_rect.x + (self.alert_notif_rect.width - text_width) // 2
text_y = self.alert_notif_rect.y + (self.alert_notif_rect.height - HEAD_BUTTON_FONT_SIZE) // 2
rl.draw_text_ex(font, alert_text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
# Version text (right aligned)
version_text = self._get_version_text()
text_width = measure_text_cached(gui_app.font(FontWeight.NORMAL), version_text, 48).x
version_x = self.header_rect.x + self.header_rect.width - text_width
version_y = self.header_rect.y + (self.header_rect.height - 48) // 2
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), version_text, rl.Vector2(int(version_x), int(version_y)), 48, 0, DEFAULT_TEXT_COLOR)
def _render_home_content(self):
self._render_left_column()
self._render_right_column()
def _render_update_view(self):
self.update_alert.render(self.content_rect)
def _render_alerts_view(self):
self.offroad_alert.render(self.content_rect)
def _render_left_column(self):
rl.draw_rectangle_rounded(self.left_column_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(self.left_column_rect, "Prime Widget", 48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _render_right_column(self):
widget_height = (self.right_column_rect.height - SPACING) // 2
exp_rect = rl.Rectangle(
self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, widget_height
)
rl.draw_rectangle_rounded(exp_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(exp_rect, "Experimental Mode", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
setup_rect = rl.Rectangle(
self.right_column_rect.x,
self.right_column_rect.y + widget_height + SPACING,
self.right_column_rect.width,
widget_height,
)
rl.draw_rectangle_rounded(setup_rect, 0.02, 10, PRIME_BG_COLOR)
gui_label(setup_rect, "Setup", 36, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _refresh(self):
self.update_available = self.update_alert.refresh()
self.alert_count = self.offroad_alert.refresh()
self._update_state_priority(self.update_available, self.alert_count > 0)
def _update_state_priority(self, update_available: bool, alerts_present: bool):
current_state = self.current_state
if not update_available and not alerts_present:
self.current_state = HomeLayoutState.HOME
elif update_available and (current_state == HomeLayoutState.HOME or (not alerts_present and current_state == HomeLayoutState.ALERTS)):
self.current_state = HomeLayoutState.UPDATE
elif alerts_present and (current_state == HomeLayoutState.HOME or (not update_available and current_state == HomeLayoutState.UPDATE)):
self.current_state = HomeLayoutState.ALERTS
def _get_version_text(self) -> str:
brand = "openpilot"
description = self.params.get("UpdaterCurrentDescription", encoding='utf-8')
return f"{brand} {description}" if description else brand

@ -0,0 +1,328 @@
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
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(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)
Loading…
Cancel
Save