From 20ba774eaa8856f71bd8d3e76c9db04a2b069282 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 18 Aug 2026 19:45:40 -0400 Subject: [PATCH] [TIZI/TICI] ui: fix missing model download status and rework status row (#1920) * [TIZI/TICI] ui: fix missing model download status and rework the status row * fix lint --- .../ui/sunnypilot/layouts/settings/models.py | 74 ++++---- openpilot/system/ui/sunnypilot/lib/utils.py | 40 +++++ .../ui/sunnypilot/widgets/download_status.py | 166 ++++++++++++++++++ 3 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 openpilot/system/ui/sunnypilot/widgets/download_status.py diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index ae4ff802f3..aa5700fe28 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -22,9 +22,9 @@ from openpilot.system.ui.widgets.toggle import ON_COLOR from openpilot.sunnypilot.models.runners.constants import CUSTOM_MODEL_PATH from openpilot.system.ui.sunnypilot.lib.styles import style -from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction +from openpilot.system.ui.sunnypilot.lib.utils import NoElideButtonAction, ScrollingButtonAction from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, toggle_item_sp, option_item_sp -from openpilot.system.ui.sunnypilot.widgets.progress_bar import progress_item +from openpilot.system.ui.sunnypilot.widgets.download_status import download_status_item from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder if gui_app.sunnypilot_ui(): @@ -35,9 +35,8 @@ class ModelsLayout(Widget): def __init__(self): super().__init__() self.model_manager = None - self.download_status = None - self.prev_download_status = None self.model_dialog = None + self._downloading = False self.last_cache_calc_time = 0 self._initialize_items() @@ -52,15 +51,11 @@ class ModelsLayout(Widget): self.current_model_item = ListItemSP( title=tr("Current Model"), description="", - action_item=NoElideButtonAction(tr("SELECT")), + action_item=ScrollingButtonAction(tr("SELECT")), callback=self._handle_current_model_clicked ) - self.supercombo_label = progress_item(tr("Driving Model")) - self.vision_label = progress_item(tr("Vision Model")) - self.policy_label = progress_item(tr("Policy Model")) - self.off_policy_label = progress_item(tr("Off-Policy Model")) - self.on_policy_label = progress_item(tr("On-Policy Model")) + self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status")) self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), @@ -98,8 +93,7 @@ class ModelsLayout(Widget): 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{v / 100:.2f} m") - self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label, - self.policy_label, self.off_policy_label, self.on_policy_label, self.refresh_item, self.clear_cache_item, + self.items = [self.current_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset] def _update_lagd_description(self, lagd_toggle: bool): @@ -135,14 +129,9 @@ class ModelsLayout(Widget): gui_app.push_widget(dialog) def _handle_bundle_download_progress(self): - labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label, - custom.ModelManagerSP.Model.Type.vision: self.vision_label, - custom.ModelManagerSP.Model.Type.policy: self.policy_label, - custom.ModelManagerSP.Model.Type.offPolicy: self.off_policy_label, - custom.ModelManagerSP.Model.Type.onPolicy: self.on_policy_label} - for label in labels.values(): - label.set_visible(False) + self.download_item.set_visible(False) self.cancel_download_item.set_visible(False) + self._downloading = False if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle): return @@ -153,32 +142,41 @@ class ModelsLayout(Widget): if not bundle: return - self.download_status = bundle.status - status_changed = self.prev_download_status != self.download_status - self.prev_download_status = self.download_status - self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None) if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") - if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: + if bundle.status == custom.ModelManagerSP.DownloadStatus.downloading: device._reset_interactive_timeout() - for model in bundle.models: - if label := labels.get(getattr(model.type, 'raw', model.type)): - label.set_visible(True) - p = model.artifact.downloadProgress - text, show, color = f"pending - {bundle.displayName}", False, rl.GRAY - if p.status == custom.ModelManagerSP.DownloadStatus.downloading: - text, show = f"{int(p.progress)}% - {bundle.displayName}", True - elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): - status_text = tr("from cache" if p.status == custom.ModelManagerSP.DownloadStatus.cached else "downloaded") - text, color = f"{bundle.displayName} - {status_text if status_changed else tr('ready')}", ON_COLOR - elif p.status == custom.ModelManagerSP.DownloadStatus.failed: - text, color = f"download failed - {bundle.displayName}", rl.RED - label.action_item.update(p.progress, text, show, color) + # every bundle is a single chunked artifact now + progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] + if not progresses: + return + + self.download_item.set_visible(True) + self.download_item.action_item.update(**self._download_row_state(progresses, bundle.internalName)) + self._downloading = self.download_item.action_item.downloading + + @staticmethod + def _download_row_state(progresses, name: str) -> dict: + """Maps a bundle's artifact progress to DownloadStatusAction.update kwargs.""" + # .raw: _DynamicEnum equals its int but does not hash like it + statuses = {getattr(p.status, 'raw', p.status) for p in progresses} + progress = sum(p.progress for p in progresses) / len(progresses) + ds = custom.ModelManagerSP.DownloadStatus + + if ds.failed in statuses: + # close.png is authored black and a tint cannot lift it, hence close2 + return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"} + if ds.downloading in statuses: + return {"name": name, "downloading": True, "progress": progress} + if statuses <= {ds.downloaded, ds.cached}: + return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"} + # circled_slash is authored grey; tinting it again only darkens it + return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE} @staticmethod def _show_reset_params_dialog(): @@ -251,7 +249,7 @@ class ModelsLayout(Widget): self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)" + active_name = self.model_manager.activeBundle.displayName if self.model_manager and self.model_manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)" self.current_model_item.action_item.set_value(active_name) if not ui_state.is_offroad(): diff --git a/openpilot/system/ui/sunnypilot/lib/utils.py b/openpilot/system/ui/sunnypilot/lib/utils.py index ecaba86f4b..b9ed152aff 100644 --- a/openpilot/system/ui/sunnypilot/lib/utils.py +++ b/openpilot/system/ui/sunnypilot/lib/utils.py @@ -4,7 +4,15 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +from collections.abc import Callable + +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.sunnypilot.lib.styles import style from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.list_view import BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, _resolve_value class NoElideButtonAction(ButtonActionSP): @@ -12,6 +20,38 @@ class NoElideButtonAction(ButtonActionSP): return super().get_width_hint() + 1 +class ScrollingButtonAction(ButtonActionSP): + """ButtonActionSP whose value scrolls instead of eliding when it doesn't fit.""" + + def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, + enabled: bool | Callable[[], bool] = True): + super().__init__(text=text, width=width, enabled=enabled) + self._value_label = UnifiedLabel("", font_size=style.ITEM_TEXT_FONT_SIZE, font_weight=FontWeight.NORMAL, + text_color=self._value_color, scroll=True, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR): + if self.value != _resolve_value(value, ""): + self._value_label.reset_scroll() + super().set_value(value, color) + self._value_label.set_text(value) + self._value_label.set_text_color(color) + + def _render(self, rect: rl.Rectangle) -> bool: + """Duplicate of ButtonActionSP._render, with the value drawn by a scrolling label""" + self._button.set_text(self.text) + self._button.set_enabled(_resolve_value(self.enabled)) + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + self._button.render(button_rect) + + if self.value: + self._value_label.render(rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)) + + pressed = self._pressed + self._pressed = False + return pressed + + class AlertFadeAnimator: def __init__(self, target_fps: int, duration_on: float = 0.75, rc: float = 0.05): from openpilot.common.filter_simple import FirstOrderFilter diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py new file mode 100644 index 0000000000..b299c464f1 --- /dev/null +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -0,0 +1,166 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import math + +import numpy as np +import pyray as rl + +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.sunnypilot.lib.styles import style +from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.list_view import ItemAction + +FONT_SIZE = style.ITEM_TEXT_FONT_SIZE +ICON_SIZE = 56 +ICON_PADDING = 12 + +BAR_WIDTH = 1100 +BAR_HEIGHT = 20 +BAR_GAP = 16 +BAR_RADIUS = BAR_HEIGHT / 2 +CAPSULE_POINTS = 24 + +RAIL_COLOR = rl.Color(60, 60, 60, 255) +FILL_COLOR = rl.Color(30, 121, 232, 255) +# rl.WHITE is a tuple; the shimmer path reads .a off the color +TEXT_COLOR = rl.Color(255, 255, 255, 255) + +SWEEP_SPEED = 550.0 # px/s +SWEEP_BAND = 240.0 # highlight half-width, px +SWEEP_DIM = 0.65 + + +class DownloadStatusAction(ItemAction): + """Model download row: a name + percent over a progress rail while downloading, a name + icon otherwise.""" + + def __init__(self): + super().__init__(width=BAR_WIDTH) + self.name = "" + self.status_text = "" + self.downloading = False + self.text_color = rl.GRAY + self.icon: str | None = None + self.icon_color: rl.Color | None = None + self._font = gui_app.font(FontWeight.NORMAL) + # raw progress arrives in steps, one per 128KB chunk the manager publishes + self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) + # integrated per frame; (t * speed) % span jumps whenever the fill width changes + self._sweep = 0.0 + + self._name_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + self._percent_label = UnifiedLabel("", font_size=FONT_SIZE, font_weight=FontWeight.NORMAL, text_color=TEXT_COLOR, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def update(self, name, downloading=False, progress=0.0, status_text="", text_color=rl.GRAY, icon=None, icon_color=None): + if downloading and not self.downloading: + self._name_label.reset_shimmer() + self._progress.x = progress + self._sweep = 0.0 + self.name = name + self.downloading = downloading + self.status_text = status_text + self.text_color = text_color + self.icon = icon + self.icon_color = icon_color + self._name_label._shimmer = downloading + if downloading: + self._progress.update(progress) + self._sweep += SWEEP_SPEED / gui_app.target_fps + + @property + def _idle_text(self) -> str: + return f"{self.name} - {self.status_text}" if self.status_text else self.name + + def get_width_hint(self) -> float: + if self.downloading: + return BAR_WIDTH + width = measure_text_cached(self._font, self._idle_text, FONT_SIZE).x + if self.icon: + width += ICON_SIZE + ICON_PADDING + return width + + def _render(self, rect: rl.Rectangle): + if self.downloading: + self._render_downloading(rect) + else: + self._render_idle(rect) + + def _sweep_gradient(self, width: float) -> Gradient: + # clearance at both ends keeps the wrap offscreen + center = (self._sweep % (width + 2 * SWEEP_BAND)) - SWEEP_BAND + + def band(x: float) -> float: + return max(0.0, 1.0 - abs(x - center) / SWEEP_BAND) + + # sampling the corners is exact for a piecewise linear band + xs = sorted({0.0, width} | {min(max(center + o, 0.0), width) for o in (-SWEEP_BAND, 0.0, SWEEP_BAND)}, reverse=True) + # the gradient axis runs right-to-left in screen space + stops = [1.0 - x / width for x in xs] + # alpha here is the lift over the SWEEP_DIM base, not the final opacity + colors = [rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * band(x))) for x in xs] + return Gradient(start=(0.0, 0.0), end=(1.0, 0.0), colors=colors, stops=stops) + + @staticmethod + def _capsule(rect: rl.Rectangle) -> np.ndarray: + """Rounded-end ribbon so the gradient covers the caps.""" + r = rect.height / 2 + cy = rect.y + r + top, bottom = [], [] + for i in range(CAPSULE_POINTS): + x = rect.x + rect.width * i / (CAPSULE_POINTS - 1) + d = min(x - rect.x, rect.x + rect.width - x, r) + h = math.sqrt(max(r * r - (r - d) ** 2, 0.0)) + top.append((x, cy - h)) + bottom.append((x, cy + h)) + return np.array(top + bottom[::-1], dtype=np.float32) + + def _draw_fill(self, rail: rl.Rectangle, fill_width: float): + if fill_width <= 0: + return + fill = rl.Rectangle(rail.x, rail.y, fill_width, rail.height) + rl.draw_rectangle_rounded(fill, 1.0, 10, rl.Color(FILL_COLOR.r, FILL_COLOR.g, FILL_COLOR.b, int(255 * SWEEP_DIM))) + draw_polygon(fill, self._capsule(fill), gradient=self._sweep_gradient(fill_width)) + + def _render_downloading(self, rect: rl.Rectangle): + percent = f"{int(self._progress.x)}%" + text_height = measure_text_cached(self._font, percent, FONT_SIZE).y + top = rect.y + (rect.height - (text_height + BAR_GAP + BAR_HEIGHT)) / 2 + + text_rect = rl.Rectangle(rect.x, top, rect.width, text_height) + self._name_label.set_text(self.name) + self._name_label.render(text_rect) + self._percent_label.set_text(percent) + self._percent_label.render(text_rect) + + rail = rl.Rectangle(rect.x, top + text_height + BAR_GAP, rect.width, BAR_HEIGHT) + rl.draw_rectangle_rounded(rail, 1.0, 10, RAIL_COLOR) + self._draw_fill(rail, max(0.0, min(rect.width, rect.width * (self._progress.x / 100.0)))) + + def _render_idle(self, rect: rl.Rectangle): + text = self._idle_text + text_size = measure_text_cached(self._font, text, FONT_SIZE) + right = rect.x + rect.width + + if self.icon: + texture = gui_app.texture(self.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) + rl.draw_texture_v(texture, rl.Vector2(right - texture.width, rect.y + (rect.height - texture.height) / 2), + self.icon_color or self.text_color) + right -= texture.width + ICON_PADDING + + rl.draw_text_ex(self._font, text, rl.Vector2(right - text_size.x, rect.y + (rect.height - text_size.y) / 2), + FONT_SIZE, 0, self.text_color) + + +def download_status_item(title): + return ListItemSP(title=title, action_item=DownloadStatusAction(), title_color=style.ITEM_TEXT_COLOR)