From 0b1a94914bb5c01b8168b35e67c9e90dfe017c9e Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:08:49 -0400 Subject: [PATCH] BigUI WIP: Simple Download Manager Holdover --- .../layouts/settings/starpilot/appearance.py | 22 +- .../starpilot/simple_download_manager.py | 632 ++++++++++++++++++ 2 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 selfdrive/ui/layouts/settings/starpilot/simple_download_manager.py diff --git a/selfdrive/ui/layouts/settings/starpilot/appearance.py b/selfdrive/ui/layouts/settings/starpilot/appearance.py index 99c032e9d..4878e8c68 100644 --- a/selfdrive/ui/layouts/settings/starpilot/appearance.py +++ b/selfdrive/ui/layouts/settings/starpilot/appearance.py @@ -27,6 +27,8 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( PLATE_TAU, AetherListColors, ) +from openpilot.selfdrive.ui.layouts.settings.starpilot.simple_download_manager import SimpleDownloadManager +from openpilot.starpilot.common.starpilot_variables import THEME_SAVE_PATH PANEL_STYLE = DEFAULT_PANEL_STYLE @@ -445,7 +447,7 @@ class StarPilotAppearanceLayout(_SettingsPage): SettingRow("BootLogo", "value", tr_noop("Boot Logo"), subtitle="", get_value=lambda: self._get_theme_value("BootLogo"), - on_click=lambda: self._show_theme_selector("BootLogo")), + on_click=self._show_boot_logo_manager), SettingRow("StartupAlert", "value", tr_noop("Startup Alert"), subtitle="", get_value=self._get_startup_alert_display, @@ -703,3 +705,21 @@ class StarPilotAppearanceLayout(_SettingsPage): dialog = MultiOptionDialog(tr("Startup Alert"), options, current, callback=on_select) gui_app.push_widget(dialog) + + # ── Boot logo manager ── + + def _show_boot_logo_manager(self): + def on_close(res, val): + pass + + gui_app.push_widget(SimpleDownloadManager( + title=tr("Boot Logo"), + asset_type="boot logo", + directory=THEME_SAVE_PATH / "bootlogos", + asset_param="BootLogo", + download_param="BootLogoToDownload", + downloadable_list_param="DownloadableBootLogos", + params=self._params, + params_memory=self._params_memory, + on_close=on_close, + )) diff --git a/selfdrive/ui/layouts/settings/starpilot/simple_download_manager.py b/selfdrive/ui/layouts/settings/starpilot/simple_download_manager.py new file mode 100644 index 000000000..9b4bae959 --- /dev/null +++ b/selfdrive/ui/layouts/settings/starpilot/simple_download_manager.py @@ -0,0 +1,632 @@ +from __future__ import annotations +import re +import time +from pathlib import Path +from collections.abc import Callable + +import pyray as rl +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( + AetherListColors, AetherListMetrics, AetherScrollbar, + draw_hud_background, draw_soft_card, draw_action_pill, draw_selection_list_row, + draw_empty_state_card, draw_busy_ring, draw_list_scroll_fades, + draw_rounded_fill, draw_rounded_stroke, draw_text_fit_common, + draw_download_icon, + with_alpha, hex_to_color, snap_rect, point_hits, + aether_begin_scissor_mode, aether_end_scissor_mode, +) + + +# ── Helpers ── + +def _theme_display_name(value: str) -> str: + if not value: + return "Stock" + if value.lower() == "stock": + return "Stock" + if value.lower() == "none": + return "None" + base, creator = (value.split("~", 1) + [""])[:2] if "~" in value else (value, "") + user_created = False + for suffix in ("-user_created", "_user_created", "-user-created", "_user-created"): + if base.endswith(suffix): + base = base[: -len(suffix)] + user_created = True + break + parts = [part for part in re.split(r"[-_]+", base) if part] + display = " ".join(part[:1].upper() + part[1:] for part in parts) if parts else value + if user_created: + display += " (User Created)" + if creator: + display += f" - by: {creator}" + return display + + +def _display_to_slug(display: str) -> str: + slug = display.lower().strip() + slug = slug.replace("(user created)", "").strip() + if " - by: " in slug: + slug = slug.split(" - by: ")[0].strip() + slug = slug.replace("(", "").replace(")", "").replace("'", "").replace(".", "").replace("\u2728", "").strip() + slug = slug.replace(" ", "_") + slug = re.sub(r"_+", "_", slug) + slug = slug.strip("_") + return slug + + +def _delete_asset_file(directory: Path, display_name: str) -> bool: + base = display_name.lower() + base = base.replace("(", "-").replace(")", "").replace(" ", "-") + base = re.sub(r"[^a-z0-9\-]", "", base) + while base.endswith("-"): + base = base[:-1] + base_underscore = base.replace("-", "_") + candidates = {base, base + "-user-created", base_underscore, base_underscore + "-user_created"} + + for f in sorted(directory.iterdir()): + if f.is_file(): + normalized = f.stem.lower().replace("_", "-") + normalized = re.sub(r"[^a-z0-9\-~]", "", normalized) + if normalized in candidates: + f.unlink() + return True + return False + + +def _add_to_downloadable_list(params, param_key: str, display_name: str): + current = params.get(param_key, encoding="utf-8") or "" + assets = [a.strip() for a in current.split(",") if a.strip()] + slug = _display_to_slug(display_name) + if slug and slug not in assets: + assets.append(slug) + assets.sort() + params.put(param_key, ",".join(assets)) + + +def _is_online() -> bool: + try: + from openpilot.selfdrive.ui.ui_state import ui_state + from cereal import log + nt = ui_state.sm["deviceState"].networkType if ui_state.sm.valid.get("deviceState", False) else log.NetworkType.none + return nt != log.NetworkType.none + except Exception: + return True + + +def _is_parked() -> bool: + try: + from openpilot.selfdrive.ui.ui_state import ui_state + if ui_state.is_offroad(): + return True + if ui_state.sm.valid.get("starpilotCarState", False): + return bool(ui_state.sm["starpilotCarState"].isParked) + except Exception: + pass + return True + + +class SimpleDownloadManager(Widget): + """Generic fullscreen dialog for managing downloadable assets.""" + + MODE_SELECT = 0 + MODE_DELETE = 1 + MODE_DOWNLOAD = 2 + + MODE_LABELS = ["SELECT", "DELETE", "DOWNLOAD"] + ITEM_HEIGHT = 88 + PILL_HEIGHT = 50 + PILL_GAP = 10 + OUTER_MARGIN = 60 + + def __init__( + self, + title: str, + asset_type: str, + directory: str | Path, + asset_param: str, + download_param: str, + downloadable_list_param: str, + params, + params_memory, + on_close: Callable, + *, + get_is_online: Callable[[], bool] | None = None, + get_is_parked: Callable[[], bool] | None = None, + ): + super().__init__() + self.title = title + self.asset_type = asset_type + self.directory = Path(directory) + self.asset_param = asset_param + self.download_param = download_param + self.downloadable_list_param = downloadable_list_param + self.params = params + self.params_memory = params_memory + self._on_close = on_close + + self._get_is_online = get_is_online or (lambda: True) + self._get_is_parked = get_is_parked or (lambda: True) + + self._active_mode = self.MODE_SELECT + self._downloading = False + self._download_progress = "" + self._finalizing_until = 0.0 + self._pressed_target: str | None = None + self._pressed_time = 0.0 + + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scrollbar = AetherScrollbar() + self._scroll_offset = 0.0 + self._content_height = 0.0 + + self._list_items: list[str] = [] + self._item_rects: dict[str, rl.Rectangle] = {} + self._pill_rects: list[rl.Rectangle] = [] + self._confirm_target: str | None = None + self._confirm_yes_rect = rl.Rectangle(0, 0, 0, 0) + self._confirm_no_rect = rl.Rectangle(0, 0, 0, 0) + self._close_rect = rl.Rectangle(0, 0, 0, 0) + self._cancel_rect = rl.Rectangle(0, 0, 0, 0) + self._info_message = "" + self._info_message_until = 0.0 + + self._refresh_list() + self.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) + + # ── List data ── + + def _refresh_list(self): + if self._active_mode in (self.MODE_SELECT, self.MODE_DELETE): + self._list_items = self._list_downloaded() + elif self._active_mode == self.MODE_DOWNLOAD: + downloadable = self._list_downloadable() + downloaded_stems = set(s.lower() for s in self._list_downloaded_stems()) + self._list_items = [ + item for item in downloadable + if _display_to_slug(item).lower() not in downloaded_stems + ] + + def _list_downloaded(self) -> list[str]: + if not self.directory.is_dir(): + return [] + items: list[str] = [] + for f in sorted(self.directory.iterdir()): + if f.is_file(): + items.append(_theme_display_name(f.stem)) + return items + + def _list_downloaded_stems(self) -> list[str]: + if not self.directory.is_dir(): + return [] + return [f.stem for f in self.directory.iterdir() if f.is_file()] + + def _list_downloadable(self) -> list[str]: + raw = self.params.get(self.downloadable_list_param, encoding="utf-8") or "" + if not raw.strip(): + return [] + names = [s.strip() for s in raw.split(",") if s.strip()] + return [_theme_display_name(n) for n in names] + + def _current_value(self) -> str: + return self.params.get(self.asset_param, encoding="utf-8") or "" + + # ── Actions ── + + def _select_asset(self, display_name: str): + slug = _display_to_slug(display_name) + if slug: + self.params.put(self.asset_param, slug) + self._close(DialogResult.CONFIRM, slug) + + def _delete_asset(self, display_name: str): + _delete_asset_file(self.directory, display_name) + _add_to_downloadable_list(self.params, self.downloadable_list_param, display_name) + self._info_message = f"Deleted \"{display_name}\"" + self._info_message_until = time.monotonic() + 2.5 + self._confirm_target = None + self._refresh_list() + + def _start_download(self, display_name: str): + if self._downloading: + return + slug = _display_to_slug(display_name) + if not slug: + return + self._downloading = True + self._download_progress = "Downloading..." + self.params_memory.put("ThemeDownloadProgress", "Downloading...") + self.params_memory.put(self.download_param, slug) + + def _cancel_download(self): + self.params_memory.put_bool("CancelThemeDownload", True) + self._info_message = tr("Cancelling...") + self._info_message_until = time.monotonic() + 5.0 + + def _close(self, result: DialogResult, value=None): + gui_app.pop_widget() + if self._on_close: + self._on_close(result, value) + + # ── Mouse handling ── + + def _hit_target(self, pos: MousePos) -> str | None: + if rl.check_collision_point_rec(pos, self._close_rect): + return "close" + for i, prect in enumerate(self._pill_rects): + if rl.check_collision_point_rec(pos, prect): + return f"mode:{i}" + if self._confirm_target is not None: + if rl.check_collision_point_rec(pos, self._confirm_yes_rect): + return "confirm_yes" + if rl.check_collision_point_rec(pos, self._confirm_no_rect): + return "confirm_no" + return None + if self._downloading: + if rl.check_collision_point_rec(pos, self._cancel_rect): + return "cancel_download" + return None + for key, irect in self._item_rects.items(): + if rl.check_collision_point_rec(pos, irect): + return key + return None + + def _handle_mouse_press(self, pos: MousePos): + target = self._hit_target(pos) + if target: + self._pressed_target = target + self._pressed_time = rl.get_time() + + def _handle_mouse_release(self, pos: MousePos): + target = self._hit_target(pos) + if target and target == self._pressed_target: + self._on_target(target) + self._pressed_target = None + + def _on_target(self, target: str): + if target == "close": + self._close(DialogResult.CANCEL) + elif target.startswith("mode:"): + mode = int(target.split(":")[1]) + if mode != self._active_mode: + self._active_mode = mode + self._scroll_offset = 0.0 + self._confirm_target = None + self._refresh_list() + elif target.startswith("item:"): + idx = int(target.split(":")[1]) + if 0 <= idx < len(self._list_items): + item = self._list_items[idx] + if self._active_mode == self.MODE_SELECT: + self._select_asset(item) + elif self._active_mode == self.MODE_DELETE: + self._confirm_target = item + elif self._active_mode == self.MODE_DOWNLOAD: + self._start_download(item) + elif target == "confirm_yes" and self._confirm_target is not None: + self._delete_asset(self._confirm_target) + elif target == "confirm_no": + self._confirm_target = None + elif target == "cancel_download": + self._cancel_download() + + # ── State update ── + + def _update_state(self): + now = time.monotonic() + if self._pressed_target is not None and not self.is_pressed: + self._pressed_target = None + if self._info_message_until > 0 and now >= self._info_message_until: + self._info_message = "" + self._info_message_until = 0.0 + if self._finalizing_until > 0 and now >= self._finalizing_until: + self._finalizing_until = 0.0 + self._downloading = False + self._download_progress = "" + self._refresh_list() + if self._downloading: + progress = self.params_memory.get("ThemeDownloadProgress", encoding="utf-8") or "" + if progress != self._download_progress: + self._download_progress = progress + if progress == "Downloaded!" or ("cancelled" in progress.lower() or "failed" in progress.lower() or "offline" in progress.lower() or "unavailable" in progress.lower()): + self._finalizing_until = now + 2.5 + + # ── keepScreenOn support ── + + @property + def should_keep_screen_on(self) -> bool: + return self._downloading + + # ── Rendering ── + + def _render(self, rect: rl.Rectangle) -> bool | int | None: + self.set_rect(rect) + + # Full-screen HUD background + draw_hud_background(rect, AetherListColors.PRIMARY, glow=0.5, radius_px=200, + bg_color=rl.Color(3, 3, 5, 240)) + + # Dialog card + margin_x = self.OUTER_MARGIN + margin_y = self.OUTER_MARGIN + card_w = min(rect.width - margin_x * 2, 680) + card_h = min(rect.height - margin_y * 2, 660) + card_x = rect.x + (rect.width - card_w) / 2 + card_y = rect.y + (rect.height - card_h) / 2 + card_rect = rl.Rectangle(card_x, card_y, card_w, card_h) + draw_soft_card(card_rect, rl.Color(14, 13, 18, 255), with_alpha(AetherListColors.PANEL_BORDER, 180), + radius=0.022, segments=20) + + cx = int(card_x + 24) + cw = int(card_w - 48) + y = int(card_y + 20) + + # ── Title + close ── + title_font = gui_app.font(FontWeight.BOLD) + close_size = 36 + close_x = int(card_x + card_w - 24 - close_size) + self._close_rect = rl.Rectangle(close_x, y + 2, close_size, close_size) + + draw_text_fit_common(title_font, self.title, rl.Vector2(float(cx), float(y)), + float(cw - close_size - 16), 32, color=AetherListColors.HEADER) + + close_hovered = self._pressed_target == "close" + rl.draw_rectangle_rounded(self._close_rect, 0.5, 8, + with_alpha(rl.Color(255, 255, 255, 28 if close_hovered else 14), 255)) + cx_c = self._close_rect.x + self._close_rect.width / 2 + cy_c = self._close_rect.y + self._close_rect.height / 2 + s = close_size * 0.3 + clr = AetherListColors.MUTED + rl.draw_line_ex(rl.Vector2(cx_c - s, cy_c - s), rl.Vector2(cx_c + s, cy_c + s), 2.5, clr) + rl.draw_line_ex(rl.Vector2(cx_c + s, cy_c - s), rl.Vector2(cx_c - s, cy_c + s), 2.5, clr) + + y += 48 + + # ── Current selection ── + current_val = self._current_value() + current_display = _theme_display_name(current_val) if current_val else "Stock" + info_font = gui_app.font(FontWeight.NORMAL) + rl.draw_text_ex(info_font, f"{tr('Current')}: {current_display}", + rl.Vector2(float(cx), float(y)), 20, 0, AetherListColors.SUBTEXT) + y += 32 + + # ── Info message ── + if self._info_message and time.monotonic() < self._info_message_until: + rl.draw_text_ex(info_font, self._info_message, + rl.Vector2(float(cx), float(y)), 18, 0, AetherListColors.SUCCESS) + y += 26 + + # ── Mode pills ── + pill_w = int((cw - self.PILL_GAP * 2) / 3) + self._pill_rects = [] + active_fill = AetherListColors.PRIMARY + active_border = with_alpha(AetherListColors.PRIMARY, 180) + active_text = AetherListColors.HEADER + inactive_fill = with_alpha(rl.Color(255, 255, 255, 8), 255) + inactive_border = with_alpha(rl.Color(255, 255, 255, 36), 255) + inactive_text = AetherListColors.MUTED + + for i, label in enumerate(self.MODE_LABELS): + px = cx + i * (pill_w + self.PILL_GAP) + prect = snap_rect(rl.Rectangle(float(px), float(y), float(pill_w), float(self.PILL_HEIGHT))) + self._pill_rects.append(prect) + is_active = i == self._active_mode + is_pressed = self._pressed_target == f"mode:{i}" + hovered = is_pressed + draw_action_pill( + prect, + tr(label), + active_fill if is_active else inactive_fill, + active_border if is_active else inactive_border, + active_text if is_active else inactive_text, + font_size=20, + ) + if is_pressed: + rl.draw_rectangle_rounded(prect, 0.35, 12, with_alpha(rl.Color(255, 255, 255, 10), 255)) + + y += self.PILL_HEIGHT + 16 + + # ── Content area ── + scroll_y = y + scroll_h = int(card_rect.y + card_rect.height - scroll_y - 16) + if scroll_h < 40: + return True + scroll_rect = rl.Rectangle(float(cx), float(scroll_y), float(cw), float(scroll_h)) + + # Measure content + n_items = len(self._list_items) + content_height = float(n_items * self.ITEM_HEIGHT) + + self._scroll_panel.set_enabled(True) + self._scroll_offset = self._scroll_panel.update(scroll_rect, max(content_height, scroll_rect.height)) + + aether_begin_scissor_mode(int(scroll_rect.x), int(scroll_rect.y), + int(scroll_rect.width), int(scroll_rect.height)) + + if n_items == 0 and not self._downloading: + empty_msg = self._empty_state_text() + draw_empty_state_card( + scroll_rect, + tr(empty_msg["title"]), + tr(empty_msg["body"]), + title_size=26, body_size=20, + fill=rl.Color(0, 0, 0, 0), border=rl.Color(0, 0, 0, 0), + ) + else: + self._item_rects.clear() + item_y = scroll_rect.y + self._scroll_offset + normal_font = gui_app.font(FontWeight.NORMAL) + + if self._downloading: + # Download progress display + prog_h = 120 + prog_rect = rl.Rectangle(scroll_rect.x + 20, scroll_rect.y + self._scroll_offset + 20, + scroll_rect.width - 40, prog_h) + draw_rounded_fill(prog_rect, with_alpha(rl.Color(255, 255, 255, 6), 255), radius_px=12) + + # Busy ring + ring_center = rl.Vector2(prog_rect.x + 50, prog_rect.y + prog_rect.height / 2) + draw_busy_ring(ring_center, rl.get_time() * 4, AetherListColors.PRIMARY, + track_color=with_alpha(rl.Color(255, 255, 255, 18), 255)) + + # Progress text + progress_text = self._download_progress or "Downloading..." + draw_text_fit_common( + normal_font, progress_text, + rl.Vector2(prog_rect.x + 90, prog_rect.y + 24), + prog_rect.width - 110, 24, + color=AetherListColors.HEADER, + ) + + # Cancel button + cancel_w, cancel_h = 160, 44 + cancel_y = prog_rect.y + prog_h - cancel_h - 16 + self._cancel_rect = snap_rect(rl.Rectangle( + prog_rect.x + 90, cancel_y, float(cancel_w), float(cancel_h))) + cancel_hovered = self._pressed_target == "cancel_download" + draw_action_pill( + self._cancel_rect, tr("CANCEL"), + with_alpha(AetherListColors.DANGER, 40 if cancel_hovered else 24), + with_alpha(AetherListColors.DANGER, 80), + AetherListColors.HEADER, font_size=18, + ) + + item_y += prog_h + 40 + + if self._confirm_target is not None: + # Confirm overlay + confirm_w = min(380, scroll_rect.width - 60) + confirm_h = 160 + confirm_rect = rl.Rectangle( + scroll_rect.x + (scroll_rect.width - confirm_w) / 2, + scroll_rect.y + self._scroll_offset + (scroll_rect.height - confirm_h) / 2, + confirm_w, confirm_h, + ) + draw_soft_card(confirm_rect, rl.Color(20, 19, 25, 255), with_alpha(AetherListColors.PANEL_BORDER, 120), + radius=0.03, segments=16) + + confirm_font = gui_app.font(FontWeight.MEDIUM) + confirm_msg = tr("Delete \"{name}\"?").format(name=self._confirm_target) + draw_text_fit_common( + confirm_font, confirm_msg, + rl.Vector2(confirm_rect.x + 20, confirm_rect.y + 24), + confirm_rect.width - 40, 24, + align_center=True, color=AetherListColors.HEADER, + ) + + btn_w = (confirm_rect.width - 50) / 2 + btn_y = confirm_rect.y + confirm_rect.height - 56 + btn_h = 40 + + self._confirm_no_rect = snap_rect(rl.Rectangle(confirm_rect.x + 16, btn_y, btn_w, btn_h)) + self._confirm_yes_rect = snap_rect(rl.Rectangle(confirm_rect.x + confirm_rect.width - btn_w - 16, btn_y, btn_w, btn_h)) + + no_hovered = self._pressed_target == "confirm_no" + yes_hovered = self._pressed_target == "confirm_yes" + + draw_action_pill(self._confirm_no_rect, tr("CANCEL"), + with_alpha(rl.Color(255, 255, 255, 10 if no_hovered else 6), 255), + with_alpha(rl.Color(255, 255, 255, 36), 255), + AetherListColors.SUBTEXT, font_size=18) + draw_action_pill(self._confirm_yes_rect, tr("DELETE"), + with_alpha(AetherListColors.DANGER, 50 if yes_hovered else 30), + with_alpha(AetherListColors.DANGER, 100), + AetherListColors.HEADER, font_size=18) + + item_y += confirm_h + 20 + + # Draw list items + for idx, item in enumerate(self._list_items): + irec = rl.Rectangle(scroll_rect.x, item_y, scroll_rect.width, float(self.ITEM_HEIGHT)) + key = f"item:{idx}" + self._item_rects[key] = irec + + is_last = idx == n_items - 1 + is_current = ( + self._active_mode == self.MODE_SELECT + and _display_to_slug(item) == self._current_value() + ) + is_pressed = self._pressed_target == key + is_hovered = is_pressed + + is_deleting = self._active_mode == self.MODE_DELETE + is_downloading_mode = self._active_mode == self.MODE_DOWNLOAD + + action_text = "" + action_fill = AetherListColors.CURRENT_BG + action_border = AetherListColors.CURRENT_BORDER + action_text_color = AetherListColors.HEADER + row_bg = AetherListColors.ROW_BG + row_border = AetherListColors.ROW_BORDER + + if is_current: + action_text = tr("Current") + action_fill = AetherListColors.CURRENT_BG + action_border = AetherListColors.CURRENT_BORDER + action_text_color = AetherListColors.HEADER + + if is_deleting: + action_text = tr("DELETE") + action_fill = with_alpha(AetherListColors.DANGER, 30) + action_border = with_alpha(AetherListColors.DANGER, 80) + action_text_color = AetherListColors.DANGER + + if is_downloading_mode: + action_text = tr("DOWNLOAD") + action_fill = with_alpha(AetherListColors.SUCCESS, 30) + action_border = with_alpha(AetherListColors.SUCCESS, 80) + action_text_color = AetherListColors.SUCCESS + + draw_selection_list_row( + irec, + title=item, + subtitle="", + action_text=action_text, + current=is_current and self._active_mode == self.MODE_SELECT, + hovered=is_hovered, + pressed=is_pressed and is_deleting, + is_last=is_last, + alpha=255, + action_pill=True, + action_pill_height=38, + title_size=26, + title_color=AetherListColors.HEADER, + subtitle_color=AetherListColors.SUBTEXT, + action_fill=action_fill, + action_border=action_border, + action_text_color=action_text_color, + row_bg=row_bg, + row_border=row_border, + row_separator=AetherListColors.ROW_SEPARATOR, + row_hover=AetherListColors.ROW_HOVER, + current_bg=AetherListColors.CURRENT_BG, + current_border=AetherListColors.CURRENT_BORDER, + ) + + item_y += self.ITEM_HEIGHT + + aether_end_scissor_mode() + + # Scrollbar + if content_height > scroll_rect.height: + self._scrollbar.render(scroll_rect, content_height, self._scroll_offset) + + # Fades + draw_list_scroll_fades(scroll_rect, content_height, self._scroll_offset, rl.Color(14, 13, 18, 255)) + + return True + + def _empty_state_text(self) -> dict: + at = self.asset_type + if self._active_mode == self.MODE_SELECT: + return {"title": tr("No {name}s").format(name=at.title()), "body": tr("Download {name}s to see them here.").format(name=at)} + if self._active_mode == self.MODE_DELETE: + return {"title": tr("Nothing to Delete"), "body": tr("No {name}s are currently downloaded.").format(name=at)} + if self._active_mode == self.MODE_DOWNLOAD: + if self._downloading: + return {"title": "", "body": ""} + return {"title": tr("All Available"), "body": tr("You have all available {name}s.").format(name=at)} + return {"title": "", "body": ""}