diff --git a/plugin_manager.py b/plugin_manager.py index fce6017a..233732f8 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -1,9 +1,21 @@ # ba_meta require api 9 + +from __future__ import annotations + import babase import bauiv1 as bui from bauiv1lib import popup, confirm from babase._meta import EXPORT_CLASS_NAME_SHORTCUTS from bauiv1lib.settings.allsettings import AllSettingsWindow +from bacommon.restapi.v1 import Endpoint, ErrorResponse +from bacommon.restapi.v1.accounts import AccountResponse +from bacommon.restapi.v1.workspaces import ( + WorkspacesResponse, + WorkspaceResponse, + WorkspaceFilesResponse, + WorkspaceEntryType, +) +from efro.error import CommunicationError import urllib.request import http.client @@ -44,6 +56,46 @@ } PLUGIN_DIRECTORY = _env["python_directory_user"] + +class UIConfig: + """UI-related configuration and scaling constants.""" + + # Color definitions + CLASS_COLORS = { + 'text_primary': (1, 1, 1), + 'text_secondary': (0.75, 0.7, 0.8), + 'text_muted': (0.5, 0.5, 0.5), + 'button_red': (0.8, 0.15, 0.35), + 'button_green': (0.2, 0.8, 0.3), + 'button_neutral': (0.6, 0.53, 0.63), + 'discord_bg': (0.525, 0.595, 1.458), + 'discord_fg': (10 - 0.32, 10 - 0.39, 10 - 0.96), + 'github_bg': (0.23, 0.23, 0.23), + 'installed_enabled': (0, 0.95, 0.2), + 'installed_update': (1, 0.6, 0), + 'installed_not_managed': (0.8, 0.2, 0.2), + 'not_installed': (0.5, 0.5, 0.5), + 'success_green': (0, 1, 0), + 'error_red': (1, 0, 0), + } + + # UI scaling helpers + @staticmethod + def scale_value(small: float, medium: float, large: float) -> float: + """Return value based on current UI scale.""" + u = _uiscale() + if u is babase.UIScale.SMALL: + return small + elif u is babase.UIScale.MEDIUM: + return medium + return large + + @staticmethod + def get_color(color_name: str) -> tuple: + """Get a color from the configuration.""" + return UIConfig.CLASS_COLORS.get(color_name, UIConfig.CLASS_COLORS['text_primary']) + + # compatibility for older API versions. if _env.get("build_number", 0) < 22714: babase._asyncio._g_asyncio_event_loop = babase._asyncio._asyncio_event_loop @@ -64,17 +116,12 @@ def _remove_popup(popup): pass -def _uiscale(): return bui.app.ui_v1.uiscale -def _regexp_friendly_class_name_shortcut(string): return string.replace(".", "\\.") +def _uiscale(): + return bui.app.ui_v1.uiscale -def _by_scale(a, b, c): - u = _uiscale() - return ( - a if u is babase.UIScale.SMALL else - b if u is babase.UIScale.MEDIUM else - c - ) +def _regexp_friendly_class_name_shortcut(string): + return string.replace(".", "\\.") REGEXP = { @@ -84,7 +131,7 @@ def _by_scale(a, b, c): "(ba_meta export (plugin|{})\n+class )(.*)\\(".format( _regexp_friendly_class_name_shortcut(EXPORT_CLASS_NAME_SHORTCUTS["plugin"]), ), - "utf-8" + "utf-8", ), ), "minigames": re.compile( @@ -92,7 +139,7 @@ def _by_scale(a, b, c): "(ba_meta export ({})\n+class )(.*)\\(".format( _regexp_friendly_class_name_shortcut("bascenev1.GameActivity"), ), - "utf-8" + "utf-8", ), ), } @@ -103,39 +150,83 @@ def _by_scale(a, b, c): class MD5CheckSumFailed(Exception): + """Raised when MD5 checksum verification fails during file operations.""" + pass class PluginNotInstalled(Exception): + """Raised when attempting to access a plugin that is not installed.""" + pass class CategoryDoesNotExist(Exception): + """Raised when accessing a plugin category that doesn't exist.""" + pass class NoCompatibleVersion(Exception): + """Raised when no compatible version exists for the current API version.""" + pass class PluginSourceNetworkError(Exception): + """Raised when there's a network error accessing a plugin source.""" + pass class CategoryMetadataParseError(Exception): + """Raised when plugin category metadata fails to parse.""" + pass -def send_network_request(request): +def send_network_request(request: urllib.request.Request) -> urllib.response.addinfourl: + """Send a network request and return the response. + + Args: + request: urllib Request object + + Returns: + Response object from urllib + """ return urllib.request.urlopen(request) -async def async_send_network_request(request): +async def async_send_network_request(request: urllib.request.Request) -> urllib.response.addinfourl: + """Asynchronously send a network request. + + Args: + request: urllib Request object + + Returns: + Response object from urllib + """ response = await loop.run_in_executor(None, send_network_request, request) return response -def stream_network_response_to_file(request, file, md5sum=None, retries=3): +def stream_network_response_to_file( + request: urllib.request.Request, file: str, md5sum: str | None = None, retries: int = 3 +) -> bytes: + """Download a file from a network request with optional MD5 verification. + + Args: + request: urllib Request object + file: File path to save to + md5sum: Optional MD5 checksum to verify against + retries: Number of retries on checksum failure + + Returns: + Downloaded file content as bytes + + Raises: + MD5CheckSumFailed: If MD5 checksum verification fails + """ response = urllib.request.urlopen(request) chunk_size = 16 * 1024 content = b"" @@ -146,6 +237,7 @@ def stream_network_response_to_file(request, file, md5sum=None, retries=3): break fout.write(chunk) content += chunk + if md5sum and hashlib.md5(content).hexdigest() != md5sum: if retries <= 0: raise MD5CheckSumFailed("MD5 checksum match failed.") @@ -153,13 +245,25 @@ def stream_network_response_to_file(request, file, md5sum=None, retries=3): request, file, md5sum=md5sum, - retries=retries-1, + retries=retries - 1, ) return content -async def async_stream_network_response_to_file(request, file, md5sum=None, retries=3): +async def async_stream_network_response_to_file( + request: urllib.request.Request, file: str, md5sum: str | None = None, retries: int = 3 +) -> bytes: + """Asynchronously download a file with optional MD5 verification. + + Args: + request: urllib Request object + file: File path to save to + md5sum: Optional MD5 checksum to verify against + retries: Number of retries on checksum failure + Returns: + Downloaded file content as bytes + """ content = await loop.run_in_executor( None, stream_network_response_to_file, @@ -171,7 +275,16 @@ async def async_stream_network_response_to_file(request, file, md5sum=None, retr return content -def partial_format(string_template, **kwargs): +def partial_format(string_template: str, **kwargs: str) -> str: + """Partially format a string template, leaving unmatched placeholders. + + Args: + string_template: Template string with placeholders in {key} format + **kwargs: Key-value pairs for replacement + + Returns: + Partially formatted string + """ for key, value in kwargs.items(): string_template = string_template.replace("{" + key + "}", value) return string_template @@ -274,14 +387,93 @@ def https_open(self, req): return self.do_open(DNSBlockWorkaround._HTTPSConnection, req) +class UIHelpers: + """Helper functions for creating common UI patterns.""" + + @staticmethod + def create_icon_button( + parent, position, size, texture, on_activate, icon_color=None, btn_color=None + ): + # Build buttonwidget kwargs + button_kwargs = { + 'parent': parent, + 'position': position, + 'size': size, + 'button_type': 'square', + 'label': '', + 'on_activate_call': on_activate, + } + if btn_color is not None: + button_kwargs['color'] = btn_color + + button = bui.buttonwidget(**button_kwargs) + + # Build imagewidget kwargs + image_kwargs = { + 'parent': parent, + 'position': position, + 'size': size, + 'texture': bui.gettexture(texture), + 'draw_controller': button, + } + if icon_color is not None: + image_kwargs['color'] = icon_color + + bui.imagewidget(**image_kwargs) + return button + + @staticmethod + def create_icon_button_with_text( + parent, + position, + size, + texture, + on_activate, + text, + text_position=None, + text_color=None, + icon_color=None, + btn_color=None, + text_scale=0.45, + text_rotate=25, + ): + """Create an icon button with optional text overlay.""" + button = UIHelpers.create_icon_button( + parent, position, size, texture, on_activate, icon_color, btn_color + ) + + # Add text overlay + if text_position is None: + # Default text position (rotated at bottom-left) + text_position = (position[0] - 3, position[1] + 12) + + if text_color is None: + text_color = (1, 1, 1) + + bui.textwidget( + parent=parent, + position=text_position, + text=text, + size=(10, 10), + draw_controller=button, + color=text_color, + rotate=text_rotate, + scale=text_scale, + ) + return button + + class StartupTasks: + """Handles plugin manager initialization and startup tasks.""" + def __init__(self): self.plugin_manager = PluginManager() def setup_config(self): # is_config_updated = False existing_plugin_manager_config = copy.deepcopy( - babase.app.config.get("Community Plugin Manager")) + babase.app.config.get("Community Plugin Manager") + ) plugin_manager_config = babase.app.config.setdefault("Community Plugin Manager", {}) plugin_manager_config.setdefault("Custom Sources", []) @@ -296,7 +488,7 @@ def setup_config(self): "Auto Update Plugin Manager": True, "Auto Update Plugins": True, "Auto Enable Plugins After Installation": True, - "Notify New Plugins": True + "Notify New Plugins": True, } settings = plugin_manager_config.setdefault("Settings", {}) @@ -310,7 +502,12 @@ def setup_config(self): babase.app.config.commit() async def update_plugin_manager(self): - if not babase.app.config["Community Plugin Manager"]["Settings"]["Auto Update Plugin Manager"]: + # add logic here for updating plugman in workspaces + await self.plugin_manager.get_current_workspaces() + + if not babase.app.config["Community Plugin Manager"]["Settings"][ + "Auto Update Plugin Manager" + ]: return update_details = await self.plugin_manager.get_update_details() if update_details: @@ -321,8 +518,9 @@ async def update_plugin_manager(self): except MD5CheckSumFailed: bui.getsound('error').play() else: - bui.screenmessage("Update successful. Restart game to reload changes.", - color=(0, 1, 0)) + bui.screenmessage( + "Update successful. Restart game to reload changes.", color=(0, 1, 0) + ) bui.getsound('shieldUp').play() async def update_plugins(self): @@ -332,7 +530,11 @@ async def update_plugins(self): all_plugins = await self.plugin_manager.categories["All"].get_plugins() plugins_to_update = [] for plugin in all_plugins: - if plugin.is_installed and await plugin.get_local().is_enabled() and plugin.has_update(): + if ( + plugin.is_installed + and await plugin.get_local().is_enabled() + and plugin.has_update() + ): plugins_to_update.append(plugin.update()) await asyncio.gather(*plugins_to_update) @@ -355,15 +557,20 @@ async def notify_new_plugins(self): await self.plugin_manager.setup_index() new_num_of_plugins = len(await self.plugin_manager.categories["All"].get_plugins()) try: - existing_num_of_plugins = babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"] + existing_num_of_plugins = babase.app.config["Community Plugin Manager"][ + "Existing Number of Plugins" + ] except KeyError: - babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"] = new_num_of_plugins + babase.app.config["Community Plugin Manager"][ + "Existing Number of Plugins" + ] = new_num_of_plugins babase.app.config.commit() return def title_it(plug): plug = str(plug).replace('_', ' ').title() return plug + if existing_num_of_plugins < new_num_of_plugins: new_plugin_count = new_num_of_plugins - existing_num_of_plugins all_plugins = await self.plugin_manager.categories["All"].get_plugins() @@ -375,20 +582,34 @@ def title_it(plug): new_supported_plugins = new_supported_plugins[:new_plugin_count] new_supported_plugins_count = len(new_supported_plugins) if new_supported_plugins_count > 0: - new_supported_plugins = ", ".join(map(title_it, (new_supported_plugins - if new_supported_plugins_count <= show_max_names else - new_supported_plugins[0:show_max_names]) - )) + new_supported_plugins = ", ".join( + map( + title_it, + ( + new_supported_plugins + if new_supported_plugins_count <= show_max_names + else new_supported_plugins[0:show_max_names] + ), + ) + ) if new_supported_plugins_count == 1: notification_text = f"{new_supported_plugins_count} new plugin ({new_supported_plugins}) is available!" else: - notification_text = new_supported_plugins + \ - ('' if new_supported_plugins_count <= show_max_names else ' and +' + - str(new_supported_plugins_count-show_max_names)) + " new plugins are available" + notification_text = ( + new_supported_plugins + + ( + '' + if new_supported_plugins_count <= show_max_names + else ' and +' + str(new_supported_plugins_count - show_max_names) + ) + + " new plugins are available" + ) bui.screenmessage(notification_text, color=(0, 1, 0)) if existing_num_of_plugins != new_num_of_plugins: - babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"] = new_num_of_plugins + babase.app.config["Community Plugin Manager"][ + "Existing Number of Plugins" + ] = new_num_of_plugins babase.app.config.commit() async def execute(self): @@ -403,7 +624,370 @@ async def execute(self): pass +class APIKeyError(Exception): + """Raised when API key is invalid or expired.""" + + pass + + +class BallisticaAPI: + """API client for Ballistica workspace management.""" + + def __init__(self, loop, url=URL): + self.loop = loop + self.url = url + self._api_key = None + + async def get_api_key(self, max_retries=3, retry_delay=2.0, force_refresh=False) -> str: + """Get a transient API key with caching and automatic retry. + + Caches the key for reuse. If an API call fails with an auth error. + """ + # Return cached key if available + if self._api_key is not None and not force_refresh: + return self._api_key + + for attempt in range(max_retries): + try: + loop_instance = asyncio.get_event_loop() + future = loop_instance.create_future() + + def on_response(api_key): + future.set_result(api_key) + + babase.app.plus.accounts.primary.request_transient_api_key(on_response) + api_key = await asyncio.wait_for(future, timeout=10.0) + + # Validate we got a real key, not an error + if isinstance(api_key, str) and api_key.startswith('bsac-'): + # Cache the key for reuse + self._api_key = api_key + return api_key + elif isinstance(api_key, CommunicationError): + print(f'Communication error while getting API key: {api_key}') + raise api_key + else: + raise ValueError(f'Invalid API key: {api_key}') + + except Exception as e: + if attempt < max_retries - 1: + await asyncio.sleep(retry_delay) + else: + print(f'Failed to get API key: {e}') + raise + + def _make_http_request( + self, + api_key: str, + endpoint: str, + method: str = 'GET', + body: bytes | None = None, + ) -> tuple[str, int]: + """Make an HTTP request to the API and return the response text and status code. + + Args: + api_key: Bearer token for authentication + endpoint: API endpoint path + method: HTTP method (GET, POST, PUT, PATCH, DELETE) + body: Request body as bytes (for POST, PUT, PATCH) + + Returns: + Tuple of (response_text, status_code) + + Raises: + APIKeyError: When API key is invalid/expired + Exception: For general HTTP errors + """ + headers = {'Authorization': f'Bearer {api_key}'} + if body is not None: + headers['Content-Type'] = 'application/json' + + try: + request = urllib.request.Request( + self.url + endpoint, data=body, headers=headers, method=method + ) + response = urllib.request.urlopen(request) + response_text = response.read().decode('utf-8') + status_code = response.getcode() + + # Check if response contains an error (API returns 200 with error in JSON) + if response_text: + try: + response_json = json.loads(response_text) + if isinstance(response_json, dict) and 'error' in response_json: + error_msg = response_json.get('message', response_json['error']) + if 'invalid' in error_msg.lower() or 'unauthorized' in error_msg.lower(): + raise APIKeyError(f'Invalid API key: {error_msg}') + raise Exception(f'API Error: {error_msg}') + except json.JSONDecodeError: + # Not JSON, continue + pass + + return response_text, status_code + except urllib.error.HTTPError as e: + # 401/403 indicates API key is invalid/expired + if e.code in (401, 403): + raise APIKeyError(f'Invalid API key (HTTP {e.code}): {e.reason}') + # For non-200 responses, try to parse error response + try: + error_body = e.read().decode('utf-8') + error_json = json.loads(error_body) + error_msg = error_json.get('message', error_json.get('error', e.reason)) + except Exception: + error_msg = e.reason + raise Exception(f'HTTP Error {e.code}: {error_msg}') + + async def _make_request( + self, endpoint: str, method: str = 'GET', body: bytes | None = None + ) -> tuple[str, int]: + """Make an HTTP request with automatic key refresh on auth error. + + Uses the cached API key and automatically refreshes it if it becomes invalid. + + Args: + endpoint: The API endpoint to call + method: HTTP method (GET, POST, PUT, PATCH, DELETE) + body: Request body as bytes (for POST, PUT, PATCH) + + Returns: + Tuple of (response_text, status_code) + + Raises: + APIKeyError: If key refresh fails + Exception: For other API errors + """ + api_key = await self.get_api_key() + + try: + return await self.loop.run_in_executor( + None, self._make_http_request, api_key, endpoint, method, body + ) + except APIKeyError: + # Key is invalid, refresh it + api_key = await self.get_api_key(force_refresh=True) + # Retry with fresh key + return await self.loop.run_in_executor( + None, self._make_http_request, api_key, endpoint, method, body + ) + + async def get_account(self, account_id: str) -> AccountResponse: + """Get account info. Pass 'me' for the authenticated account.""" + endpoint = Endpoint.ACCOUNT.format(account_id=account_id) + response_text, _ = await self._make_request(endpoint) + return dataclass_from_json(AccountResponse, response_text) + + async def get_account_by_tag(self, tag: str) -> AccountResponse: + """Look up an account by its tag (display name). + + Args: + tag: The account's display name (tag) + + Returns: + AccountResponse with the account details + """ + endpoint = Endpoint.ACCOUNT_BY_TAG.format(tag=tag) + response_text, _ = await self._make_request(endpoint) + return dataclass_from_json(AccountResponse, response_text) + + async def get_workspaces(self) -> WorkspacesResponse: + """List all workspaces for the authenticated account.""" + response_text, _ = await self._make_request(Endpoint.WORKSPACES) + return dataclass_from_json(WorkspacesResponse, response_text) + + async def get_workspace(self, workspace_id: str) -> WorkspaceResponse: + """Fetch metadata for a single workspace.""" + endpoint = Endpoint.WORKSPACE.format(workspace_id=workspace_id) + response_text, _ = await self._make_request(endpoint) + return dataclass_from_json(WorkspaceResponse, response_text) + + async def get_workspace_files(self, workspace_id: str) -> WorkspaceFilesResponse: + """Get flat listing of all files and directories in the workspace.""" + endpoint = Endpoint.WORKSPACE_FILES.format(workspace_id=workspace_id) + response_text, _ = await self._make_request(endpoint) + return dataclass_from_json(WorkspaceFilesResponse, response_text) + + async def get_active_workspace(self) -> WorkspaceResponse | None: + """Fetch the active workspace for the authenticated account. + + Returns: + WorkspaceResponse if a workspace is active, None otherwise. + """ + response_text, _ = await self._make_request(Endpoint.WORKSPACES_ACTIVE) + if response_text.strip() == 'null': + return None + return dataclass_from_json(WorkspaceResponse, response_text) + + async def create_workspace(self, name: str) -> WorkspaceResponse: + """Create a new workspace. + + Args: + name: User-assigned workspace name + + Returns: + WorkspaceResponse with HTTP 201 status + """ + body = json.dumps({'name': name}).encode('utf-8') + response_text, _ = await self._make_request(Endpoint.WORKSPACES, method='POST', body=body) + return dataclass_from_json(WorkspaceResponse, response_text) + + async def rename_workspace(self, workspace_id: str, name: str) -> WorkspaceResponse: + """Rename an existing workspace. + + Args: + workspace_id: ID of the workspace to rename + name: New workspace name + + Returns: + Updated WorkspaceResponse + """ + endpoint = Endpoint.WORKSPACE.format(workspace_id=workspace_id) + body = json.dumps({'name': name}).encode('utf-8') + response_text, _ = await self._make_request(endpoint, method='PATCH', body=body) + return dataclass_from_json(WorkspaceResponse, response_text) + + async def delete_workspace(self, workspace_id: str) -> None: + """Delete a workspace (creates a 30-day backup internally). + + Args: + workspace_id: ID of the workspace to delete + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE.format(workspace_id=workspace_id) + response_text, status_code = await self._make_request(endpoint, method='DELETE') + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + async def set_active_workspace(self, workspace_id: str | None) -> WorkspaceResponse | None: + """Set the active workspace for the authenticated account. + + Args: + workspace_id: ID of the workspace to activate, or None to disable syncing + + Returns: + Updated WorkspaceResponse if workspace is set, None if syncing is disabled. + """ + body = json.dumps({'workspace_id': workspace_id}).encode('utf-8') + response_text, _ = await self._make_request( + Endpoint.WORKSPACES_ACTIVE, method='POST', body=body + ) + if response_text.strip() == 'null': + return None + return dataclass_from_json(WorkspaceResponse, response_text) + + async def download_file(self, workspace_id: str, file_path: str) -> bytes: + """Download a file from a workspace. + + Args: + workspace_id: ID of the workspace + file_path: Path to the file relative to workspace root + + Returns: + Raw file bytes + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=file_path) + api_key = await self.get_api_key() + try: + headers = {'Authorization': f'Bearer {api_key}'} + request = urllib.request.Request(self.url + endpoint, headers=headers) + response = urllib.request.urlopen(request) + return response.read() + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise APIKeyError(f'Invalid API key (HTTP {e.code}): {e.reason}') + raise Exception(f'HTTP Error {e.code}: {e.reason}') + + async def upload_file(self, workspace_id: str, file_path: str, content: bytes) -> None: + """Upload or replace a file in a workspace. + + Parent directories are created automatically. + + Args: + workspace_id: ID of the workspace + file_path: Path to the file relative to workspace root + content: File contents as bytes + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=file_path) + response_text, status_code = await self._make_request(endpoint, method='PUT', body=content) + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + async def delete_file(self, workspace_id: str, file_path: str) -> None: + """Delete a file or directory from a workspace. + + Args: + workspace_id: ID of the workspace + file_path: Path to the file/directory relative to workspace root + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=file_path) + response_text, status_code = await self._make_request(endpoint, method='DELETE') + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + async def mkdir(self, workspace_id: str, file_path: str) -> None: + """Create a directory in a workspace. + + Args: + workspace_id: ID of the workspace + file_path: Path to the directory relative to workspace root + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=file_path) + body = json.dumps({'op': 'mkdir'}).encode('utf-8') + response_text, status_code = await self._make_request(endpoint, method='POST', body=body) + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + async def move_file(self, workspace_id: str, source_path: str, dest_path: str) -> None: + """Move or rename a file/directory in a workspace. + + Args: + workspace_id: ID of the workspace + source_path: Current path relative to workspace root + dest_path: Destination path relative to workspace root + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=source_path) + body = json.dumps({'op': 'move', 'dest': dest_path}).encode('utf-8') + response_text, status_code = await self._make_request(endpoint, method='POST', body=body) + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + async def copy_file(self, workspace_id: str, source_path: str, dest_path: str) -> None: + """Copy a file/directory in a workspace. + + Args: + workspace_id: ID of the workspace + source_path: Source path relative to workspace root + dest_path: Destination path relative to workspace root + + Returns: + HTTP 204 (No content) + """ + endpoint = Endpoint.WORKSPACE_FILE.format(workspace_id=workspace_id, file_path=source_path) + body = json.dumps({'op': 'copy', 'dest': dest_path}).encode('utf-8') + response_text, status_code = await self._make_request(endpoint, method='POST', body=body) + if status_code != 204: + raise Exception(f'Expected 204 status code, got {status_code}') + + class Category: + """Represents a category of plugins from a metadata source. + + Manages fetching, caching, and validating plugin metadata from a remote source. + """ + def __init__(self, meta_url, tag=CURRENT_TAG): self.meta_url = meta_url self.tag = tag @@ -458,14 +1042,14 @@ async def get_plugins_base_url(self): async def get_plugins(self): if self._plugins is None: await self.fetch_metadata() - self._plugins = ([ + self._plugins = [ Plugin( plugin_info, f"{await self.get_plugins_base_url()}/{plugin_info[0]}.py", tag=self.tag, ) for plugin_info in self._metadata["plugins"].items() - ]) + ] self.set_category_global_cache("plugins", self._plugins) return self._plugins @@ -505,35 +1089,38 @@ def __init__(self, plugins={}): class PluginLocal: - def __init__(self, name): - """ - Initialize a plugin locally installed on the device. - """ + """Represents a locally installed plugin on the device. + + Handles reading plugin metadata, managing installation status, + and controlling plugin enable/disable state. + """ + + def __init__(self, name: str): self.name = name self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{name}.py") self._entry_point_initials = f"{self.name}." self.cleanup() - def cleanup(self): + def cleanup(self) -> None: self._content = None self._api_version = None self._entry_points = [] self._has_minigames = None @property - def is_installed(self): + def is_installed(self) -> bool: return os.path.isfile(self.install_path) @property - def is_installed_via_plugin_manager(self): + def is_installed_via_plugin_manager(self) -> bool: return self.name in babase.app.config["Community Plugin Manager"]["Installed Plugins"] - def initialize(self): + def initialize(self) -> PluginLocal: if self.name not in babase.app.config["Community Plugin Manager"]["Installed Plugins"]: babase.app.config["Community Plugin Manager"]["Installed Plugins"][self.name] = {} return self - async def uninstall(self): + async def uninstall(self) -> None: if await self.has_minigames(): self.unload_minigames() try: @@ -548,10 +1135,11 @@ async def uninstall(self): self.save() @property - def version(self): + def version(self) -> str | None: try: - version = (babase.app.config["Community Plugin Manager"] - ["Installed Plugins"][self.name]["version"]) + version = babase.app.config["Community Plugin Manager"]["Installed Plugins"][self.name][ + "version" + ] except KeyError: version = None return version @@ -574,7 +1162,7 @@ def launch_settings(self, source_widget): if plugin_entry_point.startswith(self._entry_point_initials): return plugin_spec.plugin.show_settings_ui(source_widget) - async def get_content(self): + async def get_content(self) -> bytes: if self._content is None: if not self.is_installed: raise PluginNotInstalled("Plugin is not available locally.") @@ -582,13 +1170,23 @@ async def get_content(self): self._content = await loop.run_in_executor(None, self._get_content) return self._content - async def get_api_version(self): + async def get_api_version(self) -> bytes: + """Extract the required API version from plugin metadata. + + Returns: + API version string from ba_meta require api comment + """ if self._api_version is None: content = await self.get_content() self._api_version = REGEXP["plugin_api_version"].search(content).group() return self._api_version - async def get_entry_points(self): + async def get_entry_points(self) -> tuple[str, ...]: + """Get all entry points exported by this plugin. + + Returns: + Tuple of entry point class names + """ if not self._entry_points: content = await self.get_content() groups = REGEXP["plugin_entry_points"].findall(content) @@ -597,13 +1195,23 @@ async def get_entry_points(self): self._entry_points = entry_points return self._entry_points - async def has_minigames(self): + async def has_minigames(self) -> bool: + """Check if plugin exports any minigames. + + Returns: + True if plugin contains GameActivity classes + """ if self._has_minigames is None: content = await self.get_content() self._has_minigames = REGEXP["minigames"].search(content) is not None return self._has_minigames - async def has_plugins(self): + async def has_plugins(self) -> bool: + """Check if plugin has any entry points. + + Returns: + True if plugin exports plugin entry points + """ entry_points = await self.get_entry_points() return len(entry_points) > 0 @@ -704,6 +1312,11 @@ def save(self): class PluginVersion: + """Represents a specific version of a plugin. + + Contains version information, metadata, and handles installation. + """ + def __init__(self, plugin, version, tag=CURRENT_TAG): self.number, info = version self.plugin = plugin @@ -716,8 +1329,10 @@ def __init__(self, plugin, version, tag=CURRENT_TAG): self.view_url = self.plugin.url.format(content_type="blob", tag=tag) def __eq__(self, plugin_version): - return (self.number, self.plugin.name) == (plugin_version.number, - plugin_version.plugin.name) + return (self.number, self.plugin.name) == ( + plugin_version.number, + plugin_version.plugin.name, + ) def __repr__(self): return f"" @@ -743,7 +1358,8 @@ async def install(self, suppress_screenmessage=False): except MD5CheckSumFailed: if not suppress_screenmessage: bui.screenmessage( - f"{self.plugin.name} failed MD5 checksum during installation", color=(1, 0, 0)) + f"{self.plugin.name} failed MD5 checksum during installation", color=(1, 0, 0) + ) return False else: if not suppress_screenmessage: @@ -755,10 +1371,12 @@ async def install(self, suppress_screenmessage=False): class Plugin: + """Represents a plugin from a remote repository. + + Handles plugin information, version management, and installation/updates. + """ + def __init__(self, plugin, url, tag=CURRENT_TAG): - """ - Initialize a plugin from network repository. - """ self.name, self.info = plugin self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{self.name}.py") self.url = url @@ -795,7 +1413,8 @@ def versions(self): self, version, tag=self.tag, - ) for version in self.info["versions"].items() + ) + for version in self.info["versions"].items() ] return self._versions @@ -817,7 +1436,9 @@ def latest_compatible_version(self): self._latest_compatible_version = PluginVersion( self, (number, info), - tag=self.tag if self.latest_version.number == number else info["commit_sha"] + tag=( + self.tag if self.latest_version.number == number else info["commit_sha"] + ), ) break if self._latest_compatible_version is None: @@ -828,17 +1449,13 @@ def latest_compatible_version(self): def get_local(self): if not self.is_installed: - raise PluginNotInstalled( - f"{self.name} needs to be installed to get its local plugin.") + raise PluginNotInstalled(f"{self.name} needs to be installed to get its local plugin.") if self._local_plugin is None: self._local_plugin = PluginLocal(self.name) return self._local_plugin def create_local(self): - return ( - PluginLocal(self.name) - .initialize() - ) + return PluginLocal(self.name).initialize() async def uninstall(self): await self.get_local().uninstall() @@ -854,17 +1471,26 @@ def has_update(self): async def update(self): if await self.latest_compatible_version.install(suppress_screenmessage=True): - bui.screenmessage(f"{self.name} updated to {self.latest_compatible_version.number}", - color=(0, 1, 0)) + bui.screenmessage( + f"{self.name} updated to {self.latest_compatible_version.number}", color=(0, 1, 0) + ) bui.getsound('shieldUp').play() else: - bui.screenmessage(f"{self.name} failed MD5 checksum while updating to " - f"{self.latest_compatible_version.number}", - color=(1, 0, 0)) + bui.screenmessage( + f"{self.name} failed MD5 checksum while updating to " + f"{self.latest_compatible_version.number}", + color=(1, 0, 0), + ) bui.getsound('error').play() class PluginManager: + """Central manager for plugin operations. + + Handles plugin index management, category management, version management, + and self-updates. + """ + def __init__(self): self.request_headers = HEADERS self._index = _CACHE.get("index", {}) @@ -878,9 +1504,7 @@ async def get_index(self): if not self._index: request = urllib.request.Request( INDEX_META.format( - repository_url=REPOSITORY_URL, - content_type="raw", - tag=CURRENT_TAG + repository_url=REPOSITORY_URL, content_type="raw", tag=CURRENT_TAG ), headers=self.request_headers, ) @@ -903,12 +1527,12 @@ async def setup_index(self): async def get_changelog(self) -> list[str, bool]: requested = False if not self._changelog: - request = urllib.request.Request(CHANGELOG_META.format( - repository_url=REPOSITORY_URL, - content_type="raw", - tag=CURRENT_TAG - ), - headers=self.request_headers) + request = urllib.request.Request( + CHANGELOG_META.format( + repository_url=REPOSITORY_URL, content_type="raw", tag=CURRENT_TAG + ), + headers=self.request_headers, + ) response = await async_send_network_request(request) self._changelog = response.read().decode() requested = True @@ -927,28 +1551,34 @@ async def setup_changelog(self, version=None) -> None: # check if the changelog was requested if full_changelog[1]: pattern = rf"### {version} \(\d\d-\d\d-\d{{4}}\)\n(.*?)(?=### \d+\.\d+\.\d+|\Z)" - if (len(full_changelog[0].split(version)) > 1): + if len(full_changelog[0].split(version)) > 1: released_on = full_changelog[0].split(version)[1].split('\n')[0] matches = re.findall(pattern, full_changelog[0], re.DOTALL) else: matches = None if matches: + changelog = {'released_on': released_on, 'info': matches[0].strip()} + else: changelog = { - 'released_on': released_on, - 'info': matches[0].strip() + 'released_on': ' (Not Provided)', + 'info': f"Changelog entry for version {version} not found.", } - else: - changelog = {'released_on': ' (Not Provided)', - 'info': f"Changelog entry for version {version} not found."} else: changelog = full_changelog[0] except urllib.error.URLError: - changelog = {'released_on': ' (Not Provided)', - 'info': 'Could not get ChangeLog due to Internet Issues.'} + changelog = { + 'released_on': ' (Not Provided)', + 'info': 'Could not get ChangeLog due to Internet Issues.', + } self.set_changelog_global_cache(changelog) self._changelog_setup_in_progress = False + async def get_current_workspaces(self): + self.api = BallisticaAPI() + await self.api.initialize() + print((await self.api.get_workspaces(self.api.api_key)).workspaces) + async def setup_plugin_categories(self, plugin_index): # A hack to have the "All" category show at the top. self.categories["All"] = None @@ -1049,10 +1679,10 @@ async def soft_refresh(self): class ChangelogWindow(popup.PopupWindow): def __init__(self, origin_widget): self.scale_origin = origin_widget.get_screen_space_center() - s = 1.65 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67 + s = UIConfig.scale_value(1.65, 1.39, 1.67) width = 400 * s height = width * 0.5 - color = (1, 1, 1) + color = UIConfig.get_color('text_primary') text_scale = 0.7 * s self._transition_out = 'out_scale' transition = 'in_scale' @@ -1061,9 +1691,12 @@ def __init__(self, origin_widget): size=(width, height), on_outside_click_call=self._back, transition=transition, - scale=(1.5 if _uiscale() is babase.UIScale.SMALL else 1.5 if _uiscale() - is babase.UIScale.MEDIUM else 1.0), - scale_origin_stack_offset=self.scale_origin + scale=( + 1.5 + if _uiscale() is babase.UIScale.SMALL + else 1.5 if _uiscale() is babase.UIScale.MEDIUM else 1.0 + ), + scale_origin_stack_offset=self.scale_origin, ) _add_popup(self) @@ -1077,7 +1710,7 @@ def __init__(self, origin_widget): text='ChangeLog', scale=text_scale * 1.25, color=bui.app.ui_v1.title_color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) back_button = bui.buttonwidget( @@ -1088,7 +1721,7 @@ def __init__(self, origin_widget): label=babase.charstr(babase.SpecialChar.BACK), button_type='backSmall', on_activate_call=self._back, - enable_sound=False + enable_sound=False, ) bui.containerwidget(edit=self._root_widget, cancel_button=back_button) @@ -1113,7 +1746,7 @@ def __init__(self, origin_widget): text=PLUGIN_MANAGER_VERSION + released_on, scale=text_scale * 0.9, color=color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) bui.buttonwidget( @@ -1125,9 +1758,9 @@ def __init__(self, origin_widget): button_type='square', enable_sound=False, on_activate_call=lambda: ( - bui.getsound('deek').play() or - bui.open_url(REPOSITORY_URL + '/blob/main/CHANGELOG.md') - ) + bui.getsound('deek').play() + or bui.open_url(REPOSITORY_URL + '/blob/main/CHANGELOG.md') + ), ) loop_height = height * 0.62 @@ -1141,7 +1774,7 @@ def __init__(self, origin_widget): text=log, scale=text_scale, color=color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) loop_height -= 30 @@ -1155,10 +1788,10 @@ class AuthorsWindow(popup.PopupWindow): def __init__(self, authors_info, origin_widget): self.authors_info = authors_info self.scale_origin = origin_widget.get_screen_space_center() - s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67 + s = UIConfig.scale_value(1.25, 1.39, 1.67) width = 400 * s height = width * 0.8 - color = (1, 1, 1) + color = UIConfig.get_color('text_primary') text_scale = 0.7 * s self._transition_out = 'out_scale' transition = 'in_scale' @@ -1167,9 +1800,8 @@ def __init__(self, authors_info, origin_widget): size=(width, height), on_outside_click_call=self._back, transition=transition, - scale=(1.5 if _uiscale() is babase.UIScale.SMALL else 1.5 - if _uiscale() is babase.UIScale.MEDIUM else 1.0), - scale_origin_stack_offset=self.scale_origin + scale=UIConfig.scale_value(1.5, 1.5, 1.0), + scale_origin_stack_offset=self.scale_origin, ) _add_popup(self) @@ -1184,7 +1816,7 @@ def __init__(self, authors_info, origin_widget): text='Authors', scale=text_scale * 1.25, color=color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) back_button = bui.buttonwidget( @@ -1195,7 +1827,7 @@ def __init__(self, authors_info, origin_widget): label=babase.charstr(babase.SpecialChar.BACK), button_type='backSmall', on_activate_call=self._back, - enable_sound=False + enable_sound=False, ) bui.containerwidget(edit=self._root_widget, cancel_button=back_button) @@ -1203,13 +1835,10 @@ def __init__(self, authors_info, origin_widget): self._scrollwidget = bui.scrollwidget( parent=self._root_widget, size=(width * 0.8, height * 0.75), - position=(width * 0.1, height * 0.1) + position=(width * 0.1, height * 0.1), ) self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, - border=1, - left_border=-15, - margin=0 + parent=self._scrollwidget, border=1, left_border=-15, margin=0 ) for author in self.authors_info: @@ -1220,22 +1849,27 @@ def __init__(self, authors_info, origin_widget): bui.textwidget( parent=self._columnwidget, size=(width * 0.8, 35 if key == 'name' else 30), - color=color if key == 'name' else (0.75, 0.7, 0.8), + color=color if key == 'name' else UIConfig.get_color('text_secondary'), scale=( - (1.1 if key == 'name' else 0.9) if _uiscale() is babase.UIScale.SMALL else - (1.2 if key == 'name' else 1.0) + UIConfig.scale_value( + (1.1 if key == 'name' else 0.9), + (1.2 if key == 'name' else 1.0), + (1.2 if key == 'name' else 1.0), + ) + if _uiscale() is babase.UIScale.SMALL + else (1.2 if key == 'name' else 1.0) ), text=text, h_align='center', v_align='center', - maxwidth=420 + maxwidth=420, ) bui.textwidget( parent=self._columnwidget, size=(width * 0.8, 30), always_highlight=True, h_align='center', - v_align='center' + v_align='center', ) def _back(self) -> None: @@ -1272,10 +1906,12 @@ def get_description(self, minimum_character_offset=40): partitioned_string_length = len(partitioned_string) while partitioned_string_length != string_length: - next_empty_space = string[partitioned_string_length + - minimum_character_offset:].find(" ") - next_word_end_position = partitioned_string_length + \ - minimum_character_offset + max(0, next_empty_space) + next_empty_space = string[partitioned_string_length + minimum_character_offset:].find( + " " + ) + next_word_end_position = ( + partitioned_string_length + minimum_character_offset + max(0, next_empty_space) + ) partitioned_string += string[partitioned_string_length:next_word_end_position] if next_empty_space != -1: # Insert a line break here, there's still more partitioning to do. @@ -1286,28 +1922,27 @@ def get_description(self, minimum_character_offset=40): async def draw_ui(self): bui.getsound('swish').play() - b_text_color = (0.75, 0.7, 0.8) - s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.39 if babase.UIScale.MEDIUM else 1.67 + b_text_color = UIConfig.get_color('text_secondary') + s = UIConfig.scale_value(1.25, 1.39, 1.67) width = 450 * s height = 120 + 100 * s - color = (1, 1, 1) + color = UIConfig.get_color('text_primary') text_scale = 0.7 * s self._root_widget = bui.containerwidget( size=(width, height), on_outside_click_call=self._cancel, transition=self.transition, - scale=(2.1 if _uiscale() is babase.UIScale.SMALL else 1.5 - if _uiscale() is babase.UIScale.MEDIUM else 1.0), - scale_origin_stack_offset=self.scale_origin + scale=UIConfig.scale_value(2.1, 1.5, 1.0), + scale_origin_stack_offset=self.scale_origin, ) _add_popup(self) i = self.plugins_list.index(self.plugin) self.p_n_plugins = [ - self.plugins_list[i-1] if (i-1 > -1) else None, - self.plugins_list[i+1] if (i+1 < len(self.plugins_list)) else None + self.plugins_list[i - 1] if (i - 1 > -1) else None, + self.plugins_list[i + 1] if (i + 1 < len(self.plugins_list)) else None, ] # initialize all widgets @@ -1325,30 +1960,34 @@ async def draw_ui(self): if self.p_n_plugins[0] is not None: previous_plugin_button = bui.buttonwidget( parent=self._root_widget, - position=(-12.5*s + (4 if _uiscale() is babase.UIScale.SMALL else -5), - height/2 - 20*s), + position=( + -12.5 * s + (4 if _uiscale() is babase.UIScale.SMALL else -5), + height / 2 - 20 * s, + ), label=bui.charstr(bui.SpecialChar.LEFT_ARROW), size=(25, 40), color=(1, 0.5, 0.5), scale=s, on_activate_call=self.show_previous_plugin, enable_sound=False, - textcolor=(1, 1, 1) + textcolor=(1, 1, 1), ) # next plugin button if self.p_n_plugins[1] is not None: next_plugin_button = bui.buttonwidget( parent=self._root_widget, - position=(width - 12.5*s - (8 if _uiscale() - is babase.UIScale.SMALL else 0), height/2 - 20*s), + position=( + width - 12.5 * s - (8 if _uiscale() is babase.UIScale.SMALL else 0), + height / 2 - 20 * s, + ), label=bui.charstr(bui.SpecialChar.RIGHT_ARROW), size=(25, 40), color=(1, 0.5, 0.5), scale=s, on_activate_call=self.show_next_plugin, enable_sound=False, - textcolor=(1, 1, 1) + textcolor=(1, 1, 1), ) pos = height * 0.85 @@ -1363,7 +2002,7 @@ async def draw_ui(self): text=plugin_title, scale=text_scale * 1.25, color=color, - maxwidth=width * 0.7 - 40 + maxwidth=width * 0.7 - 40, ) pos -= 25 @@ -1371,16 +2010,16 @@ async def draw_ui(self): text = 'by ' + ', '.join([author["name"] for author in self.plugin.info["authors"]]) author_text_control_btn = bui.buttonwidget( parent=self._root_widget, - position=(width * 0.49 - (len(text)*14/2), pos - 10), - size=(len(text)*14, 20), + position=(width * 0.49 - (len(text) * 14 / 2), pos - 10), + size=(len(text) * 14, 20), label='', texture=bui.gettexture("empty"), - on_activate_call=lambda: AuthorsWindow(self.plugin.info["authors"], self._root_widget) + on_activate_call=lambda: AuthorsWindow(self.plugin.info["authors"], self._root_widget), ) bui.textwidget( parent=self._root_widget, - position=(width * 0.49 - (len(text)*14/2), pos - 10), - size=(len(text)*14, 20), + position=(width * 0.49 - (len(text) * 14 / 2), pos - 10), + size=(len(text) * 14, 20), h_align='center', v_align='center', text=text, @@ -1401,12 +2040,12 @@ async def draw_ui(self): text=self.get_description(), scale=text_scale * 0.6, color=color, - maxwidth=width * 0.95 + maxwidth=width * 0.95, ) b1_color = None - b2_color = (0.8, 0.15, 0.35) - b3_color = (0.2, 0.8, 0.3) + b2_color = UIConfig.get_color('button_red') + b3_color = UIConfig.get_color('button_green') pos = height * 0.1 button_size = (80 * s, 40 * s) @@ -1421,7 +2060,7 @@ async def draw_ui(self): else: if await self.local_plugin.is_enabled(): button1_label = "Disable" - b1_color = (0.6, 0.53, 0.63) + b1_color = UIConfig.get_color('button_neutral') button1_action = self.disable if self.local_plugin.has_settings(): to_draw_settings_button = True @@ -1443,8 +2082,13 @@ async def draw_ui(self): button1 = bui.buttonwidget( parent=self._root_widget, position=( - width * (0.1 if self.plugin.is_installed and has_update else - 0.25 if self.plugin.is_installed else 0.4), pos + width + * ( + 0.1 + if self.plugin.is_installed and has_update + else 0.25 if self.plugin.is_installed else 0.4 + ), + pos, ), size=button_size, on_activate_call=button1_action, @@ -1452,22 +2096,21 @@ async def draw_ui(self): textcolor=b_text_color, button_type='square', text_scale=1, - label=button1_label + label=button1_label, ) # button2 if self.plugin.is_installed: button2 = bui.buttonwidget( parent=self._root_widget, - position=( - width * (0.4 if has_update or not to_draw_button1 else 0.55), pos), + position=(width * (0.4 if has_update or not to_draw_button1 else 0.55), pos), size=button_size, on_activate_call=button2_action, color=b2_color, textcolor=b_text_color, button_type='square', text_scale=1, - label=button2_label + label=button2_label, ) # button3 @@ -1482,129 +2125,71 @@ async def draw_ui(self): autoselect=True, button_type='square', text_scale=1, - label=button3_label + label=button3_label, ) # determine selected button - selected_btn = button3 if has_update and self.plugin.is_installed else ( - button2 if self.plugin.is_installed else button1 + selected_btn = ( + button3 + if has_update and self.plugin.is_installed + else (button2 if self.plugin.is_installed else button1) ) bui.containerwidget( - edit=self._root_widget, - on_cancel_call=self._cancel, - selected_child=selected_btn + edit=self._root_widget, on_cancel_call=self._cancel, selected_child=selected_btn ) # button math button_x = width - 90 - button_y = 210 - _by_scale(5, 10, 15) + button_y = 210 - UIConfig.scale_value(5, 10, 15) button_color = (0, 0.75, 0.75) button_image_color = (0.8, 0.95, 1) - button_text_color = (1, 1, 1, 1) + button_text_color = UIConfig.get_color('text_primary') # more button - more_button = bui.buttonwidget( - parent=self._root_widget, - position=(button_x, button_y), - size=(40, 40), - button_type='square', - label='', - color=button_color - ) - bui.buttonwidget( - edit=more_button, - on_activate_call=babase.CallPartial( - MoreWindow, - plugin=self.plugin, - origin=more_button - ) - ) - bui.imagewidget( + more_button = UIHelpers.create_icon_button_with_text( parent=self._root_widget, position=(button_x, button_y), size=(40, 40), - color=button_image_color, - texture=bui.gettexture('file'), - draw_controller=more_button - ) - bui.textwidget( - parent=self._root_widget, - position=(button_x-3, button_y+12), + texture='file', + on_activate=lambda: MoreWindow(plugin=self.plugin, origin=more_button), text='More...', - size=(10, 10), - draw_controller=more_button, - color=button_text_color, - rotate=25, - scale=0.45 + text_color=button_text_color, + btn_color=button_color, + icon_color=button_image_color, ) # settings button if to_draw_settings_button: button_y -= 50 - settings_button = bui.buttonwidget( + settings_button = UIHelpers.create_icon_button( parent=self._root_widget, - autoselect=True, position=(button_x, button_y), size=(40, 40), - button_type="square", - label="", - color=button_color - ) - bui.buttonwidget( - edit=settings_button, - on_activate_call=babase.CallPartial(self.settings, settings_button) - ) - bui.imagewidget( - parent=self._root_widget, - position=(button_x, button_y), - size=(40, 40), - color=button_image_color, - texture=bui.gettexture("settingsIcon"), - draw_controller=settings_button + texture='settingsIcon', + on_activate=babase.CallPartial(self.settings, None), + btn_color=button_color, + icon_color=button_image_color, ) # tutorial tutorial_url = self.plugin.info['external_url'] if tutorial_url: button_y -= 50 - tutorial_button = bui.buttonwidget( + tutorial_button = UIHelpers.create_icon_button_with_text( parent=self._root_widget, - autoselect=True, position=(button_x, button_y), size=(40, 40), - button_type="square", - label="", - color=button_color - ) - bui.imagewidget( - parent=self._root_widget, - position=(button_x, button_y), - size=(40, 40), - color=button_image_color, - texture=bui.gettexture("frameInset"), - draw_controller=tutorial_button - ) - bui.textwidget( - parent=self._root_widget, - position=(button_x - 3, button_y + 12), - text="Tutorial", - size=(10, 10), - draw_controller=tutorial_button, - color=button_text_color, - rotate=25, - scale=0.45 - ) - bui.buttonwidget( - tutorial_button, - on_activate_call=bui.CallPartial( - confirm.ConfirmWindow, + texture='frameInset', + on_activate=lambda: confirm.ConfirmWindow( text=f'This will take you to:\n{tutorial_url}', origin_widget=tutorial_button, - action=bui.CallPartial( - bui.open_url, tutorial_url - ) - ) + action=bui.CallPartial(bui.open_url, tutorial_url), + ), + text='Tutorial', + text_color=button_text_color, + btn_color=button_color, + icon_color=button_image_color, ) # navigation #XXX if button1 and button2 and button3: @@ -1646,7 +2231,7 @@ async def draw_ui(self): left_widget=next_plugin_button, right_widget=leftmost_button, up_widget=author_text_control_btn, - down_widget=leftmost_button + down_widget=leftmost_button, ) # next plugin button @@ -1656,7 +2241,7 @@ async def draw_ui(self): left_widget=bottom_stack, right_widget=bui.get_special_widget('squad_button'), up_widget=author_text_control_btn, - down_widget=bottom_stack + down_widget=bottom_stack, ) # author button @@ -1665,7 +2250,7 @@ async def draw_ui(self): left_widget=previous_plugin_button, right_widget=top_stack, up_widget=center_button or leftmost_button, - down_widget=leftmost_button + down_widget=leftmost_button, ) # button1 @@ -1675,7 +2260,7 @@ async def draw_ui(self): left_widget=previous_plugin_button, right_widget=center_button or bottom_stack, up_widget=author_text_control_btn, - down_widget=author_text_control_btn + down_widget=author_text_control_btn, ) # button2 @@ -1685,7 +2270,7 @@ async def draw_ui(self): left_widget=button1 or previous_plugin_button, right_widget=button3 or bottom_stack, up_widget=author_text_control_btn, - down_widget=author_text_control_btn + down_widget=author_text_control_btn, ) # button3 @@ -1695,7 +2280,7 @@ async def draw_ui(self): left_widget=center_button or leftmost_button, right_widget=bottom_stack, up_widget=author_text_control_btn, - down_widget=author_text_control_btn + down_widget=author_text_control_btn, ) # more button @@ -1704,7 +2289,7 @@ async def draw_ui(self): left_widget=rightmost_button, right_widget=next_plugin_button, up_widget=author_text_control_btn, - down_widget=settings_button + down_widget=settings_button, ) # settings button @@ -1714,7 +2299,7 @@ async def draw_ui(self): left_widget=rightmost_button, right_widget=next_plugin_button, up_widget=more_button, - down_widget=tutorial_button + down_widget=tutorial_button, ) # tutorial button @@ -1724,7 +2309,7 @@ async def draw_ui(self): left_widget=rightmost_button, right_widget=next_plugin_button, up_widget=settings_button or more_button, - down_widget=rightmost_button + down_widget=rightmost_button, ) def _ok(self) -> None: @@ -1763,7 +2348,7 @@ def show_previous_plugin(self): self._root_widget, transition='in_left', plugins_list=self.plugins_list, - button_callback=lambda: None + button_callback=lambda: None, ) def show_next_plugin(self): @@ -1774,7 +2359,7 @@ def show_next_plugin(self): self._root_widget, transition='in_right', plugins_list=self.plugins_list, - button_callback=lambda: None + button_callback=lambda: None, ) @button @@ -1817,19 +2402,15 @@ def __init__(self, plugin: Plugin, origin=None): margin = 20 width = 350 px, py = margin, margin - px2 = width-(margin+step) - mw = px2-margin*2 + px2 = width - (margin + step) + mw = px2 - margin * 2 # root self.transition = origin and 'scale' or 'left' self._root_widget = bui.containerwidget( on_outside_click_call=self._back, transition=f'in_{self.transition}', - scale=_by_scale(1.5, 1.5, 1), - scale_origin_stack_offset=( - origin and - origin.get_screen_space_center() - or None - ) + scale=UIConfig.scale_value(1.5, 1.5, 1), + scale_origin_stack_offset=(origin and origin.get_screen_space_center() or None), ) # last updated bui.textwidget( @@ -1837,17 +2418,13 @@ def __init__(self, plugin: Plugin, origin=None): position=(px, py), h_align='left', v_align='center', - maxwidth=width-margin*2, + maxwidth=width - margin * 2, max_height=step, size=(step, step), - text=( - last_updated and - f'Last updated on: {last_updated}' - or 'Unknown' - ) + text=(last_updated and f'Last updated on: {last_updated}' or 'Unknown'), ) # version - py += margin+step/3 + py += margin + step / 3 bui.textwidget( parent=self._root_widget, position=(px, py), @@ -1856,10 +2433,10 @@ def __init__(self, plugin: Plugin, origin=None): maxwidth=mw, max_height=step, size=(step, step), - text=f"Version: {plugin.latest_compatible_version.number}" + text=f"Version: {plugin.latest_compatible_version.number}", ) # source url - py += step+margin + py += step + margin bui.textwidget( parent=self._root_widget, position=(px, py), @@ -1868,40 +2445,21 @@ def __init__(self, plugin: Plugin, origin=None): maxwidth=mw, max_height=step, size=(step, step), - text='Source URL:' - ) - source_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(px2, py), - size=(step, step), - color=(0.6, 0.53, 0.63), - button_type='square', - label='', - on_activate_call=bui.CallPartial( - bui.open_url, - plugin.view_url - ) + text='Source URL:', ) - bui.imagewidget( + source_button = UIHelpers.create_icon_button_with_text( parent=self._root_widget, position=(px2, py), size=(step, step), - color=(0.8, 0.95, 1), - texture=bui.gettexture('file'), - draw_controller=source_button - ) - bui.textwidget( - parent=self._root_widget, - position=(px2-3, py+12), + texture='file', + on_activate=bui.CallPartial(bui.open_url, plugin.view_url), text='source', - size=(10, 10), - draw_controller=source_button, - rotate=25, - scale=0.45 + text_color=(1, 1, 1), + btn_color=(0.6, 0.53, 0.63), + icon_color=(0.8, 0.95, 1), ) # report bug - py += step+margin + py += step + margin bui.textwidget( parent=self._root_widget, position=(px, py), @@ -1910,27 +2468,19 @@ def __init__(self, plugin: Plugin, origin=None): maxwidth=mw, max_height=step, size=(step, step), - text='Report a bug:' - ) - report_button = bui.buttonwidget( - parent=self._root_widget, - position=(px2, py), - size=(step, step), - button_type='square', - color=(0.6, 0.53, 0.63), - label='', - on_activate_call=self._open_bug_report_url + text='Report a bug:', ) - bui.imagewidget( + report_button = UIHelpers.create_icon_button( parent=self._root_widget, position=(px2, py), size=(step, step), - color=(0.8, 0.95, 1), - texture=bui.gettexture('githubLogo'), - draw_controller=report_button + texture='githubLogo', + on_activate=self._open_bug_report_url, + btn_color=(0.6, 0.53, 0.63), + icon_color=(0.8, 0.95, 1), ) # back - py += step+margin + py += step + margin back_button = bui.buttonwidget( parent=self._root_widget, position=(px, py), @@ -1938,36 +2488,32 @@ def __init__(self, plugin: Plugin, origin=None): label=babase.charstr(babase.SpecialChar.BACK), button_type='backSmall', on_activate_call=self._back, - enable_sound=False + enable_sound=False, ) # title px += margin bui.textwidget( parent=self._root_widget, - position=(px+(step), py), + position=(px + (step), py), h_align='left', v_align='center', - maxwidth=width-margin*2, - max_height=step*2, + maxwidth=width - margin * 2, + max_height=step * 2, size=(step, step), text='More info', scale=1.4, - shadow=0.8 + shadow=0.8, ) # finally - py += step+margin - bui.containerwidget( - edit=self._root_widget, - cancel_button=back_button, - size=(width, py) - ) + py += step + margin + bui.containerwidget(edit=self._root_widget, cancel_button=back_button, size=(width, py)) # navigation bui.buttonwidget( edit=back_button, left_widget=report_button, right_widget=report_button, up_widget=source_button, - down_widget=report_button + down_widget=report_button, ) bui.buttonwidget( @@ -1975,7 +2521,7 @@ def __init__(self, plugin: Plugin, origin=None): left_widget=back_button, right_widget=back_button, up_widget=report_button, - down_widget=back_button + down_widget=back_button, ) bui.buttonwidget( @@ -1983,7 +2529,7 @@ def __init__(self, plugin: Plugin, origin=None): left_widget=back_button, right_widget=back_button, up_widget=back_button, - down_widget=source_button + down_widget=source_button, ) def _open_bug_report_url(self): @@ -2004,25 +2550,33 @@ def _open_bug_report_url(self): params = { "title": "[PLUGIN BUG]: " + self.plugin.name, "plugin-name": self.plugin.name, - "plugin-version": self.plugin.create_local().version if self.plugin.is_installed else 'Not Installed', + "plugin-version": ( + self.plugin.create_local().version if self.plugin.is_installed else 'Not Installed' + ), "plugin-manager-version": 'v' + PLUGIN_MANAGER_VERSION, - "bombsquad-version": 'v' + babase.app.env.engine_version + " (" + str(babase.app.env.engine_build_number) + ")", - "os-version": platform.platform() if platform else babase.app.env.platform.value + babase.app.env.os_version, - "console-log": error_logs + "bombsquad-version": 'v' + + babase.app.env.engine_version + + " (" + + str(babase.app.env.engine_build_number) + + ")", + "os-version": ( + platform.platform() + if platform + else babase.app.env.platform.value + babase.app.env.os_version + ), + "console-log": error_logs, } query_string = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) base_url = GITHUB_PLUGIN_ISSUE_META.format( - repository_url=REPOSITORY_URL, issue_template=PLUGIN_ISSUE_TEMPLATE) + repository_url=REPOSITORY_URL, issue_template=PLUGIN_ISSUE_TEMPLATE + ) final_url = f"{base_url}&{query_string}" bui.open_url(final_url) def _back(self) -> None: _remove_popup(self) bui.getsound('swish').play() - bui.containerwidget( - edit=self._root_widget, - transition=f'out_{self.transition}' - ) + bui.containerwidget(edit=self._root_widget, transition=f'out_{self.transition}') class PluginCustomSourcesWindow(popup.PopupWindow): @@ -2038,10 +2592,13 @@ def __init__(self, origin_widget): size=(400, 340), on_outside_click_call=self._ok, transition=transition, - scale=(2.1 if _uiscale() is babase.UIScale.SMALL else 1.5 - if _uiscale() is babase.UIScale.MEDIUM else 1.0), + scale=( + 2.1 + if _uiscale() is babase.UIScale.SMALL + else 1.5 if _uiscale() is babase.UIScale.MEDIUM else 1.0 + ), scale_origin_stack_offset=self.scale_origin, - on_cancel_call=self._ok + on_cancel_call=self._ok, ) _add_popup(self) @@ -2058,58 +2615,64 @@ def __init__(self, origin_widget): maxwidth=270, ) - scroll_size_x = (290 if _uiscale() is babase.UIScale.SMALL else - 300 if _uiscale() is babase.UIScale.MEDIUM else 290) - scroll_size_y = (170 if _uiscale() is babase.UIScale.SMALL else - 185 if _uiscale() is babase.UIScale.MEDIUM else 180) - scroll_pos_x = (55 if _uiscale() is babase.UIScale.SMALL else - 40 if _uiscale() is babase.UIScale.MEDIUM else 60) + scroll_size_x = ( + 290 + if _uiscale() is babase.UIScale.SMALL + else 300 if _uiscale() is babase.UIScale.MEDIUM else 290 + ) + scroll_size_y = ( + 170 + if _uiscale() is babase.UIScale.SMALL + else 185 if _uiscale() is babase.UIScale.MEDIUM else 180 + ) + scroll_pos_x = ( + 55 + if _uiscale() is babase.UIScale.SMALL + else 40 if _uiscale() is babase.UIScale.MEDIUM else 60 + ) scroll_pos_y = 105 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, size=(scroll_size_x, scroll_size_y), - position=(scroll_pos_x, scroll_pos_y) - ) - self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, - border=1, margin=0 + position=(scroll_pos_x, scroll_pos_y), ) + self._columnwidget = bui.columnwidget(parent=self._scrollwidget, border=1, margin=0) delete_source_button_position_pos_x = 360 delete_source_button_position_pos_y = 110 delete_source_button = bui.buttonwidget( parent=self._root_widget, - position=( - delete_source_button_position_pos_x, delete_source_button_position_pos_y - ), + position=(delete_source_button_position_pos_x, delete_source_button_position_pos_y), size=(25, 25), label="", on_activate_call=self.delete_selected_source, button_type="square", - color=(0.6, 0, 0) + color=(0.6, 0, 0), ) bui.imagewidget( parent=self._root_widget, - position=( - delete_source_button_position_pos_x + 2, delete_source_button_position_pos_y - ), + position=(delete_source_button_position_pos_x + 2, delete_source_button_position_pos_y), size=(25, 25), color=(5, 2, 2), texture=bui.gettexture("crossOut"), - draw_controller=delete_source_button + draw_controller=delete_source_button, ) - warning_pos_x = (43 if _uiscale() is babase.UIScale.SMALL else - 35 if _uiscale() is babase.UIScale.MEDIUM else - 48) + warning_pos_x = ( + 43 + if _uiscale() is babase.UIScale.SMALL + else 35 if _uiscale() is babase.UIScale.MEDIUM else 48 + ) bui.textwidget( parent=self._root_widget, position=(warning_pos_x, 74), size=(50, 22), - text=("Warning: 3rd party plugin sources are not moderated\n" - " by the community and may be dangerous!"), + text=( + "Warning: 3rd party plugin sources are not moderated\n" + " by the community and may be dangerous!" + ), color=(1, 0.23, 0.23), scale=0.5, h_align="left", @@ -2126,7 +2689,7 @@ def __init__(self, origin_widget): editable=True, scale=0.75, maxwidth=215, - description="Add Source" + description="Add Source", ) bui.buttonwidget( @@ -2139,7 +2702,7 @@ def __init__(self, origin_widget): button_type="square", color=(0, 0.9, 0), textcolor=b_textcolor, - text_scale=1 + text_scale=1, ) self.draw_sources() @@ -2159,7 +2722,7 @@ def draw_sources(self): h_align='left', v_align='center', scale=0.75, - maxwidth=260 + maxwidth=260, ) def select_source(self, source): @@ -2192,8 +2755,9 @@ async def add_source(self): return babase.app.config["Community Plugin Manager"]["Custom Sources"].append(source) babase.app.config.commit() - bui.screenmessage("Plugin source added; Refresh plugin list to see changes", - color=(0, 1, 0)) + bui.screenmessage( + "Plugin source added; Refresh plugin list to see changes", color=(0, 1, 0) + ) bui.getsound('cashRegister2').play() self.draw_sources() @@ -2202,8 +2766,9 @@ def delete_selected_source(self): return babase.app.config["Community Plugin Manager"]["Custom Sources"].remove(self.selected_source) babase.app.config.commit() - bui.screenmessage("Plugin source deleted; Refresh plugin list to see changes", - color=(0.9, 1, 0)) + bui.screenmessage( + "Plugin source deleted; Refresh plugin list to see changes", color=(0.9, 1, 0) + ) bui.getsound('shieldDown').play() self.draw_sources() @@ -2220,11 +2785,14 @@ def __init__(self, choices, current_choice, origin_widget, asyncio_callback): self.scale_origin = origin_widget.get_screen_space_center() super().__init__( position=self.scale_origin, - scale=(2.3 if _uiscale() is babase.UIScale.SMALL else - 1.65 if _uiscale() is babase.UIScale.MEDIUM else 1.23), + scale=( + 2.3 + if _uiscale() is babase.UIScale.SMALL + else 1.65 if _uiscale() is babase.UIScale.MEDIUM else 1.23 + ), choices=choices, current_choice=current_choice, - delegate=self + delegate=self, ) self._root_widget = self.root_widget _add_popup(self) @@ -2234,7 +2802,7 @@ def _update_custom_sources_widget(self): bui.textwidget( edit=self._columnwidget.get_children()[-1], color=(0.5, 0.5, 0.5), - on_activate_call=self.show_sources_window + on_activate_call=self.show_sources_window, ) def popup_menu_selected_choice(self, window, choice): @@ -2259,11 +2827,7 @@ class PluginManagerWindow(bui.MainWindow): def main_window_should_preserve_selection(self) -> bool: return False - def __init__( - self, - transition: str = "in_right", - origin_widget: bui.Widget = None - ): + def __init__(self, transition: str = "in_right", origin_widget: bui.Widget = None): self.plugin_manager = PluginManager() self.category_selection_button = None self.selected_category = 'All' @@ -2275,12 +2839,16 @@ def __init__( loop.create_task(self.draw_index()) - self._width = (700 if _uiscale() is babase.UIScale.SMALL - else 550 if _uiscale() is babase.UIScale.MEDIUM - else 570) - self._height = (500 if _uiscale() is babase.UIScale.SMALL - else 422 if _uiscale() is babase.UIScale.MEDIUM - else 500) + self._width = ( + 700 + if _uiscale() is babase.UIScale.SMALL + else 550 if _uiscale() is babase.UIScale.MEDIUM else 570 + ) + self._height = ( + 500 + if _uiscale() is babase.UIScale.SMALL + else 422 if _uiscale() is babase.UIScale.MEDIUM else 500 + ) top_extra = 20 if _uiscale() is babase.UIScale.SMALL else 0 if origin_widget: @@ -2292,25 +2860,31 @@ def __init__( root_widget=bui.containerwidget( size=(self._width, self._height + top_extra), toolbar_visibility="menu_minimal", - scale=(1.9 if _uiscale() is babase.UIScale.SMALL - else 1.5 if _uiscale() is babase.UIScale.MEDIUM - else 1.0), - stack_offset=(0, -25) if _uiscale() is babase.UIScale.SMALL else (0, 0) + scale=( + 1.9 + if _uiscale() is babase.UIScale.SMALL + else 1.5 if _uiscale() is babase.UIScale.MEDIUM else 1.0 + ), + stack_offset=(0, -25) if _uiscale() is babase.UIScale.SMALL else (0, 0), ), transition=transition, origin_widget=origin_widget, ) - back_pos_x = 5 + (37 if _uiscale() is babase.UIScale.SMALL else - 27 if _uiscale() is babase.UIScale.MEDIUM else 68) - back_pos_y = self._height - (95 if _uiscale() is babase.UIScale.SMALL else - 65 if _uiscale() is babase.UIScale.MEDIUM else 50) + back_pos_x = 5 + ( + 37 + if _uiscale() is babase.UIScale.SMALL + else 27 if _uiscale() is babase.UIScale.MEDIUM else 68 + ) + back_pos_y = self._height - ( + 95 + if _uiscale() is babase.UIScale.SMALL + else 65 if _uiscale() is babase.UIScale.MEDIUM else 50 + ) if _uiscale() is bui.UIScale.SMALL: self._back_button = None - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) + bui.containerwidget(edit=self._root_widget, on_cancel_call=self.main_window_back) else: self._back_button = back_button = bui.buttonwidget( parent=self._root_widget, @@ -2319,13 +2893,16 @@ def __init__( scale=0.8, label=babase.charstr(babase.SpecialChar.BACK), button_type='backSmall', - on_activate_call=self.main_window_back + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=back_button) - title_pos = self._height - (83 if _uiscale() is babase.UIScale.SMALL else - 50 if _uiscale() is babase.UIScale.MEDIUM else 50) + title_pos = self._height - ( + 83 + if _uiscale() is babase.UIScale.SMALL + else 50 if _uiscale() is babase.UIScale.MEDIUM else 50 + ) bui.textwidget( parent=self._root_widget, position=(-10, title_pos), @@ -2338,8 +2915,11 @@ def __init__( maxwidth=270, ) - loading_pos_y = self._height - (275 if _uiscale() is babase.UIScale.SMALL else - 235 if _uiscale() is babase.UIScale.MEDIUM else 270) + loading_pos_y = self._height - ( + 275 + if _uiscale() is babase.UIScale.SMALL + else 235 if _uiscale() is babase.UIScale.MEDIUM else 270 + ) self._plugin_manager_status_text = bui.textwidget( parent=self._root_widget, @@ -2379,8 +2959,11 @@ def get_main_window_state(self) -> bui.MainWindowState: def spin(self, show=False): w = self._loading_spinner p = self._root_widget - bui.spinnerwidget(w, visible=show) if w.exists( - ) and p.exists() and not p.transitioning_out else None + ( + bui.spinnerwidget(w, visible=show) + if w.exists() and p.exists() and not p.transitioning_out + else None + ) @contextlib.contextmanager def exception_handler(self): @@ -2391,7 +2974,7 @@ def exception_handler(self): try: bui.textwidget( edit=self._plugin_manager_status_text, - text="Make sure you are connected\n to the Internet and try again." + text="Make sure you are connected\n to the Internet and try again.", ) except: pass @@ -2424,39 +3007,46 @@ async def draw_index(self): await self.select_category("All") def draw_plugins_scroll_bar(self): - scroll_size_x = self.scrollx = (515 if _uiscale() is babase.UIScale.SMALL else - 430 if _uiscale() is babase.UIScale.MEDIUM else 420) - scroll_size_y = (245 if _uiscale() is babase.UIScale.SMALL else - 265 if _uiscale() is babase.UIScale.MEDIUM else 335) - scroll_pos_x = (70 if _uiscale() is babase.UIScale.SMALL else - 50 if _uiscale() is babase.UIScale.MEDIUM else 70) - scroll_pos_y = (100 if _uiscale() is babase.UIScale.SMALL else - 35 if _uiscale() is babase.UIScale.MEDIUM else 40) + scroll_size_x = self.scrollx = ( + 515 + if _uiscale() is babase.UIScale.SMALL + else 430 if _uiscale() is babase.UIScale.MEDIUM else 420 + ) + scroll_size_y = ( + 245 + if _uiscale() is babase.UIScale.SMALL + else 265 if _uiscale() is babase.UIScale.MEDIUM else 335 + ) + scroll_pos_x = ( + 70 + if _uiscale() is babase.UIScale.SMALL + else 50 if _uiscale() is babase.UIScale.MEDIUM else 70 + ) + scroll_pos_y = ( + 100 + if _uiscale() is babase.UIScale.SMALL + else 35 if _uiscale() is babase.UIScale.MEDIUM else 40 + ) self._scrollwidget = sw = bui.scrollwidget( parent=self._root_widget, size=(scroll_size_x, scroll_size_y), - position=(scroll_pos_x, scroll_pos_y) - ) - self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, - border=2, - margin=0 - ) - bui.widget( - sw, - left_widget=self._back_button, - up_widget=self._filter_widget - ) - bui.widget( - self._filter_widget, - down_widget=sw + position=(scroll_pos_x, scroll_pos_y), ) + self._columnwidget = bui.columnwidget(parent=self._scrollwidget, border=2, margin=0) + bui.widget(sw, left_widget=self._back_button, up_widget=self._filter_widget) + bui.widget(self._filter_widget, down_widget=sw) def draw_category_selection_button(self, post_label): - category_pos_x = (440 if _uiscale() is babase.UIScale.SMALL else - 340 if _uiscale() is babase.UIScale.MEDIUM else 370) - category_pos_y = self._height - (141 if _uiscale() is babase.UIScale.SMALL else - 110 if _uiscale() is babase.UIScale.MEDIUM else 110) + category_pos_x = ( + 440 + if _uiscale() is babase.UIScale.SMALL + else 340 if _uiscale() is babase.UIScale.MEDIUM else 370 + ) + category_pos_y = self._height - ( + 141 + if _uiscale() is babase.UIScale.SMALL + else 110 if _uiscale() is babase.UIScale.MEDIUM else 110 + ) b_size = (140, 30) b_textcolor = (0.8, 0.8, 0.85) @@ -2473,14 +3063,17 @@ def draw_category_selection_button(self, post_label): enable_sound=False, left_widget=self._filter_widget, down_widget=self._scrollwidget, - up_widget=self._back_button + up_widget=self._back_button, ) else: b = self.alphabet_order_selection_button - bui.buttonwidget( - edit=b, - label=('Z - A' if self.selected_alphabet_order == 'z_a' else 'A - Z') - ) if b.exists() else None + ( + bui.buttonwidget( + edit=b, label=('Z - A' if self.selected_alphabet_order == 'z_a' else 'A - Z') + ) + if b.exists() + else None + ) label = f"Category: {post_label}" @@ -2493,64 +3086,85 @@ def draw_category_selection_button(self, post_label): button_type="square", textcolor=b_textcolor, text_scale=0.6, - up_widget=self._back_button + up_widget=self._back_button, ) bui.buttonwidget( edit=b, down_widget=self._scrollwidget, - on_activate_call=lambda: self.show_categories_window(source=b) + on_activate_call=lambda: self.show_categories_window(source=b), ) else: b = self.category_selection_button - bui.buttonwidget( - edit=b, - label=label - ) if b.exists() else None + bui.buttonwidget(edit=b, label=label) if b.exists() else None async def _on_order_button_press(self) -> None: bui.getsound('deek').play() - self.selected_alphabet_order = ('a_z' if self.selected_alphabet_order == 'z_a' else 'z_a') - bui.buttonwidget(edit=self.alphabet_order_selection_button, - label=('Z - A' if self.selected_alphabet_order == 'z_a' else 'A - Z') - ) + self.selected_alphabet_order = 'a_z' if self.selected_alphabet_order == 'z_a' else 'z_a' + bui.buttonwidget( + edit=self.alphabet_order_selection_button, + label=('Z - A' if self.selected_alphabet_order == 'z_a' else 'A - Z'), + ) filter_text = bui.textwidget(parent=self._root_widget, query=self._filter_widget) if self.plugin_manager.categories != {}: if self.plugin_manager.categories['All'] is not None: await self.draw_plugin_names( - self.selected_category, search_term=filter_text, refresh=True, order=self.selected_alphabet_order + self.selected_category, + search_term=filter_text, + refresh=True, + order=self.selected_alphabet_order, ) def draw_search_bar(self): - search_bar_pos_x = (85 if _uiscale() is babase.UIScale.SMALL else - 68 if _uiscale() is babase.UIScale.MEDIUM else 75) + search_bar_pos_x = ( + 85 + if _uiscale() is babase.UIScale.SMALL + else 68 if _uiscale() is babase.UIScale.MEDIUM else 75 + ) search_bar_pos_y = self._height - ( - 145 if _uiscale() is babase.UIScale.SMALL else - 110 if _uiscale() is babase.UIScale.MEDIUM else 116) + 145 + if _uiscale() is babase.UIScale.SMALL + else 110 if _uiscale() is babase.UIScale.MEDIUM else 116 + ) - search_bar_size_x = (320 if _uiscale() is babase.UIScale.SMALL else - 230 if _uiscale() is babase.UIScale.MEDIUM else 260) + search_bar_size_x = ( + 320 + if _uiscale() is babase.UIScale.SMALL + else 230 if _uiscale() is babase.UIScale.MEDIUM else 260 + ) search_bar_size_y = ( - 35 if _uiscale() is babase.UIScale.SMALL else - 35 if _uiscale() is babase.UIScale.MEDIUM else 45) - - filter_txt_pos_x = (60 if _uiscale() is babase.UIScale.SMALL else - 40 if _uiscale() is babase.UIScale.MEDIUM else 50) - filter_txt_pos_y = search_bar_pos_y + (3 if _uiscale() is babase.UIScale.SMALL else - 4 if _uiscale() is babase.UIScale.MEDIUM else 8) - - bui.textwidget(parent=self._root_widget, - text="Filter", - position=(filter_txt_pos_x, filter_txt_pos_y), - selectable=False, - h_align='left', - v_align='center', - color=bui.app.ui_v1.title_color, - scale=0.5) + 35 + if _uiscale() is babase.UIScale.SMALL + else 35 if _uiscale() is babase.UIScale.MEDIUM else 45 + ) + + filter_txt_pos_x = ( + 60 + if _uiscale() is babase.UIScale.SMALL + else 40 if _uiscale() is babase.UIScale.MEDIUM else 50 + ) + filter_txt_pos_y = search_bar_pos_y + ( + 3 + if _uiscale() is babase.UIScale.SMALL + else 4 if _uiscale() is babase.UIScale.MEDIUM else 8 + ) + + bui.textwidget( + parent=self._root_widget, + text="Filter", + position=(filter_txt_pos_x, filter_txt_pos_y), + selectable=False, + h_align='left', + v_align='center', + color=bui.app.ui_v1.title_color, + scale=0.5, + ) filter_txt = babase.Lstr(resource='filterText') - search_bar_maxwidth = search_bar_size_x - (95 if _uiscale() is babase.UIScale.SMALL else - 77 if _uiscale() is babase.UIScale.MEDIUM else - 85) + search_bar_maxwidth = search_bar_size_x - ( + 95 + if _uiscale() is babase.UIScale.SMALL + else 77 if _uiscale() is babase.UIScale.MEDIUM else 85 + ) self._filter_widget = bui.textwidget( parent=self._root_widget, text="", @@ -2562,7 +3176,7 @@ def draw_search_bar(self): scale=0.8, autoselect=True, maxwidth=search_bar_maxwidth, - description=filter_txt + description=filter_txt, ) self._last_filter_text = "" self._last_filter_plugins = [] @@ -2580,73 +3194,69 @@ async def process_search_term(self): continue try: await self.draw_plugin_names( - self.selected_category, search_term=filter_text.lower(), order=self.selected_alphabet_order) + self.selected_category, + search_term=filter_text.lower(), + order=self.selected_alphabet_order, + ) except CategoryDoesNotExist: pass def draw_settings_icon(self): - settings_pos_x = (610 if _uiscale() is babase.UIScale.SMALL else - 500 if _uiscale() is babase.UIScale.MEDIUM else 510) - settings_pos_y = (130 if _uiscale() is babase.UIScale.SMALL else - 60 if _uiscale() is babase.UIScale.MEDIUM else 70) + settings_pos_x = ( + 610 + if _uiscale() is babase.UIScale.SMALL + else 500 if _uiscale() is babase.UIScale.MEDIUM else 510 + ) + settings_pos_y = ( + 130 + if _uiscale() is babase.UIScale.SMALL + else 60 if _uiscale() is babase.UIScale.MEDIUM else 70 + ) controller_button = bui.buttonwidget( parent=self._root_widget, position=(settings_pos_x, settings_pos_y), size=(30, 30), - button_type="square", - label="", + button_type='square', + label='', left_widget=self._scrollwidget, - right_widget=bui.get_special_widget('squad_button') + right_widget=bui.get_special_widget('squad_button'), ) bui.buttonwidget( controller_button, on_activate_call=babase.CallPartial( - PluginManagerSettingsWindow, - self.plugin_manager, - controller_button - ) + PluginManagerSettingsWindow, self.plugin_manager, controller_button + ), ) bui.imagewidget( parent=self._root_widget, position=(settings_pos_x, settings_pos_y), size=(30, 30), color=(0.8, 0.95, 1), - texture=bui.gettexture("settingsIcon"), - draw_controller=controller_button + texture=bui.gettexture('settingsIcon'), + draw_controller=controller_button, ) def draw_refresh_icon(self): - refresh_pos_x = (610 if _uiscale() is babase.UIScale.SMALL else - 500 if _uiscale() is babase.UIScale.MEDIUM else 510) - refresh_pos_y = (180 if _uiscale() is babase.UIScale.SMALL else - 108 if _uiscale() is babase.UIScale.MEDIUM else 120) - - controller_button = bui.buttonwidget( - parent=self._root_widget, - position=(refresh_pos_x, refresh_pos_y), - size=(30, 30), - button_type="square", - label="", - on_activate_call=lambda: ( - bui.getsound('deek').play() or - loop.create_task(self.refresh()) - ), - enable_sound=False, - left_widget=self._scrollwidget, - right_widget=bui.get_special_widget('squad_button') + refresh_pos_x = ( + 610 + if _uiscale() is babase.UIScale.SMALL + else 500 if _uiscale() is babase.UIScale.MEDIUM else 510 ) - bui.widget( - self._scrollwidget, - right_widget=controller_button + refresh_pos_y = ( + 180 + if _uiscale() is babase.UIScale.SMALL + else 108 if _uiscale() is babase.UIScale.MEDIUM else 120 ) - bui.imagewidget( + + controller_button = UIHelpers.create_icon_button( parent=self._root_widget, position=(refresh_pos_x, refresh_pos_y), size=(30, 30), - color=(0.8, 0.95, 1), - texture=bui.gettexture("replayIcon"), - draw_controller=controller_button + texture='replayIcon', + on_activate=lambda: (bui.getsound('deek').play() or loop.create_task(self.refresh())), + icon_color=(0.8, 0.95, 1), ) + bui.widget(self._scrollwidget, right_widget=controller_button) def search_term_filterer(self, plugin, search_term): # This helps resolve "plugin name" to "plugin_name". @@ -2663,15 +3273,19 @@ def search_term_filterer(self, plugin, search_term): # XXX: Not sure if this is the best way to handle search filters. async def draw_plugin_names(self, category, search_term="", refresh=False, order='a_z'): # Re-draw plugin list UI if either search term or category was switched. - to_draw_plugin_names = (search_term, category) != (self._last_filter_text, - self.selected_category) + to_draw_plugin_names = (search_term, category) != ( + self._last_filter_text, + self.selected_category, + ) if not (to_draw_plugin_names or refresh): return try: if self.plugin_manager.categories != {}: if self.plugin_manager.categories['All'] is not None: - category_plugins = await self.plugin_manager.categories[category if category != 'Installed' else 'All'].get_plugins() + category_plugins = await self.plugin_manager.categories[ + category if category != 'Installed' else 'All' + ].get_plugins() else: return else: @@ -2684,15 +3298,18 @@ async def draw_plugin_names(self, category, search_term="", refresh=False, order return if search_term: - plugins = list(filter( - lambda plugin: self.search_term_filterer(plugin, search_term), - category_plugins, - )) + plugins = list( + filter( + lambda plugin: self.search_term_filterer(plugin, search_term), + category_plugins, + ) + ) else: plugins = category_plugins def return_name(val): return val.name + plugins.sort(key=return_name, reverse=(True if order == 'z_a' else False)) if plugins == self._last_filter_plugins and not refresh: @@ -2706,9 +3323,7 @@ def return_name(val): return if category == 'Installed': - plugin_names_to_draw = tuple( - plugin for plugin in plugins if plugin.is_installed - ) + plugin_names_to_draw = tuple(plugin for plugin in plugins if plugin.is_installed) else: plugin_names_to_draw = plugins @@ -2729,7 +3344,7 @@ def return_name(val): for i, plugin in enumerate(plugin_names_ready_to_draw): await self.draw_plugin_name(plugin, plugin_names_ready_to_draw) kids = self._columnwidget.get_children() - first_child, last_child = kids[::len(kids)-1] + first_child, last_child = kids[:: len(kids) - 1] bui.widget(first_child, up_widget=self._filter_widget) bui.widget(last_child, down_widget=self._back_button) @@ -2751,10 +3366,7 @@ async def draw_plugin_name(self, plugin, plugins_list): plugin_name_widget_to_update = self.plugins_in_current_view.get(plugin.name) if plugin_name_widget_to_update: - bui.textwidget( - edit=plugin_name_widget_to_update, - color=color - ) + bui.textwidget(edit=plugin_name_widget_to_update, color=color) else: text_widget = bui.textwidget( parent=self._columnwidget, @@ -2766,16 +3378,13 @@ async def draw_plugin_name(self, plugin, plugins_list): click_activate=True, h_align='left', v_align='center', - maxwidth=self.scrollx-10 + maxwidth=self.scrollx - 10, ) bui.textwidget( text_widget, on_activate_call=bui.CallPartial( - self.show_plugin_window, - plugin, - plugins_list, - text_widget - ) + self.show_plugin_window, plugin, plugins_list, text_widget + ), ) self.plugins_in_current_view[plugin.name] = text_widget # XXX: This seems nicer. Might wanna use this in future. @@ -2786,7 +3395,7 @@ def show_plugin_window(self, plugin, plugins_list, source): plugin, source, plugins_list=plugins_list, - button_callback=lambda: self.draw_plugin_name(plugin, plugins_list) + button_callback=lambda: self.draw_plugin_name(plugin, plugins_list), ) def show_categories_window(self, source): @@ -2801,7 +3410,11 @@ async def select_category(self, category): self.plugins_in_current_view.clear() self.draw_category_selection_button(post_label=category) await self.draw_plugin_names( - category, search_term=self._last_filter_text, refresh=True, order=self.selected_alphabet_order) + category, + search_term=self._last_filter_text, + refresh=True, + order=self.selected_alphabet_order, + ) self.selected_category = category def cleanup(self): @@ -2845,16 +3458,13 @@ def __init__(self, plugin_manager, origin_widget): async def draw_ui(self): b_text_color = (0.8, 0.8, 0.85) - s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.27 if _uiscale() is babase.UIScale.MEDIUM else 1.3 + s = UIConfig.scale_value(1.25, 1.27, 1.3) width = 380 * s height = 150 + 150 * s color = (0.9, 0.9, 0.9) # Subtracting the default bluish-purple color from the texture, so it's as close # as to white as possible. - discord_fg_color = (10 - 0.32, 10 - 0.39, 10 - 0.96) - discord_bg_color = (0.525, 0.595, 1.458) - github_bg_color = (0.23, 0.23, 0.23) text_scale = 0.7 * s self._transition_out = 'out_scale' transition = 'in_scale' @@ -2864,9 +3474,8 @@ async def draw_ui(self): size=(width, height), on_outside_click_call=self._ok, transition=transition, - scale=(2.1 if _uiscale() is babase.UIScale.SMALL else 1.5 - if _uiscale() is babase.UIScale.MEDIUM else 1.0), - scale_origin_stack_offset=self.scale_origin + scale=UIConfig.scale_value(2.1, 1.5, 1.0), + scale_origin_stack_offset=self.scale_origin, ) _add_popup(self) pos = height * 0.9 @@ -2880,7 +3489,7 @@ async def draw_ui(self): text=setting_title, scale=text_scale, color=bui.app.ui_v1.title_color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) pos -= 20 @@ -2890,7 +3499,7 @@ async def draw_ui(self): size=(80, 30), textcolor=b_text_color, button_type='square', - label='' + label='', ) bui.buttonwidget(b, on_activate_call=lambda: ChangelogWindow(b)) bui.textwidget( @@ -2914,7 +3523,7 @@ async def draw_ui(self): text_scale=1, scale=0, selectable=False, - label="Save" + label="Save", ) pos -= 40 @@ -2929,32 +3538,29 @@ async def draw_ui(self): on_value_change_call=babase.CallPartial(self.toggle_setting, setting), maxwidth=500, textcolor=(0.9, 0.9, 0.9), - scale=text_scale * 0.8 + scale=text_scale * 0.8, ) self.checkboxes.append(chk) pos -= 34 * text_scale - bui.widget( - self.checkboxes[0], - up_widget=self._changelog_button - ) + bui.widget(self.checkboxes[0], up_widget=self._changelog_button) bui.widget( self._changelog_button, down_widget=self.checkboxes[0], left_widget=self.checkboxes[0], - right_widget=self.checkboxes[0] + right_widget=self.checkboxes[0], ) pos = height - 200 bui.textwidget( parent=self._root_widget, - position=(width * 0.49, pos-5), + position=(width * 0.49, pos - 5), size=(0, 0), h_align='center', v_align='center', text='Contribute to plugins or to this community plugin manager!', scale=text_scale * 0.65, color=color, - maxwidth=width * 0.95 + maxwidth=width * 0.95, ) pos -= 75 @@ -2963,56 +3569,31 @@ async def draw_ui(self): except urllib.error.URLError: plugin_manager_update_available = False discord_width = (width * 0.20) if plugin_manager_update_available else (width * 0.31) - self.discord_button = bui.buttonwidget( + self.discord_button = UIHelpers.create_icon_button( parent=self._root_widget, position=(discord_width - button_size[0] / 2, pos), size=button_size, - on_activate_call=lambda: bui.open_url(DISCORD_URL), - textcolor=b_text_color, - color=discord_bg_color, - button_type='square', - text_scale=1, - label="", - down_widget=self._changelog_button - ) - bui.widget( - self._changelog_button, - up_widget=self.discord_button - ) - - bui.imagewidget( - parent=self._root_widget, - position=(discord_width+0.5 - button_size[0] / 2, pos), - size=button_size, - texture=bui.gettexture("discordLogo"), - color=discord_fg_color, - draw_controller=self.discord_button + texture='discordLogo', + on_activate=lambda: bui.open_url(DISCORD_URL), + btn_color=UIConfig.get_color('discord_bg'), + icon_color=UIConfig.get_color('discord_fg'), ) + bui.widget(self.discord_button, down_widget=self._changelog_button) + bui.widget(self._changelog_button, up_widget=self.discord_button) github_width = (width * 0.49) if plugin_manager_update_available else (width * 0.65) - self.github_button = bui.buttonwidget( + self.github_button = UIHelpers.create_icon_button( parent=self._root_widget, position=(github_width - button_size[0] / 2, pos), size=button_size, - on_activate_call=lambda: bui.open_url(REPOSITORY_URL), - textcolor=b_text_color, - color=github_bg_color, - button_type='square', - text_scale=1, - label='', - up_widget=self.checkboxes[-1], - down_widget=self._changelog_button + texture='githubLogo', + on_activate=lambda: bui.open_url(REPOSITORY_URL), + btn_color=UIConfig.get_color('github_bg'), + icon_color=UIConfig.get_color('github_fg'), ) - - bui.imagewidget( - parent=self._root_widget, - position=(github_width + 0.5 - button_size[0] / 2, pos), - size=button_size, - texture=bui.gettexture("githubLogo"), - color=(1, 1, 1), - draw_controller=self.github_button + bui.widget( + self.github_button, up_widget=self.checkboxes[-1], down_widget=self._changelog_button ) - bui.containerwidget(edit=self._root_widget, on_cancel_call=self._ok) try: @@ -3028,7 +3609,8 @@ async def draw_ui(self): position=((width * 0.77) - button_size[0] / 2, pos), size=button_size, on_activate_call=lambda: loop.create_task( - self.update(*plugin_manager_update_available)), + self.update(*plugin_manager_update_available) + ), textcolor=b_text_color, button_type='square', text_scale=1, @@ -3036,12 +3618,9 @@ async def draw_ui(self): label=update_button_label, up_widget=self.checkboxes[-1], down_widget=self._changelog_button, - right_widget=self.discord_button - ) - bui.widget( - self.discord_button, - left_widget=self._update_button + right_widget=self.discord_button, ) + bui.widget(self.discord_button, left_widget=self._update_button) self._restart_to_reload_changes_text = bui.textwidget( parent=self._root_widget, position=(width * 0.79, pos + 20), @@ -3051,7 +3630,7 @@ async def draw_ui(self): text='', scale=text_scale * 0.65, color=(0, 0.8, 0), - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) else: text_color = (0, 0.8, 0) @@ -3065,7 +3644,7 @@ async def draw_ui(self): text=f'Plugin Manager v{PLUGIN_MANAGER_VERSION}', scale=text_scale * 0.8, color=text_color, - maxwidth=width * 0.9 + maxwidth=width * 0.9, ) pos -= 25 bui.textwidget( @@ -3077,7 +3656,7 @@ async def draw_ui(self): text=f'API Version: {_app_api_version}', scale=text_scale * 0.7, color=(0.4, 0.8, 1), - maxwidth=width * 0.95 + maxwidth=width * 0.95, ) pos = height * 0.1 @@ -3085,33 +3664,18 @@ async def draw_ui(self): def toggle_setting(self, setting, set_value): self.settings[setting] = set_value check = self.settings == babase.app.config["Community Plugin Manager"]["Settings"] - bui.buttonwidget( - edit=self._save_button, - scale=0 if check else 1, - selectable=(not check) - ) + bui.buttonwidget(edit=self._save_button, scale=0 if check else 1, selectable=(not check)) bui.widget( self._changelog_button, - right_widget=( - check and self.checkboxes[0] - or self._save_button - ), - left_widget=( - check and self.checkboxes[0] - or self._save_button - ) + right_widget=(check and self.checkboxes[0] or self._save_button), + left_widget=(check and self.checkboxes[0] or self._save_button), ) - bui.widget( - self._update_button, - up_widget=( - check and self.checkboxes[0] - or self._save_button - ), - down_widget=( - check and self._changelog_button - or self._save_button + if hasattr(self, '_update_button'): + bui.widget( + self._update_button, + up_widget=(check and self.checkboxes[0] or self._save_button), + down_widget=(check and self._changelog_button or self._save_button), ) - ) def save_settings_button(self): babase.app.config["Community Plugin Manager"]["Settings"] = self.settings.copy() @@ -3131,7 +3695,7 @@ async def update(self, to_version=None, commit_sha=None): bui.getsound('shieldUp').play() bui.textwidget( edit=self._restart_to_reload_changes_text, - text='Update Applied!\nRestart game to reload changes.' + text='Update Applied!\nRestart game to reload changes.', ) self._update_button.delete() @@ -3168,15 +3732,11 @@ def __init__( for child in self._root_widget.get_children(): child.delete() - bui.containerwidget( - edit=self._root_widget, size=(width, height + top_extra) - ) + bui.containerwidget(edit=self._root_widget, size=(width, height + top_extra)) if uiscale is bui.UIScale.SMALL: self._back_button = None - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) + bui.containerwidget(edit=self._root_widget, on_cancel_call=self.main_window_back) else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, @@ -3213,9 +3773,7 @@ def __init__( v = height - 265 basew = 280 if uiscale is bui.UIScale.SMALL else 230 baseh = 170 - x_offs = ( - x_inset + (105 if uiscale is bui.UIScale.SMALL else 72) - basew - ) # now unused + x_offs = x_inset + (105 if uiscale is bui.UIScale.SMALL else 72) - basew # now unused x_dif = (basew - 7) / 2 x_offs2 = x_offs + basew - 7 x_offs3 = x_offs + 2 * (basew - 7) @@ -3226,9 +3784,7 @@ def __init__( x_offs3 -= x_dif x_offs4 -= x_dif - def _b_title( - x: float, y: float, button: bui.Widget, text: str | bui.Lstr - ) -> None: + def _b_title(x: float, y: float, button: bui.Widget, text: str | bui.Lstr) -> None: bui.textwidget( parent=self._root_widget, text=text, @@ -3253,9 +3809,7 @@ def _b_title( if self._back_button is None: bbtn = bui.get_special_widget('back_button') bui.widget(edit=ctb, left_widget=bbtn) - _b_title( - x_offs2, v, ctb, bui.Lstr(resource=f'{self._r}.controllersText') - ) + _b_title(x_offs2, v, ctb, bui.Lstr(resource=f'{self._r}.controllersText')) imgw = imgh = 130 bui.imagewidget( parent=self._root_widget, @@ -3339,7 +3893,7 @@ def _b_title( button_type='square', label='', up_widget=abtn, - on_activate_call=self._do_plugman + on_activate_call=self._do_plugman, ) _b_title(x_offs6, v, pmb, bui.Lstr(value="Plugin Manager")) imgw = imgh = 120 @@ -3349,7 +3903,7 @@ def _b_title( size=(imgw, imgh), color=(0.8, 0.95, 1), texture=bui.gettexture('storeIcon'), - draw_controller=pmb + draw_controller=pmb, ) self._restore_state() @@ -3358,11 +3912,7 @@ def _do_plugman(self) -> None: if not self.main_window_has_control(): return - self.main_window_replace( - lambda: PluginManagerWindow( - origin_widget=self._plugman_button - ) - ) + self.main_window_replace(lambda: PluginManagerWindow(origin_widget=self._plugman_button)) def _save_state(self) -> None: try: @@ -3389,9 +3939,7 @@ def _save_state(self) -> None: def _restore_state(self) -> None: try: assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) + sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get('sel_name') sel: bui.Widget | None if sel_name == 'Controllers': sel = self._controllers_button @@ -3418,6 +3966,7 @@ class EntryPoint(babase.Plugin): def on_app_running(self) -> None: """Called when the app is being launched.""" from bauiv1lib.settings import allsettings + allsettings.AllSettingsWindow = NewAllSettingsWindow DNSBlockWorkaround.apply() asyncio.set_event_loop(babase._asyncio._g_asyncio_event_loop) diff --git a/plugins/utilities/automated_ui_test.py b/plugins/utilities/automated_ui_test.py new file mode 100644 index 00000000..c421dc2e --- /dev/null +++ b/plugins/utilities/automated_ui_test.py @@ -0,0 +1,299 @@ +# ba_meta require api 9 + +import babase +import bauiv1 as bui +from bauiv1 import _automation as ui_automation + + +def _describe_widget(widget: bui.Widget) -> str: + try: + wid = str(widget.id) + except Exception: + wid = '' + try: + label = ui_automation._label_text(widget) + except Exception: + label = '' + return f'{type(widget).__name__} id={wid} label={label!r}' + + +def _is_widget_visible(widget: bui.Widget) -> bool: + """Check if a widget is visible and not hidden behind other windows.""" + # Try to find the root window this widget belongs to + try: + current = widget + while hasattr(current, 'parent') and current.parent is not None: + current = current.parent + except Exception: + pass + + return True + + +def _should_skip_widget(widget: bui.Widget) -> bool: + """Check if a widget should be skipped during the DFS test.""" + try: + wid = str(widget.id) + # Skip confirm/exit buttons that would terminate the test + if 'mainmenuwindow2' in wid.lower() or 'quit' in wid.lower(): + if '|' in wid: # Confirm dialog patterns like "confirm1|ok" + return True + except Exception: + pass + + # Skip widgets that don't have valid screen-space centers + try: + widget.get_screen_space_center() + except Exception: + return True + + # Skip widgets that are hidden behind other windows + if not _is_widget_visible(widget): + return True + + # Skip widgets that are not selectable + try: + if hasattr(widget, 'selectable'): + if not widget.selectable: + print( + f'Skipping widget {len(self._seen_widget_ids)}: {description} (Not selectable)') + return True + else: + print( + f'Widget {len(self._seen_widget_ids)}: {description} has no selectable attribute; assuming not selectable') + return True + except Exception: + pass + + return False + + +def _walk_widget_tree() -> list[object]: + roots: list[object] = [] + main_window = babase.app.ui_v1.get_main_window() + if main_window is not None: + try: + roots.append(main_window.get_root_widget()) + except Exception: + pass + + for name in ui_automation._SPECIAL_WIDGET_NAMES: + try: + special = ui_automation._bauiv1.get_special_widget(name) + except Exception: + special = None + if special is not None: + roots.append(special) + + out: list[object] = [] + seen: set[int] = set() + stack: list[object] = list(roots) + while stack: + widget = stack.pop() + if id(widget) in seen: + continue + seen.add(id(widget)) + out.append(widget) + try: + children = widget.get_children() + except Exception: + children = [] + stack.extend(children) + return out + + +# ba_meta export babase.Plugin +class AutomatedUITreeClickTest(babase.Plugin): + def __init__(self): + self._window = None + self._is_running = False + self._stack: list[object] = [] + self._seen_widget_ids: set[int] = set() + + def has_settings_ui(self) -> bool: + return True + + def show_settings_ui(self, source_widget=None) -> None: + self._window = bui.containerwidget( + size=(700, 320), + transition='in_right', + stack_offset=(0, 0), + ) + + bui.textwidget( + parent=self._window, + position=(40, 280), + size=(620, 40), + text='Automated UI widget tree clicker', + h_align='left', + v_align='center', + color=(1.0, 1.0, 1.0, 1.0), + maxwidth=620, + ) + bui.textwidget( + parent=self._window, + position=(40, 240), + size=(620, 40), + text=( + 'Builds the current widget tree and iteratively clicks each visible ' + 'widget to check for errors.' + ), + h_align='left', + v_align='top', + color=(0.9, 0.9, 0.9, 1.0), + maxwidth=620, + ) + + bui.buttonwidget( + parent=self._window, + position=(40, 170), + size=(300, 70), + label='Run widget click test', + on_activate_call=self._on_run_test, + ) + + bui.buttonwidget( + parent=self._window, + position=(360, 170), + size=(300, 70), + label='Dump widget tree', + on_activate_call=self._on_dump_tree, + ) + + close_button = bui.buttonwidget( + parent=self._window, + position=(250, 80), + size=(200, 70), + label='Close', + on_activate_call=lambda widget=None: bui.containerwidget( + edit=self._window, transition='out_right' + ), + ) + bui.containerwidget(edit=self._window, cancel_button=close_button) + + def on_app_running(self) -> None: + babase.apptimer(0.5, self._on_run_test) + + def _log(self, message: str) -> None: + print(f'[automated_ui_test] {message}') + babase.screenmessage(message) + + def _on_dump_tree(self, widget=None) -> None: + self._dump_widget_tree() + + def _on_run_test(self, widget=None) -> None: + if self._is_running: + self._log('Automated UI test is already running.') + return + + if not hasattr(ui_automation._badev, 'automation_press_at_virtual'): + self._log('Automation build is required to run this plugin.') + return + + self._seen_widget_ids.clear() + self._stack = list(reversed(_walk_widget_tree())) + if not self._stack: + self._log('No visible widgets found to click.') + return + + self._is_running = True + self._log(f'Starting automated DFS click test for {len(self._stack)} initial widgets...') + self._process_next_widget() + + def _process_next_widget(self) -> None: + while self._stack and id(self._stack[-1]) in self._seen_widget_ids: + self._stack.pop() + + if not self._stack: + self._is_running = False + self._log('Automated UI test complete.') + return + + widget = self._stack.pop() + widget_id = id(widget) + self._seen_widget_ids.add(widget_id) + + if _should_skip_widget(widget): + description = _describe_widget(widget) + self._log( + f'Skipping widget {len(self._seen_widget_ids)}: {description} (hidden or invalid)') + self._process_next_widget() + return + + description = _describe_widget(widget) + self._log(f'Clicking widget {len(self._seen_widget_ids)}: {description}') + try: + ui_automation._click_widget(widget, 'automated_ui_test', description) + except Exception as exc: + self._log(f'Error clicking widget {len(self._seen_widget_ids)}: {description} - {exc}') + + babase.apptimer(0.5, self._after_click) + + def _after_click(self) -> None: + current_widgets = _walk_widget_tree() + new_widgets = 0 + for widget in reversed(current_widgets): + widget_id = id(widget) + if widget_id in self._seen_widget_ids: + continue + if any(id(existing) == widget_id for existing in self._stack): + continue + self._stack.append(widget) + new_widgets += 1 + + if new_widgets: + self._log(f'Found {new_widgets} new widget(s) after click.') + self._process_next_widget() + + def _dump_widget_tree(self) -> None: + roots: list[object] = [] + main_window = babase.app.ui_v1.get_main_window() + if main_window is not None: + try: + roots.append(main_window.get_root_widget()) + except Exception: + pass + + for name in ui_automation._SPECIAL_WIDGET_NAMES: + try: + special = ui_automation._bauiv1.get_special_widget(name) + except Exception: + special = None + if special is not None: + roots.append(special) + + lines: list[str] = [] + seen: set[int] = set() + for root in roots: + self._append_widget_tree(root, 0, seen, lines) + + if not lines: + self._log('No widget tree roots found.') + return + + self._log(f'Widget tree contains {len(lines)} entries. See console for full dump.') + print('[automated_ui_test] widget tree dump:') + print('\n'.join(lines)) + + def _append_widget_tree( + self, + widget: bui.Widget, + depth: int, + seen: set[int], + lines: list[str], + ) -> None: + if id(widget) in seen: + return + seen.add(id(widget)) + try: + entry = ' ' * depth + _describe_widget(widget) + except Exception: + entry = ' ' * depth + f'{type(widget).__name__} ' + lines.append(entry) + + try: + children = widget.get_children() + except Exception: + children = [] + for child in children: + self._append_widget_tree(child, depth + 1, seen, lines)