bombsquad-plugin-manager/plugin_manager.py

3599 lines
128 KiB
Python
Raw Normal View History

2025-01-13 17:04:48 +05:30
# ba_meta require api 9
2023-05-17 02:57:02 +05:30
import babase
2025-01-14 16:49:58 +05:30
import bauiv1 as bui
2023-06-08 21:24:02 +05:30
from bauiv1lib import popup, confirm
2025-01-14 16:49:58 +05:30
from bauiv1lib.settings.allsettings import AllSettingsWindow
import urllib.error
import urllib.request
2022-12-17 20:10:00 +05:30
import http.client
import socket
import json
2022-12-17 20:10:00 +05:30
2025-01-14 16:49:58 +05:30
import re
2022-07-31 21:40:54 +05:30
import os
2025-01-14 16:49:58 +05:30
import copy
import asyncio
import pathlib
2022-08-21 04:28:35 +05:30
import hashlib
import weakref
import threading
2025-01-14 16:49:58 +05:30
import contextlib
import concurrent.futures
from typing import override
from datetime import datetime
2023-05-17 02:57:02 +05:30
# Modules used for overriding AllSettingsWindow
import logging
2026-08-23 17:53:35 +05:30
PLUGIN_MANAGER_VERSION = "1.1.13"
2022-11-30 18:31:00 +05:30
REPOSITORY_URL = "https://github.com/bombsquad-community/plugin-manager"
2023-04-30 22:33:01 +05:30
# Current tag can be changed to "staging" or any other branch in
# plugin manager repo for testing purpose.
2022-08-14 07:57:03 +05:30
CURRENT_TAG = "main"
_env = babase.env()
_app_api_version = babase.app.env.api_version
2026-02-15 20:06:59 +05:30
PLUGIN_ISSUE_TEMPLATE = "plugin-bug-report.yml"
GITHUB_PLUGIN_ISSUE_META = "{repository_url}/issues/new?template={issue_template}"
2022-08-14 07:57:03 +05:30
INDEX_META = "{repository_url}/{content_type}/{tag}/index.json"
2024-04-22 21:31:22 +05:30
CHANGELOG_META = "{repository_url}/{content_type}/{tag}/CHANGELOG.md"
HEADERS = {
2023-06-21 00:33:31 +05:30
"User-Agent": _env["legacy_user_agent_string"],
}
2022-07-31 21:40:54 +05:30
PLUGIN_DIRECTORY = _env["python_directory_user"]
2026-02-23 17:29:39 +05:30
NETWORK_REQUEST_TIMEOUT = 10 # seconds
2026-02-23 17:29:39 +05:30
loop = babase.app.asyncio_loop
pool = babase.app.threadpool
2023-10-02 22:28:17 +00:00
# babase.app.threadpool (aka `pool`, also wired up as the asyncio loop's
# default executor) is reserved for short parallel work and warns/starves
# on long-running tasks. Network requests routinely run past that, so they
# get their own small dedicated pool instead.
_network_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=4,
thread_name_prefix="PluginManagerNetwork",
)
async def _shutdown_network_pool() -> None:
"""Drain _network_pool's workers so none outlive app shutdown.
ThreadPoolExecutor.shutdown(wait=True) blocks, so it's run on a
throwaway thread and awaited from here rather than blocking the
event loop (other shutdown tasks run concurrently with this one).
In-flight requests are bounded by NETWORK_REQUEST_TIMEOUT, so this
settles quickly.
"""
done = asyncio.Event()
def _join() -> None:
_network_pool.shutdown(wait=True, cancel_futures=True)
loop.call_soon_threadsafe(done.set)
threading.Thread(target=_join, daemon=True).start()
await done.wait()
open_popups = []
2025-08-06 19:56:08 +00:00
def _add_popup(popup):
open_popups.append(popup)
2025-08-06 19:56:08 +00:00
2025-08-06 12:26:16 +00:00
def _remove_popup(popup):
try:
open_popups.remove(popup)
except ValueError:
pass
2025-08-06 19:56:08 +00:00
2025-08-04 02:51:27 +05:30
def _uiscale(): return bui.app.ui_v1.uiscale
2023-06-20 19:26:47 +00:00
def _regexp_friendly_class_name_shortcut(string): return string.replace(".", "\\.")
2023-05-16 23:39:05 +00:00
def _by_scale(a, b, c):
2026-01-26 16:05:30 +05:30
u = _uiscale()
return (
2026-01-26 16:05:30 +05:30
a if u is babase.UIScale.SMALL else
b if u is babase.UIScale.MEDIUM else
c
)
2026-08-23 17:53:35 +05:30
def _wrap_markdown_bullets(source_lines, max_width, scale):
"""Reflow markdown bullets so every rendered line fits `max_width`.
CHANGELOG.md hard-wraps its bullets across several source lines, so the
lines are first stitched back into whole bullets and then re-wrapped to
the width we actually have. Drawing the source lines as-is left each one
a different length, and since a text widget shrinks itself to its own
maxwidth, long lines rendered visibly smaller than short ones.
"""
bullets = []
for line in source_lines:
stripped = line.strip()
if not stripped:
continue
if stripped.startswith(("-", "*")) or not bullets:
bullets.append(stripped.lstrip("-*").strip())
else:
# A continuation of the bullet above it.
bullets[-1] += " " + stripped
lines = []
for bullet in bullets:
prefix, current = "- ", ""
for word in bullet.split():
candidate = f"{current} {word}" if current else word
width = bui.get_string_width(prefix + candidate, suppress_warning=True)
if current and width * scale > max_width:
lines.append(prefix + current)
# Indent wrapped remainders under the bullet's text.
prefix, current = " ", word
else:
current = candidate
lines.append(prefix + current)
return lines
2022-08-08 03:36:49 +05:30
REGEXP = {
"plugin_api_version": re.compile(b"(?<=ba_meta require api )(.*)"),
2023-05-17 05:08:21 +05:30
"plugin_entry_points": re.compile(
bytes(
"(ba_meta export (plugin|{})\n+class )(.*)\\(".format(
_regexp_friendly_class_name_shortcut("babase.Plugin"),
2023-05-17 05:08:21 +05:30
),
"utf-8"
),
),
"minigames": re.compile(
bytes(
2023-06-08 21:24:02 +05:30
"(ba_meta export ({})\n+class )(.*)\\(".format(
_regexp_friendly_class_name_shortcut("bascenev1.GameActivity"),
2023-05-17 05:08:21 +05:30
),
"utf-8"
),
),
2022-08-08 03:36:49 +05:30
}
2022-11-30 18:31:00 +05:30
DISCORD_URL = "https://ballistica.net/discord"
_CACHE = {}
2022-08-06 01:01:55 +05:30
class MD5CheckSumFailed(Exception):
2022-08-27 19:30:21 +05:30
pass
class PluginNotInstalled(Exception):
2022-08-27 19:30:21 +05:30
pass
class CategoryDoesNotExist(Exception):
2022-08-27 19:30:21 +05:30
pass
class NoCompatibleVersion(Exception):
pass
class PluginSourceNetworkError(Exception):
pass
class CategoryMetadataParseError(Exception):
2022-08-28 23:09:09 +05:30
pass
2026-08-23 17:33:02 +05:30
@contextlib.contextmanager
def network_errors_as_urlerror():
"""Normalize network failures into urllib.error.URLError.
Every caller in here reports connectivity problems by catching
URLError, but urllib only guarantees that shape while it is building
the request. Once urlopen() has returned, a socket timeout surfaces
as a bare TimeoutError and a truncated/dropped response as an
http.client.HTTPException, both of which sail past those handlers.
Since NETWORK_REQUEST_TIMEOUT made timeouts reachable at all, wrap
the whole fetch so callers only ever have one exception to catch.
"""
try:
yield
except urllib.error.URLError:
# Includes HTTPError; already the shape callers expect.
raise
except (TimeoutError, http.client.HTTPException) as e:
raise urllib.error.URLError(e) from e
2022-08-08 03:36:49 +05:30
def send_network_request(request):
2026-08-23 17:33:02 +05:30
"""Fetch `request` and return its full body as bytes.
The body is read here rather than handed back unread, because reading
it is itself a blocking call that can time out, and callers await this
from the event loop thread, where doing so would both stall the game
and raise outside network_errors_as_urlerror()'s reach.
"""
with network_errors_as_urlerror():
with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response:
return response.read()
2022-08-08 03:36:49 +05:30
async def async_send_network_request(request):
2026-08-23 17:33:02 +05:30
content = await loop.run_in_executor(_network_pool, send_network_request, request)
return content
2022-08-27 18:39:43 +05:30
def stream_network_response_to_file(request, file, md5sum=None, retries=3):
2022-08-08 03:36:49 +05:30
chunk_size = 16 * 1024
content = b""
2026-08-23 17:33:02 +05:30
with network_errors_as_urlerror():
with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response:
with open(file, "wb") as fout:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
fout.write(chunk)
content += chunk
if md5sum and hashlib.md5(content).hexdigest() != md5sum:
if retries <= 0:
raise MD5CheckSumFailed("MD5 checksum match failed.")
2022-08-27 18:39:43 +05:30
return stream_network_response_to_file(
request,
file,
md5sum=md5sum,
retries=retries-1,
)
2022-08-08 03:36:49 +05:30
return content
2022-08-27 18:39:43 +05:30
async def async_stream_network_response_to_file(request, file, md5sum=None, retries=3):
2023-10-03 08:20:09 +00:00
2022-08-27 18:39:43 +05:30
content = await loop.run_in_executor(
_network_pool,
2022-08-27 18:39:43 +05:30
stream_network_response_to_file,
request,
file,
md5sum,
retries,
)
return content
2022-08-14 00:42:40 +05:30
def partial_format(string_template, **kwargs):
2022-08-14 07:57:03 +05:30
for key, value in kwargs.items():
string_template = string_template.replace("{" + key + "}", value)
return string_template
2022-08-14 00:42:40 +05:30
2022-12-17 20:10:00 +05:30
class DNSBlockWorkaround:
"""
Some ISPs put a DNS block on domains that are needed for plugin manager to
work properly. This class stores methods to workaround such blocks by adding
dns.google as a fallback.
Such as Jio, a pretty popular ISP in India has a DNS block on
raw.githubusercontent.com (sigh..).
References:
* https://github.com/orgs/community/discussions/42655
2022-12-17 20:10:00 +05:30
Usage:
-----
>>> import urllib.request
>>> import socket
>>> import json
>>> DNSBlockWorkaround.apply()
>>> response = urllib.request.urlopen("https://dnsblockeddomain.com/path/to/resource/")
"""
2026-08-23 17:33:02 +05:30
# Hostnames worth second-guessing the system resolver on. Keeping this
# explicit is what lets apply() patch a process-global function safely:
# every other name the game looks up takes a set membership test and is
# then handed straight to the original resolver.
_blockable_hosts = frozenset(("raw.githubusercontent.com",))
# Maps a hostname to the addresses to dial for it, or to None when the
# host resolves normally and needs no workaround at all. Only populated
# for hosts we've already checked, so the check happens once per session.
_resolution_cache = {}
_original_getaddrinfo = None
2022-12-17 20:10:00 +05:30
@classmethod
def apply(cls):
2026-08-23 17:33:02 +05:30
"""Correct socket.getaddrinfo() instead of rebuilding the HTTP stack.
The block only ever corrupts one thing, the answer the resolver
hands back, so that is the only thing worth replacing. Fixing it
here leaves urllib, http.client and ssl completely stock:
socket.create_connection() still does its own address walking,
IPv6 handling and error aggregation, and TLS still verifies
against the hostname rather than whatever address we dialed.
"""
if cls._original_getaddrinfo is not None:
# Already applied. Patching again would nest the wrapper.
return
cls._original_getaddrinfo = staticmethod(socket.getaddrinfo)
socket.getaddrinfo = cls._getaddrinfo
@classmethod
def _getaddrinfo(cls, host, port, family=0, type=0, proto=0, flags=0):
# Signature and argument names mirror socket.getaddrinfo(), which
# callers pass both positionally and by keyword.
if host in cls._blockable_hosts:
addresses = cls._addresses_to_dial(host)
if addresses is not None:
# Re-resolving each literal address is just parsing; it
# builds the 5-tuples callers expect without a lookup.
return [
addrinfo
for address in addresses
for addrinfo in cls._original_getaddrinfo(
address, port, family, type, proto, flags)
]
return cls._original_getaddrinfo(host, port, family, type, proto, flags)
2022-12-17 20:10:00 +05:30
@classmethod
def _resolve_using_google_dns(cls, hostname):
response = urllib.request.urlopen(
f"https://dns.google/resolve?name={hostname}",
timeout=NETWORK_REQUEST_TIMEOUT,
)
2022-12-17 20:10:00 +05:30
response = response.read()
response = json.loads(response)
2026-08-23 17:33:02 +05:30
# Answers can include CNAME records (type 5) alongside the A (1) and
# AAAA (28) records; only the latter are dialable.
return [answer["data"] for answer in response.get("Answer", ())
if answer.get("type") in (1, 28)]
2022-12-17 20:10:00 +05:30
@classmethod
def _resolve_using_system_dns(cls, hostname):
2022-12-17 20:10:00 +05:30
resolved_host = socket.gethostbyname(hostname)
return resolved_host
@classmethod
2026-08-23 17:33:02 +05:30
def _addresses_to_dial(cls, hostname):
"""Addresses to substitute for `hostname`, or None to leave it alone.
Returning None is the common case and means the system resolver's
answer stands. Note that both branches yield *every* usable address
rather than one: socket.create_connection() walks the list until
something answers, and raw.githubusercontent.com publishes four A
records whose edge nodes are not all reachable from every network,
so collapsing to a single address would fail a share of requests
outright.
"""
if hostname in cls._resolution_cache:
return cls._resolution_cache[hostname]
2022-12-17 20:10:00 +05:30
2026-08-23 17:33:02 +05:30
try:
resolved_host_by_system_dns = cls._resolve_using_system_dns(hostname)
except socket.gaierror:
# A block that answers with NXDOMAIN rather than a bogus address.
addresses = cls._resolve_using_google_dns(hostname) or None
2022-12-17 20:10:00 +05:30
else:
2026-08-23 17:33:02 +05:30
if cls._is_blocked(hostname, resolved_host_by_system_dns):
addresses = cls._resolve_using_google_dns(hostname) or None
else:
addresses = None
2022-12-17 20:10:00 +05:30
2026-08-23 17:33:02 +05:30
cls._resolution_cache[hostname] = addresses
return addresses
2022-12-17 20:10:00 +05:30
@classmethod
def _is_blocked(cls, hostname, address):
2022-12-17 20:10:00 +05:30
is_blocked = False
if hostname == "raw.githubusercontent.com":
# Jio's DNS server may be blocking it.
is_blocked = address.startswith("49.44.")
return is_blocked
class StartupTasks:
def __init__(self):
self.plugin_manager = PluginManager()
def setup_config(self):
2022-08-24 16:18:12 +05:30
# is_config_updated = False
existing_plugin_manager_config = copy.deepcopy(
2023-05-17 02:57:02 +05:30
babase.app.config.get("Community Plugin Manager"))
2023-05-17 02:57:02 +05:30
plugin_manager_config = babase.app.config.setdefault("Community Plugin Manager", {})
plugin_manager_config.setdefault("Custom Sources", [])
installed_plugins = plugin_manager_config.setdefault("Installed Plugins", {})
for plugin_name in tuple(installed_plugins.keys()):
plugin = PluginLocal(plugin_name)
if not plugin.is_installed:
del installed_plugins[plugin_name]
# This order is the options will show up in Settings window.
current_settings = {
"Auto Update Plugin Manager": True,
"Auto Update Plugins": True,
"Auto Enable Plugins After Installation": True,
2022-10-03 20:20:33 +05:30
"Notify New Plugins": True
}
settings = plugin_manager_config.setdefault("Settings", {})
for setting, value in settings.items():
if setting in current_settings:
current_settings[setting] = value
plugin_manager_config["Settings"] = current_settings
if plugin_manager_config != existing_plugin_manager_config:
2023-05-17 02:57:02 +05:30
babase.app.config.commit()
async def update_plugin_manager(self):
2023-05-17 02:57:02 +05:30
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:
to_version, commit_sha = update_details
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"Plugin Manager is being updated to v{to_version}")
2022-08-29 18:41:17 +05:30
try:
await self.plugin_manager.update(to_version, commit_sha)
except MD5CheckSumFailed:
2023-05-17 02:57:02 +05:30
bui.getsound('error').play()
2022-08-29 18:41:17 +05:30
else:
2023-05-17 02:57:02 +05:30
bui.screenmessage("Update successful. Restart game to reload changes.",
color=(0, 1, 0))
bui.getsound('shieldUp').play()
async def update_plugins(self):
2023-05-17 02:57:02 +05:30
if not babase.app.config["Community Plugin Manager"]["Settings"]["Auto Update Plugins"]:
2022-08-21 06:01:15 +05:30
return
await self.plugin_manager.setup_index()
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():
2022-08-21 06:01:15 +05:30
plugins_to_update.append(plugin.update())
await asyncio.gather(*plugins_to_update)
@staticmethod
def _is_new_supported_plugin(plugin):
is_an_update = len(plugin.versions) > 1
if is_an_update:
return False
try:
plugin.latest_compatible_version
except NoCompatibleVersion:
return False
else:
return True
2022-10-03 20:20:33 +05:30
async def notify_new_plugins(self):
2023-05-17 02:57:02 +05:30
if not babase.app.config["Community Plugin Manager"]["Settings"]["Notify New Plugins"]:
2022-10-03 20:20:33 +05:30
return
show_max_names = 2
2022-10-03 20:20:33 +05:30
await self.plugin_manager.setup_index()
new_num_of_plugins = len(await self.plugin_manager.categories["All"].get_plugins())
2022-10-03 20:20:33 +05:30
try:
2023-05-17 02:57:02 +05:30
existing_num_of_plugins = babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"]
except KeyError:
2023-05-17 02:57:02 +05:30
babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"] = new_num_of_plugins
babase.app.config.commit()
2022-10-03 20:20:33 +05:30
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()
new_supported_plugins = list(filter(self._is_new_supported_plugin, all_plugins))
new_supported_plugins.sort(
key=lambda plugin: plugin.latest_compatible_version.released_on_date,
reverse=True,
)
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
2024-05-20 21:33:08 +00:00
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"
2023-05-17 02:57:02 +05:30
bui.screenmessage(notification_text, color=(0, 1, 0))
if existing_num_of_plugins != new_num_of_plugins:
2023-05-17 02:57:02 +05:30
babase.app.config["Community Plugin Manager"]["Existing Number of Plugins"] = new_num_of_plugins
babase.app.config.commit()
2022-10-03 20:20:33 +05:30
async def execute(self):
self.setup_config()
try:
await asyncio.gather(
self.update_plugin_manager(),
self.update_plugins(),
2022-10-03 20:20:33 +05:30
self.notify_new_plugins(),
)
except urllib.error.URLError:
pass
class Category:
def __init__(self, meta_url, tag=CURRENT_TAG):
self.meta_url = meta_url
self.tag = tag
2022-08-08 03:36:49 +05:30
self.request_headers = HEADERS
2022-08-10 01:02:10 +05:30
self._metadata = _CACHE.get("categories", {}).get(meta_url, {}).get("metadata")
self._plugins = _CACHE.get("categories", {}).get(meta_url, {}).get("plugins")
2022-08-10 01:02:10 +05:30
async def fetch_metadata(self):
if self._metadata is None:
2023-04-30 22:33:01 +05:30
# Let's keep depending on the "main" branch for 3rd party sources
# even if we're using a different branch of plugin manager's repository.
request = urllib.request.Request(
self.meta_url.format(content_type="raw", tag=self.tag),
2022-08-08 03:36:49 +05:30
headers=self.request_headers,
)
2026-08-23 17:33:02 +05:30
content = await async_send_network_request(request)
self._metadata = json.loads(content)
2022-08-10 01:02:10 +05:30
self.set_category_global_cache("metadata", self._metadata)
return self
async def validate(self):
2022-08-29 18:41:17 +05:30
try:
await self.fetch_metadata()
except urllib.error.HTTPError as e:
raise PluginSourceNetworkError(str(e))
except json.decoder.JSONDecodeError as e:
raise CategoryMetadataParseError(f"Failed to parse JSON: {str(e)}")
try:
await asyncio.gather(
self.get_name(),
self.get_description(),
self.get_plugins_base_url(),
self.get_plugins(),
)
except KeyError:
raise CategoryMetadataParseError("Failed to parse JSON; missing required fields.")
else:
return True
2022-08-10 01:02:10 +05:30
async def get_name(self):
await self.fetch_metadata()
2022-08-10 01:02:10 +05:30
return self._metadata["name"]
async def get_description(self):
await self.fetch_metadata()
2022-08-10 01:02:10 +05:30
return self._metadata["description"]
async def get_plugins_base_url(self):
await self.fetch_metadata()
2022-08-10 01:02:10 +05:30
return self._metadata["plugins_base_url"]
async def get_plugins(self):
if self._plugins is None:
await self.fetch_metadata()
2022-08-10 01:02:10 +05:30
self._plugins = ([
Plugin(
plugin_info,
f"{await self.get_plugins_base_url()}/{plugin_info[0]}.py",
tag=self.tag,
)
2022-08-10 01:02:10 +05:30
for plugin_info in self._metadata["plugins"].items()
])
self.set_category_global_cache("plugins", self._plugins)
return self._plugins
2022-08-10 01:02:10 +05:30
def set_category_global_cache(self, key, value):
2022-08-06 01:01:55 +05:30
if "categories" not in _CACHE:
_CACHE["categories"] = {}
2022-08-10 01:02:10 +05:30
if self.meta_url not in _CACHE["categories"]:
_CACHE["categories"][self.meta_url] = {}
_CACHE["categories"][self.meta_url][key] = value
2022-08-06 01:01:55 +05:30
2022-08-10 01:02:10 +05:30
def unset_category_global_cache(self):
2022-08-06 01:01:55 +05:30
try:
2022-08-10 01:02:10 +05:30
del _CACHE["categories"][self.meta_url]
2022-08-06 01:01:55 +05:30
except KeyError:
pass
2022-08-10 01:02:10 +05:30
def cleanup(self):
self._metadata = None
self._plugins.clear()
self.unset_category_global_cache()
2022-08-21 01:33:25 +05:30
async def refresh(self):
self.cleanup()
await self.get_plugins()
def save(self):
2023-05-17 02:57:02 +05:30
babase.app.config["Community Plugin Manager"]["Custom Sources"].append(self.meta_url)
babase.app.config.commit()
class CategoryAll(Category):
def __init__(self, plugins={}):
2022-08-10 01:02:10 +05:30
super().__init__(meta_url=None)
self._name = "All"
self._description = "All plugins"
self._plugins = plugins
2022-08-05 22:43:07 +05:30
class PluginLocal:
def __init__(self, name):
"""
Initialize a plugin locally installed on the device.
"""
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):
2022-08-08 03:36:49 +05:30
self._content = None
self._api_version = None
self._entry_points = []
2022-08-10 01:02:10 +05:30
self._has_minigames = None
2022-08-05 22:43:07 +05:30
@property
def is_installed(self):
return os.path.isfile(self.install_path)
@property
def is_installed_via_plugin_manager(self):
2023-05-17 02:57:02 +05:30
return self.name in babase.app.config["Community Plugin Manager"]["Installed Plugins"]
2022-08-05 22:43:07 +05:30
def initialize(self):
2023-05-17 02:57:02 +05:30
if self.name not in babase.app.config["Community Plugin Manager"]["Installed Plugins"]:
babase.app.config["Community Plugin Manager"]["Installed Plugins"][self.name] = {}
2022-08-05 22:43:07 +05:30
return self
async def uninstall(self):
if await self.has_minigames():
self.unload_minigames()
2022-08-05 22:43:07 +05:30
try:
os.remove(self.install_path)
except FileNotFoundError:
pass
try:
2023-05-17 02:57:02 +05:30
del babase.app.config["Community Plugin Manager"]["Installed Plugins"][self.name]
2022-08-05 22:43:07 +05:30
except KeyError:
pass
else:
self.save()
2022-08-05 22:43:07 +05:30
@property
def version(self):
try:
2023-05-17 02:57:02 +05:30
version = (babase.app.config["Community Plugin Manager"]
2022-08-07 00:52:33 +05:30
["Installed Plugins"][self.name]["version"])
2022-08-05 22:43:07 +05:30
except KeyError:
version = None
return version
2022-08-08 03:36:49 +05:30
def _get_content(self):
with open(self.install_path, "rb") as fin:
return fin.read()
def _set_content(self, content):
with open(self.install_path, "wb") as fout:
fout.write(content)
def has_settings(self):
for plugin_entry_point, plugin_spec in bui.app.plugins.plugin_specs.items():
if plugin_entry_point.startswith(self._entry_point_initials):
return plugin_spec.plugin.has_settings_ui()
def launch_settings(self, source_widget):
for plugin_entry_point, plugin_spec in bui.app.plugins.plugin_specs.items():
if plugin_entry_point.startswith(self._entry_point_initials):
return plugin_spec.plugin.show_settings_ui(source_widget)
2022-08-08 03:36:49 +05:30
async def get_content(self):
if self._content is None:
if not self.is_installed:
raise PluginNotInstalled("Plugin is not available locally.")
2023-10-03 08:20:09 +00:00
self._content = await loop.run_in_executor(pool, self._get_content)
2022-08-08 03:36:49 +05:30
return self._content
async def get_api_version(self):
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):
if not self._entry_points:
content = await self.get_content()
groups = REGEXP["plugin_entry_points"].findall(content)
2023-05-17 05:08:21 +05:30
# Actual entry points are stored in the last index inside the matching groups.
entry_points = tuple(f"{self.name}.{group[-1].decode('utf-8')}" for group in groups)
2022-08-08 03:36:49 +05:30
self._entry_points = entry_points
return self._entry_points
async def has_minigames(self):
2022-08-10 01:02:10 +05:30
if self._has_minigames is None:
content = await self.get_content()
2022-08-10 01:02:10 +05:30
self._has_minigames = REGEXP["minigames"].search(content) is not None
return self._has_minigames
2022-09-05 00:27:45 +05:30
async def has_plugins(self):
entry_points = await self.get_entry_points()
return len(entry_points) > 0
def load_minigames(self):
2023-05-17 02:57:02 +05:30
scanner = babase._meta.DirectoryScan(paths="")
directory, module = self.install_path.rsplit(os.path.sep, 1)
scanner._scan_module(
pathlib.Path(directory),
pathlib.Path(module),
)
scanned_results = set(babase.app.meta.scanresults.exports["bascenev1.GameActivity"])
for game in scanner.results.exports["bascenev1.GameActivity"]:
if game not in scanned_results:
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{game} minigame loaded")
babase.app.meta.scanresults.exports["bascenev1.GameActivity"].append(game)
def unload_minigames(self):
2023-05-17 02:57:02 +05:30
scanner = babase._meta.DirectoryScan(paths="")
directory, module = self.install_path.rsplit(os.path.sep, 1)
scanner._scan_module(
pathlib.Path(directory),
pathlib.Path(module),
)
new_scanned_results_games = []
for game in babase.app.meta.scanresults.exports["bascenev1.GameActivity"]:
if game in scanner.results.exports["bascenev1.GameActivity"]:
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{game} minigame unloaded")
2022-08-21 06:01:15 +05:30
else:
new_scanned_results_games.append(game)
babase.app.meta.scanresults.exports["bascenev1.GameActivity"] = new_scanned_results_games
async def is_enabled(self):
2022-08-08 03:36:49 +05:30
"""
Return True even if a single entry point is enabled or contains minigames.
2022-08-08 03:36:49 +05:30
"""
2022-09-05 00:27:45 +05:30
if not await self.has_plugins():
return True
2023-05-17 02:57:02 +05:30
for entry_point, plugin_info in babase.app.config["Plugins"].items():
if entry_point.startswith(self._entry_point_initials) and plugin_info["enabled"]:
2022-08-08 03:36:49 +05:30
return True
2022-09-05 00:27:45 +05:30
return False
2022-08-08 03:36:49 +05:30
async def enable(self):
for entry_point in await self.get_entry_points():
2023-05-17 02:57:02 +05:30
if entry_point not in babase.app.config["Plugins"]:
babase.app.config["Plugins"][entry_point] = {}
babase.app.config["Plugins"][entry_point]["enabled"] = True
plugin_spec = bui.app.plugins.plugin_specs.get(entry_point)
if plugin_spec not in bui.app.plugins.active_plugins:
self.load_plugin(entry_point)
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{entry_point} loaded")
if await self.has_minigames():
self.load_minigames()
self.save()
2022-08-08 03:36:49 +05:30
def load_plugin(self, entry_point):
plugin_class = babase.getclass(entry_point, babase.Plugin)
loaded_plugin_instance = plugin_class()
loaded_plugin_instance.on_app_running()
plugin_spec = babase.PluginSpec(class_path=entry_point, loadable=True)
plugin_spec.enabled = True
plugin_spec.plugin = loaded_plugin_instance
bui.app.plugins.plugin_specs[entry_point] = plugin_spec
bui.app.plugins.active_plugins.append(plugin_spec.plugin)
def disable(self):
2023-05-17 02:57:02 +05:30
for entry_point, plugin_info in babase.app.config["Plugins"].items():
if entry_point.startswith(self._entry_point_initials):
plugin_info["enabled"] = False
self.save()
2022-08-08 03:36:49 +05:30
2022-08-05 22:43:07 +05:30
def set_version(self, version):
2023-05-17 02:57:02 +05:30
app = babase.app
2022-08-24 16:03:15 +05:30
app.config["Community Plugin Manager"]["Installed Plugins"][self.name]["version"] = version
2022-08-08 03:36:49 +05:30
return self
async def set_content(self, content):
2022-08-21 04:28:35 +05:30
if not self._content:
2023-10-03 08:20:09 +00:00
await loop.run_in_executor(pool, self._set_content, content)
2022-08-21 04:28:35 +05:30
self._content = content
2022-08-08 03:36:49 +05:30
return self
2022-08-27 18:39:43 +05:30
async def set_content_from_network_response(self, request, md5sum=None, retries=3):
2022-08-21 04:28:35 +05:30
if not self._content:
self._content = await async_stream_network_response_to_file(
request,
self.install_path,
md5sum=md5sum,
2022-08-27 18:39:43 +05:30
retries=retries,
)
2022-08-21 04:28:35 +05:30
return self._content
2022-08-05 22:43:07 +05:30
def save(self):
2023-05-17 02:57:02 +05:30
babase.app.config.commit()
2022-08-05 22:43:07 +05:30
return self
2022-08-08 03:36:49 +05:30
class PluginVersion:
def __init__(self, plugin, version, tag=CURRENT_TAG):
2022-08-17 02:07:08 +05:30
self.number, info = version
# Plugin already owns its PluginVersions (via `versions`,
# `latest_version`, `latest_compatible_version`); holding a strong
# back-reference here would form a Plugin<->PluginVersion cycle
# that only the cyclic GC can free. A weakref avoids that so
# they're freed by refcounting alone.
self._plugin_ref = weakref.ref(plugin)
2022-08-17 02:07:08 +05:30
self.api_version = info["api_version"]
self.released_on = info["released_on"]
2022-08-17 02:07:08 +05:30
self.commit_sha = info["commit_sha"]
self.md5sum = info["md5sum"]
self.download_url = plugin.url.format(content_type="raw", tag=tag)
self.view_url = plugin.url.format(content_type="blob", tag=tag)
@property
def plugin(self):
return self._plugin_ref()
2022-08-17 02:07:08 +05:30
def __eq__(self, plugin_version):
2022-08-24 16:03:15 +05:30
return (self.number, self.plugin.name) == (plugin_version.number,
plugin_version.plugin.name)
2022-08-17 02:07:08 +05:30
def __repr__(self):
2022-08-21 01:33:25 +05:30
return f"<PluginVersion({self.plugin.name} {self.number})>"
2022-08-17 02:07:08 +05:30
@property
def released_on_date(self):
return datetime.strptime(self.released_on, "%d-%m-%Y")
2022-08-21 04:28:35 +05:30
async def _download(self, retries=3):
2022-08-17 02:07:08 +05:30
local_plugin = self.plugin.create_local()
2022-08-27 18:39:43 +05:30
await local_plugin.set_content_from_network_response(
self.download_url,
md5sum=self.md5sum,
retries=retries,
)
2022-08-17 02:07:08 +05:30
local_plugin.set_version(self.number)
local_plugin.save()
return local_plugin
2022-08-27 18:39:43 +05:30
async def install(self, suppress_screenmessage=False):
try:
local_plugin = await self._download()
except MD5CheckSumFailed:
2022-08-27 18:39:43 +05:30
if not suppress_screenmessage:
2023-05-17 02:57:02 +05:30
bui.screenmessage(
2022-08-31 19:26:52 +05:30
f"{self.plugin.name} failed MD5 checksum during installation", color=(1, 0, 0))
2022-08-27 18:39:43 +05:30
return False
else:
if not suppress_screenmessage:
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{self.plugin.name} installed", color=(0, 1, 0))
check = babase.app.config["Community Plugin Manager"]["Settings"]
2022-08-27 18:39:43 +05:30
if check["Auto Enable Plugins After Installation"]:
await local_plugin.enable()
return True
2022-08-08 03:36:49 +05:30
2022-07-31 21:40:54 +05:30
class Plugin:
def __init__(self, plugin, url, tag=CURRENT_TAG):
2022-08-05 22:43:07 +05:30
"""
Initialize a plugin from network repository.
"""
2022-07-31 21:40:54 +05:30
self.name, self.info = plugin
self.install_path = os.path.join(PLUGIN_DIRECTORY, f"{self.name}.py")
2022-08-17 02:07:08 +05:30
self.url = url
self.tag = tag
2022-08-08 03:36:49 +05:30
self._local_plugin = None
2022-07-31 21:40:54 +05:30
2022-08-17 02:07:08 +05:30
self._versions = None
self._latest_version = None
self._latest_compatible_version = None
2022-08-05 22:43:07 +05:30
def __repr__(self):
return f"<Plugin({self.name})>"
def __str__(self):
return self.name
@property
def view_url(self):
if self.latest_compatible_version == self.latest_version:
tag = CURRENT_TAG
else:
tag = self.latest_compatible_version.commit_sha
return self.url.format(content_type="blob", tag=tag)
2022-07-31 21:40:54 +05:30
@property
2022-08-05 22:43:07 +05:30
def is_installed(self):
2022-07-31 21:40:54 +05:30
return os.path.isfile(self.install_path)
2022-08-17 02:07:08 +05:30
@property
def versions(self):
if self._versions is None:
self._versions = [
PluginVersion(
self,
version,
tag=self.tag,
2022-08-17 02:07:08 +05:30
) for version in self.info["versions"].items()
]
return self._versions
2022-08-05 22:43:07 +05:30
@property
def latest_version(self):
2022-08-17 02:07:08 +05:30
if self._latest_version is None:
self._latest_version = PluginVersion(
self,
tuple(self.info["versions"].items())[0],
tag=self.tag,
2022-08-17 02:07:08 +05:30
)
return self._latest_version
2022-08-05 22:43:07 +05:30
2022-08-17 02:07:08 +05:30
@property
def latest_compatible_version(self):
if self._latest_compatible_version is None:
for number, info in self.info["versions"].items():
if info["api_version"] == _app_api_version:
2022-08-17 02:07:08 +05:30
self._latest_compatible_version = PluginVersion(
self,
(number, info),
tag=self.tag if self.latest_version.number == number else info["commit_sha"]
2022-08-17 02:07:08 +05:30
)
break
2022-08-28 23:09:09 +05:30
if self._latest_compatible_version is None:
raise NoCompatibleVersion(
f"{self.name} has no version compatible with API {_app_api_version}."
2022-08-28 23:09:09 +05:30
)
2022-08-17 02:07:08 +05:30
return self._latest_compatible_version
2022-08-08 03:36:49 +05:30
2022-08-05 22:43:07 +05:30
def get_local(self):
if not self.is_installed:
raise PluginNotInstalled(
2022-08-31 19:26:52 +05:30
f"{self.name} needs to be installed to get its local plugin.")
2022-08-08 03:36:49 +05:30
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()
2022-08-05 22:43:07 +05:30
)
async def uninstall(self):
await self.get_local().uninstall()
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{self.name} uninstalled", color=(0.9, 1, 0))
2022-08-21 06:01:15 +05:30
def has_update(self):
2022-08-28 23:09:09 +05:30
try:
latest_compatible_version = self.latest_compatible_version
except NoCompatibleVersion:
2022-08-28 23:09:09 +05:30
return False
else:
return self.get_local().version != latest_compatible_version.number
2022-08-05 22:43:07 +05:30
async def update(self):
2022-08-27 18:39:43 +05:30
if await self.latest_compatible_version.install(suppress_screenmessage=True):
2023-05-17 02:57:02 +05:30
bui.screenmessage(f"{self.name} updated to {self.latest_compatible_version.number}",
color=(0, 1, 0))
bui.getsound('shieldUp').play()
2022-08-27 18:39:43 +05:30
else:
2023-05-17 02:57:02 +05:30
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()
2022-07-31 21:40:54 +05:30
class PluginManager:
def __init__(self):
self.request_headers = HEADERS
self._index = _CACHE.get("index", {})
2026-08-23 17:33:02 +05:30
# The raw changelog text, kept separate from the parsed entry in
# _CACHE["changelog"]; see setup_changelog().
self._changelog = _CACHE.get("changelog_source")
self.categories = {}
self.module_path = __file__
self._index_setup_in_progress = False
self._changelog_setup_in_progress = False
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
),
headers=self.request_headers,
)
2026-08-23 17:33:02 +05:30
content = await async_send_network_request(request)
index = json.loads(content)
self.set_index_global_cache(index)
self._index = index
return self._index
async def setup_index(self):
while self._index_setup_in_progress:
# Avoid making multiple network calls to the same resource in parallel.
# Rather wait for the previous network call to complete.
await asyncio.sleep(0.1)
self._index_setup_in_progress = not bool(self._index)
2026-08-23 17:33:02 +05:30
try:
index = await self.get_index()
await self.setup_plugin_categories(index)
finally:
# Must clear even when the setup raised, or every later call
# spins in the loop above forever waiting on a call that has
# already given up.
self._index_setup_in_progress = False
async def get_changelog(self) -> str:
"""The full CHANGELOG.md text, fetched once per session."""
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)
2026-08-23 17:33:02 +05:30
content = await async_send_network_request(request)
self._changelog = content.decode()
self.set_changelog_source_global_cache(self._changelog)
return self._changelog
async def setup_changelog(self, version=None) -> None:
if version is None:
version = PLUGIN_MANAGER_VERSION
while self._changelog_setup_in_progress:
# Avoid making multiple network calls to the same resource in parallel.
# Rather wait for the previous network call to complete.
await asyncio.sleep(0.1)
self._changelog_setup_in_progress = not bool(self._changelog)
try:
2026-08-23 17:33:02 +05:30
try:
# Parsing is pure string work on text we already hold, so it
# runs every time rather than only on the call that fetched.
# Skipping it was what let the raw text reach the cache in
# place of the parsed entry ChangelogWindow reads.
full_changelog = await self.get_changelog()
pattern = rf"### {version} \(\d\d-\d\d-\d{{4}}\)\n(.*?)(?=### \d+\.\d+\.\d+|\Z)"
2026-08-23 17:33:02 +05:30
if (len(full_changelog.split(version)) > 1):
released_on = full_changelog.split(version)[1].split('\n')[0]
matches = re.findall(pattern, full_changelog, re.DOTALL)
else:
released_on = ' (Not Provided)'
matches = None
if matches:
changelog = {
'released_on': released_on,
'info': matches[0].strip()
}
else:
changelog = {'released_on': released_on,
'info': f"Changelog entry for version {version} not found."}
2026-08-23 17:33:02 +05:30
except urllib.error.URLError:
changelog = {'released_on': ' (Not Provided)',
'info': 'Could not get ChangeLog due to Internet Issues.'}
self.set_changelog_global_cache(changelog)
finally:
# Must clear even when the setup raised, or every later call
# spins in the loop above forever waiting on a call that has
# already given up.
self._changelog_setup_in_progress = False
async def setup_plugin_categories(self, plugin_index):
# A hack to have the "All" category show at the top.
self.categories["All"] = None
requests = []
for meta_url in plugin_index["categories"]:
category = Category(meta_url)
request = category.fetch_metadata()
requests.append(request)
for source in babase.app.config["Community Plugin Manager"]["Custom Sources"]:
source_splits = source.split("@", maxsplit=1)
if len(source_splits) == 1:
# Fallack to `main` if `@branchname` isn't specified in an external source URI.
source_repo, source_tag = source_splits[0], "main"
else:
source_repo, source_tag = source_splits
meta_url = partial_format(
plugin_index["external_source_url"],
repository=source_repo,
)
category = Category(meta_url, tag=source_tag)
request = category.fetch_metadata()
requests.append(request)
categories = await asyncio.gather(*requests)
all_plugins = []
for category in categories:
self.categories[await category.get_name()] = category
all_plugins.extend(await category.get_plugins())
self.categories["All"] = CategoryAll(plugins=all_plugins)
def cleanup(self):
for category in self.categories.values():
if category is not None:
category.cleanup()
self.categories.clear()
self._index.clear()
self._changelog = None
self.unset_index_global_cache()
async def refresh(self):
self.cleanup()
await self.setup_index()
def set_index_global_cache(self, index):
_CACHE["index"] = index
def set_changelog_global_cache(self, changelog):
_CACHE["changelog"] = changelog
2026-08-23 17:33:02 +05:30
def set_changelog_source_global_cache(self, changelog_source):
_CACHE["changelog_source"] = changelog_source
def unset_index_global_cache(self):
2026-08-23 17:33:02 +05:30
for key in ("index", "changelog", "changelog_source"):
_CACHE.pop(key, None)
async def get_update_details(self):
index = await self.get_index()
for version, info in index["versions"].items():
if info["api_version"] != _app_api_version:
# No point checking a version of the API game doesn't support.
continue
if version == PLUGIN_MANAGER_VERSION:
# We're already on the latest version for the current API.
return
else:
if next(iter(index["versions"])) == version:
# Version on the top is the latest, so no need to specify
# the commit SHA explicitly to GitHub to access the latest file.
commit_sha = None
else:
commit_sha = info["commit_sha"]
return version, commit_sha
async def update(self, to_version=None, commit_sha=None):
index = await self.get_index()
if to_version is None:
to_version, commit_sha = await self.get_update_details()
to_version_info = index["versions"][to_version]
tag = commit_sha or CURRENT_TAG
download_url = index["plugin_manager_url"].format(
content_type="raw",
tag=tag,
)
2026-08-23 17:33:02 +05:30
content = await async_send_network_request(download_url)
if hashlib.md5(content).hexdigest() != to_version_info["md5sum"]:
raise MD5CheckSumFailed("MD5 checksum failed during plugin manager update.")
with open(self.module_path, "wb") as fout:
fout.write(content)
return to_version_info
async def soft_refresh(self):
pass
2024-04-23 01:49:42 +05:30
class ChangelogWindow(popup.PopupWindow):
def __init__(self, origin_widget):
self.scale_origin = origin_widget.get_screen_space_center()
2025-08-04 02:51:27 +05:30
s = 1.65 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67
2024-04-23 01:49:42 +05:30
width = 400 * s
2026-08-23 17:53:35 +05:30
height = width * 0.6
2024-04-23 01:49:42 +05:30
color = (1, 1, 1)
text_scale = 0.7 * s
self._transition_out = 'out_scale'
transition = 'in_scale'
self._root_widget = bui.containerwidget(
size=(width, height),
on_outside_click_call=self._back,
transition=transition,
2025-08-06 19:56:08 +00:00
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
)
2024-04-23 01:49:42 +05:30
_add_popup(self)
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, height * 0.87),
size=(0, 0),
h_align='center',
v_align='center',
text='ChangeLog',
scale=text_scale * 1.25,
color=bui.app.ui_v1.title_color,
maxwidth=width * 0.9
)
2024-04-23 01:49:42 +05:30
back_button = bui.buttonwidget(
parent=self._root_widget,
position=(width * 0.1, height * 0.8),
size=(60, 60),
scale=0.8,
label=babase.charstr(babase.SpecialChar.BACK),
button_type='backSmall',
on_activate_call=self._back,
enable_sound=False
2025-08-04 02:35:20 +05:30
)
2024-04-23 01:49:42 +05:30
bui.containerwidget(edit=self._root_widget, cancel_button=back_button)
2024-08-08 01:49:31 +05:30
try:
released_on = _CACHE['changelog']['released_on']
logs = _CACHE['changelog']['info'].split('\n')
h_align = 'left'
except KeyError:
released_on = ''
logs = ["Could not load ChangeLog"]
h_align = 'center'
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, height * 0.75),
2025-08-04 02:35:20 +05:30
size=(0, 0),
h_align='center',
v_align='center',
text=PLUGIN_MANAGER_VERSION + released_on,
scale=text_scale * 0.9,
color=color,
maxwidth=width * 0.9
)
2024-04-23 01:49:42 +05:30
bui.buttonwidget(
parent=self._root_widget,
position=(width * 0.7, height * 0.72),
2024-04-23 01:49:42 +05:30
size=(140, 60),
scale=0.8,
label='Full ChangeLog',
button_type='square',
enable_sound=False,
on_activate_call=lambda: (
bui.getsound('deek').play() or
bui.open_url(REPOSITORY_URL + '/blob/main/CHANGELOG.md')
)
2025-08-04 02:35:20 +05:30
)
2024-04-23 01:49:42 +05:30
2026-08-23 17:53:35 +05:30
# The entry can be arbitrarily long, so it scrolls rather than
# spilling out of the bottom of the window.
scroll_width = width * 0.88
scroll_height = height * 0.62
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
size=(scroll_width, scroll_height),
position=(width * 0.06, height * 0.04),
capture_arrows=True
)
body_scale = text_scale * 0.7
line_height = 36 * body_scale
text_width = scroll_width - 30
if h_align == 'left':
# Leave headroom below `maxwidth` so no line ends up shrunk
# (and thus smaller than its neighbours) by a rounding hair.
logs = _wrap_markdown_bullets(logs, text_width * 0.95, body_scale)
content_height = max(scroll_height, line_height * len(logs) + 20)
content = bui.containerwidget(
parent=self._scrollwidget,
size=(text_width, content_height),
background=False,
claims_left_right=False
)
loop_height = content_height - line_height * 0.5 - 10
2024-04-23 01:49:42 +05:30
for log in logs:
2025-08-04 02:35:20 +05:30
bui.textwidget(
2026-08-23 17:53:35 +05:30
parent=content,
position=(text_width * 0.5 if h_align == 'center' else 0,
loop_height),
2025-08-04 02:35:20 +05:30
size=(0, 0),
h_align=h_align,
v_align='center',
2025-08-04 02:35:20 +05:30
text=log,
2026-08-23 17:53:35 +05:30
scale=body_scale,
2025-08-04 02:35:20 +05:30
color=color,
2026-08-23 17:53:35 +05:30
maxwidth=text_width
2025-08-04 02:35:20 +05:30
)
2026-08-23 17:53:35 +05:30
loop_height -= line_height
2024-04-23 01:49:42 +05:30
def _back(self) -> None:
bui.getsound('swish').play()
_remove_popup(self)
2024-04-23 01:49:42 +05:30
bui.containerwidget(edit=self._root_widget, transition='out_scale')
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()
2025-08-04 02:51:27 +05:30
s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67
width = 400 * s
height = width * 0.8
color = (1, 1, 1)
text_scale = 0.7 * s
self._transition_out = 'out_scale'
transition = 'in_scale'
self._root_widget = bui.containerwidget(
size=(width, height),
on_outside_click_call=self._back,
transition=transition,
2025-08-04 02:51:27 +05:30
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)
pos = height * 0.9
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text='Authors',
scale=text_scale * 1.25,
color=color,
maxwidth=width * 0.9
)
2024-04-21 10:28:35 +00:00
back_button = bui.buttonwidget(
parent=self._root_widget,
position=(width * 0.1, height * 0.87),
size=(60, 60),
scale=0.8,
label=babase.charstr(babase.SpecialChar.BACK),
button_type='backSmall',
on_activate_call=self._back,
enable_sound=False
2025-08-04 02:35:20 +05:30
)
bui.containerwidget(edit=self._root_widget, cancel_button=back_button)
2025-08-04 02:35:20 +05:30
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
size=(width * 0.8, height * 0.75),
position=(width * 0.1, height * 0.1)
2025-08-06 19:56:08 +00:00
)
2025-08-04 02:35:20 +05:30
self._columnwidget = bui.columnwidget(
parent=self._scrollwidget,
border=1,
left_border=-15,
margin=0
)
for author in self.authors_info:
for key, value in author.items():
text = f"{key.title()}: {value if value != '' else 'Not Provided'}"
if key == 'name':
text = value
2025-08-04 02:35:20 +05:30
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),
scale=(
2025-08-06 19:56:08 +00:00
(1.1 if key == 'name' else 0.9) if _uiscale() is babase.UIScale.SMALL else
(1.2 if key == 'name' else 1.0)
),
2025-08-04 02:35:20 +05:30
text=text,
h_align='center',
v_align='center',
maxwidth=420
)
bui.textwidget(
parent=self._columnwidget,
size=(width * 0.8, 30),
always_highlight=True,
h_align='center',
v_align='center'
)
def _back(self) -> None:
bui.getsound('swish').play()
_remove_popup(self)
bui.containerwidget(edit=self._root_widget, transition='out_scale')
2022-08-02 00:35:19 +05:30
class PluginWindow(popup.PopupWindow):
2025-08-06 00:50:09 +05:30
def __init__(
self,
plugin: Plugin,
origin_widget,
plugins_list,
2025-08-06 19:56:08 +00:00
transition='in_scale',
2025-08-06 00:50:09 +05:30
button_callback=lambda: None,
):
self.plugin: Plugin = plugin
self.transition = transition
self.plugins_list = plugins_list
2022-08-06 00:18:35 +05:30
self.button_callback = button_callback
self.scale_origin = origin_widget.get_screen_space_center()
2023-10-03 08:20:09 +00:00
loop.create_task(self.draw_ui())
2022-12-21 08:39:02 +00:00
2023-01-18 18:54:16 +05:30
def get_description(self, minimum_character_offset=40):
"""
2025-08-06 00:50:09 +05:30
Splits the long plugin description into multiple lines.
2023-01-18 18:54:16 +05:30
"""
string = self.plugin.info["description"]
string_length = len(string)
partitioned_string = ""
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)
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.
partitioned_string += "\n"
partitioned_string_length = len(partitioned_string)
2023-01-18 18:54:16 +05:30
return partitioned_string
async def draw_ui(self):
2023-05-17 02:57:02 +05:30
bui.getsound('swish').play()
2022-07-31 23:44:25 +05:30
b_text_color = (0.75, 0.7, 0.8)
2025-08-04 02:51:27 +05:30
s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.39 if babase.UIScale.MEDIUM else 1.67
2025-08-06 00:50:09 +05:30
width = 450 * s
height = 120 + 100 * s
2022-07-31 21:40:54 +05:30
color = (1, 1, 1)
text_scale = 0.7 * s
self._root_widget = bui.containerwidget(
size=(width, height),
on_outside_click_call=self._cancel,
2025-08-06 00:50:09 +05:30
transition=self.transition,
2025-08-04 02:51:27 +05:30
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
)
_add_popup(self)
2025-08-06 00:50:09 +05:30
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
]
# initialize all widgets
previous_plugin_button = None
next_plugin_button = None
author_text_control_btn = None
button1 = None
button2 = None
button3 = None
more_button = None
settings_button = None
tutorial_button = None
# previous plugin button
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),
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)
)
2025-08-06 00:50:09 +05:30
# 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),
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)
)
2025-08-06 00:50:09 +05:30
pos = height * 0.85
plug_name = self.plugin.name.replace('_', ' ').title()
plugin_title = plug_name
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text=plugin_title,
scale=text_scale * 1.25,
color=color,
maxwidth=width * 0.7 - 40
2025-08-04 02:35:20 +05:30
)
2022-07-31 21:40:54 +05:30
pos -= 25
# author
2024-04-21 15:35:56 +05:30
text = 'by ' + ', '.join([author["name"] for author in self.plugin.info["authors"]])
2025-08-04 02:35:20 +05:30
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),
label='',
texture=bui.gettexture("empty"),
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),
h_align='center',
v_align='center',
text=text,
scale=text_scale * 0.8,
color=(0.75, 0.7, 0.8),
maxwidth=width * 0.9,
draw_controller=author_text_control_btn,
)
2025-01-14 16:49:58 +05:30
pos -= 60
# info
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text=self.get_description(),
scale=text_scale * 0.6,
color=color,
maxwidth=width * 0.95
)
2022-08-29 18:41:17 +05:30
b1_color = None
2022-08-05 22:43:07 +05:30
b2_color = (0.8, 0.15, 0.35)
b3_color = (0.2, 0.8, 0.3)
2022-07-31 23:44:25 +05:30
pos = height * 0.1
button_size = (80 * s, 40 * s)
2022-08-02 00:35:19 +05:30
to_draw_button1 = True
to_draw_settings_button = False
has_update = False
if self.plugin.is_installed:
self.local_plugin = self.plugin.get_local()
2022-09-05 00:27:45 +05:30
if not await self.local_plugin.has_plugins():
to_draw_button1 = False
2022-07-31 23:44:25 +05:30
else:
if await self.local_plugin.is_enabled():
button1_label = "Disable"
2022-08-29 18:41:17 +05:30
b1_color = (0.6, 0.53, 0.63)
button1_action = self.disable
if self.local_plugin.has_settings():
to_draw_settings_button = True
else:
button1_label = "Enable"
button1_action = self.enable
2022-07-31 23:44:25 +05:30
button2_label = "Uninstall"
2022-08-05 22:43:07 +05:30
button2_action = self.uninstall
2022-08-21 06:01:15 +05:30
has_update = self.plugin.has_update()
2022-08-05 22:43:07 +05:30
if has_update:
button3_label = "Update"
button3_action = self.update
2022-07-31 23:44:25 +05:30
else:
button1_label = "Install"
2022-08-05 22:43:07 +05:30
button1_action = self.install
2022-07-31 23:44:25 +05:30
# button1
if to_draw_button1:
button1 = bui.buttonwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
position=(
width * (0.1 if self.plugin.is_installed and has_update else
2025-08-06 19:56:08 +00:00
0.25 if self.plugin.is_installed else 0.4), pos
2025-08-04 02:35:20 +05:30
),
size=button_size,
on_activate_call=button1_action,
color=b1_color,
textcolor=b_text_color,
button_type='square',
text_scale=1,
label=button1_label
)
2022-08-01 00:32:52 +05:30
# button2
if self.plugin.is_installed:
button2 = bui.buttonwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
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
)
2022-08-01 00:32:52 +05:30
# button3
2022-08-05 22:43:07 +05:30
if has_update:
button3 = bui.buttonwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
position=(width * 0.7, pos),
size=button_size,
on_activate_call=button3_action,
color=b3_color,
textcolor=b_text_color,
autoselect=True,
button_type='square',
text_scale=1,
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
)
bui.containerwidget(
edit=self._root_widget,
on_cancel_call=self._cancel,
selected_child=selected_btn
)
2023-05-17 02:57:02 +05:30
# button math
button_x = width - 90
button_y = 210 - _by_scale(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)
# more button
more_button = bui.buttonwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
position=(button_x, button_y),
2025-08-04 02:35:20 +05:30
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
)
2025-08-04 02:35:20 +05:30
)
bui.imagewidget(
parent=self._root_widget,
position=(button_x, button_y),
2025-08-04 02:35:20 +05:30
size=(40, 40),
color=button_image_color,
texture=bui.gettexture('file'),
draw_controller=more_button
2025-08-04 02:35:20 +05:30
)
bui.textwidget(
parent=self._root_widget,
position=(button_x-3, button_y+12),
text='More...',
2025-08-04 02:35:20 +05:30
size=(10, 10),
draw_controller=more_button,
color=button_text_color,
2025-08-04 02:35:20 +05:30
rotate=25,
scale=0.45
)
2023-02-07 23:12:23 +05:30
# settings button
if to_draw_settings_button:
button_y -= 50
settings_button = bui.buttonwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
autoselect=True,
position=(button_x, button_y),
2025-08-04 02:35:20 +05:30
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
2025-08-04 02:35:20 +05:30
)
# tutorial
tutorial_url = self.plugin.info['external_url']
if tutorial_url:
button_y -= 50
tutorial_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(button_x, button_y),
size=(40, 40),
button_type="square",
label="",
color=button_color
)
2025-08-04 02:35:20 +05:30
bui.imagewidget(
parent=self._root_widget,
position=(button_x, button_y),
2025-08-04 02:35:20 +05:30
size=(40, 40),
color=button_image_color,
2025-08-04 02:35:20 +05:30
texture=bui.gettexture("frameInset"),
draw_controller=tutorial_button
2025-08-04 02:35:20 +05:30
)
bui.textwidget(
parent=self._root_widget,
position=(button_x - 3, button_y + 12),
2025-08-04 02:35:20 +05:30
text="Tutorial",
size=(10, 10),
draw_controller=tutorial_button,
color=button_text_color,
2025-08-04 02:35:20 +05:30
rotate=25,
scale=0.45
)
bui.buttonwidget(
tutorial_button,
on_activate_call=bui.CallPartial(
confirm.ConfirmWindow,
text=f'This will take you to:\n{tutorial_url}',
origin_widget=tutorial_button,
action=bui.CallPartial(
bui.open_url, tutorial_url
)
)
)
# navigation #XXX
if button1 and button2 and button3:
# three buttons
leftmost_button = button1
center_button = button2
rightmost_button = button3
elif button1 and button2:
# two buttons
leftmost_button = button1
center_button = None
rightmost_button = button2
elif button2 and button3:
# two buttons
leftmost_button = button2
center_button = None
rightmost_button = button3
elif button1:
# one button
leftmost_button = button1
center_button = None
rightmost_button = button1
elif button2:
# one button
leftmost_button = button2
center_button = None
rightmost_button = button2
else:
leftmost_button = center_button = rightmost_button = None
2023-02-07 23:12:23 +05:30
# determine vertical stack
bottom_stack = tutorial_button or settings_button or more_button
top_stack = more_button
# previous plugin button
if previous_plugin_button is not None:
bui.buttonwidget(
edit=previous_plugin_button,
left_widget=next_plugin_button,
right_widget=leftmost_button,
up_widget=author_text_control_btn,
down_widget=leftmost_button
2025-08-04 02:35:20 +05:30
)
# next plugin button
if next_plugin_button is not None:
bui.buttonwidget(
edit=next_plugin_button,
left_widget=bottom_stack,
right_widget=bui.get_special_widget('squad_button'),
up_widget=author_text_control_btn,
down_widget=bottom_stack
)
# author button
bui.buttonwidget(
edit=author_text_control_btn,
left_widget=previous_plugin_button,
right_widget=top_stack,
up_widget=center_button or leftmost_button,
down_widget=leftmost_button
)
# button1
if button1 is not None:
bui.buttonwidget(
edit=button1,
left_widget=previous_plugin_button,
right_widget=center_button or bottom_stack,
up_widget=author_text_control_btn,
down_widget=author_text_control_btn
)
# button2
if button2 is not None:
bui.buttonwidget(
edit=button2,
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
)
# button3
if button3 is not None:
bui.buttonwidget(
edit=button3,
left_widget=center_button or leftmost_button,
right_widget=bottom_stack,
up_widget=author_text_control_btn,
down_widget=author_text_control_btn
)
# more button
bui.buttonwidget(
edit=more_button,
left_widget=rightmost_button,
right_widget=next_plugin_button,
up_widget=author_text_control_btn,
down_widget=settings_button
)
# settings button
if settings_button is not None:
2023-05-17 02:57:02 +05:30
bui.buttonwidget(
edit=settings_button,
left_widget=rightmost_button,
right_widget=next_plugin_button,
up_widget=more_button,
down_widget=tutorial_button
2025-08-04 02:35:20 +05:30
)
# tutorial button
if tutorial_button is not None:
bui.buttonwidget(
edit=tutorial_button,
left_widget=rightmost_button,
right_widget=next_plugin_button,
up_widget=settings_button or more_button,
down_widget=rightmost_button
2025-08-04 02:35:20 +05:30
)
2022-08-06 16:03:12 +05:30
def _ok(self) -> None:
_remove_popup(self)
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self._root_widget, transition='out_scale')
2023-04-30 22:24:56 +05:30
def _cancel(self) -> None:
2023-05-17 02:57:02 +05:30
bui.getsound('swish').play()
_remove_popup(self)
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self._root_widget, transition='out_scale')
2022-08-01 00:32:52 +05:30
@staticmethod
2022-08-05 22:43:07 +05:30
def button(fn):
2022-08-06 00:18:35 +05:30
async def asyncio_handler(fn, self, *args, **kwargs):
await fn(self, *args, **kwargs)
await self.button_callback()
2022-08-06 00:18:35 +05:30
2022-08-05 22:43:07 +05:30
def wrapper(self, *args, **kwargs):
2022-08-06 16:03:12 +05:30
self._ok()
2023-10-03 08:20:09 +00:00
2022-08-06 00:18:35 +05:30
if asyncio.iscoroutinefunction(fn):
loop.create_task(asyncio_handler(fn, self, *args, **kwargs))
else:
fn(self, *args, **kwargs)
loop.create_task(self.button_callback())
2022-08-05 22:43:07 +05:30
return wrapper
def settings(self, source_widget):
self.local_plugin.launch_settings(source_widget)
2025-08-06 00:50:09 +05:30
def show_previous_plugin(self):
bui.containerwidget(edit=self._root_widget, transition='out_right')
_remove_popup(self)
2025-08-06 00:50:09 +05:30
PluginWindow(
self.p_n_plugins[0],
self._root_widget,
transition='in_left',
plugins_list=self.plugins_list,
button_callback=lambda: None
)
def show_next_plugin(self):
bui.containerwidget(edit=self._root_widget, transition='out_left')
_remove_popup(self)
2025-08-06 00:50:09 +05:30
PluginWindow(
self.p_n_plugins[1],
self._root_widget,
transition='in_right',
plugins_list=self.plugins_list,
button_callback=lambda: None
)
@button
def disable(self) -> None:
self.local_plugin.disable()
2022-08-05 22:43:07 +05:30
@button
2022-08-06 00:18:35 +05:30
async def enable(self) -> None:
2022-08-08 03:36:49 +05:30
await self.local_plugin.enable()
2023-05-17 02:57:02 +05:30
bui.getsound('gunCocking').play()
2022-08-05 22:43:07 +05:30
@button
2022-08-06 00:18:35 +05:30
async def install(self):
await self.plugin.latest_compatible_version.install()
2023-05-17 02:57:02 +05:30
bui.getsound('cashRegister2').play()
2022-08-05 22:43:07 +05:30
@button
async def uninstall(self):
await self.plugin.uninstall()
2023-05-17 02:57:02 +05:30
bui.getsound('shieldDown').play()
2022-08-05 22:43:07 +05:30
@button
2022-08-06 00:18:35 +05:30
async def update(self):
await self.plugin.update()
2023-05-17 02:57:02 +05:30
bui.getsound('shieldUp').play()
2022-08-05 22:43:07 +05:30
2025-08-09 15:25:49 +00:00
class MoreWindow:
2026-02-15 20:06:59 +05:30
def __init__(self, plugin: Plugin, origin=None):
_add_popup(self)
2026-02-15 20:06:59 +05:30
self.plugin = plugin
# collect info
last_updated = None
for value in plugin.info['versions'].values():
if value['api_version'] == _app_api_version:
last_updated = value['released_on']
# math
step = 40
margin = 20
width = 350
px, py = margin, margin
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
)
)
# last updated
bui.textwidget(
parent=self._root_widget,
position=(px, py),
h_align='left',
v_align='center',
maxwidth=width-margin*2,
max_height=step,
size=(step, step),
text=(
last_updated and
f'Last updated on: {last_updated}'
or 'Unknown'
)
)
# version
py += margin+step/3
bui.textwidget(
parent=self._root_widget,
position=(px, py),
h_align='left',
v_align='center',
maxwidth=mw,
max_height=step,
size=(step, step),
text=f"Version: {plugin.latest_compatible_version.number}"
)
# source url
py += step+margin
bui.textwidget(
parent=self._root_widget,
position=(px, py),
h_align='left',
v_align='center',
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
)
)
bui.imagewidget(
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),
text='source',
size=(10, 10),
draw_controller=source_button,
rotate=25,
scale=0.45
)
# report bug
py += step+margin
bui.textwidget(
parent=self._root_widget,
position=(px, py),
h_align='left',
v_align='center',
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='',
2026-02-15 20:06:59 +05:30
on_activate_call=self._open_bug_report_url
)
bui.imagewidget(
parent=self._root_widget,
position=(px2, py),
size=(step, step),
color=(0.8, 0.95, 1),
texture=bui.gettexture('githubLogo'),
draw_controller=report_button
)
# back
py += step+margin
back_button = bui.buttonwidget(
parent=self._root_widget,
position=(px, py),
size=(step, step),
label=babase.charstr(babase.SpecialChar.BACK),
button_type='backSmall',
on_activate_call=self._back,
enable_sound=False
)
# title
px += margin
bui.textwidget(
parent=self._root_widget,
position=(px+(step), py),
h_align='left',
v_align='center',
maxwidth=width-margin*2,
max_height=step*2,
size=(step, step),
text='More info',
scale=1.4,
shadow=0.8
)
# finally
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
)
bui.buttonwidget(
edit=source_button,
left_widget=back_button,
right_widget=back_button,
up_widget=report_button,
down_widget=back_button
)
bui.buttonwidget(
edit=report_button,
left_widget=back_button,
right_widget=back_button,
up_widget=back_button,
down_widget=source_button
)
2026-02-15 20:06:59 +05:30
def _open_bug_report_url(self):
import platform
import urllib.parse
import baenv
LOGS_LENGTH_LIMIT = 5000
_logs = ""
for entry in baenv._EnvGlobals.get().config.log_handler.get_cached().entries:
_logs += entry.message + "\n"
# reduce this more if it's too long
error_logs = f"""```py
{_logs[:LOGS_LENGTH_LIMIT]}
```"""
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-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
}
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)
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}'
)
class PluginCustomSourcesWindow(popup.PopupWindow):
def __init__(self, origin_widget):
2022-08-29 18:41:17 +05:30
self.selected_source = None
self.scale_origin = origin_widget.get_screen_space_center()
b_textcolor = (0.75, 0.7, 0.8)
self._transition_out = 'out_scale'
transition = 'in_scale'
self._root_widget = bui.containerwidget(
size=(400, 340),
on_outside_click_call=self._ok,
transition=transition,
2025-08-04 02:51:27 +05:30
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
)
2023-05-17 02:57:02 +05:30
_add_popup(self)
2023-05-17 02:57:02 +05:30
bui.textwidget(
parent=self._root_widget,
position=(155, 300),
size=(100, 25),
text="Custom Plugin Sources",
2023-06-21 00:33:31 +05:30
color=bui.app.ui_v1.title_color,
scale=0.8,
h_align="center",
v_align="center",
maxwidth=270,
)
2025-08-04 02:51:27 +05:30
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)
2022-08-15 21:40:56 +05:30
scroll_pos_y = 105
2025-08-04 02:35:20 +05:30
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
)
delete_source_button_position_pos_x = 360
delete_source_button_position_pos_y = 110
2025-08-04 02:35:20 +05:30
delete_source_button = bui.buttonwidget(
parent=self._root_widget,
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)
)
2023-05-17 02:57:02 +05:30
2025-08-04 02:35:20 +05:30
bui.imagewidget(
parent=self._root_widget,
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
)
2023-05-17 02:57:02 +05:30
2025-08-04 02:51:27 +05:30
warning_pos_x = (43 if _uiscale() is babase.UIScale.SMALL else
35 if _uiscale() is babase.UIScale.MEDIUM else
2022-08-29 18:41:17 +05:30
48)
2023-05-17 02:57:02 +05:30
bui.textwidget(
parent=self._root_widget,
2022-08-29 18:41:17 +05:30
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!"),
color=(1, 0.23, 0.23),
scale=0.5,
h_align="left",
v_align="center",
maxwidth=400,
)
2025-08-04 02:35:20 +05:30
self._add_source_widget = bui.textwidget(
parent=self._root_widget,
size=(335, 50),
position=(21, 22),
h_align='left',
v_align='center',
editable=True,
scale=0.75,
maxwidth=215,
description="Add Source"
)
bui.buttonwidget(
parent=self._root_widget,
position=(330, 28),
size=(37, 37),
on_activate_call=lambda: loop.create_task(self.add_source()),
label="",
texture=bui.gettexture("startButton"),
button_type="square",
color=(0, 0.9, 0),
textcolor=b_textcolor,
text_scale=1
)
self.draw_sources()
def draw_sources(self):
for plugin in self._columnwidget.get_children():
plugin.delete()
color = (1, 1, 1)
2023-05-17 02:57:02 +05:30
for custom_source in babase.app.config["Community Plugin Manager"]["Custom Sources"]:
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._columnwidget,
selectable=True,
color=color,
text=custom_source,
on_select_call=lambda: self.select_source(custom_source),
h_align='left',
v_align='center',
scale=0.75,
maxwidth=260
)
def select_source(self, source):
self.selected_source = source
async def add_source(self):
2023-05-17 02:57:02 +05:30
source = bui.textwidget(query=self._add_source_widget)
# External source URIs can optionally suffix `@branchname`, for example:
# `bombsquad-community/sample-plugin-source@experimental`
source_splits = source.split("@", maxsplit=1)
if len(source_splits) == 1:
# Fallack to `main` if `@branchname` isn't specified in an external source URI.
source_repo, source_tag = source_splits[0], "main"
else:
source_repo, source_tag = source_splits
meta_url = partial_format(
_CACHE["index"]["external_source_url"],
repository=source_repo,
2022-08-14 00:42:40 +05:30
)
category = Category(meta_url, tag=source_tag)
try:
await category.validate()
except (PluginSourceNetworkError, CategoryMetadataParseError) as e:
bui.screenmessage(str(e), color=(1, 0, 0))
2023-05-17 02:57:02 +05:30
bui.getsound('error').play()
return
2023-05-17 02:57:02 +05:30
if source in babase.app.config["Community Plugin Manager"]["Custom Sources"]:
bui.screenmessage("Plugin source already exists")
bui.getsound('error').play()
return
2023-05-17 02:57:02 +05:30
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.getsound('cashRegister2').play()
self.draw_sources()
def delete_selected_source(self):
2022-08-29 18:41:17 +05:30
if self.selected_source is None:
return
2023-05-17 02:57:02 +05:30
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.getsound('shieldDown').play()
2022-08-29 18:41:17 +05:30
self.draw_sources()
def _ok(self) -> None:
2023-05-17 02:57:02 +05:30
bui.getsound('swish').play()
_remove_popup(self)
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self._root_widget, transition='out_scale')
class PluginCategoryWindow(popup.PopupMenuWindow):
def __init__(self, choices, current_choice, origin_widget, asyncio_callback):
choices = (*choices, "Installed", "Custom Sources")
self._asyncio_callback = asyncio_callback
self.scale_origin = origin_widget.get_screen_space_center()
super().__init__(
position=self.scale_origin,
2025-08-04 02:51:27 +05:30
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,
2025-08-04 02:35:20 +05:30
delegate=self
)
self._root_widget = self.root_widget
_add_popup(self)
self._update_custom_sources_widget()
def _update_custom_sources_widget(self):
2025-08-04 02:35:20 +05:30
bui.textwidget(
edit=self._columnwidget.get_children()[-1],
color=(0.5, 0.5, 0.5),
on_activate_call=self.show_sources_window
)
def popup_menu_selected_choice(self, window, choice):
2023-10-03 08:20:09 +00:00
loop.create_task(self._asyncio_callback(choice))
def popup_menu_closing(self, window):
pass
def show_sources_window(self):
self._ok()
PluginCustomSourcesWindow(origin_widget=self.root_widget)
def _ok(self) -> None:
2023-05-17 02:57:02 +05:30
bui.getsound('swish').play()
_remove_popup(self)
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self.root_widget, transition='out_scale')
2025-01-13 17:04:48 +05:30
class PluginManagerWindow(bui.MainWindow):
@override
def main_window_should_preserve_selection(self) -> bool:
return False
2025-08-05 06:27:53 +05:30
def __init__(
self,
transition: str | None = "in_right",
origin_widget: bui.Widget | None = None
2025-08-05 06:27:53 +05:30
):
2022-08-10 01:02:10 +05:30
self.plugin_manager = PluginManager()
self.category_selection_button = None
self.selected_category = 'All'
2022-08-06 00:18:35 +05:30
self.plugins_in_current_view = {}
self.selected_alphabet_order = 'a_z'
self.alphabet_order_selection_button = None
global open_popups
open_popups = []
2022-08-10 01:02:10 +05:30
loop.create_task(self.draw_index())
2025-08-04 02:51:27 +05:30
self._width = (700 if _uiscale() is babase.UIScale.SMALL
else 550 if _uiscale() is babase.UIScale.MEDIUM
2022-08-29 18:41:17 +05:30
else 570)
2025-08-04 02:51:27 +05:30
self._height = (500 if _uiscale() is babase.UIScale.SMALL
else 422 if _uiscale() is babase.UIScale.MEDIUM
2022-08-07 17:12:45 +05:30
else 500)
2025-08-04 02:51:27 +05:30
top_extra = 20 if _uiscale() is babase.UIScale.SMALL else 0
if origin_widget:
self._transition_out = "out_scale"
self._scale_origin = origin_widget.get_screen_space_center()
transition = "in_scale"
2025-01-13 17:04:48 +05:30
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + top_extra),
toolbar_visibility="menu_minimal",
2025-08-04 02:51:27 +05:30
scale=(1.9 if _uiscale() is babase.UIScale.SMALL
else 1.5 if _uiscale() is babase.UIScale.MEDIUM
2025-01-14 21:49:07 +00:00
else 1.0),
2025-08-04 02:51:27 +05:30
stack_offset=(0, -25) if _uiscale() is babase.UIScale.SMALL else (0, 0)
2025-01-13 17:04:48 +05:30
),
transition=transition,
2025-01-13 17:04:48 +05:30
origin_widget=origin_widget,
)
2025-08-04 02:51:27 +05:30
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)
2025-01-14 21:49:07 +00:00
2025-08-04 02:51:27 +05:30
if _uiscale() is bui.UIScale.SMALL:
2025-01-13 17:04:48 +05:30
self._back_button = None
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,
position=(back_pos_x, back_pos_y),
size=(60, 60),
scale=0.8,
label=babase.charstr(babase.SpecialChar.BACK),
button_type='backSmall',
2025-08-04 02:35:20 +05:30
on_activate_call=self.main_window_back
)
2022-08-01 00:32:52 +05:30
2025-01-13 17:04:48 +05:30
bui.containerwidget(edit=self._root_widget, cancel_button=back_button)
2025-08-04 02:51:27 +05:30
title_pos = self._height - (83 if _uiscale() is babase.UIScale.SMALL else
50 if _uiscale() is babase.UIScale.MEDIUM else 50)
2023-05-17 02:57:02 +05:30
bui.textwidget(
parent=self._root_widget,
2022-08-01 02:20:48 +05:30
position=(-10, title_pos),
size=(self._width, 25),
text="Community Plugin Manager",
2023-06-21 00:33:31 +05:30
color=bui.app.ui_v1.title_color,
scale=1.05,
h_align="center",
v_align="center",
maxwidth=270,
)
2022-08-10 01:02:10 +05:30
2025-08-04 02:51:27 +05:30
loading_pos_y = self._height - (275 if _uiscale() is babase.UIScale.SMALL else
235 if _uiscale() is babase.UIScale.MEDIUM else 270)
2022-08-10 01:02:10 +05:30
2023-05-17 02:57:02 +05:30
self._plugin_manager_status_text = bui.textwidget(
parent=self._root_widget,
position=(-5, loading_pos_y),
size=(self._width, 25),
text="",
2023-06-21 00:33:31 +05:30
color=bui.app.ui_v1.title_color,
scale=0.7,
h_align="center",
v_align="center",
maxwidth=400,
)
self._loading_spinner = bui.spinnerwidget(
parent=self._root_widget,
position=(self._width * 0.5, loading_pos_y),
style='bomb',
size=48,
)
2025-08-05 06:27:53 +05:30
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
2025-08-09 22:05:42 +05:30
# Close all open popups if ui changes.
# check pr #390 for more info.
for pup in open_popups:
try:
bui.containerwidget(edit=pup._root_widget, transition='out_scale')
2025-08-06 19:56:08 +00:00
except:
pass
2025-08-05 06:27:53 +05:30
cls = type(self)
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition, origin_widget=origin_widget
)
)
2025-04-08 16:32:09 +00:00
def spin(self, show=False):
2025-04-08 18:24:09 +02:00
w = self._loading_spinner
p = self._root_widget
2025-04-08 16:32:09 +00:00
bui.spinnerwidget(w, visible=show) if w.exists(
) and p.exists() and not p.transitioning_out else None
2025-04-08 18:24:09 +02:00
@contextlib.contextmanager
def exception_handler(self):
2022-08-07 02:05:48 +05:30
try:
yield
except urllib.error.URLError:
2025-04-08 18:24:09 +02:00
self.spin()
2025-01-13 17:04:48 +05:30
try:
bui.textwidget(
edit=self._plugin_manager_status_text,
text="Make sure you are connected\n to the Internet and try again."
)
2025-01-21 15:04:16 +00:00
except:
pass
self.plugin_manager._index_setup_in_progress = False
except RuntimeError:
2023-05-17 02:57:02 +05:30
# User probably went back before a bui.Window could finish loading.
pass
except Exception as e:
2025-04-08 18:24:09 +02:00
self.spin()
2025-01-13 17:04:48 +05:30
try:
bui.textwidget(edit=self._plugin_manager_status_text, text=str(e))
2025-01-21 15:04:16 +00:00
except:
pass
raise
async def draw_index(self):
self.draw_search_bar()
self.draw_plugins_scroll_bar()
self.draw_category_selection_button(post_label="All")
self.draw_refresh_icon()
self.draw_settings_icon()
with self.exception_handler():
2024-06-19 21:27:58 +05:30
await self.plugin_manager.setup_changelog()
await self.plugin_manager.setup_index()
2025-04-08 18:24:09 +02:00
self.spin()
2025-01-13 17:04:48 +05:30
try:
bui.textwidget(edit=self._plugin_manager_status_text, text="")
2025-01-21 15:04:16 +00:00
except:
pass
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)
2025-08-04 02:51:27 +05:30
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(
2025-08-04 21:07:55 +05:30
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
)
def draw_category_selection_button(self, post_label):
2025-08-04 02:51:27 +05:30
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)
2022-08-07 19:23:57 +05:30
b_size = (140, 30)
2022-08-29 18:41:17 +05:30
b_textcolor = (0.8, 0.8, 0.85)
if self.alphabet_order_selection_button is None:
2025-08-04 02:35:20 +05:30
self.alphabet_order_selection_button = bui.buttonwidget(
parent=self._root_widget,
size=(40, 30),
position=(category_pos_x - 47, category_pos_y),
label='Z - A' if self.selected_alphabet_order == 'z_a' else 'A - Z',
on_activate_call=lambda: loop.create_task(self._on_order_button_press()),
button_type="square",
textcolor=b_textcolor,
text_scale=0.6,
enable_sound=False,
left_widget=self._filter_widget,
down_widget=self._scrollwidget,
up_widget=self._back_button
2025-08-04 02:35:20 +05:30
)
else:
2025-04-08 18:24:09 +02:00
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
2022-08-10 01:02:10 +05:30
label = f"Category: {post_label}"
2022-08-06 03:44:15 +05:30
if self.category_selection_button is None:
2025-08-04 02:35:20 +05:30
self.category_selection_button = b = bui.buttonwidget(
parent=self._root_widget,
position=(category_pos_x, category_pos_y),
size=b_size,
label=label,
button_type="square",
textcolor=b_textcolor,
text_scale=0.6,
up_widget=self._back_button
2025-08-04 02:35:20 +05:30
)
2025-08-06 19:56:08 +00:00
bui.buttonwidget(
edit=b,
down_widget=self._scrollwidget,
on_activate_call=lambda: self.show_categories_window(source=b)
)
2022-08-06 03:44:15 +05:30
else:
2025-04-08 18:24:09 +02:00
b = self.category_selection_button
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,
2024-04-22 10:03:53 +00:00
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)
2024-04-29 20:16:25 +05:30
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
)
def draw_search_bar(self):
2025-08-04 02:51:27 +05:30
search_bar_pos_x = (85 if _uiscale() is babase.UIScale.SMALL else
68 if _uiscale() is babase.UIScale.MEDIUM else 75)
2022-08-06 15:55:19 +05:30
search_bar_pos_y = self._height - (
2025-08-04 02:51:27 +05:30
145 if _uiscale() is babase.UIScale.SMALL else
110 if _uiscale() is babase.UIScale.MEDIUM else 116)
2022-08-07 19:23:57 +05:30
2025-08-04 02:51:27 +05:30
search_bar_size_x = (320 if _uiscale() is babase.UIScale.SMALL else
230 if _uiscale() is babase.UIScale.MEDIUM else 260)
2022-08-07 23:44:30 +05:30
search_bar_size_y = (
2025-08-04 02:51:27 +05:30
35 if _uiscale() is babase.UIScale.SMALL else
35 if _uiscale() is babase.UIScale.MEDIUM else 45)
2023-05-17 02:57:02 +05:30
2025-08-04 02:51:27 +05:30
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)
2023-05-17 02:57:02 +05:30
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',
2023-06-21 00:33:31 +05:30
color=bui.app.ui_v1.title_color,
2023-05-17 02:57:02 +05:30
scale=0.5)
filter_txt = babase.Lstr(resource='filterText')
2025-08-04 02:51:27 +05:30
search_bar_maxwidth = search_bar_size_x - (95 if _uiscale() is babase.UIScale.SMALL else
77 if _uiscale() is babase.UIScale.MEDIUM else
2022-08-29 18:41:17 +05:30
85)
2025-08-04 21:07:55 +05:30
self._filter_widget = bui.textwidget(
parent=self._root_widget,
text="",
size=(search_bar_size_x, search_bar_size_y),
position=(search_bar_pos_x, search_bar_pos_y),
h_align='left',
v_align='center',
editable=True,
scale=0.8,
autoselect=True,
maxwidth=search_bar_maxwidth,
description=filter_txt
)
2023-12-08 23:44:14 +05:30
self._last_filter_text = ""
2022-08-21 01:33:25 +05:30
self._last_filter_plugins = []
2023-10-03 08:20:09 +00:00
loop.create_task(self.process_search_term())
2022-08-21 01:33:25 +05:30
async def process_search_term(self):
2022-08-21 01:33:25 +05:30
while True:
await asyncio.sleep(0.2)
2023-12-08 23:44:14 +05:30
if not self._filter_widget:
2022-08-21 01:33:25 +05:30
# Search filter widget got destroyed. No point checking for filter text anymore.
return
2023-12-08 23:44:14 +05:30
filter_text = bui.textwidget(parent=self._root_widget, query=self._filter_widget)
2022-08-21 01:33:25 +05:30
if self.selected_category is None:
continue
try:
await self.draw_plugin_names(
self.selected_category, search_term=filter_text.lower(), order=self.selected_alphabet_order)
except CategoryDoesNotExist:
2022-08-21 01:33:25 +05:30
pass
def draw_settings_icon(self):
2025-08-04 02:51:27 +05:30
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)
2025-08-04 21:07:55 +05:30
controller_button = bui.buttonwidget(
parent=self._root_widget,
position=(settings_pos_x, settings_pos_y),
size=(30, 30),
button_type="square",
label="",
left_widget=self._scrollwidget,
right_widget=bui.get_special_widget('squad_button')
2025-08-04 21:07:55 +05:30
)
bui.buttonwidget(
controller_button,
on_activate_call=babase.CallPartial(
PluginManagerSettingsWindow,
self.plugin_manager,
controller_button
)
)
2025-08-04 21:07:55 +05:30
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
)
def draw_refresh_icon(self):
2025-08-04 02:51:27 +05:30
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)
2023-10-03 08:20:09 +00:00
2025-04-08 18:24:09 +02:00
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')
)
bui.widget(
self._scrollwidget,
right_widget=controller_button
2025-04-08 18:24:09 +02:00
)
bui.imagewidget(
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
)
2022-08-06 03:44:15 +05:30
def search_term_filterer(self, plugin, search_term):
# This helps resolve "plugin name" to "plugin_name".
2025-01-14 16:49:58 +05:30
if search_term in plugin.info["description"].lower():
return True
search_term = search_term.replace(" ", "_")
if search_term in plugin.name:
return True
for author in plugin.info["authors"]:
if search_term in author["name"].lower():
return True
return False
2022-08-21 01:33:25 +05:30
# 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,
2022-12-03 15:37:23 +00:00
self.selected_category)
2024-04-20 03:15:58 +05:30
if not (to_draw_plugin_names or refresh):
2022-08-21 01:33:25 +05:30
return
2022-08-27 19:30:21 +05:30
try:
2024-04-29 20:31:32 +05:30
if self.plugin_manager.categories != {}:
2024-04-29 20:29:30 +05:30
if self.plugin_manager.categories['All'] is not None:
category_plugins = await self.plugin_manager.categories[category if category != 'Installed' else 'All'].get_plugins()
2024-04-30 09:23:50 +00:00
else:
return
else:
return
2022-08-27 19:30:21 +05:30
except (KeyError, AttributeError):
no_internet_text = "Make sure you are connected\n to the Internet and try again."
if bui.textwidget(query=self._plugin_manager_status_text) != no_internet_text:
raise CategoryDoesNotExist(f"{category} does not exist.")
else:
return
2022-08-21 01:33:25 +05:30
if search_term:
plugins = list(filter(
2023-12-08 23:44:14 +05:30
lambda plugin: self.search_term_filterer(plugin, search_term),
category_plugins,
))
2022-08-21 01:33:25 +05:30
else:
plugins = category_plugins
2024-04-22 10:03:53 +00:00
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:
# Plugins names to draw on UI are already drawn.
2022-08-21 01:33:25 +05:30
return
self._last_filter_text = search_term
2022-08-21 01:33:25 +05:30
self._last_filter_plugins = plugins
2025-04-08 16:32:09 +00:00
if not self._columnwidget.exists():
return
2025-04-08 18:24:09 +02:00
if category == 'Installed':
2025-04-08 18:24:09 +02:00
plugin_names_to_draw = tuple(
2025-08-06 00:50:09 +05:30
plugin for plugin in plugins if plugin.is_installed
2025-04-08 18:24:09 +02:00
)
else:
2025-08-06 00:50:09 +05:30
plugin_names_to_draw = plugins
2022-08-21 01:33:25 +05:30
for plugin in self._columnwidget.get_children():
plugin.delete()
2024-08-08 03:04:04 +05:30
text_widget = bui.textwidget(parent=self._columnwidget)
text_widget.delete()
2025-08-06 00:50:09 +05:30
# await asyncio.gather(*plugin_names_to_draw)
2022-08-06 00:18:35 +05:30
2025-08-06 00:50:09 +05:30
plugin_names_ready_to_draw = []
for plugin in plugin_names_to_draw:
2025-08-06 19:56:08 +00:00
try:
plugin.latest_compatible_version
2025-08-06 19:56:08 +00:00
except NoCompatibleVersion:
continue
2025-08-06 00:50:09 +05:30
plugin_names_ready_to_draw += [plugin]
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]
bui.widget(first_child, up_widget=self._filter_widget)
bui.widget(last_child, down_widget=self._back_button)
2025-08-06 00:50:09 +05:30
async def draw_plugin_name(self, plugin, plugins_list):
2022-08-28 23:09:09 +05:30
2022-08-06 00:18:35 +05:30
if plugin.is_installed:
2022-08-08 03:36:49 +05:30
local_plugin = plugin.get_local()
if await local_plugin.is_enabled():
2022-08-06 00:18:35 +05:30
if not local_plugin.is_installed_via_plugin_manager:
color = (0.8, 0.2, 0.2)
2025-08-06 00:50:09 +05:30
elif local_plugin.version == plugin.latest_compatible_version.number:
2022-08-29 18:41:17 +05:30
color = (0, 0.95, 0.2)
2022-08-05 22:43:07 +05:30
else:
2022-08-06 00:18:35 +05:30
color = (1, 0.6, 0)
2022-08-05 22:43:07 +05:30
else:
2022-08-06 01:01:55 +05:30
color = (1, 1, 1)
2022-08-06 00:18:35 +05:30
else:
2022-08-06 01:01:55 +05:30
color = (0.5, 0.5, 0.5)
2022-08-06 00:18:35 +05:30
2022-08-21 01:33:25 +05:30
plugin_name_widget_to_update = self.plugins_in_current_view.get(plugin.name)
if plugin_name_widget_to_update:
2025-04-08 18:24:09 +02:00
bui.textwidget(
edit=plugin_name_widget_to_update,
color=color
)
2022-08-06 00:18:35 +05:30
else:
2025-04-08 18:24:09 +02:00
text_widget = bui.textwidget(
parent=self._columnwidget,
size=(self.scrollx, 30),
2025-04-08 18:24:09 +02:00
selectable=True,
always_highlight=True,
color=color,
text=plugin.name.replace('_', ' ').title(),
click_activate=True,
h_align='left',
v_align='center',
maxwidth=self.scrollx-10
)
bui.textwidget(
text_widget,
on_activate_call=bui.CallPartial(
self.show_plugin_window,
plugin,
plugins_list,
text_widget
)
2025-04-08 18:24:09 +02:00
)
2022-08-06 00:18:35 +05:30
self.plugins_in_current_view[plugin.name] = text_widget
2022-08-21 01:33:25 +05:30
# XXX: This seems nicer. Might wanna use this in future.
# text_widget.add_delete_callback(lambda: self.plugins_in_current_view.pop(plugin.name))
def show_plugin_window(self, plugin, plugins_list, source):
2025-08-06 00:50:09 +05:30
PluginWindow(
plugin,
source,
2025-08-06 00:50:09 +05:30
plugins_list=plugins_list,
button_callback=lambda: self.draw_plugin_name(plugin, plugins_list)
)
2025-04-08 16:32:09 +00:00
def show_categories_window(self, source):
PluginCategoryWindow(
self.plugin_manager.categories.keys(),
self.selected_category,
source,
self.select_category,
)
async def select_category(self, category):
2022-08-06 03:44:15 +05:30
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)
2022-08-21 01:33:25 +05:30
self.selected_category = category
2022-08-06 03:44:15 +05:30
def cleanup(self):
2022-08-10 01:02:10 +05:30
self.plugin_manager.cleanup()
2022-08-06 03:44:15 +05:30
for plugin in self._columnwidget.get_children():
plugin.delete()
self.plugins_in_current_view.clear()
2023-12-08 23:44:14 +05:30
self._last_filter_text = ""
2022-08-21 01:33:25 +05:30
self._last_filter_plugins = []
2022-08-06 03:44:15 +05:30
2022-08-21 01:33:25 +05:30
async def refresh(self):
self.cleanup()
# try:
# bui.textwidget(edit=self._plugin_manager_status_text, text="Refreshing")
# except:
# pass
2025-04-08 18:24:09 +02:00
self.spin(True)
with self.exception_handler():
await self.plugin_manager.refresh()
await self.plugin_manager.setup_changelog()
await self.plugin_manager.setup_index()
2025-04-08 18:24:09 +02:00
self.spin()
2025-01-13 17:04:48 +05:30
try:
bui.textwidget(edit=self._plugin_manager_status_text, text="")
2025-01-21 15:04:16 +00:00
except:
pass
await self.select_category(self.selected_category)
2022-08-06 03:44:15 +05:30
2022-08-06 00:18:35 +05:30
def soft_refresh(self):
pass
2022-08-07 17:12:45 +05:30
2022-08-07 16:00:14 +05:30
class PluginManagerSettingsWindow(popup.PopupWindow):
2022-08-14 07:57:03 +05:30
def __init__(self, plugin_manager, origin_widget):
self._plugin_manager = plugin_manager
2022-08-14 00:42:40 +05:30
self.scale_origin = origin_widget.get_screen_space_center()
2023-05-17 02:57:02 +05:30
self.settings = babase.app.config["Community Plugin Manager"]["Settings"].copy()
2022-08-14 00:42:40 +05:30
loop.create_task(self.draw_ui())
async def draw_ui(self):
2022-08-29 18:41:17 +05:30
b_text_color = (0.8, 0.8, 0.85)
2025-08-04 02:51:27 +05:30
s = 1.25 if _uiscale() is babase.UIScale.SMALL else 1.27 if _uiscale() is babase.UIScale.MEDIUM else 1.3
2022-08-14 07:57:03 +05:30
width = 380 * s
height = 150 + 150 * s
color = (0.9, 0.9, 0.9)
2022-12-05 20:55:07 +05:30
# 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)
2022-08-07 16:00:14 +05:30
text_scale = 0.7 * s
self._transition_out = 'out_scale'
transition = 'in_scale'
button_size = (32 * s, 32 * s)
# index = await self._plugin_manager.get_index()
self._root_widget = bui.containerwidget(
size=(width, height),
on_outside_click_call=self._ok,
transition=transition,
2025-08-04 02:51:27 +05:30
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
)
_add_popup(self)
2022-08-07 16:00:14 +05:30
pos = height * 0.9
setting_title = "Settings"
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text=setting_title,
scale=text_scale,
color=bui.app.ui_v1.title_color,
maxwidth=width * 0.9
)
2022-08-15 20:11:08 +05:30
pos -= 20
2025-08-04 02:35:20 +05:30
self._changelog_button = b = bui.buttonwidget(
parent=self._root_widget,
position=((width * 0.2) - button_size[0] / 2 - 5, pos),
size=(80, 30),
textcolor=b_text_color,
button_type='square',
label=''
)
2025-04-08 16:32:09 +00:00
bui.buttonwidget(b, on_activate_call=lambda: ChangelogWindow(b))
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=((width * 0.2) - button_size[0] / 2, pos),
size=(70, 30),
scale=0.6,
h_align='center',
v_align='center',
text='ChangeLog',
color=b_text_color,
draw_controller=self._changelog_button,
)
self._save_button = bui.buttonwidget(
parent=self._root_widget,
position=((width * 0.82) - button_size[0] / 2, pos),
size=(73, 35),
on_activate_call=self.save_settings_button,
textcolor=b_text_color,
button_type='square',
text_scale=1,
scale=0,
selectable=False,
label="Save"
)
2022-08-15 20:11:08 +05:30
pos -= 40
self.checkboxes = []
2022-08-15 20:11:08 +05:30
for setting, value in self.settings.items():
chk = bui.checkboxwidget(
2025-08-04 02:35:20 +05:30
parent=self._root_widget,
position=(width * 0.1, pos),
size=(170, 30),
text=setting,
value=value,
on_value_change_call=babase.CallPartial(self.toggle_setting, setting),
2025-08-04 02:35:20 +05:30
maxwidth=500,
textcolor=(0.9, 0.9, 0.9),
scale=text_scale * 0.8
)
self.checkboxes.append(chk)
2022-08-29 18:41:17 +05:30
pos -= 34 * text_scale
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]
)
2022-08-15 20:11:08 +05:30
2022-10-03 20:20:33 +05:30
pos = height - 200
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
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
)
2022-08-07 16:00:14 +05:30
2022-10-03 20:20:33 +05:30
pos -= 75
2024-08-08 01:49:31 +05:30
try:
plugin_manager_update_available = await self._plugin_manager.get_update_details()
except urllib.error.URLError:
plugin_manager_update_available = False
discord_width = (width * 0.20) if plugin_manager_update_available else (width * 0.31)
2025-08-04 02:35:20 +05:30
self.discord_button = bui.buttonwidget(
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
2025-08-04 02:35:20 +05:30
)
2023-05-17 02:57:02 +05:30
2025-08-04 02:35:20 +05:30
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
)
2023-05-17 02:57:02 +05:30
2024-08-08 01:49:31 +05:30
github_width = (width * 0.49) if plugin_manager_update_available else (width * 0.65)
2025-08-04 02:35:20 +05:30
self.github_button = bui.buttonwidget(
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
2025-08-04 02:35:20 +05:30
)
2025-08-04 02:35:20 +05:30
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
)
2022-10-03 20:20:33 +05:30
2025-01-14 16:49:58 +05:30
bui.containerwidget(edit=self._root_widget, on_cancel_call=self._ok)
2022-08-14 07:57:03 +05:30
try:
plugin_manager_update_available = await self._plugin_manager.get_update_details()
except urllib.error.URLError:
plugin_manager_update_available = False
2022-08-14 07:57:03 +05:30
if plugin_manager_update_available:
text_color = (0.75, 0.2, 0.2)
button_size = (95 * s, 32 * s)
2022-08-24 15:44:54 +05:30
update_button_label = f'Update to v{plugin_manager_update_available[0]}'
2025-08-04 02:35:20 +05:30
self._update_button = bui.buttonwidget(
parent=self._root_widget,
position=((width * 0.77) - button_size[0] / 2, pos),
size=button_size,
2025-08-06 19:56:08 +00:00
on_activate_call=lambda: loop.create_task(
self.update(*plugin_manager_update_available)),
2025-08-04 02:35:20 +05:30
textcolor=b_text_color,
button_type='square',
text_scale=1,
color=(0, 0.7, 0),
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
2025-08-04 02:35:20 +05:30
)
self._restart_to_reload_changes_text = bui.textwidget(
parent=self._root_widget,
position=(width * 0.79, pos + 20),
size=(0, 0),
h_align='center',
v_align='center',
text='',
scale=text_scale * 0.65,
color=(0, 0.8, 0),
maxwidth=width * 0.9
)
2022-08-14 07:57:03 +05:30
else:
text_color = (0, 0.8, 0)
pos -= 25
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text=f'Plugin Manager v{PLUGIN_MANAGER_VERSION}',
scale=text_scale * 0.8,
color=text_color,
maxwidth=width * 0.9
)
2022-10-03 20:20:33 +05:30
pos -= 25
2025-08-04 02:35:20 +05:30
bui.textwidget(
parent=self._root_widget,
position=(width * 0.49, pos),
size=(0, 0),
h_align='center',
v_align='center',
text=f'API Version: {_app_api_version}',
scale=text_scale * 0.7,
color=(0.4, 0.8, 1),
maxwidth=width * 0.95
)
2022-08-14 07:57:03 +05:30
pos = height * 0.1
2022-08-15 20:11:08 +05:30
def toggle_setting(self, setting, set_value):
self.settings[setting] = set_value
2025-01-14 16:49:58 +05:30
check = self.settings == babase.app.config["Community Plugin Manager"]["Settings"]
2025-08-04 02:35:20 +05:30
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
)
)
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
)
)
2022-08-15 20:11:08 +05:30
def save_settings_button(self):
2023-05-17 02:57:02 +05:30
babase.app.config["Community Plugin Manager"]["Settings"] = self.settings.copy()
babase.app.config.commit()
2022-08-15 20:11:08 +05:30
self._ok()
2023-05-17 02:57:02 +05:30
bui.getsound('gunCocking').play()
2022-08-15 20:11:08 +05:30
async def update(self, to_version=None, commit_sha=None):
try:
await self._plugin_manager.update(to_version, commit_sha)
2024-04-23 02:31:25 +05:30
await self._plugin_manager.setup_changelog()
except MD5CheckSumFailed:
2023-05-17 02:57:02 +05:30
bui.screenmessage("MD5 checksum failed during plugin manager update", color=(1, 0, 0))
bui.getsound('error').play()
else:
2023-05-17 02:57:02 +05:30
bui.screenmessage("Plugin manager update successful", color=(0, 1, 0))
bui.getsound('shieldUp').play()
2025-08-04 02:35:20 +05:30
bui.textwidget(
edit=self._restart_to_reload_changes_text,
text='Update Applied!\nRestart game to reload changes.'
)
self._update_button.delete()
2022-08-07 16:00:14 +05:30
2022-08-15 20:11:08 +05:30
def _ok(self) -> None:
2023-05-17 02:57:02 +05:30
bui.getsound('swish').play()
_remove_popup(self)
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self._root_widget, transition='out_scale')
2022-08-07 16:00:14 +05:30
2025-01-14 16:49:58 +05:30
class NewAllSettingsWindow(AllSettingsWindow):
@override
def main_window_should_preserve_selection(self) -> bool:
return False
2023-05-17 02:57:02 +05:30
"""Window for selecting a settings category."""
def __init__(
self,
2025-01-13 17:04:48 +05:30
transition: str | None = 'in_right',
2025-04-08 22:29:14 +05:30
origin_widget: bui.Widget | None = None,
2023-05-17 02:57:02 +05:30
):
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
2023-05-17 02:57:02 +05:30
assert bui.app.classic is not None
2025-01-13 17:04:48 +05:30
uiscale = bui.app.ui_v1.uiscale
width = 1000 if uiscale is bui.UIScale.SMALL else 800
x_inset = 125 if uiscale is bui.UIScale.SMALL else 105
height = 490
top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
2025-01-14 16:49:58 +05:30
self._plugman_button = None
2025-01-14 16:49:58 +05:30
super().__init__(transition, origin_widget)
for child in self._root_widget.get_children():
child.delete()
2025-01-14 21:49:07 +00:00
2025-01-14 16:49:58 +05:30
bui.containerwidget(
edit=self._root_widget, size=(width, height + top_extra)
2023-05-17 02:57:02 +05:30
)
2025-01-13 17:04:48 +05:30
if uiscale is bui.UIScale.SMALL:
self._back_button = None
2023-05-17 02:57:02 +05:30
bui.containerwidget(
2025-01-13 17:04:48 +05:30
edit=self._root_widget, on_cancel_call=self.main_window_back
2023-05-17 02:57:02 +05:30
)
else:
2023-05-17 02:57:02 +05:30
self._back_button = btn = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
2025-01-13 17:04:48 +05:30
position=(x_inset - 20, height - 85),
size=(130, 60),
scale=0.8,
text_scale=1.2,
2023-05-17 02:57:02 +05:30
label=bui.Lstr(resource='backText'),
button_type='back',
2025-01-13 17:04:48 +05:30
on_activate_call=self.main_window_back,
2023-05-17 02:57:02 +05:30
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
bui.textwidget(
parent=self._root_widget,
2025-01-13 17:04:48 +05:30
position=(0, height - 80),
2023-05-17 02:57:02 +05:30
size=(width, 25),
2025-01-13 17:04:48 +05:30
text=bui.Lstr(resource=f'{self._r}.titleText'),
2023-06-21 00:33:31 +05:30
color=bui.app.ui_v1.title_color,
2023-05-17 02:57:02 +05:30
h_align='center',
v_align='center',
maxwidth=130,
)
if self._back_button is not None:
2023-05-17 02:57:02 +05:30
bui.buttonwidget(
edit=self._back_button,
button_type='backSmall',
size=(60, 60),
label=bui.charstr(bui.SpecialChar.BACK),
)
2025-01-14 16:49:58 +05:30
v = height - 265
2025-01-13 17:04:48 +05:30
basew = 280 if uiscale is bui.UIScale.SMALL else 230
baseh = 170
2023-05-17 02:57:02 +05:30
x_offs = (
2025-01-13 17:04:48 +05:30
x_inset + (105 if uiscale is bui.UIScale.SMALL else 72) - basew
2023-05-17 02:57:02 +05:30
) # now unused
2025-01-13 17:04:48 +05:30
x_dif = (basew - 7) / 2
x_offs2 = x_offs + basew - 7
x_offs3 = x_offs + 2 * (basew - 7)
x_offs4 = x_offs + 3 * (basew - 7)
2025-01-13 17:04:48 +05:30
x_offs5 = x_offs2
x_offs6 = x_offs3
x_offs2 -= x_dif
x_offs3 -= x_dif
x_offs4 -= x_dif
2023-05-17 02:57:02 +05:30
def _b_title(
2025-04-08 22:29:14 +05:30
x: float, y: float, button: bui.Widget, text: str | bui.Lstr
2023-05-17 02:57:02 +05:30
) -> None:
bui.textwidget(
parent=self._root_widget,
text=text,
position=(x + basew * 0.47, y + baseh * 0.22),
maxwidth=basew * 0.7,
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=button,
color=(0.7, 0.9, 0.7, 1.0),
)
ctb = self._controllers_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(x_offs2, v),
size=(basew, baseh),
button_type='square',
label='',
on_activate_call=self._do_controllers,
)
2025-01-13 17:04:48 +05:30
if self._back_button is None:
2023-05-17 02:57:02 +05:30
bbtn = bui.get_special_widget('back_button')
bui.widget(edit=ctb, left_widget=bbtn)
_b_title(
2025-01-13 17:04:48 +05:30
x_offs2, v, ctb, bui.Lstr(resource=f'{self._r}.controllersText')
2023-05-17 02:57:02 +05:30
)
imgw = imgh = 130
2023-05-17 02:57:02 +05:30
bui.imagewidget(
parent=self._root_widget,
position=(x_offs2 + basew * 0.49 - imgw * 0.5, v + 35),
size=(imgw, imgh),
texture=bui.gettexture('controllerIcon'),
draw_controller=ctb,
)
gfxb = self._graphics_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(x_offs3, v),
size=(basew, baseh),
button_type='square',
label='',
on_activate_call=self._do_graphics,
)
2025-01-13 17:04:48 +05:30
pbtn = bui.get_special_widget('squad_button')
bui.widget(edit=gfxb, up_widget=pbtn, right_widget=pbtn)
_b_title(x_offs3, v, gfxb, bui.Lstr(resource=f'{self._r}.graphicsText'))
imgw = imgh = 110
2023-05-17 02:57:02 +05:30
bui.imagewidget(
parent=self._root_widget,
position=(x_offs3 + basew * 0.49 - imgw * 0.5, v + 42),
size=(imgw, imgh),
texture=bui.gettexture('graphicsIcon'),
draw_controller=gfxb,
)
abtn = self._audio_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(x_offs4, v),
size=(basew, baseh),
button_type='square',
up_widget=pbtn,
right_widget=pbtn,
2023-05-17 02:57:02 +05:30
label='',
on_activate_call=self._do_audio,
)
bui.buttonwidget(gfxb, right_widget=abtn)
2025-01-13 17:04:48 +05:30
_b_title(x_offs4, v, abtn, bui.Lstr(resource=f'{self._r}.audioText'))
imgw = imgh = 120
2023-05-17 02:57:02 +05:30
bui.imagewidget(
parent=self._root_widget,
position=(x_offs4 + basew * 0.49 - imgw * 0.5 + 5, v + 35),
size=(imgw, imgh),
color=(1, 1, 0),
texture=bui.gettexture('audioIcon'),
draw_controller=abtn,
)
v -= baseh - 5
avb = self._advanced_button = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
position=(x_offs5, v),
size=(basew, baseh),
button_type='square',
label='',
on_activate_call=self._do_advanced,
)
2025-01-13 17:04:48 +05:30
_b_title(x_offs5, v, avb, bui.Lstr(resource=f'{self._r}.advancedText'))
imgw = imgh = 120
2023-05-17 02:57:02 +05:30
bui.imagewidget(
parent=self._root_widget,
position=(x_offs5 + basew * 0.49 - imgw * 0.5 + 5, v + 35),
size=(imgw, imgh),
color=(0.8, 0.95, 1),
texture=bui.gettexture('advancedIcon'),
draw_controller=avb,
)
2025-01-14 16:49:58 +05:30
self._plugman_button = pmb = bui.buttonwidget(
2025-01-13 17:04:48 +05:30
parent=self._root_widget,
autoselect=True,
position=(x_offs6, v),
size=(basew, baseh),
button_type='square',
label='',
up_widget=abtn,
on_activate_call=self._do_plugman
2025-01-13 17:04:48 +05:30
)
_b_title(x_offs6, v, pmb, bui.Lstr(value="Plugin Manager"))
imgw = imgh = 120
bui.imagewidget(
parent=self._root_widget,
position=(x_offs6 + basew * 0.49 - imgw * 0.5 + 5, v + 35),
size=(imgw, imgh),
color=(0.8, 0.95, 1),
texture=bui.gettexture('storeIcon'),
draw_controller=pmb
2025-01-13 17:04:48 +05:30
)
self._restore_state()
2025-01-13 17:04:48 +05:30
def _do_plugman(self) -> None:
# no-op if we're not in control.
if not self.main_window_has_control():
return
self.main_window_replace(
lambda: PluginManagerWindow(
origin_widget=self._plugman_button
)
2025-01-13 17:04:48 +05:30
)
def _save_state(self) -> None:
try:
sel = self._root_widget.get_selected_child()
if sel == self._controllers_button:
2023-05-17 02:57:02 +05:30
sel_name = 'Controllers'
elif sel == self._graphics_button:
2023-05-17 02:57:02 +05:30
sel_name = 'Graphics'
elif sel == self._audio_button:
2023-05-17 02:57:02 +05:30
sel_name = 'Audio'
elif sel == self._advanced_button:
2023-05-17 02:57:02 +05:30
sel_name = 'Advanced'
2025-01-13 17:04:48 +05:30
elif sel == self._plugman_button:
sel_name = 'PlugMan'
elif sel == self._back_button:
2023-05-17 02:57:02 +05:30
sel_name = 'Back'
else:
2023-05-17 02:57:02 +05:30
raise ValueError(f'unrecognized selection \'{sel}\'')
assert bui.app.classic is not None
2025-01-13 17:04:48 +05:30
bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
except Exception:
2023-05-17 02:57:02 +05:30
logging.exception('Error saving state for %s.', self)
def _restore_state(self) -> None:
try:
2023-05-17 02:57:02 +05:30
assert bui.app.classic is not None
2023-06-21 00:33:31 +05:30
sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
2023-05-17 02:57:02 +05:30
'sel_name'
)
2025-04-08 22:29:14 +05:30
sel: bui.Widget | None
2023-05-17 02:57:02 +05:30
if sel_name == 'Controllers':
sel = self._controllers_button
2023-05-17 02:57:02 +05:30
elif sel_name == 'Graphics':
sel = self._graphics_button
2023-05-17 02:57:02 +05:30
elif sel_name == 'Audio':
sel = self._audio_button
2023-05-17 02:57:02 +05:30
elif sel_name == 'Advanced':
sel = self._advanced_button
2025-01-13 17:04:48 +05:30
elif sel_name == "PlugMan":
sel = self._plugman_button
2023-05-17 02:57:02 +05:30
elif sel_name == 'Back':
sel = self._back_button
else:
sel = self._controllers_button
if sel is not None:
2023-05-17 02:57:02 +05:30
bui.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
2023-05-17 02:57:02 +05:30
logging.exception('Error restoring state for %s.', self)
2023-05-17 02:57:02 +05:30
# ba_meta export babase.Plugin
class EntryPoint(babase.Plugin):
2022-07-21 19:12:31 +05:30
def on_app_running(self) -> None:
"""Called when the app is being launched."""
2023-06-08 21:24:02 +05:30
from bauiv1lib.settings import allsettings
allsettings.AllSettingsWindow = NewAllSettingsWindow
2022-12-17 20:10:00 +05:30
DNSBlockWorkaround.apply()
babase.app.add_shutdown_task(_shutdown_network_pool())
startup_tasks = StartupTasks()
2023-10-03 08:20:09 +00:00
loop.create_task(startup_tasks.execute())