updating ba_data

This commit is contained in:
Ayush Saini 2024-11-28 00:23:35 +05:30
parent b0b6865bdf
commit 48af420a73
92 changed files with 3174 additions and 1075 deletions

View file

@ -27,6 +27,7 @@ from _babase import (
apptime,
apptimer,
AppTimer,
asset_loads_allowed,
fullscreen_control_available,
fullscreen_control_get,
fullscreen_control_key_shortcut,
@ -207,6 +208,7 @@ __all__ = [
'apptime',
'apptimer',
'AppTimer',
'asset_loads_allowed',
'Call',
'fullscreen_control_available',
'fullscreen_control_get',

View file

@ -6,9 +6,9 @@ from __future__ import annotations
import hashlib
import logging
from functools import partial
from typing import TYPE_CHECKING, assert_never
from efro.call import tpartial
from efro.error import CommunicationError
from bacommon.login import LoginType
import _babase
@ -223,7 +223,7 @@ class AccountV2Subsystem:
if service_str is not None:
_babase.apptimer(
2.0,
tpartial(
partial(
_babase.screenmessage,
Lstr(
resource='notUsingAccountText',

View file

@ -7,13 +7,11 @@ from __future__ import annotations
import os
import logging
from enum import Enum
from functools import partial
from typing import TYPE_CHECKING, TypeVar, override
from concurrent.futures import ThreadPoolExecutor
from threading import RLock
from efro.call import tpartial
import _babase
from babase._language import LanguageSubsystem
from babase._plugin import PluginSubsystem
@ -512,7 +510,7 @@ class App:
# Do the actual work of calcing our app-mode/etc. in a bg thread
# since it may block for a moment to load modules/etc.
self.threadpool_submit_no_wait(tpartial(self._set_intent, intent))
self.threadpool_submit_no_wait(partial(self._set_intent, intent))
def push_apply_app_config(self) -> None:
"""Internal. Use app.config.apply() to apply app config changes."""
@ -644,13 +642,13 @@ class App:
# kick back to the logic thread to apply.
mode = modetype()
_babase.pushcall(
tpartial(self._apply_intent, intent, mode),
partial(self._apply_intent, intent, mode),
from_other_thread=True,
)
except Exception:
logging.exception('Error setting app intent to %s.', intent)
_babase.pushcall(
tpartial(self._display_set_intent_error, intent),
partial(self._display_set_intent_error, intent),
from_other_thread=True,
)

View file

@ -7,10 +7,10 @@ import gc
import os
import logging
from threading import Thread
from functools import partial
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
from efro.call import tpartial
from efro.log import LogLevel
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
@ -18,7 +18,7 @@ import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Any, TextIO
from typing import Any, TextIO, Callable
import babase
@ -320,7 +320,7 @@ def dump_app_state(
# We want this to work from any thread, so need to kick this part
# over to the logic thread so timer works.
_babase.pushcall(
tpartial(_babase.apptimer, delay + 1.0, log_dumped_app_state),
partial(_babase.apptimer, delay + 1.0, log_dumped_app_state),
from_other_thread=True,
suppress_other_thread_warning=True,
)

View file

@ -3,6 +3,7 @@
"""Utility snippets applying to generic Python code."""
from __future__ import annotations
import sys
import types
import weakref
import random
@ -15,8 +16,8 @@ from efro.terminal import Clr
import _babase
if TYPE_CHECKING:
import functools
from typing import Any
from efro.call import Call as Call # 'as Call' so we re-export.
# Declare distinct types for different time measurements we use so the
@ -66,7 +67,9 @@ def existing(obj: ExistableT | None) -> ExistableT | None:
return obj if obj is not None and obj.exists() else None
def getclass(name: str, subclassof: type[T]) -> type[T]:
def getclass(
name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False
) -> type[T]:
"""Given a full class name such as foo.bar.MyClass, return the class.
Category: **General Utility Functions**
@ -79,6 +82,8 @@ def getclass(name: str, subclassof: type[T]) -> type[T]:
splits = name.split('.')
modulename = '.'.join(splits[:-1])
classname = splits[-1]
if modulename in sys.stdlib_module_names and check_sdlib_modulename_clash:
raise Exception(f'{modulename} is an inbuilt module.')
module = importlib.import_module(modulename)
cls: type = getattr(module, classname)
@ -166,7 +171,7 @@ class _WeakCall:
if not self._did_invalid_call_warning:
logging.warning(
'Warning: callable passed to babase.WeakCall() is not'
' weak-referencable (%s); use babase.Call() instead'
' weak-referencable (%s); use functools.partial instead'
' to avoid this warning.',
stack_info=True,
)
@ -199,9 +204,13 @@ class _Call:
The callable is strong-referenced so it won't die until this
object does.
WARNING: This is exactly the same as Python's built in functools.partial().
Use functools.partial instead of this for new code, as this will probably
be deprecated at some point.
Note that a bound method (ex: ``myobj.dosomething``) contains a reference
to ``self`` (``myobj`` in that case), so you will be keeping that object
alive too. Use babase.WeakCall if you want to pass a method to callback
alive too. Use babase.WeakCall if you want to pass a method to a callback
without keeping its object alive.
"""
@ -239,12 +248,16 @@ class _Call:
if TYPE_CHECKING:
# Some interaction between our ballistica pylint plugin
# and this code is crashing starting on pylint 2.15.0.
# This seems to fix things for now.
# For type-checking, point at functools.partial which gives us full
# type checking on both positional and keyword arguments (as of mypy
# 1.11).
# Note: Something here is wonky with pylint, possibly related to our
# custom pylint plugin. Disabling all checks seems to fix it.
# pylint: disable=all
WeakCall = Call
Call = Call
WeakCall = functools.partial
Call = functools.partial
else:
WeakCall = _WeakCall
WeakCall.__name__ = 'WeakCall'

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import os
import json
import logging
from functools import partial
from typing import TYPE_CHECKING, overload, override
import _babase
@ -32,6 +33,7 @@ class LanguageSubsystem(AppSubsystem):
self._language: str | None = None
self._language_target: AttrDict | None = None
self._language_merged: AttrDict | None = None
self._test_timer: babase.AppTimer | None = None
@property
def locale(self) -> str:
@ -93,9 +95,44 @@ class LanguageSubsystem(AppSubsystem):
name for name in names if self._can_display_language(name)
)
def testlanguage(self, langid: str) -> None:
"""Set the app to test an in-progress language.
Pass a language id from the translation editor website as 'langid';
something like 'Gibberish_3263'. Once set to testing, the engine
will repeatedly download and apply that same test language, so
changes can be made to it and observed live.
"""
print(
f'Language test mode enabled.'
f' Will fetch and apply \'{langid}\' every 5 seconds,'
f' so you can see your changes live.'
)
self._test_timer = _babase.AppTimer(
5.0, partial(self._update_test_language, langid), repeat=True
)
self._update_test_language(langid)
def _on_test_lang_response(
self, langid: str, response: None | dict[str, Any]
) -> None:
if response is None:
return
self.setlanguage(response)
print(f'Fetched and applied {langid}.')
def _update_test_language(self, langid: str) -> None:
if _babase.app.classic is None:
raise RuntimeError('This requires classic.')
_babase.app.classic.master_server_v1_get(
'bsLangGet',
{'lang': langid, 'format': 'json'},
partial(self._on_test_lang_response, langid),
)
def setlanguage(
self,
language: str | None,
language: str | dict | None,
print_change: bool = True,
store_to_config: bool = True,
) -> None:
@ -110,18 +147,6 @@ class LanguageSubsystem(AppSubsystem):
cfg = _babase.app.config
cur_language = cfg.get('Lang', None)
# Store this in the config if its changing.
if language != cur_language and store_to_config:
if language is None:
if 'Lang' in cfg:
del cfg['Lang'] # Clear it out for default.
else:
cfg['Lang'] = language
cfg.commit()
switched = True
else:
switched = False
with open(
os.path.join(
_babase.app.env.data_directory,
@ -134,32 +159,55 @@ class LanguageSubsystem(AppSubsystem):
) as infile:
lenglishvalues = json.loads(infile.read())
# None implies default.
if language is None:
language = self.default_language
try:
if language == 'English':
lmodvalues = None
else:
lmodfile = os.path.join(
_babase.app.env.data_directory,
'ba_data',
'data',
'languages',
language.lower() + '.json',
)
with open(lmodfile, encoding='utf-8') as infile:
lmodvalues = json.loads(infile.read())
except Exception:
logging.exception("Error importing language '%s'.", language)
_babase.screenmessage(
f"Error setting language to '{language}'; see log for details.",
color=(1, 0, 0),
)
# Special case - passing a complete dict for testing.
if isinstance(language, dict):
self._language = 'Custom'
lmodvalues = language
switched = False
lmodvalues = None
print_change = False
store_to_config = False
else:
# Ok, we're setting a real language.
self._language = language
# Store this in the config if its changing.
if language != cur_language and store_to_config:
if language is None:
if 'Lang' in cfg:
del cfg['Lang'] # Clear it out for default.
else:
cfg['Lang'] = language
cfg.commit()
switched = True
else:
switched = False
# None implies default.
if language is None:
language = self.default_language
try:
if language == 'English':
lmodvalues = None
else:
lmodfile = os.path.join(
_babase.app.env.data_directory,
'ba_data',
'data',
'languages',
language.lower() + '.json',
)
with open(lmodfile, encoding='utf-8') as infile:
lmodvalues = json.loads(infile.read())
except Exception:
logging.exception("Error importing language '%s'.", language)
_babase.screenmessage(
f"Error setting language to '{language}';"
f' see log for details.',
color=(1, 0, 0),
)
switched = False
lmodvalues = None
self._language = language
# Create an attrdict of *just* our target language.
self._language_target = AttrDict()
@ -207,6 +255,7 @@ class LanguageSubsystem(AppSubsystem):
random_names = [n for n in random_names if n != '']
_babase.set_internal_language_keys(internal_vals, random_names)
if switched and print_change:
assert isinstance(language, str)
_babase.screenmessage(
Lstr(
resource='languageSetText',

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import time
import logging
from functools import partial
from dataclasses import dataclass
from typing import TYPE_CHECKING, final, override
@ -162,8 +163,8 @@ class LoginAdapter:
the adapter will attempt to sign in if possible. An exception will
be returned if the sign-in attempt fails.
"""
assert _babase.in_logic_thread()
from babase._general import Call
# Have been seeing multiple sign-in attempts come through
# nearly simultaneously which can be problematic server-side.
@ -185,7 +186,7 @@ class LoginAdapter:
appnow,
)
_babase.pushcall(
Call(
partial(
result_cb,
self,
RuntimeError('sign_in called too soon after last.'),
@ -215,7 +216,7 @@ class LoginAdapter:
self.login_type.name,
)
_babase.pushcall(
Call(
partial(
result_cb,
self,
RuntimeError('fetch-sign-in-token failed.'),
@ -245,7 +246,7 @@ class LoginAdapter:
self.login_type.name,
response,
)
_babase.pushcall(Call(result_cb, self, response))
_babase.pushcall(partial(result_cb, self, response))
else:
# This means our credentials were explicitly rejected.
if response.credentials is None:
@ -262,7 +263,7 @@ class LoginAdapter:
result2 = self.SignInResult(
credentials=response.credentials
)
_babase.pushcall(Call(result_cb, self, result2))
_babase.pushcall(partial(result_cb, self, result2))
assert _babase.app.plus is not None
_babase.app.plus.cloud.send_message_cb(
@ -293,10 +294,9 @@ class LoginAdapter:
as needed. The provided completion_cb should then be called with
either a token or None if sign in failed or was cancelled.
"""
from babase._general import Call
# Default implementation simply fails immediately.
_babase.pushcall(Call(completion_cb, None))
_babase.pushcall(partial(completion_cb, None))
def _update_implicit_login_state(self) -> None:
# If we've received an implicit login state, schedule it to be
@ -304,7 +304,6 @@ class LoginAdapter:
# called so that account-client-v2 has had a chance to load
# any existing state so it can properly respond to this.
if self._implicit_login_state_dirty and self._on_app_loading_called:
from babase._general import Call
if DEBUG_LOG:
logging.debug(
@ -315,7 +314,7 @@ class LoginAdapter:
assert _babase.app.plus is not None
_babase.pushcall(
Call(
partial(
_babase.app.plus.accounts.on_implicit_login_state_changed,
self.login_type,
self._implicit_login_state,

View file

@ -7,12 +7,12 @@ from __future__ import annotations
import os
import time
import logging
from threading import Thread
from pathlib import Path
from threading import Thread
from functools import partial
from typing import TYPE_CHECKING, TypeVar
from dataclasses import dataclass, field
from efro.call import tpartial
import _babase
if TYPE_CHECKING:
@ -116,7 +116,7 @@ class MetadataSubsystem:
loading work happens, pass completion_cb_in_bg_thread=True.
"""
Thread(
target=tpartial(
target=partial(
self._load_exported_classes,
cls,
completion_cb,
@ -145,7 +145,7 @@ class MetadataSubsystem:
except Exception:
logging.exception('Error loading exported classes.')
completion_call = tpartial(completion_cb, classes)
completion_call = partial(completion_cb, classes)
if completion_cb_in_bg_thread:
completion_call()
else:

View file

@ -126,7 +126,7 @@ class SpecialChar(Enum):
OUYA_BUTTON_U = 23
OUYA_BUTTON_Y = 24
OUYA_BUTTON_A = 25
OUYA_LOGO = 26
TOKEN = 26
LOGO = 27
TICKET = 28
GOOGLE_PLAY_GAMES_LOGO = 29

View file

@ -4,11 +4,13 @@
from __future__ import annotations
import ssl
import socket
import threading
import ipaddress
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import socket
pass
# Timeout for standard functions talking to the master-server/etc.
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
@ -18,6 +20,10 @@ class NetworkSubsystem:
"""Network related app subsystem."""
def __init__(self) -> None:
# Our shared SSL context. Creating these can be expensive so we
# create it here once and recycle for our various connections.
self.sslcontext = ssl.create_default_context()
# Anyone accessing/modifying zone_pings should hold this lock,
# as it is updated by a background thread.
self.zone_pings_lock = threading.Lock()
@ -27,8 +33,6 @@ class NetworkSubsystem:
# that a nearby server has been pinged.
self.zone_pings: dict[str, float] = {}
self._sslcontext: ssl.SSLContext | None = None
# For debugging.
self.v1_test_log: str = ''
self.v1_ctest_results: dict[int, str] = {}
@ -36,42 +40,12 @@ class NetworkSubsystem:
self.transport_state = 'uninited'
self.server_time_offset_hours: float | None = None
@property
def sslcontext(self) -> ssl.SSLContext:
"""Create/return our shared SSLContext.
This can be reused for all standard urllib requests/etc.
"""
# Note: I've run into older Android devices taking upwards of 1 second
# to put together a default SSLContext, so recycling one can definitely
# be a worthwhile optimization. This was suggested to me in this
# thread by one of Python's SSL maintainers:
# https://github.com/python/cpython/issues/94637
if self._sslcontext is None:
self._sslcontext = ssl.create_default_context()
return self._sslcontext
def get_ip_address_type(addr: str) -> socket.AddressFamily:
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
import socket
socket_type = None
# First try it as an ipv4 address.
try:
socket.inet_pton(socket.AF_INET, addr)
socket_type = socket.AF_INET
except OSError:
pass
# Hmm apparently not ipv4; try ipv6.
if socket_type is None:
try:
socket.inet_pton(socket.AF_INET6, addr)
socket_type = socket.AF_INET6
except OSError:
pass
if socket_type is None:
raise ValueError(f'addr seems to be neither v4 or v6: {addr}')
return socket_type
version = ipaddress.ip_address(addr).version
if version == 4:
return socket.AF_INET
assert version == 6
return socket.AF_INET6

View file

@ -278,7 +278,7 @@ class PluginSpec:
if not self.loadable:
return None
try:
cls = getclass(self.class_path, Plugin)
cls = getclass(self.class_path, Plugin, True)
except Exception as exc:
_babase.getsimplesound('error').play()
_babase.screenmessage(

View file

@ -9,9 +9,9 @@ import sys
import logging
from pathlib import Path
from threading import Thread
from functools import partial
from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CleanError
import _babase
import bacommon.cloud
@ -118,7 +118,7 @@ class WorkspaceSubsystem:
state.iteration += 1
_babase.pushcall(
tpartial(
partial(
self._successmsg,
Lstr(
resource='activatedText',
@ -130,7 +130,7 @@ class WorkspaceSubsystem:
except _SkipSyncError:
_babase.pushcall(
tpartial(
partial(
self._errmsg,
Lstr(
resource='workspaceSyncReuseText',
@ -145,7 +145,7 @@ class WorkspaceSubsystem:
# be in wonky state.
set_path = False
_babase.pushcall(
tpartial(self._errmsg, Lstr(value=str(exc))),
partial(self._errmsg, Lstr(value=str(exc))),
from_other_thread=True,
)
except Exception:
@ -153,7 +153,7 @@ class WorkspaceSubsystem:
set_path = False
logging.exception("Error syncing workspace '%s'.", workspacename)
_babase.pushcall(
tpartial(
partial(
self._errmsg,
Lstr(
resource='workspaceSyncErrorText',

View file

@ -133,7 +133,10 @@ def create_user_system_scripts() -> None:
if env.python_directory_app is None:
raise RuntimeError('app python dir unset')
path = f'{env.python_directory_user}/sys/{env.engine_version}'
path = (
f'{env.python_directory_user}/sys/'
f'{env.engine_version}_{env.engine_build_number}'
)
pathtmp = path + '_tmp'
if os.path.exists(path):
print('Delete Existing User Scripts first!')
@ -181,7 +184,10 @@ def delete_user_system_scripts() -> None:
if env.python_directory_user is None:
raise RuntimeError('user python dir unset')
path = f'{env.python_directory_user}/sys/{env.engine_version}'
path = (
f'{env.python_directory_user}/sys/'
f'{env.engine_version}_{env.engine_build_number}'
)
if os.path.exists(path):
shutil.rmtree(path)
print('User system scripts deleted.')

View file

@ -271,7 +271,10 @@ class AccountV1Subsystem:
),
color=(0, 1, 0),
)
babase.getsimplesound('click01').play()
# Ick; this can get called early in the bootstrapping process
# before we're allowed to load assets. Guard against that.
if babase.asset_loads_allowed():
babase.getsimplesound('click01').play()
def on_account_state_changed(self) -> None:
"""(internal)"""

View file

@ -96,13 +96,9 @@ class MasterServerV1CallThread(threading.Thread):
self._data = babase.utf8_all(self._data)
babase.set_thread_name('BA_ServerCallThread')
if self._request_type == 'get':
url = (
plus.get_master_server_address()
+ '/'
+ self._request
+ '?'
+ urllib.parse.urlencode(self._data)
)
msaddr = plus.get_master_server_address()
dataenc = urllib.parse.urlencode(self._data)
url = f'{msaddr}/{self._request}?{dataenc}'
assert url is not None
response = urllib.request.urlopen(
urllib.request.Request(
@ -114,7 +110,7 @@ class MasterServerV1CallThread(threading.Thread):
timeout=babase.DEFAULT_REQUEST_TIMEOUT_SECONDS,
)
elif self._request_type == 'post':
url = plus.get_master_server_address() + '/' + self._request
url = f'{plus.get_master_server_address()}/{self._request}'
assert url is not None
response = urllib.request.urlopen(
urllib.request.Request(

View file

@ -460,6 +460,6 @@ class ServerController:
bascenev1.new_host_session(sessiontype)
# Run an access check if we're trying to make a public party.
if not self._ran_access_check:
if not self._ran_access_check :
self._run_access_check()
self._ran_access_check = True

View file

@ -14,7 +14,26 @@ if TYPE_CHECKING:
# Version is sent to the master-server with all commands. Can be incremented
# if we need to change behavior server-side to go along with client changes.
BACLOUD_VERSION = 8
BACLOUD_VERSION = 13
def asset_file_cache_path(filehash: str) -> str:
"""Given a sha256 hex file hash, return a storage path."""
# We expect a 64 byte hex str with only lowercase letters and
# numbers. Note to self: I considered base64 hashes to save space
# but then remembered that lots of filesystems out there ignore case
# so that would not end well.
assert len(filehash) == 64
assert filehash.islower()
assert filehash.isalnum()
# Split into a few levels of directories to keep directory listings
# and operations reasonable. This will give 256 top level dirs, each
# with 256 subdirs. So if we have 65,536 files in our cache then
# dirs will average 1 file each. That seems like a reasonable spread
# I think.
return f'{filehash[:2]}/{filehash[2:4]}/{filehash[4:]}'
@ioprepped
@ -32,7 +51,6 @@ class RequestData:
@ioprepped
@dataclass
class ResponseData:
# noinspection PyUnresolvedReferences
"""Response sent from the bacloud server to the client.
Attributes:
@ -49,11 +67,13 @@ class ResponseData:
It should be added to end_command args as 'manifest'.
uploads: If present, client should upload the requested files (arg1)
individually to a server command (arg2) with provided args (arg3).
uploads_inline: If present, a list of pathnames that should be base64
gzipped and uploaded to an 'uploads_inline' dict in end_command args.
uploads_inline: If present, a list of pathnames that should be gzipped
and uploaded to an 'uploads_inline' bytes dict in end_command args.
This should be limited to relatively small files.
deletes: If present, file paths that should be deleted on the client.
downloads_inline: If present, pathnames mapped to base64 gzipped data to
downloads: If present, describes files the client should individually
request from the server if not already present on the client.
downloads_inline: If present, pathnames mapped to gzipped data to
be written to the client. This should only be used for relatively
small files as they are all included inline as part of the response.
dir_prune_empty: If present, all empty dirs under this one should be
@ -69,6 +89,39 @@ class ResponseData:
end_command: If present, this command is run with these args at the end
of response processing.
"""
@ioprepped
@dataclass
class Downloads:
"""Info about downloads included in a response."""
@ioprepped
@dataclass
class Entry:
"""Individual download."""
path: Annotated[str, IOAttrs('p')]
# Args include with this particular request (combined with
# baseargs).
args: Annotated[dict[str, str], IOAttrs('a')]
# TODO: could add a hash here if we want the client to
# verify hashes.
# If present, will be prepended to all entry paths via os.path.join.
basepath: Annotated[str | None, IOAttrs('p')]
# Server command that should be called for each download. The
# server command is expected to respond with a downloads_inline
# containing a single 'default' entry. In the future this may
# be expanded to a more streaming-friendly process.
cmd: Annotated[str, IOAttrs('c')]
# Args that should be included with all download requests.
baseargs: Annotated[dict[str, str], IOAttrs('a')]
# Everything that should be downloaded.
entries: Annotated[list[Entry], IOAttrs('e')]
message: Annotated[str | None, IOAttrs('m', store_default=False)] = None
message_end: Annotated[str, IOAttrs('m_end', store_default=False)] = '\n'
error: Annotated[str | None, IOAttrs('e', store_default=False)] = None
@ -87,8 +140,11 @@ class ResponseData:
deletes: Annotated[
list[str] | None, IOAttrs('dlt', store_default=False)
] = None
downloads: Annotated[
Downloads | None, IOAttrs('dl', store_default=False)
] = None
downloads_inline: Annotated[
dict[str, str] | None, IOAttrs('dinl', store_default=False)
dict[str, bytes] | None, IOAttrs('dinl', store_default=False)
] = None
dir_prune_empty: Annotated[
str | None, IOAttrs('dpe', store_default=False)

View file

@ -16,6 +16,13 @@ if TYPE_CHECKING:
pass
class WebLocation(Enum):
"""Set of places we can be directed on ballistica.net."""
ACCOUNT_EDITOR = 'e'
ACCOUNT_DELETE_SECTION = 'd'
@ioprepped
@dataclass
class LoginProxyRequestMessage(Message):
@ -238,6 +245,10 @@ class SignInResponse(Response):
class ManageAccountMessage(Message):
"""Message asking for a manage-account url."""
weblocation: Annotated[WebLocation, IOAttrs('l')] = (
WebLocation.ACCOUNT_EDITOR
)
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
@ -250,3 +261,65 @@ class ManageAccountResponse(Response):
"""Here's that sign-in result you asked for, boss."""
url: Annotated[str | None, IOAttrs('u')]
@ioprepped
@dataclass
class StoreQueryMessage(Message):
"""Message asking about purchasable stuff and store related state."""
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [StoreQueryResponse]
@ioprepped
@dataclass
class StoreQueryResponse(Response):
"""Here's that store info you asked for, boss."""
class Result(Enum):
"""Our overall result."""
SUCCESS = 's'
ERROR = 'e'
@dataclass
class Purchase:
"""Info about a purchasable thing."""
purchaseid: Annotated[str, IOAttrs('id')]
# Overall result; all data is undefined if not SUCCESS.
result: Annotated[Result, IOAttrs('r')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
available_purchases: Annotated[list[Purchase], IOAttrs('p')]
token_info_url: Annotated[str, IOAttrs('tiu')]
@ioprepped
@dataclass
class BSPrivatePartyMessage(Message):
"""Message asking about info we need for private-party UI."""
need_datacode: Annotated[bool, IOAttrs('d')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [BSPrivatePartyResponse]
@ioprepped
@dataclass
class BSPrivatePartyResponse(Response):
"""Here's that private party UI info you asked for, boss."""
success: Annotated[bool, IOAttrs('s')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
datacode: Annotated[str | None, IOAttrs('d')]

View file

@ -40,3 +40,15 @@ class LoginType(Enum):
return 'Google Play Games'
case cls.GAME_CENTER:
return 'Game Center'
@property
def displaynameshort(self) -> str:
"""Human readable name for this value."""
cls = type(self)
match self:
case cls.EMAIL:
return 'Email'
case cls.GPGS:
return 'GPGS'
case cls.GAME_CENTER:
return 'Game Center'

View file

@ -64,6 +64,7 @@ class PrivateHostingState:
unavailable_error: str | None = None
party_code: str | None = None
tickets_to_host_now: int = 0
tokens_to_host_now: int = 0
minutes_until_free_host: float | None = None
free_host_minutes_remaining: float | None = None

View file

@ -0,0 +1,3 @@
# Released under the MIT License. See LICENSE for details.
#
"""Workspace functionality."""

View file

@ -0,0 +1,97 @@
# Released under the MIT License. See LICENSE for details.
#
"""Public types for assets-v1 workspaces.
These types may only be used server-side, but they are exposed here
for reference when setting workspace config data by hand or for use
in client-side workspace modification tools. There may be advanced
settings that are not accessible through the UI/etc.
"""
from __future__ import annotations
from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, override, assert_never
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class AssetsV1GlobalVals:
"""Global values for an assets_v1 workspace."""
base_assets: Annotated[
str | None, IOAttrs('base_assets', store_default=False)
] = None
base_assets_filter: Annotated[
str, IOAttrs('base_assets_filter', store_default=False)
] = ''
class AssetsV1PathValsTypeID(Enum):
"""Types of vals we can store for paths."""
TEX_V1 = 'tex_v1'
class AssetsV1PathVals(IOMultiType[AssetsV1PathValsTypeID]):
"""Top level class for path vals classes."""
@override
@classmethod
def get_type_id_storage_name(cls) -> str:
return 'type'
@override
@classmethod
def get_type_id(cls) -> AssetsV1PathValsTypeID:
# Require child classes to supply this themselves. If we
# did a full type registry/lookup here it would require us
# to import everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(
cls, type_id: AssetsV1PathValsTypeID
) -> type[AssetsV1PathVals]:
# pylint: disable=cyclic-import
out: type[AssetsV1PathVals]
t = AssetsV1PathValsTypeID
if type_id is t.TEX_V1:
out = AssetsV1PathValsTexV1
else:
# Important to make sure we provide all types.
assert_never(type_id)
return out
@ioprepped
@dataclass
class AssetsV1PathValsTexV1(AssetsV1PathVals):
"""Path-specific values for an assets_v1 workspace path."""
class TextureQuality(Enum):
"""Quality settings for our textures."""
LOW = 'low'
MEDIUM = 'medium'
HIGH = 'high'
# Just dummy testing values for now.
texture_quality: Annotated[
TextureQuality, IOAttrs('texture_quality', store_default=False)
] = TextureQuality.MEDIUM
@override
@classmethod
def get_type_id(cls) -> AssetsV1PathValsTypeID:
return AssetsV1PathValsTypeID.TEX_V1

View file

@ -52,8 +52,8 @@ if TYPE_CHECKING:
# Build number and version of the ballistica binary we expect to be
# using.
TARGET_BALLISTICA_BUILD = 21879
TARGET_BALLISTICA_VERSION = '1.7.35'
TARGET_BALLISTICA_BUILD = 21949
TARGET_BALLISTICA_VERSION = '1.7.37'
@dataclass
@ -348,12 +348,16 @@ def _setup_paths(
if user_python_dir is None:
user_python_dir = str(Path(config_dir, 'mods'))
# Wherever our user_python_dir is, if we find a sys/FOO dir
# under it where FOO matches our version, use that as our
# app_python_dir. This allows modding built-in stuff on
# platforms where there is no write access to said built-in
# stuff.
check_dir = Path(user_python_dir, 'sys', TARGET_BALLISTICA_VERSION)
# Wherever our user_python_dir is, if we find a sys/FOO_BAR dir
# under it where FOO matches our version and BAR matches our
# build number, use that as our app_python_dir. This allows
# modding built-in stuff on platforms where there is no write
# access to said built-in stuff.
check_dir = Path(
user_python_dir,
'sys',
f'{TARGET_BALLISTICA_VERSION}_{TARGET_BALLISTICA_BUILD}',
)
try:
if check_dir.is_dir():
app_python_dir = str(check_dir)

View file

@ -100,6 +100,24 @@ class CloudSubsystem(babase.AppSubsystem):
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.StoreQueryMessage,
on_response: Callable[
[bacommon.cloud.StoreQueryResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.BSPrivatePartyMessage,
on_response: Callable[
[bacommon.cloud.BSPrivatePartyResponse | Exception], None
],
) -> None: ...
def send_message_cb(
self,
msg: Message,
@ -194,6 +212,10 @@ def cloud_console_exec(code: str) -> None:
except Exception:
import traceback
# Note to self: Seems like we should just use
# logging.exception() here. Except currently that winds up
# triggering our cloud logging stuff so we'd probably want a
# specific logger or whatnot to avoid that.
apptime = babase.apptime()
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
traceback.print_exc()

View file

@ -254,6 +254,11 @@ class PlusSubsystem(AppSubsystem):
"""(internal)"""
return _baplus.tournament_query(callback, args)
@staticmethod
def supports_purchases() -> bool:
"""Does this platform support in-app-purchases?"""
return _baplus.supports_purchases()
@staticmethod
def have_incentivized_ad() -> bool:
"""Is an incentivized ad available?"""

View file

@ -10,9 +10,8 @@ import babase
import _bascenev1
from bascenev1._activity import Activity
# False-positive from pylint due to our class-generics-filter.
from bascenev1._player import EmptyPlayer # pylint: disable=W0611
from bascenev1._team import EmptyTeam # pylint: disable=W0611
from bascenev1._player import EmptyPlayer
from bascenev1._team import EmptyTeam
from bascenev1._music import MusicType, setmusic

View file

@ -244,8 +244,21 @@ class AssaultGame(bs.TeamGameActivity[Player, Team]):
bs.timer(0.5, light.delete)
bs.animate(light, 'intensity', {0: 0, 0.1: 1.0, 0.5: 0})
if player.actor:
player.actor.handlemessage(
bs.StandMessage(new_pos, random.uniform(0, 360))
random_num = random.uniform(0, 360)
# Slightly hacky workaround: normally,
# teleporting back to base with a sticky
# bomb stuck to you gives a crazy whiplash
# rubber-band effect. Running the teleport
# twice in a row seems to suppress that
# though. Would be better to fix this at a
# lower level, but this works for now.
self._teleport(player, new_pos, random_num)
bs.timer(
0.01,
bs.Call(
self._teleport, player, new_pos, random_num
),
)
# Have teammates celebrate.
@ -258,6 +271,12 @@ class AssaultGame(bs.TeamGameActivity[Player, Team]):
if player_team.score >= self._score_to_win:
self.end_game()
def _teleport(
self, client: Player, pos: Sequence[float], num: float
) -> None:
if client.actor:
client.actor.handlemessage(bs.StandMessage(pos, num))
@override
def end_game(self) -> None:
results = bs.GameResults()

View file

@ -274,9 +274,9 @@ class TutorialActivity(bs.Activity[Player, Team]):
# Need different versions of this: taps/buttons/keys.
txt = (
bs.Lstr(resource=self._r + '.cpuBenchmarkText')
bs.Lstr(resource=f'{self._r}.cpuBenchmarkText')
if self._benchmark_type == 'cpu'
else bs.Lstr(resource=self._r + '.toSkipPressAnythingText')
else bs.Lstr(resource=f'{self._r}.toSkipPressAnythingText')
)
t = self._skip_text = bs.newnode(
'text',
@ -852,13 +852,13 @@ class TutorialActivity(bs.Activity[Player, Team]):
DelayOld(1000),
AnalyticsScreen('Tutorial Section 1'),
Text(
bs.Lstr(resource=self._r + '.phrase01Text')
bs.Lstr(resource=f'{self._r}.phrase01Text')
), # hi there
Celebrate('left'),
DelayOld(2000),
Text(
bs.Lstr(
resource=self._r + '.phrase02Text',
resource=f'{self._r}.phrase02Text',
subs=[
('${APP_NAME}', bs.Lstr(resource='titleText'))
],
@ -888,7 +888,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
MoveUD(0),
DelayOld(1500),
Text(
bs.Lstr(resource=self._r + '.phrase03Text')
bs.Lstr(resource=f'{self._r}.phrase03Text')
), # here's a few tips
DelayOld(1000),
ShowControls(),
@ -900,7 +900,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
AnalyticsScreen('Tutorial Section 2'),
Text(
bs.Lstr(
resource=self._r + '.phrase04Text',
resource=f'{self._r}.phrase04Text',
subs=[
('${APP_NAME}', bs.Lstr(resource='titleText'))
],
@ -1261,7 +1261,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
DelayOld(1000),
Text(
bs.Lstr(resource=self._r + '.phrase05Text')
bs.Lstr(resource=f'{self._r}.phrase05Text')
), # for example when you punch..
DelayOld(510),
Move(0, -0.01),
@ -1279,7 +1279,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
(-3.1, 4.3, -2.0),
make_current=False,
color=(1, 1, 0.4),
name=bs.Lstr(resource=self._r + '.randomName1Text'),
name=bs.Lstr(resource=f'{self._r}.randomName1Text'),
),
Move(-1.0, 0),
DelayOld(1050),
@ -1288,7 +1288,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
DelayOld(1000),
Text(
bs.Lstr(resource=self._r + '.phrase06Text')
bs.Lstr(resource=f'{self._r}.phrase06Text')
), # your damage is based
DelayOld(1200),
Move(-0.05, 0),
@ -1304,12 +1304,12 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
Text(
bs.Lstr(
resource=self._r + '.phrase07Text',
resource=f'{self._r}.phrase07Text',
subs=[
(
'${NAME}',
bs.Lstr(
resource=self._r + '.randomName1Text'
resource=f'{self._r}.randomName1Text'
),
)
],
@ -1319,7 +1319,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Celebrate('right', spaz_num=1),
DelayOld(1400),
Text(
bs.Lstr(resource=self._r + '.phrase08Text')
bs.Lstr(resource=f'{self._r}.phrase08Text')
), # lets jump and spin to get more speed
DelayOld(30),
MoveLR(0),
@ -1520,12 +1520,12 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
DelayOld(1000),
Text(
bs.Lstr(resource=self._r + '.phrase09Text')
bs.Lstr(resource=f'{self._r}.phrase09Text')
), # ah that's better
DelayOld(1900),
AnalyticsScreen('Tutorial Section 3'),
Text(
bs.Lstr(resource=self._r + '.phrase10Text')
bs.Lstr(resource=f'{self._r}.phrase10Text')
), # running also helps
DelayOld(100),
SpawnSpaz(
@ -1536,11 +1536,11 @@ class TutorialActivity(bs.Activity[Player, Team]):
(3.3, 4.2, -5.8),
make_current=False,
color=(0.9, 0.5, 1.0),
name=bs.Lstr(resource=self._r + '.randomName2Text'),
name=bs.Lstr(resource=f'{self._r}.randomName2Text'),
),
DelayOld(1800),
Text(
bs.Lstr(resource=self._r + '.phrase11Text')
bs.Lstr(resource=f'{self._r}.phrase11Text')
), # hold ANY button to run
DelayOld(300),
MoveUD(0),
@ -1797,7 +1797,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
MoveUD(0),
AnalyticsScreen('Tutorial Section 4'),
Text(
bs.Lstr(resource=self._r + '.phrase12Text')
bs.Lstr(resource=f'{self._r}.phrase12Text')
), # for extra-awesome punches,...
DelayOld(200),
SpawnSpaz(
@ -1816,7 +1816,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
make_current=False,
color=(1.0, 0.7, 0.3),
# name=R.randomName3Text),
name=bs.Lstr(resource=self._r + '.randomName3Text'),
name=bs.Lstr(resource=f'{self._r}.randomName3Text'),
),
DelayOld(100),
Powerup(1, (2.5, 0.0, 0), relative_to=0),
@ -2015,12 +2015,12 @@ class TutorialActivity(bs.Activity[Player, Team]):
MoveLR(0),
Text(
bs.Lstr(
resource=self._r + '.phrase13Text',
resource=f'{self._r}.phrase13Text',
subs=[
(
'${NAME}',
bs.Lstr(
resource=self._r + '.randomName3Text'
resource=f'{self._r}.randomName3Text'
),
)
],
@ -2031,12 +2031,12 @@ class TutorialActivity(bs.Activity[Player, Team]):
AnalyticsScreen('Tutorial Section 5'),
Text(
bs.Lstr(
resource=self._r + '.phrase14Text',
resource=f'{self._r}.phrase14Text',
subs=[
(
'${NAME}',
bs.Lstr(
resource=self._r + '.randomName4Text'
resource=f'{self._r}.randomName4Text'
),
)
],
@ -2055,7 +2055,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
relative_to=0,
make_current=False,
color=(0.4, 1.0, 0.7),
name=bs.Lstr(resource=self._r + '.randomName4Text'),
name=bs.Lstr(resource=f'{self._r}.randomName4Text'),
),
DelayOld(1000),
Celebrate('left', 1, duration=1000),
@ -2083,11 +2083,11 @@ class TutorialActivity(bs.Activity[Player, Team]):
),
AnalyticsScreen('Tutorial Section 6'),
Text(
bs.Lstr(resource=self._r + '.phrase15Text')
bs.Lstr(resource=f'{self._r}.phrase15Text')
), # lastly there's bombs
DelayOld(1900),
Text(
bs.Lstr(resource=self._r + '.phrase16Text')
bs.Lstr(resource=f'{self._r}.phrase16Text')
), # throwing bombs takes practice
DelayOld(2000),
Bomb(),
@ -2099,11 +2099,11 @@ class TutorialActivity(bs.Activity[Player, Team]):
Bomb(),
DelayOld(2000),
Text(
bs.Lstr(resource=self._r + '.phrase17Text')
bs.Lstr(resource=f'{self._r}.phrase17Text')
), # not a very good throw
DelayOld(3000),
Text(
bs.Lstr(resource=self._r + '.phrase18Text')
bs.Lstr(resource=f'{self._r}.phrase18Text')
), # moving helps you get distance
DelayOld(1000),
Bomb(),
@ -2121,7 +2121,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
DelayOld(2500),
Text(
bs.Lstr(resource=self._r + '.phrase19Text')
bs.Lstr(resource=f'{self._r}.phrase19Text')
), # jumping helps you get height
DelayOld(2000),
Bomb(),
@ -2141,7 +2141,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
DelayOld(2000),
Text(
bs.Lstr(resource=self._r + '.phrase20Text')
bs.Lstr(resource=f'{self._r}.phrase20Text')
), # whiplash your bombs
DelayOld(1000),
Bomb(release=False),
@ -2303,7 +2303,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
DelayOld(2000),
AnalyticsScreen('Tutorial Section 7'),
Text(
bs.Lstr(resource=self._r + '.phrase21Text')
bs.Lstr(resource=f'{self._r}.phrase21Text')
), # timing your bombs can be tricky
Move(-1, 0),
DelayOld(1000),
@ -2323,7 +2323,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
relative_to=0,
make_current=False,
color=(0.3, 0.8, 1.0),
name=bs.Lstr(resource=self._r + '.randomName5Text'),
name=bs.Lstr(resource=f'{self._r}.randomName5Text'),
),
DelayOld2(1000),
Move(-1, 0),
@ -2341,12 +2341,12 @@ class TutorialActivity(bs.Activity[Player, Team]):
DelayOld2(1000),
Move(0, 0),
DelayOld2(1500),
Text(bs.Lstr(resource=self._r + '.phrase22Text')), # dang
Text(bs.Lstr(resource=f'{self._r}.phrase22Text')), # dang
Delay(1500),
Text(''),
Delay(200),
Text(
bs.Lstr(resource=self._r + '.phrase23Text')
bs.Lstr(resource=f'{self._r}.phrase23Text')
), # try cooking off
Delay(1500),
Bomb(),
@ -2362,7 +2362,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
Move(0, 0),
Delay(2000),
Text(
bs.Lstr(resource=self._r + '.phrase24Text')
bs.Lstr(resource=f'{self._r}.phrase24Text')
), # hooray nicely cooked
Celebrate(),
DelayOld(2000),
@ -2376,23 +2376,23 @@ class TutorialActivity(bs.Activity[Player, Team]):
DelayOld(1000),
AnalyticsScreen('Tutorial Section 8'),
Text(
bs.Lstr(resource=self._r + '.phrase25Text')
bs.Lstr(resource=f'{self._r}.phrase25Text')
), # well that's just about it
DelayOld(2000),
Text(
bs.Lstr(resource=self._r + '.phrase26Text')
bs.Lstr(resource=f'{self._r}.phrase26Text')
), # go get em tiger
DelayOld(2000),
Text(
bs.Lstr(resource=self._r + '.phrase27Text')
bs.Lstr(resource=f'{self._r}.phrase27Text')
), # remember you training
DelayOld(3000),
Text(
bs.Lstr(resource=self._r + '.phrase28Text')
bs.Lstr(resource=f'{self._r}.phrase28Text')
), # well maybe
DelayOld(1600),
Text(
bs.Lstr(resource=self._r + '.phrase29Text')
bs.Lstr(resource=f'{self._r}.phrase29Text')
), # good luck
Celebrate('right', duration=10000),
DelayOld(1000),
@ -2440,7 +2440,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
assert self._skip_count_text
self._skip_count_text.text = (
bs.Lstr(
resource=self._r + '.skipVoteCountText',
resource=f'{self._r}.skipVoteCountText',
subs=[
('${COUNT}', str(count)),
('${TOTAL}', str(len(self.players))),
@ -2460,7 +2460,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
bs.getsound('swish').play()
# self._skip_count_text.text = self._r.skippingText
self._skip_count_text.text = bs.Lstr(
resource=self._r + '.skippingText'
resource=f'{self._r}.skippingText'
)
assert self._skip_text
self._skip_text.text = ''
@ -2474,7 +2474,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
self._issued_warning = True
assert self._skip_text
self._skip_text.text = bs.Lstr(
resource=self._r + '.skipConfirmText'
resource=f'{self._r}.skipConfirmText'
)
self._skip_text.color = (1, 1, 1)
self._skip_text.scale = 1.3
@ -2510,7 +2510,7 @@ class TutorialActivity(bs.Activity[Player, Team]):
def _revert_confirm(self) -> None:
assert self._skip_text
self._skip_text.text = bs.Lstr(
resource=self._r + '.toSkipPressAnythingText'
resource=f'{self._r}.toSkipPressAnythingText'
)
self._skip_text.color = (1, 1, 1)
self._issued_warning = False

View file

@ -61,13 +61,11 @@ def party_icon_activate(origin: Sequence[float]) -> None:
def on_button_press_x() ->None:
import ui_hooks
ui_hooks.on_button_xy_press("X")
print("button X pressed from UI or keyboard")
def on_button_press_y() ->None:
import ui_hooks
ui_hooks.on_button_xy_press("X")
print("button Y pressed from UI or keyboard")
def quit_window(quit_type: babase.QuitType) -> None:

View file

@ -8,10 +8,12 @@ from __future__ import annotations
import time
import logging
from bacommon.cloud import WebLocation
from bacommon.login import LoginType
import bacommon.cloud
import bauiv1 as bui
# These days we're directing people to the web based account settings
# for V2 account linking and trying to get them to disconnect remaining
# V1 links, but leaving this escape hatch here in case needed.
@ -150,7 +152,7 @@ class AccountSettingsWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 41),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=app.ui_v1.title_color,
maxwidth=self._width - 340,
h_align='center',
@ -346,11 +348,12 @@ class AccountSettingsWindow(bui.Window):
show_reset_progress_button = False
reset_progress_button_space = 70.0
show_manage_v2_account_button = (
self._v1_signed_in and v1_account_type == 'V2'
)
show_manage_v2_account_button = primary_v2_account is not None
manage_v2_account_button_space = 100.0
show_delete_account_button = primary_v2_account is not None
delete_account_button_space = 80.0
show_player_profiles_button = self._v1_signed_in
player_profiles_button_space = (
70.0 if show_manage_v2_account_button else 100.0
@ -365,22 +368,19 @@ class AccountSettingsWindow(bui.Window):
unlink_accounts_button_space = 90.0
# Phasing this out.
# show_v2_link_info = self._v1_signed_in
# and not show_link_accounts_button
show_v2_link_info = False
v2_link_info_space = 70.0
legacy_unlink_button_space = 120.0
show_sign_out_button = self._v1_signed_in and v1_account_type in [
'Local',
'V2',
]
show_sign_out_button = primary_v2_account is not None or (
self._v1_signed_in and v1_account_type == 'Local'
)
sign_out_button_space = 80.0
# We can show cancel if we're either waiting on an adapter to
# provide us with v2 credentials or waiting for those credentials
# to be verified.
# provide us with v2 credentials or waiting for those
# credentials to be verified.
show_cancel_sign_in_button = self._signing_in_adapter is not None or (
plus.accounts.have_primary_credentials()
and primary_v2_account is None
@ -435,6 +435,8 @@ class AccountSettingsWindow(bui.Window):
self._sub_height += legacy_unlink_button_space
if show_sign_out_button:
self._sub_height += sign_out_button_space
if show_delete_account_button:
self._sub_height += delete_account_button_space
if show_cancel_sign_in_button:
self._sub_height += cancel_sign_in_button_space
self._subcontainer = bui.containerwidget(
@ -579,7 +581,7 @@ class AccountSettingsWindow(bui.Window):
v + sign_in_benefits_space * 0.4,
),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.signInInfoText'),
text=bui.Lstr(resource=f'{self._r}.signInInfoText'),
max_height=sign_in_benefits_space * 0.9,
scale=0.9,
color=(0.75, 0.7, 0.8),
@ -624,7 +626,7 @@ class AccountSettingsWindow(bui.Window):
(
'${B}',
bui.Lstr(
resource=self._r + '.signInWithText',
resource=f'{self._r}.signInWithText',
subs=[
(
'${SERVICE}',
@ -669,7 +671,7 @@ class AccountSettingsWindow(bui.Window):
(
'${B}',
bui.Lstr(
resource=self._r + '.signInWithText',
resource=f'{self._r}.signInWithText',
subs=[('${SERVICE}', 'Game Center')],
),
),
@ -703,11 +705,11 @@ class AccountSettingsWindow(bui.Window):
)
v2labeltext: bui.Lstr | str = (
bui.Lstr(resource=self._r + '.signInWithAnEmailAddressText')
bui.Lstr(resource=f'{self._r}.signInWithAnEmailAddressText')
if show_game_center_sign_in_button
or show_google_play_sign_in_button
or show_device_sign_in_button
else bui.Lstr(resource=self._r + '.signInText')
else bui.Lstr(resource=f'{self._r}.signInText')
)
v2infotext: bui.Lstr | str | None = None
@ -796,7 +798,7 @@ class AccountSettingsWindow(bui.Window):
(
'${B}',
bui.Lstr(
resource=self._r + '.signInWithDeviceText'
resource=f'{self._r}.signInWithDeviceText'
),
),
],
@ -811,7 +813,7 @@ class AccountSettingsWindow(bui.Window):
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v - 4),
text=bui.Lstr(resource=self._r + '.signInWithDeviceInfoText'),
text=bui.Lstr(resource=f'{self._r}.signInWithDeviceInfoText'),
flatness=1.0,
scale=0.57,
maxwidth=button_width * 0.9,
@ -1042,10 +1044,10 @@ class AccountSettingsWindow(bui.Window):
button_width = 250
if show_reset_progress_button:
confirm_text = (
bui.Lstr(resource=self._r + '.resetProgressConfirmText')
bui.Lstr(resource=f'{self._r}.resetProgressConfirmText')
if self._can_reset_achievements
else bui.Lstr(
resource=self._r + '.resetProgressConfirmNoAchievementsText'
resource=f'{self._r}.resetProgressConfirmNoAchievementsText'
)
)
v -= reset_progress_button_space
@ -1056,7 +1058,7 @@ class AccountSettingsWindow(bui.Window):
textcolor=(0.75, 0.7, 0.8),
autoselect=True,
size=(button_width, 60),
label=bui.Lstr(resource=self._r + '.resetProgressText'),
label=bui.Lstr(resource=f'{self._r}.resetProgressText'),
on_activate_call=lambda: confirm.ConfirmWindow(
text=confirm_text,
width=500,
@ -1083,7 +1085,7 @@ class AccountSettingsWindow(bui.Window):
scale=0.9,
color=(0.75, 0.7, 0.8),
maxwidth=self._sub_width * 0.95,
text=bui.Lstr(resource=self._r + '.linkedAccountsText'),
text=bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
h_align='center',
v_align='center',
)
@ -1112,7 +1114,7 @@ class AccountSettingsWindow(bui.Window):
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v + 17 + 20),
text=bui.Lstr(resource=self._r + '.linkAccountsText'),
text=bui.Lstr(resource=f'{self._r}.linkAccountsText'),
maxwidth=button_width * 0.8,
color=(0.75, 0.7, 0.8),
)
@ -1123,7 +1125,7 @@ class AccountSettingsWindow(bui.Window):
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v - 4 + 20),
text=bui.Lstr(resource=self._r + '.linkAccountsInfoText'),
text=bui.Lstr(resource=f'{self._r}.linkAccountsInfoText'),
flatness=1.0,
scale=0.5,
maxwidth=button_width * 0.8,
@ -1157,7 +1159,7 @@ class AccountSettingsWindow(bui.Window):
v_align='center',
size=(0, 0),
position=(self._sub_width * 0.5, v + 55),
text=bui.Lstr(resource=self._r + '.unlinkAccountsText'),
text=bui.Lstr(resource=f'{self._r}.unlinkAccountsText'),
maxwidth=button_width * 0.8,
color=(0.75, 0.7, 0.8),
)
@ -1212,7 +1214,7 @@ class AccountSettingsWindow(bui.Window):
autoselect=True,
size=(button_width_w, 60),
label=bui.Lstr(
resource=self._r + '.unlinkLegacyV1AccountsText'
resource=f'{self._r}.unlinkLegacyV1AccountsText'
),
textcolor=(0.8, 0.4, 0),
color=(0.55, 0.5, 0.6),
@ -1225,7 +1227,7 @@ class AccountSettingsWindow(bui.Window):
parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
label=bui.Lstr(resource=self._r + '.signOutText'),
label=bui.Lstr(resource=f'{self._r}.signOutText'),
color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8),
autoselect=True,
@ -1261,6 +1263,27 @@ class AccountSettingsWindow(bui.Window):
)
bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
if show_delete_account_button:
v -= delete_account_button_space
self._delete_account_button = btn = bui.buttonwidget(
parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
label=bui.Lstr(resource=f'{self._r}.deleteAccountText'),
color=(0.85, 0.5, 0.6),
textcolor=(0.9, 0.7, 0.8),
autoselect=True,
on_activate_call=self._on_delete_account_press,
)
if first_selectable is None:
first_selectable = btn
if bui.app.ui_v1.use_toolbars:
bui.widget(
edit=btn,
right_widget=bui.get_special_widget('party_button'),
)
bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15)
# Whatever the topmost selectable thing is, we want it to scroll all
# the way up when we select it.
if first_selectable is not None:
@ -1303,6 +1326,12 @@ class AccountSettingsWindow(bui.Window):
show_what_is_v2_page()
def _on_manage_account_press(self) -> None:
self._do_manage_account_press(WebLocation.ACCOUNT_EDITOR)
def _on_delete_account_press(self) -> None:
self._do_manage_account_press(WebLocation.ACCOUNT_DELETE_SECTION)
def _do_manage_account_press(self, weblocation: WebLocation) -> None:
plus = bui.app.plus
assert plus is not None
@ -1327,7 +1356,7 @@ class AccountSettingsWindow(bui.Window):
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.cloud.ManageAccountMessage(),
bacommon.cloud.ManageAccountMessage(weblocation=weblocation),
on_response=bui.WeakCall(self._on_manage_account_response),
)
@ -1414,7 +1443,7 @@ class AccountSettingsWindow(bui.Window):
subs=[
(
'${L}',
bui.Lstr(resource=self._r + '.linkedAccountsText'),
bui.Lstr(resource=f'{self._r}.linkedAccountsText'),
),
('${A}', accounts_str),
],
@ -1434,7 +1463,7 @@ class AccountSettingsWindow(bui.Window):
# Last level cant be completed; hence the -1;
progress = min(1.0, float(levels_complete) / (len(levels) - 1))
p_str = bui.Lstr(
resource=self._r + '.campaignProgressText',
resource=f'{self._r}.campaignProgressText',
subs=[('${PROGRESS}', str(int(progress * 100.0)) + '%')],
)
except Exception:
@ -1456,7 +1485,7 @@ class AccountSettingsWindow(bui.Window):
bui.textwidget(
edit=self._tickets_text,
text=bui.Lstr(
resource=self._r + '.ticketsText', subs=[('${COUNT}', tc_str)]
resource=f'{self._r}.ticketsText', subs=[('${COUNT}', tc_str)]
),
)
@ -1496,7 +1525,7 @@ class AccountSettingsWindow(bui.Window):
)
total = len(bui.app.classic.ach.achievements)
txt_final = bui.Lstr(
resource=self._r + '.achievementProgressText',
resource=f'{self._r}.achievementProgressText',
subs=[('${COUNT}', str(complete)), ('${TOTAL}', str(total))],
)
@ -1575,7 +1604,7 @@ class AccountSettingsWindow(bui.Window):
cfg.commit()
bui.buttonwidget(
edit=self._sign_out_button,
label=bui.Lstr(resource=self._r + '.signingOutText'),
label=bui.Lstr(resource=f'{self._r}.signingOutText'),
)
# Speed UI updates along.

View file

@ -109,15 +109,20 @@ class ConfigNumberEdit:
self._value = bui.app.config.resolve(configkey)
except ValueError:
self._value = bui.app.config.get(configkey, fallback_value)
self._value = (
self._minval
if self._minval > self._value
else self._maxval if self._maxval < self._value else self._value
)
self._as_percent = as_percent
self._f = f
self.nametext = bui.textwidget(
parent=parent,
position=position,
size=(100, 30),
position=(position[0], position[1] + 12.0),
size=(0, 0),
text=displayname,
maxwidth=160 + xoffset,
maxwidth=150 + xoffset,
color=(0.8, 0.8, 0.8, 1.0),
h_align='left',
v_align='center',
@ -125,8 +130,8 @@ class ConfigNumberEdit:
)
self.valuetext = bui.textwidget(
parent=parent,
position=(246 + xoffset, position[1]),
size=(60, 28),
position=(position[0] + 216 + xoffset, position[1] + 12.0),
size=(0, 0),
editable=False,
color=(0.3, 1.0, 0.3, 1.0),
h_align='right',
@ -136,7 +141,7 @@ class ConfigNumberEdit:
)
self.minusbutton = bui.buttonwidget(
parent=parent,
position=(330 + xoffset, position[1]),
position=(position[0] + 230 + xoffset, position[1]),
size=(28, 28),
label='-',
autoselect=True,
@ -146,7 +151,7 @@ class ConfigNumberEdit:
)
self.plusbutton = bui.buttonwidget(
parent=parent,
position=(380 + xoffset, position[1]),
position=(position[0] + 280 + xoffset, position[1]),
size=(28, 28),
label='+',
autoselect=True,

View file

@ -18,7 +18,7 @@ class ConfirmWindow:
def __init__(
self,
text: str | bui.Lstr = 'Are you sure?',
text: str | bui.Lstr | None = None,
action: Callable[[], Any] | None = None,
width: float = 360.0,
height: float = 100.0,
@ -31,6 +31,8 @@ class ConfirmWindow:
origin_widget: bui.Widget | None = None,
):
# pylint: disable=too-many-locals
if text is None:
text = bui.Lstr(resource='areYouSureText')
if ok_text is None:
ok_text = bui.Lstr(resource='okText')
if cancel_text is None:

View file

@ -28,8 +28,10 @@ def wait_for_connectivity(
assert plus is not None
# Quick-out: if we're already connected, don't bother with the UI.
# We do, however, push this call instead of calling it immediately
# so as to be consistent with the waiting path.
if plus.cloud.connected:
on_connected()
bui.pushcall(on_connected)
return
WaitForConnectivityWindow(on_connected=on_connected, on_cancel=on_cancel)

View file

@ -214,7 +214,7 @@ class ContinuesWindow(bui.Window):
self._on_cancel()
def _on_continue_press(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
plus = bui.app.plus
assert plus is not None
@ -238,7 +238,7 @@ class ContinuesWindow(bui.Window):
self._counting_down = False
bui.textwidget(edit=self._counter_text, text='')
bui.getsound('error').play()
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
return
if not self._transitioning_out:
bui.getsound('swish').play()

View file

@ -684,7 +684,7 @@ class CoopBrowserWindow(bui.Window):
text=bui.Lstr(
value='${C} (${P})',
subs=[
('${C}', bui.Lstr(resource=self._r + '.campaignText')),
('${C}', bui.Lstr(resource=f'{self._r}.campaignText')),
('${P}', p_str),
],
),
@ -694,7 +694,7 @@ class CoopBrowserWindow(bui.Window):
# pylint: disable=cyclic-import
from bauiv1lib.confirm import ConfirmWindow
txt = bui.Lstr(resource=self._r + '.tournamentInfoText')
txt = bui.Lstr(resource=f'{self._r}.tournamentInfoText')
ConfirmWindow(
txt,
cancel_button=False,

View file

@ -102,7 +102,7 @@ class TournamentButton:
position=(x + 360, y + scly - 20),
size=(0, 0),
h_align='center',
text=bui.Lstr(resource=self._r + '.entryFeeText'),
text=bui.Lstr(resource=f'{self._r}.entryFeeText'),
v_align='center',
maxwidth=100,
scale=0.9,
@ -167,7 +167,7 @@ class TournamentButton:
position=(x + 447 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=bui.Lstr(resource=self._r + '.prizesText'),
text=bui.Lstr(resource=f'{self._r}.prizesText'),
v_align='center',
maxwidth=130,
scale=0.9,
@ -267,7 +267,7 @@ class TournamentButton:
position=(x + 620 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=bui.Lstr(resource=self._r + '.currentBestText'),
text=bui.Lstr(resource=f'{self._r}.currentBestText'),
v_align='center',
maxwidth=180,
scale=0.9,
@ -290,6 +290,7 @@ class TournamentButton:
text='-',
v_align='center',
maxwidth=170,
glow_type='uniform',
scale=1.4,
color=value_color,
flatness=1.0,
@ -331,7 +332,7 @@ class TournamentButton:
position=(x + 820 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=bui.Lstr(resource=self._r + '.timeRemainingText'),
text=bui.Lstr(resource=f'{self._r}.timeRemainingText'),
v_align='center',
maxwidth=180,
scale=0.9,
@ -532,13 +533,13 @@ class TournamentButton:
bui.textwidget(edit=self.current_leader_score_text, text=leader_score)
bui.buttonwidget(
edit=self.more_scores_button,
label=bui.Lstr(resource=self._r + '.seeMoreText'),
label=bui.Lstr(resource=f'{self._r}.seeMoreText'),
)
out_of_time_text: str | bui.Lstr = (
'-'
if 'totalTime' not in entry
else bui.Lstr(
resource=self._r + '.ofTotalTimeText',
resource=f'{self._r}.ofTotalTimeText',
subs=[
(
'${TOTAL}',

View file

@ -95,7 +95,7 @@ class CreditsListWindow(bui.Window):
position=(0, height - (59 if uiscale is bui.UIScale.SMALL else 54)),
size=(width, 30),
text=bui.Lstr(
resource=self._r + '.titleText',
resource=f'{self._r}.titleText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
),
h_align='center',
@ -156,7 +156,7 @@ class CreditsListWindow(bui.Window):
return sval
sound_and_music = bui.Lstr(
resource=self._r + '.songCreditText'
resource=f'{self._r}.songCreditText'
).evaluate()
sound_and_music = sound_and_music.replace(
'${TITLE}', "'William Tell (Trumpet Entry)'"
@ -232,41 +232,41 @@ class CreditsListWindow(bui.Window):
# (or add mesh splitting under the hood)
credits_text = (
' '
+ bui.Lstr(resource=self._r + '.codingGraphicsAudioText')
+ bui.Lstr(resource=f'{self._r}.codingGraphicsAudioText')
.evaluate()
.replace('${NAME}', 'Eric Froemling')
+ '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.additionalAudioArtIdeasText')
+ bui.Lstr(resource=f'{self._r}.additionalAudioArtIdeasText')
.evaluate()
.replace('${NAME}', 'Raphael Suter')
+ '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.soundAndMusicText').evaluate()
+ bui.Lstr(resource=f'{self._r}.soundAndMusicText').evaluate()
+ '\n'
'\n' + sound_and_music + '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.publicDomainMusicViaText')
+ bui.Lstr(resource=f'{self._r}.publicDomainMusicViaText')
.evaluate()
.replace('${NAME}', 'Musopen.com')
+ '\n'
' '
+ bui.Lstr(resource=self._r + '.thanksEspeciallyToText')
+ bui.Lstr(resource=f'{self._r}.thanksEspeciallyToText')
.evaluate()
.replace('${NAME}', 'the US Army, Navy, and Marine Bands')
+ '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.additionalMusicFromText')
+ bui.Lstr(resource=f'{self._r}.additionalMusicFromText')
.evaluate()
.replace('${NAME}', 'The YouTube Audio Library')
+ '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.soundsText')
+ bui.Lstr(resource=f'{self._r}.soundsText')
.evaluate()
.replace('${SOURCE}', 'Freesound.org')
+ '\n'
@ -274,7 +274,7 @@ class CreditsListWindow(bui.Window):
'\n'
' '
+ bui.Lstr(
resource=self._r + '.languageTranslationsText'
resource=f'{self._r}.languageTranslationsText'
).evaluate()
+ '\n'
'\n'
@ -295,25 +295,25 @@ class CreditsListWindow(bui.Window):
' Holiday theme vector art designed by Freepik\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.specialThanksText').evaluate()
+ bui.Lstr(resource=f'{self._r}.specialThanksText').evaluate()
+ '\n'
'\n'
' Todd, Laura, and Robert Froemling\n'
' '
+ bui.Lstr(resource=self._r + '.allMyFamilyText')
+ bui.Lstr(resource=f'{self._r}.allMyFamilyText')
.evaluate()
.replace('\n', '\n ')
+ '\n'
' '
+ bui.Lstr(
resource=self._r + '.whoeverInventedCoffeeText'
resource=f'{self._r}.whoeverInventedCoffeeText'
).evaluate()
+ '\n'
'\n'
' ' + bui.Lstr(resource=self._r + '.legalText').evaluate() + '\n'
' ' + bui.Lstr(resource=f'{self._r}.legalText').evaluate() + '\n'
'\n'
' '
+ bui.Lstr(resource=self._r + '.softwareBasedOnText')
+ bui.Lstr(resource=f'{self._r}.softwareBasedOnText')
.evaluate()
.replace('${NAME}', 'the Khronos Group')
+ '\n'

View file

@ -70,7 +70,7 @@ class DebugWindow(bui.Window):
parent=self._root_widget,
position=(0, height - 60),
size=(width, 30),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
h_align='center',
color=bui.app.ui_v1.title_color,
v_align='center',
@ -98,7 +98,7 @@ class DebugWindow(bui.Window):
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
autoselect=True,
label=bui.Lstr(resource=self._r + '.runCPUBenchmarkText'),
label=bui.Lstr(resource=f'{self._r}.runCPUBenchmarkText'),
on_activate_call=self._run_cpu_benchmark_pressed,
)
bui.widget(
@ -111,7 +111,7 @@ class DebugWindow(bui.Window):
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
autoselect=True,
label=bui.Lstr(resource=self._r + '.runGPUBenchmarkText'),
label=bui.Lstr(resource=f'{self._r}.runGPUBenchmarkText'),
on_activate_call=self._run_gpu_benchmark_pressed,
)
v -= 60
@ -121,7 +121,7 @@ class DebugWindow(bui.Window):
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
autoselect=True,
label=bui.Lstr(resource=self._r + '.runMediaReloadBenchmarkText'),
label=bui.Lstr(resource=f'{self._r}.runMediaReloadBenchmarkText'),
on_activate_call=self._run_media_reload_benchmark_pressed,
)
v -= 60
@ -130,7 +130,7 @@ class DebugWindow(bui.Window):
parent=self._subcontainer,
position=(self._sub_width * 0.5, v + 22),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.stressTestTitleText'),
text=bui.Lstr(resource=f'{self._r}.stressTestTitleText'),
maxwidth=200,
color=bui.app.ui_v1.heading_color,
scale=0.85,
@ -144,7 +144,7 @@ class DebugWindow(bui.Window):
parent=self._subcontainer,
position=(x_offs - 10, v + 22),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.stressTestPlaylistTypeText'),
text=bui.Lstr(resource=f'{self._r}.stressTestPlaylistTypeText'),
maxwidth=130,
color=bui.app.ui_v1.heading_color,
scale=0.65,
@ -174,7 +174,7 @@ class DebugWindow(bui.Window):
parent=self._subcontainer,
position=(x_offs - 10, v + 22),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.stressTestPlaylistNameText'),
text=bui.Lstr(resource=f'{self._r}.stressTestPlaylistNameText'),
maxwidth=130,
color=bui.app.ui_v1.heading_color,
scale=0.65,
@ -192,7 +192,7 @@ class DebugWindow(bui.Window):
autoselect=True,
color=(0.9, 0.9, 0.9, 1.0),
description=bui.Lstr(
resource=self._r + '.stressTestPlaylistDescriptionText'
resource=f'{self._r}.stressTestPlaylistDescriptionText'
),
editable=True,
padding=4,
@ -205,7 +205,7 @@ class DebugWindow(bui.Window):
parent=self._subcontainer,
position=(x_offs - 10, v),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.stressTestPlayerCountText'),
text=bui.Lstr(resource=f'{self._r}.stressTestPlayerCountText'),
color=(0.8, 0.8, 0.8, 1.0),
h_align='right',
v_align='center',
@ -250,7 +250,7 @@ class DebugWindow(bui.Window):
parent=self._subcontainer,
position=(x_offs - 10, v),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.stressTestRoundDurationText'),
text=bui.Lstr(resource=f'{self._r}.stressTestRoundDurationText'),
color=(0.8, 0.8, 0.8, 1.0),
h_align='right',
v_align='center',
@ -298,7 +298,7 @@ class DebugWindow(bui.Window):
position=((self._sub_width - button_width) * 0.5, v),
size=(button_width, 60),
autoselect=True,
label=bui.Lstr(resource=self._r + '.runStressTestText'),
label=bui.Lstr(resource=f'{self._r}.runStressTestText'),
on_activate_call=self._stress_test_pressed,
)
bui.widget(btn, show_buffer_bottom=50)

View file

@ -70,12 +70,12 @@ class FileSelectorWindow(bui.Window):
h_align='center',
v_align='center',
text=(
bui.Lstr(resource=self._r + '.titleFolderText')
bui.Lstr(resource=f'{self._r}.titleFolderText')
if (allow_folders and not valid_file_extensions)
else (
bui.Lstr(resource=self._r + '.titleFileText')
bui.Lstr(resource=f'{self._r}.titleFileText')
if not allow_folders
else bui.Lstr(resource=self._r + '.titleFileFolderText')
else bui.Lstr(resource=f'{self._r}.titleFileFolderText')
)
),
maxwidth=210,
@ -382,7 +382,7 @@ class FileSelectorWindow(bui.Window):
),
size=(self._button_width, 50),
label=bui.Lstr(
resource=self._r + '.useThisFolderButtonText'
resource=f'{self._r}.useThisFolderButtonText'
),
on_activate_call=self._on_folder_entry_activated,
)

View file

@ -165,7 +165,7 @@ class GatherWindow(bui.Window):
),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=550,
)
@ -174,23 +174,23 @@ class GatherWindow(bui.Window):
# Build up the set of tabs we want.
tabdefs: list[tuple[GatherWindow.TabID, bui.Lstr]] = [
(self.TabID.ABOUT, bui.Lstr(resource=self._r + '.aboutText'))
(self.TabID.ABOUT, bui.Lstr(resource=f'{self._r}.aboutText'))
]
if plus.get_v1_account_misc_read_val('enablePublicParties', True):
tabdefs.append(
(
self.TabID.INTERNET,
bui.Lstr(resource=self._r + '.publicText'),
bui.Lstr(resource=f'{self._r}.publicText'),
)
)
tabdefs.append(
(self.TabID.PRIVATE, bui.Lstr(resource=self._r + '.privateText'))
(self.TabID.PRIVATE, bui.Lstr(resource=f'{self._r}.privateText'))
)
tabdefs.append(
(self.TabID.NEARBY, bui.Lstr(resource=self._r + '.nearbyText'))
(self.TabID.NEARBY, bui.Lstr(resource=f'{self._r}.nearbyText'))
)
tabdefs.append(
(self.TabID.MANUAL, bui.Lstr(resource=self._r + '.manualText'))
(self.TabID.MANUAL, bui.Lstr(resource=f'{self._r}.manualText'))
)
# On small UI, push our tabs up closer to the top of the screen to

View file

@ -13,14 +13,17 @@ from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, override
from efro.error import CommunicationError
from efro.dataclassio import dataclass_from_dict, dataclass_to_dict
import bacommon.cloud
from bacommon.net import (
PrivateHostingState,
PrivateHostingConfig,
PrivatePartyConnectResult,
)
from bauiv1lib.gather import GatherTab
from bauiv1lib.getcurrency import GetCurrencyWindow, show_get_tickets_prompt
from bauiv1lib.gettokens import GetTokensWindow, show_get_tokens_prompt
import bascenev1 as bs
import bauiv1 as bui
@ -55,7 +58,9 @@ class PrivateGatherTab(GatherTab):
super().__init__(window)
self._container: bui.Widget | None = None
self._state: State = State()
self._last_datacode_refresh_time: float | None = None
self._hostingstate = PrivateHostingState()
self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None
self._join_sub_tab_text: bui.Widget | None = None
self._host_sub_tab_text: bui.Widget | None = None
self._update_timer: bui.AppTimer | None = None
@ -63,14 +68,15 @@ class PrivateGatherTab(GatherTab):
self._c_width: float = 0.0
self._c_height: float = 0.0
self._last_hosting_state_query_time: float | None = None
self._last_v2_state_query_time: float | None = None
self._waiting_for_initial_state = True
self._waiting_for_start_stop_response = True
self._host_playlist_button: bui.Widget | None = None
self._host_copy_button: bui.Widget | None = None
self._host_connect_button: bui.Widget | None = None
self._host_start_stop_button: bui.Widget | None = None
self._get_tickets_button: bui.Widget | None = None
self._ticket_count_text: bui.Widget | None = None
self._get_tokens_button: bui.Widget | None = None
self._token_count_text: bui.Widget | None = None
self._showing_not_signed_in_screen = False
self._create_time = time.time()
self._last_action_send_time: float | None = None
@ -159,7 +165,9 @@ class PrivateGatherTab(GatherTab):
# Prevent taking any action until we've updated our state.
self._waiting_for_initial_state = True
# This will get a state query sent out immediately.
# Force some immediate refreshes.
self._last_datacode_refresh_time = None
self._last_v2_state_query_time = None
self._last_action_send_time = None # Ensure we don't ignore response.
self._last_hosting_state_query_time = None
self._update()
@ -263,19 +271,20 @@ class PrivateGatherTab(GatherTab):
plus = bui.app.plus
assert plus is not None
try:
t_str = str(plus.get_v1_account_ticket_count())
except Exception:
t_str = '?'
if self._get_tickets_button:
if self._v2state is not None:
t_str = str(self._v2state.tokens)
else:
t_str = '-'
if self._get_tokens_button:
bui.buttonwidget(
edit=self._get_tickets_button,
label=bui.charstr(bui.SpecialChar.TICKET) + t_str,
edit=self._get_tokens_button,
label=bui.charstr(bui.SpecialChar.TOKEN) + t_str,
)
if self._ticket_count_text:
if self._token_count_text:
bui.textwidget(
edit=self._ticket_count_text,
text=bui.charstr(bui.SpecialChar.TICKET) + t_str,
edit=self._token_count_text,
text=bui.charstr(bui.SpecialChar.TOKEN) + t_str,
)
def _update(self) -> None:
@ -292,11 +301,11 @@ class PrivateGatherTab(GatherTab):
# If we're not signed in, just refresh to show that.
if (
plus.get_v1_account_state() != 'signed_in'
and self._showing_not_signed_in_screen
):
or plus.accounts.primary is None
) and not self._showing_not_signed_in_screen:
self._refresh_sub_tab()
else:
# Query an updated state periodically.
# Query an updated v1 state periodically.
if (
self._last_hosting_state_query_time is None
or now - self._last_hosting_state_query_time > 15.0
@ -309,23 +318,78 @@ class PrivateGatherTab(GatherTab):
'expire_time': time.time() + 20,
},
callback=bui.WeakCall(
self._hosting_state_idle_response
self._idle_hosting_state_response
),
)
plus.run_v1_account_transactions()
else:
self._hosting_state_idle_response(None)
self._idle_hosting_state_response(None)
self._last_hosting_state_query_time = now
def _hosting_state_idle_response(
# Query an updated v2 state periodically.
if (
self._last_v2_state_query_time is None
or now - self._last_v2_state_query_time > 12.0
):
self._debug_server_comm('querying pp v2 state')
if plus.accounts.primary is not None:
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.cloud.BSPrivatePartyMessage(
need_datacode=(
self._last_datacode_refresh_time is None
or time.monotonic()
- self._last_datacode_refresh_time
> 30.0
)
),
on_response=bui.WeakCall(
self._on_private_party_query_response
),
)
self._last_v2_state_query_time = now
def _on_private_party_query_response(
self, response: bacommon.cloud.BSPrivatePartyResponse | Exception
) -> None:
if isinstance(response, Exception):
self._debug_server_comm('got pp v2 state response (err)')
# We expect comm errors sometimes. Make noise on anything else.
if not isinstance(response, CommunicationError):
logging.exception('Error on private-party-query-response')
return
# Ignore if something went wrong server-side.
if not response.success:
self._debug_server_comm('got pp v2 state response (serverside err)')
return
self._debug_server_comm('got pp v2 state response')
existing_datacode = (
None if self._v2state is None else self._v2state.datacode
)
self._v2state = response
if self._v2state.datacode is None:
# We don't fetch datacode each time; preserve our existing
# if we didn't.
self._v2state.datacode = existing_datacode
else:
# If we *did* fetch it, note the time.
self._last_datacode_refresh_time = time.monotonic()
def _idle_hosting_state_response(
self, result: dict[str, Any] | None
) -> None:
# This simply passes through to our standard response handler.
# The one exception is if we've recently sent an action to the
# server (start/stop hosting/etc.) In that case we want to ignore
# idle background updates and wait for the response to our action.
# (this keeps the button showing 'one moment...' until the change
# takes effect, etc.)
# server (start/stop hosting/etc.) In that case we want to
# ignore idle background updates and wait for the response to
# our action. (this keeps the button showing 'one moment...'
# until the change takes effect, etc.)
if (
self._last_action_send_time is not None
and time.time() - self._last_action_send_time < 5.0
@ -354,8 +418,8 @@ class PrivateGatherTab(GatherTab):
else:
self._debug_server_comm('private party state response errored')
# Hmm I guess let's just ignore failed responses?...
# Or should we show some sort of error state to the user?...
# Hmm I guess let's just ignore failed responses?... Or should
# we show some sort of error state to the user?...
if result is None or state is None:
return
@ -369,14 +433,17 @@ class PrivateGatherTab(GatherTab):
if playsound:
bui.getsound('click01').play()
# If switching from join to host, do a fresh state query.
# If switching from join to host, force some refreshes.
if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST:
# Prevent taking any action until we've gotten a fresh state.
# Prevent taking any action until we've gotten a fresh
# state.
self._waiting_for_initial_state = True
# This will get a state query sent out immediately.
# Get some refreshes going immediately.
self._last_hosting_state_query_time = None
self._last_action_send_time = None # So we don't ignore response.
self._last_datacode_refresh_time = None
self._last_v2_state_query_time = None
self._update()
self._state.sub_tab = value
@ -403,14 +470,14 @@ class PrivateGatherTab(GatherTab):
self._host_copy_button,
self._host_connect_button,
self._host_start_stop_button,
self._get_tickets_button,
self._get_tokens_button,
]
def _refresh_sub_tab(self) -> None:
assert self._container
# Store an index for our current selection so we can
# reselect the equivalent recreated widget if possible.
# Store an index for our current selection so we can reselect
# the equivalent recreated widget if possible.
selindex: int | None = None
selchild = self._container.get_selected_child()
if selchild is not None:
@ -481,13 +548,13 @@ class PrivateGatherTab(GatherTab):
edit=self._join_party_code_text, on_return_press_call=btn.activate
)
def _on_get_tickets_press(self) -> None:
def _on_get_tokens_press(self) -> None:
if self._waiting_for_start_stop_response:
return
# Bring up get-tickets window and then kill ourself (we're on the
# overlay layer so we'd show up above it).
GetCurrencyWindow(modal=True, origin_widget=self._get_tickets_button)
# Bring up get-tickets window and then kill ourself (we're on
# the overlay layer so we'd show up above it).
GetTokensWindow(origin_widget=self._get_tokens_button)
def _build_host_tab(self) -> None:
# pylint: disable=too-many-branches
@ -498,13 +565,20 @@ class PrivateGatherTab(GatherTab):
assert plus is not None
hostingstate = self._hostingstate
havegoldpass = self._v2state is not None and self._v2state.gold_pass
# We use both v1 and v2 account functionality here (sigh). So
# make sure we're signed in on both ends.
# Make sure the V1 side is good to go.
if plus.get_v1_account_state() != 'signed_in':
bui.textwidget(
parent=self._container,
size=(0, 0),
h_align='center',
v_align='center',
maxwidth=200,
maxwidth=self._c_width * 0.8,
scale=0.8,
color=(0.6, 0.56, 0.6),
position=(self._c_width * 0.5, self._c_height * 0.5),
@ -512,14 +586,31 @@ class PrivateGatherTab(GatherTab):
)
self._showing_not_signed_in_screen = True
return
# Make sure the V2 side is good to go.
if plus.accounts.primary is None:
bui.textwidget(
parent=self._container,
size=(0, 0),
h_align='center',
v_align='center',
maxwidth=self._c_width * 0.8,
scale=0.8,
color=(0.6, 0.56, 0.6),
position=(self._c_width * 0.5, self._c_height * 0.5),
text=bui.Lstr(resource='v2AccountRequiredText'),
)
self._showing_not_signed_in_screen = True
return
self._showing_not_signed_in_screen = False
# At first we don't want to show anything until we've gotten a state.
# Update: In this situation we now simply show our existing state
# but give the start/stop button a loading message and disallow its
# use. This keeps things a lot less jumpy looking and allows selecting
# playlists/etc without having to wait for the server each time
# back to the ui.
# At first we don't want to show anything until we've gotten a
# state. Update: In this situation we now simply show our
# existing state but give the start/stop button a loading
# message and disallow its use. This keeps things a lot less
# jumpy looking and allows selecting playlists/etc without
# having to wait for the server each time back to the ui.
if self._waiting_for_initial_state and bool(False):
bui.textwidget(
parent=self._container,
@ -537,16 +628,21 @@ class PrivateGatherTab(GatherTab):
)
return
# If we're not currently hosting and hosting requires tickets,
# If we're not currently hosting and hosting requires tokens,
# Show our count (possibly with a link to purchase more).
if (
not self._waiting_for_initial_state
and hostingstate.party_code is None
and hostingstate.tickets_to_host_now != 0
and not havegoldpass
):
if not bui.app.ui_v1.use_toolbars:
if bui.app.classic.allow_ticket_purchases:
self._get_tickets_button = bui.buttonwidget(
# Currently have no allow_token_purchases value like
# we had with tickets; just assuming we always allow.
if bool(True):
# if bui.app.classic.allow_ticket_purchases:
self._get_tokens_button = bui.buttonwidget(
parent=self._container,
position=(
self._c_width - 210 + 125,
@ -555,24 +651,25 @@ class PrivateGatherTab(GatherTab):
autoselect=True,
scale=0.6,
size=(120, 60),
textcolor=(0.2, 1, 0.2),
label=bui.charstr(bui.SpecialChar.TICKET),
textcolor=(1.0, 0.6, 0.0),
label=bui.charstr(bui.SpecialChar.TOKEN),
color=(0.65, 0.5, 0.8),
on_activate_call=self._on_get_tickets_press,
on_activate_call=self._on_get_tokens_press,
)
else:
self._ticket_count_text = bui.textwidget(
self._token_count_text = bui.textwidget(
parent=self._container,
scale=0.6,
position=(
self._c_width - 210 + 125,
self._c_height - 44,
),
color=(0.2, 1, 0.2),
color=(1.0, 0.6, 0.0),
h_align='center',
v_align='center',
)
# Set initial ticket count.
# Set initial token count.
self._update_currency_ui()
v = self._c_height - 90
@ -594,7 +691,8 @@ class PrivateGatherTab(GatherTab):
v -= 100
if hostingstate.party_code is None:
# We've got no current party running; show options to set one up.
# We've got no current party running; show options to set
# one up.
bui.textwidget(
parent=self._container,
size=(0, 0),
@ -713,8 +811,14 @@ class PrivateGatherTab(GatherTab):
)
),
)
elif havegoldpass:
# If we have a gold pass, none of the
# timing/free-server-availability info below is relevant to
# us.
pass
elif hostingstate.free_host_minutes_remaining is not None:
# If we've been pre-approved to start/stop for free, show that.
# If we've been pre-approved to start/stop for free, show
# that.
bui.textwidget(
parent=self._container,
size=(0, 0),
@ -811,12 +915,12 @@ class PrivateGatherTab(GatherTab):
resource='gatherWindow.hostingUnavailableText'
)
elif hostingstate.party_code is None:
ticon = bui.charstr(bui.SpecialChar.TICKET)
nowtickets = hostingstate.tickets_to_host_now
if nowtickets > 0:
ticon = bui.charstr(bui.SpecialChar.TOKEN)
nowtokens = hostingstate.tokens_to_host_now
if nowtokens > 0 and not havegoldpass:
btnlabel = bui.Lstr(
resource='gatherWindow.startHostingPaidText',
subs=[('${COST}', f'{ticon}{nowtickets}')],
subs=[('${COST}', f'{ticon}{nowtokens}')],
)
else:
btnlabel = bui.Lstr(
@ -867,8 +971,8 @@ class PrivateGatherTab(GatherTab):
)
def _connect_to_party_code(self, code: str) -> None:
# Ignore attempted followup sends for a few seconds.
# (this will reset if we get a response)
# Ignore attempted followup sends for a few seconds. (this will
# reset if we get a response)
plus = bui.app.plus
assert plus is not None
@ -915,21 +1019,30 @@ class PrivateGatherTab(GatherTab):
bui.getsound('click01').play()
# We need our v2 info for this.
if self._v2state is None or self._v2state.datacode is None:
bui.screenmessage(
bui.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
# If we're not hosting, start.
if self._hostingstate.party_code is None:
# If there's a ticket cost, make sure we have enough tickets.
if self._hostingstate.tickets_to_host_now > 0:
ticket_count: int | None
try:
ticket_count = plus.get_v1_account_ticket_count()
except Exception:
# FIXME: should add a bui.NotSignedInError we can use here.
ticket_count = None
ticket_cost = self._hostingstate.tickets_to_host_now
if ticket_count is not None and ticket_count < ticket_cost:
show_get_tickets_prompt()
# If there's a token cost, make sure we have enough tokens
# or a gold pass.
if self._hostingstate.tokens_to_host_now > 0:
if (
not self._v2state.gold_pass
and self._v2state.tokens
< self._hostingstate.tokens_to_host_now
):
show_get_tokens_prompt()
bui.getsound('error').play()
return
self._last_action_send_time = time.time()
plus.add_v1_account_transaction(
{
@ -937,6 +1050,7 @@ class PrivateGatherTab(GatherTab):
'config': dataclass_to_dict(self._hostingconfig),
'region_pings': bui.app.net.zone_pings,
'expire_time': time.time() + 20,
'datacode': self._v2state.datacode,
},
callback=bui.WeakCall(self._hosting_state_response),
)

View file

@ -0,0 +1,881 @@
# Released under the MIT License. See LICENSE for details.
#
"""UI functionality for purchasing/acquiring currency."""
from __future__ import annotations
from typing import TYPE_CHECKING
from efro.util import utc_now
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any
class GetTicketsWindow(bui.Window):
"""Window for purchasing/acquiring classic tickets."""
def __init__(
self,
transition: str = 'in_right',
from_modal_store: bool = False,
modal: bool = False,
origin_widget: bui.Widget | None = None,
store_back_location: str | None = None,
):
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
plus = bui.app.plus
assert plus is not None
bui.set_analytics_screen('Get Tickets Window')
self._transitioning_out = False
self._store_back_location = store_back_location # ew.
self._ad_button_greyed = False
self._smooth_update_timer: bui.AppTimer | None = None
self._ad_button = None
self._ad_label = None
self._ad_image = None
self._ad_time_text = None
# If they provided an origin-widget, scale up from that.
scale_origin: tuple[float, float] | None
if origin_widget is not None:
self._transition_out = 'out_scale'
scale_origin = origin_widget.get_screen_space_center()
transition = 'in_scale'
else:
self._transition_out = 'out_right'
scale_origin = None
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._width = 1000.0 if uiscale is bui.UIScale.SMALL else 800.0
x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0
self._height = 480.0
self._modal = modal
self._from_modal_store = from_modal_store
self._r = 'getTicketsWindow'
top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + top_extra),
transition=transition,
scale_origin_stack_offset=scale_origin,
color=(0.4, 0.37, 0.55),
scale=(
1.63
if uiscale is bui.UIScale.SMALL
else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, -3) if uiscale is bui.UIScale.SMALL else (0, 0)
),
)
)
btn = bui.buttonwidget(
parent=self._root_widget,
position=(55 + x_inset, self._height - 79),
size=(140, 60),
scale=1.0,
autoselect=True,
label=bui.Lstr(resource='doneText' if modal else 'backText'),
button_type='regular' if modal else 'back',
on_activate_call=self._back,
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5 - 15, self._height - 47),
size=(0, 0),
color=bui.app.ui_v1.title_color,
scale=1.2,
h_align='right',
v_align='center',
text=bui.Lstr(resource=f'{self._r}.titleText'),
# text='Testing really long text here blah blah',
maxwidth=260,
)
# Get Tokens button
bui.buttonwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 72),
color=(0.65, 0.5, 0.7),
textcolor=bui.app.ui_v1.title_color,
size=(190, 50),
autoselect=True,
label=bui.Lstr(resource='tokens.getTokensText'),
on_activate_call=self._get_tokens_press,
)
# 'New!' by tokens button
bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource='newExclaimText'),
position=(self._width * 0.5 + 25, self._height - 32),
size=(0, 0),
color=(1, 1, 0, 1.0),
rotate=22,
shadow=1.0,
maxwidth=150,
h_align='center',
v_align='center',
scale=0.7,
)
if not modal:
bui.buttonwidget(
edit=btn,
button_type='backSmall',
size=(60, 60),
label=bui.charstr(bui.SpecialChar.BACK),
)
b_size = (220.0, 180.0)
v = self._height - b_size[1] - 80
spacing = 1
self._ad_button = None
def _add_button(
item: str,
position: tuple[float, float],
size: tuple[float, float],
label: bui.Lstr,
price: str | None = None,
tex_name: str | None = None,
tex_opacity: float = 1.0,
tex_scale: float = 1.0,
enabled: bool = True,
text_scale: float = 1.0,
) -> bui.Widget:
btn2 = bui.buttonwidget(
parent=self._root_widget,
position=position,
button_type='square',
size=size,
label='',
autoselect=True,
color=None if enabled else (0.5, 0.5, 0.5),
on_activate_call=(
bui.Call(self._purchase, item)
if enabled
else self._disabled_press
),
)
txt = bui.textwidget(
parent=self._root_widget,
text=label,
position=(
position[0] + size[0] * 0.5,
position[1] + size[1] * 0.3,
),
scale=text_scale,
maxwidth=size[0] * 0.75,
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=btn2,
color=(0.7, 0.9, 0.7, 1.0 if enabled else 0.2),
)
if price is not None and enabled:
bui.textwidget(
parent=self._root_widget,
text=price,
position=(
position[0] + size[0] * 0.5,
position[1] + size[1] * 0.17,
),
scale=0.7,
maxwidth=size[0] * 0.75,
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=btn2,
color=(0.4, 0.9, 0.4, 1.0),
)
i = None
if tex_name is not None:
tex_size = 90.0 * tex_scale
i = bui.imagewidget(
parent=self._root_widget,
texture=bui.gettexture(tex_name),
position=(
position[0] + size[0] * 0.5 - tex_size * 0.5,
position[1] + size[1] * 0.66 - tex_size * 0.5,
),
size=(tex_size, tex_size),
draw_controller=btn2,
opacity=tex_opacity * (1.0 if enabled else 0.25),
)
if item == 'ad':
self._ad_button = btn2
self._ad_label = txt
assert i is not None
self._ad_image = i
self._ad_time_text = bui.textwidget(
parent=self._root_widget,
text='1m 10s',
position=(
position[0] + size[0] * 0.5,
position[1] + size[1] * 0.5,
),
scale=text_scale * 1.2,
maxwidth=size[0] * 0.85,
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=btn2,
color=(0.4, 0.9, 0.4, 1.0),
)
return btn2
rsrc = f'{self._r}.ticketsText'
c2txt = bui.Lstr(
resource=rsrc,
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val('tickets2Amount', 500)
),
)
],
)
c3txt = bui.Lstr(
resource=rsrc,
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val(
'tickets3Amount', 1500
)
),
)
],
)
c4txt = bui.Lstr(
resource=rsrc,
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val(
'tickets4Amount', 5000
)
),
)
],
)
c5txt = bui.Lstr(
resource=rsrc,
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val(
'tickets5Amount', 15000
)
),
)
],
)
h = 110.0
# Enable buttons if we have prices.
tickets2_price = plus.get_price('tickets2')
tickets3_price = plus.get_price('tickets3')
tickets4_price = plus.get_price('tickets4')
tickets5_price = plus.get_price('tickets5')
# TEMP
# tickets1_price = '$0.99'
# tickets2_price = '$4.99'
# tickets3_price = '$9.99'
# tickets4_price = '$19.99'
# tickets5_price = '$49.99'
_add_button(
'tickets2',
enabled=(tickets2_price is not None),
position=(
self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h,
v,
),
size=b_size,
label=c2txt,
price=tickets2_price,
tex_name='ticketsMore',
) # 0.99-ish
_add_button(
'tickets3',
enabled=(tickets3_price is not None),
position=(
self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h,
v,
),
size=b_size,
label=c3txt,
price=tickets3_price,
tex_name='ticketRoll',
) # 4.99-ish
v -= b_size[1] - 5
_add_button(
'tickets4',
enabled=(tickets4_price is not None),
position=(
self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h,
v,
),
size=b_size,
label=c4txt,
price=tickets4_price,
tex_name='ticketRollBig',
tex_scale=1.2,
) # 9.99-ish
_add_button(
'tickets5',
enabled=(tickets5_price is not None),
position=(
self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h,
v,
),
size=b_size,
label=c5txt,
price=tickets5_price,
tex_name='ticketRolls',
tex_scale=1.2,
) # 19.99-ish
self._enable_ad_button = plus.has_video_ads()
h = self._width * 0.5 + 110.0
v = self._height - b_size[1] - 115.0
if self._enable_ad_button:
h_offs = 35
b_size_3 = (150, 120)
cdb = _add_button(
'ad',
position=(h + h_offs, v),
size=b_size_3,
label=bui.Lstr(
resource=f'{self._r}.ticketsFromASponsorText',
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val(
'sponsorTickets', 5
)
),
)
],
),
tex_name='ticketsMore',
enabled=self._enable_ad_button,
tex_opacity=0.6,
tex_scale=0.7,
text_scale=0.7,
)
bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7))
self._ad_free_text = bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource=f'{self._r}.freeText'),
position=(
h + h_offs + b_size_3[0] * 0.5,
v + b_size_3[1] * 0.5 + 25,
),
size=(0, 0),
color=(1, 1, 0, 1.0),
draw_controller=cdb,
rotate=15,
shadow=1.0,
maxwidth=150,
h_align='center',
v_align='center',
scale=1.0,
)
v -= 125
else:
v -= 20
if bool(True):
h_offs = 35
b_size_3 = (150, 120)
cdb = _add_button(
'app_invite',
position=(h + h_offs, v),
size=b_size_3,
label=bui.Lstr(
resource='gatherWindow.earnTicketsForRecommendingText',
subs=[
(
'${COUNT}',
str(
plus.get_v1_account_misc_read_val(
'sponsorTickets', 5
)
),
)
],
),
tex_name='ticketsMore',
enabled=True,
tex_opacity=0.6,
tex_scale=0.7,
text_scale=0.7,
)
bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7))
bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource=f'{self._r}.freeText'),
position=(
h + h_offs + b_size_3[0] * 0.5,
v + b_size_3[1] * 0.5 + 25,
),
size=(0, 0),
color=(1, 1, 0, 1.0),
draw_controller=cdb,
rotate=15,
shadow=1.0,
maxwidth=150,
h_align='center',
v_align='center',
scale=1.0,
)
tc_y_offs = 0
else:
tc_y_offs = 0
h = self._width - (185 + x_inset)
v = self._height - 105 + tc_y_offs
txt1 = (
bui.Lstr(resource=f'{self._r}.youHaveText')
.evaluate()
.partition('${COUNT}')[0]
.strip()
)
txt2 = (
bui.Lstr(resource=f'{self._r}.youHaveText')
.evaluate()
.rpartition('${COUNT}')[-1]
.strip()
)
bui.textwidget(
parent=self._root_widget,
text=txt1,
position=(h, v),
size=(0, 0),
color=(0.5, 0.5, 0.6),
maxwidth=200,
h_align='center',
v_align='center',
scale=0.8,
)
v -= 30
self._ticket_count_text = bui.textwidget(
parent=self._root_widget,
position=(h, v),
size=(0, 0),
color=(0.2, 1.0, 0.2),
maxwidth=200,
h_align='center',
v_align='center',
scale=1.6,
)
v -= 30
bui.textwidget(
parent=self._root_widget,
text=txt2,
position=(h, v),
size=(0, 0),
color=(0.5, 0.5, 0.6),
maxwidth=200,
h_align='center',
v_align='center',
scale=0.8,
)
self._ticking_sound: bui.Sound | None = None
self._smooth_ticket_count: float | None = None
self._ticket_count = 0
self._update()
self._update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update), repeat=True
)
self._smooth_increase_speed = 1.0
def __del__(self) -> None:
if self._ticking_sound is not None:
self._ticking_sound.stop()
self._ticking_sound = None
def _smooth_update(self) -> None:
if not self._ticket_count_text:
self._smooth_update_timer = None
return
finished = False
# If we're going down, do it immediately.
assert self._smooth_ticket_count is not None
if int(self._smooth_ticket_count) >= self._ticket_count:
self._smooth_ticket_count = float(self._ticket_count)
finished = True
else:
# We're going up; start a sound if need be.
self._smooth_ticket_count = min(
self._smooth_ticket_count + 1.0 * self._smooth_increase_speed,
self._ticket_count,
)
if int(self._smooth_ticket_count) >= self._ticket_count:
finished = True
self._smooth_ticket_count = float(self._ticket_count)
elif self._ticking_sound is None:
self._ticking_sound = bui.getsound('scoreIncrease')
self._ticking_sound.play()
bui.textwidget(
edit=self._ticket_count_text,
text=str(int(self._smooth_ticket_count)),
)
# If we've reached the target, kill the timer/sound/etc.
if finished:
self._smooth_update_timer = None
if self._ticking_sound is not None:
self._ticking_sound.stop()
self._ticking_sound = None
bui.getsound('cashRegister2').play()
def _update(self) -> None:
import datetime
plus = bui.app.plus
assert plus is not None
# If we somehow get signed out, just die.
if plus.get_v1_account_state() != 'signed_in':
self._back()
return
self._ticket_count = plus.get_v1_account_ticket_count()
# Update our incentivized ad button depending on whether ads are
# available.
if self._ad_button is not None:
next_reward_ad_time = plus.get_v1_account_misc_read_val_2(
'nextRewardAdTime', None
)
if next_reward_ad_time is not None:
next_reward_ad_time = datetime.datetime.fromtimestamp(
next_reward_ad_time, datetime.UTC
)
now = utc_now()
if plus.have_incentivized_ad() and (
next_reward_ad_time is None or next_reward_ad_time <= now
):
self._ad_button_greyed = False
bui.buttonwidget(edit=self._ad_button, color=(0.65, 0.5, 0.7))
bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 1.0))
bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 1))
bui.imagewidget(edit=self._ad_image, opacity=0.6)
bui.textwidget(edit=self._ad_time_text, text='')
else:
self._ad_button_greyed = True
bui.buttonwidget(edit=self._ad_button, color=(0.5, 0.5, 0.5))
bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 0.2))
bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 0.2))
bui.imagewidget(edit=self._ad_image, opacity=0.6 * 0.25)
sval: str | bui.Lstr
if (
next_reward_ad_time is not None
and next_reward_ad_time > now
):
sval = bui.timestring(
(next_reward_ad_time - now).total_seconds(), centi=False
)
else:
sval = ''
bui.textwidget(edit=self._ad_time_text, text=sval)
# If this is our first update, assign immediately; otherwise kick
# off a smooth transition if the value has changed.
if self._smooth_ticket_count is None:
self._smooth_ticket_count = float(self._ticket_count)
self._smooth_update() # will set the text widget
elif (
self._ticket_count != int(self._smooth_ticket_count)
and self._smooth_update_timer is None
):
self._smooth_update_timer = bui.AppTimer(
0.05, bui.WeakCall(self._smooth_update), repeat=True
)
diff = abs(float(self._ticket_count) - self._smooth_ticket_count)
self._smooth_increase_speed = (
diff / 100.0
if diff >= 5000
else (
diff / 50.0
if diff >= 1500
else diff / 30.0 if diff >= 500 else diff / 15.0
)
)
def _disabled_press(self) -> None:
plus = bui.app.plus
assert plus is not None
# If we're on a platform without purchases, inform the user they
# can link their accounts and buy stuff elsewhere.
app = bui.app
assert app.classic is not None
if (
app.env.test
or (
app.classic.platform == 'android'
and app.classic.subplatform in ['oculus', 'cardboard']
)
) and plus.get_v1_account_misc_read_val('allowAccountLinking2', False):
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.unavailableLinkAccountText'),
color=(1, 0.5, 0),
)
else:
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.unavailableText'),
color=(1, 0.5, 0),
)
bui.getsound('error').play()
def _purchase(self, item: str) -> None:
from bauiv1lib import account
from bauiv1lib import appinvite
plus = bui.app.plus
assert plus is not None
if bui.app.classic is None:
raise RuntimeError('This requires classic support.')
if item == 'app_invite':
if plus.get_v1_account_state() != 'signed_in':
account.show_sign_in_prompt()
return
appinvite.handle_app_invites_press()
return
# Here we ping the server to ask if it's valid for us to
# purchase this.. (better to fail now than after we've paid
# locally).
app = bui.app
assert app.classic is not None
bui.app.classic.master_server_v1_get(
'bsAccountPurchaseCheck',
{
'item': item,
'platform': app.classic.platform,
'subplatform': app.classic.subplatform,
'version': app.env.engine_version,
'buildNumber': app.env.engine_build_number,
},
callback=bui.WeakCall(self._purchase_check_result, item),
)
def _purchase_check_result(
self, item: str, result: dict[str, Any] | None
) -> None:
if result is None:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
else:
if result['allow']:
self._do_purchase(item)
else:
if result['reason'] == 'versionTooOld':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource='getTicketsWindow.versionTooOldText'),
color=(1, 0, 0),
)
else:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource='getTicketsWindow.unavailableText'),
color=(1, 0, 0),
)
# Actually start the purchase locally.
def _do_purchase(self, item: str) -> None:
plus = bui.app.plus
assert plus is not None
if item == 'ad':
import datetime
# If ads are disabled until some time, error.
next_reward_ad_time = plus.get_v1_account_misc_read_val_2(
'nextRewardAdTime', None
)
if next_reward_ad_time is not None:
next_reward_ad_time = datetime.datetime.fromtimestamp(
next_reward_ad_time, datetime.UTC
)
now = utc_now()
if (
next_reward_ad_time is not None and next_reward_ad_time > now
) or self._ad_button_greyed:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource='getTicketsWindow.unavailableTemporarilyText'
),
color=(1, 0, 0),
)
elif self._enable_ad_button:
assert bui.app.classic is not None
bui.app.classic.ads.show_ad('tickets')
else:
plus.purchase(item)
def _get_tokens_press(self) -> None:
from functools import partial
from bauiv1lib.gettokens import GetTokensWindow
# No-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
if self._transitioning_out:
return
bui.containerwidget(edit=self._root_widget, transition='out_left')
# Note: Make sure we don't pass anything here that would
# capture 'self'. (a lambda would implicitly do this by capturing
# the stack frame).
restorecall = partial(
_restore_get_tickets_window,
self._modal,
self._from_modal_store,
self._store_back_location,
)
window = GetTokensWindow(
transition='in_right',
restore_previous_call=restorecall,
).get_root_widget()
if not self._modal and not self._from_modal_store:
assert bui.app.classic is not None
bui.app.ui_v1.set_main_menu_window(
window, from_window=self._root_widget
)
self._transitioning_out = True
def _back(self) -> None:
from bauiv1lib.store import browser
# No-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
if self._transitioning_out:
return
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
if not self._modal:
window = browser.StoreBrowserWindow(
transition='in_left',
modal=self._from_modal_store,
back_location=self._store_back_location,
).get_root_widget()
if not self._from_modal_store:
assert bui.app.classic is not None
bui.app.ui_v1.set_main_menu_window(
window, from_window=self._root_widget
)
self._transitioning_out = True
# A call we can bundle up and pass to windows we open; allows them to
# get back to us without having to explicitly know about us.
def _restore_get_tickets_window(
modal: bool,
from_modal_store: bool,
store_back_location: str | None,
from_window: bui.Widget,
) -> None:
restored = GetTicketsWindow(
transition='in_left',
modal=modal,
from_modal_store=from_modal_store,
store_back_location=store_back_location,
)
if not modal and not from_modal_store:
assert bui.app.classic is not None
bui.app.ui_v1.set_main_menu_window(
restored.get_root_widget(), from_window=from_window
)
def show_get_tickets_prompt() -> None:
"""Show a 'not enough tickets' prompt with an option to purchase more.
Note that the purchase option may not always be available
depending on the build of the game.
"""
from bauiv1lib.confirm import ConfirmWindow
assert bui.app.classic is not None
if bui.app.classic.allow_ticket_purchases:
ConfirmWindow(
bui.Lstr(
translate=(
'serverResponses',
'You don\'t have enough tickets for this!',
)
),
lambda: GetTicketsWindow(modal=True),
ok_text=bui.Lstr(resource='getTicketsWindow.titleText'),
width=460,
height=130,
)
else:
ConfirmWindow(
bui.Lstr(
translate=(
'serverResponses',
'You don\'t have enough tickets for this!',
)
),
cancel_button=False,
width=460,
height=130,
)

View file

@ -0,0 +1,809 @@
# Released under the MIT License. See LICENSE for details.
#
"""UI functionality for purchasing/acquiring currency."""
from __future__ import annotations
import time
from enum import Enum
from functools import partial
from dataclasses import dataclass
from typing import TYPE_CHECKING, assert_never
import bacommon.cloud
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable
@dataclass
class _ButtonDef:
itemid: str
width: float
color: tuple[float, float, float]
imgdefs: list[_ImgDef]
txtdefs: list[_TxtDef]
prepad: float = 0.0
@dataclass
class _ImgDef:
tex: str
pos: tuple[float, float]
size: tuple[float, float]
color: tuple[float, float, float] = (1, 1, 1)
opacity: float = 1.0
draw_controller_mult: float | None = None
class TextContents(Enum):
"""Some type of text to show."""
PRICE = 'price'
@dataclass
class _TxtDef:
text: str | TextContents | bui.Lstr
pos: tuple[float, float]
maxwidth: float | None
scale: float = 1.0
color: tuple[float, float, float] = (1, 1, 1)
rotate: float | None = None
class GetTokensWindow(bui.Window):
"""Window for purchasing/acquiring classic tickets."""
class State(Enum):
"""What are we doing?"""
LOADING = 'loading'
NOT_SIGNED_IN = 'not_signed_in'
HAVE_GOLD_PASS = 'have_gold_pass'
SHOWING_STORE = 'showing_store'
def __init__(
self,
transition: str = 'in_right',
origin_widget: bui.Widget | None = None,
restore_previous_call: Callable[[bui.Widget], None] | None = None,
):
# pylint: disable=too-many-locals
bwidthstd = 170
bwidthwide = 300
ycolor = (0, 0, 0.3)
pcolor = (0, 0, 0.3)
pos1 = 65
pos2 = 34
titlescale = 0.9
pricescale = 0.65
bcapcol1 = (0.25, 0.13, 0.02)
self._buttondefs: list[_ButtonDef] = [
_ButtonDef(
itemid='tokens1',
width=bwidthstd,
color=ycolor,
imgdefs=[
_ImgDef(
'tokens1',
pos=(-3, 85),
size=(172, 172),
opacity=1.0,
draw_controller_mult=0.5,
),
_ImgDef(
'windowBottomCap',
pos=(1.5, 4),
size=(bwidthstd * 0.960, 100),
color=bcapcol1,
opacity=1.0,
),
],
txtdefs=[
_TxtDef(
bui.Lstr(
resource='tokens.numTokensText',
subs=[('${COUNT}', '50')],
),
pos=(bwidthstd * 0.5, pos1),
color=(1.1, 1.05, 1.0),
scale=titlescale,
maxwidth=bwidthstd * 0.9,
),
_TxtDef(
TextContents.PRICE,
pos=(bwidthstd * 0.5, pos2),
color=(1.1, 1.05, 1.0),
scale=pricescale,
maxwidth=bwidthstd * 0.9,
),
],
),
_ButtonDef(
itemid='tokens2',
width=bwidthstd,
color=ycolor,
imgdefs=[
_ImgDef(
'tokens2',
pos=(-3, 85),
size=(172, 172),
opacity=1.0,
draw_controller_mult=0.5,
),
_ImgDef(
'windowBottomCap',
pos=(1.5, 4),
size=(bwidthstd * 0.960, 100),
color=bcapcol1,
opacity=1.0,
),
],
txtdefs=[
_TxtDef(
bui.Lstr(
resource='tokens.numTokensText',
subs=[('${COUNT}', '500')],
),
pos=(bwidthstd * 0.5, pos1),
color=(1.1, 1.05, 1.0),
scale=titlescale,
maxwidth=bwidthstd * 0.9,
),
_TxtDef(
TextContents.PRICE,
pos=(bwidthstd * 0.5, pos2),
color=(1.1, 1.05, 1.0),
scale=pricescale,
maxwidth=bwidthstd * 0.9,
),
],
),
_ButtonDef(
itemid='tokens3',
width=bwidthstd,
color=ycolor,
imgdefs=[
_ImgDef(
'tokens3',
pos=(-3, 85),
size=(172, 172),
opacity=1.0,
draw_controller_mult=0.5,
),
_ImgDef(
'windowBottomCap',
pos=(1.5, 4),
size=(bwidthstd * 0.960, 100),
color=bcapcol1,
opacity=1.0,
),
],
txtdefs=[
_TxtDef(
bui.Lstr(
resource='tokens.numTokensText',
subs=[('${COUNT}', '1200')],
),
pos=(bwidthstd * 0.5, pos1),
color=(1.1, 1.05, 1.0),
scale=titlescale,
maxwidth=bwidthstd * 0.9,
),
_TxtDef(
TextContents.PRICE,
pos=(bwidthstd * 0.5, pos2),
color=(1.1, 1.05, 1.0),
scale=pricescale,
maxwidth=bwidthstd * 0.9,
),
],
),
_ButtonDef(
itemid='tokens4',
width=bwidthstd,
color=ycolor,
imgdefs=[
_ImgDef(
'tokens4',
pos=(-3, 85),
size=(172, 172),
opacity=1.0,
draw_controller_mult=0.5,
),
_ImgDef(
'windowBottomCap',
pos=(1.5, 4),
size=(bwidthstd * 0.960, 100),
color=bcapcol1,
opacity=1.0,
),
],
txtdefs=[
_TxtDef(
bui.Lstr(
resource='tokens.numTokensText',
subs=[('${COUNT}', '2600')],
),
pos=(bwidthstd * 0.5, pos1),
color=(1.1, 1.05, 1.0),
scale=titlescale,
maxwidth=bwidthstd * 0.9,
),
_TxtDef(
TextContents.PRICE,
pos=(bwidthstd * 0.5, pos2),
color=(1.1, 1.05, 1.0),
scale=pricescale,
maxwidth=bwidthstd * 0.9,
),
],
),
_ButtonDef(
itemid='gold_pass',
width=bwidthwide,
color=pcolor,
imgdefs=[
_ImgDef(
'goldPass',
pos=(-7, 102),
size=(312, 156),
draw_controller_mult=0.3,
),
_ImgDef(
'windowBottomCap',
pos=(8, 4),
size=(bwidthwide * 0.923, 116),
color=(0.25, 0.12, 0.15),
opacity=1.0,
),
],
txtdefs=[
_TxtDef(
bui.Lstr(resource='goldPass.goldPassText'),
pos=(bwidthwide * 0.5, pos1 + 27),
color=(1.1, 1.05, 1.0),
scale=titlescale,
maxwidth=bwidthwide * 0.8,
),
_TxtDef(
bui.Lstr(resource='goldPass.desc1InfTokensText'),
pos=(bwidthwide * 0.5, pos1 + 6),
color=(1.1, 1.05, 1.0),
scale=0.4,
maxwidth=bwidthwide * 0.8,
),
_TxtDef(
bui.Lstr(resource='goldPass.desc2NoAdsText'),
pos=(bwidthwide * 0.5, pos1 + 6 - 13 * 1),
color=(1.1, 1.05, 1.0),
scale=0.4,
maxwidth=bwidthwide * 0.8,
),
_TxtDef(
bui.Lstr(resource='goldPass.desc3ForeverText'),
pos=(bwidthwide * 0.5, pos1 + 6 - 13 * 2),
color=(1.1, 1.05, 1.0),
scale=0.4,
maxwidth=bwidthwide * 0.8,
),
_TxtDef(
TextContents.PRICE,
pos=(bwidthwide * 0.5, pos2 - 9),
color=(1.1, 1.05, 1.0),
scale=pricescale,
maxwidth=bwidthwide * 0.8,
),
],
prepad=-8,
),
]
self._transitioning_out = False
self._restore_previous_call = restore_previous_call
self._textcolor = (0.92, 0.92, 2.0)
self._query_in_flight = False
self._last_query_time = -1.0
self._last_query_response: bacommon.cloud.StoreQueryResponse | None = (
None
)
# If they provided an origin-widget, scale up from that.
scale_origin: tuple[float, float] | None
if origin_widget is not None:
self._transition_out = 'out_scale'
scale_origin = origin_widget.get_screen_space_center()
transition = 'in_scale'
else:
self._transition_out = 'out_right'
scale_origin = None
uiscale = bui.app.ui_v1.uiscale
self._width = 1000.0 if uiscale is bui.UIScale.SMALL else 800.0
self._x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0
self._height = 480.0
self._r = 'getTokensWindow'
top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + top_extra),
transition=transition,
scale_origin_stack_offset=scale_origin,
color=(0.3, 0.23, 0.36),
scale=(
1.63
if uiscale is bui.UIScale.SMALL
else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, -3) if uiscale is bui.UIScale.SMALL else (0, 0)
),
)
)
self._back_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(45 + self._x_inset, self._height - 80),
size=(
(140, 60) if self._restore_previous_call is None else (60, 60)
),
scale=1.0,
autoselect=True,
label=(
bui.Lstr(resource='doneText')
if self._restore_previous_call is None
else bui.charstr(bui.SpecialChar.BACK)
),
button_type=(
'regular'
if self._restore_previous_call is None
else 'backSmall'
),
on_activate_call=self._back,
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
self._title_text = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 47),
size=(0, 0),
color=self._textcolor,
flatness=0.0,
shadow=1.0,
scale=1.2,
h_align='center',
v_align='center',
text=bui.Lstr(resource='tokens.getTokensText'),
maxwidth=260,
)
self._status_text = bui.textwidget(
parent=self._root_widget,
size=(0, 0),
position=(self._width * 0.5, self._height * 0.5),
h_align='center',
v_align='center',
color=(0.6, 0.6, 0.6),
scale=0.75,
text=bui.Lstr(resource='store.loadingText'),
)
self._core_widgets = [
self._back_button,
self._title_text,
self._status_text,
]
self._token_count_widget: bui.Widget | None = None
self._smooth_update_timer: bui.AppTimer | None = None
self._smooth_token_count: float | None = None
self._token_count: int = 0
self._smooth_increase_speed = 1.0
self._ticking_sound: bui.Sound | None = None
# Get all textures used by our buttons preloading so hopefully
# they'll be in place by the time we show them.
for bdef in self._buttondefs:
for bimg in bdef.imgdefs:
bui.gettexture(bimg.tex)
self._state = self.State.LOADING
self._update_timer = bui.AppTimer(
0.789, bui.WeakCall(self._update), repeat=True
)
self._update()
def __del__(self) -> None:
if self._ticking_sound is not None:
self._ticking_sound.stop()
self._ticking_sound = None
def _update(self) -> None:
# No-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
plus = bui.app.plus
if plus is None or plus.accounts.primary is None:
self._update_state(self.State.NOT_SIGNED_IN)
return
# Poll for relevant changes to the store or our account.
now = time.monotonic()
if not self._query_in_flight and now - self._last_query_time > 2.0:
self._last_query_time = now
self._query_in_flight = True
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.cloud.StoreQueryMessage(),
on_response=bui.WeakCall(self._on_store_query_response),
)
# Can't do much until we get a store state.
if self._last_query_response is None:
return
# If we've got a gold-pass, just show that. No need to offer any
# other purchases.
if self._last_query_response.gold_pass:
self._update_state(self.State.HAVE_GOLD_PASS)
return
# Ok we seem to be signed in and have store stuff we can show.
# Do that.
self._update_state(self.State.SHOWING_STORE)
def _update_state(self, state: State) -> None:
# We don't do much when state is unchanged.
if state is self._state:
# Update a few things in store mode though, such as token
# count.
if state is self.State.SHOWING_STORE:
self._update_store_state()
return
# Ok, state is changing. Start by resetting to a blank slate.
self._token_count_widget = None
for widget in self._root_widget.get_children():
if widget not in self._core_widgets:
widget.delete()
# Build up new state.
if state is self.State.NOT_SIGNED_IN:
bui.textwidget(
edit=self._status_text,
color=(1, 0, 0),
text=bui.Lstr(resource='notSignedInErrorText'),
)
elif state is self.State.LOADING:
raise RuntimeError('Should never return to loading state.')
elif state is self.State.HAVE_GOLD_PASS:
bui.textwidget(
edit=self._status_text,
color=(0, 1, 0),
text=bui.Lstr(resource='tokens.youHaveGoldPassText'),
)
elif state is self.State.SHOWING_STORE:
assert self._last_query_response is not None
bui.textwidget(edit=self._status_text, text='')
self._build_store_for_response(self._last_query_response)
else:
# Make sure we handle all cases.
assert_never(state)
self._state = state
def _on_load_error(self) -> None:
bui.textwidget(
edit=self._status_text,
text=bui.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
def _on_store_query_response(
self, response: bacommon.cloud.StoreQueryResponse | Exception
) -> None:
self._query_in_flight = False
if isinstance(response, bacommon.cloud.StoreQueryResponse):
self._last_query_response = response
# Hurry along any effects of this response.
self._update()
def _build_store_for_response(
self, response: bacommon.cloud.StoreQueryResponse
) -> None:
# pylint: disable=too-many-locals
plus = bui.app.plus
bui.textwidget(edit=self._status_text, text='')
xinset = 40
scrollwidth = self._width - 2 * (self._x_inset + xinset)
scrollheight = 280
buttonpadding = -5
yoffs = 5
# We currently don't handle the zero-button case.
assert self._buttondefs
total_button_width = sum(
b.width + b.prepad for b in self._buttondefs
) + buttonpadding * (len(self._buttondefs) - 1)
h_scroll = bui.hscrollwidget(
parent=self._root_widget,
size=(scrollwidth, scrollheight),
position=(self._x_inset + xinset, 45),
claims_left_right=True,
highlight=False,
border_opacity=0.25,
)
subcontainer = bui.containerwidget(
parent=h_scroll,
background=False,
size=(max(total_button_width, scrollwidth), scrollheight),
)
tinfobtn = bui.buttonwidget(
parent=self._root_widget,
autoselect=True,
label=bui.Lstr(resource='learnMoreText'),
position=(self._width * 0.5 - 75, self._height * 0.703),
size=(180, 43),
scale=0.8,
color=(0.4, 0.25, 0.5),
textcolor=self._textcolor,
on_activate_call=partial(
self._on_learn_more_press, response.token_info_url
),
)
x = 0.0
bwidgets: list[bui.Widget] = []
for i, buttondef in enumerate(self._buttondefs):
price = None if plus is None else plus.get_price(buttondef.itemid)
x += buttondef.prepad
tdelay = 0.3 - i / len(self._buttondefs) * 0.25
btn = bui.buttonwidget(
autoselect=True,
label='',
color=buttondef.color,
transition_delay=tdelay,
up_widget=tinfobtn,
parent=subcontainer,
size=(buttondef.width, 275),
position=(x, -10 + yoffs),
button_type='square',
on_activate_call=partial(
self._purchase_press, buttondef.itemid
),
)
bwidgets.append(btn)
for imgdef in buttondef.imgdefs:
_img = bui.imagewidget(
parent=subcontainer,
size=imgdef.size,
position=(x + imgdef.pos[0], imgdef.pos[1] + yoffs),
draw_controller=btn,
draw_controller_mult=imgdef.draw_controller_mult,
color=imgdef.color,
texture=bui.gettexture(imgdef.tex),
transition_delay=tdelay,
opacity=imgdef.opacity,
)
for txtdef in buttondef.txtdefs:
txt: bui.Lstr | str
if isinstance(txtdef.text, TextContents):
if txtdef.text is TextContents.PRICE:
tcolor = (
(1, 1, 1, 0.5) if price is None else txtdef.color
)
txt = (
bui.Lstr(resource='unavailableText')
if price is None
else price
)
else:
# Make sure we cover all cases.
assert_never(txtdef.text)
else:
tcolor = txtdef.color
txt = txtdef.text
_txt = bui.textwidget(
parent=subcontainer,
text=txt,
position=(x + txtdef.pos[0], txtdef.pos[1] + yoffs),
size=(0, 0),
scale=txtdef.scale,
h_align='center',
v_align='center',
draw_controller=btn,
color=tcolor,
transition_delay=tdelay,
flatness=0.0,
shadow=1.0,
rotate=txtdef.rotate,
maxwidth=txtdef.maxwidth,
)
x += buttondef.width + buttonpadding
bui.containerwidget(edit=subcontainer, visible_child=bwidgets[0])
_tinfotxt = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.812),
color=self._textcolor,
shadow=1.0,
scale=0.7,
size=(0, 0),
h_align='center',
v_align='center',
text=bui.Lstr(resource='tokens.shinyNewCurrencyText'),
)
self._token_count_widget = bui.textwidget(
parent=self._root_widget,
position=(self._width - self._x_inset - 120.0, self._height - 48),
color=(2.0, 0.7, 0.0),
shadow=1.0,
flatness=0.0,
size=(0, 0),
h_align='left',
v_align='center',
text='',
)
self._token_count = response.tokens
self._smooth_token_count = float(self._token_count)
self._smooth_update() # will set the text widget.
_tlabeltxt = bui.textwidget(
parent=self._root_widget,
position=(self._width - self._x_inset - 123.0, self._height - 48),
size=(0, 0),
h_align='right',
v_align='center',
text=bui.charstr(bui.SpecialChar.TOKEN),
)
def _purchase_press(self, itemid: str) -> None:
plus = bui.app.plus
price = None if plus is None else plus.get_price(itemid)
if price is None:
if plus is not None and plus.supports_purchases():
# Looks like internet is down or something temporary.
errmsg = bui.Lstr(resource='purchaseNotAvailableText')
else:
# Looks like purchases will never work here.
errmsg = bui.Lstr(resource='purchaseNeverAvailableText')
bui.screenmessage(errmsg, color=(1, 0.5, 0))
bui.getsound('error').play()
return
assert plus is not None
plus.purchase(itemid)
def _update_store_state(self) -> None:
"""Called to make minor updates to an already shown store."""
assert self._token_count_widget is not None
assert self._last_query_response is not None
self._token_count = self._last_query_response.tokens
# Kick off new smooth update if need be.
assert self._smooth_token_count is not None
if (
self._token_count != int(self._smooth_token_count)
and self._smooth_update_timer is None
):
self._smooth_update_timer = bui.AppTimer(
0.05, bui.WeakCall(self._smooth_update), repeat=True
)
diff = abs(float(self._token_count) - self._smooth_token_count)
self._smooth_increase_speed = (
diff / 100.0
if diff >= 5000
else (
diff / 50.0
if diff >= 1500
else diff / 30.0 if diff >= 500 else diff / 15.0
)
)
def _smooth_update(self) -> None:
# Stop if the count widget disappears.
if not self._token_count_widget:
self._smooth_update_timer = None
return
finished = False
# If we're going down, do it immediately.
assert self._smooth_token_count is not None
if int(self._smooth_token_count) >= self._token_count:
self._smooth_token_count = float(self._token_count)
finished = True
else:
# We're going up; start a sound if need be.
self._smooth_token_count = min(
self._smooth_token_count + 1.0 * self._smooth_increase_speed,
self._token_count,
)
if int(self._smooth_token_count) >= self._token_count:
finished = True
self._smooth_token_count = float(self._token_count)
elif self._ticking_sound is None:
self._ticking_sound = bui.getsound('scoreIncrease')
self._ticking_sound.play()
bui.textwidget(
edit=self._token_count_widget,
text=str(int(self._smooth_token_count)),
)
# If we've reached the target, kill the timer/sound/etc.
if finished:
self._smooth_update_timer = None
if self._ticking_sound is not None:
self._ticking_sound.stop()
self._ticking_sound = None
bui.getsound('cashRegister2').play()
def _back(self) -> None:
# No-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
if self._restore_previous_call is not None:
self._restore_previous_call(self._root_widget)
def _on_learn_more_press(self, url: str) -> None:
bui.open_url(url)
def show_get_tokens_prompt() -> None:
"""Show a 'not enough tokens' prompt with an option to purchase more.
Note that the purchase option may not always be available
depending on the build of the game.
"""
from bauiv1lib.confirm import ConfirmWindow
assert bui.app.classic is not None
# Currently always allowing token purchases.
if bool(True):
ConfirmWindow(
bui.Lstr(resource='tokens.notEnoughTokensText'),
GetTokensWindow,
ok_text=bui.Lstr(resource='tokens.getTokensText'),
width=460,
height=130,
)
else:
ConfirmWindow(
bui.Lstr(resource='tokens.notEnoughTokensText'),
cancel_button=False,
width=460,
height=130,
)

View file

@ -68,7 +68,7 @@ class HelpWindow(bui.Window):
position=(0, height - (50 if uiscale is bui.UIScale.SMALL else 45)),
size=(width, 25),
text=bui.Lstr(
resource=self._r + '.titleText',
resource=f'{self._r}.titleText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
),
color=bui.app.ui_v1.title_color,
@ -138,9 +138,9 @@ class HelpWindow(bui.Window):
self._sub_width = 660
self._sub_height = (
1590
+ bui.app.lang.get_resource(self._r + '.someDaysExtraSpace')
+ bui.app.lang.get_resource(f'{self._r}.someDaysExtraSpace')
+ bui.app.lang.get_resource(
self._r + '.orPunchingSomethingExtraSpace'
f'{self._r}.orPunchingSomethingExtraSpace'
)
)
@ -162,7 +162,7 @@ class HelpWindow(bui.Window):
paragraph = (0.8, 0.8, 1.0, 1.0)
txt = bui.Lstr(
resource=self._r + '.welcomeText',
resource=f'{self._r}.welcomeText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
).evaluate()
txt_scale = 1.4
@ -198,7 +198,7 @@ class HelpWindow(bui.Window):
assert app.classic is not None
v -= spacing * 50.0
txt = bui.Lstr(resource=self._r + '.someDaysText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.someDaysText').evaluate()
bui.textwidget(
parent=self._subcontainer,
position=(h, v),
@ -211,9 +211,9 @@ class HelpWindow(bui.Window):
v_align='center',
flatness=1.0,
)
v -= spacing * 25.0 + getres(self._r + '.someDaysExtraSpace')
v -= spacing * 25.0 + getres(f'{self._r}.someDaysExtraSpace')
txt_scale = 0.66
txt = bui.Lstr(resource=self._r + '.orPunchingSomethingText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.orPunchingSomethingText').evaluate()
bui.textwidget(
parent=self._subcontainer,
position=(h, v),
@ -226,10 +226,10 @@ class HelpWindow(bui.Window):
v_align='center',
flatness=1.0,
)
v -= spacing * 27.0 + getres(self._r + '.orPunchingSomethingExtraSpace')
v -= spacing * 27.0 + getres(f'{self._r}.orPunchingSomethingExtraSpace')
txt_scale = 1.0
txt = bui.Lstr(
resource=self._r + '.canHelpText',
resource=f'{self._r}.canHelpText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
).evaluate()
bui.textwidget(
@ -246,7 +246,7 @@ class HelpWindow(bui.Window):
v -= spacing * 70.0
txt_scale = 1.0
txt = bui.Lstr(resource=self._r + '.toGetTheMostText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.toGetTheMostText').evaluate()
bui.textwidget(
parent=self._subcontainer,
position=(h, v),
@ -262,7 +262,7 @@ class HelpWindow(bui.Window):
v -= spacing * 40.0
txt_scale = 0.74
txt = bui.Lstr(resource=self._r + '.friendsText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.friendsText').evaluate()
hval2 = h - 220
bui.textwidget(
parent=self._subcontainer,
@ -278,7 +278,7 @@ class HelpWindow(bui.Window):
)
txt = bui.Lstr(
resource=self._r + '.friendsGoodText',
resource=f'{self._r}.friendsGoodText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
).evaluate()
txt_scale = 0.7
@ -298,9 +298,9 @@ class HelpWindow(bui.Window):
v -= spacing * 45.0
txt = (
bui.Lstr(resource=self._r + '.devicesText').evaluate()
bui.Lstr(resource=f'{self._r}.devicesText').evaluate()
if app.env.vr
else bui.Lstr(resource=self._r + '.controllersText').evaluate()
else bui.Lstr(resource=f'{self._r}.controllersText').evaluate()
)
txt_scale = 0.74
hval2 = h - 220
@ -322,7 +322,7 @@ class HelpWindow(bui.Window):
infotxt = '.controllersInfoText'
txt = bui.Lstr(
resource=self._r + infotxt,
fallback_resource=self._r + '.controllersInfoText',
fallback_resource=f'{self._r}.controllersInfoText',
subs=[
('${APP_NAME}', bui.Lstr(resource='titleText')),
('${REMOTE_APP_NAME}', bui.get_remote_app_name()),
@ -330,7 +330,7 @@ class HelpWindow(bui.Window):
).evaluate()
else:
txt = bui.Lstr(
resource=self._r + '.devicesInfoText',
resource=f'{self._r}.devicesInfoText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
).evaluate()
@ -349,7 +349,7 @@ class HelpWindow(bui.Window):
v -= spacing * 150.0
txt = bui.Lstr(resource=self._r + '.controlsText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.controlsText').evaluate()
txt_scale = 1.4
txt_maxwidth = 480
bui.textwidget(
@ -383,7 +383,7 @@ class HelpWindow(bui.Window):
txt_scale = 0.7
txt = bui.Lstr(
resource=self._r + '.controlsSubtitleText',
resource=f'{self._r}.controlsSubtitleText',
subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))],
).evaluate()
bui.textwidget(
@ -413,8 +413,8 @@ class HelpWindow(bui.Window):
color=(1, 0.7, 0.3),
)
txt_scale = getres(self._r + '.punchInfoTextScale')
txt = bui.Lstr(resource=self._r + '.punchInfoText').evaluate()
txt_scale = getres(f'{self._r}.punchInfoTextScale')
txt = bui.Lstr(resource=f'{self._r}.punchInfoText').evaluate()
bui.textwidget(
parent=self._subcontainer,
position=(h - sep - 185 + 70, v + 120),
@ -437,8 +437,8 @@ class HelpWindow(bui.Window):
color=(1, 0.3, 0.3),
)
txt = bui.Lstr(resource=self._r + '.bombInfoText').evaluate()
txt_scale = getres(self._r + '.bombInfoTextScale')
txt = bui.Lstr(resource=f'{self._r}.bombInfoText').evaluate()
txt_scale = getres(f'{self._r}.bombInfoTextScale')
bui.textwidget(
parent=self._subcontainer,
position=(h + sep + 50 + 60, v - 35),
@ -462,8 +462,8 @@ class HelpWindow(bui.Window):
color=(0.5, 0.5, 1),
)
txtl = bui.Lstr(resource=self._r + '.pickUpInfoText')
txt_scale = getres(self._r + '.pickUpInfoTextScale')
txtl = bui.Lstr(resource=f'{self._r}.pickUpInfoText')
txt_scale = getres(f'{self._r}.pickUpInfoTextScale')
bui.textwidget(
parent=self._subcontainer,
position=(h + 60 + 120, v + sep + 50),
@ -486,8 +486,8 @@ class HelpWindow(bui.Window):
color=(0.4, 1, 0.4),
)
txt = bui.Lstr(resource=self._r + '.jumpInfoText').evaluate()
txt_scale = getres(self._r + '.jumpInfoTextScale')
txt = bui.Lstr(resource=f'{self._r}.jumpInfoText').evaluate()
txt_scale = getres(f'{self._r}.jumpInfoTextScale')
bui.textwidget(
parent=self._subcontainer,
position=(h - 250 + 75, v - sep - 15 + 30),
@ -500,8 +500,8 @@ class HelpWindow(bui.Window):
v_align='top',
)
txt = bui.Lstr(resource=self._r + '.runInfoText').evaluate()
txt_scale = getres(self._r + '.runInfoTextScale')
txt = bui.Lstr(resource=f'{self._r}.runInfoText').evaluate()
txt_scale = getres(f'{self._r}.runInfoTextScale')
bui.textwidget(
parent=self._subcontainer,
position=(h, v - sep - 100),
@ -517,7 +517,7 @@ class HelpWindow(bui.Window):
v -= spacing * 280.0
txt = bui.Lstr(resource=self._r + '.powerupsText').evaluate()
txt = bui.Lstr(resource=f'{self._r}.powerupsText').evaluate()
txt_scale = 1.4
txt_maxwidth = 480
bui.textwidget(
@ -546,8 +546,8 @@ class HelpWindow(bui.Window):
)
v -= spacing * 50.0
txt_scale = getres(self._r + '.powerupsSubtitleTextScale')
txt = bui.Lstr(resource=self._r + '.powerupsSubtitleText').evaluate()
txt_scale = getres(f'{self._r}.powerupsSubtitleTextScale')
txt = bui.Lstr(resource=f'{self._r}.powerupsSubtitleText').evaluate()
bui.textwidget(
parent=self._subcontainer,
position=(h, v),
@ -586,8 +586,8 @@ class HelpWindow(bui.Window):
'powerupLandMines',
'powerupCurse',
]:
name = bui.Lstr(resource=self._r + '.' + tex + 'NameText')
desc = bui.Lstr(resource=self._r + '.' + tex + 'DescriptionText')
name = bui.Lstr(resource=f'{self._r}.' + tex + 'NameText')
desc = bui.Lstr(resource=f'{self._r}.' + tex + 'DescriptionText')
v -= spacing * 60.0

View file

@ -69,7 +69,7 @@ class KioskWindow(bui.Window):
size=(0, 0),
position=(self._width * 0.5, self._height + y_extra - 44),
transition_delay=tdelay,
text=bui.Lstr(resource=self._r + '.singlePlayerExamplesText'),
text=bui.Lstr(resource=f'{self._r}.singlePlayerExamplesText'),
flatness=1.0,
scale=1.2,
h_align='center',
@ -116,7 +116,7 @@ class KioskWindow(bui.Window):
size=(0, 0),
position=(h, label_height),
maxwidth=b_width * 0.7,
text=bui.Lstr(resource=self._r + '.easyText'),
text=bui.Lstr(resource=f'{self._r}.easyText'),
scale=1.3,
h_align='center',
v_align='center',
@ -151,7 +151,7 @@ class KioskWindow(bui.Window):
size=(0, 0),
position=(h, label_height),
maxwidth=b_width * 0.7,
text=bui.Lstr(resource=self._r + '.mediumText'),
text=bui.Lstr(resource=f'{self._r}.mediumText'),
scale=1.3,
h_align='center',
v_align='center',
@ -215,7 +215,7 @@ class KioskWindow(bui.Window):
size=(0, 0),
position=(self._width * 0.5, self._height + y_extra - 44),
transition_delay=tdelay,
text=bui.Lstr(resource=self._r + '.versusExamplesText'),
text=bui.Lstr(resource=f'{self._r}.versusExamplesText'),
flatness=1.0,
scale=1.2,
h_align='center',
@ -312,7 +312,7 @@ class KioskWindow(bui.Window):
size=(0, 0),
position=(h, label_height),
maxwidth=b_width * 0.7,
text=bui.Lstr(resource=self._r + '.epicModeText'),
text=bui.Lstr(resource=f'{self._r}.epicModeText'),
scale=1.3,
h_align='center',
v_align='center',
@ -342,7 +342,7 @@ class KioskWindow(bui.Window):
scale=0.5,
position=(self._width * 0.5 - 60.0, b_v - 70.0),
transition_delay=tdelay,
label=bui.Lstr(resource=self._r + '.fullMenuText'),
label=bui.Lstr(resource=f'{self._r}.fullMenuText'),
on_activate_call=self._do_full_menu,
)
else:

View file

@ -39,7 +39,8 @@ class MainMenuWindow(bui.Window):
bui.set_analytics_screen('Main Menu')
self._show_remote_app_info_on_first_launch()
# Make a vanilla container; we'll modify it to our needs in refresh.
# Make a vanilla container; we'll modify it to our needs in
# refresh.
super().__init__(
root_widget=bui.containerwidget(
transition=transition,
@ -91,7 +92,6 @@ class MainMenuWindow(bui.Window):
0.27, bui.WeakCall(self._check_refresh), repeat=True
)
# noinspection PyUnresolvedReferences
@staticmethod
def _preload_modules() -> None:
"""Preload modules we use; avoids hitches (called in bg thread)."""
@ -162,8 +162,9 @@ class MainMenuWindow(bui.Window):
if now < self._next_refresh_allow_time:
return
# Don't refresh for the first few seconds the game is up so we don't
# interrupt the transition in.
# Don't refresh for the first few seconds the game is up so we
# don't interrupt the transition in.
# bui.app.main_menu_window_refresh_check_count += 1
# if bui.app.main_menu_window_refresh_check_count < 4:
# return
@ -254,7 +255,7 @@ class MainMenuWindow(bui.Window):
size=(self._button_width, self._button_height),
scale=scale,
autoselect=self._use_autoselect,
label=bui.Lstr(resource=self._r + '.settingsText'),
label=bui.Lstr(resource=f'{self._r}.settingsText'),
transition_delay=self._tdelay,
on_activate_call=self._settings,
)
@ -323,7 +324,7 @@ class MainMenuWindow(bui.Window):
scale=scale,
size=(self._button_width, self._button_height),
autoselect=self._use_autoselect,
label=bui.Lstr(resource=self._r + '.leavePartyText'),
label=bui.Lstr(resource=f'{self._r}.leavePartyText'),
on_activate_call=self._confirm_leave_party,
)
@ -413,8 +414,8 @@ class MainMenuWindow(bui.Window):
else:
self._quit_button = None
# If we're not in-game, have no quit button, and this is android,
# we want back presses to quit our activity.
# If we're not in-game, have no quit button, and this is
# android, we want back presses to quit our activity.
if (
not self._in_game
and not self._have_quit_button
@ -428,9 +429,9 @@ class MainMenuWindow(bui.Window):
edit=self._root_widget, on_cancel_call=_do_quit
)
# Add speed-up/slow-down buttons for replays.
# (ideally this should be part of a fading-out playback bar like most
# media players but this works for now).
# Add speed-up/slow-down buttons for replays. Ideally this
# should be part of a fading-out playback bar like most media
# players but this works for now.
if bs.is_in_replay():
b_size = 50.0
b_buffer_1 = 50.0
@ -605,13 +606,13 @@ class MainMenuWindow(bui.Window):
def _set_allow_time() -> None:
self._next_refresh_allow_time = bui.apptime() + 2.5
# Slight hack: widget transitions currently only progress when
# frames are being drawn, but this tends to get called before
# frame drawing even starts, meaning we don't know exactly how
# long we should wait before refreshing to avoid interrupting
# the transition. To make things a bit better, let's do a
# redundant set of the time in a deferred call which hopefully
# happens closer to actual frame draw times.
# Slight hack: widget transitions currently only progress
# when frames are being drawn, but this tends to get called
# before frame drawing even starts, meaning we don't know
# exactly how long we should wait before refreshing to avoid
# interrupting the transition. To make things a bit better,
# let's do a redundant set of the time in a deferred call
# which hopefully happens closer to actual frame draw times.
_set_allow_time()
bui.pushcall(_set_allow_time)
@ -880,7 +881,7 @@ class MainMenuWindow(bui.Window):
scale=scale,
autoselect=self._use_autoselect,
size=(self._button_width, self._button_height),
label=bui.Lstr(resource=self._r + '.howToPlayText'),
label=bui.Lstr(resource=f'{self._r}.howToPlayText'),
transition_delay=self._tdelay,
on_activate_call=self._howtoplay,
)
@ -912,7 +913,7 @@ class MainMenuWindow(bui.Window):
position=(h - self._button_width * 0.5 * scale, v),
size=(self._button_width, self._button_height),
autoselect=self._use_autoselect,
label=bui.Lstr(resource=self._r + '.creditsText'),
label=bui.Lstr(resource=f'{self._r}.creditsText'),
scale=scale,
transition_delay=self._tdelay,
on_activate_call=self._credits,
@ -1005,7 +1006,7 @@ class MainMenuWindow(bui.Window):
position=(h - self._button_width / 2, v),
size=(self._button_width, self._button_height),
scale=scale,
label=bui.Lstr(resource=self._r + '.resumeText'),
label=bui.Lstr(resource=f'{self._r}.resumeText'),
autoselect=self._use_autoselect,
on_activate_call=self._resume,
)
@ -1056,7 +1057,7 @@ class MainMenuWindow(bui.Window):
and player_name[-1] != '>'
):
txt = bui.Lstr(
resource=self._r + '.justPlayerText',
resource=f'{self._r}.justPlayerText',
subs=[('${NAME}', player_name)],
)
else:
@ -1070,7 +1071,7 @@ class MainMenuWindow(bui.Window):
* (0.64 if player_name != '' else 0.5),
),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.leaveGameText'),
text=bui.Lstr(resource=f'{self._r}.leaveGameText'),
scale=(0.83 if player_name != '' else 1.0),
color=(0.75, 1.0, 0.7),
h_align='center',
@ -1209,7 +1210,7 @@ class MainMenuWindow(bui.Window):
# Select cancel by default; this occasionally gets called by accident
# in a fit of button mashing and this will help reduce damage.
ConfirmWindow(
bui.Lstr(resource=self._r + '.exitToMenuText'),
bui.Lstr(resource=f'{self._r}.exitToMenuText'),
self._end_game,
cancel_is_selected=True,
)
@ -1221,7 +1222,7 @@ class MainMenuWindow(bui.Window):
# Select cancel by default; this occasionally gets called by accident
# in a fit of button mashing and this will help reduce damage.
ConfirmWindow(
bui.Lstr(resource=self._r + '.exitToMenuText'),
bui.Lstr(resource=f'{self._r}.exitToMenuText'),
self._end_game,
cancel_is_selected=True,
)
@ -1233,7 +1234,7 @@ class MainMenuWindow(bui.Window):
# Select cancel by default; this occasionally gets called by accident
# in a fit of button mashing and this will help reduce damage.
ConfirmWindow(
bui.Lstr(resource=self._r + '.exitToMenuText'),
bui.Lstr(resource=f'{self._r}.exitToMenuText'),
self._end_game,
cancel_is_selected=True,
)
@ -1245,7 +1246,7 @@ class MainMenuWindow(bui.Window):
# Select cancel by default; this occasionally gets called by accident
# in a fit of button mashing and this will help reduce damage.
ConfirmWindow(
bui.Lstr(resource=self._r + '.leavePartyConfirmText'),
bui.Lstr(resource=f'{self._r}.leavePartyConfirmText'),
self._leave_party,
cancel_is_selected=True,
)

View file

@ -96,7 +96,7 @@ class PartyWindow(bui.Window):
if info is not None and info.name != '':
title = bui.Lstr(value=info.name)
else:
title = bui.Lstr(resource=self._r + '.titleText')
title = bui.Lstr(resource=f'{self._r}.titleText')
self._title_text = bui.textwidget(
parent=self._root_widget,
@ -151,7 +151,7 @@ class PartyWindow(bui.Window):
maxwidth=494,
shadow=0.3,
flatness=1.0,
description=bui.Lstr(resource=self._r + '.chatMessageText'),
description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
autoselect=True,
v_align='center',
corner_scale=0.7,
@ -175,7 +175,7 @@ class PartyWindow(bui.Window):
btn = bui.buttonwidget(
parent=self._root_widget,
size=(50, 35),
label=bui.Lstr(resource=self._r + '.sendText'),
label=bui.Lstr(resource=f'{self._r}.sendText'),
button_type='square',
autoselect=True,
position=(self._width - 70, 35),
@ -294,7 +294,7 @@ class PartyWindow(bui.Window):
top_section_height = 60
bui.textwidget(
edit=self._empty_str,
text=bui.Lstr(resource=self._r + '.emptyText'),
text=bui.Lstr(resource=f'{self._r}.emptyText'),
)
bui.scrollwidget(
edit=self._scrollwidget,
@ -428,7 +428,7 @@ class PartyWindow(bui.Window):
maxwidth=c_width * 0.96 - twd,
color=(0.1, 1, 0.1, 0.5),
text=bui.Lstr(
resource=self._r + '.hostText'
resource=f'{self._r}.hostText'
),
scale=0.4,
shadow=0.1,
@ -578,8 +578,10 @@ class PartyWindow(bui.Window):
self._popup_party_member_is_host = is_host
def _send_chat_message(self) -> None:
bs.chatmessage(cast(str, bui.textwidget(query=self._text_field)))
bui.textwidget(edit=self._text_field, text='')
text = cast(str, bui.textwidget(query=self._text_field)).strip()
if text != '':
bs.chatmessage(text)
bui.textwidget(edit=self._text_field, text='')
def close(self) -> None:
"""Close the window."""

View file

@ -564,7 +564,7 @@ class PartyQueueWindow(bui.Window):
def on_boost_press(self) -> None:
"""Boost was pressed."""
from bauiv1lib import account
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
plus = bui.app.plus
assert plus is not None
@ -575,7 +575,7 @@ class PartyQueueWindow(bui.Window):
if plus.get_v1_account_ticket_count() < self._boost_tickets:
bui.getsound('error').play()
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
return
bui.getsound('laserReverse').play()

View file

@ -82,7 +82,7 @@ class PlayWindow(bui.Window):
size=(0, 0),
text=bui.Lstr(
resource=(
(self._r + '.titleText')
(f'{self._r}.titleText')
if self._is_main_menu
else 'playlistsText'
)
@ -228,7 +228,7 @@ class PlayWindow(bui.Window):
draw_controller=btn,
position=(hoffs + scl * (-10), v + (scl * 54)),
size=(scl * button_width, scl * 30),
text=bui.Lstr(resource=self._r + '.oneToFourPlayersText'),
text=bui.Lstr(resource=f'{self._r}.oneToFourPlayersText'),
h_align='center',
v_align='center',
scale=0.83 * scl,
@ -359,7 +359,7 @@ class PlayWindow(bui.Window):
draw_controller=btn,
position=(hoffs + scl * (-10), v + (scl * 54)),
size=(scl * button_width, scl * 30),
text=bui.Lstr(resource=self._r + '.twoToEightPlayersText'),
text=bui.Lstr(resource=f'{self._r}.twoToEightPlayersText'),
h_align='center',
v_align='center',
res_scale=1.5,
@ -480,7 +480,7 @@ class PlayWindow(bui.Window):
draw_controller=btn,
position=(hoffs + scl * (-10), v + (scl * 54)),
size=(scl * button_width, scl * 30),
text=bui.Lstr(resource=self._r + '.twoToEightPlayersText'),
text=bui.Lstr(resource=f'{self._r}.twoToEightPlayersText'),
h_align='center',
v_align='center',
scale=0.9 * scl,

View file

@ -81,7 +81,7 @@ class PlaylistAddGameWindow(bui.Window):
position=(self._width * 0.5, self._height - 28),
size=(0, 0),
scale=1.0,
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
h_align='center',
color=bui.app.ui_v1.title_color,
maxwidth=250,
@ -211,7 +211,7 @@ class PlaylistAddGameWindow(bui.Window):
self._get_more_games_button = bui.buttonwidget(
parent=self._column,
autoselect=True,
label=bui.Lstr(resource=self._r + '.getMoreGamesText'),
label=bui.Lstr(resource=f'{self._r}.getMoreGamesText'),
color=(0.54, 0.52, 0.67),
textcolor=(0.7, 0.65, 0.7),
on_activate_call=self._on_get_more_games_press,

View file

@ -88,7 +88,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
position=(0, self._height - 47),
size=(self._width, 25),
text=bui.Lstr(
resource=self._r + '.titleText',
resource=f'{self._r}.titleText',
subs=[('${TYPE}', self._pvars.window_title_name)],
),
color=bui.app.ui_v1.heading_color,
@ -129,7 +129,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
textcolor=b_textcolor,
text_scale=0.7,
label=bui.Lstr(
resource='newText', fallback_resource=self._r + '.newText'
resource='newText', fallback_resource=f'{self._r}.newText'
),
)
self._lock_images.append(
@ -154,7 +154,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
button_type='square',
text_scale=0.7,
label=bui.Lstr(
resource='editText', fallback_resource=self._r + '.editText'
resource='editText', fallback_resource=f'{self._r}.editText'
),
)
self._lock_images.append(
@ -180,7 +180,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
text_scale=0.7,
label=bui.Lstr(
resource='duplicateText',
fallback_resource=self._r + '.duplicateText',
fallback_resource=f'{self._r}.duplicateText',
),
)
self._lock_images.append(
@ -205,7 +205,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
button_type='square',
text_scale=0.7,
label=bui.Lstr(
resource='deleteText', fallback_resource=self._r + '.deleteText'
resource='deleteText', fallback_resource=f'{self._r}.deleteText'
),
)
self._lock_images.append(
@ -400,7 +400,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
txtw = bui.textwidget(
parent=self._columnwidget,
size=(self._width - 40, 30),
maxwidth=self._width - 110,
maxwidth=440,
text=self._get_playlist_display_name(pname),
h_align='left',
v_align='center',
@ -509,7 +509,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantEditDefaultText')
bui.Lstr(resource=f'{self._r}.cantEditDefaultText')
)
return
self._save_playlist_selection()
@ -598,7 +598,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantShareDefaultText'),
bui.Lstr(resource=f'{self._r}.cantShareDefaultText'),
color=(1, 0, 0),
)
return
@ -635,12 +635,12 @@ class PlaylistCustomizeBrowserWindow(bui.Window):
if self._selected_playlist_name == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantDeleteDefaultText')
bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText')
)
else:
ConfirmWindow(
bui.Lstr(
resource=self._r + '.deleteConfirmText',
resource=f'{self._r}.deleteConfirmText',
subs=[('${LIST}', self._selected_playlist_name)],
),
self._do_delete_playlist,

View file

@ -90,7 +90,7 @@ class PlaylistEditWindow(bui.Window):
parent=self._root_widget,
position=(-10, self._height - 50),
size=(self._width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
scale=1.05,
h_align='center',
@ -104,7 +104,7 @@ class PlaylistEditWindow(bui.Window):
bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource=self._r + '.listNameText'),
text=bui.Lstr(resource=f'{self._r}.listNameText'),
position=(196 + x_inset, v + 31),
maxwidth=150,
color=(0.8, 0.8, 0.8, 0.5),
@ -122,9 +122,10 @@ class PlaylistEditWindow(bui.Window):
h_align='left',
v_align='center',
max_chars=40,
maxwidth=380,
autoselect=True,
color=(0.9, 0.9, 0.9, 1.0),
description=bui.Lstr(resource=self._r + '.listNameText'),
description=bui.Lstr(resource=f'{self._r}.listNameText'),
editable=True,
padding=4,
on_return_press_call=self._save_press_with_sound,
@ -160,7 +161,7 @@ class PlaylistEditWindow(bui.Window):
color=b_color,
textcolor=b_textcolor,
text_scale=0.8,
label=bui.Lstr(resource=self._r + '.addGameText'),
label=bui.Lstr(resource=f'{self._r}.addGameText'),
)
bui.widget(edit=add_game_button, up_widget=self._text_field)
v -= 63.0 * scl
@ -176,7 +177,7 @@ class PlaylistEditWindow(bui.Window):
color=b_color,
textcolor=b_textcolor,
text_scale=0.8,
label=bui.Lstr(resource=self._r + '.editGameText'),
label=bui.Lstr(resource=f'{self._r}.editGameText'),
)
v -= 63.0 * scl
@ -190,7 +191,7 @@ class PlaylistEditWindow(bui.Window):
button_type='square',
color=b_color,
textcolor=b_textcolor,
label=bui.Lstr(resource=self._r + '.removeGameText'),
label=bui.Lstr(resource=f'{self._r}.removeGameText'),
)
v -= 40
h += 9
@ -330,7 +331,7 @@ class PlaylistEditWindow(bui.Window):
]
):
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantSaveAlreadyExistsText')
bui.Lstr(resource=f'{self._r}.cantSaveAlreadyExistsText')
)
bui.getsound('error').play()
return
@ -339,7 +340,7 @@ class PlaylistEditWindow(bui.Window):
return
if not self._editcontroller.get_playlist():
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantSaveEmptyListText')
bui.Lstr(resource=f'{self._r}.cantSaveEmptyListText')
)
bui.getsound('error').play()
return
@ -348,7 +349,7 @@ class PlaylistEditWindow(bui.Window):
# using its exact name to avoid confusion.
if new_name == self._editcontroller.get_default_list_name().evaluate():
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantOverwriteDefaultText')
bui.Lstr(resource=f'{self._r}.cantOverwriteDefaultText')
)
bui.getsound('error').play()
return

View file

@ -159,7 +159,7 @@ class PlaylistEditGameWindow(bui.Window):
scale=0.75,
text_scale=1.3,
label=(
bui.Lstr(resource=self._r + '.addGameText')
bui.Lstr(resource=f'{self._r}.addGameText')
if is_add
else bui.Lstr(resource='doneText')
),

View file

@ -341,7 +341,7 @@ class PlayOptionsWindow(PopupWindow):
scale=1.0,
size=(250, 30),
autoselect=True,
text=bui.Lstr(resource=self._r + '.shuffleGameOrderText'),
text=bui.Lstr(resource=f'{self._r}.shuffleGameOrderText'),
maxwidth=300,
textcolor=(0.8, 0.8, 0.8),
value=self._do_randomize_val,
@ -362,7 +362,7 @@ class PlayOptionsWindow(PopupWindow):
scale=1.0,
size=(250, 30),
autoselect=True,
text=bui.Lstr(resource=self._r + '.showTutorialText'),
text=bui.Lstr(resource=f'{self._r}.showTutorialText'),
maxwidth=300,
textcolor=(0.8, 0.8, 0.8),
value=show_tutorial,

View file

@ -96,7 +96,7 @@ class ProfileBrowserWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 36),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=300,
color=bui.app.ui_v1.title_color,
scale=0.9,
@ -134,7 +134,7 @@ class ProfileBrowserWindow(bui.Window):
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.newButtonText'),
label=bui.Lstr(resource=f'{self._r}.newButtonText'),
)
v -= 70.0 * scl
self._edit_button = bui.buttonwidget(
@ -147,7 +147,7 @@ class ProfileBrowserWindow(bui.Window):
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.editButtonText'),
label=bui.Lstr(resource=f'{self._r}.editButtonText'),
)
v -= 70.0 * scl
self._delete_button = bui.buttonwidget(
@ -160,7 +160,7 @@ class ProfileBrowserWindow(bui.Window):
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.deleteButtonText'),
label=bui.Lstr(resource=f'{self._r}.deleteButtonText'),
)
v = self._height - 87
@ -169,7 +169,7 @@ class ProfileBrowserWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 71),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.explanationText'),
text=bui.Lstr(resource=f'{self._r}.explanationText'),
color=bui.app.ui_v1.infotextcolor,
maxwidth=self._width * 0.83,
scale=0.6,
@ -269,13 +269,13 @@ class ProfileBrowserWindow(bui.Window):
if self._selected_profile == '__account__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantDeleteAccountProfileText'),
bui.Lstr(resource=f'{self._r}.cantDeleteAccountProfileText'),
color=(1, 0, 0),
)
return
confirm.ConfirmWindow(
bui.Lstr(
resource=self._r + '.deleteConfirmText',
resource=f'{self._r}.deleteConfirmText',
subs=[('${PROFILE}', self._selected_profile)],
),
self._do_delete_profile,

View file

@ -110,9 +110,9 @@ class EditProfileWindow(bui.Window):
position=(self._width * 0.5, height - 38),
size=(0, 0),
text=(
bui.Lstr(resource=self._r + '.titleNewText')
bui.Lstr(resource=f'{self._r}.titleNewText')
if existing_profile is None
else bui.Lstr(resource=self._r + '.titleEditText')
else bui.Lstr(resource=f'{self._r}.titleEditText')
),
color=bui.app.ui_v1.title_color,
maxwidth=290,
@ -200,7 +200,7 @@ class EditProfileWindow(bui.Window):
if not self._is_account_profile and not self._global:
bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource=self._r + '.nameText'),
text=bui.Lstr(resource=f'{self._r}.nameText'),
position=(200 + x_inset, v - 6),
size=(0, 0),
h_align='right',
@ -286,7 +286,7 @@ class EditProfileWindow(bui.Window):
position=(self._width * 0.5 - 160, v - 55 - 15),
size=(0, 0),
draw_controller=btn,
text=bui.Lstr(resource=self._r + '.iconText'),
text=bui.Lstr(resource=f'{self._r}.iconText'),
scale=0.7,
color=bui.app.ui_v1.title_color,
maxwidth=120,
@ -343,7 +343,7 @@ class EditProfileWindow(bui.Window):
h_align='left',
v_align='center',
max_chars=16,
description=bui.Lstr(resource=self._r + '.nameDescriptionText'),
description=bui.Lstr(resource=f'{self._r}.nameDescriptionText'),
autoselect=True,
editable=True,
padding=4,
@ -433,7 +433,7 @@ class EditProfileWindow(bui.Window):
position=(self._width * 0.5 - b_offs, v - 65),
size=(0, 0),
draw_controller=btn,
text=bui.Lstr(resource=self._r + '.colorText'),
text=bui.Lstr(resource=f'{self._r}.colorText'),
scale=0.7,
color=bui.app.ui_v1.title_color,
maxwidth=120,
@ -461,7 +461,7 @@ class EditProfileWindow(bui.Window):
position=(self._width * 0.5, v - 80),
size=(0, 0),
draw_controller=btn,
text=bui.Lstr(resource=self._r + '.characterText'),
text=bui.Lstr(resource=f'{self._r}.characterText'),
scale=0.7,
color=bui.app.ui_v1.title_color,
maxwidth=130,
@ -505,7 +505,7 @@ class EditProfileWindow(bui.Window):
position=(self._width * 0.5 + b_offs, v - 65),
size=(0, 0),
draw_controller=btn,
text=bui.Lstr(resource=self._r + '.highlightText'),
text=bui.Lstr(resource=f'{self._r}.highlightText'),
scale=0.7,
color=bui.app.ui_v1.title_color,
maxwidth=120,
@ -545,8 +545,6 @@ class EditProfileWindow(bui.Window):
for n in [
bui.SpecialChar.GOOGLE_PLAY_GAMES_LOGO,
bui.SpecialChar.GAME_CENTER_LOGO,
bui.SpecialChar.GAME_CIRCLE_LOGO,
bui.SpecialChar.OUYA_LOGO,
bui.SpecialChar.LOCAL_ACCOUNT,
bui.SpecialChar.OCULUS_LOGO,
bui.SpecialChar.NVIDIA_LOGO,

View file

@ -87,7 +87,7 @@ class ProfileUpgradeWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 38),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.upgradeToGlobalProfileText'),
text=bui.Lstr(resource=f'{self._r}.upgradeToGlobalProfileText'),
color=bui.app.ui_v1.title_color,
maxwidth=self._width * 0.45,
scale=1.0,
@ -100,7 +100,7 @@ class ProfileUpgradeWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 100),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.upgradeProfileInfoText'),
text=bui.Lstr(resource=f'{self._r}.upgradeProfileInfoText'),
color=bui.app.ui_v1.infotextcolor,
maxwidth=self._width * 0.8,
scale=0.7,
@ -113,7 +113,7 @@ class ProfileUpgradeWindow(bui.Window):
position=(self._width * 0.5, self._height - 160),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.checkingAvailabilityText',
resource=f'{self._r}.checkingAvailabilityText',
subs=[('${NAME}', self._name)],
),
color=(0.8, 0.4, 0.0),
@ -183,7 +183,7 @@ class ProfileUpgradeWindow(bui.Window):
bui.textwidget(
edit=self._status_text,
text=bui.Lstr(
resource=self._r + '.availableText',
resource=f'{self._r}.availableText',
subs=[('${NAME}', self._name)],
),
color=(0, 1, 0),
@ -197,7 +197,7 @@ class ProfileUpgradeWindow(bui.Window):
bui.textwidget(
edit=self._status_text,
text=bui.Lstr(
resource=self._r + '.unavailableText',
resource=f'{self._r}.unavailableText',
subs=[('${NAME}', self._name)],
),
color=(1, 0, 0),
@ -210,7 +210,7 @@ class ProfileUpgradeWindow(bui.Window):
)
def _on_upgrade_press(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
if self._status is None:
plus = bui.app.plus
@ -220,7 +220,7 @@ class ProfileUpgradeWindow(bui.Window):
tickets = plus.get_v1_account_ticket_count()
if tickets < self._cost:
bui.getsound('error').play()
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
return
bui.screenmessage(
bui.Lstr(resource='purchasingText'), color=(0, 1, 0)

View file

@ -162,7 +162,7 @@ class PurchaseWindow(bui.Window):
bui.containerwidget(edit=self._root_widget, transition='out_left')
def _purchase(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
plus = bui.app.plus
assert plus is not None
@ -176,7 +176,7 @@ class PurchaseWindow(bui.Window):
except Exception:
ticket_count = None
if ticket_count is not None and ticket_count < self._price:
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
bui.getsound('error').play()
return

View file

@ -109,7 +109,7 @@ class SendInfoWindow(bui.Window):
parent=self._root_widget,
text=bui.Lstr(
resource=(
self._r + '.codeText'
f'{self._r}.codeText'
if legacy_code_mode
else 'descriptionText'
)
@ -133,7 +133,7 @@ class SendInfoWindow(bui.Window):
color=(0.9, 0.9, 0.9, 1.0),
description=bui.Lstr(
resource=(
self._r + '.codeText'
f'{self._r}.codeText'
if legacy_code_mode
else 'descriptionText'
)
@ -152,7 +152,7 @@ class SendInfoWindow(bui.Window):
size=(b_width, 60),
scale=1.0,
label=bui.Lstr(
resource='submitText', fallback_resource=self._r + '.enterText'
resource='submitText', fallback_resource=f'{self._r}.enterText'
),
on_activate_call=self._do_enter,
)

View file

@ -87,7 +87,7 @@ class AllSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, height - 44),
size=(width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
@ -143,7 +143,7 @@ class AllSettingsWindow(bui.Window):
bbtn = bui.get_special_widget('back_button')
bui.widget(edit=ctb, left_widget=bbtn)
_b_title(
x_offs2, v, ctb, bui.Lstr(resource=self._r + '.controllersText')
x_offs2, v, ctb, bui.Lstr(resource=f'{self._r}.controllersText')
)
imgw = imgh = 130
bui.imagewidget(
@ -166,7 +166,7 @@ class AllSettingsWindow(bui.Window):
if bui.app.ui_v1.use_toolbars:
pbtn = bui.get_special_widget('party_button')
bui.widget(edit=gfxb, up_widget=pbtn, right_widget=pbtn)
_b_title(x_offs3, v, gfxb, bui.Lstr(resource=self._r + '.graphicsText'))
_b_title(x_offs3, v, gfxb, bui.Lstr(resource=f'{self._r}.graphicsText'))
imgw = imgh = 110
bui.imagewidget(
parent=self._root_widget,
@ -187,7 +187,7 @@ class AllSettingsWindow(bui.Window):
label='',
on_activate_call=self._do_audio,
)
_b_title(x_offs4, v, abtn, bui.Lstr(resource=self._r + '.audioText'))
_b_title(x_offs4, v, abtn, bui.Lstr(resource=f'{self._r}.audioText'))
imgw = imgh = 120
bui.imagewidget(
parent=self._root_widget,
@ -207,7 +207,7 @@ class AllSettingsWindow(bui.Window):
label='',
on_activate_call=self._do_advanced,
)
_b_title(x_offs5, v, avb, bui.Lstr(resource=self._r + '.advancedText'))
_b_title(x_offs5, v, avb, bui.Lstr(resource=f'{self._r}.advancedText'))
imgw = imgh = 120
bui.imagewidget(
parent=self._root_widget,

View file

@ -97,7 +97,7 @@ class AudioSettingsWindow(bui.Window):
parent=self._root_widget,
position=(width * 0.5, height - 32),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
maxwidth=180,
h_align='center',
@ -116,7 +116,7 @@ class AudioSettingsWindow(bui.Window):
position=(40, v),
xoffset=10,
configkey='Sound Volume',
displayname=bui.Lstr(resource=self._r + '.soundVolumeText'),
displayname=bui.Lstr(resource=f'{self._r}.soundVolumeText'),
minval=0.0,
maxval=1.0,
increment=0.05,
@ -133,7 +133,7 @@ class AudioSettingsWindow(bui.Window):
position=(40, v),
xoffset=10,
configkey='Music Volume',
displayname=bui.Lstr(resource=self._r + '.musicVolumeText'),
displayname=bui.Lstr(resource=f'{self._r}.musicVolumeText'),
minval=0.0,
maxval=1.0,
increment=0.05,
@ -151,7 +151,7 @@ class AudioSettingsWindow(bui.Window):
parent=self._root_widget,
position=(40, v + 24),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.headRelativeVRAudioText'),
text=bui.Lstr(resource=f'{self._r}.headRelativeVRAudioText'),
color=(0.8, 0.8, 0.8),
maxwidth=230,
h_align='left',
@ -179,7 +179,7 @@ class AudioSettingsWindow(bui.Window):
position=(width * 0.5, v - 11),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.headRelativeVRAudioInfoText'
resource=f'{self._r}.headRelativeVRAudioInfoText'
),
scale=0.5,
color=(0.7, 0.8, 0.7),
@ -200,7 +200,7 @@ class AudioSettingsWindow(bui.Window):
position=((width - 310) / 2, v),
size=(310, 50),
autoselect=True,
label=bui.Lstr(resource=self._r + '.soundtrackButtonText'),
label=bui.Lstr(resource=f'{self._r}.soundtrackButtonText'),
on_activate_call=self._do_soundtracks,
)
v -= spacing * 0.5
@ -208,7 +208,7 @@ class AudioSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, v),
size=(width, 20),
text=bui.Lstr(resource=self._r + '.soundtrackDescriptionText'),
text=bui.Lstr(resource=f'{self._r}.soundtrackDescriptionText'),
flatness=1.0,
h_align='center',
scale=0.5,

View file

@ -152,7 +152,7 @@ class ControlsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, height - 49),
size=(width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='top',
@ -173,7 +173,7 @@ class ControlsSettingsWindow(bui.Window):
position=((width - button_width) / 2, v),
size=(button_width, 43),
autoselect=True,
label=bui.Lstr(resource=self._r + '.configureTouchText'),
label=bui.Lstr(resource=f'{self._r}.configureTouchText'),
on_activate_call=self._do_touchscreen,
)
if bui.app.ui_v1.use_toolbars:
@ -197,7 +197,7 @@ class ControlsSettingsWindow(bui.Window):
position=((width - button_width) / 2 - 7, v),
size=(button_width, 43),
autoselect=True,
label=bui.Lstr(resource=self._r + '.configureControllersText'),
label=bui.Lstr(resource=f'{self._r}.configureControllersText'),
on_activate_call=self._do_gamepads,
)
if bui.app.ui_v1.use_toolbars:
@ -223,12 +223,15 @@ class ControlsSettingsWindow(bui.Window):
if show_keyboard:
self._keyboard_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=((width - button_width) / 2 + 5, v),
position=((width - button_width) / 2 - 5, v),
size=(button_width, 43),
autoselect=True,
label=bui.Lstr(resource=self._r + '.configureKeyboardText'),
label=bui.Lstr(resource=f'{self._r}.configureKeyboardText'),
on_activate_call=self._config_keyboard,
)
bui.widget(
edit=self._keyboard_button, left_widget=self._keyboard_button
)
if bui.app.ui_v1.use_toolbars:
bui.widget(
edit=btn,
@ -249,10 +252,14 @@ class ControlsSettingsWindow(bui.Window):
position=((width - button_width) / 2 - 3, v),
size=(button_width, 43),
autoselect=True,
label=bui.Lstr(resource=self._r + '.configureKeyboard2Text'),
label=bui.Lstr(resource=f'{self._r}.configureKeyboard2Text'),
on_activate_call=self._config_keyboard2,
)
v -= spacing
bui.widget(
edit=self._keyboard_2_button,
left_widget=self._keyboard_2_button,
)
if show_space_2:
v -= space_height
if show_remote:
@ -261,9 +268,12 @@ class ControlsSettingsWindow(bui.Window):
position=((width - button_width) / 2 - 5, v),
size=(button_width, 43),
autoselect=True,
label=bui.Lstr(resource=self._r + '.configureMobileText'),
label=bui.Lstr(resource=f'{self._r}.configureMobileText'),
on_activate_call=self._do_mobile_devices,
)
bui.widget(
edit=self._idevices_button, left_widget=self._idevices_button
)
if bui.app.ui_v1.use_toolbars:
bui.widget(
edit=btn,
@ -289,7 +299,7 @@ class ControlsSettingsWindow(bui.Window):
bui.getsound('gunCocking').play()
bui.set_low_level_config_value('enablexinput', not value)
bui.checkboxwidget(
xinput_checkbox = bui.checkboxwidget(
parent=self._root_widget,
position=(100, v + 3),
size=(120, 30),
@ -310,6 +320,11 @@ class ControlsSettingsWindow(bui.Window):
color=bui.app.ui_v1.infotextcolor,
maxwidth=width * 0.8,
)
bui.widget(
edit=xinput_checkbox,
left_widget=xinput_checkbox,
right_widget=xinput_checkbox,
)
v -= spacing
if show_mac_controller_subsystem:

View file

@ -205,7 +205,7 @@ class GamepadSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, v + 5),
size=(self._width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
maxwidth=310,
h_align='center',
@ -229,7 +229,7 @@ class GamepadSettingsWindow(bui.Window):
parent=self._root_widget,
position=(50, v + 10),
size=(self._width - 100, 30),
text=bui.Lstr(resource=self._r + '.appliesToAllText'),
text=bui.Lstr(resource=f'{self._r}.appliesToAllText'),
maxwidth=330,
scale=0.65,
color=(0.5, 0.6, 0.5, 1.0),
@ -244,7 +244,7 @@ class GamepadSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, v + 5),
size=(self._width, 25),
text=bui.Lstr(resource=self._r + '.secondaryText'),
text=bui.Lstr(resource=f'{self._r}.secondaryText'),
color=bui.app.ui_v1.title_color,
maxwidth=300,
h_align='center',
@ -256,7 +256,7 @@ class GamepadSettingsWindow(bui.Window):
parent=self._root_widget,
position=(50, v + 10),
size=(self._width - 100, 30),
text=bui.Lstr(resource=self._r + '.secondHalfText'),
text=bui.Lstr(resource=f'{self._r}.secondHalfText'),
maxwidth=300,
scale=0.65,
color=(0.6, 0.8, 0.6, 1.0),
@ -269,7 +269,7 @@ class GamepadSettingsWindow(bui.Window):
autoselect=True,
on_value_change_call=self._enable_check_box_changed,
size=(200, 30),
text=bui.Lstr(resource=self._r + '.secondaryEnableText'),
text=bui.Lstr(resource=f'{self._r}.secondaryEnableText'),
scale=1.2,
)
v = self._height - 205
@ -279,8 +279,8 @@ class GamepadSettingsWindow(bui.Window):
d_color = (0.4, 0.4, 0.8)
sclx = 1.2
scly = 0.98
dpm = bui.Lstr(resource=self._r + '.pressAnyButtonOrDpadText')
dpm2 = bui.Lstr(resource=self._r + '.ifNothingHappensTryAnalogText')
dpm = bui.Lstr(resource=f'{self._r}.pressAnyButtonOrDpadText')
dpm2 = bui.Lstr(resource=f'{self._r}.ifNothingHappensTryAnalogText')
self._capture_button(
pos=(h_offs, v + scly * dist),
color=d_color,
@ -318,7 +318,7 @@ class GamepadSettingsWindow(bui.Window):
message2=dpm2,
)
dpm3 = bui.Lstr(resource=self._r + '.ifNothingHappensTryDpadText')
dpm3 = bui.Lstr(resource=f'{self._r}.ifNothingHappensTryDpadText')
self._capture_button(
pos=(h_offs + 130, v - 125),
color=(0.4, 0.4, 0.6),
@ -326,7 +326,7 @@ class GamepadSettingsWindow(bui.Window):
maxwidth=140,
texture=bui.gettexture('analogStick'),
scale=1.2,
message=bui.Lstr(resource=self._r + '.pressLeftRightText'),
message=bui.Lstr(resource=f'{self._r}.pressLeftRightText'),
message2=dpm3,
)
@ -563,13 +563,13 @@ class GamepadSettingsWindow(bui.Window):
+ ' / '
+ self._input.get_axis_name(sval2)
)
return bui.Lstr(resource=self._r + '.unsetText')
return bui.Lstr(resource=f'{self._r}.unsetText')
# If they're looking for triggers.
if control in ['triggerRun1' + self._ext, 'triggerRun2' + self._ext]:
if control in self._settings:
return self._input.get_axis_name(self._settings[control])
return bui.Lstr(resource=self._r + '.unsetText')
return bui.Lstr(resource=f'{self._r}.unsetText')
# Dead-zone.
if control == 'analogStickDeadZone' + self._ext:
@ -590,7 +590,7 @@ class GamepadSettingsWindow(bui.Window):
if any(b in self._settings for b in dpad_buttons):
if control in self._settings:
return self._input.get_button_name(self._settings[control])
return bui.Lstr(resource=self._r + '.unsetText')
return bui.Lstr(resource=f'{self._r}.unsetText')
# No dpad buttons - show the dpad number for all 4.
dpadnum = (
@ -603,19 +603,19 @@ class GamepadSettingsWindow(bui.Window):
return bui.Lstr(
value='${A} ${B}',
subs=[
('${A}', bui.Lstr(resource=self._r + '.dpadText')),
('${A}', bui.Lstr(resource=f'{self._r}.dpadText')),
(
'${B}',
str(dpadnum),
),
],
)
return bui.Lstr(resource=self._r + '.unsetText')
return bui.Lstr(resource=f'{self._r}.unsetText')
# Other buttons.
if control in self._settings:
return self._input.get_button_name(self._settings[control])
return bui.Lstr(resource=self._r + '.unsetText')
return bui.Lstr(resource=f'{self._r}.unsetText')
def _gamepad_event(
self,
@ -694,7 +694,7 @@ class GamepadSettingsWindow(bui.Window):
self._input,
'analogStickUD' + ext,
self._gamepad_event,
bui.Lstr(resource=self._r + '.pressUpDownText'),
bui.Lstr(resource=f'{self._r}.pressUpDownText'),
)
elif control == 'analogStickUD' + ext:
@ -745,7 +745,7 @@ class GamepadSettingsWindow(bui.Window):
maxwidth: float = 80.0,
) -> bui.Widget:
if message is None:
message = bui.Lstr(resource=self._r + '.pressAnyButtonText')
message = bui.Lstr(resource=f'{self._r}.pressAnyButtonText')
base_size = 79
btn = bui.buttonwidget(
parent=self._root_widget,
@ -852,7 +852,7 @@ class GamepadSettingsWindow(bui.Window):
'reset',
]
choices_display: list[bui.Lstr] = [
bui.Lstr(resource=self._r + '.advancedText'),
bui.Lstr(resource=f'{self._r}.advancedText'),
bui.Lstr(resource='settingsWindowAdvanced.resetText'),
]

View file

@ -58,7 +58,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
self._height - (40 if uiscale is bui.UIScale.SMALL else 34),
),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.advancedTitleText'),
text=bui.Lstr(resource=f'{self._r}.advancedTitleText'),
maxwidth=320,
color=bui.app.ui_v1.title_color,
h_align='center',
@ -126,7 +126,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h + 70, v),
size=(500, 30),
text=bui.Lstr(resource=self._r + '.unassignedButtonsRunText'),
text=bui.Lstr(resource=f'{self._r}.unassignedButtonsRunText'),
textcolor=(0.8, 0.8, 0.8),
maxwidth=330,
scale=1.0,
@ -140,7 +140,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
v -= 60
capb = self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.runButton1Text'),
name=bui.Lstr(resource=f'{self._r}.runButton1Text'),
control='buttonRun1' + self._parent_window.get_ext(),
)
if self._parent_window.get_is_secondary():
@ -149,14 +149,14 @@ class GamepadAdvancedSettingsWindow(bui.Window):
v -= 42
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.runButton2Text'),
name=bui.Lstr(resource=f'{self._r}.runButton2Text'),
control='buttonRun2' + self._parent_window.get_ext(),
)
bui.textwidget(
parent=self._subcontainer,
position=(self._sub_width * 0.5, v - 24),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.runTriggerDescriptionText'),
text=bui.Lstr(resource=f'{self._r}.runTriggerDescriptionText'),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
scale=0.7,
@ -168,16 +168,16 @@ class GamepadAdvancedSettingsWindow(bui.Window):
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.runTrigger1Text'),
name=bui.Lstr(resource=f'{self._r}.runTrigger1Text'),
control='triggerRun1' + self._parent_window.get_ext(),
message=bui.Lstr(resource=self._r + '.pressAnyAnalogTriggerText'),
message=bui.Lstr(resource=f'{self._r}.pressAnyAnalogTriggerText'),
)
v -= 42
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.runTrigger2Text'),
name=bui.Lstr(resource=f'{self._r}.runTrigger2Text'),
control='triggerRun2' + self._parent_window.get_ext(),
message=bui.Lstr(resource=self._r + '.pressAnyAnalogTriggerText'),
message=bui.Lstr(resource=f'{self._r}.pressAnyAnalogTriggerText'),
)
# in vr mode, allow assigning a reset-view button
@ -185,45 +185,45 @@ class GamepadAdvancedSettingsWindow(bui.Window):
v -= 50
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.vrReorientButtonText'),
name=bui.Lstr(resource=f'{self._r}.vrReorientButtonText'),
control='buttonVRReorient' + self._parent_window.get_ext(),
)
v -= 60
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.extraStartButtonText'),
name=bui.Lstr(resource=f'{self._r}.extraStartButtonText'),
control='buttonStart2' + self._parent_window.get_ext(),
)
v -= 60
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.ignoredButton1Text'),
name=bui.Lstr(resource=f'{self._r}.ignoredButton1Text'),
control='buttonIgnored' + self._parent_window.get_ext(),
)
v -= 42
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.ignoredButton2Text'),
name=bui.Lstr(resource=f'{self._r}.ignoredButton2Text'),
control='buttonIgnored2' + self._parent_window.get_ext(),
)
v -= 42
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.ignoredButton3Text'),
name=bui.Lstr(resource=f'{self._r}.ignoredButton3Text'),
control='buttonIgnored3' + self._parent_window.get_ext(),
)
v -= 42
self._capture_button(
pos=(h2, v),
name=bui.Lstr(resource=self._r + '.ignoredButton4Text'),
name=bui.Lstr(resource=f'{self._r}.ignoredButton4Text'),
control='buttonIgnored4' + self._parent_window.get_ext(),
)
bui.textwidget(
parent=self._subcontainer,
position=(self._sub_width * 0.5, v - 14),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.ignoredButtonDescriptionText'),
text=bui.Lstr(resource=f'{self._r}.ignoredButtonDescriptionText'),
color=(0.7, 1, 0.7, 0.6),
scale=0.8,
maxwidth=self._sub_width * 0.8,
@ -239,7 +239,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
position=(h + 50, v),
size=(400, 30),
text=bui.Lstr(
resource=self._r + '.startButtonActivatesDefaultText'
resource=f'{self._r}.startButtonActivatesDefaultText'
),
textcolor=(0.8, 0.8, 0.8),
maxwidth=450,
@ -254,7 +254,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
position=(self._sub_width * 0.5, v - 12),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.startButtonActivatesDefaultDescriptionText'
resource=f'{self._r}.startButtonActivatesDefaultDescriptionText'
),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
@ -269,7 +269,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
autoselect=True,
position=(h + 50, v),
size=(400, 30),
text=bui.Lstr(resource=self._r + '.uiOnlyText'),
text=bui.Lstr(resource=f'{self._r}.uiOnlyText'),
textcolor=(0.8, 0.8, 0.8),
maxwidth=450,
scale=0.9,
@ -280,7 +280,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(self._sub_width * 0.5, v - 12),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.uiOnlyDescriptionText'),
text=bui.Lstr(resource=f'{self._r}.uiOnlyDescriptionText'),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
scale=0.7,
@ -294,7 +294,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
autoselect=True,
position=(h + 50, v),
size=(400, 30),
text=bui.Lstr(resource=self._r + '.ignoreCompletelyText'),
text=bui.Lstr(resource=f'{self._r}.ignoreCompletelyText'),
textcolor=(0.8, 0.8, 0.8),
maxwidth=450,
scale=0.9,
@ -306,7 +306,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
position=(self._sub_width * 0.5, v - 12),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.ignoreCompletelyDescriptionText'
resource=f'{self._r}.ignoreCompletelyDescriptionText'
),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
@ -322,7 +322,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
autoselect=True,
position=(h + 50, v),
size=(400, 30),
text=bui.Lstr(resource=self._r + '.autoRecalibrateText'),
text=bui.Lstr(resource=f'{self._r}.autoRecalibrateText'),
textcolor=(0.8, 0.8, 0.8),
maxwidth=450,
scale=0.9,
@ -333,7 +333,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(self._sub_width * 0.5, v - 12),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.autoRecalibrateDescriptionText'),
text=bui.Lstr(resource=f'{self._r}.autoRecalibrateDescriptionText'),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
scale=0.7,
@ -343,7 +343,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
v -= 80
buttons = self._config_value_editor(
bui.Lstr(resource=self._r + '.analogStickDeadZoneText'),
bui.Lstr(resource=f'{self._r}.analogStickDeadZoneText'),
control=('analogStickDeadZone' + self._parent_window.get_ext()),
position=(h + 40, v),
min_val=0,
@ -359,7 +359,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
position=(self._sub_width * 0.5, v - 12),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.analogStickDeadZoneDescriptionText'
resource=f'{self._r}.analogStickDeadZoneDescriptionText'
),
color=(0.7, 1, 0.7, 0.6),
maxwidth=self._sub_width * 0.8,
@ -375,7 +375,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
bui.buttonwidget(
parent=self._subcontainer,
autoselect=True,
label=bui.Lstr(resource=self._r + '.twoInOneSetupText'),
label=bui.Lstr(resource=f'{self._r}.twoInOneSetupText'),
position=(40, v),
size=(self._sub_width - 80, 50),
on_activate_call=self._parent_window.show_secondary_editor,
@ -414,7 +414,7 @@ class GamepadAdvancedSettingsWindow(bui.Window):
left_widget=btn,
color=(0.45, 0.4, 0.5),
textcolor=(0.65, 0.6, 0.7),
label=bui.Lstr(resource=self._r + '.clearText'),
label=bui.Lstr(resource=f'{self._r}.clearText'),
size=(110, 50),
scale=0.7,
on_activate_call=bui.Call(self._clear_control, control),

View file

@ -147,7 +147,7 @@ class GamepadSelectWindow(bui.Window):
parent=self._root_widget,
position=(20, height - 50),
size=(width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=250,
color=bui.app.ui_v1.title_color,
h_align='center',
@ -168,7 +168,7 @@ class GamepadSelectWindow(bui.Window):
position=(15, v),
size=(width - 30, 30),
scale=0.8,
text=bui.Lstr(resource=self._r + '.pressAnyButtonText'),
text=bui.Lstr(resource=f'{self._r}.pressAnyButtonText'),
maxwidth=width * 0.95,
color=bui.app.ui_v1.infotextcolor,
h_align='center',
@ -181,7 +181,7 @@ class GamepadSelectWindow(bui.Window):
position=(15, v),
size=(width - 30, 30),
scale=0.46,
text=bui.Lstr(resource=self._r + '.androidNoteText'),
text=bui.Lstr(resource=f'{self._r}.androidNoteText'),
maxwidth=width * 0.95,
color=(0.7, 0.9, 0.7, 0.5),
h_align='center',

View file

@ -111,7 +111,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(0, height - 44),
size=(width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='top',
@ -159,7 +159,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(60, v),
size=(160, 25),
text=bui.Lstr(resource=self._r + '.visualsText'),
text=bui.Lstr(resource=f'{self._r}.visualsText'),
color=bui.app.ui_v1.heading_color,
scale=0.65,
maxwidth=150,
@ -179,10 +179,10 @@ class GraphicsSettingsWindow(bui.Window):
),
choices_display=[
bui.Lstr(resource='autoText'),
bui.Lstr(resource=self._r + '.higherText'),
bui.Lstr(resource=self._r + '.highText'),
bui.Lstr(resource=self._r + '.mediumText'),
bui.Lstr(resource=self._r + '.lowText'),
bui.Lstr(resource=f'{self._r}.higherText'),
bui.Lstr(resource=f'{self._r}.highText'),
bui.Lstr(resource=f'{self._r}.mediumText'),
bui.Lstr(resource=f'{self._r}.lowText'),
],
current_choice=bui.app.config.resolve('Graphics Quality'),
on_value_change_call=self._set_quality,
@ -193,7 +193,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(230, v),
size=(160, 25),
text=bui.Lstr(resource=self._r + '.texturesText'),
text=bui.Lstr(resource=f'{self._r}.texturesText'),
color=bui.app.ui_v1.heading_color,
scale=0.65,
maxwidth=150,
@ -208,9 +208,9 @@ class GraphicsSettingsWindow(bui.Window):
choices=['Auto', 'High', 'Medium', 'Low'],
choices_display=[
bui.Lstr(resource='autoText'),
bui.Lstr(resource=self._r + '.highText'),
bui.Lstr(resource=self._r + '.mediumText'),
bui.Lstr(resource=self._r + '.lowText'),
bui.Lstr(resource=f'{self._r}.highText'),
bui.Lstr(resource=f'{self._r}.mediumText'),
bui.Lstr(resource=f'{self._r}.lowText'),
],
current_choice=bui.app.config.resolve('Texture Quality'),
on_value_change_call=self._set_textures,
@ -231,7 +231,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(h_offs + 60, v),
size=(160, 25),
text=bui.Lstr(resource=self._r + '.resolutionText'),
text=bui.Lstr(resource=f'{self._r}.resolutionText'),
color=bui.app.ui_v1.heading_color,
scale=0.65,
maxwidth=150,
@ -319,7 +319,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(230, v),
size=(160, 25),
text=bui.Lstr(resource=self._r + '.verticalSyncText'),
text=bui.Lstr(resource=f'{self._r}.verticalSyncText'),
color=bui.app.ui_v1.heading_color,
scale=0.65,
maxwidth=150,
@ -334,8 +334,8 @@ class GraphicsSettingsWindow(bui.Window):
choices=['Auto', 'Always', 'Never'],
choices_display=[
bui.Lstr(resource='autoText'),
bui.Lstr(resource=self._r + '.alwaysText'),
bui.Lstr(resource=self._r + '.neverText'),
bui.Lstr(resource=f'{self._r}.alwaysText'),
bui.Lstr(resource=f'{self._r}.neverText'),
],
current_choice=bui.app.config.resolve('Vertical Sync'),
on_value_change_call=self._set_vsync,
@ -360,7 +360,7 @@ class GraphicsSettingsWindow(bui.Window):
parent=self._root_widget,
position=(155, v + 10),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.maxFPSText'),
text=bui.Lstr(resource=f'{self._r}.maxFPSText'),
color=bui.app.ui_v1.heading_color,
scale=0.9,
maxwidth=90,
@ -399,7 +399,7 @@ class GraphicsSettingsWindow(bui.Window):
size=(210, 30),
scale=0.86,
configkey='Show FPS',
displayname=bui.Lstr(resource=self._r + '.showFPSText'),
displayname=bui.Lstr(resource=f'{self._r}.showFPSText'),
maxwidth=130,
)
if self._max_fps_text is not None:
@ -419,7 +419,7 @@ class GraphicsSettingsWindow(bui.Window):
size=(210, 30),
scale=0.86,
configkey='TV Border',
displayname=bui.Lstr(resource=self._r + '.tvBorderText'),
displayname=bui.Lstr(resource=f'{self._r}.tvBorderText'),
maxwidth=130,
)
bui.widget(edit=fpsc.widget, right_widget=tvc.widget)

View file

@ -111,7 +111,7 @@ class ConfigKeyboardWindow(bui.Window):
position=(self._width * 0.5, v + 15),
size=(0, 0),
text=bui.Lstr(
resource=self._r + '.configuringText',
resource=f'{self._r}.configuringText',
subs=[('${DEVICE}', self._displayname)],
),
color=bui.app.ui_v1.title_color,
@ -129,7 +129,7 @@ class ConfigKeyboardWindow(bui.Window):
parent=self._root_widget,
position=(0, v + 19),
size=(self._width, 50),
text=bui.Lstr(resource=self._r + '.keyboard2NoteText'),
text=bui.Lstr(resource=f'{self._r}.keyboard2NoteText'),
scale=0.7,
maxwidth=self._width * 0.75,
max_height=110,

View file

@ -349,7 +349,12 @@ class PluginWindow(bui.Window):
text=bui.Lstr(value=classpath),
autoselect=True,
value=enabled,
maxwidth=self._scroll_width - 200,
maxwidth=self._scroll_width
- (
200
if plugin is not None and plugin.has_settings_ui()
else 80
),
position=(10, item_y),
size=(self._scroll_width - 40, 50),
on_value_change_call=bui.Call(
@ -388,7 +393,9 @@ class PluginWindow(bui.Window):
edit=check,
up_widget=self._back_button,
left_widget=self._back_button,
right_widget=self._settings_button,
right_widget=(
self._settings_button if button is None else button
),
)
if button is not None:
bui.widget(edit=button, up_widget=self._back_button)

View file

@ -48,7 +48,7 @@ class RemoteAppSettingsWindow(bui.Window):
parent=self._root_widget,
position=(width * 0.5, height - 42),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=370,
color=bui.app.ui_v1.title_color,
scale=0.8,
@ -73,7 +73,7 @@ class RemoteAppSettingsWindow(bui.Window):
color=(0.7, 0.9, 0.7, 1.0),
scale=0.8,
text=bui.Lstr(
resource=self._r + '.explanationText',
resource=f'{self._r}.explanationText',
subs=[
('${APP_NAME}', bui.Lstr(resource='titleText')),
('${REMOTE_APP_NAME}', bui.get_remote_app_name()),
@ -106,7 +106,7 @@ class RemoteAppSettingsWindow(bui.Window):
size=(0, 0),
color=(0.7, 0.9, 0.7, 0.8),
scale=0.65,
text=bui.Lstr(resource=self._r + '.bestResultsText'),
text=bui.Lstr(resource=f'{self._r}.bestResultsText'),
maxwidth=width * 0.95,
max_height=height * 0.19,
h_align='center',

View file

@ -55,7 +55,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._root_widget,
position=(25, self._height - 50),
size=(self._width, 25),
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
maxwidth=280,
h_align='center',
@ -111,7 +111,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(-10, v + 43),
size=(self._sub_width, 25),
text=bui.Lstr(resource=self._r + '.swipeInfoText'),
text=bui.Lstr(resource=f'{self._r}.swipeInfoText'),
flatness=1.0,
color=(0, 0.9, 0.1, 0.7),
maxwidth=self._sub_width * 0.9,
@ -124,7 +124,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h, v - 2),
size=(0, 30),
text=bui.Lstr(resource=self._r + '.movementText'),
text=bui.Lstr(resource=f'{self._r}.movementText'),
maxwidth=190,
color=clr,
v_align='center',
@ -133,7 +133,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h + 220, v),
size=(170, 30),
text=bui.Lstr(resource=self._r + '.joystickText'),
text=bui.Lstr(resource=f'{self._r}.joystickText'),
maxwidth=100,
textcolor=clr2,
scale=0.9,
@ -142,7 +142,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h + 357, v),
size=(170, 30),
text=bui.Lstr(resource=self._r + '.swipeText'),
text=bui.Lstr(resource=f'{self._r}.swipeText'),
maxwidth=100,
textcolor=clr2,
value=False,
@ -158,7 +158,7 @@ class TouchscreenSettingsWindow(bui.Window):
xoffset=65,
configkey='Touch Controls Scale Movement',
displayname=bui.Lstr(
resource=self._r + '.movementControlScaleText'
resource=f'{self._r}.movementControlScaleText'
),
changesound=False,
minval=0.1,
@ -171,7 +171,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h, v - 2),
size=(0, 30),
text=bui.Lstr(resource=self._r + '.actionsText'),
text=bui.Lstr(resource=f'{self._r}.actionsText'),
maxwidth=190,
color=clr,
v_align='center',
@ -180,7 +180,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h + 220, v),
size=(170, 30),
text=bui.Lstr(resource=self._r + '.buttonsText'),
text=bui.Lstr(resource=f'{self._r}.buttonsText'),
maxwidth=100,
textcolor=clr2,
scale=0.9,
@ -189,7 +189,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(h + 357, v),
size=(170, 30),
text=bui.Lstr(resource=self._r + '.swipeText'),
text=bui.Lstr(resource=f'{self._r}.swipeText'),
maxwidth=100,
textcolor=clr2,
scale=0.9,
@ -203,7 +203,7 @@ class TouchscreenSettingsWindow(bui.Window):
position=(h, v),
xoffset=65,
configkey='Touch Controls Scale Actions',
displayname=bui.Lstr(resource=self._r + '.actionControlScaleText'),
displayname=bui.Lstr(resource=f'{self._r}.actionControlScaleText'),
changesound=False,
minval=0.1,
maxval=4.0,
@ -217,7 +217,7 @@ class TouchscreenSettingsWindow(bui.Window):
size=(400, 30),
maxwidth=400,
configkey='Touch Controls Swipe Hidden',
displayname=bui.Lstr(resource=self._r + '.swipeControlsHiddenText'),
displayname=bui.Lstr(resource=f'{self._r}.swipeControlsHiddenText'),
)
v -= 65
@ -225,7 +225,7 @@ class TouchscreenSettingsWindow(bui.Window):
parent=self._subcontainer,
position=(self._sub_width * 0.5 - 70, v),
size=(170, 60),
label=bui.Lstr(resource=self._r + '.resetText'),
label=bui.Lstr(resource=f'{self._r}.resetText'),
scale=0.75,
on_activate_call=self._reset,
)
@ -235,7 +235,7 @@ class TouchscreenSettingsWindow(bui.Window):
position=(self._width * 0.5, 38),
size=(0, 0),
h_align='center',
text=bui.Lstr(resource=self._r + '.dragControlsText'),
text=bui.Lstr(resource=f'{self._r}.dragControlsText'),
maxwidth=self._width * 0.8,
scale=0.65,
color=(1, 1, 1, 0.4),

View file

@ -90,7 +90,7 @@ class SoundtrackBrowserWindow(bui.Window):
position=(self._width * 0.5, self._height - 35),
size=(0, 0),
maxwidth=300,
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
@ -119,7 +119,7 @@ class SoundtrackBrowserWindow(bui.Window):
autoselect=True,
textcolor=b_textcolor,
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.newText'),
label=bui.Lstr(resource=f'{self._r}.newText'),
)
self._lock_images.append(
bui.imagewidget(
@ -148,7 +148,7 @@ class SoundtrackBrowserWindow(bui.Window):
autoselect=True,
textcolor=b_textcolor,
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.editText'),
label=bui.Lstr(resource=f'{self._r}.editText'),
)
self._lock_images.append(
bui.imagewidget(
@ -176,7 +176,7 @@ class SoundtrackBrowserWindow(bui.Window):
color=b_color,
textcolor=b_textcolor,
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.duplicateText'),
label=bui.Lstr(resource=f'{self._r}.duplicateText'),
)
self._lock_images.append(
bui.imagewidget(
@ -204,7 +204,7 @@ class SoundtrackBrowserWindow(bui.Window):
autoselect=True,
textcolor=b_textcolor,
text_scale=0.7,
label=bui.Lstr(resource=self._r + '.deleteText'),
label=bui.Lstr(resource=f'{self._r}.deleteText'),
)
self._lock_images.append(
bui.imagewidget(
@ -303,13 +303,13 @@ class SoundtrackBrowserWindow(bui.Window):
if self._selected_soundtrack == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantDeleteDefaultText'),
bui.Lstr(resource=f'{self._r}.cantDeleteDefaultText'),
color=(1, 0, 0),
)
else:
ConfirmWindow(
bui.Lstr(
resource=self._r + '.deleteConfirmText',
resource=f'{self._r}.deleteConfirmText',
subs=[('${NAME}', self._selected_soundtrack)],
),
self._do_delete_soundtrack,
@ -438,7 +438,7 @@ class SoundtrackBrowserWindow(bui.Window):
if self._selected_soundtrack == '__default__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantEditDefaultText'),
bui.Lstr(resource=f'{self._r}.cantEditDefaultText'),
color=(1, 0, 0),
)
return
@ -455,7 +455,7 @@ class SoundtrackBrowserWindow(bui.Window):
def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr:
if soundtrack == '__default__':
return bui.Lstr(resource=self._r + '.defaultSoundtrackNameText')
return bui.Lstr(resource=f'{self._r}.defaultSoundtrackNameText')
return bui.Lstr(value=soundtrack)
def _refresh(self, select_soundtrack: str | None = None) -> None:

View file

@ -121,7 +121,7 @@ class SoundtrackEditWindow(bui.Window):
bui.textwidget(
parent=self._root_widget,
text=bui.Lstr(resource=self._r + '.nameText'),
text=bui.Lstr(resource=f'{self._r}.nameText'),
maxwidth=80,
scale=0.8,
position=(105 + x_inset, v + 19),
@ -135,7 +135,7 @@ class SoundtrackEditWindow(bui.Window):
if existing_soundtrack is None:
i = 1
st_name_text = bui.Lstr(
resource=self._r + '.newSoundtrackNameText'
resource=f'{self._r}.newSoundtrackNameText'
).evaluate()
if '${COUNT}' not in st_name_text:
# make sure we insert number *somewhere*
@ -155,7 +155,7 @@ class SoundtrackEditWindow(bui.Window):
v_align='center',
max_chars=32,
autoselect=True,
description=bui.Lstr(resource=self._r + '.nameText'),
description=bui.Lstr(resource=f'{self._r}.nameText'),
editable=True,
padding=4,
on_return_press_call=self._do_it_with_sound,
@ -305,7 +305,7 @@ class SoundtrackEditWindow(bui.Window):
btn = bui.buttonwidget(
parent=row,
size=(50, 32),
label=bui.Lstr(resource=self._r + '.testText'),
label=bui.Lstr(resource=f'{self._r}.testText'),
text_scale=0.6,
on_activate_call=bui.Call(self._test, bs.MusicType(song_type)),
up_widget=(
@ -389,7 +389,7 @@ class SoundtrackEditWindow(bui.Window):
if bui.app.config.resolve('Music Volume') < 0.01:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.musicVolumeZeroWarning'),
bui.Lstr(resource=f'{self._r}.musicVolumeZeroWarning'),
color=(1, 0.5, 0),
)
music.set_music_play_mode(bui.app.classic.MusicPlayMode.TEST)
@ -405,7 +405,7 @@ class SoundtrackEditWindow(bui.Window):
etype = music.get_soundtrack_entry_type(entry)
ename: str | bui.Lstr
if etype == 'default':
ename = bui.Lstr(resource=self._r + '.defaultGameMusicText')
ename = bui.Lstr(resource=f'{self._r}.defaultGameMusicText')
elif etype in ('musicFile', 'musicFolder'):
ename = os.path.basename(music.get_soundtrack_entry_name(entry))
else:
@ -453,7 +453,7 @@ class SoundtrackEditWindow(bui.Window):
new_name = cast(str, bui.textwidget(query=self._text_field))
if new_name != self._soundtrack_name and new_name in cfg['Soundtracks']:
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantSaveAlreadyExistsText')
bui.Lstr(resource=f'{self._r}.cantSaveAlreadyExistsText')
)
bui.getsound('error').play()
return
@ -463,11 +463,11 @@ class SoundtrackEditWindow(bui.Window):
if (
new_name
== bui.Lstr(
resource=self._r + '.defaultSoundtrackNameText'
resource=f'{self._r}.defaultSoundtrackNameText'
).evaluate()
):
bui.screenmessage(
bui.Lstr(resource=self._r + '.cantOverwriteDefaultText')
bui.Lstr(resource=f'{self._r}.cantOverwriteDefaultText')
)
bui.getsound('error').play()
return

View file

@ -82,7 +82,7 @@ class SoundtrackEntryTypeSelectWindow(bui.Window):
parent=self._root_widget,
position=(self._width * 0.5, self._height - 32),
size=(0, 0),
text=bui.Lstr(resource=self._r + '.selectASourceText'),
text=bui.Lstr(resource=f'{self._r}.selectASourceText'),
color=bui.app.ui_v1.title_color,
maxwidth=230,
h_align='center',
@ -110,7 +110,7 @@ class SoundtrackEntryTypeSelectWindow(bui.Window):
parent=self._root_widget,
size=(self._width - 100, 60),
position=(50, v),
label=bui.Lstr(resource=self._r + '.useDefaultGameMusicText'),
label=bui.Lstr(resource=f'{self._r}.useDefaultGameMusicText'),
on_activate_call=self._on_default_press,
)
if current_entry_type == 'default':
@ -122,7 +122,7 @@ class SoundtrackEntryTypeSelectWindow(bui.Window):
parent=self._root_widget,
size=(self._width - 100, 60),
position=(50, v),
label=bui.Lstr(resource=self._r + '.useITunesPlaylistText'),
label=bui.Lstr(resource=f'{self._r}.useITunesPlaylistText'),
on_activate_call=self._on_mac_music_app_playlist_press,
icon=None,
)
@ -135,7 +135,7 @@ class SoundtrackEntryTypeSelectWindow(bui.Window):
parent=self._root_widget,
size=(self._width - 100, 60),
position=(50, v),
label=bui.Lstr(resource=self._r + '.useMusicFileText'),
label=bui.Lstr(resource=f'{self._r}.useMusicFileText'),
on_activate_call=self._on_music_file_press,
icon=bui.gettexture('file'),
)
@ -148,7 +148,7 @@ class SoundtrackEntryTypeSelectWindow(bui.Window):
parent=self._root_widget,
size=(self._width - 100, 60),
position=(50, v),
label=bui.Lstr(resource=self._r + '.useMusicFolderText'),
label=bui.Lstr(resource=f'{self._r}.useMusicFolderText'),
on_activate_call=self._on_music_folder_press,
icon=bui.gettexture('folder'),
icon_color=(1.1, 0.8, 0.2),

View file

@ -52,7 +52,7 @@ class MacMusicAppPlaylistSelectWindow(bui.Window):
parent=self._root_widget,
position=(20, self._height - 54),
size=(self._width, 25),
text=bui.Lstr(resource=self._r + '.selectAPlaylistText'),
text=bui.Lstr(resource=f'{self._r}.selectAPlaylistText'),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
@ -75,7 +75,7 @@ class MacMusicAppPlaylistSelectWindow(bui.Window):
bui.textwidget(
parent=self._column,
size=(self._width - 80, 22),
text=bui.Lstr(resource=self._r + '.fetchingITunesText'),
text=bui.Lstr(resource=f'{self._r}.fetchingITunesText'),
color=(0.6, 0.9, 0.6, 1.0),
scale=0.8,
)

View file

@ -440,7 +440,7 @@ class SpecialOfferWindow(bui.Window):
def _on_get_more_tickets_press(self) -> None:
from bauiv1lib import account
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
plus = bui.app.plus
assert plus is not None
@ -448,10 +448,10 @@ class SpecialOfferWindow(bui.Window):
if plus.get_v1_account_state() != 'signed_in':
account.show_sign_in_prompt()
return
getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
gettickets.GetTicketsWindow(modal=True).get_root_widget()
def _purchase(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
from bauiv1lib import confirm
plus = bui.app.plus
@ -474,7 +474,7 @@ class SpecialOfferWindow(bui.Window):
except Exception:
ticket_count = None
if ticket_count is not None and ticket_count < self._offer['price']:
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
bui.getsound('error').play()
return

View file

@ -205,17 +205,17 @@ class StoreBrowserWindow(bui.Window):
tab_buffer_h = 250 + 2 * x_inset
tabs_def = [
(self.TabID.EXTRAS, bui.Lstr(resource=self._r + '.extrasText')),
(self.TabID.MAPS, bui.Lstr(resource=self._r + '.mapsText')),
(self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')),
(self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')),
(
self.TabID.MINIGAMES,
bui.Lstr(resource=self._r + '.miniGamesText'),
bui.Lstr(resource=f'{self._r}.miniGamesText'),
),
(
self.TabID.CHARACTERS,
bui.Lstr(resource=self._r + '.charactersText'),
bui.Lstr(resource=f'{self._r}.charactersText'),
),
(self.TabID.ICONS, bui.Lstr(resource=self._r + '.iconsText')),
(self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')),
]
self._tab_row = TabRow(
@ -449,7 +449,7 @@ class StoreBrowserWindow(bui.Window):
color=(1, 0.7, 1, 0.5),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.loadingText'),
text=bui.Lstr(resource=f'{self._r}.loadingText'),
maxwidth=self._scroll_width * 0.9,
)
@ -574,7 +574,7 @@ class StoreBrowserWindow(bui.Window):
"""Attempt to purchase the provided item."""
from bauiv1lib import account
from bauiv1lib.confirm import ConfirmWindow
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
assert bui.app.classic is not None
store = bui.app.classic.store
@ -620,7 +620,7 @@ class StoreBrowserWindow(bui.Window):
our_tickets = plus.get_v1_account_ticket_count()
if price is not None and our_tickets < price:
bui.getsound('error').play()
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
else:
def do_it() -> None:
@ -653,7 +653,7 @@ class StoreBrowserWindow(bui.Window):
def _print_already_own(self, charname: str) -> None:
bui.screenmessage(
bui.Lstr(
resource=self._r + '.alreadyOwnText',
resource=f'{self._r}.alreadyOwnText',
subs=[('${NAME}', charname)],
),
color=(1, 0, 0),
@ -868,7 +868,7 @@ class StoreBrowserWindow(bui.Window):
color=(1, 0.3, 0.3, 1.0),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.loadErrorText'),
text=bui.Lstr(resource=f'{self._r}.loadErrorText'),
maxwidth=self._scroll_width * 0.9,
)
else:
@ -1238,7 +1238,7 @@ class StoreBrowserWindow(bui.Window):
color=(1, 1, 0.3, 1.0),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.comingSoonText'),
text=bui.Lstr(resource=f'{self._r}.comingSoonText'),
maxwidth=self._scroll_width * 0.9,
)
@ -1320,7 +1320,7 @@ class StoreBrowserWindow(bui.Window):
def _on_get_more_tickets_press(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.account import show_sign_in_prompt
from bauiv1lib.getcurrency import GetCurrencyWindow
from bauiv1lib.gettickets import GetTicketsWindow
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
@ -1334,7 +1334,7 @@ class StoreBrowserWindow(bui.Window):
return
self._save_state()
bui.containerwidget(edit=self._root_widget, transition='out_left')
window = GetCurrencyWindow(
window = GetTicketsWindow(
from_modal_store=self._modal,
store_back_location=self._back_location,
).get_root_widget()
@ -1391,7 +1391,7 @@ def _check_merch_availability_in_bg_thread() -> None:
def _store_in_logic_thread() -> None:
cfg = bui.app.config
current: str | None = cfg.get(MERCH_LINK_KEY)
current = cfg.get(MERCH_LINK_KEY)
if not isinstance(current, str | None):
current = None
if current != response.url:
@ -1414,6 +1414,9 @@ def _check_merch_availability_in_bg_thread() -> None:
# Slight hack; start checking merch availability in the bg (but only if
# it looks like we've been imported for use in a running app; don't want
# to do this during docs generation/etc.)
# TODO: Should wire this up explicitly to app bootstrapping; not good to
# be kicking off work at module import time.
if (
os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1'
and bui.app.state is not bui.app.State.NOT_STARTED

View file

@ -632,7 +632,7 @@ class TournamentEntryWindow(PopupWindow):
bui.apptimer(0 if practice else 1.25, self._transition_out)
def _on_pay_with_tickets_press(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
plus = bui.app.plus
assert plus is not None
@ -675,7 +675,7 @@ class TournamentEntryWindow(PopupWindow):
ticket_count = None
ticket_cost = self._purchase_price
if ticket_count is not None and ticket_count < ticket_cost:
getcurrency.show_get_tickets_prompt()
gettickets.show_get_tickets_prompt()
bui.getsound('error').play()
self._transition_out()
return
@ -781,7 +781,7 @@ class TournamentEntryWindow(PopupWindow):
self._launch()
def _on_get_tickets_press(self) -> None:
from bauiv1lib import getcurrency
from bauiv1lib import gettickets
# If we're already entering, ignore presses.
if self._entering:
@ -789,7 +789,7 @@ class TournamentEntryWindow(PopupWindow):
# Bring up get-tickets window and then kill ourself (we're on the
# overlay layer so we'd show up above it).
getcurrency.GetCurrencyWindow(
gettickets.GetTicketsWindow(
modal=True, origin_widget=self._get_tickets_button
)
self._transition_out()

View file

@ -116,14 +116,14 @@ class WatchWindow(bui.Window):
scale=1.5,
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.titleText'),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=400,
)
tabdefs = [
(
self.TabID.MY_REPLAYS,
bui.Lstr(resource=self._r + '.myReplaysText'),
bui.Lstr(resource=f'{self._r}.myReplaysText'),
),
# (self.TabID.TEST_TAB, bui.Lstr(value='Testing')),
]
@ -276,7 +276,7 @@ class WatchWindow(bui.Window):
textcolor=b_textcolor,
on_activate_call=self._on_my_replay_play_press,
text_scale=tscl,
label=bui.Lstr(resource=self._r + '.watchReplayButtonText'),
label=bui.Lstr(resource=f'{self._r}.watchReplayButtonText'),
autoselect=True,
)
bui.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button)
@ -296,7 +296,7 @@ class WatchWindow(bui.Window):
textcolor=b_textcolor,
on_activate_call=self._on_my_replay_rename_press,
text_scale=tscl,
label=bui.Lstr(resource=self._r + '.renameReplayButtonText'),
label=bui.Lstr(resource=f'{self._r}.renameReplayButtonText'),
autoselect=True,
)
btnv -= b_height + b_space_extra
@ -309,7 +309,7 @@ class WatchWindow(bui.Window):
textcolor=b_textcolor,
on_activate_call=self._on_my_replay_delete_press,
text_scale=tscl,
label=bui.Lstr(resource=self._r + '.deleteReplayButtonText'),
label=bui.Lstr(resource=f'{self._r}.deleteReplayButtonText'),
autoselect=True,
)
@ -339,7 +339,7 @@ class WatchWindow(bui.Window):
def _no_replay_selected_error(self) -> None:
bui.screenmessage(
bui.Lstr(resource=self._r + '.noReplaySelectedErrorText'),
bui.Lstr(resource=f'{self._r}.noReplaySelectedErrorText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
@ -395,7 +395,7 @@ class WatchWindow(bui.Window):
h_align='center',
v_align='center',
text=bui.Lstr(
resource=self._r + '.renameReplayText',
resource=f'{self._r}.renameReplayText',
subs=[('${REPLAY}', dname)],
),
maxwidth=c_width * 0.8,
@ -408,7 +408,7 @@ class WatchWindow(bui.Window):
v_align='center',
text=dname,
editable=True,
description=bui.Lstr(resource=self._r + '.replayNameText'),
description=bui.Lstr(resource=f'{self._r}.replayNameText'),
position=(c_width * 0.1, c_height - 140),
autoselect=True,
maxwidth=c_width * 0.7,
@ -427,7 +427,7 @@ class WatchWindow(bui.Window):
)
okb = bui.buttonwidget(
parent=cnt,
label=bui.Lstr(resource=self._r + '.renameText'),
label=bui.Lstr(resource=f'{self._r}.renameText'),
size=(180, 60),
position=(c_width - 230, 30),
on_activate_call=bui.Call(
@ -477,7 +477,7 @@ class WatchWindow(bui.Window):
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource=self._r + '.replayRenameErrorInvalidName'
resource=f'{self._r}.replayRenameErrorInvalidName'
),
color=(1, 0, 0),
)
@ -492,7 +492,7 @@ class WatchWindow(bui.Window):
)
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.replayRenameErrorText'),
bui.Lstr(resource=f'{self._r}.replayRenameErrorText'),
color=(1, 0, 0),
)
@ -508,7 +508,7 @@ class WatchWindow(bui.Window):
return
confirm.ConfirmWindow(
bui.Lstr(
resource=self._r + '.deleteConfirmText',
resource=f'{self._r}.deleteConfirmText',
subs=[
(
'${REPLAY}',
@ -540,7 +540,7 @@ class WatchWindow(bui.Window):
logging.exception("Error deleting replay '%s'.", replay)
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=self._r + '.replayDeleteErrorText'),
bui.Lstr(resource=f'{self._r}.replayDeleteErrorText'),
color=(1, 0, 0),
)

View file

@ -4,328 +4,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar, Generic, Callable, cast
import functools
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, overload
pass
CT = TypeVar('CT', bound=Callable)
class _CallbackCall(Generic[CT]):
"""Descriptor for exposing a call with a type defined by a TypeVar."""
def __get__(self, obj: Any, type_in: Any = None) -> CT:
return cast(CT, None)
class CallbackSet(Generic[CT]):
"""Wrangles callbacks for a particular event in a type-safe manner."""
# In the type-checker's eyes, our 'run' attr is a CallbackCall which
# returns a callable with the type we were created with. This lets us
# type-check our run calls. (Is there another way to expose a function
# with a signature defined by a generic?..)
# At runtime, run() simply passes its args verbatim to its registered
# callbacks; no types are checked.
if TYPE_CHECKING:
run: _CallbackCall[CT] = _CallbackCall()
else:
def run(self, *args, **keywds):
"""Run all callbacks."""
print('HELLO FROM RUN', *args, **keywds)
def __init__(self) -> None:
print('CallbackSet()')
def __del__(self) -> None:
print('~CallbackSet()')
def add(self, call: CT) -> None:
"""Add a callback to be run."""
print('Would add call', call)
# Define Call() which can be used in type-checking call-wrappers that behave
# similarly to functools.partial (in that they take a callable and some
# positional arguments to be passed to it).
# In type-checking land, We define several different _CallXArg classes
# corresponding to different argument counts and define Call() as an
# overloaded function which returns one of them based on how many args are
# passed.
# To use this, simply assign your call type to this Call for type checking:
# Example:
# class _MyCallWrapper:
# <runtime class defined here>
# if TYPE_CHECKING:
# MyCallWrapper = efro.call.Call
# else:
# MyCallWrapper = _MyCallWrapper
# Note that this setup currently only works with positional arguments; if you
# would like to pass args via keyword you can wrap a lambda or local function
# which takes keyword args and converts to a call containing keywords.
if TYPE_CHECKING:
In1T = TypeVar('In1T')
In2T = TypeVar('In2T')
In3T = TypeVar('In3T')
In4T = TypeVar('In4T')
In5T = TypeVar('In5T')
In6T = TypeVar('In6T')
In7T = TypeVar('In7T')
OutT = TypeVar('OutT')
class _CallNoArgs(Generic[OutT]):
"""Single argument variant of call wrapper."""
def __init__(self, _call: Callable[[], OutT]): ...
def __call__(self) -> OutT: ...
class _Call1Arg(Generic[In1T, OutT]):
"""Single argument variant of call wrapper."""
def __init__(self, _call: Callable[[In1T], OutT]): ...
def __call__(self, _arg1: In1T) -> OutT: ...
class _Call2Args(Generic[In1T, In2T, OutT]):
"""Two argument variant of call wrapper"""
def __init__(self, _call: Callable[[In1T, In2T], OutT]): ...
def __call__(self, _arg1: In1T, _arg2: In2T) -> OutT: ...
class _Call3Args(Generic[In1T, In2T, In3T, OutT]):
"""Three argument variant of call wrapper"""
def __init__(self, _call: Callable[[In1T, In2T, In3T], OutT]): ...
def __call__(self, _arg1: In1T, _arg2: In2T, _arg3: In3T) -> OutT: ...
class _Call4Args(Generic[In1T, In2T, In3T, In4T, OutT]):
"""Four argument variant of call wrapper"""
def __init__(self, _call: Callable[[In1T, In2T, In3T, In4T], OutT]): ...
def __call__(
self, _arg1: In1T, _arg2: In2T, _arg3: In3T, _arg4: In4T
) -> OutT: ...
class _Call5Args(Generic[In1T, In2T, In3T, In4T, In5T, OutT]):
"""Five argument variant of call wrapper"""
def __init__(
self, _call: Callable[[In1T, In2T, In3T, In4T, In5T], OutT]
): ...
def __call__(
self,
_arg1: In1T,
_arg2: In2T,
_arg3: In3T,
_arg4: In4T,
_arg5: In5T,
) -> OutT: ...
class _Call6Args(Generic[In1T, In2T, In3T, In4T, In5T, In6T, OutT]):
"""Six argument variant of call wrapper"""
def __init__(
self, _call: Callable[[In1T, In2T, In3T, In4T, In5T, In6T], OutT]
): ...
def __call__(
self,
_arg1: In1T,
_arg2: In2T,
_arg3: In3T,
_arg4: In4T,
_arg5: In5T,
_arg6: In6T,
) -> OutT: ...
class _Call7Args(Generic[In1T, In2T, In3T, In4T, In5T, In6T, In7T, OutT]):
"""Seven argument variant of call wrapper"""
def __init__(
self,
_call: Callable[[In1T, In2T, In3T, In4T, In5T, In6T, In7T], OutT],
): ...
def __call__(
self,
_arg1: In1T,
_arg2: In2T,
_arg3: In3T,
_arg4: In4T,
_arg5: In5T,
_arg6: In6T,
_arg7: In7T,
) -> OutT: ...
# No arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(call: Callable[[], OutT]) -> _CallNoArgs[OutT]: ...
# 1 arg call; 1 arg bundled.
# noinspection PyPep8Naming
@overload
def Call(call: Callable[[In1T], OutT], arg1: In1T) -> _CallNoArgs[OutT]: ...
# 1 arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(call: Callable[[In1T], OutT]) -> _Call1Arg[In1T, OutT]: ...
# 2 arg call; 2 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T], OutT], arg1: In1T, arg2: In2T
) -> _CallNoArgs[OutT]: ...
# 2 arg call; 1 arg bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T], OutT], arg1: In1T
) -> _Call1Arg[In2T, OutT]: ...
# 2 arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T], OutT]
) -> _Call2Args[In1T, In2T, OutT]: ...
# 3 arg call; 3 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
) -> _CallNoArgs[OutT]: ...
# 3 arg call; 2 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T], OutT], arg1: In1T, arg2: In2T
) -> _Call1Arg[In3T, OutT]: ...
# 3 arg call; 1 arg bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T], OutT], arg1: In1T
) -> _Call2Args[In2T, In3T, OutT]: ...
# 3 arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T], OutT]
) -> _Call3Args[In1T, In2T, In3T, OutT]: ...
# 4 arg call; 4 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
arg4: In4T,
) -> _CallNoArgs[OutT]: ...
# 4 arg call; 3 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
) -> _Call1Arg[In4T, OutT]: ...
# 4 arg call; 2 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
arg2: In2T,
) -> _Call2Args[In3T, In4T, OutT]: ...
# 4 arg call; 1 arg bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
arg1: In1T,
) -> _Call3Args[In2T, In3T, In4T, OutT]: ...
# 4 arg call; no args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T], OutT],
) -> _Call4Args[In1T, In2T, In3T, In4T, OutT]: ...
# 5 arg call; 5 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T, In5T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
arg4: In4T,
arg5: In5T,
) -> _CallNoArgs[OutT]: ...
# 6 arg call; 6 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T, In5T, In6T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
arg4: In4T,
arg5: In5T,
arg6: In6T,
) -> _CallNoArgs[OutT]: ...
# 7 arg call; 7 args bundled.
# noinspection PyPep8Naming
@overload
def Call(
call: Callable[[In1T, In2T, In3T, In4T, In5T, In6T, In7T], OutT],
arg1: In1T,
arg2: In2T,
arg3: In3T,
arg4: In4T,
arg5: In5T,
arg6: In6T,
arg7: In7T,
) -> _CallNoArgs[OutT]: ...
# noinspection PyPep8Naming
def Call(*_args: Any, **_keywds: Any) -> Any: ...
# (Type-safe Partial)
# A convenient wrapper around functools.partial which adds type-safety
# (though it does not support keyword arguments).
tpartial = Call
else:
tpartial = functools.partial
# TODO: should deprecate tpartial since it nowadays simply wraps
# functools.partial (mypy added support for functools.partial in 1.11 so
# there's no benefit to rolling our own type-safe version anymore).
# Perhaps we can use Python 13's @warnings.deprecated() stuff for this.
tpartial = functools.partial

View file

@ -44,6 +44,7 @@ def dataclass_to_dict(
obj: Any,
codec: Codec = Codec.JSON,
coerce_to_float: bool = True,
discard_extra_attrs: bool = False,
) -> dict:
"""Given a dataclass object, return a json-friendly dict.
@ -62,7 +63,11 @@ def dataclass_to_dict(
"""
out = _Outputter(
obj, create=True, codec=codec, coerce_to_float=coerce_to_float
obj,
create=True,
codec=codec,
coerce_to_float=coerce_to_float,
discard_extra_attrs=discard_extra_attrs,
).run()
assert isinstance(out, dict)
return out
@ -157,14 +162,21 @@ def dataclass_from_json(
def dataclass_validate(
obj: Any, coerce_to_float: bool = True, codec: Codec = Codec.JSON
obj: Any,
coerce_to_float: bool = True,
codec: Codec = Codec.JSON,
discard_extra_attrs: bool = False,
) -> None:
"""Ensure that values in a dataclass instance are the correct types."""
# Simply run an output pass but tell it not to generate data;
# only run validation.
_Outputter(
obj, create=False, codec=codec, coerce_to_float=coerce_to_float
obj,
create=False,
codec=codec,
coerce_to_float=coerce_to_float,
discard_extra_attrs=discard_extra_attrs,
).run()

View file

@ -61,6 +61,29 @@ class IOExtendedData:
type-safe form.
"""
# pylint: disable=useless-return
@classmethod
def handle_input_error(cls, exc: Exception) -> Self | None:
"""Called when an error occurs during input decoding.
This allows a type to optionally return substitute data
to be used in place of the failed decode. If it returns
None, the original exception is re-raised.
It is generally a bad idea to apply catch-alls such as this,
as it can lead to silent data loss. This should only be used
in specific cases such as user settings where an occasional
reset is harmless and is preferable to keeping all contained
enums and other values backward compatible indefinitely.
"""
del exc # Unused.
# By default we let things fail.
return None
# pylint: enable=useless-return
EnumT = TypeVar('EnumT', bound=Enum)

View file

@ -236,6 +236,28 @@ class _Inputter:
sets should be passed as lists, enums should be passed as their
associated values, and nested dataclasses should be passed as dicts.
"""
try:
return self._do_dataclass_from_input(cls, fieldpath, values)
except Exception as exc:
# Extended data types can choose to sub default data in case
# of failures (generally not a good idea but occasionally
# useful).
if issubclass(cls, IOExtendedData):
fallback = cls.handle_input_error(exc)
if fallback is None:
raise
# Make sure fallback gave us the right type.
if not isinstance(fallback, cls):
raise RuntimeError(
f'handle_input_error() was expected to return a {cls}'
f' but returned a {type(fallback)}.'
) from exc
return fallback
raise
def _do_dataclass_from_input(
self, cls: type, fieldpath: str, values: dict
) -> Any:
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
@ -377,6 +399,7 @@ class _Inputter:
create=False,
codec=self._codec,
coerce_to_float=self._coerce_to_float,
discard_extra_attrs=False,
)
self._soft_default_validator.soft_default_check(
value=value, anntype=anntype, fieldpath=fieldpath

View file

@ -38,25 +38,35 @@ class _Outputter:
"""Validates or exports data contained in a dataclass instance."""
def __init__(
self, obj: Any, create: bool, codec: Codec, coerce_to_float: bool
self,
obj: Any,
create: bool,
codec: Codec,
coerce_to_float: bool,
discard_extra_attrs: bool,
) -> None:
self._obj = obj
self._create = create
self._codec = codec
self._coerce_to_float = coerce_to_float
self._discard_extra_attrs = discard_extra_attrs
def run(self) -> Any:
"""Do the thing."""
obj = self._obj
# mypy workaround - if we check 'obj' here it assumes the
# isinstance call below fails.
assert dataclasses.is_dataclass(self._obj)
# For special extended data types, call their 'will_output' callback.
# FIXME - should probably move this into _process_dataclass so it
# can work on nested values.
if isinstance(self._obj, IOExtendedData):
self._obj.will_output()
if isinstance(obj, IOExtendedData):
obj.will_output()
return self._process_dataclass(type(self._obj), self._obj, '')
return self._process_dataclass(type(obj), obj, '')
def soft_default_check(
self, value: Any, anntype: Any, fieldpath: str
@ -133,17 +143,18 @@ class _Outputter:
out[storagename] = outvalue
# If there's extra-attrs stored on us, check/include them.
extra_attrs = getattr(obj, EXTRA_ATTRS_ATTR, None)
if isinstance(extra_attrs, dict):
if not _is_valid_for_codec(extra_attrs, self._codec):
raise TypeError(
f'Extra attrs on \'{fieldpath}\' contains data type(s)'
f' not supported by \'{self._codec.value}\' codec:'
f' {extra_attrs}.'
)
if self._create:
assert out is not None
out.update(extra_attrs)
if not self._discard_extra_attrs:
extra_attrs = getattr(obj, EXTRA_ATTRS_ATTR, None)
if isinstance(extra_attrs, dict):
if not _is_valid_for_codec(extra_attrs, self._codec):
raise TypeError(
f'Extra attrs on \'{fieldpath}\' contains data type(s)'
f' not supported by \'{self._codec.value}\' codec:'
f' {extra_attrs}.'
)
if self._create:
assert out is not None
out.update(extra_attrs)
# If this obj inherits from multi-type, store its type id.
if isinstance(obj, IOMultiType):

View file

@ -399,8 +399,8 @@ class DeadlockWatcher:
Use the enable_deadlock_watchers() to enable this system.
Next, create these in contexts where they will be torn down after
some operation completes. If any is not torn down within the
Next, use these wrapped in a with statement around some operation
that may deadlock. If the with statement does not complete within the
timeout period, a traceback of all threads will be dumped.
Note that the checker thread runs a cycle every ~5 seconds, so
@ -442,10 +442,39 @@ class DeadlockWatcher:
if curthread.ident is None
else hex(curthread.ident).removeprefix('0x')
)
self.active = False
with cls.watchers_lock:
cls.watchers.append(weakref.ref(self))
# Support the with statement.
def __enter__(self) -> Any:
self.active = True
return self
def __exit__(self, exc_type: Any, exc_value: Any, exc_tb: Any) -> None:
self.active = False
# Print if we lived past our deadline. This is just an extra
# data point. The watcher thread should be doing the actual
# stack dumps/etc.
if self.logger is None or self.logextra is None:
return
duration = time.monotonic() - self.create_time
if duration > self.timeout:
self.logger.error(
'DeadlockWatcher %s at %s in thread %s lived %.2fs,'
' past timeout %.2fs. This should have triggered'
' a deadlock dump.',
id(self),
self.caller_source_loc,
self.thread_id,
duration,
self.timeout,
extra=self.logextra,
)
@classmethod
def enable_deadlock_watchers(cls) -> None:
"""Spins up deadlock-watcher functionality.
@ -486,7 +515,7 @@ class DeadlockWatcher:
found_fresh_expired = False
# If any watcher is still alive but expired, sleep past the
# If any watcher is still active and expired, sleep past the
# timeout to force the dumper to do its thing.
with cls.watchers_lock:
@ -496,14 +525,16 @@ class DeadlockWatcher:
w is not None
and now - w.create_time > w.timeout
and not w.noted_expire
and w.active
):
# If they supplied a logger, let them know they
# should check stderr for a dump.
if w.logger is not None:
w.logger.error(
'DeadlockWatcher at %s in thread %s'
'DeadlockWatcher %s at %s in thread %s'
' with time %.2f expired;'
' check stderr for stack traces.',
id(w),
w.caller_source_loc,
w.thread_id,
w.timeout,

View file

@ -73,9 +73,9 @@ class RemoteError(Exception):
occurs remotely. The error string can consist of a remote stack
trace or a simple message depending on the context.
Communication systems should raise more specific error types locally
when more introspection/control is needed; this is intended somewhat
as a catch-all.
Communication systems should aim to communicate specific errors
gracefully as standard message responses when specific details are
needed; this is intended more as a catch-all.
"""
def __init__(self, msg: str, peer_desc: str):

View file

@ -10,13 +10,13 @@ import logging
import datetime
import itertools
from enum import Enum
from functools import partial
from collections import deque
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override
from threading import Thread, current_thread, Lock
from efro.util import utc_now
from efro.call import tpartial
from efro.terminal import Clr
from efro.dataclassio import ioprepped, IOAttrs, dataclass_to_json
@ -150,7 +150,6 @@ class LogHandler(logging.Handler):
self._cache = deque[tuple[int, LogEntry]]()
self._cache_index_offset = 0
self._cache_lock = Lock()
# self._report_blocking_io_on_echo_error = False
self._printed_callback_error = False
self._thread_bootstrapped = False
self._thread = Thread(target=self._log_thread_main, daemon=True)
@ -179,7 +178,7 @@ class LogHandler(logging.Handler):
# process cached entries at the same time to ensure there are no
# race conditions that could cause entries to be skipped/etc.
self._event_loop.call_soon_threadsafe(
tpartial(self._add_callback_in_thread, call, feed_existing_logs)
partial(self._add_callback_in_thread, call, feed_existing_logs)
)
def _add_callback_in_thread(
@ -343,7 +342,7 @@ class LogHandler(logging.Handler):
if __debug__:
formattime = echotime = time.monotonic()
self._event_loop.call_soon_threadsafe(
tpartial(
partial(
self._emit_in_thread,
record.name,
record.levelno,
@ -396,7 +395,7 @@ class LogHandler(logging.Handler):
echotime = time.monotonic()
self._event_loop.call_soon_threadsafe(
tpartial(
partial(
self._emit_in_thread,
record.name,
record.levelno,
@ -428,7 +427,7 @@ class LogHandler(logging.Handler):
# the bg event loop thread we've already got.
self._last_slow_emit_warning_time = now
self._event_loop.call_soon_threadsafe(
tpartial(
partial(
logging.warning,
'efro.log.LogHandler emit took too long'
' (%.2fs total; %.2fs format, %.2fs echo,'
@ -478,7 +477,7 @@ class LogHandler(logging.Handler):
# another thread for each character. Perhaps should do some sort
# of basic accumulation here?
self._event_loop.call_soon_threadsafe(
tpartial(self._file_write_in_thread, name, output)
partial(self._file_write_in_thread, name, output)
)
def _file_write_in_thread(self, name: str, output: str) -> None:
@ -515,11 +514,30 @@ class LogHandler(logging.Handler):
traceback.print_exc(file=self._echofile)
def shutdown(self) -> None:
"""Block until all pending logs/prints are done."""
done = False
self.file_flush('stdout')
self.file_flush('stderr')
def _set_done() -> None:
nonlocal done
done = True
self._event_loop.call_soon_threadsafe(_set_done)
starttime = time.monotonic()
while not done:
if time.monotonic() - starttime > 5.0:
print('LogHandler shutdown hung!!!', file=sys.stderr)
break
time.sleep(0.01)
def file_flush(self, name: str) -> None:
"""Send raw stdout/stderr flush to the logger to be collated."""
self._event_loop.call_soon_threadsafe(
tpartial(self._file_flush_in_thread, name)
partial(self._file_flush_in_thread, name)
)
def _file_flush_in_thread(self, name: str) -> None:
@ -721,11 +739,7 @@ def setup_logging(
# Optionally intercept Python's stdout/stderr output and generate
# log entries from it.
if log_stdout_stderr:
sys.stdout = FileLogEcho( # type: ignore
sys.stdout, 'stdout', loghandler
)
sys.stderr = FileLogEcho( # type: ignore
sys.stderr, 'stderr', loghandler
)
sys.stdout = FileLogEcho(sys.stdout, 'stdout', loghandler)
sys.stderr = FileLogEcho(sys.stderr, 'stderr', loghandler)
return loghandler

View file

@ -45,7 +45,7 @@ class MessageProtocol:
forward_communication_errors: bool = False,
forward_clean_errors: bool = False,
remote_errors_include_stack_traces: bool = False,
log_remote_errors: bool = True,
log_errors_on_receiver: bool = True,
) -> None:
"""Create a protocol with a given configuration.
@ -62,8 +62,8 @@ class MessageProtocol:
When an exception is not covered by the optional forwarding
mechanisms above, it will come across as efro.error.RemoteError
and the exception will be logged on the receiver
end - at least by default (see details below).
and the exception will be logged on the receiver end - at least
by default (see details below).
If 'remote_errors_include_stack_traces' is True, stringified
stack traces will be returned with efro.error.RemoteError
@ -77,8 +77,8 @@ class MessageProtocol:
goal is usually to avoid returning opaque RemoteErrors and to
instead return something meaningful as part of the expected
response type (even if that value itself represents a logical
error state). If 'log_remote_errors' is False, however, such
exceptions will not be logged on the receiver. This can be
error state). If 'log_errors_on_receiver' is False, however, such
exceptions will *not* be logged on the receiver. This can be
useful in combination with 'remote_errors_include_stack_traces'
and 'forward_clean_errors' in situations where all error
logging/management will be happening on the sender end. Be
@ -168,7 +168,7 @@ class MessageProtocol:
self.remote_errors_include_stack_traces = (
remote_errors_include_stack_traces
)
self.log_remote_errors = log_remote_errors
self.log_errors_on_receiver = log_errors_on_receiver
@staticmethod
def encode_dict(obj: dict) -> str:
@ -213,13 +213,20 @@ class MessageProtocol:
return (
ErrorSysResponse(
error_message=(
traceback.format_exc()
# Note: need to format exception ourself here; it
# might not be current so we can't use
# traceback.format_exc().
''.join(
traceback.format_exception(
type(exc), exc, exc.__traceback__
)
)
if self.remote_errors_include_stack_traces
else 'An internal error has occurred.'
),
error_type=ErrorSysResponse.ErrorType.REMOTE,
),
self.log_remote_errors,
self.log_errors_on_receiver,
)
def _to_dict(

View file

@ -38,6 +38,7 @@ class MessageReceiver:
# MyMessageReceiver fills out handler() overloads to ensure all
# registered handlers have valid types/return-types.
@receiver.handler
def handle_some_message_type(self, message: SomeMsg) -> SomeResponse:
# Deal with this message type here.
@ -47,7 +48,7 @@ class MessageReceiver:
obj.receiver.handle_raw_message(some_raw_data)
Any unhandled Exception occurring during message handling will result in
an Exception being raised on the sending end.
an efro.error.RemoteError being raised on the sending end.
"""
is_async = False
@ -89,20 +90,6 @@ class MessageReceiver:
f' got {sig.args}'
)
# Make sure we are only given async methods if we are an async handler
# and sync ones otherwise.
# UPDATE - can't do this anymore since we now sometimes use
# regular functions which return awaitables instead of having
# the entire function be async.
# is_async = inspect.iscoroutinefunction(call)
# if self.is_async != is_async:
# msg = (
# 'Expected a sync method; found an async one.'
# if is_async
# else 'Expected an async method; found a sync one.'
# )
# raise ValueError(msg)
# Check annotation types to determine what message types we handle.
# Return-type annotation can be a Union, but we probably don't
# have it available at runtime. Explicitly pull it in.
@ -161,7 +148,7 @@ class MessageReceiver:
if msgtype in self._handlers:
raise TypeError(
f'Message type {msgtype} already has a registered' f' handler.'
f'Message type {msgtype} already has a registered handler.'
)
# Make sure the responses exactly matches what the message expects.
@ -284,7 +271,6 @@ class MessageReceiver:
"""
assert not self.is_async, "can't call sync handler on async receiver"
msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try:
msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
@ -304,7 +290,8 @@ class MessageReceiver:
bound_obj, msg_decoded, exc
)
if dolog:
if msgtype is not None:
if msg_decoded is not None:
msgtype = type(msg_decoded)
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
@ -312,7 +299,9 @@ class MessageReceiver:
)
else:
logging.exception(
'Error handling raw efro.message. msg=%s', msg
'Error handling raw efro.message'
' (likely a message format incompatibility): %s.',
msg,
)
return rstr
@ -329,9 +318,8 @@ class MessageReceiver:
# able to guarantee that messages handlers would be called in the
# order the messages were received.
assert self.is_async, "can't call async handler on sync receiver"
assert self.is_async, "Can't call async handler on sync receiver."
msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try:
msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
@ -346,43 +334,51 @@ class MessageReceiver:
):
raise
return self._handle_raw_message_async_error(
bound_obj, msg_decoded, msgtype, exc
bound_obj, msg, msg_decoded, exc
)
# Return an awaitable to handle the rest asynchronously.
return self._handle_raw_message_async(
bound_obj, msg_decoded, msgtype, handler_awaitable
bound_obj, msg, msg_decoded, handler_awaitable
)
async def _handle_raw_message_async_error(
self,
bound_obj: Any,
msg_raw: str,
msg_decoded: Message | None,
msgtype: type[Message] | None,
exc: Exception,
) -> str:
rstr, dolog = self.encode_error_response(bound_obj, msg_decoded, exc)
if dolog:
if msgtype is not None:
if msg_decoded is not None:
msgtype = type(msg_decoded)
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
msgtype.__qualname__,
# We need to explicitly provide the exception here,
# otherwise it shows up at None. I assume related to
# the fact that we're an async function.
exc_info=exc,
)
else:
logging.exception(
'Error handling raw async efro.message.'
' msgtype=%s msg_decoded=%s.',
msgtype,
msg_decoded,
'Error handling raw async efro.message'
' (likely a message format incompatibility): %s.',
msg_raw,
# We need to explicitly provide the exception here,
# otherwise it shows up at None. I assume related to
# the fact that we're an async function.
exc_info=exc,
)
return rstr
async def _handle_raw_message_async(
self,
bound_obj: Any,
msg_raw: str,
msg_decoded: Message,
msgtype: type[Message] | None,
handler_awaitable: Awaitable[Response | None],
) -> str:
"""Should be called when the receiver gets a message.
@ -396,7 +392,7 @@ class MessageReceiver:
except Exception as exc:
return await self._handle_raw_message_async_error(
bound_obj, msg_decoded, msgtype, exc
bound_obj, msg_raw, msg_decoded, exc
)

View file

@ -20,27 +20,41 @@ if TYPE_CHECKING:
class MessageSender:
"""Facilitates sending messages to a target and receiving responses.
This is instantiated at the class level and used to register unbound
class methods to handle raw message sending.
These are instantiated at the class level and used to register unbound
class methods to handle raw message sending. Generally this class is not
used directly, but instead autogenerated subclasses which provide type
safe overloads are used instead.
Example:
(In this example, MyMessageSender is an autogenerated class that
inherits from MessageSender).
class MyClass:
msg = MyMessageSender(some_protocol)
msg = MyMessageSender()
@msg.send_method
def send_raw_message(self, message: str) -> str:
# Actually send the message here.
# MyMessageSender class should provide overloads for send(), send_async(),
# etc. to ensure all sending happens with valid types.
obj = MyClass()
obj.msg.send(SomeMessageType())
# The MyMessageSender generated class would provides overloads for
# send(), send_async(), etc. to provide type-safety for message types
# and their associated response types.
# Thus, given the statement below, a type-checker would know that
# 'response' is a SomeResponseType or whatever is associated with
# SomeMessageType.
response = obj.msg.send(SomeMessageType())
"""
def __init__(self, protocol: MessageProtocol) -> None:
self.protocol = protocol
self._send_raw_message_call: Callable[[Any, str], str] | None = None
self._send_raw_message_ex_call: (
Callable[[Any, str, Message], str] | None
) = None
self._send_async_raw_message_call: (
Callable[[Any, str], Awaitable[str]] | None
) = None
@ -69,6 +83,19 @@ class MessageSender:
self._send_raw_message_call = call
return call
def send_ex_method(
self, call: Callable[[Any, str, Message], str]
) -> Callable[[Any, str, Message], str]:
"""Function decorator for extended send method.
Version of send_method which is also is passed the original
unencoded message; can be useful for cases where metadata is sent
along with messages referring to their payloads/etc.
"""
assert self._send_raw_message_ex_call is None
self._send_raw_message_ex_call = call
return call
def send_async_method(
self, call: Callable[[Any, str], Awaitable[str]]
) -> Callable[[Any, str], Awaitable[str]]:
@ -189,14 +216,23 @@ class MessageSender:
for when message sending and response handling need to happen
in different contexts/threads.
"""
if self._send_raw_message_call is None:
if (
self._send_raw_message_call is None
and self._send_raw_message_ex_call is None
):
raise RuntimeError('send() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message)
try:
response_encoded = self._send_raw_message_call(
bound_obj, msg_encoded
)
if self._send_raw_message_ex_call is not None:
response_encoded = self._send_raw_message_ex_call(
bound_obj, msg_encoded, message
)
else:
assert self._send_raw_message_call is not None
response_encoded = self._send_raw_message_call(
bound_obj, msg_encoded
)
except Exception as exc:
response = ErrorSysResponse(
error_message='Error in MessageSender @send_method.',

View file

@ -9,6 +9,7 @@ import asyncio
import logging
import weakref
from enum import Enum
from functools import partial
from collections import deque
from dataclasses import dataclass
from threading import current_thread
@ -84,7 +85,6 @@ def ssl_stream_writer_underlying_transport_info(
def ssl_stream_writer_force_close_check(writer: asyncio.StreamWriter) -> None:
"""Ensure a writer is closed; hacky workaround for odd hang."""
from efro.call import tpartial
from threading import Thread
# Disabling for now..
@ -100,9 +100,8 @@ def ssl_stream_writer_force_close_check(writer: asyncio.StreamWriter) -> None:
raw_transport = getattr(sslproto, '_transport', None)
if raw_transport is not None:
Thread(
target=tpartial(
_do_writer_force_close_check,
weakref.ref(raw_transport),
target=partial(
_do_writer_force_close_check, weakref.ref(raw_transport)
),
daemon=True,
).start()

View file

@ -72,6 +72,7 @@ def _default_color_enabled() -> bool:
import platform
# If our stdout is not attached to a terminal, go with no-color.
assert sys.__stdout__ is not None
if not sys.__stdout__.isatty():
return False

View file

@ -8,14 +8,12 @@ import os
import time
import weakref
import datetime
import functools
from enum import Enum
from typing import TYPE_CHECKING, cast, TypeVar, Generic
from typing import TYPE_CHECKING, cast, TypeVar, Generic, overload
if TYPE_CHECKING:
import asyncio
from efro.call import Call as Call # 'as Call' so we re-export.
from typing import Any, Callable
from typing import Any, Callable, Literal
T = TypeVar('T')
ValT = TypeVar('ValT')
@ -35,13 +33,6 @@ _g_empty_weak_ref = weakref.ref(_EmptyObj())
assert _g_empty_weak_ref() is None
# TODO: kill this and just use efro.call.tpartial
if TYPE_CHECKING:
Call = Call
else:
Call = functools.partial
def explicit_bool(val: bool) -> bool:
"""Return a non-inferable boolean value.
@ -536,6 +527,21 @@ def make_hash(obj: Any) -> int:
return hash(tuple(frozenset(sorted(new_obj.items()))))
def float_hash_from_string(s: str) -> float:
"""Given a string value, returns a float between 0 and 1.
If consistent across processes. Can be useful for assigning db ids
shard values for efficient parallel processing.
"""
import hashlib
hash_bytes = hashlib.md5(s.encode()).digest()
# Generate a random 64 bit int from hash digest bytes.
ival = int.from_bytes(hash_bytes[:8])
return ival / ((1 << 64) - 1)
def asserttype(obj: Any, typ: type[T]) -> T:
"""Return an object typed as a given type.
@ -898,3 +904,58 @@ def split_list(input_list: list[T], max_length: int) -> list[list[T]]:
input_list[i : i + max_length]
for i in range(0, len(input_list), max_length)
]
def extract_flag(args: list[str], name: str) -> bool:
"""Given a list of args and a flag name, returns whether it is present.
The arg flag, if present, is removed from the arg list.
"""
from efro.error import CleanError
count = args.count(name)
if count > 1:
raise CleanError(f'Flag {name} passed multiple times.')
if not count:
return False
args.remove(name)
return True
@overload
def extract_arg(
args: list[str], name: str, required: Literal[False] = False
) -> str | None: ...
@overload
def extract_arg(args: list[str], name: str, required: Literal[True]) -> str: ...
def extract_arg(
args: list[str], name: str, required: bool = False
) -> str | None:
"""Given a list of args and an arg name, returns a value.
The arg flag and value are removed from the arg list.
raises CleanErrors on any problems.
"""
from efro.error import CleanError
count = args.count(name)
if not count:
if required:
raise CleanError(f'Required argument {name} not passed.')
return None
if count > 1:
raise CleanError(f'Arg {name} passed multiple times.')
argindex = args.index(name)
if argindex + 1 >= len(args):
raise CleanError(f'No value passed after {name} arg.')
val = args[argindex + 1]
del args[argindex : argindex + 2]
return val