From 912160c429903c80664208b89de980b0204331d0 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 04:54:45 +0800 Subject: [PATCH 01/20] ui: improve calibration height initialization (#35459) improve calibration height initialization --- selfdrive/ui/onroad/model_renderer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/selfdrive/ui/onroad/model_renderer.py b/selfdrive/ui/onroad/model_renderer.py index 38951fb504..ad037f8af3 100644 --- a/selfdrive/ui/onroad/model_renderer.py +++ b/selfdrive/ui/onroad/model_renderer.py @@ -7,6 +7,7 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import DEFAULT_FPS from openpilot.system.ui.lib.shader_polygon import draw_polygon +from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT CLIP_MARGIN = 500 @@ -51,7 +52,7 @@ class ModelRenderer: self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - self._path_offset_z = 1.22 + self._path_offset_z = HEIGHT_INIT[0] # Initialize ModelPoints objects self._path = ModelPoints() @@ -99,7 +100,10 @@ class ModelRenderer: # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - self._path_offset_z = sm['liveCalibration'].height[0] + + live_calib = sm['liveCalibration'] + self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + if sm.updated['carParams']: self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl From 88466fb62f0530813e8823132883e458c24ce164 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 04:55:03 +0800 Subject: [PATCH 02/20] ui: improve onroad layout transitions (#35458) improve onroad layout transitions --- selfdrive/ui/layouts/main.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index 2096480f22..08ce50407d 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -35,6 +35,7 @@ class MainLayout: self._current_callback = None self._update_layout_rects(rect) + self._handle_onroad_transition() self._render_main_content() self._handle_input() @@ -47,7 +48,7 @@ class MainLayout: on_flag=lambda: setattr(self, '_current_callback', self._on_flag_clicked), ) self._layouts[MainState.SETTINGS].set_callbacks( - on_close=lambda: setattr(self, '_current_callback', self._on_settings_closed) + on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state) ) def _update_layout_rects(self, rect): @@ -57,14 +58,24 @@ class MainLayout: x_offset = SIDEBAR_WIDTH if self._sidebar_visible else 0 self._content_rect = rl.Rectangle(rect.y + x_offset, rect.y, rect.width - x_offset, rect.height) + def _handle_onroad_transition(self): + if ui_state.started != self._prev_onroad: + self._prev_onroad = ui_state.started + + self._set_mode_for_state() + + def _set_mode_for_state(self): + if ui_state.started: + self._current_mode = MainState.ONROAD + self._sidebar_visible = False + else: + self._current_mode = MainState.HOME + self._sidebar_visible = True + def _on_settings_clicked(self): self._current_mode = MainState.SETTINGS self._sidebar_visible = False - def _on_settings_closed(self): - self._current_mode = MainState.HOME if not ui_state.started else MainState.ONROAD - self._sidebar_visible = True - def _on_flag_clicked(self): pass @@ -73,13 +84,6 @@ class MainLayout: if self._sidebar_visible: self._sidebar.render(self._sidebar_rect) - if ui_state.started != self._prev_onroad: - self._prev_onroad = ui_state.started - if ui_state.started: - self._current_mode = MainState.ONROAD - else: - self._current_mode = MainState.HOME - content_rect = self._content_rect if self._sidebar_visible else self._window_rect self._layouts[self._current_mode].render(content_rect) From 96cfd5aaf7746e965e7f19850de13881c644d8d5 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 05:06:50 +0800 Subject: [PATCH 03/20] ui: add ListView component and settings layouts with declarative UI (#35453) * add flexible ListView component * fix crash --------- Co-authored-by: Shane Smiskol --- selfdrive/ui/layouts/settings/developer.py | 52 +++ selfdrive/ui/layouts/settings/device.py | 47 +++ selfdrive/ui/layouts/settings/settings.py | 37 +- selfdrive/ui/layouts/settings/software.py | 21 ++ selfdrive/ui/layouts/settings/toggles.py | 68 ++++ system/ui/lib/list_view.py | 380 +++++++++++++++++++++ system/ui/lib/toggle.py | 24 +- system/ui/lib/wrap_text.py | 87 +++++ 8 files changed, 690 insertions(+), 26 deletions(-) create mode 100644 selfdrive/ui/layouts/settings/developer.py create mode 100644 selfdrive/ui/layouts/settings/device.py create mode 100644 selfdrive/ui/layouts/settings/software.py create mode 100644 selfdrive/ui/layouts/settings/toggles.py create mode 100644 system/ui/lib/list_view.py create mode 100644 system/ui/lib/wrap_text.py diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py new file mode 100644 index 0000000000..6ab5be2792 --- /dev/null +++ b/selfdrive/ui/layouts/settings/developer.py @@ -0,0 +1,52 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + 'enable_adb': ( + "ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. " + + "See https://docs.comma.ai/how-to/connect-to-comma for more info." + ), + 'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", +} + + +class DeveloperLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable ADB", + description=DESCRIPTIONS["enable_adb"], + initial_state=self._params.get_bool("AdbEnabled"), + callback=self._on_enable_adb, + ), + toggle_item( + "Joystick Debug Mode", + description=DESCRIPTIONS["joystick_debug_mode"], + initial_state=self._params.get_bool("JoystickDebugMode"), + callback=self._on_joystick_debug_mode, + ), + toggle_item( + "Longitudinal Maneuver Mode", + description="", + initial_state=self._params.get_bool("LongitudinalManeuverMode"), + callback=self._on_long_maneuver_mode, + ), + toggle_item( + "openpilot Longitudinal Control (Alpha)", + description="", + initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), + callback=self._on_alpha_long_enabled, + ), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_enable_adb(self): pass + def _on_joystick_debug_mode(self): pass + def _on_long_maneuver_mode(self): pass + def _on_alpha_long_enabled(self): pass diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py new file mode 100644 index 0000000000..5051dd6ba6 --- /dev/null +++ b/selfdrive/ui/layouts/settings/device.py @@ -0,0 +1,47 @@ +from openpilot.system.ui.lib.list_view import ListView, text_item, button_item +from openpilot.common.params import Params +from openpilot.system.hardware import TICI + +# Description constants +DESCRIPTIONS = { + 'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.", + 'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", + 'reset_calibration': ( + "openpilot requires the device to be mounted within 4° left or right and within 5° " + + "up or 9° down. openpilot is continuously calibrating, resetting is rarely required." + ), + 'review_guide': "Review the rules, features, and limitations of openpilot", +} + + +class DeviceLayout: + def __init__(self): + params = Params() + dongle_id = params.get("DongleId", encoding="utf-8") or "N/A" + serial = params.get("HardwareSerial") or "N/A" + + items = [ + text_item("Dongle ID", dongle_id), + text_item("Serial", serial), + button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device), + button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera), + button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration), + button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), + ] + + if TICI: + items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory)) + + items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language)) + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_pair_device(self): pass + def _on_driver_camera(self): pass + def _on_reset_calibration(self): pass + def _on_review_training_guide(self): pass + def _on_regulatory(self): pass + def _on_change_language(self): pass diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 6a9a50b855..fc03529e56 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -3,6 +3,10 @@ from dataclasses import dataclass from enum import IntEnum from collections.abc import Callable from openpilot.common.params import Params +from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout +from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout +from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.label import gui_text_box @@ -52,12 +56,12 @@ class SettingsLayout: # Panel configuration self._panels = { - PanelType.DEVICE: PanelInfo("Device", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.TOGGLES: PanelInfo("Toggles", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.SOFTWARE: PanelInfo("Software", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)), PanelType.NETWORK: PanelInfo("Network", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.DEVELOPER: PanelInfo("Developer", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), } self._font_medium = gui_app.font(FontWeight.MEDIUM) @@ -130,16 +134,23 @@ class SettingsLayout: i += 1 def _draw_current_panel(self, rect: rl.Rectangle): - content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) - rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) - gui_text_box( - content_rect, - f"Demo {self._panels[self._current_panel].name} Panel", - font_size=170, - color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + rl.draw_rectangle_rounded( + rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR ) + content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) + # rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) + panel = self._panels[self._current_panel] + if panel.instance: + panel.instance.render(content_rect) + else: + gui_text_box( + content_rect, + f"Demo {self._panels[self._current_panel].name} Panel", + font_size=170, + color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + ) def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: # Check close button diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py new file mode 100644 index 0000000000..e57e13c148 --- /dev/null +++ b/selfdrive/ui/layouts/settings/software.py @@ -0,0 +1,21 @@ +from openpilot.system.ui.lib.list_view import ListView, button_item, text_item + +class SoftwareLayout: + def __init__(self): + items = [ + text_item("Current Version", ""), + button_item("Download", "CHECK", callback=self._on_download_update), + button_item("Install Update", "INSTALL", callback=self._on_install_update), + button_item("Target Branch", "SELECT", callback=self._on_select_branch), + button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_download_update(self): pass + def _on_install_update(self): pass + def _on_select_branch(self): pass + def _on_uninstall(self): pass diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py new file mode 100644 index 0000000000..d966baf9cd --- /dev/null +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -0,0 +1,68 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + "OpenpilotEnabledToggle": ( + "Use the openpilot system for adaptive cruise control and lane keep driver assistance. " + + "Your attention is required at all times to use this feature." + ), + "DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.", + "IsLdwEnabled": ( + "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + + "without a turn signal activated while driving over 31 mph (50 km/h)." + ), + "AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.", + 'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.", + "IsMetric": "Display speed in km/h instead of mph.", +} + + +class TogglesLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable openpilot", + DESCRIPTIONS["OpenpilotEnabledToggle"], + self._params.get_bool("OpenpilotEnabledToggle"), + icon="chffr_wheel.png", + ), + toggle_item( + "Experimental Mode", + initial_state=self._params.get_bool("ExperimentalMode"), + icon="experimental_white.png", + ), + toggle_item( + "Disengage on Accelerator Pedal", + DESCRIPTIONS["DisengageOnAccelerator"], + self._params.get_bool("DisengageOnAccelerator"), + icon="disengage_on_accelerator.png", + ), + toggle_item( + "Enable Lane Departure Warnings", + DESCRIPTIONS["IsLdwEnabled"], + self._params.get_bool("IsLdwEnabled"), + icon="warning.png", + ), + toggle_item( + "Always-On Driver Monitoring", + DESCRIPTIONS["AlwaysOnDM"], + self._params.get_bool("AlwaysOnDM"), + icon="monitoring.png", + ), + toggle_item( + "Record and Upload Driver Camera", + DESCRIPTIONS["RecordFront"], + self._params.get_bool("RecordFront"), + icon="monitoring.png", + ), + toggle_item( + "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png" + ), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py new file mode 100644 index 0000000000..9ca2363bc7 --- /dev/null +++ b/system/ui/lib/list_view.py @@ -0,0 +1,380 @@ +import os +import pyray as rl +from dataclasses import dataclass +from collections.abc import Callable +from abc import ABC, abstractmethod +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.lib.application import gui_app, FontWeight +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.lib.button import gui_button +from openpilot.system.ui.lib.toggle import Toggle +from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT + + +LINE_PADDING = 40 +LINE_COLOR = rl.GRAY +ITEM_PADDING = 20 +ITEM_SPACING = 80 +ITEM_BASE_HEIGHT = 170 +ITEM_TEXT_FONT_SIZE = 50 +ITEM_TEXT_COLOR = rl.WHITE +ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) +ITEM_DESC_FONT_SIZE = 40 +ITEM_DESC_V_OFFSET = 130 +RIGHT_ITEM_PADDING = 20 +ICON_SIZE = 80 +BUTTON_WIDTH = 250 +BUTTON_HEIGHT = 100 +BUTTON_BORDER_RADIUS = 50 +BUTTON_FONT_SIZE = 35 +BUTTON_FONT_WEIGHT = FontWeight.MEDIUM + + +# Abstract base class for right-side items +class RightItem(ABC): + def __init__(self, width: int = 100): + self.width = width + self.enabled = True + + @abstractmethod + def draw(self, rect: rl.Rectangle) -> bool: + pass + + @abstractmethod + def get_width(self) -> int: + pass + + +class ToggleRightItem(RightItem): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH): + super().__init__(width) + self.toggle = Toggle(initial_state=initial_state) + self.state = initial_state + self.enabled = True + + def draw(self, rect: rl.Rectangle) -> bool: + if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)): + self.state = not self.state + return True + return False + + def get_width(self) -> int: + return self.width + + def set_state(self, state: bool): + self.state = state + self.toggle.set_state(state) + + def get_state(self) -> bool: + return self.state + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class ButtonRightItem(RightItem): + def __init__(self, text: str, width: int = BUTTON_WIDTH): + super().__init__(width) + self.text = text + self.enabled = True + + def draw(self, rect: rl.Rectangle) -> bool: + return ( + gui_button( + rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT), + self.text, + border_radius=BUTTON_BORDER_RADIUS, + font_weight=BUTTON_FONT_WEIGHT, + font_size=BUTTON_FONT_SIZE, + is_enabled=self.enabled, + ) + == 1 + ) + + def get_width(self) -> int: + return self.width + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class TextRightItem(RightItem): + def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE): + self.text = text + self.color = color + self.font_size = font_size + + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, font_size).x + super().__init__(int(text_width + 20)) + + def draw(self, rect: rl.Rectangle) -> bool: + font = gui_app.font(FontWeight.NORMAL) + text_size = measure_text_cached(font, self.text, self.font_size) + + # Center the text in the allocated rectangle + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + + rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color) + return False + + def get_width(self) -> int: + return self.width + + def set_text(self, text: str): + self.text = text + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, self.font_size).x + self.width = int(text_width + 20) + + +@dataclass +class ListItem: + title: str + icon: str | None = None + description: str | None = None + description_visible: bool = False + rect: "rl.Rectangle | None" = None + callback: Callable | None = None + right_item: RightItem | None = None + + # Cached properties for performance + _wrapped_description: str | None = None + _description_height: float = 0 + + def get_right_item(self) -> RightItem | None: + return self.right_item + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + if self.description_visible and self.description: + if not self._wrapped_description: + wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width) + self._wrapped_description = "\n".join(wrapped_lines) + self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding + return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING + return ITEM_BASE_HEIGHT + + def get_content_width(self, total_width: int) -> int: + if self.right_item: + return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING + return total_width + + def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: + if not self.right_item: + return rl.Rectangle(0, 0, 0, 0) + + right_width = self.right_item.get_width() + right_x = item_rect.x + item_rect.width - right_width + right_y = item_rect.y + return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) + + +class ListView: + def __init__(self, items: list[ListItem]): + self._items: list[ListItem] = items + self._last_dim: tuple[float, float] = (0, 0) + self.scroll_panel = GuiScrollPanel() + + self._font_normal = gui_app.font(FontWeight.NORMAL) + + # Interaction state + self._hovered_item: int = -1 + self._last_mouse_pos = rl.Vector2(0, 0) + + self._total_height: float = 0 + self._visible_range = (0, 0) + + def invalid_height_cache(self): + self._last_dim = (0, 0) + + def render(self, rect: rl.Rectangle): + if self._last_dim != (rect.width, rect.height): + self._update_item_rects(rect) + self._last_dim = (rect.width, rect.height) + + # Update layout and handle scrolling + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height) + scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) + + # Handle mouse interaction + if self.scroll_panel.is_click_valid(): + self._handle_mouse_interaction(rect, scroll_offset) + + # Set scissor mode for clipping + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + # Calculate visible range for performance + self._calculate_visible_range(rect, -scroll_offset.y) + + # Render only visible items + for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))): + item = self._items[i] + if item.rect: + adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) + self._render_item(item, adjusted_rect, i) + + if i != len(self._items) - 1: + rl.draw_line_ex( + rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1), + rl.Vector2( + adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1 + ), + 1.0, + LINE_COLOR, + ) + rl.end_scissor_mode() + + def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int): + content_x = rect.x + ITEM_PADDING + text_x = content_x + + # Calculate available width for main content + content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2)) + + # Draw icon if present + if item.icon: + icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) + rl.draw_texture( + icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE + ) + text_x += ICON_SIZE + ITEM_PADDING + + # Draw main text + text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE) + item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 + rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) + + # Draw description if visible (adjust width for right item) + if item.description_visible and item._wrapped_description: + desc_y = rect.y + ITEM_DESC_V_OFFSET + desc_max_width = int(content_width - (text_x - content_x)) + + # Re-wrap description if needed due to right item + if (item.right_item and item.description) and not item._wrapped_description: + wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width) + item._wrapped_description = "\n".join(wrapped_lines) + + rl.draw_text_ex( + self._font_normal, + item._wrapped_description, + rl.Vector2(text_x, desc_y), + ITEM_DESC_FONT_SIZE, + 0, + ITEM_DESC_TEXT_COLOR, + ) + + # Draw right item if present + if item.right_item: + right_rect = item.get_right_item_rect(rect) + # Adjust for scroll offset + right_rect.y = right_rect.y + if item.right_item.draw(right_rect): + # Right item was clicked/activated + if item.callback: + item.callback() + + def _update_item_rects(self, container_rect: rl.Rectangle) -> None: + current_y: float = 0.0 + self._total_height = 0 + + for item in self._items: + content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) + item_height = item.get_item_height(self._font_normal, content_width) + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) + current_y += item_height + self._total_height += item_height + + def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float): + if not self._items: + self._visible_range = (0, 0) + return + + visible_top = scroll_offset + visible_bottom = scroll_offset + rect.height + + start_idx = 0 + end_idx = len(self._items) + + # Find first visible item + for i, item in enumerate(self._items): + if item.rect and item.rect.y + item.rect.height >= visible_top: + start_idx = max(0, i - 1) + break + + # Find last visible item + for i in range(start_idx, len(self._items)): + item = self._items[i] + if item.rect and item.rect.y > visible_bottom: + end_idx = min(len(self._items), i + 2) + break + + self._visible_range = (start_idx, end_idx) + + def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): + mouse_pos = rl.get_mouse_position() + + self._hovered_item = -1 + if not rl.check_collision_point_rec(mouse_pos, rect): + return + + content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y + + for i, item in enumerate(self._items): + if item.rect: + # Check if mouse is within this item's bounds in content space + if ( + mouse_pos.x >= rect.x + and mouse_pos.x <= rect.x + rect.width + and content_mouse_y >= item.rect.y + and content_mouse_y <= item.rect.y + item.rect.height + ): + item_screen_y = item.rect.y + scroll_offset.y + if item_screen_y < rect.height and item_screen_y + item.rect.height > 0: + self._hovered_item = i + break + + # Handle click on main item (not right item) + if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0: + item = self._items[self._hovered_item] + + # Check if click was on right item area + if item.right_item and item.rect: + adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) + right_rect = item.get_right_item_rect(adjusted_rect) + if rl.check_collision_point_rec(mouse_pos, right_rect): + # Click was handled by right item, don't process main item click + return + + # Toggle description visibility if item has description + if item.description: + item.description_visible = not item.description_visible + # Force layout update when description visibility changes + self._last_dim = (0, 0) + + # Call item callback + if item.callback: + item.callback() + + +# Factory functions +def simple_item(title: str, callback: Callable | None = None) -> ListItem: + return ListItem(title=title, callback=callback) + + +def toggle_item( + title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "" +) -> ListItem: + toggle = ToggleRightItem(initial_state=initial_state) + return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback) + + +def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem: + button = ButtonRightItem(text=button_text) + return ListItem(title=title, description=description, right_item=button, callback=callback) + + +def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem: + text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255)) + return ListItem(title=title, description=description, right_item=text_item, callback=callback) diff --git a/system/ui/lib/toggle.py b/system/ui/lib/toggle.py index 5ae2500406..0128dca206 100644 --- a/system/ui/lib/toggle.py +++ b/system/ui/lib/toggle.py @@ -9,9 +9,9 @@ ANIMATION_SPEED = 8.0 class Toggle: - def __init__(self, x, y, initial_state=False): + def __init__(self, initial_state=False): self._state = initial_state - self._rect = rl.Rectangle(x, y, WIDTH, HEIGHT) + self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT) self._progress = 1.0 if initial_state else 0.0 self._target = self._progress @@ -20,17 +20,23 @@ class Toggle: if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): self._state = not self._state self._target = 1.0 if self._state else 0.0 + return 1 + return 0 def get_state(self): return self._state + def set_state(self, state: bool): + self._state = state + def update(self): if abs(self._progress - self._target) > 0.01: delta = rl.get_frame_time() * ANIMATION_SPEED self._progress += delta if self._progress < self._target else -delta self._progress = max(0.0, min(1.0, self._progress)) - def render(self): + def render(self, rect: rl.Rectangle): + self._rect.x, self._rect.y = rect.x, rect.y self. update() # Draw background bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT) @@ -42,15 +48,7 @@ class Toggle: knob_y = self._rect.y + HEIGHT / 2 rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR) + return self.handle_input() + def _blend_color(self, c1, c2, t): return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255) - - -if __name__ == "__main__": - from openpilot.system.ui.lib.application import gui_app - - gui_app.init_window("Text toggle example") - toggle = Toggle(100, 100) - for _ in gui_app.render(): - toggle.handle_input() - toggle.render() diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py new file mode 100644 index 0000000000..fdfb1970aa --- /dev/null +++ b/system/ui/lib/wrap_text.py @@ -0,0 +1,87 @@ +import pyray as rl +from openpilot.system.ui.lib.text_measure import measure_text_cached + +def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]: + if not word: + return [] + + parts = [] + remaining = word + + while remaining: + if measure_text_cached(font, remaining, font_size).x <= max_width: + parts.append(remaining) + break + + # Binary search for the longest substring that fits + left, right = 1, len(remaining) + best_fit = 1 + + while left <= right: + mid = (left + right) // 2 + substring = remaining[:mid] + width = measure_text_cached(font, substring, font_size).x + + if width <= max_width: + best_fit = mid + left = mid + 1 + else: + right = mid - 1 + + # Add the part that fits + parts.append(remaining[:best_fit]) + remaining = remaining[best_fit:] + + return parts + + +def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]: + if not text or max_width <= 0: + return [] + + words = text.split() + if not words: + return [] + + lines: list[str] = [] + current_line: list[str] = [] + current_width = 0 + space_width = int(measure_text_cached(font, " ", font_size).x) + + for word in words: + word_width = int(measure_text_cached(font, word, font_size).x) + + # Check if word alone exceeds max width (need to break the word) + if word_width > max_width: + # Finish current line if it has content + if current_line: + lines.append(" ".join(current_line)) + current_line = [] + current_width = 0 + + # Break the long word into parts + lines.extend(_break_long_word(font, word, font_size, max_width)) + continue + + # Calculate width if we add this word + needed_width = current_width + if current_line: # Need space before word + needed_width += space_width + needed_width += word_width + + # Check if word fits on current line + if needed_width <= max_width: + current_line.append(word) + current_width = needed_width + else: + # Start new line with this word + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_width = word_width + + # Add remaining words + if current_line: + lines.append(" ".join(current_line)) + + return lines From 0360926962ead2b08158b2d4a975ae688ab1190a Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 05:08:29 +0800 Subject: [PATCH 04/20] ui: fill the bg with disengaged color if no frame (#35451) fill the bg with disengaged color if no frame Co-authored-by: Shane Smiskol --- selfdrive/ui/onroad/augmented_road_view.py | 1 + selfdrive/ui/onroad/cameraview.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index fa7469d2dd..d727337e31 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -28,6 +28,7 @@ BORDER_COLORS = { class AugmentedRoadView(CameraView): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): super().__init__("camerad", stream_type) + self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED]) self.device_camera: DeviceCameraConfig | None = None self.view_from_calib = view_frame_from_device_frame.copy() diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index d59e7b0a48..2bc49fec03 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -74,6 +74,8 @@ class CameraView: self.egl_images: dict[int, EGLImage] = {} self.egl_texture: rl.Texture | None = None + self._placeholder_color : rl.Color | None = None + # Initialize EGL for zero-copy rendering on TICI if TICI: if not init_egl(): @@ -84,6 +86,10 @@ class CameraView: self.egl_texture = rl.load_texture_from_image(temp_image) rl.unload_image(temp_image) + def _set_placeholder_color(self, color: rl.Color): + """Set a placeholder color to be drawn when no frame is available.""" + self._placeholder_color = color + def switch_stream(self, stream_type: VisionStreamType) -> None: if self._stream_type != stream_type: cloudlog.debug(f'switching stream from {self._stream_type} to {stream_type}') @@ -130,6 +136,7 @@ class CameraView: def render(self, rect: rl.Rectangle): if not self._ensure_connection(): + self._draw_placeholder(rect) return # Try to get a new buffer without blocking @@ -139,6 +146,7 @@ class CameraView: self.frame = buffer if not self.frame: + self._draw_placeholder(rect) return transform = self._calc_frame_matrix(rect) @@ -163,6 +171,10 @@ class CameraView: else: self._render_textures(src_rect, dst_rect) + def _draw_placeholder(self, rect: rl.Rectangle): + if self._placeholder_color: + rl.draw_rectangle_rec(rect, self._placeholder_color) + def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None: """Render using EGL for direct buffer access""" if self.frame is None or self.egl_texture is None: From 8b516abb31ae6c68c52e09771c5e1fb6af05b6b5 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 05:14:40 +0800 Subject: [PATCH 05/20] ui: replace rl.measure_text with measure_text_cached in sidebar (#35450) Replace rl.measure_text with measure_text_cached --- selfdrive/ui/layouts/sidebar.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index eeb073e67b..343efe5851 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -5,6 +5,7 @@ from collections.abc import Callable from cereal import log from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached SIDEBAR_WIDTH = 300 METRIC_HEIGHT = 126 @@ -199,7 +200,7 @@ class Sidebar: # Draw text text = f"{metric.label}\n{metric.value}" - text_size = rl.measure_text_ex(self._font_bold, text, 35, 0) + text_size = measure_text_cached(self._font_bold, text, 35) text_pos = rl.Vector2( metric_rect.x + 22 + (metric_rect.width - 22 - text_size.x) / 2, metric_rect.y + (metric_rect.height - text_size.y) / 2 From 462301d2e0f6697ca2c5ca2e126ba387e83fb960 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Thu, 5 Jun 2025 05:20:52 +0800 Subject: [PATCH 06/20] ui: implement smooth camera stream switching with seamless transitions (#35449) implement smooth camera stream switching with seamless transitions Co-authored-by: Shane Smiskol --- selfdrive/ui/onroad/augmented_road_view.py | 14 ++-- selfdrive/ui/onroad/cameraview.py | 81 +++++++++++++++++++--- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index d727337e31..47769f268e 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -16,6 +16,8 @@ from openpilot.common.transformations.orientation import rot_from_euler OpState = log.SelfdriveState.OpenpilotState CALIBRATED = log.LiveCalibrationData.Status.calibrated +ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] BORDER_COLORS = { @@ -36,6 +38,7 @@ class AugmentedRoadView(CameraView): self._last_calib_time: float = 0 self._last_rect_dims = (0.0, 0.0) + self._last_stream_type = stream_type self._cached_matrix: np.ndarray | None = None self._content_rect = rl.Rectangle() @@ -120,6 +123,7 @@ class AugmentedRoadView(CameraView): current_dims = (self._content_rect.width, self._content_rect.height) if (self._last_calib_time == calib_time and self._last_rect_dims == current_dims and + self._last_stream_type == self.stream_type and self._cached_matrix is not None): return self._cached_matrix @@ -155,9 +159,10 @@ class AugmentedRoadView(CameraView): except (ZeroDivisionError, OverflowError): x_offset, y_offset = 0, 0 - # Update cache values + # Cache the computed transformation matrix to avoid recalculations self._last_calib_time = calib_time self._last_rect_dims = current_dims + self._last_stream_type = self.stream_type self._cached_matrix = np.array([ [zoom * 2 * cx / w, 0, -x_offset / w * 2], [0, zoom * 2 * cy / h, -y_offset / h * 2], @@ -176,14 +181,15 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(VisionStreamType.VISION_STREAM_ROAD) + road_camera_view = AugmentedRoadView(ROAD_CAM) print("***press space to switch camera view***") try: for _ in gui_app.render(): ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): - is_wide = road_camera_view.stream_type == VisionStreamType.VISION_STREAM_WIDE_ROAD - road_camera_view.switch_stream(VisionStreamType.VISION_STREAM_ROAD if is_wide else VisionStreamType.VISION_STREAM_WIDE_ROAD) + if WIDE_CAM in road_camera_view.available_streams: + stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + road_camera_view.switch_stream(stream) road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: road_camera_view.close() diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index 2bc49fec03..9f58133abf 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -57,9 +57,17 @@ else: class CameraView: def __init__(self, name: str, stream_type: VisionStreamType): - self.client = VisionIpcClient(name, stream_type, conflate=True) self._name = name + # Primary stream + self.client = VisionIpcClient(name, stream_type, conflate=True) self._stream_type = stream_type + self.available_streams: list[VisionStreamType] = [] + + # Target stream for switching + self._target_client: VisionIpcClient | None = None + self._target_stream_type: VisionStreamType | None = None + self._switching: bool = False + self._texture_needs_update = True self.last_connection_attempt: float = 0.0 @@ -91,12 +99,20 @@ class CameraView: self._placeholder_color = color def switch_stream(self, stream_type: VisionStreamType) -> None: - if self._stream_type != stream_type: - cloudlog.debug(f'switching stream from {self._stream_type} to {stream_type}') - self._clear_textures() - self.frame = None - self._stream_type = stream_type - self.client = VisionIpcClient(self._name, stream_type, conflate=True) + if self._stream_type == stream_type: + return + + if self._switching and self._target_stream_type == stream_type: + return + + cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}') + + if self._target_client: + del self._target_client + + self._target_stream_type = stream_type + self._target_client = VisionIpcClient(self._name, stream_type, conflate=True) + self._switching = True @property def stream_type(self) -> VisionStreamType: @@ -135,6 +151,9 @@ class CameraView: ]) def render(self, rect: rl.Rectangle): + if self._switching: + self._handle_switch() + if not self._ensure_connection(): self._draw_placeholder(rect) return @@ -226,6 +245,7 @@ class CameraView: def _ensure_connection(self) -> bool: if not self.client.is_connected(): self.frame = None + self.available_streams.clear() # Throttle connection attempts current_time = rl.get_time() @@ -237,16 +257,57 @@ class CameraView: return False cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}") - self._clear_textures() + self._initialize_textures() + self.available_streams = self.client.available_streams(self._name, block=False) + return True + + def _handle_switch(self) -> None: + """Check if target stream is ready and switch immediately.""" + if not self._target_client or not self._switching: + return + + # Try to connect target if needed + if not self._target_client.is_connected(): + if not self._target_client.connect(False) or not self._target_client.num_buffers: + return + + cloudlog.debug(f"Target stream connected: {self._target_stream_type}") + + # Check if target has frames ready + target_frame = self._target_client.recv(timeout_ms=0) + if target_frame: + self.frame = target_frame # Update current frame to target frame + self._complete_switch() + + def _complete_switch(self) -> None: + """Instantly switch to target stream.""" + cloudlog.debug(f"Switching to {self._target_stream_type}") + # Clean up current resources + if self.client: + del self.client + + # Switch to target + self.client = self._target_client + self._stream_type = self._target_stream_type + self._texture_needs_update = True + + # Reset state + self._target_client = None + self._target_stream_type = None + self._switching = False + + # Initialize textures for new stream + self._initialize_textures() + + def _initialize_textures(self): + self._clear_textures() if not TICI: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) - return True - def _clear_textures(self): if self.texture_y and self.texture_y.id: rl.unload_texture(self.texture_y) From 0d7b157965e246762f93a69456be46a7f78c803c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 4 Jun 2025 15:10:52 -0700 Subject: [PATCH 07/20] bump opendbc (#35465) bump --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 0d7fef4422..ed0fe857f9 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 0d7fef4422456aeb456fa895da1617a1f61f6896 +Subproject commit ed0fe857f9cda9720250eef9139210d58062cd4c From a8639d3feb79f77278f87f4517778b38eeea3fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 4 Jun 2025 23:40:41 -0700 Subject: [PATCH 08/20] rgb is default (#35467) * rgb is default * change dummy default --- selfdrive/test/process_replay/regen.py | 2 +- tools/lib/framereader.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/selfdrive/test/process_replay/regen.py b/selfdrive/test/process_replay/regen.py index 4d00613f0a..273659c9ff 100755 --- a/selfdrive/test/process_replay/regen.py +++ b/selfdrive/test/process_replay/regen.py @@ -23,7 +23,7 @@ class DummyFrameReader(BaseFrameReader): self.frame_count = frame_count self.frame_type = FrameType.raw - def get(self, idx, count=1, pix_fmt="yuv420p"): + def get(self, idx, count=1, pix_fmt="rgb24"): if pix_fmt == "rgb24": shape = (self.h, self.w, 3) elif pix_fmt == "nv12" or pix_fmt == "yuv420p": diff --git a/tools/lib/framereader.py b/tools/lib/framereader.py index 275b9b65b8..0eddc88868 100644 --- a/tools/lib/framereader.py +++ b/tools/lib/framereader.py @@ -209,7 +209,7 @@ class BaseFrameReader: def close(self): pass - def get(self, num, count=1, pix_fmt="yuv420p"): + def get(self, num, count=1, pix_fmt="rgb24"): raise NotImplementedError @@ -497,7 +497,7 @@ class GOPFrameReader(BaseFrameReader): return self.frame_cache[(num, pix_fmt)] - def get(self, num, count=1, pix_fmt="yuv420p"): + def get(self, num, count=1, pix_fmt="rgb24"): assert self.frame_count is not None if num + count > self.frame_count: @@ -523,12 +523,12 @@ class StreamFrameReader(StreamGOPReader, GOPFrameReader): GOPFrameReader.__init__(self, readahead, readbehind) -def GOPFrameIterator(gop_reader, pix_fmt): +def GOPFrameIterator(gop_reader, pix_fmt='rgb24'): dec = VideoStreamDecompressor(gop_reader.fn, gop_reader.vid_fmt, gop_reader.w, gop_reader.h, pix_fmt) yield from dec.read() -def FrameIterator(fn, pix_fmt, **kwargs): +def FrameIterator(fn, pix_fmt='rgb24', **kwargs): fr = FrameReader(fn, **kwargs) if isinstance(fr, GOPReader): yield from GOPFrameIterator(fr, pix_fmt) From 05fa135be662fd8d8772b5f8cb15818c67cf744d Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 6 Jun 2025 01:13:44 +0800 Subject: [PATCH 09/20] ui: make the toggle color match QT (#35466) make the toggle color match QT --- system/ui/lib/toggle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/ui/lib/toggle.py b/system/ui/lib/toggle.py index 0128dca206..32b9ca5416 100644 --- a/system/ui/lib/toggle.py +++ b/system/ui/lib/toggle.py @@ -1,6 +1,6 @@ import pyray as rl -ON_COLOR = rl.Color(0, 255, 0, 255) +ON_COLOR = rl.Color(51, 171, 76, 255) OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) KNOB_COLOR = rl.WHITE WIDTH, HEIGHT = 160, 80 From 3ad74b440c29e72d139ca097de097ff199487a5b Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Jun 2025 20:23:27 +0300 Subject: [PATCH 10/20] macos: no logcatd and proclogd (#35457) * don't build proclogd on mac * don't run logcatd and proclogd on mac --- SConstruct | 2 +- system/manager/process_config.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/SConstruct b/SConstruct index c534eedbe8..d5051c8fb2 100644 --- a/SConstruct +++ b/SConstruct @@ -342,13 +342,13 @@ SConscript(['rednose/SConscript']) # Build system services SConscript([ - 'system/proclogd/SConscript', 'system/ubloxd/SConscript', 'system/loggerd/SConscript', ]) if arch != "Darwin": SConscript([ 'system/logcatd/SConscript', + 'system/proclogd/SConscript', ]) if arch == "larch64": diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 738c20a85b..ecd5b724cd 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -1,5 +1,6 @@ import os import operator +import platform from cereal import car from openpilot.common.params import Params @@ -70,8 +71,8 @@ procs = [ NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM), PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM), - NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad), - NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad), + NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad, platform.system() != "Darwin"), + NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad, platform.system() != "Darwin"), PythonProcess("micd", "system.micd", iscar), PythonProcess("timed", "system.timed", always_run, enabled=not PC), From ef6719f24eeef7c93118e85477b7cb213aea21cc Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Jun 2025 20:23:51 +0300 Subject: [PATCH 11/20] webcam: make driver camera optional (#35455) * webcam: driverCamera optional * webcam: update README --- tools/webcam/README.md | 20 ++++++++++---------- tools/webcam/camerad.py | 9 +++++++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/tools/webcam/README.md b/tools/webcam/README.md index f4b46d1f66..67ad2cc8cb 100644 --- a/tools/webcam/README.md +++ b/tools/webcam/README.md @@ -1,22 +1,22 @@ # Run openpilot with webcam on PC What's needed: -- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216)) +- Ubuntu 24.04 ([WSL2 is not supported](https://github.com/commaai/openpilot/issues/34216)) or macOS - GPU (recommended) -- Two USB webcams, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615) -- [Car harness](https://comma.ai/shop/products/comma-car-harness) with black panda to connect to your car -- [Panda paw](https://comma.ai/shop/products/panda-paw) or USB-A to USB-A cable to connect panda to your computer -That's it! +- One USB webcam, at least 720p and 78 degrees FOV (e.g. Logitech C920/C615, NexiGo N60) +- [Car harness](https://comma.ai/shop/products/comma-car-harness) +- [panda](https://comma.ai/shop/panda) +- USB-A to USB-A cable to connect panda to your computer ## Setup openpilot - Follow [this readme](../README.md) to install and build the requirements -- Install OpenCL Driver +- Install OpenCL Driver (Ubuntu) ``` sudo apt install pocl-opencl-icd ``` ## Connect the hardware -- Connect the road facing camera first, then the driver facing camera +- Connect the camera first - Connect your computer to panda ## GO @@ -24,12 +24,12 @@ sudo apt install pocl-opencl-icd USE_WEBCAM=1 system/manager/manager.py ``` - Start the car, then the UI should show the road webcam's view -- Adjust and secure the webcams. +- Adjust and secure the webcam - Finish calibration and engage! ## Specify Cameras -Use the `ROAD_CAM`, `DRIVER_CAM`, and optional `WIDE_CAM` environment variables to specify which camera is which (ie. `DRIVER_CAM=2` uses `/dev/video2` for the driver-facing camera): +Use the `ROAD_CAM` (default 0) and optional `DRIVER_CAM`, `WIDE_CAM` environment variables to specify which camera is which (ie. `ROAD_CAM=1` uses `/dev/video1`, on Ubuntu, for the road camera): ``` -USE_WEBCAM=1 ROAD_CAM=4 WIDE_CAM=6 system/manager/manager.py +USE_WEBCAM=1 ROAD_CAM=1 system/manager/manager.py ``` diff --git a/tools/webcam/camerad.py b/tools/webcam/camerad.py index a0916ed5ee..cb096dc00b 100755 --- a/tools/webcam/camerad.py +++ b/tools/webcam/camerad.py @@ -9,14 +9,19 @@ from cereal import messaging from openpilot.tools.webcam.camera import Camera from openpilot.common.realtime import Ratekeeper +ROAD_CAM = os.getenv("ROAD_CAM", "0") WIDE_CAM = os.getenv("WIDE_CAM") +DRIVER_CAM = os.getenv("DRIVER_CAM") + CameraType = namedtuple("CameraType", ["msg_name", "stream_type", "cam_id"]) + CAMERAS = [ - CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, os.getenv("ROAD_CAM", "0")), - CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, os.getenv("DRIVER_CAM", "2")), + CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, ROAD_CAM) ] if WIDE_CAM: CAMERAS.append(CameraType("wideRoadCameraState", VisionStreamType.VISION_STREAM_WIDE_ROAD, WIDE_CAM)) +if DRIVER_CAM: + CAMERAS.append(CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, DRIVER_CAM)) class Camerad: def __init__(self): From 459179ff6c3e2313897332029a28a3f6a9b1c603 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 6 Jun 2025 02:01:49 +0800 Subject: [PATCH 12/20] ui: fix settings close button shape and press state (#35469) fix button rounding and pressed state display in settings --- selfdrive/ui/layouts/settings/settings.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index fc03529e56..849a26f0dd 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -50,8 +50,6 @@ class SettingsLayout: def __init__(self): self._params = Params() self._current_panel = PanelType.DEVICE - self._close_btn_pressed = False - self._scroll_offset = 0.0 self._max_scroll = 0.0 # Panel configuration @@ -93,12 +91,15 @@ class SettingsLayout: rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 45, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE ) - close_color = CLOSE_BTN_PRESSED if self._close_btn_pressed else CLOSE_BTN_COLOR - rl.draw_rectangle_rounded(close_btn_rect, 0.5, 20, close_color) + pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and + rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect)) + close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR + rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color) + close_text_size = rl.measure_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, 140, 0) close_text_pos = rl.Vector2( close_btn_rect.x + (close_btn_rect.width - close_text_size.x) / 2, - close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2 - 20, + close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2, ) rl.draw_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, close_text_pos, 140, 0, TEXT_SELECTED) @@ -121,7 +122,6 @@ class SettingsLayout: # Button styling is_selected = panel_type == self._current_panel text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL - # Draw button text (right-aligned) text_size = rl.measure_text_ex(self._font_medium, panel_info.name, 65, 0) text_pos = rl.Vector2( @@ -155,7 +155,6 @@ class SettingsLayout: def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: # Check close button if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): - self._close_btn_pressed = True if self._close_callback: self._close_callback() return True @@ -171,9 +170,6 @@ class SettingsLayout: def _switch_to_panel(self, panel_type: PanelType): if panel_type != self._current_panel: self._current_panel = panel_type - self._scroll_offset = 0.0 # Reset scroll when switching panels - self._transition_progress = 0.0 - self._transitioning = True def set_current_panel(self, index: int, param: str = ""): panel_types = list(self._panels.keys()) From 1668a46b6a74d53dbd81e6beb48b91d0212e2908 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 5 Jun 2025 11:42:05 -0700 Subject: [PATCH 13/20] bump submodules --- opendbc_repo | 2 +- panda | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index ed0fe857f9..e370a03607 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit ed0fe857f9cda9720250eef9139210d58062cd4c +Subproject commit e370a036073bf38856f3cd9936c0bd44ff349dea diff --git a/panda b/panda index c19692245c..e462c34d3d 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit c19692245cfe681389a1e9ddb2401340cf08759d +Subproject commit e462c34d3d5402c2c4a274bee7abaafabf516993 From 8aadf02b2fd91f4e1285e18c2c7feb32d93b66f5 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 5 Jun 2025 12:51:21 -0700 Subject: [PATCH 14/20] selfdrived: more robust NVMe check --- selfdrive/selfdrived/selfdrived.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index f3dabf3791..8529dff4e2 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -117,7 +117,8 @@ class SelfdriveD: # some comma three with NVMe experience NVMe dropouts mid-drive that # cause loggerd to crash on write, so ignore it only on that platform self.ignored_processes = set() - if HARDWARE.get_device_type() == 'tici' and os.path.exists('/dev/nvme0'): + nvme_expected = os.path.exists('/dev/nvme0n1') or (not os.path.isfile("/persist/comma/living-in-the-moment")) + if HARDWARE.get_device_type() == 'tici' and nvme_expected: self.ignored_processes = {'loggerd', } # Determine startup event From 23ea85dca656e3646354799bf59d896386c09ac0 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 6 Jun 2025 04:14:57 +0800 Subject: [PATCH 15/20] ui: kick watchdog (#35460) * kick watchdog * use global * use monotonic --- common/watchdog.py | 22 ++++++++++++++++++++++ selfdrive/ui/ui.py | 4 ++++ system/manager/process.py | 3 +-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 common/watchdog.py diff --git a/common/watchdog.py b/common/watchdog.py new file mode 100644 index 0000000000..ddb6f744e9 --- /dev/null +++ b/common/watchdog.py @@ -0,0 +1,22 @@ +import os +import time +import struct +from openpilot.system.hardware.hw import Paths + +WATCHDOG_FN = f"{Paths.shm_path()}/wd_" +_LAST_KICK = 0.0 + +def kick_watchdog(): + global _LAST_KICK + current_time = time.monotonic() + + if current_time - _LAST_KICK < 1.0: + return + + try: + with open(f"{WATCHDOG_FN}{os.getpid()}", 'wb') as f: + f.write(struct.pack(' Date: Fri, 6 Jun 2025 04:15:12 +0800 Subject: [PATCH 16/20] ui: add support for enabling/disabling the toggle (#35471) add support for enabling/disabling the toggle --- system/ui/lib/toggle.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/system/ui/lib/toggle.py b/system/ui/lib/toggle.py index 32b9ca5416..5b3fbf277c 100644 --- a/system/ui/lib/toggle.py +++ b/system/ui/lib/toggle.py @@ -3,6 +3,9 @@ import pyray as rl ON_COLOR = rl.Color(51, 171, 76, 255) OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) KNOB_COLOR = rl.WHITE +DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on +DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255) +DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255) WIDTH, HEIGHT = 160, 80 BG_HEIGHT = 60 ANIMATION_SPEED = 8.0 @@ -11,11 +14,15 @@ ANIMATION_SPEED = 8.0 class Toggle: def __init__(self, initial_state=False): self._state = initial_state + self._enabled = True self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT) self._progress = 1.0 if initial_state else 0.0 self._target = self._progress def handle_input(self): + if not self._enabled: + return 0 + if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON): if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): self._state = not self._state @@ -28,6 +35,13 @@ class Toggle: def set_state(self, state: bool): self._state = state + self._target = 1.0 if state else 0.0 + + def set_enabled(self, enabled: bool): + self._enabled = enabled + + def is_enabled(self): + return self._enabled def update(self): if abs(self._progress - self._target) > 0.01: @@ -37,16 +51,23 @@ class Toggle: def render(self, rect: rl.Rectangle): self._rect.x, self._rect.y = rect.x, rect.y - self. update() + self.update() + + if self._enabled: + bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress) + knob_color = KNOB_COLOR + else: + bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress) + knob_color = DISABLED_KNOB_COLOR + # Draw background bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT) - bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress) rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color) # Draw knob knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress knob_y = self._rect.y + HEIGHT / 2 - rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR) + rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color) return self.handle_input() From 6b59f67ab548415b98cbd765b20a80cd4b25381d Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 6 Jun 2025 04:16:07 +0800 Subject: [PATCH 17/20] ui: implement home layout with fully functional offroad alerts (#35468) implement home layout with offroad alerts --- selfdrive/ui/layouts/home.py | 213 +++++++++++++++- selfdrive/ui/widgets/offroad_alerts.py | 328 +++++++++++++++++++++++++ 2 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 selfdrive/ui/widgets/offroad_alerts.py diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 56df253b52..55de9e82f1 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.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 diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py new file mode 100644 index 0000000000..df88dc8554 --- /dev/null +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -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) From 3a622cbe255735d02b13227b4c910dffacf6e175 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Fri, 6 Jun 2025 04:16:38 +0800 Subject: [PATCH 18/20] ui: add WiFi manager to settings (#35454) add WiFi manager to settings --- selfdrive/ui/layouts/network.py | 19 +++++++++++++++++++ selfdrive/ui/layouts/settings/settings.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 selfdrive/ui/layouts/network.py diff --git a/selfdrive/ui/layouts/network.py b/selfdrive/ui/layouts/network.py new file mode 100644 index 0000000000..bf52f18876 --- /dev/null +++ b/selfdrive/ui/layouts/network.py @@ -0,0 +1,19 @@ +import pyray as rl +from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper +from openpilot.system.ui.widgets.network import WifiManagerUI + + +class NetworkLayout: + def __init__(self): + self.wifi_manager = WifiManagerWrapper() + self.wifi_ui = WifiManagerUI(self.wifi_manager) + + def render(self, rect: rl.Rectangle): + self.wifi_ui.render(rect) + + @property + def require_full_screen(self): + return self.wifi_ui.require_full_screen + + def shutdown(self): + self.wifi_manager.shutdown() diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 849a26f0dd..b3d28b0adf 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -9,6 +9,7 @@ from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.label import gui_text_box +from openpilot.selfdrive.ui.layouts.network import NetworkLayout # Import individual panels @@ -58,7 +59,7 @@ class SettingsLayout: PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.NETWORK: PanelInfo("Network", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.NETWORK: PanelInfo("Network", NetworkLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), } From 2000f9aff3477e6eaaf9dea4006acd93e59bc816 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 5 Jun 2025 14:37:12 -0700 Subject: [PATCH 19/20] raylib: ban non-cached measure_text_ex (#35462) * add * use it * Update pyproject.toml * many more * comment --- pyproject.toml | 1 + selfdrive/ui/layouts/settings/settings.py | 5 +++-- system/ui/lib/button.py | 3 ++- system/ui/lib/inputbox.py | 7 ++++--- system/ui/lib/label.py | 7 ++++--- system/ui/lib/text_measure.py | 3 ++- system/ui/spinner.py | 3 ++- system/ui/text.py | 3 ++- 8 files changed, 20 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9a9fcab55..ce763d152d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ lint.flake8-implicit-str-concat.allow-multiline = false "tools".msg = "Use openpilot.tools" "pytest.main".msg = "pytest.main requires special handling that is easy to mess up!" "unittest".msg = "Use pytest" +"pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" [tool.coverage.run] concurrency = ["multiprocessing", "thread"] diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index b3d28b0adf..1afa4f8f4a 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -9,6 +9,7 @@ from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.label import gui_text_box +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.selfdrive.ui.layouts.network import NetworkLayout # Import individual panels @@ -97,7 +98,7 @@ class SettingsLayout: close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color) - close_text_size = rl.measure_text_ex(self._font_bold, SETTINGS_CLOSE_TEXT, 140, 0) + close_text_size = measure_text_cached(self._font_bold, SETTINGS_CLOSE_TEXT, 140) close_text_pos = rl.Vector2( close_btn_rect.x + (close_btn_rect.width - close_text_size.x) / 2, close_btn_rect.y + (close_btn_rect.height - close_text_size.y) / 2, @@ -124,7 +125,7 @@ class SettingsLayout: is_selected = panel_type == self._current_panel text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL # Draw button text (right-aligned) - text_size = rl.measure_text_ex(self._font_medium, panel_info.name, 65, 0) + text_size = measure_text_cached(self._font_medium, panel_info.name, 65) text_pos = rl.Vector2( button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2 ) diff --git a/system/ui/lib/button.py b/system/ui/lib/button.py index 9ca82c9732..024e360448 100644 --- a/system/ui/lib/button.py +++ b/system/ui/lib/button.py @@ -1,6 +1,7 @@ import pyray as rl from enum import IntEnum from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached class ButtonStyle(IntEnum): @@ -99,7 +100,7 @@ def gui_button( # Handle icon and text positioning font = gui_app.font(font_weight) - text_size = rl.measure_text_ex(font, text, font_size, 0) + text_size = measure_text_cached(font, text, font_size) text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering # Draw icon if provided diff --git a/system/ui/lib/inputbox.py b/system/ui/lib/inputbox.py index 367e0dc7e4..ae036c03c2 100644 --- a/system/ui/lib/inputbox.py +++ b/system/ui/lib/inputbox.py @@ -1,6 +1,7 @@ import pyray as rl import time from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.text_measure import measure_text_cached PASSWORD_MASK_CHAR = "•" @@ -60,7 +61,7 @@ class InputBox: padding = 10 if self._cursor_position > 0: - cursor_x = rl.measure_text_ex(font, display_text[: self._cursor_position], self._font_size, 0).x + cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x else: cursor_x = 0 @@ -141,7 +142,7 @@ class InputBox: if self._show_cursor: cursor_x = rect.x + padding if len(display_text) > 0 and self._cursor_position > 0: - cursor_x += rl.measure_text_ex(font, display_text[: self._cursor_position], font_size, 0).x + cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x # Apply text offset to cursor position cursor_x -= self._text_offset @@ -182,7 +183,7 @@ class InputBox: min_distance = float('inf') for i in range(len(self._input_text) + 1): - char_width = rl.measure_text_ex(font, display_text[:i], font_size, 0).x + char_width = measure_text_cached(font, display_text[:i], font_size).x distance = abs(relative_x - char_width) if distance < min_distance: min_distance = distance diff --git a/system/ui/lib/label.py b/system/ui/lib/label.py index 00a4eda7c6..82533660de 100644 --- a/system/ui/lib/label.py +++ b/system/ui/lib/label.py @@ -1,5 +1,6 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.utils import GuiStyleContext @@ -14,7 +15,7 @@ def gui_label( elide_right: bool = True ): font = gui_app.font(font_weight) - text_size = rl.measure_text_ex(font, text, font_size, 0) + text_size = measure_text_cached(font, text, font_size) display_text = text # Elide text to fit within the rectangle @@ -24,13 +25,13 @@ def gui_label( while left < right: mid = (left + right) // 2 candidate = text[:mid] + ellipsis - candidate_size = rl.measure_text_ex(font, candidate, font_size, 0) + candidate_size = measure_text_cached(font, candidate, font_size) if candidate_size.x <= rect.width: left = mid + 1 else: right = mid display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis - text_size = rl.measure_text_ex(font, display_text, font_size, 0) + text_size = measure_text_cached(font, display_text, font_size) # Calculate horizontal position based on alignment text_x = rect.x + { diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py index 9cdd566409..c172f94251 100644 --- a/system/ui/lib/text_measure.py +++ b/system/ui/lib/text_measure.py @@ -4,10 +4,11 @@ _cache: dict[int, rl.Vector2] = {} def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2: + """Caches text measurements to avoid redundant calculations.""" key = hash((font.texture.id, text, font_size, spacing)) if key in _cache: return _cache[key] - result = rl.measure_text_ex(font, text, font_size, spacing) + result = rl.measure_text_ex(font, text, font_size, spacing) # noqa: TID251 _cache[key] = result return result diff --git a/system/ui/spinner.py b/system/ui/spinner.py index 2af24c4e51..3825f1300d 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -4,6 +4,7 @@ import threading import time from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.window import BaseWindow from openpilot.system.ui.text import wrap_text @@ -78,7 +79,7 @@ class SpinnerRenderer: rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE) elif wrapped_lines: for i, line in enumerate(wrapped_lines): - text_size = rl.measure_text_ex(gui_app.font(), line, FONT_SIZE, 0.0) + text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE) rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT), FONT_SIZE, 0.0, rl.WHITE) diff --git a/system/ui/text.py b/system/ui/text.py index 82e64d836f..63d00fbde3 100755 --- a/system/ui/text.py +++ b/system/ui/text.py @@ -3,6 +3,7 @@ import re import time import pyray as rl from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.button import gui_button, ButtonStyle from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app @@ -33,7 +34,7 @@ def wrap_text(text, font_size, max_width): while len(words): word = words.pop(0) test_line = current_line + word + (words.pop(0) if words else "") - if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width: + if measure_text_cached(font, test_line, font_size).x <= max_width: current_line = test_line else: lines.append(current_line) From ce9d1683a572aae708be7dea1dd988fe580bd834 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 5 Jun 2025 16:58:44 -0700 Subject: [PATCH 20/20] bump to 0.9.10 --- RELEASES.md | 3 +++ common/version.h | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index 5d74689a2d..91652d6dc7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,3 +1,6 @@ +Version 0.9.10 (2025-06-30) +======================== + Version 0.9.9 (2025-05-23) ======================== * New driving model diff --git a/common/version.h b/common/version.h index 6351a5b3ff..67b69da7c7 100644 --- a/common/version.h +++ b/common/version.h @@ -1 +1 @@ -#define COMMA_VERSION "0.9.9" +#define COMMA_VERSION "0.9.10"