[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
This commit is contained in:
Jason Wen
2026-08-18 19:45:40 -04:00
committed by GitHub
parent 59833c500a
commit 20ba774eaa
3 changed files with 242 additions and 38 deletions
@@ -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():
@@ -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
@@ -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)