diff --git a/opendbc_repo/opendbc/car/torque_data/substitute.toml b/opendbc_repo/opendbc/car/torque_data/substitute.toml index df3fa8d4..9f67cac7 100644 --- a/opendbc_repo/opendbc/car/torque_data/substitute.toml +++ b/opendbc_repo/opendbc/car/torque_data/substitute.toml @@ -96,6 +96,7 @@ legend = ["LAT_ACCEL_FACTOR", "MAX_LAT_ACCEL_MEASURED", "FRICTION"] "CADILLAC_CT6_CC" = "CHEVROLET_VOLT" "CADILLAC_XT5_CC" = "GMC_ACADIA" +"BUICK_BABYENCLAVE" = "GMC_ACADIA" "CHEVROLET_EQUINOX_CC" = "CHEVROLET_EQUINOX" "GMC_YUKON_CC" = "CHEVROLET_SILVERADO" "CHEVROLET_TRAILBLAZER_CC" = "CHEVROLET_TRAILBLAZER" diff --git a/opendbc_repo/opendbc/car/toyota/carcontroller.py b/opendbc_repo/opendbc/car/toyota/carcontroller.py index c79b5e34..b3f94e64 100644 --- a/opendbc_repo/opendbc/car/toyota/carcontroller.py +++ b/opendbc_repo/opendbc/car/toyota/carcontroller.py @@ -49,7 +49,7 @@ def get_long_tune(CP, params): k_f = 1.0 if CP.carFingerprint == CAR.TOYOTA_PRIUS: - k_f = 0.4 + k_f = 0.7 elif CP.carFingerprint not in TSS2_CAR: kiBP = [0., 5., 35.] kiV = [3.6, 2.4, 1.5] diff --git a/panda/board/obj/gitversion.h b/panda/board/obj/gitversion.h index b0dcbb6b..bce1bd4a 100644 --- a/panda/board/obj/gitversion.h +++ b/panda/board/obj/gitversion.h @@ -1,2 +1,2 @@ extern const uint8_t gitversion[19]; -const uint8_t gitversion[19] = "DEV-e0cf4fd9-DEBUG"; +const uint8_t gitversion[19] = "DEV-7ceb47eb-DEBUG"; diff --git a/panda/board/obj/version b/panda/board/obj/version index 1d14da96..2c948033 100644 --- a/panda/board/obj/version +++ b/panda/board/obj/version @@ -1 +1 @@ -DEV-e0cf4fd9-DEBUG \ No newline at end of file +DEV-7ceb47eb-DEBUG \ No newline at end of file diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 50009a76..06cad2d3 100644 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -9,7 +9,7 @@ from cereal import car, custom, log from msgq.visionipc import VisionIpcClient, VisionStreamType -from opendbc.car.gm.values import CC_ONLY_CAR +from opendbc.car.gm.values import GMFlags from openpilot.common.params import Params from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper, DT_CTRL @@ -165,7 +165,7 @@ class SelfdriveD: self.starpilot_events_prev = [] - self.has_menu = self.CP.brand == "gm" and self.CP.carFingerprint not in CC_ONLY_CAR + self.has_menu = self.CP.brand == "gm" and not (self.CP.flags & GMFlags.NO_CAMERA.value) self.FPCP = messaging.log_from_bytes(self.params.get("StarPilotCarParams", block=True), custom.StarPilotCarParams) diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 199bf867..c2ebcb49 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -198,11 +198,12 @@ class AetherTile(Widget): class HubTile(AetherTile): - def __init__(self, title: str, desc: str, icon_path: str, on_click: Callable | None = None, starpilot_icon: bool = False, bg_color: rl.Color | str | None = None): + def __init__(self, title: str, desc: str, icon_path: str, on_click: Callable | None = None, starpilot_icon: bool = False, bg_color: rl.Color | str | None = None, get_status: Callable[[], str] | None = None): if bg_color: super().__init__(surface_color=bg_color, on_click=on_click) else: super().__init__(on_click=on_click) + self.get_status = get_status self.title = title self.desc = desc if icon_path: @@ -214,6 +215,17 @@ class HubTile(AetherTile): def _render(self, rect: rl.Rectangle): face = self._render_layers(rect) + + status_text = self.get_status() if self.get_status else "" + if status_text: + import re + m = re.search(r'(\d+)%$', status_text) + if m: + ratio = min(1.0, max(0.0, float(m.group(1)) / 100.0)) + if ratio > 0.05: + fill_rect = rl.Rectangle(face.x, face.y, face.width * ratio, face.height) + rl.draw_rectangle_rounded(fill_rect, TILE_RADIUS, 10, rl.Color(255, 255, 255, 40)) + content_pad = SPACING.tile_content max_w = face.width - (content_pad * 2) lines = self._wrap_text(self._font_title, self.title, max_w, 30) @@ -224,8 +236,9 @@ class HubTile(AetherTile): for i, line in enumerate(lines): self._draw_text_fit(self._font_title, line, rl.Vector2(face.x + content_pad, ty + i * (line_h + line_spacing)), max_w, line_h, align_center=True) - if self.desc: - desc_lines = self._wrap_text(self._font_desc, self.desc, max_w, 18, max_lines=3) + desc_to_render = status_text if status_text else self.desc + if desc_to_render: + desc_lines = self._wrap_text(self._font_desc, desc_to_render, max_w, 18, max_lines=3) desc_y = ty + len(lines) * (line_h + line_spacing) + SPACING.lg for i, line in enumerate(desc_lines): self._draw_text_fit(self._font_desc, line, rl.Vector2(face.x + content_pad, desc_y + i * 20), max_w, 18, align_center=True) @@ -492,14 +505,14 @@ class AetherSliderDialog(Widget): if self._is_pressed_ok: self._ok_target = 0.0 if rl.check_collision_point_rec(mouse_pos, self._ok_rect): - self._user_callback(DialogResult.CONFIRM, self._current_val) gui_app.set_modal_overlay(None) + self._user_callback(DialogResult.CONFIRM, self._current_val) self._is_pressed_ok = False if self._is_pressed_cancel: self._cancel_target = 0.0 if rl.check_collision_point_rec(mouse_pos, self._cancel_rect): - self._user_callback(DialogResult.CANCEL, self._current_val) gui_app.set_modal_overlay(None) + self._user_callback(DialogResult.CANCEL, self._current_val) self._is_pressed_cancel = False def _render(self, rect: rl.Rectangle): @@ -635,26 +648,46 @@ class TileGrid(Widget): self.tiles = [] self._uniform_width = uniform_width + @property + def gap(self) -> int: + return self._gap + def add_tile(self, tile: Widget): self.tiles.append(tile) def clear(self): self.tiles.clear() + def get_column_count(self, tile_count: int | None = None) -> int: + count = len(self.tiles) if tile_count is None else tile_count + if count <= 0: + return self._columns or 1 + if self._columns is not None: + return self._columns + if count == 1: return 1 + if count == 2: return 2 + if count == 3: return 3 + if count == 4: return 2 + if count <= 6: return 3 + return 4 + + def get_row_count(self, tile_count: int | None = None) -> int: + count = len(self.tiles) if tile_count is None else tile_count + if count <= 0: + return 0 + cols = self.get_column_count(count) + return (count + cols - 1) // cols + + def get_internal_gap_height(self, tile_count: int | None = None) -> float: + rows = self.get_row_count(tile_count) + return self._gap * max(0, rows - 1) + def _render(self, rect: rl.Rectangle): self.set_rect(rect) if not self.tiles: return tiles_to_render = list(self.tiles) count = len(tiles_to_render) - if self._columns is not None: - cols = self._columns - else: - if count == 1: cols = 1 - elif count == 2: cols = 2 - elif count == 3: cols = 3 - elif count == 4: cols = 2 - elif count <= 6: cols = 3 - else: cols = 4 - rows = (count + cols - 1) // cols + cols = self.get_column_count(count) + rows = self.get_row_count(count) tile_h = (rect.height - (self._gap * (rows - 1))) / rows uniform_tile_w = (rect.width - (self._gap * (cols - 1))) / cols if self._uniform_width else 0 tile_idx = 0 diff --git a/selfdrive/ui/layouts/settings/starpilot/driving_model.py b/selfdrive/ui/layouts/settings/starpilot/driving_model.py index 12a4e9bd..0729c3c5 100644 --- a/selfdrive/ui/layouts/settings/starpilot/driving_model.py +++ b/selfdrive/ui/layouts/settings/starpilot/driving_model.py @@ -1,12 +1,14 @@ from __future__ import annotations +from dataclasses import dataclass +from collections.abc import Callable import json -import os +import shutil import threading -from pathlib import Path from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.starpilot.assets.model_manager import ModelManager -from openpilot.system.hardware import HARDWARE, PC +from openpilot.starpilot.assets.model_manager import ModelManager, TINYGRAD_VERSIONS, canonical_model_key, is_builtin_model_key, model_key_aliases +from openpilot.starpilot.common.starpilot_variables import MODELS_PATH, update_starpilot_toggles +from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult @@ -15,105 +17,158 @@ from openpilot.system.ui.widgets.selection_dialog import SelectionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import AetherSliderDialog + +@dataclass +class ModelCatalogEntry: + key: str + name: str + series: str + version: str + released: str + builtin: bool + installed: bool + partial: bool + community_favorite: bool + user_favorite: bool + + +def _clean_model_name(name: str) -> str: + return str(name or "").replace("_default", "").replace("(Default)", "").strip() + class StarPilotDrivingModelLayout(StarPilotPanel): def __init__(self): super().__init__() - if PC: - self._model_dir = Path(os.path.expanduser("~/.comma/starpilot/data/models")) - else: - self._model_dir = Path("/data/starpilot/models") + self._model_dir = MODELS_PATH self._model_dir.mkdir(parents=True, exist_ok=True) - self._available_models = [] - self._available_model_names = [] - self._available_model_series = [] - self._available_model_versions = [] - self._model_released_dates = {} - self._model_file_to_name = {} - self._model_series_map = {} - self._model_version_map = {} - self._current_model_name = tr("Default") + self._catalog_entries: dict[str, ModelCatalogEntry] = {} + self._model_file_to_name: dict[str, str] = {} + self._model_file_to_name_processed: dict[str, str] = {} + self._model_series_map: dict[str, str] = {} + self._model_released_dates: dict[str, str] = {} + self._model_version_map: dict[str, str] = {} + self._community_favorites: set[str] = set() + self._user_favorites: set[str] = set() + self._current_model_key = self._default_model_key() + self._current_model_name = self._default_model_name() - self.CATEGORIES = [ + self.SECTIONS = [ { - "title": tr_noop("Select Model"), - "type": "value", - "icon": "toggle_icons/icon_steering.png", - "on_click": self._on_select_model_clicked, - "get_value": lambda: self._current_model_name, - "visible": lambda: not self._params.get_bool("ModelRandomizer"), - "color": "#597497" + "title": tr_noop("Model Selection"), + "columns": 1, + "uniform_width": True, + "categories": [ + { + "title": tr_noop("Select Model"), + "type": "value", + "icon": "toggle_icons/icon_steering.png", + "on_click": self._on_select_model_clicked, + "get_value": lambda: self._current_model_name, + "visible": lambda: not self._params.get_bool("ModelRandomizer"), + "color": "#597497" + }, + ], }, { - "title": tr_noop("Download Models"), - "type": "hub", - "icon": "toggle_icons/icon_system.png", - "on_click": self._on_download_clicked, - "color": "#597497" + "title": tr_noop("Model Actions"), + "columns": 2, + "uniform_width": True, + "categories": [ + { + "title": tr_noop("Download Models"), + "type": "hub", + "icon": "toggle_icons/icon_system.png", + "on_click": self._on_download_clicked, + "color": "#597497", + "get_status": lambda: self._params_memory.get("ModelDownloadProgress", encoding="utf-8") if self._is_download_active() else "" + }, + { + "title": tr_noop("Delete Models"), + "type": "hub", + "icon": "toggle_icons/icon_system.png", + "on_click": self._on_delete_clicked, + "color": "#597497" + }, + ], }, { - "title": tr_noop("Delete Models"), - "type": "hub", - "icon": "toggle_icons/icon_system.png", - "on_click": self._on_delete_clicked, - "color": "#597497" + "title": tr_noop("Automation"), + "columns": 2, + "uniform_width": True, + "categories": [ + { + "title": tr_noop("Model Randomizer"), + "type": "toggle", + "icon": "toggle_icons/icon_conditional.png", + "get_state": lambda: self._params.get_bool("ModelRandomizer"), + "set_state": self._on_model_randomizer_toggled, + "color": "#597497" + }, + { + "title": tr_noop("Auto Download"), + "type": "toggle", + "icon": "toggle_icons/icon_system.png", + "get_state": lambda: self._params.get_bool("AutomaticallyDownloadModels"), + "set_state": lambda s: self._params.put_bool("AutomaticallyDownloadModels", s), + "color": "#597497" + }, + ], }, { - "title": tr_noop("Model Randomizer"), - "type": "toggle", - "icon": "toggle_icons/icon_conditional.png", - "get_state": lambda: self._params.get_bool("ModelRandomizer"), - "set_state": self._on_model_randomizer_toggled, - "color": "#597497" - }, - { - "title": tr_noop("Recovery Power"), - "type": "value", - "icon": "toggle_icons/icon_road.png", - "get_value": lambda: f"{self._params.get_float('RecoveryPower'):.1f}", - "on_click": self._on_recovery_power_clicked, - "visible": lambda: self._params.get_int("TuningLevel") == 3, - "color": "#597497" - }, - { - "title": tr_noop("Stop Distance"), - "type": "value", - "icon": "toggle_icons/icon_road.png", - "get_value": lambda: f"{self._params.get_float('StopDistance'):.1f}m", - "on_click": self._on_stop_distance_clicked, - "visible": lambda: self._params.get_int("TuningLevel") == 3, - "color": "#597497" - }, - { - "title": tr_noop("Auto Download"), - "type": "toggle", - "icon": "toggle_icons/icon_system.png", - "get_state": lambda: self._params.get_bool("AutomaticallyDownloadModels"), - "set_state": lambda s: self._params.put_bool("AutomaticallyDownloadModels", s), - "color": "#597497" - }, - { - "title": tr_noop("Blacklist"), - "type": "hub", - "icon": "toggle_icons/icon_system.png", - "on_click": self._on_blacklist_clicked, + "title": tr_noop("Randomizer Details"), + "columns": 2, + "uniform_width": True, "visible": lambda: self._params.get_bool("ModelRandomizer"), - "color": "#597497" + "categories": [ + { + "title": tr_noop("Blacklist"), + "type": "hub", + "icon": "toggle_icons/icon_system.png", + "on_click": self._on_blacklist_clicked, + "color": "#597497" + }, + { + "title": tr_noop("Ratings"), + "type": "hub", + "icon": "toggle_icons/icon_system.png", + "on_click": self._on_scores_clicked, + "color": "#597497" + }, + ], }, { - "title": tr_noop("Ratings"), - "type": "hub", - "icon": "toggle_icons/icon_system.png", - "on_click": self._on_scores_clicked, - "visible": lambda: self._params.get_bool("ModelRandomizer"), - "color": "#597497" + "title": tr_noop("Advanced Tuning"), + "columns": 2, + "uniform_width": True, + "visible": lambda: self._params.get_int("TuningLevel") == 3, + "categories": [ + { + "title": tr_noop("Recovery Power"), + "type": "value", + "icon": "toggle_icons/icon_road.png", + "get_value": lambda: f"{self._params.get_float('RecoveryPower'):.1f}", + "on_click": self._on_recovery_power_clicked, + "color": "#597497" + }, + { + "title": tr_noop("Stop Distance"), + "type": "value", + "icon": "toggle_icons/icon_road.png", + "get_value": lambda: f"{self._params.get_float('StopDistance'):.1f}m", + "on_click": self._on_stop_distance_clicked, + "color": "#597497" + }, + ], }, ] self._model_manager = ModelManager(self._params, self._params_memory) self._download_thread = None + self._manifest_fetch_thread = None + self._manifest_fetched = False + self._fetch_manifest_async() self._update_model_metadata() self._rebuild_grid() @@ -123,56 +178,187 @@ class StarPilotDrivingModelLayout(StarPilotPanel): def show_event(self): super().show_event() + self._fetch_manifest_async() self._update_model_metadata() - def _is_model_installed(self, key: str) -> bool: - if not key: return False - has_thneed = (self._model_dir / f"{key}.thneed").exists() - if has_thneed: return True - return (self._model_dir / f"{key}_driving_policy_tinygrad.pkl").exists() + def _fetch_manifest_async(self): + if self._manifest_fetch_thread is not None and self._manifest_fetch_thread.is_alive(): + return + + def _task(): + self._model_manager.update_models() + self._manifest_fetched = True + + self._manifest_fetch_thread = threading.Thread(target=_task, daemon=True) + self._manifest_fetch_thread.start() + + def _default_model_key(self) -> str: + default_key = self._params.get_default_value("Model") or self._params.get_default_value("DrivingModel") + if isinstance(default_key, bytes): + default_key = default_key.decode("utf-8", errors="ignore") + return canonical_model_key(str(default_key or "").strip()) or "sc2" + + def _default_model_name(self) -> str: + default_name = self._params.get_default_value("DrivingModelName") + if isinstance(default_name, bytes): + default_name = default_name.decode("utf-8", errors="ignore") + return _clean_model_name(default_name or "") or "South Carolina" + + def _default_model_version(self) -> str: + default_version = self._params.get_default_value("ModelVersion") or self._params.get_default_value("DrivingModelVersion") + if isinstance(default_version, bytes): + default_version = default_version.decode("utf-8", errors="ignore") + return str(default_version or "").strip() or "v11" + + def _current_selected_key(self) -> str: + current_key = self._params.get("Model", encoding="utf-8") or self._params.get("DrivingModel", encoding="utf-8") or "" + return canonical_model_key(str(current_key).strip()) or self._default_model_key() + + def _load_on_disk_files(self) -> set[str]: + try: + return {entry.name for entry in self._model_dir.iterdir()} + except Exception: + return set() + + def _is_model_installed(self, key: str, version: str = "", on_disk_files: set[str] | None = None) -> bool: + model_key = canonical_model_key(str(key or "").strip()) + if not model_key: + return False + + if is_builtin_model_key(model_key): + return True + + files = on_disk_files if on_disk_files is not None else self._load_on_disk_files() + if f"{model_key}.thneed" in files: + return True + + if version in TINYGRAD_VERSIONS: + required_files = set(self._required_files_for_version(model_key, version)) + return required_files.issubset(files) + + if version == "v7": + return f"{model_key}.pkl" in files + + return any(file.startswith(f"{model_key}.") or file.startswith(f"{model_key}_") for file in files) + + def _required_files_for_version(self, key: str, version: str) -> list[str]: + files = [ + f"{key}_driving_policy_tinygrad.pkl", + f"{key}_driving_vision_tinygrad.pkl", + f"{key}_driving_policy_metadata.pkl", + f"{key}_driving_vision_metadata.pkl", + ] + + if version == "v12": + files.extend([ + f"{key}_driving_off_policy_tinygrad.pkl", + f"{key}_driving_off_policy_metadata.pkl", + ]) + + return files + + def _ensure_default_model_visible(self): + default_key = self._default_model_key() + default_name = self._default_model_name() + default_series = tr("Custom Series") + default_released = "" + + for alias in model_key_aliases(default_key): + alias = canonical_model_key(alias) + if alias not in self._model_file_to_name: + continue + + default_name = self._model_file_to_name.get(alias, default_name) + default_series = self._model_series_map.get(alias, default_series) + default_released = self._model_released_dates.get(alias, default_released) + + if alias != default_key: + self._model_file_to_name.pop(alias, None) + self._model_file_to_name_processed.pop(alias, None) + self._model_series_map.pop(alias, None) + self._model_released_dates.pop(alias, None) + self._model_version_map.pop(alias, None) + self._catalog_entries.pop(alias, None) + + version = self._model_version_map.get(default_key, self._default_model_version()) + self._model_file_to_name[default_key] = default_name + self._model_file_to_name_processed[default_key] = _clean_model_name(default_name) + self._model_series_map[default_key] = default_series + if default_released: + self._model_released_dates[default_key] = default_released + self._model_version_map.setdefault(default_key, version) + + def _build_catalog_entries(self, on_disk_files: set[str]): + self._catalog_entries.clear() + self._model_file_to_name.clear() + self._model_file_to_name_processed.clear() + self._model_series_map.clear() + self._model_released_dates.clear() + self._model_version_map.clear() + + available_models = [entry.strip() for entry in (self._params.get("AvailableModels", encoding="utf-8") or "").split(",")] + available_names = [entry.strip() for entry in (self._params.get("AvailableModelNames", encoding="utf-8") or "").split(",")] + available_series = [entry.strip() for entry in (self._params.get("AvailableModelSeries", encoding="utf-8") or "").split(",")] + available_versions = [entry.strip() for entry in (self._params.get("ModelVersions", encoding="utf-8") or "").split(",")] + released_dates = [entry.strip() for entry in (self._params.get("ModelReleasedDates", encoding="utf-8") or "").split(",")] + + self._community_favorites = {canonical_model_key(entry.strip()) for entry in (self._params.get("CommunityFavorites", encoding="utf-8") or "").split(",") if entry.strip()} + self._user_favorites = {canonical_model_key(entry.strip()) for entry in (self._params.get("UserFavorites", encoding="utf-8") or "").split(",") if entry.strip()} + + size = min(len(available_models), len(available_names)) + for i in range(size): + canonical_key = canonical_model_key(available_models[i]) + name = available_names[i].strip() + if not canonical_key or not name: + continue + + series = available_series[i].strip() if i < len(available_series) and available_series[i].strip() else tr("Custom Series") + version = available_versions[i].strip() if i < len(available_versions) else "" + released = released_dates[i].strip() if i < len(released_dates) else "" + + self._model_file_to_name.setdefault(canonical_key, name) + self._model_file_to_name_processed.setdefault(canonical_key, _clean_model_name(name)) + self._model_series_map.setdefault(canonical_key, series) + if released: + self._model_released_dates.setdefault(canonical_key, released) + if version: + self._model_version_map.setdefault(canonical_key, version) + + self._ensure_default_model_visible() + + for key, name in self._model_file_to_name.items(): + version = self._model_version_map.get(key, self._default_model_version() if is_builtin_model_key(key) else "") + installed = self._is_model_installed(key, version, on_disk_files) + partial = (not is_builtin_model_key(key)) and (not installed) and any(file.startswith(f"{key}.") or file.startswith(f"{key}_") for file in on_disk_files) + self._catalog_entries[key] = ModelCatalogEntry( + key=key, + name=name, + series=self._model_series_map.get(key, tr("Custom Series")), + version=version, + released=self._model_released_dates.get(key, ""), + builtin=is_builtin_model_key(key), + installed=installed, + partial=partial, + community_favorite=(key in self._community_favorites), + user_favorite=(key in self._user_favorites), + ) def _update_model_metadata(self): - available_models_raw = self._params.get("AvailableModels", encoding='utf-8') - if not available_models_raw: - manager = ModelManager(self._params, self._params_memory) - manager.update_models() - available_models_raw = self._params.get("AvailableModels", encoding='utf-8') + on_disk_files = self._load_on_disk_files() + self._build_catalog_entries(on_disk_files) - self._available_models = [m.strip() for m in (available_models_raw or "").split(",") if m.strip()] - self._available_model_names = [m.strip() for m in (self._params.get("AvailableModelNames", encoding='utf-8') or "").split(",") if m.strip()] - self._available_model_series = [m.strip() for m in (self._params.get("AvailableModelSeries", encoding='utf-8') or "").split(",") if m.strip()] - self._available_model_versions = [m.strip() for m in (self._params.get("ModelVersions", encoding='utf-8') or "").split(",")] - released_dates = (self._params.get("ModelReleasedDates", encoding='utf-8') or "").split(",") + self._current_model_key = self._current_selected_key() + current_entry = self._catalog_entries.get(self._current_model_key) + if current_entry is None or not current_entry.installed: + self._current_model_key = self._default_model_key() + current_entry = self._catalog_entries.get(self._current_model_key) - self._model_file_to_name.clear() - self._model_series_map.clear() - self._model_version_map.clear() - self._model_released_dates.clear() + if current_entry is not None: + self._current_model_name = current_entry.name + else: + self._current_model_name = self._default_model_name() - size = min(len(self._available_models), len(self._available_model_names)) - for i in range(size): - key = self._available_models[i].strip() - name = self._available_model_names[i].strip() - if not key or not name: continue - series = self._available_model_series[i].strip() if i < len(self._available_model_series) else tr("Custom Series") - self._model_file_to_name[key] = name - self._model_series_map[key] = series - if i < len(self._available_model_versions): - v = self._available_model_versions[i].strip() - if v: self._model_version_map[key] = v - if i < len(released_dates): - d = released_dates[i].strip() - if d: self._model_released_dates[key] = d - - model_key = self._params.get("Model") or self._params.get("DrivingModel") - if model_key and isinstance(model_key, bytes): model_key = model_key.decode() - if not model_key or not self._is_model_installed(model_key): - model_key = self._params.get_default_value("Model") or self._params.get_default_value("DrivingModel") or "" - if model_key and isinstance(model_key, bytes): model_key = model_key.decode() - - self._current_model_name = self._model_file_to_name.get(model_key, "Default") - - def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable): + def _show_selection_dialog(self, title: str, options: dict[str, str] | list[str], current_val: str, on_confirm: Callable, current_key: str = ""): if not options: gui_app.set_modal_overlay(alert_dialog(tr("No options available."))) return @@ -186,11 +372,16 @@ class StarPilotDrivingModelLayout(StarPilotPanel): grouped = {} name_to_key = {} + key_to_display = {} + name_counts = {} for key, name in options.items(): series = self._model_series_map.get(key, tr("Custom Series")) if series not in grouped: grouped[series] = [] - grouped[series].append(name) - name_to_key[name] = key + name_counts[name] = name_counts.get(name, 0) + 1 + display_name = name if name_counts[name] == 1 else f"{name} [{key}]" + grouped[series].append(display_name) + name_to_key[display_name] = key + key_to_display[key] = display_name for series in grouped: grouped[series].sort() sorted_series = sorted(grouped.keys()) @@ -214,8 +405,10 @@ class StarPilotDrivingModelLayout(StarPilotPanel): user_favs = [f.strip() for f in (self._params.get("UserFavorites", encoding='utf-8') or "").split(",") if f.strip()] comm_favs = [f.strip() for f in (self._params.get("CommunityFavorites", encoding='utf-8') or "").split(",") if f.strip()] + current_display = key_to_display.get(current_key, current_val) + dialog = SelectionDialog( - title, final_grouped, current_val, on_close=_on_close_grouped, + title, final_grouped, current_display, on_close=_on_close_grouped, model_released_dates=self._model_released_dates, model_file_to_name=self._model_file_to_name, user_favorites=user_favs, @@ -224,23 +417,75 @@ class StarPilotDrivingModelLayout(StarPilotPanel): ) gui_app.set_modal_overlay(dialog) + def _is_download_active(self) -> bool: + return bool(self._params_memory.get("ModelToDownload", encoding="utf-8") or self._params_memory.get_bool("DownloadAllModels")) + + def _selected_model_version(self, model_key: str) -> str: + version = self._model_version_map.get(model_key, "") + if version: + return version + + try: + versions_file = self._model_dir / ".model_versions.json" + if versions_file.is_file(): + payload = json.loads(versions_file.read_text()) + if isinstance(payload, dict): + for alias in model_key_aliases(model_key): + resolved = str(payload.get(alias, "")).strip() + if resolved: + return resolved + except Exception: + pass + + if is_builtin_model_key(model_key): + return self._default_model_version() + return "" + + def _build_selectable_models(self) -> dict[str, str]: + models: dict[str, str] = {} + for key, entry in self._catalog_entries.items(): + if entry.installed: + models[key] = entry.name + + return models + + def _build_deletable_models(self) -> dict[str, str]: + installed = self._build_selectable_models() + default_key = self._default_model_key() + current_name = _clean_model_name(self._current_model_name) + default_name = _clean_model_name(installed.get(default_key, self._default_model_name())) + + deletable: dict[str, str] = {} + for key, display_name in installed.items(): + processed_name = _clean_model_name(display_name) + if processed_name == current_name or processed_name == default_name: + continue + deletable[key] = display_name + return deletable + def _on_select_model_clicked(self): - installed_models = {k: v for k, v in self._model_file_to_name.items() if self._is_model_installed(k)} - if not installed_models: return + self._update_model_metadata() + installed_models = self._build_selectable_models() + if not installed_models: + gui_app.set_modal_overlay(alert_dialog(tr("No downloaded models found."))) + return def _on_confirm(model_key): - self._params.put("Model", model_key) - self._params.put("DrivingModel", model_key) + selected_model = canonical_model_key(model_key) + self._params.put("Model", selected_model) + self._params.put("DrivingModel", selected_model) self._params.put("DrivingModelName", installed_models[model_key]) - mv = self._model_version_map.get(model_key, "") - if mv: - self._params.put("ModelVersion", mv) - self._params.put("DrivingModelVersion", mv) + resolved_version = self._selected_model_version(selected_model) + if resolved_version: + self._params.put("ModelVersion", resolved_version) + self._params.put("DrivingModelVersion", resolved_version) + update_starpilot_toggles() self._update_model_metadata() if ui_state.started: - gui_app.set_modal_overlay(ConfirmDialog(tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), on_close=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None)) + self._params.put_bool("OnroadCycleRequested", True) + gui_app.set_modal_overlay(alert_dialog(tr("Drive-cycle requested for immediate apply."))) - self._show_selection_dialog(tr("Select Driving Model"), installed_models, self._current_model_name, _on_confirm) + self._show_selection_dialog(tr("Select Driving Model"), installed_models, self._current_model_name, _on_confirm, current_key=self._current_model_key) def _on_recovery_power_clicked(self): def on_close(res, val): @@ -259,29 +504,48 @@ class StarPilotDrivingModelLayout(StarPilotPanel): gui_app.set_modal_overlay(AetherSliderDialog(tr("Stop Distance"), 4.0, 10.0, 0.1, self._params.get_float("StopDistance"), on_close, unit="m", color="#597497")) def _on_download_clicked(self): - is_downloading = self._params_memory.get("ModelToDownload") or self._params_memory.get_bool("DownloadAllModels") - if is_downloading: - self._params_memory.remove("ModelToDownload") - self._params_memory.put_bool("DownloadAllModels", False) - self._params_memory.remove("ModelDownloadProgress") + self._update_model_metadata() + if ui_state.started: + gui_app.set_modal_overlay(alert_dialog(tr("Cannot download models while driving."))) + return + + is_downloading = self._is_download_active() + if is_downloading: + self._params_memory.put_bool("CancelModelDownload", True) + return + + not_installed = {key: entry.name for key, entry in self._catalog_entries.items() if not entry.installed} + if not not_installed: + gui_app.set_modal_overlay(alert_dialog(tr("All models are already installed."))) return - not_installed = {k: v for k, v in self._model_file_to_name.items() if not self._is_model_installed(k)} self._show_selection_dialog(tr("Select Model to Download"), not_installed, "", lambda mk: self._params_memory.put("ModelToDownload", mk)) def _on_delete_clicked(self): - installed = {k: v for k, v in self._model_file_to_name.items() if self._is_model_installed(k)} - dk = self._params.get_default_value("Model") or "" - if isinstance(dk, bytes): dk = dk.decode() - ck = self._params.get("Model", encoding='utf-8') or "" - deletable = {k: v for k, v in installed.items() if k != dk and k != ck} + self._update_model_metadata() + if ui_state.started: + gui_app.set_modal_overlay(alert_dialog(tr("Cannot delete model files while driving."))) + return + if self._is_download_active(): + gui_app.set_modal_overlay(alert_dialog(tr("Cannot delete model files while a download is in progress."))) + return + + deletable = self._build_deletable_models() + + if not deletable: + gui_app.set_modal_overlay(alert_dialog(tr("No deletable models found."))) + return def _on_confirm(mk): def _execute_delete(res): - if res == DialogResult.CONFIRM: - for file in self._model_dir.iterdir(): - if file.name.startswith(mk): file.unlink() - self._update_model_metadata() + if res == DialogResult.CONFIRM: + for file in self._model_dir.iterdir(): + if not (file.name == f"{mk}.thneed" or file.name == f"{mk}.pkl" or file.name.startswith(f"{mk}_")): + continue + if file.is_file(): + file.unlink(missing_ok=True) + self._update_model_metadata() + self._rebuild_grid() gui_app.set_modal_overlay(ConfirmDialog(tr(f"Delete '{deletable[mk]}'?"), tr("Delete"), on_close=_execute_delete)) self._show_selection_dialog(tr("Select Model to Delete"), deletable, "", _on_confirm) @@ -317,7 +581,7 @@ class StarPilotDrivingModelLayout(StarPilotPanel): def _on_model_randomizer_toggled(self, state: bool): self._params.put_bool("ModelRandomizer", state) if state: - not_installed = [k for k in self._model_file_to_name if not self._is_model_installed(k)] + not_installed = [key for key, entry in self._catalog_entries.items() if not entry.installed] if not_installed: def _on_download_confirm(res): if res == DialogResult.CONFIRM: @@ -326,16 +590,32 @@ class StarPilotDrivingModelLayout(StarPilotPanel): gui_app.set_modal_overlay(ConfirmDialog(tr("Download all models for Randomizer?"), tr("Download All"), on_close=_on_download_confirm)) def _update_state(self): + if getattr(self, "_manifest_fetched", False): + self._manifest_fetched = False + self._update_model_metadata() + self._rebuild_grid() + model_to_download = self._params_memory.get("ModelToDownload", encoding='utf-8') or "" download_all = self._params_memory.get_bool("DownloadAllModels") is_downloading = bool(model_to_download or download_all) if is_downloading and (self._download_thread is None or not self._download_thread.is_alive()): - def _download_task(): - try: - if download_all: self._model_manager.download_all_models() - else: self._model_manager.download_model(model_to_download) - except: pass - finally: self._download_thread = None - self._download_thread = threading.Thread(target=_download_task, daemon=True) - self._download_thread.start() + def _download_task(): + try: + if download_all: + self._model_manager.download_all_models() + else: + self._model_manager.download_model(model_to_download) + except Exception: + pass + finally: + self._params_memory.remove("CancelModelDownload") + self._params_memory.remove("ModelToDownload") + self._params_memory.put_bool("DownloadAllModels", False) + self._params_memory.remove("ModelDownloadProgress") + self._download_thread = None + self._update_model_metadata() + self._rebuild_grid() + + self._download_thread = threading.Thread(target=_download_task, daemon=True) + self._download_thread.start() diff --git a/selfdrive/ui/layouts/settings/starpilot/panel.py b/selfdrive/ui/layouts/settings/starpilot/panel.py index d3ea1331..c7cd62ba 100644 --- a/selfdrive/ui/layouts/settings/starpilot/panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/panel.py @@ -9,6 +9,7 @@ from openpilot.common.params import Params from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SPACING +from openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel import SectionedTileLayout, TileSection class StarPilotPanelType(IntEnum): @@ -46,7 +47,9 @@ class StarPilotPanel(Widget): self._sub_panels: dict[str, Widget] = {} self._scroller = None self._tile_grid = None + self._sectioned_grid = None self.CATEGORIES = [] + self.SECTIONS = [] def set_navigate_callback(self, callback: Callable): self._navigate_callback = callback @@ -57,7 +60,74 @@ class StarPilotPanel(Widget): def set_current_sub_panel(self, sub_panel: str): self._current_sub_panel = sub_panel + def _is_category_visible(self, cat: dict) -> bool: + visible_fn = cat.get("visible") + return visible_fn is None or visible_fn() + + def _build_tile(self, cat: dict) -> Widget | None: + tile_type = cat.get("type", "hub") + if tile_type == "hub": + on_click = cat.get("on_click") + if on_click is None: + on_click = lambda c=cat: self._navigate_to(c["panel"]) + + return HubTile( + title=tr(cat["title"]), + desc=tr(cat.get("desc", "")), + icon_path=cat.get("icon"), + on_click=on_click, + starpilot_icon=cat.get("starpilot_icon", True), + bg_color=cat.get("color"), + get_status=cat.get("get_status"), + ) + + if tile_type == "toggle": + raw_set_state = cat["set_state"] + + def on_toggle(state: bool, setter=raw_set_state): + setter(state) + self._rebuild_grid() + + return ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=on_toggle, icon_path=cat.get("icon"), bg_color=cat.get("color"), desc=tr(cat.get("desc", "")), is_enabled=cat.get("is_enabled"), disabled_label=cat.get("disabled_label", "")) + + if tile_type == "value": + return ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"), is_enabled=cat.get("is_enabled"), desc=tr(cat.get("desc", ""))) + + return None + + def _build_tile_grid(self, categories: list[dict], columns: int | None = None, padding: int | None = None, uniform_width: bool = False) -> TileGrid: + grid = TileGrid(columns=columns, padding=padding, uniform_width=uniform_width) + for cat in categories: + if not self._is_category_visible(cat): + continue + tile = self._build_tile(cat) + if tile is not None: + grid.add_tile(tile) + return grid + def _rebuild_grid(self): + if self.SECTIONS: + if self._sectioned_grid is None: + self._sectioned_grid = SectionedTileLayout() + + sections: list[TileSection] = [] + for section in self.SECTIONS: + visible_fn = section.get("visible") + if visible_fn is not None and not visible_fn(): + continue + + grid = self._build_tile_grid( + section.get("categories", []), + columns=section.get("columns", 2), + padding=section.get("padding", SPACING.tile_gap), + uniform_width=section.get("uniform_width", True), + ) + if grid.tiles: + sections.append(TileSection(tr(section["title"]), grid)) + + self._sectioned_grid.set_sections(sections) + return + if not self.CATEGORIES: return @@ -67,38 +137,11 @@ class StarPilotPanel(Widget): self._tile_grid.clear() for cat in self.CATEGORIES: - visible_fn = cat.get("visible") - if visible_fn is not None and not visible_fn(): + if not self._is_category_visible(cat): continue - - tile_type = cat.get("type", "hub") - if tile_type == "hub": - on_click = cat.get("on_click") - if on_click is None: - on_click = lambda c=cat: self._navigate_to(c["panel"]) - - tile = HubTile( - title=tr(cat["title"]), - desc=tr(cat.get("desc", "")), - icon_path=cat.get("icon"), - on_click=on_click, - starpilot_icon=cat.get("starpilot_icon", True), - bg_color=cat.get("color"), - ) - elif tile_type == "toggle": - raw_set_state = cat["set_state"] - - def on_toggle(state: bool, setter=raw_set_state): - setter(state) - self._rebuild_grid() - - tile = ToggleTile(title=tr(cat["title"]), get_state=cat["get_state"], set_state=on_toggle, icon_path=cat.get("icon"), bg_color=cat.get("color"), desc=tr(cat.get("desc", "")), is_enabled=cat.get("is_enabled"), disabled_label=cat.get("disabled_label", "")) - elif tile_type == "value": - tile = ValueTile(title=tr(cat["title"]), get_value=cat["get_value"], on_click=cat["on_click"], icon_path=cat.get("icon"), bg_color=cat.get("color"), is_enabled=cat.get("is_enabled"), desc=tr(cat.get("desc", ""))) - else: - continue - - self._tile_grid.add_tile(tile) + tile = self._build_tile(cat) + if tile is not None: + self._tile_grid.add_tile(tile) def _navigate_to(self, sub_panel: str): self._current_sub_panel = sub_panel @@ -113,6 +156,8 @@ class StarPilotPanel(Widget): def _render(self, rect: rl.Rectangle): if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].render(rect) + elif self.SECTIONS and self._sectioned_grid: + self._sectioned_grid.render(rect) elif self.CATEGORIES and self._tile_grid: self._tile_grid.render(rect) elif self._scroller: @@ -123,9 +168,20 @@ class StarPilotPanel(Widget): self._rebuild_grid() if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].show_event() + elif self.SECTIONS and self._sectioned_grid: + self._sectioned_grid.show_event() elif self._scroller: self._scroller.show_event() + def hide_event(self): + super().hide_event() + if self._current_sub_panel and self._current_sub_panel in self._sub_panels: + self._sub_panels[self._current_sub_panel].hide_event() + elif self.SECTIONS and self._sectioned_grid: + self._sectioned_grid.hide_event() + elif self._scroller: + self._scroller.hide_event() + def create_tile_panel(categories: list[dict], sub_panels: dict[str, Widget] | None = None) -> StarPilotPanel: panel = StarPilotPanel() diff --git a/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py b/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py new file mode 100644 index 00000000..5589c9eb --- /dev/null +++ b/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid + + +@dataclass(frozen=True) +class TileSection: + title: str + grid: TileGrid + + +class SectionedTileLayout(Widget): + def __init__(self, section_gap: int = SPACING.section_gap, title_height: int = 32, title_gap: int = SPACING.sm, + min_row_height: int = 150, max_row_height: int = 280, top_padding: int = 0, + horizontal_padding: int = SPACING.xl, max_content_width: int | None = 1440): + super().__init__() + self._sections: list[TileSection] = [] + self._section_gap = section_gap + self._title_height = title_height + self._title_gap = title_gap + self._min_row_height = min_row_height + self._max_row_height = max_row_height + self._top_padding = top_padding + self._horizontal_padding = horizontal_padding + self._max_content_width = max_content_width + self._title_font_size = 26 + self._font_title = gui_app.font(FontWeight.BOLD) + self._is_active = False + + def set_sections(self, sections: list[TileSection]): + if self._is_active: + for section in self._sections: + section.grid.hide_event() + self._sections = list(sections) + if self._is_active: + for section in self._sections: + section.grid.show_event() + + def clear(self): + self._sections.clear() + + def show_event(self): + self._is_active = True + super().show_event() + for section in self._sections: + section.grid.show_event() + + def hide_event(self): + self._is_active = False + super().hide_event() + for section in self._sections: + section.grid.hide_event() + + def _title_block_height(self, section: TileSection) -> int: + return (self._title_height + self._title_gap) if section.title else 0 + + def _section_band_height(self, section: TileSection, row_height: float) -> float: + rows = section.grid.get_row_count() + if rows <= 0: + return 0.0 + return (rows * row_height) + (section.grid.gap * max(0, rows - 1)) + + def _content_rect(self, rect: rl.Rectangle) -> rl.Rectangle: + content_x = rect.x + self._horizontal_padding + content_w = max(0.0, rect.width - (self._horizontal_padding * 2)) + if self._max_content_width is not None and content_w > self._max_content_width: + content_w = float(self._max_content_width) + content_x = rect.x + (rect.width - content_w) / 2 + return rl.Rectangle(content_x, rect.y, content_w, rect.height) + + def _compute_row_height(self, rect: rl.Rectangle, sections: list[TileSection]) -> float: + total_rows = sum(section.grid.get_row_count() for section in sections) + if total_rows <= 0: + return 0.0 + + total_title_height = sum(self._title_block_height(section) for section in sections) + total_section_gaps = self._section_gap * max(0, len(sections) - 1) + total_internal_gaps = sum(section.grid.get_internal_gap_height() for section in sections) + # Clamp oversized sections so tiles keep a touch-friendly shape instead of stretching to fill the full panel. + fit_row_height = max(0.0, (rect.height - self._top_padding - total_title_height - total_section_gaps - total_internal_gaps) / total_rows) + if fit_row_height >= self._min_row_height: + return min(fit_row_height, self._max_row_height) + return fit_row_height + + def _draw_section_title(self, rect: rl.Rectangle, title: str): + title_text = title.upper() + spacing = round(self._title_font_size * 0.08) + size = measure_text_cached(self._font_title, title_text, self._title_font_size, spacing=spacing) + text_y = rect.y + (rect.height - size.y) / 2 + text_pos = rl.Vector2(round(rect.x), round(text_y)) + rl.draw_text_ex(self._font_title, title_text, rl.Vector2(text_pos.x + 1, text_pos.y + 1), self._title_font_size, spacing, rl.Color(0, 0, 0, 90)) + rl.draw_text_ex(self._font_title, title_text, text_pos, self._title_font_size, spacing, rl.Color(255, 255, 255, 215)) + + line_x = rect.x + size.x + SPACING.lg + line_w = rect.width - (line_x - rect.x) + if line_w <= 0: + return + line_y = int(rect.y + rect.height / 2) + rl.draw_rectangle(int(line_x), line_y, int(line_w), 2, rl.Color(255, 255, 255, 36)) + + def _render(self, rect: rl.Rectangle): + self.set_rect(rect) + sections = [section for section in self._sections if section.grid.tiles] + if not sections: + return + + content_rect = self._content_rect(rect) + row_height = self._compute_row_height(content_rect, sections) + if row_height <= 0: + return + + y = content_rect.y + self._top_padding + for index, section in enumerate(sections): + if section.title: + title_rect = rl.Rectangle(content_rect.x, y, content_rect.width, self._title_height) + self._draw_section_title(title_rect, section.title) + y += self._title_height + self._title_gap + + active_grid_height = (section.grid.get_row_count() * row_height) + section.grid.get_internal_gap_height() + section.grid.render(rl.Rectangle(content_rect.x, y, content_rect.width, active_grid_height)) + y += self._section_band_height(section, row_height) + + if index < len(sections) - 1: + y += self._section_gap diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index e5d45501..776bc8b6 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -8,7 +8,7 @@ from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts @@ -37,52 +37,70 @@ void main() { } """ -FRAME_FRAGMENT_SHADER_EGL = """ - #version 300 es - #extension GL_OES_EGL_image_external_essl3 : enable - precision mediump float; - in vec2 fragTexCoord; - uniform samplerExternalOES texture0; - out vec4 fragColor; - uniform int enhance_driver; +# Choose fragment shader based on platform capabilities +if TICI: + FRAME_FRAGMENT_SHADER = """ + #version 300 es + #extension GL_OES_EGL_image_external_essl3 : enable + precision mediump float; + in vec2 fragTexCoord; + uniform samplerExternalOES texture0; + out vec4 fragColor; + uniform int engaged; + uniform int enhance_driver; - void main() { - vec4 color = texture(texture0, fragTexCoord); - color.rgb = pow(color.rgb, vec3(1.0/1.28)); - if (enhance_driver == 1) { - float brightness = 1.1; - color.rgb = color.rgb + 0.15; - color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); - color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb); - color.rgb = pow(color.rgb, vec3(0.8)); + void main() { + vec4 color = texture(texture0, fragTexCoord); + if (engaged == 1) { + float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma + color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation + color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast + color.rgb = pow(color.rgb, vec3(1.0/1.28)); + } else { + color.rgb *= 0.85; // 85% opacity + } + if (enhance_driver == 1) { + float brightness = 1.1; + color.rgb = color.rgb + 0.15; + color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); + color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb); + color.rgb = pow(color.rgb, vec3(0.8)); + } + fragColor = vec4(color.rgb, color.a); } - fragColor = vec4(color.rgb, color.a); - } - """ + """ +else: + FRAME_FRAGMENT_SHADER = VERSION + """ + in vec2 fragTexCoord; + uniform sampler2D texture0; + uniform sampler2D texture1; + out vec4 fragColor; + uniform int engaged; + uniform int enhance_driver; -FRAME_FRAGMENT_SHADER_TEXTURES = VERSION + """ - in vec2 fragTexCoord; - uniform sampler2D texture0; - uniform sampler2D texture1; - out vec4 fragColor; - uniform int enhance_driver; - - void main() { - float y = texture(texture0, fragTexCoord).r; - vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; - vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x); - // TODO: the images out of camerad need some more correction and - // the ui should apply a gamma curve for the device display - if (enhance_driver == 1) { - float brightness = 1.1; - rgb = rgb + 0.15; - rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); - rgb = rgb * rgb * (3.0 - 2.0 * rgb); - rgb = pow(rgb, vec3(0.8)); + void main() { + float y = texture(texture0, fragTexCoord).r; + vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; + vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x); + if (engaged == 1) { + float gray = dot(rgb, vec3(0.299, 0.587, 0.114)); + rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation + rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast + } else { + rgb *= 0.85; // 85% opacity + } + // TODO: the images out of camerad need some more correction and + // the ui should apply a gamma curve for the device display + if (enhance_driver == 1) { + float brightness = 1.1; + rgb = rgb + 0.15; + rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0); + rgb = rgb * rgb * (3.0 - 2.0 * rgb); + rgb = pow(rgb, vec3(0.8)); + } + fragColor = vec4(rgb, 1.0); } - fragColor = vec4(rgb, 1.0); - } - """ + """ class CameraView(Widget): @@ -101,6 +119,12 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 + self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._engaged_loc = rl.get_shader_location(self.shader, "engaged") + self._engaged_val = rl.ffi.new("int[1]", [1]) + self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") + self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0]) self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -111,18 +135,12 @@ class CameraView(Widget): self.egl_texture: rl.Texture | None = None self._placeholder_color: rl.Color | None = None - self._use_egl = TICI and init_egl() - fragment_shader = FRAME_FRAGMENT_SHADER_EGL if self._use_egl else FRAME_FRAGMENT_SHADER_TEXTURES - self.shader = rl.load_shader_from_memory(VERTEX_SHADER, fragment_shader) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not self._use_egl else -1 - self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") - self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0]) + # Initialize EGL for zero-copy rendering on TICI + if TICI: + if not init_egl(): + raise RuntimeError("Failed to initialize EGL") - # Keep the UI alive if EGL cannot be used on device startup. - if TICI and not self._use_egl: - cloudlog.warning(f"Failed to initialize EGL for {self._name}; falling back to texture rendering") - elif self._use_egl: # Create a 1x1 pixel placeholder texture for EGL image binding temp_image = rl.gen_image_color(1, 1, rl.BLACK) self.egl_texture = rl.load_texture_from_image(temp_image) @@ -136,11 +154,11 @@ class CameraView(Widget): # Prevent old frames from showing when going onroad. Qt has a separate thread # which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough # and only clears internal buffers, not the message queue. - self.frame = None self.available_streams.clear() if self.client: del self.client self.client = VisionIpcClient(self._name, self._stream_type, conflate=True) + self.frame = None def _set_placeholder_color(self, color: rl.Color): """Set a placeholder color to be drawn when no frame is available.""" @@ -170,7 +188,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if self.egl_texture: + if TICI and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -245,7 +263,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if self._use_egl: + if TICI: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -279,7 +297,7 @@ class CameraView(Widget): # Render with shader rl.begin_shader_mode(self.shader) - self._update_shader_uniforms() + self._update_texture_color_filtering() rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) rl.end_shader_mode() @@ -299,12 +317,14 @@ class CameraView(Widget): # Render with shader rl.begin_shader_mode(self.shader) - self._update_shader_uniforms() + self._update_texture_color_filtering() rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv) rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) rl.end_shader_mode() - def _update_shader_uniforms(self): + def _update_texture_color_filtering(self): + self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0 + rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) def _ensure_connection(self) -> bool: @@ -356,7 +376,6 @@ class CameraView(Widget): self.client = self._target_client self._stream_type = self._target_stream_type self._texture_needs_update = True - self._enhance_driver_val[0] = 1 if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0 # Reset state self._target_client = None @@ -368,7 +387,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not self._use_egl: + 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), @@ -384,7 +403,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if self._use_egl: + if TICI: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index e71578af..54439484 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -37,30 +37,32 @@ void main() { } """ -FRAME_FRAGMENT_SHADER_EGL = """ - #version 300 es - #extension GL_OES_EGL_image_external_essl3 : enable - precision mediump float; - in vec2 fragTexCoord; - uniform samplerExternalOES texture0; - out vec4 fragColor; - void main() { - vec4 color = texture(texture0, fragTexCoord); - fragColor = vec4(pow(color.rgb, vec3(1.0/1.28)), color.a); - } - """ - -FRAME_FRAGMENT_SHADER_TEXTURES = VERSION + """ - in vec2 fragTexCoord; - uniform sampler2D texture0; - uniform sampler2D texture1; - out vec4 fragColor; - void main() { - float y = texture(texture0, fragTexCoord).r; - vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; - fragColor = vec4(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x, 1.0); - } - """ +# Choose fragment shader based on platform capabilities +if TICI: + FRAME_FRAGMENT_SHADER = """ + #version 300 es + #extension GL_OES_EGL_image_external_essl3 : enable + precision mediump float; + in vec2 fragTexCoord; + uniform samplerExternalOES texture0; + out vec4 fragColor; + void main() { + vec4 color = texture(texture0, fragTexCoord); + fragColor = vec4(pow(color.rgb, vec3(1.0/1.28)), color.a); + } + """ +else: + FRAME_FRAGMENT_SHADER = VERSION + """ + in vec2 fragTexCoord; + uniform sampler2D texture0; + uniform sampler2D texture1; + out vec4 fragColor; + void main() { + float y = texture(texture0, fragTexCoord).r; + vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; + fragColor = vec4(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x, 1.0); + } + """ class CameraView(Widget): @@ -79,6 +81,8 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 + self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -89,16 +93,12 @@ class CameraView(Widget): self.egl_texture: rl.Texture | None = None self._placeholder_color: rl.Color | None = None - self._use_egl = TICI and init_egl() - fragment_shader = FRAME_FRAGMENT_SHADER_EGL if self._use_egl else FRAME_FRAGMENT_SHADER_TEXTURES - self.shader = rl.load_shader_from_memory(VERTEX_SHADER, fragment_shader) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not self._use_egl else -1 + # Initialize EGL for zero-copy rendering on TICI + if TICI: + if not init_egl(): + raise RuntimeError("Failed to initialize EGL") - # Keep the UI alive if EGL cannot be used on device startup. - if TICI and not self._use_egl: - cloudlog.warning(f"Failed to initialize EGL for {self._name}; falling back to texture rendering") - elif self._use_egl: # Create a 1x1 pixel placeholder texture for EGL image binding temp_image = rl.gen_image_color(1, 1, rl.BLACK) self.egl_texture = rl.load_texture_from_image(temp_image) @@ -146,7 +146,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if self.egl_texture: + if TICI and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -220,7 +220,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if self._use_egl: + if TICI: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -337,7 +337,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not self._use_egl: + 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), @@ -353,7 +353,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if self._use_egl: + if TICI: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index 1f118e7e..b62d444b 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -249,6 +249,25 @@ class Soundd: sd._initialize() return sd.OutputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER) + def start_stream(self, sd): + stream = self.get_stream(sd) + stream.start() + cloudlog.info(f"soundd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") + return stream + + def describe_stream(self, stream) -> str: + attrs = { + "active": getattr(stream, "active", None), + "stopped": getattr(stream, "stopped", None), + "closed": getattr(stream, "closed", None), + "samplerate": getattr(stream, "samplerate", None), + "channels": getattr(stream, "channels", None), + "dtype": getattr(stream, "dtype", None), + "device": getattr(stream, "device", None), + "blocksize": getattr(stream, "blocksize", None), + } + return " ".join(f"{k}={v!r}" for k, v in attrs.items()) + def soundd_thread(self): # sounddevice must be imported after forking processes import sounddevice as sd @@ -257,39 +276,51 @@ class Soundd: sm = sm.extend(['starpilotSelfdriveState', 'starpilotPlan']) - with self.get_stream(sd) as stream: - rk = Ratekeeper(20) + while True: + stream = None + try: + stream = self.start_stream(sd) + rk = Ratekeeper(20) - cloudlog.info(f"soundd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") - while True: - sm.update(0) + while True: + sm.update(0) - if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert - self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb) - self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x)) + if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert + self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb) + self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x)) - if self.starpilot_toggles.alert_volume_controller: - self.auto_volume = self.current_volume - self.current_volume = 0.0 + if self.starpilot_toggles.alert_volume_controller: + self.auto_volume = self.current_volume + self.current_volume = 0.0 - elif self.current_alert != AudibleAlert.none and self.starpilot_toggles.alert_volume_controller: - self.current_volume = self.get_volume_override() - if self.current_volume == 1.01: - self.current_volume = self.auto_volume + elif self.current_alert != AudibleAlert.none and self.starpilot_toggles.alert_volume_controller: + self.current_volume = self.get_volume_override() + if self.current_volume == 1.01: + self.current_volume = self.auto_volume - self.get_audible_alert(sm) + self.get_audible_alert(sm) - rk.keep_time() + rk.keep_time() - assert stream.active + if not stream.active: + raise RuntimeError(f"soundd stream inactive: {self.describe_stream(stream)}") - starpilot_toggles = get_starpilot_toggles(sm) - if starpilot_toggles != self.starpilot_toggles: - self.starpilot_toggles = starpilot_toggles + starpilot_toggles = get_starpilot_toggles(sm) + if starpilot_toggles != self.starpilot_toggles: + self.starpilot_toggles = starpilot_toggles - stream = self.update_starpilot_sounds(sd, stream) - elif rk.frame % 5 == 0: - stream = self.update_starpilot_sounds(sd, stream) + stream = self.update_starpilot_sounds(sd, stream) + elif rk.frame % 5 == 0: + stream = self.update_starpilot_sounds(sd, stream) + except Exception: + cloudlog.exception("soundd: stream failed, restarting") + time.sleep(1) + finally: + if stream is not None: + try: + stream.close() + except Exception: + cloudlog.exception("soundd: failed to close stream") def update_starpilot_sounds(self, sd=None, stream=None): self.volume_map = { diff --git a/selfdrive/ui/ui b/selfdrive/ui/ui index f533c00b..e8007c10 100755 Binary files a/selfdrive/ui/ui and b/selfdrive/ui/ui differ diff --git a/selfdrive/ui/ui.cc b/selfdrive/ui/ui.cc index 0b9601a0..fd13e739 100644 --- a/selfdrive/ui/ui.cc +++ b/selfdrive/ui/ui.cc @@ -5,13 +5,10 @@ #include #include #include -#include #include #include -#include #include #include -#include #include #include #include @@ -48,8 +45,7 @@ std::atomic ui_stall_frame{0}; std::atomic ui_stall_reported{false}; std::atomic ui_stall_reported_ns{0}; std::atomic ui_stall_reported_phase{static_cast(UIStallPhase::INIT)}; -std::atomic ui_stall_dump_fd{-1}; -pthread_t ui_main_thread{}; +std::atomic ui_main_tid{0}; double read_env_double(const char *name, double default_value) { const char *value = std::getenv(name); @@ -84,25 +80,29 @@ std::string ui_stall_dump_dir() { return access("/data/log", W_OK) == 0 ? "/data/log" : "/tmp"; } -void ui_stall_signal_handler(int sig) { - const int fd = ui_stall_dump_fd.load(std::memory_order_relaxed); - if (fd < 0) { +void write_stall_dump_section(int fd, const std::string &title, const std::string &body) { + std::string output = "== " + title + " ==\n"; + output += body.empty() ? "\n" : body; + if (!output.empty() && output.back() != '\n') { + output += '\n'; + } + HANDLE_EINTR(write(fd, output.data(), output.size())); +} + +void write_ui_thread_snapshot(int fd) { + const pid_t tid = ui_main_tid.load(std::memory_order_relaxed); + if (tid <= 0) { + write_stall_dump_section(fd, "main_thread", ""); return; } - char header[256]; - const pid_t tid = static_cast(syscall(SYS_gettid)); - const int header_len = std::snprintf(header, sizeof(header), - "=== UI stall backtrace (signal=%d pid=%d tid=%d) ===\n", - sig, getpid(), tid); - if (header_len > 0) { - write(fd, header, header_len); - } + write_stall_dump_section(fd, "main_thread", util::string_format("pid=%d tid=%d", getpid(), tid)); - void *frames[128]; - const int frame_count = backtrace(frames, 128); - backtrace_symbols_fd(frames, frame_count, fd); - write(fd, "\n", 1); + const std::string task_dir = "/proc/self/task/" + std::to_string(tid); + write_stall_dump_section(fd, "main_thread status", util::read_file(task_dir + "/status")); + write_stall_dump_section(fd, "main_thread wchan", util::read_file(task_dir + "/wchan")); + write_stall_dump_section(fd, "main_thread syscall", util::read_file(task_dir + "/syscall")); + write_stall_dump_section(fd, "main_thread kernel_stack", util::read_file(task_dir + "/stack")); } void ui_stall_progress(UIStallPhase phase, uint64_t frame = 0) { @@ -126,8 +126,7 @@ void ui_stall_progress(UIStallPhase phase, uint64_t frame = 0) { void start_ui_stall_monitor() { static std::once_flag once; std::call_once(once, [] { - ui_main_thread = pthread_self(); - std::signal(SIGUSR1, ui_stall_signal_handler); + ui_main_tid.store(static_cast(syscall(SYS_gettid)), std::memory_order_relaxed); ui_stall_progress(UIStallPhase::INIT, 0); const double stall_probe_dt = read_env_double("UI_STALL_PROBE_MAX_DT", 5.0); @@ -176,10 +175,7 @@ void start_ui_stall_monitor() { write(fd, header, header_len); } - ui_stall_dump_fd.store(fd, std::memory_order_relaxed); - pthread_kill(ui_main_thread, SIGUSR1); - std::this_thread::sleep_for(50ms); - ui_stall_dump_fd.store(-1, std::memory_order_relaxed); + write_ui_thread_snapshot(fd); close(fd); LOGE("UI main thread stalled for %.1fs (phase=%s frame=%llu dump=%s)", stalled_for_s, diff --git a/starpilot/navigation/mapd_wrapper.py b/starpilot/navigation/mapd_wrapper.py index 7c772795..0d98e242 100644 --- a/starpilot/navigation/mapd_wrapper.py +++ b/starpilot/navigation/mapd_wrapper.py @@ -101,7 +101,14 @@ def terminate_child(proc: subprocess.Popen[str]) -> None: def run_mapd_once() -> int: - OFFLINE_ROOT.mkdir(parents=True, exist_ok=True) + try: + OFFLINE_ROOT.mkdir(parents=True, exist_ok=True) + except PermissionError: + cloudlog.exception(f"mapd_wrapper cannot create offline directory: {OFFLINE_ROOT}") + return 2 + except OSError: + cloudlog.exception(f"mapd_wrapper failed to prepare offline directory: {OFFLINE_ROOT}") + return 2 proc = subprocess.Popen( [MAPD_BIN.as_posix()], diff --git a/system/ui/widgets/selection_dialog.py b/system/ui/widgets/selection_dialog.py index 708fb2df..f984e75f 100644 --- a/system/ui/widgets/selection_dialog.py +++ b/system/ui/widgets/selection_dialog.py @@ -64,17 +64,14 @@ class SelectionHeader(Widget): self._pressed = False class SelectionItem(Widget): - def __init__(self, text: str, is_selected: bool, is_favorite: bool, callback: Callable[[str], None], fav_callback: Callable[[str], None] = None): + def __init__(self, text: str, is_selected: bool, callback: Callable[[str], None]): super().__init__() self._text = text self._is_selected = is_selected - self._is_favorite = is_favorite self._callback = callback - self._fav_callback = fav_callback self._font = gui_app.font(FontWeight.MEDIUM) self._font_size = 48 self._pressed = False - self._fav_pressed = False self.set_rect(rl.Rectangle(0, 0, 0, 110)) def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: @@ -94,14 +91,9 @@ class SelectionItem(Widget): if self._is_selected: rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE) - # Favorite Star - Left side - star = "♥" if self._is_favorite else "♡" - star_pos = rl.Vector2(rect.x + 25, rect.y + (rect.height - self._font_size) / 2) - rl.draw_text_ex(self._font, star, star_pos, self._font_size + 10, 0, rl.WHITE) - # Text text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0) - text_pos = rl.Vector2(rect.x + 90, rect.y + (rect.height - text_size.y) / 2) + text_pos = rl.Vector2(rect.x + 40, rect.y + (rect.height - text_size.y) / 2) rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE) # Indicator (Dot for selection instead of radio) @@ -109,25 +101,15 @@ class SelectionItem(Widget): circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2) rl.draw_circle_v(circle_center, 12, rl.WHITE) - @property - def _fav_rect(self) -> rl.Rectangle: - return rl.Rectangle(self._rect.x, self._rect.y, 80, self._rect.height) - def _handle_mouse_press(self, mouse_pos): - if rl.check_collision_point_rec(mouse_pos, self._fav_rect): - self._fav_pressed = True - elif rl.check_collision_point_rec(mouse_pos, self._hit_rect): + if rl.check_collision_point_rec(mouse_pos, self._hit_rect): self._pressed = True def _handle_mouse_release(self, mouse_pos): - if self._fav_pressed and rl.check_collision_point_rec(mouse_pos, self._fav_rect): - if self._fav_callback: - self._fav_callback(self._text) - elif self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect): + if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect): if self._callback: self._callback(self._text) self._pressed = False - self._fav_pressed = False class SelectionDialog(Widget): def __init__(self, title: str, options, current_selection: str = "", @@ -214,20 +196,16 @@ class SelectionDialog(Widget): for model in sorted_models: key = self._name_to_file.get(model, model) is_selected = (model == self._selected_value or key == self._selected_value) - is_fav = key in self._user_favorites or key in self._community_favorites items.append(SelectionItem( text=model, is_selected=is_selected, - is_favorite=is_fav, - callback=self._on_item_selected, - fav_callback=self._toggle_favorite if self._favorites_editable else None + callback=self._on_item_selected )) else: for option in self._options_raw: items.append(SelectionItem( text=option, is_selected=(option == self._selected_value), - is_favorite=False, callback=self._on_item_selected )) @@ -257,14 +235,14 @@ class SelectionDialog(Widget): item._is_selected = (item._text == val) def _cancel_button_callback(self): + gui_app.set_modal_overlay(None) if self._on_close: self._on_close(DialogResult.CANCEL, "") - gui_app.set_modal_overlay(None) def _confirm_button_callback(self): + gui_app.set_modal_overlay(None) if self._on_close: self._on_close(DialogResult.CONFIRM, self._selected_value) - gui_app.set_modal_overlay(None) def show_event(self): super().show_event()