import re import shutil import threading from enum import IntEnum from pathlib import Path from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog, DialogResult from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, TextAction, ITEM_TEXT_VALUE_COLOR from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.frogpilot.common.frogpilot_variables import update_frogpilot_toggles from openpilot.frogpilot.system.ui.widgets.frogpilot_controls import ( FrogPilotButtonsControl, FrogPilotButtonToggleControl, FrogPilotConfirmationDialog, FrogPilotManageControl, ) THEME_PACKS_DIR = Path("/data/themes/theme_packs/") WHEELS_DIR = Path("/data/themes/steering_wheels/") CUSTOM_THEME_KEYS = { "ColorScheme", "DistanceIconPack", "DownloadStatusLabel", "IconPack", "SignalAnimation", "SoundPack", "WheelIcon", } HOLIDAY_THEMES = [ "New Year's", "Valentine's Day", "St. Patrick's Day", "World Frog Day", "April Fools", "Easter", "May the Fourth", "Cinco de Mayo", "Stitch Day", "Fourth of July", "Halloween", "Thanksgiving", "Christmas", ] # Asset type configurations: (sub_folder, param_key, downloadable_param, download_key) ASSET_CONFIGS = { "ColorScheme": ("colors", "ColorScheme", "DownloadableColors", "ColorToDownload"), "DistanceIconPack": ("distance_icons", "DistanceIconPack", "DownloadableDistanceIcons", "DistanceIconToDownload"), "IconPack": ("icons", "IconPack", "DownloadableIcons", "IconToDownload"), "SignalAnimation": ("signals", "SignalAnimation", "DownloadableSignals", "SignalToDownload"), "SoundPack": ("sounds", "SoundPack", "DownloadableSounds", "SoundToDownload"), "WheelIcon": ("", "WheelIcon", "DownloadableWheels", "WheelToDownload"), } class SubPanel(IntEnum): MAIN = 0 CUSTOM_THEMES = 1 def is_user_created_theme(theme_name: str) -> bool: """Check if a theme is user-created.""" return theme_name.endswith("-user_created") def normalize_theme_name(name: str) -> str: """Normalize a theme name for file matching.""" normalized = name.lower() normalized = re.sub(r'[()]', '-', normalized) normalized = re.sub(r'\s+', '-', normalized) normalized = re.sub(r'[^a-z0-9\-]', '', normalized) normalized = normalized.rstrip('-') return normalized def get_theme_display_name(param_key: str, params: Params) -> str: """Get the display name for a theme from its stored param value.""" value = params.get(param_key, encoding="utf-8") or "" if not value: return "Stock" base_name = value # Extract creator if present (after ~) creator = "" tilde_idx = base_name.find("~") if tilde_idx >= 0: creator = base_name[tilde_idx + 1:] base_name = base_name[:tilde_idx] # Split on - or _ and capitalize each part separator = "-" if "-" in base_name else "_" parts = [p for p in base_name.split(separator) if p] parts = [p.capitalize() for p in parts] # Format display name if "-" in base_name and len(parts) > 1: display_name = f"{parts[0]} ({' '.join(parts[1:])})" else: display_name = " ".join(parts) # Add user created indicator if is_user_created_theme(value): display_name = display_name.split(" (")[0] + " 🌟" # Add creator if creator: display_name += f" - by: {creator}" return display_name def store_theme_name(input_name: str, param_key: str, params: Params) -> str: """Store a theme name and return its display name.""" output = input_name.lower() output = output.replace("(", "").replace(")", "").replace("'", "").replace(".", "") # Use - for names with parentheses, _ otherwise if "(" in input_name: output = output.replace(" ", "-") else: output = output.replace(" ", "_") # Handle user created marker output = output.replace("_🌟", "-user_created").replace(" 🌟", "-user_created") output = output.strip() params.put(param_key, output) return get_theme_display_name(param_key, params) def get_theme_list(directory: Path, sub_folder: str, asset_param: str, params: Params, exclude_current: bool = True) -> list[str]: """Get list of available themes from a directory.""" use_files = not sub_folder current_asset = params.get(asset_param, encoding="utf-8") or "" if exclude_current else "" theme_list = [] if not directory.exists(): return theme_list for entry in directory.iterdir(): # Skip current asset if entry.stem == current_asset: continue # For files mode, skip directories if use_files and entry.is_dir(): continue # For sub-folder mode, check if sub-folder exists if not use_files: target_path = entry / sub_folder if not target_path.exists(): continue base_name = entry.stem user_created = is_user_created_theme(base_name) if user_created: base_name = base_name.replace("-user_created", "") # Extract creator creator = "" tilde_idx = base_name.find("~") if tilde_idx >= 0: creator = base_name[tilde_idx + 1:] base_name = base_name[:tilde_idx] # Split and capitalize separator = "-" if "-" in base_name else "_" parts = [p for p in base_name.split(separator) if p] parts = [p.capitalize() for p in parts] # Format display name if user_created: display_name = " ".join(parts) else: if len(parts) <= 1 or use_files: display_name = " ".join(parts) else: display_name = f"{parts[0]} ({' '.join(parts[1:])})" if user_created: display_name += " 🌟" if creator: display_name += f" - by: {creator}" theme_list.append(display_name) return sorted(theme_list) def update_asset_param(asset_param: str, params: Params, value: str, add: bool): """Update the downloadable asset list.""" assets_str = params.get(asset_param, encoding="utf-8") or "" assets = [a for a in assets_str.split(",") if a] if add: if value not in assets: assets.append(value) else: if value in assets: assets.remove(value) assets.sort() params.put(asset_param, ",".join(assets)) def download_theme_asset(input_name: str, download_key: str, downloadable_param: str, params: Params, params_memory: Params): """Initiate a theme asset download.""" output = input_name # Handle creator suffix tilde_idx = output.find("~") if tilde_idx >= 0: output = output[:tilde_idx].lower() + "~" + output[tilde_idx + 1:] else: output = output.lower() output = output.replace("(", "").replace(")", "") output = output.replace(" ", "-" if "(" in input_name else "_") params_memory.put(download_key, output) def delete_theme_asset(directory: Path, sub_folder: str, downloadable_param: str, theme_to_delete: str, params: Params): """Delete a theme asset.""" use_files = not sub_folder # Normalize the name for matching base_name = theme_to_delete.lower() base_name = re.sub(r'[()]', '-', base_name) base_name = base_name.replace(" ", "-") base_name = re.sub(r'[^a-z0-9\-]', '', base_name) base_name = base_name.rstrip('-') base_underscore = base_name.replace("-", "_") candidate_names = [ base_name, base_name + "-user-created", base_underscore, base_underscore + "-user_created", ] if use_files: # Delete file for file in directory.iterdir(): if not file.is_file(): continue normalized_file = file.stem.lower().replace("_", "-") normalized_file = re.sub(r'[^a-z0-9\-~]', '', normalized_file) if normalized_file in candidate_names: file.unlink() break else: # Delete directory for candidate in candidate_names: target_dir = directory / candidate / sub_folder if target_dir.exists(): shutil.rmtree(target_dir.parent, ignore_errors=True) break # Update downloadable list - add back to available downloads update_asset_param(downloadable_param, params, theme_to_delete, True) class FrogPilotThemePanel(Widget): def __init__(self): super().__init__() self._current_panel = SubPanel.MAIN self._params = Params() self._params_memory = Params("", True) self._toggles = {} self._tuning_level = 0 # State tracking self._cancelling_download = False self._finalizing_download = False self._online = False self._parked = True self._random_themes = False self._started = False self._theme_downloading = False # Pending dialog action tracking self._pending_action = None # "delete", "download", "select", "delete_confirm", "custom_top", "custom_bottom", "clear_startup" self._pending_asset_type = None # "ColorScheme", "DistanceIconPack", etc. self._pending_selection = None # Selected item from first dialog # Download state per asset type self._color_downloading = False self._distance_icon_downloading = False self._icon_downloading = False self._signal_downloading = False self._sound_downloading = False self._wheel_downloading = False # Downloaded state (no more available to download) self._colors_downloaded = False self._distance_icons_downloaded = False self._icons_downloaded = False self._signals_downloaded = False self._sounds_downloaded = False self._wheels_downloaded = False # Download status self._download_status = "Idle" # Keyboard for text input self._keyboard = Keyboard() self._build_main_panel() self._build_custom_themes_panel() ui_state.add_offroad_transition_callback(self._update_toggles) def _build_main_panel(self): self._custom_themes_control = FrogPilotManageControl( "CustomThemes", "Custom Themes", "The overall look and feel of openpilot. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "../../frogpilot/assets/toggle_icons/icon_frog.png", ) self._custom_themes_control.set_manage_callback(self._open_custom_themes_panel) self._holiday_themes_item = ListItem( title="Holiday Themes", description="Themes based on U.S. holidays. Minor holidays last one day; major holidays (Christmas, Easter, Halloween) run for a full week.", action_item=ToggleAction( initial_state=self._params.get_bool("HolidayThemes"), callback=lambda state: self._simple_toggle("HolidayThemes", state), ), ) self._rainbow_path_item = ListItem( title="Rainbow Path", description="Color the driving path like a Mario Kart-style \"Rainbow Road\".", action_item=ToggleAction( initial_state=self._params.get_bool("RainbowPath"), callback=lambda state: self._simple_toggle("RainbowPath", state), ), ) self._random_events_item = ListItem( title="Random Events", description="Occasional on-screen effects triggered by driving conditions. These are purely visual and don't impact how openpilot drives!", action_item=ToggleAction( initial_state=self._params.get_bool("RandomEvents"), callback=lambda state: self._simple_toggle("RandomEvents", state), ), ) self._random_themes_control = FrogPilotButtonToggleControl( "RandomThemes", "Random Themes", "Pick a random theme between each drive from the themes you have downloaded. Great for variety without changing settings while driving.", "../../frogpilot/assets/toggle_icons/icon_random_themes.png", button_params=["RandomThemesHolidays"], button_texts=["Include Holiday Themes"], ) self._random_themes_control.set_toggle_callback(self._on_random_themes_toggle) self._startup_alert_control = FrogPilotButtonsControl( "Startup Alert", "Customize the \"Startup Alert\" message shown at the start of each drive.", "../../frogpilot/assets/toggle_icons/icon_message.png", button_texts=["STOCK", "FROGPILOT", "CUSTOM", "CLEAR"], ) self._startup_alert_control.set_click_callback(self._on_startup_alert_click) self._update_startup_alert_buttons() main_items = [ self._custom_themes_control, self._holiday_themes_item, self._rainbow_path_item, self._random_events_item, self._random_themes_control, self._startup_alert_control, ] self._toggles["CustomThemes"] = self._custom_themes_control self._toggles["HolidayThemes"] = self._holiday_themes_item self._toggles["RainbowPath"] = self._rainbow_path_item self._toggles["RandomEvents"] = self._random_events_item self._toggles["RandomThemes"] = self._random_themes_control self._toggles["StartupAlert"] = self._startup_alert_control self._main_scroller = Scroller(main_items, line_separator=True, spacing=0) def _build_custom_themes_panel(self): # Color Scheme self._color_scheme_control = FrogPilotButtonsControl( "Color Scheme", "The color scheme used throughout openpilot. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._color_scheme_control.set_click_callback(self._on_color_scheme_click) self._color_scheme_control.set_value(get_theme_display_name("ColorScheme", self._params)) # Distance Icon Pack self._distance_icon_control = FrogPilotButtonsControl( "Distance Button", "The distance button icons shown on the driving screen. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._distance_icon_control.set_click_callback(self._on_distance_icon_click) self._distance_icon_control.set_value(get_theme_display_name("DistanceIconPack", self._params)) # Icon Pack self._icon_pack_control = FrogPilotButtonsControl( "Icon Pack", "The icon style used across openpilot. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._icon_pack_control.set_click_callback(self._on_icon_pack_click) self._icon_pack_control.set_value(get_theme_display_name("IconPack", self._params)) # Signal Animation self._signal_animation_control = FrogPilotButtonsControl( "Turn Signal", "Themed turn-signal animations. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._signal_animation_control.set_click_callback(self._on_signal_animation_click) self._signal_animation_control.set_value(get_theme_display_name("SignalAnimation", self._params)) # Sound Pack self._sound_pack_control = FrogPilotButtonsControl( "Sound Pack", "The sound pack used by openpilot. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._sound_pack_control.set_click_callback(self._on_sound_pack_click) self._sound_pack_control.set_value(get_theme_display_name("SoundPack", self._params)) # Wheel Icon self._wheel_icon_control = FrogPilotButtonsControl( "Steering Wheel", "The steering-wheel icon shown at the top-right of the driving screen. Use the \"Theme Maker\" in \"The Pond\" to create and share your own themes!", "", button_texts=["DELETE", "DOWNLOAD", "SELECT"], ) self._wheel_icon_control.set_click_callback(self._on_wheel_icon_click) self._wheel_icon_control.set_value(get_theme_display_name("WheelIcon", self._params)) # Download Status Label self._download_status_item = ListItem( title="Download Status", action_item=TextAction(lambda: self._download_status, color=ITEM_TEXT_VALUE_COLOR), ) custom_theme_items = [ self._color_scheme_control, self._distance_icon_control, self._icon_pack_control, self._signal_animation_control, self._sound_pack_control, self._wheel_icon_control, self._download_status_item, ] self._toggles["ColorScheme"] = self._color_scheme_control self._toggles["DistanceIconPack"] = self._distance_icon_control self._toggles["IconPack"] = self._icon_pack_control self._toggles["SignalAnimation"] = self._signal_animation_control self._toggles["SoundPack"] = self._sound_pack_control self._toggles["WheelIcon"] = self._wheel_icon_control self._toggles["DownloadStatusLabel"] = self._download_status_item self._custom_themes_scroller = Scroller(custom_theme_items, line_separator=True, spacing=0) def _simple_toggle(self, param: str, state: bool): self._params.put_bool(param, state) update_frogpilot_toggles() def _on_random_themes_toggle(self, state: bool): self._params.put_bool("RandomThemes", state) update_frogpilot_toggles() self._random_themes = state if state: gui_app.set_modal_overlay(alert_dialog( "\"Random Themes\" only works with downloaded themes, so make sure you download the themes you want it to use!" )) # Hide SELECT buttons and clear values self._color_scheme_control.set_value("") self._color_scheme_control.set_visible_button(2, False) self._distance_icon_control.set_value("") self._distance_icon_control.set_visible_button(2, False) self._icon_pack_control.set_value("") self._icon_pack_control.set_visible_button(2, False) self._signal_animation_control.set_value("") self._signal_animation_control.set_visible_button(2, False) self._sound_pack_control.set_value("") self._sound_pack_control.set_visible_button(2, False) self._wheel_icon_control.set_value("") self._wheel_icon_control.set_visible_button(2, False) else: # Show SELECT buttons and restore values self._color_scheme_control.set_value(get_theme_display_name("ColorScheme", self._params)) self._color_scheme_control.set_visible_button(2, True) self._distance_icon_control.set_value(get_theme_display_name("DistanceIconPack", self._params)) self._distance_icon_control.set_visible_button(2, True) self._icon_pack_control.set_value(get_theme_display_name("IconPack", self._params)) self._icon_pack_control.set_visible_button(2, True) self._signal_animation_control.set_value(get_theme_display_name("SignalAnimation", self._params)) self._signal_animation_control.set_visible_button(2, True) self._sound_pack_control.set_value(get_theme_display_name("SoundPack", self._params)) self._sound_pack_control.set_visible_button(2, True) self._wheel_icon_control.set_value(get_theme_display_name("WheelIcon", self._params)) self._wheel_icon_control.set_visible_button(2, True) def _update_startup_alert_buttons(self): """Update startup alert button states based on current values.""" current_top = self._params.get("StartupMessageTop", encoding="utf-8") or "" current_bottom = self._params.get("StartupMessageBottom", encoding="utf-8") or "" stock_top = "Be ready to take over at any time" stock_bottom = "Always keep hands on wheel and eyes on road" frogpilot_top = "Hop in and buckle up!" frogpilot_bottom = "Human-tested, frog-approved 🐸" if current_top == stock_top and current_bottom == stock_bottom: self._startup_alert_control.set_checked_button(0) elif current_top == frogpilot_top and current_bottom == frogpilot_bottom: self._startup_alert_control.set_checked_button(1) elif current_top or current_bottom: self._startup_alert_control.set_checked_button(2) def _on_startup_alert_click(self, button_id: int): stock_top = "Be ready to take over at any time" stock_bottom = "Always keep hands on wheel and eyes on road" frogpilot_top = "Hop in and buckle up!" frogpilot_bottom = "Human-tested, frog-approved 🐸" if button_id == 0: # Stock self._params.put("StartupMessageTop", stock_top) self._params.put("StartupMessageBottom", stock_bottom) elif button_id == 1: # FrogPilot self._params.put("StartupMessageTop", frogpilot_top) self._params.put("StartupMessageBottom", frogpilot_bottom) elif button_id == 2: # Custom - show input dialog for top message self._pending_action = "custom_top" current_top = self._params.get("StartupMessageTop", encoding="utf-8") or "" self._keyboard.reset() self._keyboard.set_title("Enter the text for the top half") self._keyboard.set_text(current_top) gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result) elif button_id == 3: # Clear - show confirmation self._pending_action = "clear_startup" gui_app.set_modal_overlay(ConfirmDialog( "Are you sure you want to completely reset your startup message?", "Yes", "No", )) def _get_control_for_asset(self, asset_type: str) -> FrogPilotButtonsControl: """Get the control widget for an asset type.""" controls = { "ColorScheme": self._color_scheme_control, "DistanceIconPack": self._distance_icon_control, "IconPack": self._icon_pack_control, "SignalAnimation": self._signal_animation_control, "SoundPack": self._sound_pack_control, "WheelIcon": self._wheel_icon_control, } return controls.get(asset_type) def _get_downloading_attr(self, asset_type: str) -> str: """Get the downloading attribute name for an asset type.""" attrs = { "ColorScheme": "_color_downloading", "DistanceIconPack": "_distance_icon_downloading", "IconPack": "_icon_downloading", "SignalAnimation": "_signal_downloading", "SoundPack": "_sound_downloading", "WheelIcon": "_wheel_downloading", } return attrs.get(asset_type) def _get_downloaded_attr(self, asset_type: str) -> str: """Get the downloaded attribute name for an asset type.""" attrs = { "ColorScheme": "_colors_downloaded", "DistanceIconPack": "_distance_icons_downloaded", "IconPack": "_icons_downloaded", "SignalAnimation": "_signals_downloaded", "SoundPack": "_sounds_downloaded", "WheelIcon": "_wheels_downloaded", } return attrs.get(asset_type) def _handle_asset_click(self, button_id: int, asset_type: str): """Generic handler for asset button clicks (DELETE, DOWNLOAD, SELECT).""" config = ASSET_CONFIGS[asset_type] sub_folder, param_key, downloadable_param, download_key = config directory = WHEELS_DIR if asset_type == "WheelIcon" else THEME_PACKS_DIR downloading_attr = self._get_downloading_attr(asset_type) if button_id == 0: # DELETE - show selection dialog theme_list = get_theme_list(directory, sub_folder, param_key, self._params) if not theme_list: gui_app.set_modal_overlay(alert_dialog(f"No {asset_type.lower()} available to delete.")) return self._pending_action = "delete" self._pending_asset_type = asset_type gui_app.set_modal_overlay(MultiOptionDialog( f"Select a {asset_type.lower()} to delete", theme_list, )) elif button_id == 1: # DOWNLOAD or CANCEL if getattr(self, downloading_attr): # Cancel download self._cancelling_download = True self._params_memory.put_bool("CancelThemeDownload", True) def reset_cancel(): self._cancelling_download = False setattr(self, downloading_attr, False) self._theme_downloading = False self._params_memory.put_bool("CancelThemeDownload", False) threading.Timer(2.5, reset_cancel).start() else: # Start download - show selection dialog downloadable_str = self._params.get(downloadable_param, encoding="utf-8") or "" downloadable = [d for d in downloadable_str.split(",") if d] if not downloadable: gui_app.set_modal_overlay(alert_dialog(f"All {asset_type.lower()}s are already downloaded.")) return self._pending_action = "download" self._pending_asset_type = asset_type gui_app.set_modal_overlay(MultiOptionDialog( f"Select a {asset_type.lower()} to download", downloadable, )) elif button_id == 2: # SELECT - show selection dialog theme_list = get_theme_list(directory, sub_folder, param_key, self._params, exclude_current=False) # Add default options if asset_type == "SignalAnimation": theme_list.append("None") elif asset_type == "WheelIcon": theme_list.append("None") theme_list.append("Stock") else: theme_list.append("Stock") theme_list.extend(HOLIDAY_THEMES) theme_list.sort() current = get_theme_display_name(param_key, self._params) self._pending_action = "select" self._pending_asset_type = asset_type gui_app.set_modal_overlay(MultiOptionDialog( f"Select a {asset_type.lower()}", theme_list, current, )) def _on_color_scheme_click(self, button_id: int): self._handle_asset_click(button_id, "ColorScheme") def _on_distance_icon_click(self, button_id: int): self._handle_asset_click(button_id, "DistanceIconPack") def _on_icon_pack_click(self, button_id: int): self._handle_asset_click(button_id, "IconPack") def _on_signal_animation_click(self, button_id: int): self._handle_asset_click(button_id, "SignalAnimation") def _on_sound_pack_click(self, button_id: int): self._handle_asset_click(button_id, "SoundPack") def _on_wheel_icon_click(self, button_id: int): self._handle_asset_click(button_id, "WheelIcon") def _on_keyboard_result(self, result: DialogResult): """Callback for keyboard modal overlay.""" self.handle_dialog_result(result, self._keyboard.text) def handle_dialog_result(self, result: DialogResult, selection: str = ""): """Handle dialog results for all pending actions.""" action = self._pending_action asset_type = self._pending_asset_type self._pending_action = None if action == "delete": # First dialog - theme selection for delete if result != DialogResult.CONFIRM or not selection: self._pending_asset_type = None return # Show confirmation dialog self._pending_action = "delete_confirm" self._pending_selection = selection gui_app.set_modal_overlay(ConfirmDialog( f'Delete the "{selection}" {asset_type.lower()}?', "Delete", "Cancel", )) elif action == "delete_confirm": # Confirmation for delete if result != DialogResult.CONFIRM: self._pending_asset_type = None self._pending_selection = None return selection = self._pending_selection self._pending_selection = None config = ASSET_CONFIGS[asset_type] sub_folder, param_key, downloadable_param, download_key = config directory = WHEELS_DIR if asset_type == "WheelIcon" else THEME_PACKS_DIR # Mark as not all downloaded anymore downloaded_attr = self._get_downloaded_attr(asset_type) setattr(self, downloaded_attr, False) # Delete the asset delete_theme_asset(directory, sub_folder, downloadable_param, selection, self._params) self._pending_asset_type = None elif action == "download": # Theme selection for download if result != DialogResult.CONFIRM or not selection: self._pending_asset_type = None return config = ASSET_CONFIGS[asset_type] sub_folder, param_key, downloadable_param, download_key = config # Set downloading flags downloading_attr = self._get_downloading_attr(asset_type) setattr(self, downloading_attr, True) self._theme_downloading = True self._params_memory.put("ThemeDownloadProgress", "Downloading...") self._download_status = "Downloading..." # Initiate download download_theme_asset(selection, download_key, downloadable_param, self._params, self._params_memory) self._pending_asset_type = None elif action == "select": # Theme selection if result != DialogResult.CONFIRM or not selection: self._pending_asset_type = None return config = ASSET_CONFIGS[asset_type] sub_folder, param_key, downloadable_param, download_key = config # Store the theme and update display control = self._get_control_for_asset(asset_type) display_name = store_theme_name(selection, param_key, self._params) control.set_value(display_name) self._pending_asset_type = None elif action == "custom_top": # Custom startup message - top line if result != DialogResult.CONFIRM or not selection: return self._params.put("StartupMessageTop", selection.strip()) # Now show dialog for bottom line self._pending_action = "custom_bottom" current_bottom = self._params.get("StartupMessageBottom", encoding="utf-8") or "" self._keyboard.reset() self._keyboard.set_title("Enter the text for the bottom half") self._keyboard.set_text(current_bottom) gui_app.set_modal_overlay(self._keyboard, callback=self._on_keyboard_result) elif action == "custom_bottom": # Custom startup message - bottom line if result == DialogResult.CONFIRM and selection: self._params.put("StartupMessageBottom", selection.strip()) self._update_startup_alert_buttons() elif action == "clear_startup": # Clear startup message confirmation if result == DialogResult.CONFIRM: self._params.remove("StartupMessageTop") self._params.remove("StartupMessageBottom") self._startup_alert_control.clear_checked_buttons() def _translate_progress(self, progress: str) -> str: """Translate download progress messages.""" translations = { "Download cancelled...": "Download cancelled...", "Download failed...": "Download failed...", "Downloaded!": "Downloaded!", "Downloading...": "Downloading...", "GitHub and GitLab are offline...": "GitHub and GitLab are offline...", "Repository unavailable": "Repository unavailable", "Unpacking theme...": "Unpacking theme...", "Verifying authenticity...": "Verifying authenticity...", } if progress in translations: return translations[progress] if progress.endswith("%"): return progress return "Idle" def _update_download_state(self): """Update UI based on download progress.""" if self._finalizing_download: return if not self._theme_downloading: return progress = self._params_memory.get("ThemeDownloadProgress", encoding="utf-8") or "" download_failed = bool(re.search(r"cancelled|exists|failed|offline", progress, re.IGNORECASE)) if progress and progress != "Downloading...": self._download_status = self._translate_progress(progress) if progress == "Downloaded!" or download_failed: self._finalizing_download = True def finalize(): self._color_downloading = False self._distance_icon_downloading = False self._finalizing_download = False self._icon_downloading = False self._signal_downloading = False self._sound_downloading = False self._theme_downloading = False self._wheel_downloading = False # Update downloaded states self._colors_downloaded = not self._params.get("DownloadableColors", encoding="utf-8") self._distance_icons_downloaded = not self._params.get("DownloadableDistanceIcons", encoding="utf-8") self._icons_downloaded = not self._params.get("DownloadableIcons", encoding="utf-8") self._signals_downloaded = not self._params.get("DownloadableSignals", encoding="utf-8") self._sounds_downloaded = not self._params.get("DownloadableSounds", encoding="utf-8") self._wheels_downloaded = not self._params.get("DownloadableWheels", encoding="utf-8") self._params_memory.remove("CancelThemeDownload") self._params_memory.remove("ThemeDownloadProgress") self._download_status = "Idle" threading.Timer(2.5, finalize).start() def _update_button_states(self): """Update button enabled/visible states.""" # Helper for updating each asset control def update_asset_buttons(control, downloading, downloaded): control.set_text(1, "CANCEL" if downloading else "DOWNLOAD") control.set_enabled_buttons(0, not self._theme_downloading) can_download = (self._online and (not self._theme_downloading or downloading) and not self._cancelling_download and not self._finalizing_download and not downloaded and self._parked) control.set_enabled_buttons(1, can_download) control.set_enabled_buttons(2, not self._theme_downloading) update_asset_buttons(self._color_scheme_control, self._color_downloading, self._colors_downloaded) update_asset_buttons(self._distance_icon_control, self._distance_icon_downloading, self._distance_icons_downloaded) update_asset_buttons(self._icon_pack_control, self._icon_downloading, self._icons_downloaded) update_asset_buttons(self._signal_animation_control, self._signal_downloading, self._signals_downloaded) update_asset_buttons(self._sound_pack_control, self._sound_downloading, self._sounds_downloaded) update_asset_buttons(self._wheel_icon_control, self._wheel_downloading, self._wheels_downloaded) def _open_custom_themes_panel(self): self._current_panel = SubPanel.CUSTOM_THEMES def _close_sub_panel(self): self._current_panel = SubPanel.MAIN def _update_toggles(self): self._tuning_level = self._params.get_int("TuningLevel") or 0 # DistanceIconPack only visible if QOLVisuals AND OnroadDistanceButton qol_visuals = self._params.get_bool("QOLVisuals") onroad_distance_button = self._params.get_bool("OnroadDistanceButton") if hasattr(self._distance_icon_control, "set_visible"): self._distance_icon_control.set_visible(qol_visuals and onroad_distance_button) # RandomThemes only visible if CustomThemes enabled custom_themes = self._params.get_bool("CustomThemes") if hasattr(self._random_themes_control, "set_visible"): self._random_themes_control.set_visible(custom_themes) def _load_downloaded_states(self): """Load initial downloaded states.""" self._colors_downloaded = not self._params.get("DownloadableColors", encoding="utf-8") self._distance_icons_downloaded = not self._params.get("DownloadableDistanceIcons", encoding="utf-8") self._icons_downloaded = not self._params.get("DownloadableIcons", encoding="utf-8") self._signals_downloaded = not self._params.get("DownloadableSignals", encoding="utf-8") self._sounds_downloaded = not self._params.get("DownloadableSounds", encoding="utf-8") self._wheels_downloaded = not self._params.get("DownloadableWheels", encoding="utf-8") self._random_themes = self._params.get_bool("RandomThemes") if self._random_themes: # Hide SELECT buttons and clear values self._color_scheme_control.set_value("") self._color_scheme_control.set_visible_button(2, False) self._distance_icon_control.set_value("") self._distance_icon_control.set_visible_button(2, False) self._icon_pack_control.set_value("") self._icon_pack_control.set_visible_button(2, False) self._signal_animation_control.set_value("") self._signal_animation_control.set_visible_button(2, False) self._sound_pack_control.set_value("") self._sound_pack_control.set_visible_button(2, False) self._wheel_icon_control.set_value("") self._wheel_icon_control.set_visible_button(2, False) def show_event(self): super().show_event() self._main_scroller.show_event() self._load_downloaded_states() self._update_toggles() self._update_startup_alert_buttons() self._started = ui_state.started def hide_event(self): super().hide_event() self._current_panel = SubPanel.MAIN def _render(self, rect): self._started = ui_state.started self._parked = not self._started # Update download state self._update_download_state() self._update_button_states() if self._current_panel == SubPanel.CUSTOM_THEMES: self._custom_themes_scroller.render(rect) else: self._main_scroller.render(rect)