diff --git a/dist/ba_data/python/babase/__init__.py b/dist/ba_data/python/babase/__init__.py index 8b6baa7..b1e689d 100644 --- a/dist/ba_data/python/babase/__init__.py +++ b/dist/ba_data/python/babase/__init__.py @@ -142,6 +142,7 @@ from babase._apputils import ( ) from babase._cloud import CloudSubscription from babase._devconsole import ( + DevConsoleButtonDef, DevConsoleTab, DevConsoleTabEntry, DevConsoleSubsystem, @@ -179,7 +180,14 @@ from babase._general import ( ) from babase._language import Lstr, LanguageSubsystem from babase._locale import LocaleSubsystem -from babase._logging import balog, accountlog, applog, lifecyclelog, netlog +from babase._logging import ( + balog, + accountlog, + applog, + lifecyclelog, + netlog, + uilog, +) from babase._login import LoginAdapter, LoginInfo from babase._mgen.enums import ( @@ -250,6 +258,7 @@ __all__ = [ 'ContextError', 'ContextRef', 'DelegateNotFoundError', + 'DevConsoleButtonDef', 'DevConsoleTab', 'DevConsoleTabEntry', 'DevConsoleSubsystem', @@ -371,6 +380,7 @@ __all__ = [ 'supports_unicode_display', 'TeamNotFoundError', 'timestring', + 'uilog', 'UIScale', 'unlock_all_input', 'update_internal_logger_levels', diff --git a/dist/ba_data/python/babase/_app.py b/dist/ba_data/python/babase/_app.py index ca85f6f..8a2673f 100644 --- a/dist/ba_data/python/babase/_app.py +++ b/dist/ba_data/python/babase/_app.py @@ -202,10 +202,16 @@ class App: return _babase.app_is_active() @property - def mode(self) -> AppMode | None: - """The app's current mode.""" + def mode(self) -> AppMode: + """The app's current mode. + + Raises :class:`ValueError` if no mode is set. + """ assert _babase.in_logic_thread() - return self._mode + mode = self._mode + if mode is None: + raise ValueError('No app-mode set.') + return mode @property def asyncio_loop(self) -> asyncio.AbstractEventLoop: diff --git a/dist/ba_data/python/babase/_appmode.py b/dist/ba_data/python/babase/_appmode.py index 715f066..48f19f9 100644 --- a/dist/ba_data/python/babase/_appmode.py +++ b/dist/ba_data/python/babase/_appmode.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: - from babase import AppIntent + from babase import AppIntent, DevConsoleButtonDef class AppMode: @@ -125,3 +125,11 @@ class AppMode: ) if babase.asset_loads_allowed(): babase.getsimplesound('cashRegister').play() + + def get_dev_console_ui_tab_buttons(self) -> list[DevConsoleButtonDef]: + """Define buttons to show up in the UI dev console. + + This can be useful for exposing UI code examples or debugging + functionality. + """ + return [] diff --git a/dist/ba_data/python/babase/_devconsole.py b/dist/ba_data/python/babase/_devconsole.py index c871711..24c4d2f 100644 --- a/dist/ba_data/python/babase/_devconsole.py +++ b/dist/ba_data/python/babase/_devconsole.py @@ -14,6 +14,19 @@ if TYPE_CHECKING: from typing import Callable, Any, Literal +class DevConsoleButtonDef: + """A barebones way to define a custom button for the dev console. + + Note that a :class:`DevConsoleTab` should use its + :meth:`DevConsoleTab.button()` method to create buttons; this is + instead for allowing basic customization. + """ + + def __init__(self, name: str, call: Callable[[], Any]) -> None: + self.name = name + self.call = call + + class DevConsoleTab: """Base class for a :class:`~babase.DevConsoleSubsystem` tab.""" diff --git a/dist/ba_data/python/babase/_devconsoletabs.py b/dist/ba_data/python/babase/_devconsoletabs.py index cc6b9b2..18221f1 100644 --- a/dist/ba_data/python/babase/_devconsoletabs.py +++ b/dist/ba_data/python/babase/_devconsoletabs.py @@ -119,14 +119,30 @@ class DevConsoleTabUI(DevConsoleTab): def refresh(self) -> None: from babase._mgen.enums import UIScale - xoffs = -305 - yoffs = 10 + xoffs = -305.0 + yoffs = 10.0 + + custom_buttons = _babase.app.mode.get_dev_console_ui_tab_buttons() + cboffs = 50 + cbwidth = 180 + cbspacing = 10 + + if custom_buttons: + cbtotalwidth = ( + len(custom_buttons) * cbwidth + + max(0, len(custom_buttons) - 1) * cbspacing + + cboffs + ) + else: + cbtotalwidth = 0 + + xoffs -= cbtotalwidth * 0.5 self.text( 'A UI should either fit in the virtual safe area' ' or dynamically respond to screen size changes.', scale=0.6, - pos=(xoffs + 15, yoffs + 65), + pos=(xoffs + 8, yoffs + 65), h_align='left', v_align='center', ) @@ -165,6 +181,19 @@ class DevConsoleTabUI(DevConsoleTab): ) x += bwidth + 2 + if custom_buttons: + x += cboffs + for custom_button in custom_buttons: + self.button( + custom_button.name, + pos=(xoffs + x, yoffs + 15), + size=(cbwidth, 40), + label_scale=0.6, + call=custom_button.call, + corner_radius=10.0, + ) + x += cbwidth + cbspacing + def toggle_ui_overlay(self) -> None: """Toggle UI overlay drawing.""" _babase.set_draw_virtual_safe_area_bounds( @@ -544,12 +573,15 @@ class DevConsoleTabLogging(DevConsoleTab): index = 0 effectivelevel = logger.getEffectiveLevel() notsetname = 'Not Set' + bradius = 5.0 + bspacing = 2.0 tab.button( notsetname, pos=(x + width - bwidth * 6.5 + xoffs + 1.0, y + 5.0), - size=(bwidth * 1.0 - 2.0, height - 10), + size=(bwidth * 1.0 - bspacing, height - 10), label_scale=btextscale, style='white_bright' if level == logging.NOTSET else 'black', + corner_radius=bradius, call=partial( self._set_entry_val, entry_index, entry, logging.NOTSET ), @@ -558,13 +590,14 @@ class DevConsoleTabLogging(DevConsoleTab): tab.button( 'Debug', pos=(x + width - bwidth * 5 + xoffs + 1.0, y + 5.0), - size=(bwidth - 2.0, height - 10), + size=(bwidth - bspacing, height - 10), label_scale=btextscale, style=( 'white_bright' if level == logging.DEBUG else 'blue' if effectivelevel <= logging.DEBUG else 'black' ), + corner_radius=bradius, call=partial( self._set_entry_val, entry_index, entry, logging.DEBUG ), @@ -573,26 +606,28 @@ class DevConsoleTabLogging(DevConsoleTab): tab.button( 'Info', pos=(x + width - bwidth * 4 + xoffs + 1.0, y + 5.0), - size=(bwidth - 2.0, height - 10), + size=(bwidth - bspacing, height - 10), label_scale=btextscale, style=( 'white_bright' if level == logging.INFO else 'white' if effectivelevel <= logging.INFO else 'black' ), + corner_radius=bradius, call=partial(self._set_entry_val, entry_index, entry, logging.INFO), ) index += 1 tab.button( 'Warning', pos=(x + width - bwidth * 3 + xoffs + 1.0, y + 5.0), - size=(bwidth - 2.0, height - 10), + size=(bwidth - bspacing, height - 10), label_scale=btextscale, style=( 'white_bright' if level == logging.WARNING else 'yellow' if effectivelevel <= logging.WARNING else 'black' ), + corner_radius=bradius, call=partial( self._set_entry_val, entry_index, entry, logging.WARNING ), @@ -601,13 +636,14 @@ class DevConsoleTabLogging(DevConsoleTab): tab.button( 'Error', pos=(x + width - bwidth * 2 + xoffs + 1.0, y + 5.0), - size=(bwidth - 2.0, height - 10), + size=(bwidth - bspacing, height - 10), label_scale=btextscale, style=( 'white_bright' if level == logging.ERROR else 'red' if effectivelevel <= logging.ERROR else 'black' ), + corner_radius=bradius, call=partial( self._set_entry_val, entry_index, entry, logging.ERROR ), @@ -616,7 +652,7 @@ class DevConsoleTabLogging(DevConsoleTab): tab.button( 'Critical', pos=(x + width - bwidth * 1 + xoffs + 1.0, y + 5.0), - size=(bwidth - 2.0, height - 10), + size=(bwidth - bspacing, height - 10), label_scale=btextscale, style=( 'white_bright' @@ -625,6 +661,7 @@ class DevConsoleTabLogging(DevConsoleTab): 'purple' if effectivelevel <= logging.CRITICAL else 'black' ) ), + corner_radius=bradius, call=partial( self._set_entry_val, entry_index, entry, logging.CRITICAL ), diff --git a/dist/ba_data/python/babase/_logging.py b/dist/ba_data/python/babase/_logging.py index e33cdcf..16957b7 100644 --- a/dist/ba_data/python/babase/_logging.py +++ b/dist/ba_data/python/babase/_logging.py @@ -31,6 +31,7 @@ cloudsublog = logging.getLogger(ClientLoggerName.CLOUD_SUBSCRIPTION.value) accountlog = logging.getLogger(ClientLoggerName.ACCOUNT.value) accountclientv2log = logging.getLogger(ClientLoggerName.ACCOUNT_CLIENT_V2.value) loginadapterlog = logging.getLogger(ClientLoggerName.LOGIN_ADAPTER.value) +uilog = logging.getLogger(ClientLoggerName.UI.value) def description_for_logger(logger: str) -> str | None: diff --git a/dist/ba_data/python/baclassic/_appmode.py b/dist/ba_data/python/baclassic/_appmode.py index 6aa26ee..ce065b1 100644 --- a/dist/ba_data/python/baclassic/_appmode.py +++ b/dist/ba_data/python/baclassic/_appmode.py @@ -1,7 +1,6 @@ # Released under the MIT License. See LICENSE for details. # """Contains ClassicAppMode.""" -# pylint: disable=too-many-lines from __future__ import annotations @@ -13,8 +12,8 @@ from typing import TYPE_CHECKING, override from efro.error import CommunicationError import bacommon.bs -import babase -import bauiv1 +from babase import AppMode +import bauiv1 as bui from bauiv1lib.connectivity import wait_for_connectivity from bauiv1lib.account.signin import show_sign_in_prompt @@ -29,7 +28,7 @@ if TYPE_CHECKING: # ba_meta export babase.AppMode -class ClassicAppMode(babase.AppMode): +class ClassicAppMode(AppMode): """AppMode for the classic BombSquad experience.""" _ACCOUNT_STATE_CONFIG_KEY = 'ClassicAccountState' @@ -42,17 +41,17 @@ class ClassicAppMode(babase.AppMode): self._on_connectivity_changed_callback: CallbackRegistration | None = ( None ) - self._test_sub: babase.CloudSubscription | None = None - self._account_data_sub: babase.CloudSubscription | None = None + self._test_sub: bui.CloudSubscription | None = None + self._account_data_sub: bui.CloudSubscription | None = None self._have_account_values = False self._have_connectivity = False self._current_account_id: str | None = None - self._purchase_ui_pause: bauiv1.RootUIUpdatePause | None = None + self._purchase_ui_pause: bui.RootUIUpdatePause | None = None self._last_tokens_value = 0 - self._purchases_update_timer: babase.AppTimer | None = None + self._purchases_update_timer: bui.AppTimer | None = None self._purchase_request_in_flight = False self._target_purchases_state: str | None = None @@ -62,18 +61,16 @@ class ClassicAppMode(babase.AppMode): @override @classmethod - def can_handle_intent(cls, intent: babase.AppIntent) -> bool: + def can_handle_intent(cls, intent: bui.AppIntent) -> bool: # We support default and exec intents currently. - return isinstance( - intent, babase.AppIntentExec | babase.AppIntentDefault - ) + return isinstance(intent, bui.AppIntentExec | bui.AppIntentDefault) @override - def handle_intent(self, intent: babase.AppIntent) -> None: - if isinstance(intent, babase.AppIntentExec): + def handle_intent(self, intent: bui.AppIntent) -> None: + if isinstance(intent, bui.AppIntentExec): _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) return - assert isinstance(intent, babase.AppIntentDefault) + assert isinstance(intent, bui.AppIntentDefault) _baclassic.classic_app_mode_handle_app_intent_default() @override @@ -82,7 +79,7 @@ class ClassicAppMode(babase.AppMode): # Let the native layer do its thing. _baclassic.classic_app_mode_activate() - app = babase.app + app = bui.app plus = app.plus assert plus is not None @@ -160,7 +157,7 @@ class ClassicAppMode(babase.AppMode): @override def on_deactivate(self) -> None: - classic = babase.app.classic + classic = bui.app.classic # Store latest league vis vals for any active account. self._save_account_state() @@ -183,11 +180,11 @@ class ClassicAppMode(babase.AppMode): @override def on_app_active_changed(self) -> None: - if not babase.app.active: + if not bui.app.active: # If we're going inactive, ask for the main ui, which should # have the side effect of pausing the action if we're in a # game. - babase.request_main_ui() + bui.request_main_ui() # Also store any league vis state for the active account. # this may be our last chance to do this on mobile. @@ -208,13 +205,13 @@ class ClassicAppMode(babase.AppMode): # need to explicitly kill this pause if we are deactivated since # we wouldn't get the on_purchase_process_end() call; the next # app-mode would. - self._purchase_ui_pause = bauiv1.RootUIUpdatePause() + self._purchase_ui_pause = bui.RootUIUpdatePause() # Also grab our last known token count here to plug into animations. # We need to do this here before the purchase gets submitted so that # we know we're seeing the old value. - assert babase.app.classic is not None - self._last_tokens_value = babase.app.classic.tokens + assert bui.app.classic is not None + self._last_tokens_value = bui.app.classic.tokens @override def on_purchase_process_end( @@ -259,7 +256,7 @@ class ClassicAppMode(babase.AppMode): 'Unhandled item_id in on_purchase_process_end: %s', item_id ) - assert babase.app.classic is not None + assert bui.app.classic is not None effects: list[bacommon.bs.ClientEffect] = [ bacommon.bs.ClientEffectTokensAnimation( duration=anim_time, @@ -276,23 +273,23 @@ class ClassicAppMode(babase.AppMode): sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER ), ] - babase.app.classic.run_bs_client_effects(effects) + bui.app.classic.run_bs_client_effects(effects) elif item_id.startswith('gold_pass'): - babase.screenmessage( - babase.Lstr( + bui.screenmessage( + bui.Lstr( translate=('serverResponses', 'You got a ${ITEM}!'), subs=[ ( '${ITEM}', - babase.Lstr(resource='goldPass.goldPassText'), + bui.Lstr(resource='goldPass.goldPassText'), ) ], ), color=(0, 1, 0), ) - if babase.asset_loads_allowed(): - babase.getsimplesound('cashRegister').play() + if bui.asset_loads_allowed(): + bui.getsound('cashRegister').play() else: @@ -300,15 +297,15 @@ class ClassicAppMode(babase.AppMode): logging.warning( 'on_purchase_process_end got unexpected item_id: %s.', item_id ) - babase.screenmessage( - babase.Lstr( + bui.screenmessage( + bui.Lstr( translate=('serverResponses', 'You got a ${ITEM}!'), subs=[('${ITEM}', item_id)], ), color=(0, 1, 0), ) - if babase.asset_loads_allowed(): - babase.getsimplesound('cashRegister').play() + if bui.asset_loads_allowed(): + bui.getsound('cashRegister').play() def on_engine_will_reset(self) -> None: """Called just before classic resets the engine. @@ -337,9 +334,9 @@ class ClassicAppMode(babase.AppMode): return self._purchase_request_in_flight = True - babase.accountlog.debug('Requesting latest purchases state...') + bui.accountlog.debug('Requesting latest purchases state...') - plus = babase.app.plus + plus = bui.app.plus assert plus is not None if plus.accounts.primary is None: raise RuntimeError( @@ -349,7 +346,7 @@ class ClassicAppMode(babase.AppMode): with plus.accounts.primary: plus.cloud.send_message_cb( bacommon.bs.GetClassicPurchasesMessage(), - on_response=babase.WeakCall( + on_response=bui.WeakCall( self._on_get_classic_purchases_response ), ) @@ -365,12 +362,12 @@ class ClassicAppMode(babase.AppMode): # No biggie; we expect these when offline/etc. pass else: - babase.netlog.exception('Error requesting classic purchases.') + bui.netlog.exception('Error requesting classic purchases.') return # If we're no longer looking for a state, we can abort early. if self._target_purchases_state is None: - babase.accountlog.debug( + bui.accountlog.debug( 'No longer looking for new purchases state; aborting fetch.' ) self._purchases_update_timer = None @@ -387,10 +384,10 @@ class ClassicAppMode(babase.AppMode): self._current_purchases = frozenset(response.purchases) self._current_purchases_state = state - assert babase.app.classic is not None - babase.app.classic.purchases = self._current_purchases + assert bui.app.classic is not None + bui.app.classic.purchases = self._current_purchases - babase.accountlog.debug( + bui.accountlog.debug( 'Updated purchases state to %s: (%s items)', state, len(self._current_purchases), @@ -402,15 +399,15 @@ class ClassicAppMode(babase.AppMode): return hashlib.md5(','.join(sorted(purchases)).encode()).hexdigest() def _update_for_primary_account( - self, account: babase.AccountV2Handle | None + self, account: bui.AccountV2Handle | None ) -> None: """Update subscriptions/etc. for a new primary account state.""" - assert babase.in_logic_thread() - plus = babase.app.plus + assert bui.in_logic_thread() + plus = bui.app.plus assert plus is not None - classic = babase.app.classic + classic = bui.app.classic assert classic is not None if account is not None: @@ -512,7 +509,7 @@ class ClassicAppMode(babase.AppMode): ) -> None: achp = round(val.achievements / max(val.achievements_total, 1) * 100.0) - babase.accountlog.debug('Got new classic account data.') + bui.accountlog.debug('Got new classic account data.') chest0 = val.chests.get('0') chest1 = val.chests.get('1') @@ -521,7 +518,7 @@ class ClassicAppMode(babase.AppMode): # Keep a few handy values on classic updated with the latest # data. - classic = babase.app.classic + classic = bui.app.classic assert classic is not None classic.remove_ads = val.remove_ads classic.gold_pass = val.gold_pass @@ -533,14 +530,14 @@ class ClassicAppMode(babase.AppMode): # If they want us to ask for a review (and we haven't yet), do # so. if val.Flag.ASK_FOR_REVIEW in val.flags: - cfg = babase.app.config + cfg = bui.app.config if ( not cfg.get(self._ASKED_FOR_REVIEW_CONFIG_KEY, False) - and babase.native_review_request_supported() + and bui.native_review_request_supported() ): cfg[self._ASKED_FOR_REVIEW_CONFIG_KEY] = True cfg.commit() - babase.native_review_request() + bui.native_review_request() # If someone replaced our purchases in the classic subsystem, # fix it. @@ -556,7 +553,7 @@ class ClassicAppMode(babase.AppMode): self._target_purchases_state is not None and self._current_purchases_state != self._target_purchases_state ): - babase.accountlog.debug( + bui.accountlog.debug( 'Account purchases state is %s; we have %s. Will fetch new.', self._target_purchases_state, self._current_purchases_state, @@ -566,7 +563,7 @@ class ClassicAppMode(babase.AppMode): # doing its thing. pass else: - self._purchases_update_timer = babase.AppTimer( + self._purchases_update_timer = bui.AppTimer( 3.456, self._update_purchases, repeat=True ) self._possibly_request_purchases() @@ -596,7 +593,7 @@ class ClassicAppMode(babase.AppMode): inbox_count=val.inbox_count, inbox_count_is_max=val.inbox_count_is_max, inbox_announce_text=( - babase.Lstr(resource='unclaimedPrizesText').evaluate() + bui.Lstr(resource='unclaimedPrizesText').evaluate() if val.inbox_contains_prize else '' ), @@ -678,15 +675,15 @@ class ClassicAppMode(babase.AppMode): def _root_ui_menu_press(self) -> None: from babase import menu_press - ui = babase.app.ui_v1 + ui = bui.app.ui_v1 # If *any* main-window is up, kill it and resume play. old_window = ui.get_main_window() if old_window is not None: - bauiv1.getsound('swish').play() + bui.getsound('swish').play() - classic = babase.app.classic + classic = bui.app.classic assert classic is not None classic.resume() @@ -698,126 +695,31 @@ class ClassicAppMode(babase.AppMode): def _root_ui_account_press(self) -> None: from bauiv1lib.account.settings import AccountSettingsWindow - self._auxiliary_window_nav( + bui.app.ui_v1.auxiliary_window_activate( win_type=AccountSettingsWindow, win_create_call=lambda: AccountSettingsWindow( - origin_widget=bauiv1.get_special_widget('account_button') + origin_widget=bui.get_special_widget('account_button') ), ) def _root_ui_squad_press(self) -> None: - btn = bauiv1.get_special_widget('squad_button') + btn = bui.get_special_widget('squad_button') center = btn.get_screen_space_center() - if bauiv1.app.classic is not None: - bauiv1.app.classic.party_icon_activate(center) + if bui.app.classic is not None: + bui.app.classic.party_icon_activate(center) else: logging.warning('party_icon_activate: no classic.') def _root_ui_settings_press(self) -> None: from bauiv1lib.settings.allsettings import AllSettingsWindow - self._auxiliary_window_nav( + bui.app.ui_v1.auxiliary_window_activate( win_type=AllSettingsWindow, win_create_call=lambda: AllSettingsWindow( - origin_widget=bauiv1.get_special_widget('settings_button') + origin_widget=bui.get_special_widget('settings_button') ), ) - def _auxiliary_window_nav( - self, - win_type: type[bauiv1.MainWindow], - win_create_call: Callable[[], bauiv1.MainWindow], - ) -> None: - """Navigate to or away from an Auxiliary window. - - Auxiliary windows can be thought of as 'side quests' in the - window hierarchy; places such as settings windows or league - ranking windows that the user might want to visit without losing - their place in the regular hierarchy. - """ - # pylint: disable=unidiomatic-typecheck - - ui = babase.app.ui_v1 - - current_main_window = ui.get_main_window() - - # Scan our ancestors for auxiliary states matching our type as - # well as auxiliary states in general. - aux_matching_state: bauiv1.MainWindowState | None = None - aux_state: bauiv1.MainWindowState | None = None - - if current_main_window is None: - raise RuntimeError( - 'Not currently handling no-top-level-window case.' - ) - - state = current_main_window.main_window_back_state - while state is not None: - assert state.window_type is not None - if state.is_auxiliary: - if state.window_type is win_type: - aux_matching_state = state - else: - aux_state = state - - state = state.parent - - # If there's an ancestor auxiliary window-state matching our - # type, back out past it (example: poking settings, navigating - # down a level or two, and then poking settings again should - # back out of settings). - if aux_matching_state is not None: - current_main_window.main_window_back_state = ( - aux_matching_state.parent - ) - current_main_window.main_window_back() - return - - # If there's an ancestory auxiliary state *not* matching our - # type, crop the state and swap in our new auxiliary UI - # (example: poking settings, then poking account, then poking - # back should end up where things were before the settings - # poke). - if aux_state is not None: - # Blow away the window stack and build a fresh one. - ui.clear_main_window() - ui.set_main_window( - win_create_call(), - from_window=False, # Disable from-check. - back_state=aux_state.parent, - suppress_warning=True, - is_auxiliary=True, - ) - return - - # Ok, no auxiliary states found. Now if current window is - # auxiliary and the type matches, simply do a back. - if ( - current_main_window.main_window_is_auxiliary - and type(current_main_window) is win_type - ): - current_main_window.main_window_back() - return - - # If current window is auxiliary but type doesn't match, - # swap it out for our new auxiliary UI. - if current_main_window.main_window_is_auxiliary: - ui.clear_main_window() - ui.set_main_window( - win_create_call(), - from_window=False, # Disable from-check. - back_state=current_main_window.main_window_back_state, - suppress_warning=True, - is_auxiliary=True, - ) - return - - # Ok, no existing auxiliary stuff was found period. Just - # navigate forward to this UI. - current_main_window.main_window_replace( - win_create_call(), is_auxiliary=True - ) - def _root_ui_achievements_press(self) -> None: from bauiv1lib.achievements import AchievementsWindow @@ -825,12 +727,10 @@ class ClassicAppMode(babase.AppMode): return wait_for_connectivity( - on_connected=lambda: self._auxiliary_window_nav( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=AchievementsWindow, win_create_call=lambda: AchievementsWindow( - origin_widget=bauiv1.get_special_widget( - 'achievements_button' - ) + origin_widget=bui.get_special_widget('achievements_button') ), ) ) @@ -842,10 +742,10 @@ class ClassicAppMode(babase.AppMode): return wait_for_connectivity( - on_connected=lambda: self._auxiliary_window_nav( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=InboxWindow, win_create_call=lambda: InboxWindow( - origin_widget=bauiv1.get_special_widget('inbox_button') + origin_widget=bui.get_special_widget('inbox_button') ), ) ) @@ -857,10 +757,10 @@ class ClassicAppMode(babase.AppMode): return wait_for_connectivity( - on_connected=lambda: self._auxiliary_window_nav( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=StoreBrowserWindow, win_create_call=lambda: StoreBrowserWindow( - origin_widget=bauiv1.get_special_widget('store_button') + origin_widget=bui.get_special_widget('store_button') ), ) ) @@ -869,14 +769,14 @@ class ClassicAppMode(babase.AppMode): from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow ResourceTypeInfoWindow( - 'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter') + 'tickets', origin_widget=bui.get_special_widget('tickets_meter') ) def _root_ui_tokens_meter_press(self) -> None: from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow ResourceTypeInfoWindow( - 'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter') + 'tokens', origin_widget=bui.get_special_widget('tokens_meter') ) def _root_ui_trophy_meter_press(self) -> None: @@ -885,10 +785,10 @@ class ClassicAppMode(babase.AppMode): if not self._ensure_signed_in_v1(): return - self._auxiliary_window_nav( + bui.app.ui_v1.auxiliary_window_activate( win_type=LeagueRankWindow, win_create_call=lambda: LeagueRankWindow( - origin_widget=bauiv1.get_special_widget('trophy_meter') + origin_widget=bui.get_special_widget('trophy_meter') ), ) @@ -896,7 +796,7 @@ class ClassicAppMode(babase.AppMode): from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow ResourceTypeInfoWindow( - 'xp', origin_widget=bauiv1.get_special_widget('level_meter') + 'xp', origin_widget=bui.get_special_widget('level_meter') ) def _root_ui_inventory_press(self) -> None: @@ -905,19 +805,19 @@ class ClassicAppMode(babase.AppMode): if not self._ensure_signed_in_v1(): return - self._auxiliary_window_nav( + bui.app.ui_v1.auxiliary_window_activate( win_type=InventoryWindow, win_create_call=lambda: InventoryWindow( - origin_widget=bauiv1.get_special_widget('inventory_button') + origin_widget=bui.get_special_widget('inventory_button') ), ) def _ensure_signed_in(self) -> bool: """Make sure we're signed in (requiring modern v2 accounts).""" - plus = bauiv1.app.plus + plus = bui.app.plus if plus is None: - bauiv1.screenmessage('This requires plus.', color=(1, 0, 0)) - bauiv1.getsound('error').play() + bui.screenmessage('This requires plus.', color=(1, 0, 0)) + bui.getsound('error').play() return False if plus.accounts.primary is None: show_sign_in_prompt() @@ -926,10 +826,10 @@ class ClassicAppMode(babase.AppMode): def _ensure_signed_in_v1(self) -> bool: """Make sure we're signed in (allowing legacy v1-only accounts).""" - plus = bauiv1.app.plus + plus = bui.app.plus if plus is None: - bauiv1.screenmessage('This requires plus.', color=(1, 0, 0)) - bauiv1.getsound('error').play() + bui.screenmessage('This requires plus.', color=(1, 0, 0)) + bui.getsound('error').play() return False if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() @@ -942,10 +842,10 @@ class ClassicAppMode(babase.AppMode): if not self._ensure_signed_in(): return - self._auxiliary_window_nav( + bui.app.ui_v1.auxiliary_window_activate( win_type=GetTokensWindow, win_create_call=lambda: GetTokensWindow( - origin_widget=bauiv1.get_special_widget('get_tokens_button') + origin_widget=bui.get_special_widget('get_tokens_button') ), ) @@ -980,11 +880,11 @@ class ClassicAppMode(babase.AppMode): raise RuntimeError(f'Invalid index {index}') wait_for_connectivity( - on_connected=lambda: self._auxiliary_window_nav( + on_connected=lambda: bui.app.ui_v1.auxiliary_window_activate( win_type=winclass, win_create_call=lambda: winclass( index=index, - origin_widget=bauiv1.get_special_widget(widgetid), + origin_widget=bui.get_special_widget(widgetid), ), ) ) @@ -1001,24 +901,24 @@ class ClassicAppMode(babase.AppMode): assert 'a' not in vals vals['a'] = self._current_account_id - assert babase.app.classic is not None + assert bui.app.classic is not None assert 'p' not in vals - vals['p'] = list(babase.app.classic.purchases) + vals['p'] = list(bui.app.classic.purchases) - cfg = babase.app.config + cfg = bui.app.config cfg[self._ACCOUNT_STATE_CONFIG_KEY] = vals cfg.commit() def _restore_account_state(self) -> None: # If we've got a stored state for the current account, restore # it. - assert babase.app.classic is not None + assert bui.app.classic is not None if self._current_account_id is None: return - cfg = babase.app.config + cfg = bui.app.config vals = cfg.get(self._ACCOUNT_STATE_CONFIG_KEY) if not isinstance(vals, dict): @@ -1036,12 +936,46 @@ class ClassicAppMode(babase.AppMode): if isinstance(purchases, list): if not all(isinstance(p, str) for p in purchases): - babase.balog.exception('Invalid purchases state on restore.') + bui.balog.exception('Invalid purchases state on restore.') else: self._current_purchases = frozenset(purchases) self._current_purchases_state = self._state_from_purchases( purchases ) - babase.app.classic.purchases = self._current_purchases + bui.app.classic.purchases = self._current_purchases _baclassic.set_account_state(vals) + + @override + def get_dev_console_ui_tab_buttons( + self, + ) -> list[bui.DevConsoleButtonDef]: + return [ + bui.DevConsoleButtonDef( + 'MainWindow Template', + bui.WeakCall(self._main_win_template_press), + ), + bui.DevConsoleButtonDef( + 'CloudUI Test', bui.WeakCall(self._cloud_ui_test_press) + ), + ] + + def _main_win_template_press(self) -> None: + from bauiv1lib.template import show_template_main_window + + # Unintuitively, swish sounds come from buttons, not windows. + # And dev-console buttons don't make sounds. So we need to + # explicitly do so here. + bui.getsound('swish').play() + + show_template_main_window() + + def _cloud_ui_test_press(self) -> None: + from bauiv1 import show_cloud_ui_window + + # Unintuitively, swish sounds come from buttons, not windows. + # And dev-console buttons don't make sounds. So we need to + # explicitly do so here. + bui.getsound('swish').play() + + show_cloud_ui_window() diff --git a/dist/ba_data/python/baclassic/_appsubsystem.py b/dist/ba_data/python/baclassic/_appsubsystem.py index 1752170..9244069 100644 --- a/dist/ba_data/python/baclassic/_appsubsystem.py +++ b/dist/ba_data/python/baclassic/_appsubsystem.py @@ -753,7 +753,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): origin_widget: bauiv1.Widget | None = None, selected_profile: str | None = None, ) -> None: - """(internal)""" + """Pop up a browser window from within a game.""" from bauiv1lib.profile.browser import ProfileBrowserWindow main_window = babase.app.ui_v1.get_main_window() @@ -772,6 +772,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): minimal_toolbar=True, ), is_top_level=True, + back_state=None, suppress_warning=True, ) @@ -830,7 +831,10 @@ class ClassicAppSubsystem(babase.AppSubsystem): transition='scale_in', origin_widget=menu_button ), is_top_level=True, + back_state=None, suppress_warning=True, + # Reset selections to default for consistency. + restore_shared_state=False, ) def save_ui_state(self) -> None: @@ -868,7 +872,10 @@ class ClassicAppSubsystem(babase.AppSubsystem): from bauiv1lib.kiosk import KioskWindow app.ui_v1.set_main_window( - KioskWindow(), is_top_level=True, suppress_warning=True + KioskWindow(), + is_top_level=True, + back_state=None, + suppress_warning=True, ) else: # If there's a saved ui state, restore that. @@ -881,6 +888,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): app.ui_v1.set_main_window( MainMenuWindow(transition=None), is_top_level=True, + back_state=None, suppress_warning=True, ) @@ -895,17 +903,17 @@ class ClassicAppSubsystem(babase.AppSubsystem): @staticmethod def basic_client_ui_button_label_str( - label: bacommon.bs.BasicClientUI.ButtonLabel, + label: bacommon.bs.BasicCloudDialog.ButtonLabel, ) -> babase.Lstr: """Given a client-ui label, return an Lstr.""" import bacommon.bs - cls = bacommon.bs.BasicClientUI.ButtonLabel + cls = bacommon.bs.BasicCloudDialog.ButtonLabel if label is cls.UNKNOWN: # Server should not be sending us unknown stuff; make noise # if they do. logging.error( - 'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.' + 'Got BasicCloudDialog.ButtonLabel.UNKNOWN; should not happen.' ) return babase.Lstr(value='') diff --git a/dist/ba_data/python/bacommon/bs/__init__.py b/dist/ba_data/python/bacommon/bs/__init__.py new file mode 100644 index 0000000..7f26e38 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/__init__.py @@ -0,0 +1,139 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Functionality related to bombsquad classic.""" + +from bacommon.bs._account import ( + ClassicAccountLiveData, +) +from bacommon.bs._bs import ( + TOKENS1_COUNT, + TOKENS2_COUNT, + TOKENS3_COUNT, + TOKENS4_COUNT, +) +from bacommon.bs._chest import ( + ClassicChestAppearance, +) +from bacommon.bs._clienteffect import ( + ClientEffect, + ClientEffectChestWaitTimeAnimation, + ClientEffectDelay, + ClientEffectScreenMessage, + ClientEffectSound, + ClientEffectTicketsAnimation, + ClientEffectTokensAnimation, + ClientEffectTypeID, + ClientEffectUnknown, +) +from bacommon.bs._clouddialog import ( + BasicCloudDialog, + BasicCloudDialogComponent, + BasicCloudDialogBsClassicTourneyResult, + BasicCloudDialogComponentLink, + BasicCloudDialogComponentText, + BasicCloudDialogComponentTypeID, + BasicCloudDialogComponentUnknown, + BasicCloudDialogDisplayItems, + BasicCloudDialogExpireTime, + CloudDialog, + CloudDialogAction, + CloudDialogTypeID, + CloudDialogWrapper, + UnknownCloudDialog, +) +from bacommon.bs._cloudui import CloudUITypeID, CloudUI +from bacommon.bs._displayitem import ( + ChestDisplayItem, + DisplayItem, + DisplayItemTypeID, + DisplayItemWrapper, + TestDisplayItem, + TicketsDisplayItem, + TokensDisplayItem, + UnknownDisplayItem, +) +from bacommon.bs._msg import ( + ChestActionMessage, + ChestActionResponse, + ChestInfoMessage, + ChestInfoResponse, + CloudDialogActionMessage, + CloudDialogActionResponse, + GetClassicPurchasesMessage, + GetClassicPurchasesResponse, + GlobalProfileCheckMessage, + GlobalProfileCheckResponse, + InboxRequestMessage, + InboxRequestResponse, + LegacyRequest, + LegacyResponse, + PrivatePartyMessage, + PrivatePartyResponse, + ScoreSubmitMessage, + ScoreSubmitResponse, + SendInfoMessage, + SendInfoResponse, +) + + +__all__ = [ + 'BasicCloudDialog', + 'BasicCloudDialogComponent', + 'BasicCloudDialogBsClassicTourneyResult', + 'BasicCloudDialogComponentLink', + 'BasicCloudDialogComponentText', + 'BasicCloudDialogComponentTypeID', + 'BasicCloudDialogComponentUnknown', + 'BasicCloudDialogDisplayItems', + 'BasicCloudDialogExpireTime', + 'ChestActionMessage', + 'ChestActionResponse', + 'ChestDisplayItem', + 'ChestInfoMessage', + 'ChestInfoResponse', + 'ClassicAccountLiveData', + 'ClassicChestAppearance', + 'ClientEffect', + 'ClientEffectChestWaitTimeAnimation', + 'ClientEffectDelay', + 'ClientEffectScreenMessage', + 'ClientEffectSound', + 'ClientEffectTicketsAnimation', + 'ClientEffectTokensAnimation', + 'ClientEffectTypeID', + 'ClientEffectUnknown', + 'CloudDialog', + 'CloudDialogAction', + 'CloudDialogActionMessage', + 'CloudDialogActionResponse', + 'CloudDialogTypeID', + 'CloudDialogWrapper', + 'CloudUI', + 'CloudUITypeID', + 'DisplayItem', + 'DisplayItemTypeID', + 'DisplayItemWrapper', + 'GetClassicPurchasesMessage', + 'GetClassicPurchasesResponse', + 'GlobalProfileCheckMessage', + 'GlobalProfileCheckResponse', + 'InboxRequestMessage', + 'InboxRequestResponse', + 'LegacyRequest', + 'LegacyResponse', + 'PrivatePartyMessage', + 'PrivatePartyResponse', + 'ScoreSubmitMessage', + 'ScoreSubmitResponse', + 'SendInfoMessage', + 'SendInfoResponse', + 'TestDisplayItem', + 'TicketsDisplayItem', + 'TOKENS1_COUNT', + 'TOKENS2_COUNT', + 'TOKENS3_COUNT', + 'TOKENS4_COUNT', + 'TokensDisplayItem', + 'UnknownCloudDialog', + 'UnknownDisplayItem', +] diff --git a/dist/ba_data/python/bacommon/bs/_account.py b/dist/ba_data/python/bacommon/bs/_account.py new file mode 100644 index 0000000..da8b9fe --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_account.py @@ -0,0 +1,73 @@ +# Released under the MIT License. See LICENSE for details. +# +"""BombSquad specific bits.""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass +from typing import Annotated + +from efro.dataclassio import ioprepped, IOAttrs +from bacommon.bs._chest import ClassicChestAppearance + + +@ioprepped +@dataclass +class ClassicAccountLiveData: + """Live account data fed to the client in the bs classic app mode.""" + + @dataclass + class Chest: + """A lovely chest.""" + + appearance: Annotated[ + ClassicChestAppearance, + IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), + ] + create_time: Annotated[datetime.datetime, IOAttrs('c')] + unlock_time: Annotated[datetime.datetime, IOAttrs('t')] + unlock_tokens: Annotated[int, IOAttrs('k')] + ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')] + + class LeagueType(Enum): + """Type of league we are in.""" + + BRONZE = 'b' + SILVER = 's' + GOLD = 'g' + DIAMOND = 'd' + + class Flag(Enum): + """Flags set for our account.""" + + ASK_FOR_REVIEW = 'r' + + tickets: Annotated[int, IOAttrs('ti')] + + tokens: Annotated[int, IOAttrs('to')] + gold_pass: Annotated[bool, IOAttrs('g')] + remove_ads: Annotated[bool, IOAttrs('r')] + + achievements: Annotated[int, IOAttrs('a')] + achievements_total: Annotated[int, IOAttrs('at')] + + league_type: Annotated[LeagueType | None, IOAttrs('lt')] + league_num: Annotated[int | None, IOAttrs('ln')] + league_rank: Annotated[int | None, IOAttrs('lr')] + + level: Annotated[int, IOAttrs('lv')] + xp: Annotated[int, IOAttrs('xp')] + xpmax: Annotated[int, IOAttrs('xpm')] + + inbox_count: Annotated[int, IOAttrs('ibc')] + inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')] + inbox_contains_prize: Annotated[bool, IOAttrs('icp')] + + chests: Annotated[dict[str, Chest], IOAttrs('c')] + + # State id of our purchases for builds 22459+. + purchases_state: Annotated[str | None, IOAttrs('p')] + + flags: Annotated[set[Flag], IOAttrs('f', soft_default_factory=set)] diff --git a/dist/ba_data/python/bacommon/bs/_bs.py b/dist/ba_data/python/bacommon/bs/_bs.py new file mode 100644 index 0000000..e6eb11b --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_bs.py @@ -0,0 +1,9 @@ +# Released under the MIT License. See LICENSE for details. +# +"""BombSquad specific bits.""" + +# Token counts for our various packs. +TOKENS1_COUNT = 50 +TOKENS2_COUNT = 500 +TOKENS3_COUNT = 1200 +TOKENS4_COUNT = 2600 diff --git a/dist/ba_data/python/bacommon/bs/_chest.py b/dist/ba_data/python/bacommon/bs/_chest.py new file mode 100644 index 0000000..ba87b94 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_chest.py @@ -0,0 +1,46 @@ +# Released under the MIT License. See LICENSE for details. +# +"""BombSquad specific bits.""" + +from __future__ import annotations + +from enum import Enum +from typing import assert_never + + +class ClassicChestAppearance(Enum): + """Appearances bombsquad classic chests can have.""" + + UNKNOWN = 'u' + DEFAULT = 'd' + L1 = 'l1' + L2 = 'l2' + L3 = 'l3' + L4 = 'l4' + L5 = 'l5' + L6 = 'l6' + + @property + def pretty_name(self) -> str: + """Pretty name for the chest in English.""" + # pylint: disable=too-many-return-statements + cls = type(self) + + if self is cls.UNKNOWN: + return 'Unknown Chest' + if self is cls.DEFAULT: + return 'Chest' + if self is cls.L1: + return 'L1 Chest' + if self is cls.L2: + return 'L2 Chest' + if self is cls.L3: + return 'L3 Chest' + if self is cls.L4: + return 'L4 Chest' + if self is cls.L5: + return 'L5 Chest' + if self is cls.L6: + return 'L6 Chest' + + assert_never(self) diff --git a/dist/ba_data/python/bacommon/bs/_clienteffect.py b/dist/ba_data/python/bacommon/bs/_clienteffect.py new file mode 100644 index 0000000..c7f7f70 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_clienteffect.py @@ -0,0 +1,180 @@ +# Released under the MIT License. See LICENSE for details. +# +"""ClientEffect related functionality.""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + + +class ClientEffectTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + SCREEN_MESSAGE = 'm' + SOUND = 's' + DELAY = 'd' + CHEST_WAIT_TIME_ANIMATION = 't' + TICKETS_ANIMATION = 'ta' + TOKENS_ANIMATION = 'toa' + + +class ClientEffect(IOMultiType[ClientEffectTypeID]): + """Something that can happen on the client. + + This can include screen messages, sounds, visual effects, etc. + """ + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + # 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: ClientEffectTypeID) -> type[ClientEffect]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + # pylint: disable=too-many-return-statements + + t = ClientEffectTypeID + if type_id is t.UNKNOWN: + return ClientEffectUnknown + if type_id is t.SCREEN_MESSAGE: + return ClientEffectScreenMessage + if type_id is t.SOUND: + return ClientEffectSound + if type_id is t.DELAY: + return ClientEffectDelay + if type_id is t.CHEST_WAIT_TIME_ANIMATION: + return ClientEffectChestWaitTimeAnimation + if type_id is t.TICKETS_ANIMATION: + return ClientEffectTicketsAnimation + if type_id is t.TOKENS_ANIMATION: + return ClientEffectTokensAnimation + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> ClientEffect: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return ClientEffectUnknown() + + +@ioprepped +@dataclass +class ClientEffectUnknown(ClientEffect): + """Fallback substitute for types we don't recognize.""" + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.UNKNOWN + + +@ioprepped +@dataclass +class ClientEffectScreenMessage(ClientEffect): + """Display a screen-message.""" + + message: Annotated[str, IOAttrs('m')] + subs: Annotated[list[str], IOAttrs('s')] = field(default_factory=list) + color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0) + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.SCREEN_MESSAGE + + +@ioprepped +@dataclass +class ClientEffectSound(ClientEffect): + """Play a sound.""" + + class Sound(Enum): + """Sounds that can be made alongside the message.""" + + UNKNOWN = 'u' + CASH_REGISTER = 'c' + ERROR = 'e' + POWER_DOWN = 'p' + GUN_COCKING = 'g' + + sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)] + volume: Annotated[float, IOAttrs('v')] = 1.0 + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.SOUND + + +@ioprepped +@dataclass +class ClientEffectChestWaitTimeAnimation(ClientEffect): + """Animate chest wait time changing.""" + + chestid: Annotated[str, IOAttrs('c')] + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[datetime.datetime, IOAttrs('o')] + endvalue: Annotated[datetime.datetime, IOAttrs('n')] + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION + + +@ioprepped +@dataclass +class ClientEffectTicketsAnimation(ClientEffect): + """Animate tickets count.""" + + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[int, IOAttrs('s')] + endvalue: Annotated[int, IOAttrs('e')] + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.TICKETS_ANIMATION + + +@ioprepped +@dataclass +class ClientEffectTokensAnimation(ClientEffect): + """Animate tokens count.""" + + duration: Annotated[float, IOAttrs('u')] + startvalue: Annotated[int, IOAttrs('s')] + endvalue: Annotated[int, IOAttrs('e')] + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.TOKENS_ANIMATION + + +@ioprepped +@dataclass +class ClientEffectDelay(ClientEffect): + """Delay effect processing.""" + + seconds: Annotated[float, IOAttrs('s')] + + @override + @classmethod + def get_type_id(cls) -> ClientEffectTypeID: + return ClientEffectTypeID.DELAY diff --git a/dist/ba_data/python/bacommon/bs/_clouddialog.py b/dist/ba_data/python/bacommon/bs/_clouddialog.py new file mode 100644 index 0000000..4b4288f --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_clouddialog.py @@ -0,0 +1,308 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Simple cloud-defined UIs for things like notifications.""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + +from bacommon.bs._displayitem import DisplayItemWrapper + + +class CloudDialogTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + BASIC = 'b' + + +class CloudDialog(IOMultiType[CloudDialogTypeID]): + """Small self-contained ui bit provided by the cloud. + + These take care of updating and/or dismissing themselves based on + user input. Useful for things such as inbox messages. For more + complex UI construction, look at :class:`CloudUI`. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + # 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: CloudDialogTypeID) -> type[CloudDialog]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + out: type[CloudDialog] + + t = CloudDialogTypeID + if type_id is t.UNKNOWN: + out = UnknownCloudDialog + elif type_id is t.BASIC: + out = BasicCloudDialog + else: + # Important to make sure we provide all types. + assert_never(type_id) + return out + + @override + @classmethod + def get_unknown_type_fallback(cls) -> CloudDialog: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return UnknownCloudDialog() + + +@ioprepped +@dataclass +class UnknownCloudDialog(CloudDialog): + """Fallback type for unrecognized entries.""" + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + return CloudDialogTypeID.UNKNOWN + + +class BasicCloudDialogComponentTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + TEXT = 't' + LINK = 'l' + BS_CLASSIC_TOURNEY_RESULT = 'ct' + DISPLAY_ITEMS = 'di' + EXPIRE_TIME = 'd' + + +class BasicCloudDialogComponent(IOMultiType[BasicCloudDialogComponentTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + # 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: BasicCloudDialogComponentTypeID + ) -> type[BasicCloudDialogComponent]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = BasicCloudDialogComponentTypeID + if type_id is t.UNKNOWN: + return BasicCloudDialogComponentUnknown + if type_id is t.TEXT: + return BasicCloudDialogComponentText + if type_id is t.LINK: + return BasicCloudDialogComponentLink + if type_id is t.BS_CLASSIC_TOURNEY_RESULT: + return BasicCloudDialogBsClassicTourneyResult + if type_id is t.DISPLAY_ITEMS: + return BasicCloudDialogDisplayItems + if type_id is t.EXPIRE_TIME: + return BasicCloudDialogExpireTime + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> BasicCloudDialogComponent: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return BasicCloudDialogComponentUnknown() + + +@ioprepped +@dataclass +class BasicCloudDialogComponentUnknown(BasicCloudDialogComponent): + """An unknown basic client component type. + + In practice these should never show up since the master-server + generates these on the fly for the client and so should not send + clients one they can't digest. + """ + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.UNKNOWN + + +@ioprepped +@dataclass +class BasicCloudDialogComponentText(BasicCloudDialogComponent): + """Show some text in the inbox message.""" + + text: Annotated[str, IOAttrs('t')] + subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( + default_factory=list + ) + scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 + color: Annotated[ + tuple[float, float, float, float], IOAttrs('c', store_default=False) + ] = (1.0, 1.0, 1.0, 1.0) + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.TEXT + + +@ioprepped +@dataclass +class BasicCloudDialogComponentLink(BasicCloudDialogComponent): + """Show a link in the inbox message.""" + + url: Annotated[str, IOAttrs('u')] + label: Annotated[str, IOAttrs('l')] + subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( + default_factory=list + ) + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.LINK + + +@ioprepped +@dataclass +class BasicCloudDialogBsClassicTourneyResult(BasicCloudDialogComponent): + """Show info about a classic tourney.""" + + tournament_id: Annotated[str, IOAttrs('t')] + game: Annotated[str, IOAttrs('g')] + players: Annotated[int, IOAttrs('p')] + rank: Annotated[int, IOAttrs('r')] + trophy: Annotated[str | None, IOAttrs('tr')] + prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')] + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.BS_CLASSIC_TOURNEY_RESULT + + +@ioprepped +@dataclass +class BasicCloudDialogDisplayItems(BasicCloudDialogComponent): + """Show some display-items.""" + + items: Annotated[list[DisplayItemWrapper], IOAttrs('d')] + width: Annotated[float, IOAttrs('w')] = 100.0 + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.DISPLAY_ITEMS + + +@ioprepped +@dataclass +class BasicCloudDialogExpireTime(BasicCloudDialogComponent): + """Show expire-time.""" + + time: Annotated[datetime.datetime, IOAttrs('d')] + spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 + spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0 + + @override + @classmethod + def get_type_id(cls) -> BasicCloudDialogComponentTypeID: + return BasicCloudDialogComponentTypeID.EXPIRE_TIME + + +@ioprepped +@dataclass +class BasicCloudDialog(CloudDialog): + """A basic UI for the client.""" + + class ButtonLabel(Enum): + """Distinct button labels we support.""" + + UNKNOWN = 'u' + OK = 'o' + APPLY = 'a' + CANCEL = 'c' + ACCEPT = 'ac' + DECLINE = 'dn' + IGNORE = 'ig' + CLAIM = 'cl' + DISCARD = 'd' + + class InteractionStyle(Enum): + """Overall interaction styles we support.""" + + UNKNOWN = 'u' + BUTTON_POSITIVE = 'p' + BUTTON_POSITIVE_NEGATIVE = 'pn' + + components: Annotated[list[BasicCloudDialogComponent], IOAttrs('s')] + + interaction_style: Annotated[ + InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN) + ] = InteractionStyle.BUTTON_POSITIVE + + button_label_positive: Annotated[ + ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN) + ] = ButtonLabel.OK + + button_label_negative: Annotated[ + ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN) + ] = ButtonLabel.CANCEL + + @override + @classmethod + def get_type_id(cls) -> CloudDialogTypeID: + return CloudDialogTypeID.BASIC + + def contains_unknown_elements(self) -> bool: + """Whether something within us is an unknown type or enum.""" + return ( + self.interaction_style is self.InteractionStyle.UNKNOWN + or self.button_label_positive is self.ButtonLabel.UNKNOWN + or self.button_label_negative is self.ButtonLabel.UNKNOWN + or any( + c.get_type_id() is BasicCloudDialogComponentTypeID.UNKNOWN + for c in self.components + ) + ) + + +@ioprepped +@dataclass +class CloudDialogWrapper: + """Wrapper for a CloudDialog and its common data.""" + + id: Annotated[str, IOAttrs('i')] + createtime: Annotated[datetime.datetime, IOAttrs('c')] + ui: Annotated[CloudDialog, IOAttrs('e')] + + +class CloudDialogAction(Enum): + """Types of actions we can run.""" + + BUTTON_PRESS_POSITIVE = 'p' + BUTTON_PRESS_NEGATIVE = 'n' diff --git a/dist/ba_data/python/bacommon/bs/_cloudui.py b/dist/ba_data/python/bacommon/bs/_cloudui.py new file mode 100644 index 0000000..1b4659c --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_cloudui.py @@ -0,0 +1,180 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Full UIs defined in the cloud - similar to a basic form of html""" + +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass +from typing import Annotated, override, assert_never + +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + + +class CloudUITypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + V1 = 'v1' + + +class CloudUI(IOMultiType[CloudUITypeID]): + """UI defined by the cloud. + + Conceptually similar to a basic html page, except using app UI. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudUITypeID: + # 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: CloudUITypeID) -> type[CloudUI]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + out: type[CloudUI] + + t = CloudUITypeID + if type_id is t.UNKNOWN: + out = UnknownCloudUI + elif type_id is t.V1: + out = V1CloudUI + else: + # Important to make sure we provide all types. + assert_never(type_id) + return out + + @override + @classmethod + def get_unknown_type_fallback(cls) -> CloudUI: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return UnknownCloudUI() + + +@ioprepped +@dataclass +class UnknownCloudUI(CloudUI): + """Fallback type for unrecognized UI types. + + Will show the client a 'cannot display this UI' placeholder page. + """ + + @override + @classmethod + def get_type_id(cls) -> CloudUITypeID: + return CloudUITypeID.UNKNOWN + + +class V1CloudUIComponentTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + TEXT = 't' + + +class V1CloudUIComponent(IOMultiType[V1CloudUIComponentTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> V1CloudUIComponentTypeID: + # 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: V1CloudUIComponentTypeID + ) -> type[V1CloudUIComponent]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = V1CloudUIComponentTypeID + if type_id is t.UNKNOWN: + return V1CloudUIComponentUnknown + if type_id is t.TEXT: + return V1CloudUIComponentText + + # Important to make sure we provide all types. + assert_never(type_id) + + @override + @classmethod + def get_unknown_type_fallback(cls) -> V1CloudUIComponent: + # If we encounter some future message type we don't know + # anything about, drop in a placeholder. + return V1CloudUIComponentUnknown() + + +@ioprepped +@dataclass +class V1CloudUIComponentUnknown(V1CloudUIComponent): + """An unknown basic client component type. + + In practice these should never show up since the master-server + generates these on the fly for the client and so should not send + clients one they can't digest. + """ + + @override + @classmethod + def get_type_id(cls) -> V1CloudUIComponentTypeID: + return V1CloudUIComponentTypeID.UNKNOWN + + +@ioprepped +@dataclass +class V1CloudUIComponentText(V1CloudUIComponent): + """Show some text over a button.""" + + text: Annotated[str, IOAttrs('t')] + # position: Annotated[float, IOAttrs('p', store_default=False)] = 1.0 + # scale: Annotated[float, IOAttrs('s', store_default=False)] = 1.0 + # color: Annotated[ + # tuple[float, float, float, float], IOAttrs('c', store_default=False) + # ] = (1.0, 1.0, 1.0, 1.0) + + @override + @classmethod + def get_type_id(cls) -> V1CloudUIComponentTypeID: + return V1CloudUIComponentTypeID.TEXT + + +@ioprepped +@dataclass +class V1CloudUIButton: + """A button in our cloud ui.""" + + color: Annotated[tuple[float, float, float], IOAttrs('cl')] + size: Annotated[tuple[float, float], IOAttrs('sz')] + components: Annotated[list[V1CloudUIComponent], IOAttrs('c')] + scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 + + +@ioprepped +@dataclass +class V1CloudUIRow: + """A row in our cloud ui.""" + + buttons: Annotated[list[V1CloudUIButton], IOAttrs('b')] + + +@ioprepped +@dataclass +class V1CloudUI(CloudUI): + """Version 1 of our cloud-defined UI type.""" + + rows: Annotated[list[V1CloudUIRow], IOAttrs('r')] + + @override + @classmethod + def get_type_id(cls) -> CloudUITypeID: + return CloudUITypeID.V1 diff --git a/dist/ba_data/python/bacommon/bs/_displayitem.py b/dist/ba_data/python/bacommon/bs/_displayitem.py new file mode 100644 index 0000000..0593e31 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_displayitem.py @@ -0,0 +1,182 @@ +# Released under the MIT License. See LICENSE for details. +# +"""DisplayItem related functionality.""" + +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass +from typing import Annotated, override, assert_never + +from efro.util import pairs_to_flat +from efro.dataclassio import ioprepped, IOAttrs, IOMultiType + +from bacommon.bs._chest import ClassicChestAppearance + + +class DisplayItemTypeID(Enum): + """Type ID for each of our subclasses.""" + + UNKNOWN = 'u' + TICKETS = 't' + TOKENS = 'k' + TEST = 's' + CHEST = 'c' + + +class DisplayItem(IOMultiType[DisplayItemTypeID]): + """Some amount of something that can be shown or described. + + Used to depict chest contents, inventory, rewards, etc. + """ + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + # 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: DisplayItemTypeID) -> type[DisplayItem]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = DisplayItemTypeID + if type_id is t.UNKNOWN: + return UnknownDisplayItem + if type_id is t.TICKETS: + return TicketsDisplayItem + if type_id is t.TOKENS: + return TokensDisplayItem + if type_id is t.TEST: + return TestDisplayItem + if type_id is t.CHEST: + return ChestDisplayItem + + # Important to make sure we provide all types. + assert_never(type_id) + + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + """Return a string description and subs for the item. + + These decriptions are baked into the DisplayItemWrapper and + should be accessed from there when available. This allows + clients to give descriptions even for newer display items they + don't recognize. + """ + raise NotImplementedError() + + # Implement fallbacks so client can digest item lists even if they + # contain unrecognized stuff. DisplayItemWrapper contains basic + # baked down info that they can still use in such cases. + @override + @classmethod + def get_unknown_type_fallback(cls) -> DisplayItem: + return UnknownDisplayItem() + + +@ioprepped +@dataclass +class UnknownDisplayItem(DisplayItem): + """Something we don't know how to display.""" + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + return DisplayItemTypeID.UNKNOWN + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + import logging + + # Make noise but don't break. + logging.exception( + 'UnknownDisplayItem.get_description() should never be called.' + ' Always access descriptions on the DisplayItemWrapper.' + ) + return 'Unknown', [] + + +@ioprepped +@dataclass +class TicketsDisplayItem(DisplayItem): + """Some amount of tickets.""" + + count: Annotated[int, IOAttrs('c')] + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + return DisplayItemTypeID.TICKETS + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return '${C} Tickets', [('${C}', str(self.count))] + + +@ioprepped +@dataclass +class TokensDisplayItem(DisplayItem): + """Some amount of tokens.""" + + count: Annotated[int, IOAttrs('c')] + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + return DisplayItemTypeID.TOKENS + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return '${C} Tokens', [('${C}', str(self.count))] + + +@ioprepped +@dataclass +class TestDisplayItem(DisplayItem): + """Fills usable space for a display-item - good for calibration.""" + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + return DisplayItemTypeID.TEST + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return 'Test Display Item Here', [] + + +@ioprepped +@dataclass +class ChestDisplayItem(DisplayItem): + """Display a chest.""" + + appearance: Annotated[ClassicChestAppearance, IOAttrs('a')] + + @override + @classmethod + def get_type_id(cls) -> DisplayItemTypeID: + return DisplayItemTypeID.CHEST + + @override + def get_description(self) -> tuple[str, list[tuple[str, str]]]: + return self.appearance.pretty_name, [] + + +@ioprepped +@dataclass +class DisplayItemWrapper: + """Wraps a DisplayItem and common info.""" + + item: Annotated[DisplayItem, IOAttrs('i')] + description: Annotated[str, IOAttrs('d')] + description_subs: Annotated[list[str] | None, IOAttrs('s')] + + @classmethod + def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper: + """Convenience method to wrap a DisplayItem.""" + desc, subs = item.get_description() + return DisplayItemWrapper(item, desc, pairs_to_flat(subs)) diff --git a/dist/ba_data/python/bacommon/bs/_msg.py b/dist/ba_data/python/bacommon/bs/_msg.py new file mode 100644 index 0000000..77e9ff5 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs/_msg.py @@ -0,0 +1,336 @@ +# Released under the MIT License. See LICENSE for details. +# +"""BombSquad specific bits.""" + +from __future__ import annotations + +import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Annotated, override + +from efro.dataclassio import ioprepped, IOAttrs +from efro.message import Message, Response + +from bacommon.bs._displayitem import DisplayItemWrapper +from bacommon.bs._clienteffect import ClientEffect +from bacommon.bs._clouddialog import CloudDialogAction, CloudDialogWrapper +from bacommon.bs._chest import ClassicChestAppearance + + +@ioprepped +@dataclass +class ChestActionMessage(Message): + """Request action about a chest.""" + + class Action(Enum): + """Types of actions we can request.""" + + # Unlocking (for free or with tokens). + UNLOCK = 'u' + + # Watched an ad to reduce wait. + AD = 'ad' + + action: Annotated[Action, IOAttrs('a')] + + # Tokens we are paying (only applies to unlock). + token_payment: Annotated[int, IOAttrs('t')] + + chest_id: Annotated[str, IOAttrs('i')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [ChestActionResponse] + + +@ioprepped +@dataclass +class ChestActionResponse(Response): + """Here's the results of that action you asked for, boss.""" + + # Tokens that were actually charged. + tokens_charged: Annotated[int, IOAttrs('t')] = 0 + + # If present, signifies the chest has been opened and we should show + # the user this stuff that was in it. + contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None + + # If contents are present, which of the chest's prize-sets they + # represent. + prizeindex: Annotated[int, IOAttrs('i')] = 0 + + # Printable error if something goes wrong. + error: Annotated[str | None, IOAttrs('e')] = None + + # Printable warning. Shown in orange with an error sound. Does not + # mean the action failed; only that there's something to tell the + # users such as 'It looks like you are faking ad views; stop it or + # you won't have ad options anymore.' + warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None + + # Printable success message. Shown in green with a cash-register + # sound. Can be used for things like successful wait reductions via + # ad views. Used in builds earlier than 22311; can remove once + # 22311+ is ubiquitous. + success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None + + # Effects to show on the client. Replaces warning and success_msg in + # build 22311 or newer. + effects: Annotated[ + list[ClientEffect], IOAttrs('fx', store_default=False) + ] = field(default_factory=list) + + +@ioprepped +@dataclass +class CloudDialogActionMessage(Message): + """Do something to a client ui.""" + + id: Annotated[str, IOAttrs('i')] + action: Annotated[CloudDialogAction, IOAttrs('a')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [CloudDialogActionResponse] + + +@ioprepped +@dataclass +class CloudDialogActionResponse(Response): + """Did something to that inbox entry, boss.""" + + class ErrorType(Enum): + """Types of errors that may have occurred.""" + + # Probably a future error type we don't recognize. + UNKNOWN = 'u' + + # Something went wrong on the server, but specifics are not + # relevant. + INTERNAL = 'i' + + # The entry expired on the server. In various cases such as 'ok' + # buttons this can generally be ignored. + EXPIRED = 'e' + + error_type: Annotated[ + ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN) + ] + + # User facing error message in the case of errors. + error_message: Annotated[str | None, IOAttrs('em')] + + effects: Annotated[list[ClientEffect], IOAttrs('fx')] + + +@ioprepped +@dataclass +class GetClassicPurchasesMessage(Message): + """Asking for current account's classic purchases.""" + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [GetClassicPurchasesResponse] + + +@ioprepped +@dataclass +class GetClassicPurchasesResponse(Response): + """Here's those classic purchases ya asked for boss.""" + + purchases: Annotated[set[str], IOAttrs('p')] + + +@ioprepped +@dataclass +class GlobalProfileCheckMessage(Message): + """Is this global profile name available?""" + + name: Annotated[str, IOAttrs('n')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [GlobalProfileCheckResponse] + + +@ioprepped +@dataclass +class GlobalProfileCheckResponse(Response): + """Here's that profile check ya asked for boss.""" + + available: Annotated[bool, IOAttrs('a')] + ticket_cost: Annotated[int, IOAttrs('tc')] + + +@ioprepped +@dataclass +class InboxRequestMessage(Message): + """Message requesting our inbox.""" + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [InboxRequestResponse] + + +@ioprepped +@dataclass +class InboxRequestResponse(Response): + """Here's that inbox contents you asked for, boss.""" + + wrappers: Annotated[list[CloudDialogWrapper], IOAttrs('w')] + + # Printable error if something goes wrong. + error: Annotated[str | None, IOAttrs('e')] = None + + +@ioprepped +@dataclass +class LegacyRequest(Message): + """A generic request for the legacy master server.""" + + request: Annotated[str, IOAttrs('r')] + request_type: Annotated[str, IOAttrs('t')] + user_agent_string: Annotated[str, IOAttrs('u')] + data: Annotated[str, IOAttrs('d')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [LegacyResponse] + + +@ioprepped +@dataclass +class LegacyResponse(Response): + """Response for generic legacy request.""" + + data: Annotated[str | None, IOAttrs('d')] + zipped: Annotated[bool, IOAttrs('z')] + + +@ioprepped +@dataclass +class ChestInfoMessage(Message): + """Request info about a chest.""" + + chest_id: Annotated[str, IOAttrs('i')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [ChestInfoResponse] + + +@ioprepped +@dataclass +class ChestInfoResponse(Response): + """Here's that chest info you asked for, boss.""" + + @dataclass + class Chest: + """A lovely chest.""" + + @dataclass + class PrizeSet: + """A possible set of prizes for this chest.""" + + weight: Annotated[float, IOAttrs('w')] + contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')] + + appearance: Annotated[ + ClassicChestAppearance, + IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), + ] + + # How much it costs to unlock *now*. + unlock_tokens: Annotated[int, IOAttrs('tk')] + + # When it unlocks on its own. + unlock_time: Annotated[datetime.datetime, IOAttrs('t')] + + # Possible prizes we contain. + prizesets: Annotated[list[PrizeSet], IOAttrs('p')] + + # Are ads allowed now? + ad_allow: Annotated[bool, IOAttrs('aa')] + + chest: Annotated[Chest | None, IOAttrs('c')] + user_tokens: Annotated[int | None, IOAttrs('t')] + + +@ioprepped +@dataclass +class PrivatePartyMessage(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 [PrivatePartyResponse] + + +@ioprepped +@dataclass +class PrivatePartyResponse(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')] + + +@ioprepped +@dataclass +class ScoreSubmitMessage(Message): + """Let the server know we got some score in something.""" + + score_token: Annotated[str, IOAttrs('t')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [ScoreSubmitResponse] + + +@ioprepped +@dataclass +class ScoreSubmitResponse(Response): + """Did something to that inbox entry, boss.""" + + # Things we should show on our end. + effects: Annotated[list[ClientEffect], IOAttrs('fx')] + + +@ioprepped +@dataclass +class SendInfoMessage(Message): + """User is using the send-info function.""" + + description: Annotated[str, IOAttrs('c')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [SendInfoResponse] + + +@ioprepped +@dataclass +class SendInfoResponse(Response): + """Response to sending info to the server.""" + + handled: Annotated[bool, IOAttrs('v')] + message: Annotated[str | None, IOAttrs('m', store_default=False)] = None + effects: Annotated[ + list[ClientEffect], IOAttrs('e', store_default=False) + ] = field(default_factory=list) + legacy_code: Annotated[str | None, IOAttrs('l', store_default=False)] = None diff --git a/dist/ba_data/python/bacommon/logging.py b/dist/ba_data/python/bacommon/logging.py index 281d907..2b22fb0 100644 --- a/dist/ba_data/python/bacommon/logging.py +++ b/dist/ba_data/python/bacommon/logging.py @@ -41,6 +41,7 @@ class ClientLoggerName(Enum): ACCOUNT_CLIENT_V2 = 'ba.accountclientv2' ACCOUNT = 'ba.account' LOGIN_ADAPTER = 'ba.loginadapter' + UI = 'ba.ui' @property def description(self) -> str: @@ -86,6 +87,8 @@ class ClientLoggerName(Enum): return 'account functionality' if self is cls.LOGIN_ADAPTER: return 'support for particular login types' + if self is cls.UI: + return 'anything user-interface related' assert_never(self) diff --git a/dist/ba_data/python/baenv.py b/dist/ba_data/python/baenv.py index 0f7824b..84f0471 100644 --- a/dist/ba_data/python/baenv.py +++ b/dist/ba_data/python/baenv.py @@ -56,8 +56,8 @@ logger = logging.getLogger('ba.env') # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22535 -TARGET_BALLISTICA_VERSION = '1.7.51' +TARGET_BALLISTICA_BUILD = 22584 +TARGET_BALLISTICA_VERSION = '1.7.53' @dataclass diff --git a/dist/ba_data/python/baplus/_cloud.py b/dist/ba_data/python/baplus/_cloud.py index 96d1dd7..f1f4097 100644 --- a/dist/ba_data/python/baplus/_cloud.py +++ b/dist/ba_data/python/baplus/_cloud.py @@ -250,9 +250,9 @@ class CloudSubsystem(babase.AppSubsystem): @overload def send_message_cb( self, - msg: bacommon.bs.ClientUIActionMessage, + msg: bacommon.bs.CloudDialogActionMessage, on_response: Callable[ - [bacommon.bs.ClientUIActionResponse | Exception], None + [bacommon.bs.CloudDialogActionResponse | Exception], None ], ) -> None: ... diff --git a/dist/ba_data/python/bascenev1/_coopsession.py b/dist/ba_data/python/bascenev1/_coopsession.py index 8505d89..c8b176b 100644 --- a/dist/ba_data/python/bascenev1/_coopsession.py +++ b/dist/ba_data/python/bascenev1/_coopsession.py @@ -260,7 +260,6 @@ class CoopSession(Session): with activity.context: activity.end(results={'outcome': 'restart'}, force=True) - # noinspection PyUnresolvedReferences @override def on_activity_end( self, activity: bascenev1.Activity, results: Any diff --git a/dist/ba_data/python/bascenev1/_gameactivity.py b/dist/ba_data/python/bascenev1/_gameactivity.py index dcff28b..698fec2 100644 --- a/dist/ba_data/python/bascenev1/_gameactivity.py +++ b/dist/ba_data/python/bascenev1/_gameactivity.py @@ -258,7 +258,6 @@ class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team]( """Return a name for this particular game instance.""" return self.get_display_string(self.settings_raw) - # noinspection PyUnresolvedReferences def get_instance_scoreboard_display_string(self) -> babase.Lstr: """Return a name for this particular game instance. diff --git a/dist/ba_data/python/bascenev1/_gameutils.py b/dist/ba_data/python/bascenev1/_gameutils.py index 3f3d6e6..1662e05 100644 --- a/dist/ba_data/python/bascenev1/_gameutils.py +++ b/dist/ba_data/python/bascenev1/_gameutils.py @@ -83,7 +83,6 @@ def animate( # FIXME: Even if we are looping we should have a way to die once we # get disconnected. if not loop: - # noinspection PyUnresolvedReferences _bascenev1.timer( (int(mult * items[-1][0]) + 1000) / 1000.0, curve.delete ) @@ -146,8 +145,6 @@ def animate_array( # If we're not looping, set a timer to kill this # curve after its done its job. if not loop: - # (PyCharm seems to think item is a float, not a tuple) - # noinspection PyUnresolvedReferences _bascenev1.timer( (int(mult * items[-1][0]) + 1000) / 1000.0, curve.delete, @@ -159,8 +156,6 @@ def animate_array( # FIXME: Even if we are looping we should have a way to die # once we get disconnected. if not loop: - # (PyCharm seems to think item is a float, not a tuple) - # noinspection PyUnresolvedReferences _bascenev1.timer( (int(mult * items[-1][0]) + 1000) / 1000.0, combine.delete ) diff --git a/dist/ba_data/python/bascenev1/_lobby.py b/dist/ba_data/python/bascenev1/_lobby.py index 55a6863..99f36c0 100644 --- a/dist/ba_data/python/bascenev1/_lobby.py +++ b/dist/ba_data/python/bascenev1/_lobby.py @@ -502,7 +502,6 @@ class Chooser: self._profileindex = self._profilenames.index(self._profilename) else: self._profileindex = 0 - # noinspection PyUnresolvedReferences self._profilename = self._profilenames[self._profileindex] def update_position(self) -> None: diff --git a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py index b398f7d..0bf4bd3 100644 --- a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py @@ -101,7 +101,6 @@ class MultiTeamScoreScreenActivity(bs.ScoreScreenActivity): player_records = [] valid_players = list(self.stats.get_records().items()) - # noinspection PyUnresolvedReferences def _get_player_score_set_entry( player: bs.SessionPlayer, ) -> bs.PlayerRecord | None: diff --git a/dist/ba_data/python/bascenev1lib/game/race.py b/dist/ba_data/python/bascenev1lib/game/race.py index 7ebbe93..41ff4f6 100644 --- a/dist/ba_data/python/bascenev1lib/game/race.py +++ b/dist/ba_data/python/bascenev1lib/game/race.py @@ -700,7 +700,6 @@ class RaceGame(bs.TeamGameActivity[Player, Team]): # FIXME: This is not type-safe! # This call is expected to always return an Actor! # Perhaps we need something like can_spawn_player()... - # noinspection PyTypeChecker return None # type: ignore pos = self._regions[player.last_region].pos diff --git a/dist/ba_data/python/bascenev1lib/game/runaround.py b/dist/ba_data/python/bascenev1lib/game/runaround.py index c8f0d70..ddb14d6 100644 --- a/dist/ba_data/python/bascenev1lib/game/runaround.py +++ b/dist/ba_data/python/bascenev1lib/game/runaround.py @@ -72,7 +72,6 @@ class Point(Enum): class Spawn: """Defines a bot spawn event.""" - # noinspection PyUnresolvedReferences type: type[SpazBot] path: int = 0 point: Point | None = None diff --git a/dist/ba_data/python/bascenev1lib/mapdata/big_g.py b/dist/ba_data/python/bascenev1lib/mapdata/big_g.py index 8bc8272..1390a40 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/big_g.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/big_g.py @@ -4,7 +4,6 @@ # This file was automatically generated from "big_g.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-0.4011866709, 2.331310176, -0.5426286416) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/bridgit.py b/dist/ba_data/python/bascenev1lib/mapdata/bridgit.py index d6e8994..96733b7 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/bridgit.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/bridgit.py @@ -4,7 +4,6 @@ # This file was automatically generated from "bridgit.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-0.2457963347, 3.828181068, -1.528362695) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/courtyard.py b/dist/ba_data/python/bascenev1lib/mapdata/courtyard.py index 9220e05..82d0ea2 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/courtyard.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/courtyard.py @@ -4,7 +4,6 @@ # This file was automatically generated from "courtyard.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.3544110667, 3.958431362, -2.175025358) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/crag_castle.py b/dist/ba_data/python/bascenev1lib/mapdata/crag_castle.py index 587002f..3916c77 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/crag_castle.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/crag_castle.py @@ -4,7 +4,6 @@ # This file was automatically generated from "crag_castle.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.7033834902, 6.55869393, -3.153439808) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/doom_shroom.py b/dist/ba_data/python/bascenev1lib/mapdata/doom_shroom.py index a6a5d49..a759cbd 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/doom_shroom.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/doom_shroom.py @@ -4,7 +4,6 @@ # This file was automatically generated from "doom_shroom.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.4687647786, 2.320345088, -3.219423694) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/football_stadium.py b/dist/ba_data/python/bascenev1lib/mapdata/football_stadium.py index 6e490c4..75a2386 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/football_stadium.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/football_stadium.py @@ -4,7 +4,6 @@ # This file was automatically generated from "football_stadium.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.0, 1.185751251, 0.4326226188) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/happy_thoughts.py b/dist/ba_data/python/bascenev1lib/mapdata/happy_thoughts.py index 164bdb2..fd51949 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/happy_thoughts.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/happy_thoughts.py @@ -4,7 +4,6 @@ # This file was automatically generated from "happy_thoughts.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-1.045859963, 12.67722855, -5.401537075) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/hockey_stadium.py b/dist/ba_data/python/bascenev1lib/mapdata/hockey_stadium.py index 9f00e49..8e0500c 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/hockey_stadium.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/hockey_stadium.py @@ -4,7 +4,6 @@ # This file was automatically generated from "hockey_stadium.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.0, 0.7956858119, 0.0) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/lake_frigid.py b/dist/ba_data/python/bascenev1lib/mapdata/lake_frigid.py index 4f5fbe7..26cd223 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/lake_frigid.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/lake_frigid.py @@ -4,7 +4,6 @@ # This file was automatically generated from "lake_frigid.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.622753268, 3.958431362, -2.48708008) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/monkey_face.py b/dist/ba_data/python/bascenev1lib/mapdata/monkey_face.py index 274f58e..d28a5e0 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/monkey_face.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/monkey_face.py @@ -4,7 +4,6 @@ # This file was automatically generated from "monkey_face.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-1.657177611, 4.132574186, -1.580485661) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/rampage.py b/dist/ba_data/python/bascenev1lib/mapdata/rampage.py index f1e96a2..2e6a356 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/rampage.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/rampage.py @@ -4,7 +4,6 @@ # This file was automatically generated from "rampage.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.3544110667, 5.616383286, -4.066055072) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/roundabout.py b/dist/ba_data/python/bascenev1lib/mapdata/roundabout.py index 084cb70..2034dcd 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/roundabout.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/roundabout.py @@ -4,7 +4,6 @@ # This file was automatically generated from "roundabout.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-1.552280404, 3.189001207, -2.40908495) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/step_right_up.py b/dist/ba_data/python/bascenev1lib/mapdata/step_right_up.py index b214840..5a64362 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/step_right_up.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/step_right_up.py @@ -4,7 +4,6 @@ # This file was automatically generated from "step_right_up.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.3544110667, 6.07676405, -2.271833016) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/the_pad.py b/dist/ba_data/python/bascenev1lib/mapdata/the_pad.py index cfdc835..3560aaa 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/the_pad.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/the_pad.py @@ -4,7 +4,6 @@ # This file was automatically generated from "the_pad.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.3544110667, 4.493562578, -2.518391331) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/tip_top.py b/dist/ba_data/python/bascenev1lib/mapdata/tip_top.py index 19c9326..0be6e60 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/tip_top.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/tip_top.py @@ -4,7 +4,6 @@ # This file was automatically generated from "tip_top.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (0.004375512593, 7.141135803, -0.01745294675) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/tower_d.py b/dist/ba_data/python/bascenev1lib/mapdata/tower_d.py index 0816c2b..88481ad 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/tower_d.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/tower_d.py @@ -4,7 +4,6 @@ # This file was automatically generated from "tower_d.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-0.4714933293, 2.887077774, -1.505479919) diff --git a/dist/ba_data/python/bascenev1lib/mapdata/zig_zag.py b/dist/ba_data/python/bascenev1lib/mapdata/zig_zag.py index 51fec1a..e8cf496 100644 --- a/dist/ba_data/python/bascenev1lib/mapdata/zig_zag.py +++ b/dist/ba_data/python/bascenev1lib/mapdata/zig_zag.py @@ -4,7 +4,6 @@ # This file was automatically generated from "zig_zag.ma" # pylint: disable=all points = {} -# noinspection PyDictCreation boxes = {} boxes['area_of_interest_bounds'] = ( (-1.807378035, 3.943412768, -1.61304303) diff --git a/dist/ba_data/python/bascenev1lib/maps.py b/dist/ba_data/python/bascenev1lib/maps.py index 6646b72..c6aa225 100644 --- a/dist/ba_data/python/bascenev1lib/maps.py +++ b/dist/ba_data/python/bascenev1lib/maps.py @@ -42,7 +42,6 @@ def register_all_maps() -> None: class HockeyStadium(bs.Map): """Stadium map used for ice hockey games.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import hockey_stadium as defs name = 'Hockey Stadium' @@ -207,7 +206,6 @@ class FootballStadium(bs.Map): class Bridgit(bs.Map): """Map with a narrow bridge in the middle.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import bridgit as defs name = 'Bridgit' @@ -316,7 +314,6 @@ class Bridgit(bs.Map): class BigG(bs.Map): """Large G shaped map for racing""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import big_g as defs name = 'Big G' @@ -430,7 +427,6 @@ class BigG(bs.Map): class Roundabout(bs.Map): """CTF map featuring two platforms and a long way around between them""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import roundabout as defs name = 'Roundabout' @@ -538,7 +534,6 @@ class Roundabout(bs.Map): class MonkeyFace(bs.Map): """Map sorta shaped like a monkey face; teehee!""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import monkey_face as defs name = 'Monkey Face' @@ -646,7 +641,6 @@ class MonkeyFace(bs.Map): class ZigZag(bs.Map): """A very long zig-zaggy map""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import zig_zag as defs name = 'Zigzag' @@ -757,7 +751,6 @@ class ZigZag(bs.Map): class ThePad(bs.Map): """A simple square shaped map with a raised edge.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import the_pad as defs name = 'The Pad' @@ -849,7 +842,6 @@ class ThePad(bs.Map): class DoomShroom(bs.Map): """A giant mushroom. Of doom!""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import doom_shroom as defs name = 'Doom Shroom' @@ -949,7 +941,6 @@ class DoomShroom(bs.Map): class LakeFrigid(bs.Map): """An icy lake fit for racing.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import lake_frigid as defs name = 'Lake Frigid' @@ -1039,7 +1030,6 @@ class LakeFrigid(bs.Map): class TipTop(bs.Map): """A pointy map good for king-of-the-hill-ish games.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import tip_top as defs name = 'Tip Top' @@ -1120,7 +1110,6 @@ class TipTop(bs.Map): class CragCastle(bs.Map): """A lovely castle map.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import crag_castle as defs name = 'Crag Castle' @@ -1343,7 +1332,6 @@ class TowerD(bs.Map): class HappyThoughts(bs.Map): """Flying map.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import happy_thoughts as defs name = 'Happy Thoughts' @@ -1463,7 +1451,6 @@ class HappyThoughts(bs.Map): class StepRightUp(bs.Map): """Wide stepped map good for CTF or Assault.""" - # noinspection PyUnresolvedReferences from bascenev1lib.mapdata import step_right_up as defs name = 'Step Right Up' diff --git a/dist/ba_data/python/bascenev1lib/tutorial.py b/dist/ba_data/python/bascenev1lib/tutorial.py index c317825..341ec59 100644 --- a/dist/ba_data/python/bascenev1lib/tutorial.py +++ b/dist/ba_data/python/bascenev1lib/tutorial.py @@ -234,7 +234,6 @@ class RemoveGloves: def run(self, a: TutorialActivity) -> None: # pylint: disable=protected-access assert a.current_spaz is not None - # noinspection PyProtectedMember a.current_spaz._gloves_wear_off() diff --git a/dist/ba_data/python/bauiv1/__init__.py b/dist/ba_data/python/bauiv1/__init__.py index 94f0b34..a75bd09 100644 --- a/dist/ba_data/python/bauiv1/__init__.py +++ b/dist/ba_data/python/bauiv1/__init__.py @@ -16,8 +16,9 @@ from __future__ import annotations import logging -# from efro.util import set_canonical_module_names from babase import ( + accountlog, + AccountV2Handle, add_clean_frame_callback, allows_ticket_sales, app, @@ -34,7 +35,13 @@ from babase import ( AppTime, apptimer, AppTimer, + asset_loads_allowed, + balog, Call, + DevConsoleButtonDef, + DevConsoleTab, + DevConsoleTabEntry, + DevConsoleSubsystem, fullscreen_control_available, fullscreen_control_get, fullscreen_control_key_shortcut, @@ -43,6 +50,7 @@ from babase import ( clipboard_is_supported, clipboard_set_text, commit_app_config, + CloudSubscription, ContextRef, displaytime, DisplayTime, @@ -90,6 +98,7 @@ from babase import ( pushcall, quit, QuitType, + request_main_ui, request_permission, safecolor, screenmessage, @@ -103,6 +112,7 @@ from babase import ( supports_vsync, supports_unicode_display, timestring, + uilog, UIScale, unlock_all_input, utc_now_cloud, @@ -116,6 +126,7 @@ from _bauiv1 import ( columnwidget, containerwidget, get_qrcode_texture, + get_selected_widget, get_special_widget, getmesh, getsound, @@ -136,20 +147,26 @@ from _bauiv1 import ( uibounds, Widget, widget, + widget_by_id, ) +from bauiv1._cloudui import show_cloud_ui_window from bauiv1._keyboard import Keyboard from bauiv1._uitypes import ( + uicleanupcheck, + RootUIUpdatePause, +) +from bauiv1._appsubsystem import UIV1AppSubsystem +from bauiv1._window import ( Window, MainWindowState, BasicMainWindowState, - uicleanupcheck, MainWindow, - RootUIUpdatePause, MainWindowAutoRecreateSuppress, ) -from bauiv1._appsubsystem import UIV1AppSubsystem __all__ = [ + 'accountlog', + 'AccountV2Handle', 'add_clean_frame_callback', 'allows_ticket_sales', 'app', @@ -167,9 +184,15 @@ __all__ = [ 'AppTime', 'apptimer', 'AppTimer', + 'asset_loads_allowed', + 'balog', 'BasicMainWindowState', 'buttonwidget', 'Call', + 'DevConsoleButtonDef', + 'DevConsoleTab', + 'DevConsoleTabEntry', + 'DevConsoleSubsystem', 'fullscreen_control_available', 'fullscreen_control_get', 'fullscreen_control_key_shortcut', @@ -181,6 +204,7 @@ __all__ = [ 'columnwidget', 'commit_app_config', 'containerwidget', + 'CloudSubscription', 'ContextRef', 'displaytime', 'DisplayTime', @@ -197,6 +221,7 @@ __all__ = [ 'get_qrcode_texture', 'get_remote_app_name', 'get_replays_dir', + 'get_selected_widget', 'get_special_widget', 'get_string_height', 'get_string_width', @@ -241,6 +266,7 @@ __all__ = [ 'quit', 'QuitType', 'reload_hooks', + 'request_main_ui', 'request_permission', 'root_ui_pause_updates', 'root_ui_resume_updates', @@ -253,6 +279,7 @@ __all__ = [ 'set_low_level_config_value', 'set_party_window_open', 'set_main_ui_input_device', + 'show_cloud_ui_window', 'shutdown_suppress_begin', 'shutdown_suppress_end', 'Sound', @@ -266,22 +293,19 @@ __all__ = [ 'timestring', 'uibounds', 'uicleanupcheck', + 'uilog', 'UIScale', 'UIV1AppSubsystem', 'unlock_all_input', 'utc_now_cloud', 'WeakCall', 'widget', + 'widget_by_id', 'Widget', 'Window', 'workspaces_in_use', ] -# We want stuff to show up as bauiv1.Foo instead of bauiv1._sub.Foo. -# UPDATE: Trying without this for now. Seems like this might cause more -# harm than good. Can flip it back on if it is missed. -# set_canonical_module_names(globals()) - # Sanity check: we want to keep ballistica's dependencies and # bootstrapping order clearly defined; let's check a few particular # modules to make sure they never directly or indirectly import us diff --git a/dist/ba_data/python/bauiv1/_appsubsystem.py b/dist/ba_data/python/bauiv1/_appsubsystem.py index fb3d864..72bc0dd 100644 --- a/dist/ba_data/python/bauiv1/_appsubsystem.py +++ b/dist/ba_data/python/bauiv1/_appsubsystem.py @@ -4,12 +4,14 @@ from __future__ import annotations +import os import time import logging import inspect import weakref import warnings from enum import Enum +from dataclasses import dataclass from typing import TYPE_CHECKING, override from efro.util import empty_weakref @@ -20,14 +22,13 @@ import _bauiv1 if TYPE_CHECKING: from typing import Any, Callable - from bauiv1._uitypes import ( - UICleanupCheck, - Window, - MainWindow, - MainWindowState, - ) + from bauiv1._window import Window, MainWindow, MainWindowState import bauiv1 +# Set environment variable BA_DEBUG_UI_CLEANUP_CHECKS to 1 +# to print detailed info about what is getting cleaned up when. +DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1' + class UIV1AppSubsystem(babase.AppSubsystem): """Consolidated UI functionality for the app. @@ -57,7 +58,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): CHEST_SLOT_3 = 'chest_slot_3' def __init__(self) -> None: - from bauiv1._uitypes import MainWindow + from bauiv1._window import MainWindow super().__init__() @@ -73,12 +74,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): # For storing arbitrary class-level state data for Windows or # other UI related classes. self.window_states: dict[type, Any] = {} - - self._uiscale: babase.UIScale - self._update_ui_scale() - - self.cleanupchecks: list[UICleanupCheck] = [] - self.upkeeptimer: babase.AppTimer | None = None + self.main_window_shared_states: dict = {} self.title_color = (0.72, 0.7, 0.75) self.heading_color = (0.72, 0.7, 0.75) @@ -86,10 +82,15 @@ class UIV1AppSubsystem(babase.AppSubsystem): self.window_auto_recreate_suppress_count = 0 + self._uiscale: babase.UIScale + self._update_ui_scale() + self._upkeeptimer: babase.AppTimer | None = None + self._cleanupchecks: list[_UICleanupCheck] = [] self._last_win_recreate_screen_size: tuple[float, float] | None = None self._last_win_recreate_uiscale: bauiv1.UIScale | None = None self._last_win_recreate_time: float | None = None self._win_recreate_timer: babase.AppTimer | None = None + self._base_ids: dict[str, int] = {} # Elements in our root UI will call anything here when # activated. @@ -97,6 +98,15 @@ class UIV1AppSubsystem(babase.AppSubsystem): UIV1AppSubsystem.RootUIElement, Callable[[], None] ] = {} + def new_id_prefix(self, name: str) -> str: + """Generate a unique id given a base name. + + Useful to ensure widgets have globally unique ids even if + a particular window type is instantiated multiple times. + """ + val = self._base_ids[name] = self._base_ids.get(name, 0) + 1 + return f'{name}{val}' + def _update_ui_scale(self) -> None: uiscalestr = babase.get_ui_scale() if uiscalestr == 'large': @@ -121,7 +131,7 @@ class UIV1AppSubsystem(babase.AppSubsystem): @override def reset(self) -> None: - from bauiv1._uitypes import MainWindow + from bauiv1._window import MainWindow self.root_ui_calls.clear() self._main_window = empty_weakref(MainWindow) @@ -134,10 +144,9 @@ class UIV1AppSubsystem(babase.AppSubsystem): @override def on_app_loading(self) -> None: - from bauiv1._uitypes import ui_upkeep # Kick off our periodic UI upkeep. - self.upkeeptimer = babase.AppTimer(2.6543, ui_upkeep, repeat=True) + self._upkeeptimer = babase.AppTimer(2.6543, self._upkeep, repeat=True) def get_main_window(self) -> bauiv1.MainWindow | None: """Return main window, if any.""" @@ -147,12 +156,13 @@ class UIV1AppSubsystem(babase.AppSubsystem): self, window: bauiv1.MainWindow, *, + back_state: MainWindowState | None, from_window: bauiv1.MainWindow | None | bool = True, is_back: bool = False, is_top_level: bool = False, is_auxiliary: bool = False, - back_state: MainWindowState | None = None, suppress_warning: bool = False, + restore_shared_state: bool = True, ) -> None: """Set the current 'main' window. @@ -163,10 +173,10 @@ class UIV1AppSubsystem(babase.AppSubsystem): The caller is responsible for cleaning up any previous main window. """ - # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements - from bauiv1._uitypes import MainWindow + # pylint: disable=too-many-locals + from bauiv1._window import MainWindow # If we haven't grabbed initial uiscale or screen size for # recreate comparision purposes, this is a good time to do so. @@ -191,6 +201,10 @@ class UIV1AppSubsystem(babase.AppSubsystem): # We used to accept Widgets but now want MainWindows. if not isinstance(window, MainWindow): + + # if callable(window): + # window = window() + # else: raise RuntimeError( f'set_main_window() now takes a MainWindow as its "window" arg.' f' You passed a {type(window)}.', @@ -293,38 +307,23 @@ class UIV1AppSubsystem(babase.AppSubsystem): if is_top_level: # Top level windows don't have or expect anywhere to go # back to. - window.main_window_back_state = None - elif back_state is not None: - window.main_window_back_state = back_state - else: - oldwin = self._main_window() - if oldwin is None: - # We currenty only hold weak refs to windows so that - # they are free to die on their own, but we expect - # the main menu window to keep itself alive as long - # as its the main one. Holler if that seems to not - # be happening. - logging.warning( - 'set_main_window: No old MainWindow found' - ' and is_top_level is False;' - ' this should not happen.' - ) - window.main_window_back_state = None - else: - window.main_window_back_state = self.save_main_window_state( - oldwin - ) + assert back_state is None + window.main_window_back_state = back_state self._main_window = window_weakref self._main_window_widget = window_widget + # Now that we're all set up, restore any state. + if restore_shared_state: + window.main_window_restore_shared_state() + def has_main_window(self) -> bool: """Return whether a main menu window is present.""" return bool(self._main_window_widget) def clear_main_window(self, transition: str | None = None) -> None: """Clear any existing main window.""" - from bauiv1._uitypes import MainWindow + from bauiv1._window import MainWindow main_window = self._main_window() if main_window: @@ -358,6 +357,23 @@ class UIV1AppSubsystem(babase.AppSubsystem): return winstate + def save_current_main_window_state(self) -> MainWindowState | None: + """Save state for the current window, if any.""" + # Calc a back-state from the current window. + current_main_win = self._main_window() + if current_main_win is None: + # We currenty only hold weak refs to windows so that + # they are free to die on their own, but we expect + # the main menu window to keep itself alive as long + # as its the main one. Holler if that seems to not + # be happening. + babase.uilog.warning( + 'save_current_main_window_state: No old MainWindow found;' + ' this should not happen.' + ) + return None + return self.save_main_window_state(current_main_win) + def restore_main_window_state(self, state: MainWindowState) -> None: """Restore UI to a saved state.""" existing = self.get_main_window() @@ -411,6 +427,142 @@ class UIV1AppSubsystem(babase.AppSubsystem): self._schedule_main_win_recreate() + def add_ui_cleanup_check(self, obj: Any, widget: bauiv1.Widget) -> None: + """Checks to ensure a widget-owning object gets cleaned up properly. + + This adds a check which will print an error message if the provided + object still exists ~5 seconds after the provided bauiv1.Widget + dies. + + This is a good sanity check for any sort of object that wraps or + controls a bauiv1.Widget. For instance, a 'Window' class instance + has no reason to still exist once its root container bauiv1.Widget + has fully transitioned out and been destroyed. Circular references + or careless strong referencing can lead to such objects never + getting destroyed, however, and this helps detect such cases to + avoid memory leaks. + """ + if DEBUG_UI_CLEANUP_CHECKS: + print(f'adding uicleanup to {obj}') + if not isinstance(widget, _bauiv1.Widget): + raise TypeError('widget arg is not a bauiv1.Widget') + + if bool(False): + + def foobar() -> None: + """Just testing.""" + if DEBUG_UI_CLEANUP_CHECKS: + print('uicleanupcheck widget dying...') + + widget.add_delete_callback(foobar) + + self._cleanupchecks.append( + _UICleanupCheck( + obj=weakref.ref(obj), widget=widget, widget_death_time=None + ) + ) + + def auxiliary_window_activate( + self, + win_type: type[bauiv1.MainWindow], + win_create_call: Callable[[], bauiv1.MainWindow], + ) -> None: + """Navigate to or away from an Auxiliary window. + + Auxiliary windows can be thought of as 'side quests' in the + window hierarchy; places such as settings windows or league + ranking windows that the user might want to visit without losing + their place in the regular hierarchy. + + Calling this method with a MainWindow of the provided type + already in the stack will back out past it (effectively toggling + the 'side quest' back off). + + Calling this method with a *different* auxiliary window in the + stack will back out past that and replace it with this + (effectively ending the old side-quest and starting a new one). + """ + # pylint: disable=unidiomatic-typecheck + + current_main_window = self.get_main_window() + + # Scan our ancestors for auxiliary states matching our type as + # well as auxiliary states in general. + aux_matching_state: bauiv1.MainWindowState | None = None + aux_state: bauiv1.MainWindowState | None = None + + if current_main_window is None: + raise RuntimeError( + 'Not currently handling no-top-level-window case.' + ) + + state = current_main_window.main_window_back_state + while state is not None: + assert state.window_type is not None + if state.is_auxiliary: + if state.window_type is win_type: + aux_matching_state = state + else: + aux_state = state + + state = state.parent + + # If there's an ancestor auxiliary window-state matching our + # type, back out past it (example: poking settings, navigating + # down a level or two, and then poking settings again should + # back out of settings). + if aux_matching_state is not None: + current_main_window.main_window_back_state = ( + aux_matching_state.parent + ) + current_main_window.main_window_back() + return + + # If there's an ancestory auxiliary state *not* matching our + # type, crop the state and swap in our new auxiliary UI + # (example: poking settings, then poking account, then poking + # back should end up where things were before the settings + # poke). + if aux_state is not None: + # Blow away the window stack and build a fresh one. + self.clear_main_window() + self.set_main_window( + win_create_call(), + from_window=False, # Disable from-check. + back_state=aux_state.parent, + suppress_warning=True, + is_auxiliary=True, + ) + return + + # Ok, no auxiliary states found. Now if current window is + # auxiliary and the type matches, simply do a back. + if ( + current_main_window.main_window_is_auxiliary + and type(current_main_window) is win_type + ): + current_main_window.main_window_back() + return + + # If current window is auxiliary but type doesn't match, + # swap it out for our new auxiliary UI. + if current_main_window.main_window_is_auxiliary: + self.clear_main_window() + self.set_main_window( + win_create_call(), + from_window=False, # Disable from-check. + back_state=current_main_window.main_window_back_state, + suppress_warning=True, + is_auxiliary=True, + ) + return + + # Ok, no existing auxiliary stuff was found period. Just + # navigate forward to this UI. + current_main_window.main_window_replace( + win_create_call, is_auxiliary=True + ) + def _schedule_main_win_recreate(self) -> None: # If there is a timer set already, do nothing. @@ -483,3 +635,48 @@ class UIV1AppSubsystem(babase.AppSubsystem): # future recreates. self._last_win_recreate_uiscale = uiscale self._last_win_recreate_screen_size = virtual_screen_size + + def _upkeep(self) -> None: + """Run UI cleanup checks, etc. should be called periodically.""" + + assert babase.app.classic is not None + remainingchecks = [] + now = babase.apptime() + for check in self._cleanupchecks: + obj = check.obj() + + # If the object has died, ignore and don't re-add. + if obj is None: + if DEBUG_UI_CLEANUP_CHECKS: + print('uicleanupcheck object is dead; hooray!') + continue + + # If the widget hadn't died yet, note if it has. + if check.widget_death_time is None: + remainingchecks.append(check) + if not check.widget: + check.widget_death_time = now + else: + # Widget was already dead; complain if its been too long. + if now - check.widget_death_time > 5.0: + print( + 'WARNING:', + obj, + 'is still alive 5 second after its Widget died;' + ' you might have a memory leak. Look for circular' + ' references or outside things referencing your Window' + ' class instance. See efro.debug module' + ' for tools that can help debug this sort of thing.', + ) + else: + remainingchecks.append(check) + self._cleanupchecks = remainingchecks + + +@dataclass +class _UICleanupCheck: + """Holds info about a uicleanupcheck target.""" + + obj: weakref.ref + widget: bauiv1.Widget + widget_death_time: float | None diff --git a/dist/ba_data/python/bauiv1/_cloudui.py b/dist/ba_data/python/bauiv1/_cloudui.py new file mode 100644 index 0000000..642286c --- /dev/null +++ b/dist/ba_data/python/bauiv1/_cloudui.py @@ -0,0 +1,304 @@ +# Released under the MIT License. See LICENSE for details. +# +"""UIs provided by the cloud (similar-ish to html in concept).""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import TYPE_CHECKING, override, Annotated + +from efro.dataclassio import ioprepped, IOAttrs +import babase +from bauiv1._window import MainWindow, BasicMainWindowState +import _bauiv1 + + +if TYPE_CHECKING: + from bauiv1._window import MainWindowState + + +def show_cloud_ui_window() -> None: + """Bust out a cloud-ui window.""" + + # Pop up an auxiliary window wherever we are in the nav stack. + babase.app.ui_v1.auxiliary_window_activate( + win_type=CloudUIWindow, + win_create_call=lambda: CloudUIWindow(state=None), + ) + + +@ioprepped +@dataclass +class CloudUIButton: + """Represents a button in a cloud-ui.""" + + +@ioprepped +@dataclass +class CloudUIRow: + """Represents a row in a cloud-ui.""" + + buttons: Annotated[list[CloudUIButton], IOAttrs('b')] + + +@ioprepped +@dataclass +class CloudUIRoot: + """Represents an entire cloud-ui.""" + + title: Annotated[str, IOAttrs('t')] + rows: Annotated[list[CloudUIRow], IOAttrs('r')] + + +class CloudUIWindow(MainWindow): + """An example of a well-behaved main-window.""" + + @dataclass + class _State: + root: CloudUIRoot | None + + def __init__( + self, + state: _State | None, + *, + transition: str | None = 'in_right', + origin_widget: _bauiv1.Widget | None = None, + auxiliary_style: bool = True, + ): + ui = babase.app.ui_v1 + + self._state: CloudUIWindow._State | None = None + + # We want to display differently whether we're an auxiliary + # window or not, but unfortunately that value is not yet + # available until we're added to the main-window-stack so it + # must be explicitly passed in. + self._auxiliary_style = auxiliary_style + + # Calc scale and size for our backing window. For medium & large + # ui-scale we aim for a window small enough to always be fully + # visible on-screen and for small mode we aim for a window big + # enough that we never see the window edges; only the window + # texture covering the whole screen. + uiscale = ui.uiscale + self._width = 1400 if uiscale is babase.UIScale.SMALL else 750 + self._height = 1200 if uiscale is babase.UIScale.SMALL else 500 + scale = ( + 1.5 + if uiscale is babase.UIScale.SMALL + else 1.2 if uiscale is babase.UIScale.MEDIUM else 1.0 + ) + + # Do some fancy math to calculate our visible area; this will be + # limited by the screen size in small mode and our backing size + # otherwise. + screensize = babase.get_virtual_screen_size() + self._vis_width = min(self._width - 100, screensize[0] / scale) + self._vis_height = min(self._height - 100, screensize[1] / scale) + self._vis_top = 0.5 * self._height + 0.5 * self._vis_height + self._vis_left = 0.5 * self._width - 0.5 * self._vis_width + + # Nudge our vis area up a bit when we can see the full backing + # (visual fudge factor). + if uiscale is not babase.UIScale.SMALL: + self._vis_top += 12.0 + + super().__init__( + root_widget=_bauiv1.containerwidget( + size=(self._width, self._height), + toolbar_visibility='menu_full', + toolbar_cancel_button_style=( + 'close' if auxiliary_style else 'back' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We respond to screen size changes only at small ui-scale; + # in other cases we assume our window remains fully visible + # always (flip to windowed mode and resize the app window to + # confirm this). + refresh_on_screen_size_changes=uiscale is babase.UIScale.SMALL, + ) + # Avoid complaints if nothing is selected under us. + _bauiv1.widget(edit=self._root_widget, allow_preserve_selection=False) + + # Title. + self._title = _bauiv1.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._vis_top - 20), + size=(0, 0), + text='', + color=ui.title_color, + scale=0.9 if uiscale is babase.UIScale.SMALL else 1.0, + # Make sure we avoid overlapping meters in small mode. + maxwidth=(130 if uiscale is babase.UIScale.SMALL else 200), + h_align='center', + v_align='center', + ) + + # For small UI-scale we use the system back/close button; + # otherwise we make our own. + if uiscale is babase.UIScale.SMALL: + _bauiv1.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + btn = _bauiv1.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|close', + scale=0.8, + position=(self._vis_left - 15, self._vis_top - 30), + size=(50, 50) if auxiliary_style else (60, 55), + extra_touch_border_scale=2.0, + button_type=None if auxiliary_style else 'backSmall', + on_activate_call=self.main_window_back, + autoselect=True, + label=babase.charstr( + babase.SpecialChar.CLOSE + if auxiliary_style + else babase.SpecialChar.BACK + ), + ) + _bauiv1.containerwidget(edit=self._root_widget, cancel_button=btn) + + # Show our vis-area bounds (for debugging). + if bool(True): + # Skip top-left since its always overlapping back/close + # buttons. + if bool(False): + _bauiv1.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TL', + h_align='left', + v_align='top', + ) + _bauiv1.textwidget( + parent=self._root_widget, + position=(self._vis_left + self._vis_width, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TR', + h_align='right', + v_align='top', + ) + _bauiv1.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top - self._vis_height), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='BL', + h_align='left', + v_align='bottom', + ) + _bauiv1.textwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width, + self._vis_top - self._vis_height, + ), + size=(0, 0), + scale=0.5, + color=(1, 1, 1, 0.5), + text='BR', + h_align='right', + v_align='bottom', + ) + + self._spinner: _bauiv1.Widget | None = _bauiv1.spinnerwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width * 0.5, + self._vis_top - self._vis_height * 0.5, + ), + size=48, + style='bomb', + ) + + if state is not None: + self._set_state(state) + else: + if random.random() < 0.3: + babase.apptimer(1.0, babase.WeakCall(self._on_error_response)) + else: + babase.apptimer(1.0, babase.WeakCall(self._on_response)) + + def _on_error_response(self) -> None: + self._set_state(self._State(None)) + + def _on_response(self) -> None: + self._set_state(self._State(CloudUIRoot(title='Testing', rows=[]))) + + def _set_state(self, state: _State) -> None: + """Set a final state (error or page contents). + + This state may be instantly restored if the window is recreated + (depending on cache lifespan/etc.) + """ + + assert self._state is None + self._state = state + + if self._spinner: + self._spinner.delete() + self._spinner = None + + if self._state.root is None: + _bauiv1.textwidget( + edit=self._title, + literal=False, # Allow Lstr. + text=babase.Lstr(resource='errorText'), + ) + _bauiv1.textwidget( + parent=self._root_widget, + position=( + self._vis_left + 0.5 * self._vis_width, + self._vis_top - 0.5 * self._vis_height, + ), + size=(0, 0), + scale=0.6, + text=babase.Lstr(resource='store.loadErrorText'), + h_align='center', + v_align='center', + ) + else: + _bauiv1.textwidget( + edit=self._title, + literal=True, # Never interpret as Lstr. + text=self._state.root.title, + ) + + @override + def get_main_window_state(self) -> MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # IMPORTANT - Pull values from self HERE; if we do it in the + # lambda below it'll keep self alive which will lead to + # 'ui-not-getting-cleaned-up' warnings and memory leaks. + auxiliary_style = self._auxiliary_style + state = self._state + + return BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + state=state, + transition=transition, + origin_widget=origin_widget, + auxiliary_style=auxiliary_style, + ), + ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True + + @override + def get_main_window_shared_state_id(self) -> str | None: + return 'cloudui' diff --git a/dist/ba_data/python/bauiv1/_uitypes.py b/dist/ba_data/python/bauiv1/_uitypes.py index 463d4e5..3184da7 100644 --- a/dist/ba_data/python/bauiv1/_uitypes.py +++ b/dist/ba_data/python/bauiv1/_uitypes.py @@ -1,13 +1,10 @@ # Released under the MIT License. See LICENSE for details. # -"""Provide top level UI related functionality.""" +"""Misc UI related types.""" from __future__ import annotations -import os -import weakref -import logging -from dataclasses import dataclass +import warnings from typing import TYPE_CHECKING, override import babase @@ -19,405 +16,21 @@ if TYPE_CHECKING: import bauiv1 -# Set environment variable BA_DEBUG_UI_CLEANUP_CHECKS to 1 -# to print detailed info about what is getting cleaned up when. -DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1' - - -class Window: - """A basic window. - - Essentially wraps a ContainerWidget with some higher level - functionality. - """ - - def __init__( - self, - root_widget: bauiv1.Widget, - cleanupcheck: bool = True, - prevent_main_window_auto_recreate: bool = True, - ): - self._root_widget = root_widget - - # By default, the presence of any generic windows prevents the - # app from running its fancy main-window-auto-recreate mechanism - # on screen-resizes and whatnot. This avoids things like - # temporary popup windows getting stuck under auto-re-created - # main-windows. - self._window_main_window_auto_recreate_suppress = ( - MainWindowAutoRecreateSuppress() - if prevent_main_window_auto_recreate - else None - ) - - # Generally we complain if we outlive our root widget. - if cleanupcheck: - uicleanupcheck(self, root_widget) - - def get_root_widget(self) -> bauiv1.Widget: - """Return the root widget.""" - return self._root_widget - - -class MainWindow(Window): - """A special type of window that can be set as 'main'. - - The UI system has at most one main window at any given time. - MainWindows support high level functionality such as saving and - restoring states, allowing them to be automatically recreated when - navigating back from other locations or when something like ui-scale - changes. - """ - - def __init__( - self, - root_widget: bauiv1.Widget, - *, - transition: str | None, - origin_widget: bauiv1.Widget | None, - cleanupcheck: bool = True, - refresh_on_screen_size_changes: bool = False, - ): - """Create a MainWindow given a root widget and transition info. - - Automatically handles in and out transitions on the provided - widget, so there is no need to set transitions when creating it. - """ - # A back-state supplied by the ui system. - self.main_window_back_state: MainWindowState | None = None - - self.main_window_is_top_level: bool = False - - # Windows that size tailor themselves to exact screen dimensions - # can pass True for this. Generally this only applies to small - # ui scale and at larger scales windows simply fit in the - # virtual safe area. - self.refreshes_on_screen_size_changes = refresh_on_screen_size_changes - - # Windows can be flagged as auxiliary when not related to the - # main UI task at hand. UI code may choose to handle auxiliary - # windows in special ways, such as by implicitly replacing - # existing auxiliary windows with new ones instead of keeping - # old ones as back targets. - self.main_window_is_auxiliary: bool = False - - self._main_window_transition = transition - self._main_window_origin_widget = origin_widget - super().__init__( - root_widget, - cleanupcheck=cleanupcheck, - prevent_main_window_auto_recreate=False, - ) - - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._main_window_transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._main_window_transition_out = 'out_right' - scale_origin = None - _bauiv1.containerwidget( - edit=root_widget, - transition=transition, - scale_origin_stack_offset=scale_origin, - ) - - def main_window_close(self, transition: str | None = None) -> None: - """Get window transitioning out if still alive.""" - - # 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 - - # Transition ourself out. - try: - self.on_main_window_close() - except Exception: - logging.exception('Error in on_main_window_close() for %s.', self) - - # Note: normally transition of None means instant, but we use - # that to mean 'do the default' so we support a special - # 'instant' string. - if transition == 'instant': - self._root_widget.delete() - else: - _bauiv1.containerwidget( - edit=self._root_widget, - transition=( - self._main_window_transition_out - if transition is None - else transition - ), - ) - - def main_window_has_control(self) -> bool: - """Is this MainWindow allowed to change the global main window? - - It is a good idea to make sure this is True before calling - main_window_replace(). This prevents fluke UI breakage such as - multiple simultaneous events causing a MainWindow to spawn - multiple replacements for itself. - """ - # We are allowed to change main windows if we are the current one - # AND our underlying widget is still alive and not transitioning out. - return ( - babase.app.ui_v1.get_main_window() is self - and bool(self._root_widget) - and not self._root_widget.transitioning_out - ) - - def main_window_back(self) -> None: - """Move back in the main window stack. - - Is a no-op if the main window does not have control; - no need to check main_window_has_control() first. - """ - - # Users should always check main_window_has_control() before - # calling us. Error if it seems they did not. - if not self.main_window_has_control(): - return - - uiv1 = babase.app.ui_v1 - - # Get the 'back' window coming in. - if not self.main_window_is_top_level: - - back_state = self.main_window_back_state - if back_state is None: - raise RuntimeError( - f'Main window {self} provides no back-state.' - ) - - # Valid states should have values here. - assert back_state.is_top_level is not None - assert back_state.is_auxiliary is not None - assert back_state.window_type is not None - - # When leaving an auxiliary window, scale the destination - # window in instead of sliding to convey that its more of a - # 'swapping out' than a 'back' action. - backwin = back_state.create_window( - transition=( - 'in_scale' if self.main_window_is_auxiliary else 'in_left' - ) - ) - - uiv1.set_main_window( - backwin, - from_window=self, - is_back=True, - back_state=back_state, - suppress_warning=True, - ) - - # Transition ourself out. - self.main_window_close() - - def main_window_replace( - self, - new_window: MainWindow, - back_state: MainWindowState | None = None, - is_auxiliary: bool = False, - ) -> None: - """Replace ourself with a new MainWindow.""" - - # Users should always check main_window_has_control() *before* - # creating new MainWindows and passing them in here. Kill the - # passed window and Error if it seems they did not. - if not self.main_window_has_control(): - new_window.get_root_widget().delete() - raise RuntimeError( - f'main_window_replace() called on a not-in-control window' - f' ({self}); always check main_window_has_control() before' - f' calling main_window_replace().' - ) - - # For auxiliary windows, use scale to give a feel that we're - # switching over to a totally separate 'side quest' ui. For - # regular back/forward relationships, shove the old out the left - # to give the feel that we're adding to a nav stack. - if is_auxiliary: - transition = 'out_scale' - else: - transition = 'out_left' - - # Transition ourself out. - try: - self.on_main_window_close() - except Exception: - logging.exception('Error in on_main_window_close() for %s.', self) - - _bauiv1.containerwidget(edit=self._root_widget, transition=transition) - babase.app.ui_v1.set_main_window( - new_window, - from_window=self, - back_state=back_state, - is_auxiliary=is_auxiliary, - suppress_warning=True, - ) - - def on_main_window_close(self) -> None: - """Called before transitioning out a main window. - - A good opportunity to save window state/etc. - """ - - def get_main_window_state(self) -> MainWindowState: - """Return a WindowState to recreate this window, if supported.""" - raise NotImplementedError() - - -class MainWindowState: - """Persistent state for a specific MainWindow. - - This allows MainWindows to be automatically recreated for back-button - purposes, when switching app-modes, etc. - """ - - def __init__(self) -> None: - # The window that back/cancel navigation should take us to. - self.parent: MainWindowState | None = None - self.is_top_level: bool | None = None - self.is_auxiliary: bool | None = None - self.window_type: type[MainWindow] | None = None - self.selection: str | None = None - - def create_window( - self, - transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, - origin_widget: bauiv1.Widget | None = None, - ) -> MainWindow: - """Create a window based on this state. - - WindowState child classes should override this to recreate their - particular type of window. - """ - raise NotImplementedError() - - -class BasicMainWindowState(MainWindowState): - """A basic MainWindowState holding a lambda to recreate a MainWindow.""" - - def __init__( - self, - create_call: Callable[ - [ - Literal['in_right', 'in_left', 'in_scale'] | None, - bauiv1.Widget | None, - ], - bauiv1.MainWindow, - ], - ) -> None: - super().__init__() - self.create_call = create_call - - @override - def create_window( - self, - transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, - origin_widget: bauiv1.Widget | None = None, - ) -> bauiv1.MainWindow: - return self.create_call(transition, origin_widget) - - -class MainWindowAutoRecreateSuppress: - """Suppresses main-window auto-recreate while in existence. - - Can be instantiated and held by windows or processes within windows - for the purpose of preventing the main-window auto-recreate - mechanism from firing. This mechanism normally fires when the screen - is resized or the ui-scale is changed, allowing windows to be - recreated to adapt to the new configuration. - """ - - def __init__(self) -> None: - babase.app.ui_v1.window_auto_recreate_suppress_count += 1 - - def __del__(self) -> None: - babase.app.ui_v1.window_auto_recreate_suppress_count -= 1 - - -@dataclass -class UICleanupCheck: - """Holds info about a uicleanupcheck target.""" - - obj: weakref.ref - widget: bauiv1.Widget - widget_death_time: float | None - +# REMOVE WHEN API 9 SUPPORT ENDS def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None: - """Checks to ensure a widget-owning object gets cleaned up properly. - - This adds a check which will print an error message if the provided - object still exists ~5 seconds after the provided bauiv1.Widget dies. - - This is a good sanity check for any sort of object that wraps or - controls a bauiv1.Widget. For instance, a 'Window' class instance has - no reason to still exist once its root container bauiv1.Widget has fully - transitioned out and been destroyed. Circular references or careless - strong referencing can lead to such objects never getting destroyed, - however, and this helps detect such cases to avoid memory leaks. """ - if DEBUG_UI_CLEANUP_CHECKS: - print(f'adding uicleanup to {obj}') - if not isinstance(widget, _bauiv1.Widget): - raise TypeError('widget arg is not a bauiv1.Widget') - - if bool(False): - - def foobar() -> None: - """Just testing.""" - if DEBUG_UI_CLEANUP_CHECKS: - print('uicleanupcheck widget dying...') - - widget.add_delete_callback(foobar) - - assert babase.app.classic is not None - babase.app.ui_v1.cleanupchecks.append( - UICleanupCheck( - obj=weakref.ref(obj), widget=widget, widget_death_time=None - ) + .. deprecated:: 1.7.51 + Use :meth:`UIV1AppSubsystem.add_ui_cleanup_check()`. + Will be removed when api 9 support ends. + """ + warnings.warn( + 'bauiv1.uicleanupcheck() will be removed when api 9 support ends;' + ' use ba*.app.ui_v1.add_ui_cleanup_check() instead.', + DeprecationWarning, + stacklevel=2, ) - - -def ui_upkeep() -> None: - """Run UI cleanup checks, etc. should be called periodically.""" - assert babase.app.classic is not None - ui = babase.app.ui_v1 - remainingchecks = [] - now = babase.apptime() - for check in ui.cleanupchecks: - obj = check.obj() - - # If the object has died, ignore and don't re-add. - if obj is None: - if DEBUG_UI_CLEANUP_CHECKS: - print('uicleanupcheck object is dead; hooray!') - continue - - # If the widget hadn't died yet, note if it has. - if check.widget_death_time is None: - remainingchecks.append(check) - if not check.widget: - check.widget_death_time = now - else: - # Widget was already dead; complain if its been too long. - if now - check.widget_death_time > 5.0: - print( - 'WARNING:', - obj, - 'is still alive 5 second after its Widget died;' - ' you might have a memory leak. Look for circular' - ' references or outside things referencing your Window' - ' class instance. See efro.debug module' - ' for tools that can help debug this sort of thing.', - ) - else: - remainingchecks.append(check) - ui.cleanupchecks = remainingchecks + babase.app.ui_v1.add_ui_cleanup_check(obj, widget) class TextWidgetStringEditAdapter(babase.StringEditAdapter): diff --git a/dist/ba_data/python/bauiv1/_window.py b/dist/ba_data/python/bauiv1/_window.py new file mode 100644 index 0000000..7c65a90 --- /dev/null +++ b/dist/ba_data/python/bauiv1/_window.py @@ -0,0 +1,604 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Window related UI bits.""" + +from __future__ import annotations + +import logging +import warnings +from typing import TYPE_CHECKING, override + +import babase + +import _bauiv1 + +if TYPE_CHECKING: + from typing import Any, Type, Literal, Callable + + import bauiv1 + + +class Window: + """A basic window. + + Essentially wraps a ContainerWidget with some higher level + functionality. + """ + + def __init__( + self, + root_widget: bauiv1.Widget, + cleanupcheck: bool = True, + prevent_main_window_auto_recreate: bool = True, + ): + self._root_widget = root_widget + + # By default, the presence of any generic windows prevents the + # app from running its fancy main-window-auto-recreate mechanism + # on screen-resizes and whatnot. This avoids things like + # temporary popup windows getting stuck under auto-re-created + # main-windows. + self._window_main_window_auto_recreate_suppress = ( + MainWindowAutoRecreateSuppress() + if prevent_main_window_auto_recreate + else None + ) + + # Generally we complain if we outlive our root widget. + if cleanupcheck: + babase.app.ui_v1.add_ui_cleanup_check(self, root_widget) + + def get_root_widget(self) -> bauiv1.Widget: + """Return the root widget.""" + return self._root_widget + + +class MainWindow(Window): + """A special type of window that can be set as 'main'. + + The UI system has at most one main window at any given time. + MainWindows support high level functionality such as saving and + restoring states, allowing them to be automatically recreated when + navigating back from other locations or when something like ui-scale + changes. + """ + + def __init__( + self, + root_widget: bauiv1.Widget, + *, + transition: str | None, + origin_widget: bauiv1.Widget | None, + cleanupcheck: bool = True, + refresh_on_screen_size_changes: bool = False, + ): + """Create a MainWindow given a root widget and transition info. + + Automatically handles in and out transitions on the provided + widget, so there is no need to set transitions when creating it. + """ + + self.main_window_id_prefix = babase.app.ui_v1.new_id_prefix( + type(self).__name__.lower() + ) + + # A back-state supplied by the ui system. + self.main_window_back_state: MainWindowState | None = None + + self.main_window_is_top_level: bool = False + + # Windows that size tailor themselves to exact screen dimensions + # can pass True for this. Generally this only applies to small + # ui scale and at larger scales windows simply fit in the + # virtual safe area. + self.refreshes_on_screen_size_changes = refresh_on_screen_size_changes + + # Windows can be flagged as auxiliary when not related to the + # main UI task at hand. UI code may choose to handle auxiliary + # windows in special ways, such as by implicitly replacing + # existing auxiliary windows with new ones instead of keeping + # old ones as back targets. + self.main_window_is_auxiliary: bool = False + + self._main_window_transition = transition + self._main_window_origin_widget = origin_widget + super().__init__( + root_widget, + cleanupcheck=cleanupcheck, + prevent_main_window_auto_recreate=False, + ) + + scale_origin: tuple[float, float] | None + if origin_widget is not None: + self._main_window_transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._main_window_transition_out = 'out_right' + scale_origin = None + _bauiv1.containerwidget( + edit=root_widget, + transition=transition, + scale_origin_stack_offset=scale_origin, + ) + + def main_window_save_shared_state(self) -> None: + """Save shared state (such as widget selection). + + This is automatically called just before main-windows are + destroyed, but the user may opt to call it at other times such + as before refreshing a UI (so that selection can be restored + after the refresh, etc.) + + State contained here is intended to operate on + already-constructed UI; state that influences which UI is + contructed should go through other mechanisms. + """ + # pylint: disable=assignment-from-none + key = self.get_main_window_shared_state_id() + assert isinstance(key, str | None) + keyfin = type(self) if key is None else key + + shared_state: dict = {} + + # Save selection if desired. + if self._get_main_window_should_preserve_selection(): + sel = _bauiv1.get_selected_widget() + if sel is None: + selfin = None + else: + if sel.allow_preserve_selection: + selfin = sel.id + if selfin is not None: + pre = f'{self.main_window_id_prefix}|' + if selfin.startswith(pre): + selfin = f'$(WIN)|{selfin.removeprefix(pre)}' + babase.uilog.debug( + "Saving ui selection from '%s': '%s'.", + self.main_window_id_prefix, + selfin, + ) + else: + # if not sel.allow_preserve_selection: + babase.uilog.warning( + 'main_window_should_preserve_selection()' + ' returned True for %s but no id was assigned' + ' to the currently selected widget %s. All' + ' selectable widgets must be assigned unique' + ' ids for selection-preserving to work' + ' properly.', + self, + sel, + ) + else: + selfin = None + babase.uilog.debug( + "Not saving ui selection from '%s';" + ' selected widget disallows it (%s).', + self.main_window_id_prefix, + sel, + ) + + shared_state['selection'] = selfin + + # Allow win to save any custom state. (Do this after selection + # save so user can manipulate save output if they want). + try: + self.main_window_do_save_shared_state(shared_state) + except Exception: + logging.exception( + 'Error in main_window_do_save_shared_state() for %s.', self + ) + assert isinstance(shared_state, dict) + + babase.uilog.debug( + "Saving shared state from '%s' using key %r.", + self.main_window_id_prefix, + keyfin, + ) + babase.app.ui_v1.main_window_shared_states[keyfin] = shared_state + + def main_window_restore_shared_state(self) -> None: + """Restore shared state (such as widget selection), if any. + + This is automatically called just after main-windows are + created, but the user may opt to call it at other times such as + after explicitly refreshing some UI. + + State contained here is intended to operate on + already-constructed UI; state that influences which UI is + contructed should go through other mechanisms. + """ + + # pylint: disable=assignment-from-none + key = self.get_main_window_shared_state_id() + assert isinstance(key, str | None) + keyfin = type(self) if key is None else key + babase.uilog.debug( + "Restoring shared state to '%s' using key %r.", + self.main_window_id_prefix, + keyfin, + ) + shared_state = babase.app.ui_v1.main_window_shared_states.get(keyfin) + if shared_state is None: + shared_state = {} + assert isinstance(shared_state, dict) + + # Allow win to restore any custom state. (Do this before + # selection restore so user can manipulate input if they want). + try: + self.main_window_do_restore_shared_state(shared_state) + except Exception: + logging.exception( + 'Error in main_window_do_restore_shared_state() for %s.', self + ) + + # Restore selection if desired. + if self._get_main_window_should_preserve_selection(): + sel = shared_state.get('selection') + if isinstance(sel, str): + babase.uilog.debug( + "Restoring ui selection to '%s': '%s'.", + self.main_window_id_prefix, + sel, + ) + pre = '$(WIN)|' + if sel.startswith(pre): + sel = ( + f'{self.main_window_id_prefix}|{sel.removeprefix(pre)}' + ) + widget = _bauiv1.widget_by_id(sel) + if widget is not None: + if widget.selectable: + widget.global_select() + widget.scroll_into_view() + else: + babase.uilog.debug( + "Unable to restore selection '%s';" + ' widget is not selectable.', + sel, + ) + + else: + # We expect this to happen sometimes (windows may come + # up with different UIs visible/etc.). Let's note it but + # subtly. + babase.uilog.debug( + "Unable to restore selection '%s'; widget not found.", + sel, + ) + + def main_window_close(self, transition: str | None = None) -> None: + """Get window transitioning out if still alive.""" + + # 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 + + # Save selection, etc. + self.main_window_save_shared_state() + + # Give the user a chance to do whatever. + try: + self.on_main_window_close() + except Exception: + logging.exception('Error in on_main_window_close() for %s.', self) + + # Transition ourself out. + + # Note: normally transition of None means instant, but we use + # that to mean 'do the default' so we support a special + # 'instant' string. + if transition == 'instant': + self._root_widget.delete() + else: + _bauiv1.containerwidget( + edit=self._root_widget, + transition=( + self._main_window_transition_out + if transition is None + else transition + ), + ) + + def main_window_has_control(self) -> bool: + """Is this MainWindow allowed to change the global main window? + + This is called internally by methods such as + :meth:`main_window_replace()` and :meth:`main_window_back()` so + generally you do not need to call it directly when using those. + However you may still opt to check this if doing other actions + besides main-window navigation (such as displaying pop-ups). + """ + # We are allowed to change main windows if we are the current one + # AND our underlying widget is still alive and not transitioning out. + return ( + babase.app.ui_v1.get_main_window() is self + and bool(self._root_widget) + and not self._root_widget.transitioning_out + ) + + def main_window_back(self) -> None: + """Move back in the main window stack. + + Is a no-op if the main window does not have control; + no need to check main_window_has_control() first. + """ + + # Users should always check main_window_has_control() before + # calling us. Error if it seems they did not. + if not self.main_window_has_control(): + return + + uiv1 = babase.app.ui_v1 + + # Get ourself transitioning out. + self.main_window_close() + + # Get the 'back' window coming in. + if not self.main_window_is_top_level: + + back_state = self.main_window_back_state + if back_state is None: + raise RuntimeError( + f'Main window {self} provides no back-state.' + ) + + # Valid states should have values here. + assert back_state.is_top_level is not None + assert back_state.is_auxiliary is not None + assert back_state.window_type is not None + + # When leaving an auxiliary window, scale the destination + # window in instead of sliding to convey that its more of a + # 'swapping out' than a 'back' action. + backwin = back_state.create_window( + transition=( + 'in_scale' if self.main_window_is_auxiliary else 'in_left' + ) + ) + + uiv1.set_main_window( + backwin, + from_window=self, + is_back=True, + back_state=back_state, + suppress_warning=True, + ) + + def main_window_replace( + self, + new_window: MainWindow | Callable[[], MainWindow], + back_state: MainWindowState | None = None, + is_auxiliary: bool = False, + ) -> MainWindow | None: + """Replace ourself with a new MainWindow. + + Returns the new MainWindow. Will no-op and return None if + we are not allowed to replace the MainWindow. + """ + + ui = babase.app.ui_v1 + + # If they didn't provide an explicit back-state, calc one to + # recreate this window. + if back_state is None: + back_state = ui.save_current_main_window_state() + + # Save selection, etc. + self.main_window_save_shared_state() + + if not isinstance(new_window, MainWindow): + # If we're not in control, we're not allowed to change things. + if not self.main_window_has_control(): + babase.uilog.debug( + 'main_window_replace:' + ' no-op due to main_window_has_control() returning False.', + stack_info=True, + ) + return None + + new_window = new_window() + else: + # We originally were passed MainWindows directly, but want + # to phase this out, as it prevents our automatic selection + # save/restore from working (we need to save the old + # selection *before* the replacement window is created since + # the creation itself will change the selection). + warnings.warn( + 'Passing MainWindow objects to main_window_replace() is' + ' deprecated and will be removed when api 9 support ends.' + ' You should instead pass calls to generate MainWindow objects.' + ' So `main_window_replace(MyWin(some_arg))` would become' + ' `main_win_replace(lambda: MyWin(some_arg))`.', + DeprecationWarning, + stacklevel=2, + ) + + # In this old path, users should always check + # main_window_has_control() *before* creating new + # MainWindows and passing them in here. Kill the passed + # window and Error if it seems they did not. + if not self.main_window_has_control(): + new_window.get_root_widget().delete() + raise RuntimeError( + f'main_window_replace() called on a not-in-control window' + f' ({self}); always check main_window_has_control() before' + f' calling main_window_replace().' + ) + + # Give user a chance to do whatever. + try: + self.on_main_window_close() + except Exception: + logging.exception('Error in on_main_window_close() for %s.', self) + + # For auxiliary windows, use scale to give a feel that we're + # switching over to a totally separate 'side quest' ui. For + # regular back/forward relationships, shove the old out the left + # to give the feel that we're adding to a nav stack. + if is_auxiliary: + transition = 'out_scale' + else: + transition = 'out_left' + + # Transition ourself out. + _bauiv1.containerwidget(edit=self._root_widget, transition=transition) + babase.app.ui_v1.set_main_window( + new_window, + from_window=self, + back_state=back_state, + is_auxiliary=is_auxiliary, + suppress_warning=True, + ) + return new_window + + def on_main_window_close(self) -> None: + """Called before transitioning out a main window. + + A good opportunity to save window state/etc. + """ + + def get_main_window_state(self) -> MainWindowState: + """Return a WindowState to recreate this specific window. + + Used to gracefully return to a window from another window or ui + system. + """ + raise NotImplementedError() + + def main_window_should_preserve_selection(self) -> bool | None: + """Whether this window should auto-save/restore selection. + + If enabled, selection will be stored in the window's shared + state. See :meth:`~bauiv1.MainWindow.get_main_window_shared_state_id()` + for more info about main-window shared-state. + + The default value of None results in a warning to explicitly + override this (as the implicit default will change from False to + True after api 9 support ends). + """ + return None + + def get_main_window_shared_state_id(self) -> str | None: + """Provide a custom id for window shared state. + + Unlike :class:`~bauiv1.MainWindowState`, which is used to save + and restore a single main-window instance, shared-state is + intended to hold values that can apply to multiple instances of + a window. + + By default, shared state uses the window class as an index (so + is shared by all windows of the same class), but this method can + be overridden to provide more distinct states. For example, a + store-page main-window class might want to keep distinct states + for different sub-pages it can display instead of having a + single state for the whole class. + + Note that shared state only persists for the current run of the + app. + """ + return None + + def main_window_do_save_shared_state(self, state: dict) -> None: + """Save state into the provided shared state dict. + + Can be overridden by subclasses to save custom data. + """ + + def main_window_do_restore_shared_state(self, state: dict) -> None: + """Restore state from the provided shared state dict. + + Can be overridden by subclasses to restore custom data. + """ + + def _get_main_window_should_preserve_selection(self) -> bool: + # pylint: disable=assignment-from-none + val = self.main_window_should_preserve_selection() + if val is None: + warnings.warn( + f'{type(self)} should override' + f' main_window_should_preserve_selection()' + ' to return True or False.' + f' The current default is False (for backward compatibility)' + f' but it will change to True when api 9 support ends.', + FutureWarning, + stacklevel=2, + ) + val = False + return val + + +class MainWindowState: + """Persistent state for a specific MainWindow. + + This allows MainWindows to be automatically recreated for back-button + purposes, when switching app-modes, etc. + """ + + def __init__(self) -> None: + # The window that back/cancel navigation should take us to. + self.parent: MainWindowState | None = None + self.is_top_level: bool | None = None + self.is_auxiliary: bool | None = None + self.window_type: type[MainWindow] | None = None + + def create_window( + self, + transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, + origin_widget: bauiv1.Widget | None = None, + ) -> MainWindow: + """Create a window based on this state. + + WindowState child classes should override this to recreate their + particular type of window. + """ + raise NotImplementedError() + + +class BasicMainWindowState(MainWindowState): + """A basic MainWindowState. + + Holds some call to recreate a window and optionally a selection to + restore. + """ + + def __init__( + self, + create_call: Callable[ + [ + Literal['in_right', 'in_left', 'in_scale'] | None, + bauiv1.Widget | None, + ], + bauiv1.MainWindow, + ], + ) -> None: + super().__init__() + self.create_call = create_call + + @override + def create_window( + self, + transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, + origin_widget: bauiv1.Widget | None = None, + ) -> bauiv1.MainWindow: + win = self.create_call(transition, origin_widget) + + return win + + +class MainWindowAutoRecreateSuppress: + """Suppresses main-window auto-recreate while in existence. + + Can be instantiated and held by windows or processes within windows + for the purpose of preventing the main-window auto-recreate + mechanism from firing. This mechanism normally fires when the screen + is resized or the ui-scale is changed, allowing main-windows to be + recreated to adapt to the new configuration. + """ + + def __init__(self) -> None: + babase.app.ui_v1.window_auto_recreate_suppress_count += 1 + + def __del__(self) -> None: + babase.app.ui_v1.window_auto_recreate_suppress_count -= 1 diff --git a/dist/ba_data/python/bauiv1/onscreenkeyboard.py b/dist/ba_data/python/bauiv1/onscreenkeyboard.py index e5838b0..c9f775c 100644 --- a/dist/ba_data/python/bauiv1/onscreenkeyboard.py +++ b/dist/ba_data/python/bauiv1/onscreenkeyboard.py @@ -13,7 +13,7 @@ import babase import _bauiv1 from bauiv1._keyboard import Keyboard -from bauiv1._uitypes import Window +from bauiv1._window import Window if TYPE_CHECKING: from babase import StringEditAdapter diff --git a/dist/ba_data/python/bauiv1lib/account/settings.py b/dist/ba_data/python/bauiv1lib/account/settings.py index b42290e..900072c 100644 --- a/dist/ba_data/python/bauiv1lib/account/settings.py +++ b/dist/ba_data/python/bauiv1lib/account/settings.py @@ -13,6 +13,7 @@ from bacommon.login import LoginType import bacommon.cloud import bauiv1 as bui +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top from bauiv1lib.connectivity import wait_for_connectivity @@ -90,6 +91,11 @@ class AccountSettingsWindow(bui.MainWindow): self._scroll_height = target_height - 33 scroll_bottom = yoffs - 61 - self._scroll_height + # Go with full-screen scrollable area in small ui. + if uiscale is bui.UIScale.SMALL: + self._scroll_height += 35 + scroll_bottom -= 3 + self._sign_in_button = None self._sign_in_text = None @@ -115,7 +121,7 @@ class AccountSettingsWindow(bui.MainWindow): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - toolbar_visibility=('menu_full'), + toolbar_visibility='menu_full', toolbar_cancel_button_style=( 'close' if auxiliary_style else 'back' ), @@ -134,6 +140,7 @@ class AccountSettingsWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(51, yoffs - 52.0), size=(60, 56), scale=0.8, @@ -149,6 +156,37 @@ class AccountSettingsWindow(bui.MainWindow): ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + highlight=False, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), + claims_left_right=True, + selection_loops_to_parent=True, + border_opacity=0.4, + ) + + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL: + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + titleyoffs = -45.0 if uiscale is bui.UIScale.SMALL else -28.0 titlescale = 0.7 if uiscale is bui.UIScale.SMALL else 1.0 bui.textwidget( @@ -166,21 +204,8 @@ class AccountSettingsWindow(bui.MainWindow): v_align='center', ) - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - scroll_bottom, - ), - claims_left_right=True, - selection_loops_to_parent=True, - border_opacity=0.4, - ) self._subcontainer: bui.Widget | None = None self._refresh() - self._restore_state() @override def get_main_window_state(self) -> bui.MainWindowState: @@ -193,8 +218,8 @@ class AccountSettingsWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _update(self) -> None: plus = bui.app.plus @@ -399,6 +424,12 @@ class AccountSettingsWindow(bui.MainWindow): if self._subcontainer is not None: self._subcontainer.delete() self._sub_height = 90.0 + + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + self._sub_height += 35 + if show_signed_in_as: self._sub_height += signed_in_as_space self._sub_height += via_space * len(via_lines) @@ -449,6 +480,10 @@ class AccountSettingsWindow(bui.MainWindow): first_selectable = None v = self._sub_height - 10.0 + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + v -= 35 + assert bui.app.classic is not None self._account_name_text: bui.Widget | None if show_signed_in_as: @@ -580,8 +615,9 @@ class AccountSettingsWindow(bui.MainWindow): if show_google_play_sign_in_button: button_width = 350 v -= sign_in_button_space - self._sign_in_google_play_button = btn = bui.buttonwidget( + btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|signingoogleplay', position=((self._sub_width - button_width) * 0.5, v - 20), autoselect=True, size=(button_width, 60), @@ -620,8 +656,9 @@ class AccountSettingsWindow(bui.MainWindow): if show_game_center_sign_in_button: button_width = 350 v -= sign_in_button_space - self._sign_in_google_play_button = btn = bui.buttonwidget( + btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|signingamecenter', position=((self._sub_width - button_width) * 0.5, v - 20), autoselect=True, size=(button_width, 60), @@ -662,6 +699,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= sign_in_button_space self._sign_in_v2_proxy_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|signinv2', position=((self._sub_width - button_width) * 0.5, v - 20), autoselect=True, size=(button_width, 60), @@ -729,6 +767,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= sign_in_button_space + deprecated_space self._sign_in_device_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|signindevice', position=((self._sub_width - button_width) * 0.5, v - 20), autoselect=True, size=(button_width, 60), @@ -816,6 +855,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= manage_account_button_space self._manage_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|manage', position=((self._sub_width - button_width) * 0.5, v), autoselect=True, size=(button_width, 60), @@ -837,6 +877,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= create_account_button_space self._create_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|create', position=((self._sub_width - button_width) * 0.5, v - 30), autoselect=True, size=(button_width, 60), @@ -870,6 +911,7 @@ class AccountSettingsWindow(bui.MainWindow): ) self._game_center_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|gamecenter', position=((self._sub_width - button_width) * 0.5, v), color=(0.55, 0.5, 0.6), textcolor=(0.75, 0.7, 0.8), @@ -914,6 +956,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= leaderboards_button_space self._leaderboards_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|leaderboards', position=((self._sub_width - button_width) * 0.5, v), color=(0.55, 0.5, 0.6), textcolor=(0.75, 0.7, 0.8), @@ -977,6 +1020,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= sign_out_button_space self._sign_out_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|signout', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), label=bui.Lstr(resource=f'{self._r}.signOutText'), @@ -996,6 +1040,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= cancel_sign_in_button_space self._cancel_sign_in_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|cancelsignin', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), label=bui.Lstr(resource='cancelText'), @@ -1015,6 +1060,7 @@ class AccountSettingsWindow(bui.MainWindow): v -= delete_account_button_space self._delete_account_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|deleteaccount', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), label=bui.Lstr(resource=f'{self._r}.deleteAccountText'), @@ -1355,34 +1401,6 @@ class AccountSettingsWindow(bui.MainWindow): assert self._sign_in_v2_proxy_button is not None V2ProxySignInWindow(origin_widget=self._sign_in_v2_proxy_button) - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._back_button: - sel_name = 'Back' - elif sel == self._scrollwidget: - sel_name = 'Scroll' - else: - raise ValueError('unrecognized selection') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'Scroll': - sel = self._scrollwidget - else: - sel = self._back_button - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) - def show_what_is_legacy_unlinking_page() -> None: """Show the webpage describing legacy unlinking.""" diff --git a/dist/ba_data/python/bauiv1lib/account/signin.py b/dist/ba_data/python/bauiv1lib/account/signin.py index 0b22723..4cdf885 100644 --- a/dist/ba_data/python/bauiv1lib/account/signin.py +++ b/dist/ba_data/python/bauiv1lib/account/signin.py @@ -35,13 +35,16 @@ def _show_account_settings() -> None: if isinstance(prev_main_window, AccountSettingsWindow): return + ui = bui.app.ui_v1 + # Set our new main window. - bui.app.ui_v1.set_main_window( + ui.set_main_window( AccountSettingsWindow( close_once_signed_in=True, origin_widget=bui.get_special_widget('account_button'), ), - from_window=False, + back_state=ui.save_current_main_window_state(), + from_window=False, # Don't check where we're coming from. is_auxiliary=True, suppress_warning=True, ) diff --git a/dist/ba_data/python/bauiv1lib/account/v2proxy.py b/dist/ba_data/python/bauiv1lib/account/v2proxy.py index ff26455..2ddf981 100644 --- a/dist/ba_data/python/bauiv1lib/account/v2proxy.py +++ b/dist/ba_data/python/bauiv1lib/account/v2proxy.py @@ -23,6 +23,7 @@ class V2ProxySignInWindow(bui.Window): self._proxyid: str | None = None self._proxykey: str | None = None self._overlay_web_browser_open = False + self._idprefix = bui.app.ui_v1.new_id_prefix('resourcetypeinfo') assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale @@ -256,6 +257,7 @@ class V2ProxySignInWindow(bui.Window): if bui.is_browser_likely_available(): bui.buttonwidget( parent=self._root_widget, + id=f'{self._idprefix}|address', position=( (self._width * 0.5 - button_width * 0.5), self._height - 185, diff --git a/dist/ba_data/python/bauiv1lib/achievements.py b/dist/ba_data/python/bauiv1lib/achievements.py index 9f62986..3272a4f 100644 --- a/dist/ba_data/python/bauiv1lib/achievements.py +++ b/dist/ba_data/python/bauiv1lib/achievements.py @@ -6,6 +6,7 @@ from __future__ import annotations from typing import override +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui @@ -58,10 +59,15 @@ class AchievementsWindow(bui.MainWindow): scroll_height = target_height - 25 scroll_bottom = yoffs - 54 - scroll_height + # Go with full-screen scrollable area in small ui. + if uiscale is bui.UIScale.SMALL: + scroll_height += 30 + scroll_bottom -= 3 + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - toolbar_visibility=('menu_full'), + toolbar_visibility='menu_full', toolbar_cancel_button_style=( 'close' if auxiliary_style else 'back' ), @@ -81,6 +87,7 @@ class AchievementsWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', autoselect=True, position=(50, yoffs - 48), size=(60, 60), @@ -100,6 +107,43 @@ class AchievementsWindow(bui.MainWindow): achievements = bui.app.classic.ach.achievements num_complete = len([a for a in achievements if a.complete]) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|scroll', + size=(scroll_width, scroll_height), + position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), + capture_arrows=True, + simple_culling_v=10, + border_opacity=0.4, + ) + bui.containerwidget( + edit=self._root_widget, selected_child=self._scrollwidget + ) + bui.widget(edit=self._scrollwidget, autoselect=True) + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._scrollwidget, + left_widget=bui.get_special_widget('back_button'), + ) + + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL: + scroll_fade_top( + self._root_widget, + self._width * 0.5 - scroll_width * 0.5, + scroll_bottom, + scroll_width, + scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - scroll_width * 0.5, + scroll_bottom, + scroll_width, + scroll_height, + ) + # In small UI mode when the screen is narrow enough we need to # go with a smaller title to avoid it overlapping with toolbar # bits. @@ -144,59 +188,6 @@ class AchievementsWindow(bui.MainWindow): color=bui.app.ui_v1.title_color, ) - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - size=(scroll_width, scroll_height), - position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), - capture_arrows=True, - simple_culling_v=10, - border_opacity=0.4, - ) - bui.widget(edit=self._scrollwidget, autoselect=True) - if uiscale is bui.UIScale.SMALL: - bui.widget( - edit=self._scrollwidget, - left_widget=bui.get_special_widget('back_button'), - ) - - # Add some blotches so our contents fades out as it approaches - # the bottom toolbar. - if uiscale is bui.UIScale.SMALL: - blotchwidth = 500.0 - blotchheight = 200.0 - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - - scroll_width * 0.5 - + 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - + scroll_width * 0.5 - - 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button ) @@ -205,21 +196,32 @@ class AchievementsWindow(bui.MainWindow): sub_width = scroll_width - 25 sub_height = 85 + len(achievements) * incr + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + sub_height += 30 + eq_rsrc = 'coopSelectWindow.powerRankingPointsEqualsText' pts_rsrc = 'coopSelectWindow.powerRankingPointsText' self._subcontainer = bui.containerwidget( parent=self._scrollwidget, + id=f'{self.main_window_id_prefix}|subcontainer', size=(sub_width, sub_height), background=False, ) + basey = sub_height + + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + basey -= 30 + total_pts = 0 for i, ach in enumerate(achievements): complete = ach.complete bui.textwidget( parent=self._subcontainer, - position=(sub_width * 0.08 - 5, sub_height - 20 - incr * i), + position=(sub_width * 0.08 - 5, basey - 20 - incr * i), maxwidth=20, scale=0.5, color=(0.6, 0.6, 0.7) if complete else (0.6, 0.6, 0.7, 0.2), @@ -234,9 +236,9 @@ class AchievementsWindow(bui.MainWindow): bui.imagewidget( parent=self._subcontainer, position=( - (sub_width * 0.10 + 1, sub_height - 20 - incr * i - 9) + (sub_width * 0.10 + 1, basey - 20 - incr * i - 9) if complete - else (sub_width * 0.10 - 4, sub_height - 20 - incr * i - 14) + else (sub_width * 0.10 - 4, basey - 20 - incr * i - 14) ), size=(18, 18) if complete else (27, 27), opacity=1.0 if complete else 0.3, @@ -248,7 +250,7 @@ class AchievementsWindow(bui.MainWindow): parent=self._subcontainer, position=( sub_width * 0.10 - 4, - sub_height - 25 - incr * i - 9, + basey - 25 - incr * i - 9, ), size=(28, 28), color=(2, 1.4, 0), @@ -256,7 +258,7 @@ class AchievementsWindow(bui.MainWindow): ) bui.textwidget( parent=self._subcontainer, - position=(sub_width * 0.19, sub_height - 19 - incr * i + 3), + position=(sub_width * 0.19, basey - 19 - incr * i + 3), maxwidth=sub_width * 0.62, scale=0.6, flatness=1.0, @@ -270,7 +272,7 @@ class AchievementsWindow(bui.MainWindow): bui.textwidget( parent=self._subcontainer, - position=(sub_width * 0.19, sub_height - 19 - incr * i - 10), + position=(sub_width * 0.19, basey - 19 - incr * i - 10), maxwidth=sub_width * 0.62, scale=0.4, flatness=1.0, @@ -295,7 +297,7 @@ class AchievementsWindow(bui.MainWindow): opacity=0.0 if complete else 1.0, position=( sub_width * 0.92 - 40.0 - chestsize * 0.5, - sub_height - 20 - incr * i - chestsize * 0.5, + basey - 20 - incr * i - chestsize * 0.5, ), size=(chestsize, chestsize), color=chestdisplayinfo.color, @@ -308,7 +310,7 @@ class AchievementsWindow(bui.MainWindow): pts = ach.power_ranking_value bui.textwidget( parent=self._subcontainer, - position=(sub_width * 0.92, sub_height - 20 - incr * i), + position=(sub_width * 0.92, basey - 20 - incr * i), maxwidth=sub_width * 0.15, color=(0.7, 0.8, 1.0) if complete else (0.9, 0.9, 1.0, 0.3), flatness=1.0, @@ -328,7 +330,7 @@ class AchievementsWindow(bui.MainWindow): parent=self._subcontainer, position=( sub_width * 1.0, - sub_height - 20 - incr * len(achievements), + basey - 20 - incr * len(achievements), ), maxwidth=sub_width * 0.5, scale=0.7, @@ -362,3 +364,7 @@ class AchievementsWindow(bui.MainWindow): transition=transition, origin_widget=origin_widget ) ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True diff --git a/dist/ba_data/python/bauiv1lib/chest.py b/dist/ba_data/python/bauiv1lib/chest.py index ec75345..047207d 100644 --- a/dist/ba_data/python/bauiv1lib/chest.py +++ b/dist/ba_data/python/bauiv1lib/chest.py @@ -33,6 +33,7 @@ class ChestWindow(bui.MainWindow): index: int, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + auxiliary_style: bool = True, ): # pylint: disable=too-many-statements self._index = index @@ -101,6 +102,9 @@ class ChestWindow(bui.MainWindow): root_widget=bui.containerwidget( size=(self._width, self._height), toolbar_visibility='menu_full', + toolbar_cancel_button_style=( + 'close' if auxiliary_style else 'back' + ), scale=scale, ), transition=transition, @@ -136,10 +140,14 @@ class ChestWindow(bui.MainWindow): btn = bui.buttonwidget( parent=self._root_widget, position=(50, self._yoffs - 44), - size=(60, 55), + size=(60, 60), scale=0.8, - label=bui.charstr(bui.SpecialChar.BACK), - button_type='backSmall', + label=bui.charstr( + bui.SpecialChar.CLOSE + if auxiliary_style + else bui.SpecialChar.BACK + ), + button_type=None if auxiliary_style else 'backSmall', extra_touch_border_scale=2.0, autoselect=True, on_activate_call=self.main_window_back, @@ -210,6 +218,14 @@ class ChestWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # This doesn't really benefit us since we do lots of widget + # creates/destroys throughout our lifetime and also we're an + # auxliary window so should never need to restore toolbar + # selections. + return False + def _update_time_display(self, unlock_time: datetime.datetime) -> None: # Once our target text widget disappears, kill our timer. if not self._time_string_text: @@ -961,6 +977,7 @@ class ChestWindow(bui.MainWindow): self, response: bacommon.bs.ChestActionResponse ) -> float: # pylint: disable=too-many-locals + # pylint: disable=too-many-statements from baclassic import show_display_item @@ -1101,9 +1118,10 @@ class ChestWindow(bui.MainWindow): # through highlighting our options and stop on the winner when # the chest opens. To do this, we start at the end at the prize # and work backwards setting timers. + ease_out = False # Experimenting... if self._prizesets: toffs2 = toffsopen - 0.01 - amt = 0.02 + amt = 0.25 if ease_out else 0.02 i = self._prizeindex while toffs2 > 0.0: bui.apptimer( @@ -1111,7 +1129,10 @@ class ChestWindow(bui.MainWindow): bui.WeakCall(self._highlight_odds_row, i), ) toffs2 -= amt - amt *= 1.05 * random.uniform(0.9, 1.1) + if ease_out: + amt = max(0.032, amt * 0.75 * random.uniform(0.9, 1.1)) + else: + amt *= 1.05 * random.uniform(0.9, 1.1) i = (i - 1) % len(self._prizesets) # Let the caller know how long we'll take in case they want to diff --git a/dist/ba_data/python/bauiv1lib/colorpicker.py b/dist/ba_data/python/bauiv1lib/colorpicker.py index 31f171c..476e059 100644 --- a/dist/ba_data/python/bauiv1lib/colorpicker.py +++ b/dist/ba_data/python/bauiv1lib/colorpicker.py @@ -336,7 +336,6 @@ class ColorPickerExact(PopupWindow): # Store the current text for our next comparison. self._hex_prev_text = hextext - # noinspection PyUnresolvedReferences def _update_for_color(self) -> None: if not self.root_widget: return diff --git a/dist/ba_data/python/bauiv1lib/config.py b/dist/ba_data/python/bauiv1lib/config.py index 1845330..cd28fba 100644 --- a/dist/ba_data/python/bauiv1lib/config.py +++ b/dist/ba_data/python/bauiv1lib/config.py @@ -34,6 +34,7 @@ class ConfigCheckBox: maxwidth: float | None = None, autoselect: bool = True, value_change_call: Callable[[Any], Any] | None = None, + check_box_id: str | None = None, ): if displayname is None: displayname = configkey @@ -41,6 +42,7 @@ class ConfigCheckBox: self._configkey = configkey self.widget = bui.checkboxwidget( parent=parent, + id=check_box_id, autoselect=autoselect, position=position, size=size, @@ -51,8 +53,8 @@ class ConfigCheckBox: scale=scale, maxwidth=maxwidth, ) - # complain if we outlive our checkbox - bui.uicleanupcheck(self, self.widget) + # Complain if we outlive our checkbox. + bui.app.ui_v1.add_ui_cleanup_check(self, self.widget) def _value_changed(self, val: bool) -> None: cfg = bui.app.config @@ -98,7 +100,9 @@ class ConfigNumberEdit: as_percent: bool = False, fallback_value: float = 0.0, f: int = 1, + idprefix: str | None = None, ): + # pylint: disable=too-many-locals if displayname is None: displayname = configkey @@ -143,6 +147,7 @@ class ConfigNumberEdit: ) self.minusbutton = bui.buttonwidget( parent=parent, + id=None if idprefix is None else f'{idprefix}|minus', position=(position[0] + 230 + xoffset, position[1]), size=(28, 28), label='-', @@ -153,6 +158,7 @@ class ConfigNumberEdit: ) self.plusbutton = bui.buttonwidget( parent=parent, + id=None if idprefix is None else f'{idprefix}|plus', position=(position[0] + 280 + xoffset, position[1]), size=(28, 28), label='+', @@ -162,7 +168,7 @@ class ConfigNumberEdit: enable_sound=changesound, ) # Complain if we outlive our widgets. - bui.uicleanupcheck(self, self.nametext) + bui.app.ui_v1.add_ui_cleanup_check(self, self.nametext) self._update_display() def _up(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/confirm.py b/dist/ba_data/python/bauiv1lib/confirm.py index 094569d..5a3be84 100644 --- a/dist/ba_data/python/bauiv1lib/confirm.py +++ b/dist/ba_data/python/bauiv1lib/confirm.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -import logging import bauiv1 as bui @@ -30,8 +29,15 @@ class ConfirmWindow: ok_text: str | bui.Lstr | None = None, cancel_text: str | bui.Lstr | None = None, origin_widget: bui.Widget | None = None, + permanent_ok_fade: bool = False, ): # pylint: disable=too-many-locals + + ui = bui.app.ui_v1 + + # Make sure our widgets have globally unique ids. + self._id_prefix = ui.new_id_prefix('confirm') + if text is None: text = bui.Lstr(resource='areYouSureText') if ok_text is None: @@ -42,6 +48,8 @@ class ConfirmWindow: width = max(width, 360) self._action = action + self._permanent_ok_fade = permanent_ok_fade + # If they provided an origin-widget, scale up from that. self._transition_out: str | None scale_origin: tuple[float, float] | None @@ -87,6 +95,7 @@ class ConfirmWindow: if cancel_button: cbtn = btn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._id_prefix}|cancel', autoselect=True, position=(20, 20), size=(150, 50), @@ -103,6 +112,7 @@ class ConfirmWindow: cbtn = None btn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._id_prefix}|ok', autoselect=True, position=(ok_button_h, 20), size=(150, 50), @@ -140,6 +150,7 @@ class ConfirmWindow: return bui.containerwidget( edit=self.root_widget, + darken_behind_is_permanent=self._permanent_ok_fade, transition=( 'out_left' if self._transition_out is None @@ -159,10 +170,9 @@ class QuitWindow: swish: bool = False, origin_widget: bui.Widget | None = None, ): - classic = bui.app.classic - assert classic is not None ui = bui.app.ui_v1 - app = bui.app + platform = bui.app.env.platform + self._quit_type = quit_type # If there's already one of us up somewhere, kill it. @@ -172,18 +182,13 @@ class QuitWindow: if swish: bui.getsound('swish').play() - if app.classic is None: - if bui.do_once(): - logging.warning( - 'QuitWindow needs to be updated to work without classic.' - ) - quit_resource = 'exitGameText' - else: - quit_resource = ( - 'quitGameText' - if app.classic.platform == 'mac' - else 'exitGameText' - ) + # Generally Macs say Quit and other stuff says Exit + quit_resource = ( + 'quitGameText' + if platform is type(platform).MACOS + else 'exitGameText' + ) + self._root_widget = ui.quit_window = ConfirmWindow( bui.Lstr( resource=quit_resource, @@ -195,4 +200,10 @@ class QuitWindow: else bui.quit(confirm=False) ), origin_widget=origin_widget, + # In situations where the quit action will *actually* kill + # the process, tell the confirm to not fade back in when the + # confirm button is pressed. It just looks a bit visually + # odd if the confirm fades back in just before the app fades + # out to quit. + permanent_ok_fade=not bui.app.env.supports_soft_quit, ).root_widget diff --git a/dist/ba_data/python/bauiv1lib/connectivity.py b/dist/ba_data/python/bauiv1lib/connectivity.py index e1a810b..4b801d5 100644 --- a/dist/ba_data/python/bauiv1lib/connectivity.py +++ b/dist/ba_data/python/bauiv1lib/connectivity.py @@ -49,6 +49,7 @@ class WaitForConnectivityWindow(bui.Window): self._on_cancel = on_cancel self._width = 650 self._height = 300 + self._idprefix = bui.app.ui_v1.new_id_prefix('connectivity') super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), @@ -90,6 +91,7 @@ class WaitForConnectivityWindow(bui.Window): self._info_text_str = '' cancel_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self._idprefix}|cancel', autoselect=True, position=(50, 30), size=(150, 50), diff --git a/dist/ba_data/python/bauiv1lib/coop/browser.py b/dist/ba_data/python/bauiv1lib/coop/browser.py index ad87150..5ebfe91 100644 --- a/dist/ba_data/python/bauiv1lib/coop/browser.py +++ b/dist/ba_data/python/bauiv1lib/coop/browser.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, override import bauiv1 as bui +from bauiv1lib.utils import scroll_fade_top, scroll_fade_bottom if TYPE_CHECKING: from typing import Any @@ -131,8 +132,14 @@ class CoopBrowserWindow(bui.MainWindow): yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 self._scroll_width = target_width - self._scroll_height = target_height - 40 - self._scroll_bottom = yoffs - 70 - self._scroll_height + self._scroll_height = target_height - ( + -5 if uiscale is bui.UIScale.SMALL else 40 + ) + self._scroll_bottom = ( + yoffs + - (27 if uiscale is bui.UIScale.SMALL else 70) + - self._scroll_height + ) super().__init__( root_widget=bui.containerwidget( @@ -154,6 +161,7 @@ class CoopBrowserWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(75, yoffs - 48.0), size=(60, 50), scale=1.2, @@ -184,7 +192,50 @@ class CoopBrowserWindow(bui.MainWindow): # Don't want initial construction affecting our last-selected. self._do_selection_callbacks = False - bui.textwidget( + + self._selected_row = cfg.get('Selected Coop Row', None) + + self._subcontainerwidth = 800.0 + self._subcontainerheight = 1400.0 + + # Allow empty space at top when our toolbar overlaps scroll area. + if uiscale is bui.UIScale.SMALL: + self._subcontainerheight += 40 + + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + highlight=False, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), + simple_culling_v=10.0, + claims_left_right=True, + selection_loops_to_parent=True, + border_opacity=0.4, + ) + + # Splotches at the top to fade scrollable content as it hits + # toolbars. + if uiscale is bui.UIScale.SMALL and bool(True): + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + + # Title. + ttxt = bui.textwidget( parent=self._root_widget, position=( self._width * 0.5, @@ -201,61 +252,7 @@ class CoopBrowserWindow(bui.MainWindow): maxwidth=tmaxw, v_align='center', ) - - self._selected_row = cfg.get('Selected Coop Row', None) - - self._subcontainerwidth = 800.0 - self._subcontainerheight = 1400.0 - - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - self._scroll_bottom, - ), - simple_culling_v=10.0, - claims_left_right=True, - selection_loops_to_parent=True, - border_opacity=0.4, - ) - - if uiscale is bui.UIScale.SMALL: - blotchwidth = 500.0 - blotchheight = 200.0 - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - - self._scroll_width * 0.5 - + 60.0 - - blotchwidth * 0.5, - self._scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - + self._scroll_width * 0.5 - - 60.0 - - blotchwidth * 0.5, - self._scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + bui.widget(edit=ttxt, depth_range=(0.9, 1.0)) self._subcontainer: bui.Widget | None = None @@ -266,7 +263,6 @@ class CoopBrowserWindow(bui.MainWindow): self._fg_state = app.fg_state self._refresh() - self._restore_state() # Even though we might display cached tournament data immediately, we # don't consider it valid until we've pinged. @@ -307,6 +303,10 @@ class CoopBrowserWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + @override def on_main_window_close(self) -> None: self._save_state() @@ -532,6 +532,7 @@ class CoopBrowserWindow(bui.MainWindow): un_sel_textcolor = (0.6, 0.6, 0.6) self._easy_button = bui.buttonwidget( parent=parent_widget, + id=f'{self.main_window_id_prefix}|easy', position=(h + 30, v2 + 105), size=(120, 70), label=bui.Lstr(resource='difficultyEasyText'), @@ -562,6 +563,7 @@ class CoopBrowserWindow(bui.MainWindow): self._hard_button = bui.buttonwidget( parent=parent_widget, + id=f'{self.main_window_id_prefix}|hard', position=(h + 30, v2 + 32), size=(120, 70), label=bui.Lstr(resource='difficultyHardText'), @@ -716,6 +718,11 @@ class CoopBrowserWindow(bui.MainWindow): v = self._subcontainerheight - 90 + # Move down past toolbar when it overlaps us. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + v -= 40.0 + self._campaign_percent_text = bui.textwidget( parent=w_parent, position=(h_base + 27, v + 30), @@ -727,7 +734,10 @@ class CoopBrowserWindow(bui.MainWindow): scale=1.1, ) - row_v_show_buffer = 80 + # Need a bit more show-buffer on top to account for our + # non-selectable titles above our selectable button rows. + row_v_show_buffer_top = 120 + row_v_show_buffer_bottom = 70 v -= 198 h_scroll = bui.hscrollwidget( @@ -743,8 +753,8 @@ class CoopBrowserWindow(bui.MainWindow): self._campaign_h_scroll = h_scroll bui.widget( edit=h_scroll, - show_buffer_top=row_v_show_buffer, - show_buffer_bottom=row_v_show_buffer, + show_buffer_top=row_v_show_buffer_top, + show_buffer_bottom=row_v_show_buffer_bottom, autoselect=True, ) if self._selected_row == 'campaign': @@ -778,6 +788,7 @@ class CoopBrowserWindow(bui.MainWindow): ) self._tournament_info_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|tourneyinfo', label='?', size=(20, 20), text_scale=0.6, @@ -837,8 +848,8 @@ class CoopBrowserWindow(bui.MainWindow): ) bui.widget( edit=h_scroll, - show_buffer_top=row_v_show_buffer, - show_buffer_bottom=row_v_show_buffer, + show_buffer_top=row_v_show_buffer_top, + show_buffer_bottom=row_v_show_buffer_bottom, autoselect=True, ) if self._selected_row == 'tournament' + str(i + 1): @@ -920,8 +931,8 @@ class CoopBrowserWindow(bui.MainWindow): ) bui.widget( edit=h_scroll, - show_buffer_top=row_v_show_buffer, - show_buffer_bottom=1.5 * row_v_show_buffer, + show_buffer_top=row_v_show_buffer_top, + show_buffer_bottom=1.5 * row_v_show_buffer_bottom, autoselect=True, ) if self._selected_row == 'custom': @@ -1168,18 +1179,6 @@ class CoopBrowserWindow(bui.MainWindow): ) return - # assert required_purchases - # if plus.get_v1_account_state() != 'signed_in': - # show_sign_in_prompt() - # else: - # # Hmm; just show the first requirement. They can come - # # back to see more after they purchase the first. - # PurchaseWindow( - # items=[required_purchases[0]], - # origin_widget=tournament_button.button, - # ) - # return - if tournament_button.time_remaining <= 0: bui.screenmessage( bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0) @@ -1197,40 +1196,11 @@ class CoopBrowserWindow(bui.MainWindow): def _save_state(self) -> None: cfg = bui.app.config - try: - sel = self._root_widget.get_selected_child() - if sel == self._back_button: - sel_name = 'Back' - elif sel == self._scrollwidget: - sel_name = 'Scroll' - else: - raise ValueError('unrecognized selection') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} - except Exception: - logging.exception('Error saving state for %s.', self) - cfg['Selected Coop Row'] = self._selected_row cfg['Selected Coop Custom Level'] = self._selected_custom_level cfg['Selected Coop Campaign Level'] = self._selected_campaign_level cfg.commit() - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'Scroll': - sel = self._scrollwidget - else: - sel = self._scrollwidget - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) - def sel_change(self, row: str, game: str) -> None: """(internal)""" if self._do_selection_callbacks: diff --git a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py index 55a06d7..dec0dc8 100644 --- a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py @@ -79,6 +79,8 @@ class GameButton: show_buffer_top=50, show_buffer_left=400, show_buffer_right=200, + # We handle reselection manually for these so no ids. + allow_preserve_selection=False, ) if select: bui.containerwidget( diff --git a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py index b1bc16c..e08c26d 100644 --- a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py @@ -60,6 +60,8 @@ class TournamentButton: show_buffer_top=50, show_buffer_left=400, show_buffer_right=200, + # We handle reselection manually for these so no ids. + allow_preserve_selection=False, ) if select: bui.containerwidget( @@ -331,6 +333,11 @@ class TournamentButton: color=value_color, flatness=1.0, ) + # We handle reselection manually for these so no ids. + bui.widget( + edit=self.current_leader_name_text, allow_preserve_selection=False + ) + self.current_leader_score_text = bui.textwidget( parent=parent, draw_controller=btn, @@ -357,6 +364,8 @@ class TournamentButton: text_scale=0.6, on_activate_call=bui.WeakCall(self._show_scores), ) + # We handle reselection manually for these so no ids. + bui.widget(edit=self.more_scores_button, allow_preserve_selection=False) bui.widget( edit=self.current_leader_name_text, down_widget=self.more_scores_button, @@ -446,6 +455,10 @@ class TournamentButton: def _update_lock_state(self) -> None: + # no-op if our widget is dead. + if not self.button: + return + if self.game is None: return diff --git a/dist/ba_data/python/bauiv1lib/credits.py b/dist/ba_data/python/bauiv1lib/credits.py index dcdf6e1..be895e4 100644 --- a/dist/ba_data/python/bauiv1lib/credits.py +++ b/dist/ba_data/python/bauiv1lib/credits.py @@ -94,8 +94,9 @@ class CreditsWindow(bui.MainWindow): else: btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(40, yoffs - 46), - size=(60, 48), + size=(60, 55), scale=0.8, label=bui.charstr(bui.SpecialChar.BACK), button_type='backSmall', @@ -113,15 +114,16 @@ class CreditsWindow(bui.MainWindow): center_small_content_horizontally=True, ) - bui.widget( - edit=scroll, - right_widget=bui.get_special_widget('squad_button'), - ) if uiscale is bui.UIScale.SMALL: bui.widget( edit=scroll, left_widget=bui.get_special_widget('back_button'), ) + bui.widget( + edit=scroll, + right_widget=bui.get_special_widget('squad_button'), + ) + bui.containerwidget(edit=self._root_widget, selected_child=scroll) def _format_names(names2: Sequence[str], inset: float) -> str: sval = '' @@ -340,6 +342,7 @@ class CreditsWindow(bui.MainWindow): container = self._subcontainer = bui.containerwidget( parent=scroll, + id=f'{self.main_window_id_prefix}|sub', size=(self._sub_width, self._sub_height), background=False, claims_left_right=False, @@ -396,3 +399,7 @@ class CreditsWindow(bui.MainWindow): transition=transition, origin_widget=origin_widget ) ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True diff --git a/dist/ba_data/python/bauiv1lib/fileselector.py b/dist/ba_data/python/bauiv1lib/fileselector.py index b99164f..e5f1588 100644 --- a/dist/ba_data/python/bauiv1lib/fileselector.py +++ b/dist/ba_data/python/bauiv1lib/fileselector.py @@ -164,6 +164,11 @@ class FileSelectorWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # TODO - wire this up. + return False + def _on_up_press(self) -> None: self._on_entry_activated('..') diff --git a/dist/ba_data/python/bauiv1lib/gather/__init__.py b/dist/ba_data/python/bauiv1lib/gather/__init__.py index 93c9073..fe25edb 100644 --- a/dist/ba_data/python/bauiv1lib/gather/__init__.py +++ b/dist/ba_data/python/bauiv1lib/gather/__init__.py @@ -2,390 +2,7 @@ # """Provides UI for inviting/joining friends.""" -from __future__ import annotations -import weakref -import logging -from enum import Enum -from typing import override, TYPE_CHECKING +from bauiv1lib.gather._gather import GatherTab, GatherWindow -from bauiv1lib.tabs import TabRow -import bauiv1 as bui - -if TYPE_CHECKING: - from bauiv1lib.play import PlaylistSelectContext - - -class GatherTab: - """Defines a tab for use in the gather UI.""" - - def __init__(self, window: GatherWindow) -> None: - self._window = weakref.ref(window) - - @property - def window(self) -> GatherWindow: - """The GatherWindow that this tab belongs to.""" - window = self._window() - if window is None: - raise bui.NotFoundError("GatherTab's window no longer exists.") - return window - - def on_activate( - self, - parent_widget: bui.Widget, - tab_button: bui.Widget, - region_width: float, - region_height: float, - region_left: float, - region_bottom: float, - ) -> bui.Widget: - """Called when the tab becomes the active one. - - The tab should create and return a container widget covering the - specified region. - """ - # pylint: disable=too-many-positional-arguments - raise RuntimeError('Should not get here.') - - def on_deactivate(self) -> None: - """Called when the tab will no longer be the active one.""" - - def save_state(self) -> None: - """Called when the parent window is saving state.""" - - def restore_state(self) -> None: - """Called when the parent window is restoring state.""" - - -class GatherWindow(bui.MainWindow): - """Window for joining/inviting friends.""" - - class TabID(Enum): - """Our available tab types.""" - - ABOUT = 'about' - INTERNET = 'internet' - PRIVATE = 'private' - NEARBY = 'nearby' - MANUAL = 'manual' - - def __init__( - self, - transition: str | None = 'in_right', - origin_widget: bui.Widget | None = None, - ): - # pylint: disable=too-many-locals - # pylint: disable=cyclic-import - from bauiv1lib.gather.abouttab import AboutGatherTab - from bauiv1lib.gather.manualtab import ManualGatherTab - from bauiv1lib.gather.privatetab import PrivateGatherTab - from bauiv1lib.gather.publictab import PublicGatherTab - from bauiv1lib.gather.nearbytab import NearbyGatherTab - - plus = bui.app.plus - assert plus is not None - - bui.set_analytics_screen('Gather Window') - uiscale = bui.app.ui_v1.uiscale - self._width = ( - 1640 - if uiscale is bui.UIScale.SMALL - else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 - ) - self._height = ( - 1000 - if uiscale is bui.UIScale.SMALL - else 730 if uiscale is bui.UIScale.MEDIUM else 900 - ) - self._current_tab: GatherWindow.TabID | None = None - self._r = 'gatherWindow' - - # Do some fancy math to fill all available screen area up to the - # size of our backing container. This lets us fit to the exact - # screen shape at small ui scale. - screensize = bui.get_virtual_screen_size() - scale = ( - 1.4 - if uiscale is bui.UIScale.SMALL - else 0.88 if uiscale is bui.UIScale.MEDIUM else 0.66 - ) - # Calc screen size in our local container space and clamp to a - # bit smaller than our container size. - target_width = min(self._width - 130, screensize[0] / scale) - target_height = min(self._height - 130, screensize[1] / scale) - - # To get top/left coords, go to the center of our window and - # offset by half the width/height of our target area. - yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 - - self._scroll_width = target_width - self._scroll_height = target_height - 65 - self._scroll_bottom = yoffs - 93 - self._scroll_height - self._scroll_left = (self._width - self._scroll_width) * 0.5 - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - toolbar_visibility=( - 'menu_tokens' - if uiscale is bui.UIScale.SMALL - else 'menu_full' - ), - scale=scale, - ), - transition=transition, - origin_widget=origin_widget, - # We're affected by screen size only at small ui-scale. - refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, - ) - - if uiscale is bui.UIScale.SMALL: - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self.main_window_back - ) - self._back_button = None - else: - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(70, yoffs - 43), - size=(60, 60), - scale=1.1, - autoselect=True, - label=bui.charstr(bui.SpecialChar.BACK), - button_type='backSmall', - on_activate_call=self.main_window_back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - - bui.textwidget( - parent=self._root_widget, - position=( - ( - self._width * 0.5 - + ( - (self._scroll_width * -0.5 + 170.0 - 70.0) - if uiscale is bui.UIScale.SMALL - else 0.0 - ) - ), - yoffs - (64 if uiscale is bui.UIScale.SMALL else 4), - ), - size=(0, 0), - color=bui.app.ui_v1.title_color, - scale=1.3 if uiscale is bui.UIScale.SMALL else 1.0, - h_align='left' if uiscale is bui.UIScale.SMALL else 'center', - v_align='center', - text=(bui.Lstr(resource=f'{self._r}.titleText')), - maxwidth=135 if uiscale is bui.UIScale.SMALL else 320, - ) - - # Build up the set of tabs we want. - tabdefs: list[tuple[GatherWindow.TabID, bui.Lstr]] = [ - (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=f'{self._r}.publicText'), - ) - ) - tabdefs.append( - (self.TabID.PRIVATE, bui.Lstr(resource=f'{self._r}.privateText')) - ) - tabdefs.append( - (self.TabID.NEARBY, bui.Lstr(resource=f'{self._r}.nearbyText')) - ) - tabdefs.append( - (self.TabID.MANUAL, bui.Lstr(resource=f'{self._r}.manualText')) - ) - - tab_inset = 250.0 if uiscale is bui.UIScale.SMALL else 100.0 - - self._tab_row = TabRow( - self._root_widget, - tabdefs, - size=(self._scroll_width - 2.0 * tab_inset, 50), - pos=( - self._scroll_left + tab_inset, - self._scroll_bottom + self._scroll_height - 4.0, - ), - on_select_call=bui.WeakCall(self._set_tab), - ) - - # Now instantiate handlers for these tabs. - tabtypes: dict[GatherWindow.TabID, type[GatherTab]] = { - self.TabID.ABOUT: AboutGatherTab, - self.TabID.MANUAL: ManualGatherTab, - self.TabID.PRIVATE: PrivateGatherTab, - self.TabID.INTERNET: PublicGatherTab, - self.TabID.NEARBY: NearbyGatherTab, - } - self._tabs: dict[GatherWindow.TabID, GatherTab] = {} - for tab_id in self._tab_row.tabs: - tabtype = tabtypes.get(tab_id) - if tabtype is not None: - self._tabs[tab_id] = tabtype(self) - - # Eww; tokens meter may or may not be here; should be smarter - # about this. - bui.widget( - edit=self._tab_row.tabs[tabdefs[-1][0]].button, - right_widget=bui.get_special_widget('tokens_meter'), - ) - if uiscale is bui.UIScale.SMALL: - bui.widget( - edit=self._tab_row.tabs[tabdefs[0][0]].button, - left_widget=bui.get_special_widget('back_button'), - up_widget=bui.get_special_widget('back_button'), - ) - - # Not actually using a scroll widget anymore; just an image. - bui.imagewidget( - parent=self._root_widget, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - self._scroll_bottom, - ), - texture=bui.gettexture('scrollWidget'), - mesh_transparent=bui.getmesh('softEdgeOutside'), - opacity=0.4, - ) - self._tab_container: bui.Widget | None = None - - self._restore_state() - - @override - def get_main_window_state(self) -> bui.MainWindowState: - # Support recreating our window for back/refresh purposes. - cls = type(self) - return bui.BasicMainWindowState( - create_call=lambda transition, origin_widget: cls( - transition=transition, origin_widget=origin_widget - ) - ) - - @override - def on_main_window_close(self) -> None: - self._save_state() - - def playlist_select( - self, - origin_widget: bui.Widget, - context: PlaylistSelectContext, - ) -> None: - """Called by the private-hosting tab to select a playlist.""" - from bauiv1lib.play import PlayWindow - - # Avoid redundant window spawns. - if not self.main_window_has_control(): - return - - playwindow = PlayWindow( - origin_widget=origin_widget, playlist_select_context=context - ) - self.main_window_replace(playwindow) - - # Grab the newly-set main-window's back-state; that will lead us - # back here once we're done going down our main-window - # rabbit-hole for playlist selection. - context.back_state = playwindow.main_window_back_state - - def _set_tab(self, tab_id: TabID) -> None: - if self._current_tab is tab_id: - return - prev_tab_id = self._current_tab - self._current_tab = tab_id - - # We wanna preserve our current tab between runs. - cfg = bui.app.config - cfg['Gather Tab'] = tab_id.value - cfg.commit() - - # Update tab colors based on which is selected. - self._tab_row.update_appearance(tab_id) - - if prev_tab_id is not None: - prev_tab = self._tabs.get(prev_tab_id) - if prev_tab is not None: - prev_tab.on_deactivate() - - # Clear up prev container if it hasn't been done. - if self._tab_container: - self._tab_container.delete() - - tab = self._tabs.get(tab_id) - if tab is not None: - self._tab_container = tab.on_activate( - self._root_widget, - self._tab_row.tabs[tab_id].button, - self._scroll_width, - self._scroll_height, - self._scroll_left, - self._scroll_bottom, - ) - return - - def _save_state(self) -> None: - try: - for tab in self._tabs.values(): - tab.save_state() - - sel = self._root_widget.get_selected_child() - selected_tab_ids = [ - tab_id - for tab_id, tab in self._tab_row.tabs.items() - if sel == tab.button - ] - if sel == self._back_button: - sel_name = 'Back' - elif selected_tab_ids: - assert len(selected_tab_ids) == 1 - sel_name = f'Tab:{selected_tab_ids[0].value}' - elif sel == self._tab_container: - sel_name = 'TabContainer' - else: - raise ValueError(f'unrecognized selection: \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = { - 'sel_name': sel_name, - } - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - for tab in self._tabs.values(): - tab.restore_state() - - sel: bui.Widget | None - assert bui.app.classic is not None - winstate = bui.app.ui_v1.window_states.get(type(self), {}) - sel_name = winstate.get('sel_name', None) - assert isinstance(sel_name, (str, type(None))) - current_tab = self.TabID.ABOUT - gather_tab_val = bui.app.config.get('Gather Tab') - try: - stored_tab = self.TabID(gather_tab_val) - if stored_tab in self._tab_row.tabs: - current_tab = stored_tab - except ValueError: - pass - self._set_tab(current_tab) - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'TabContainer': - sel = self._tab_container - elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): - try: - sel_tab_id = self.TabID(sel_name.split(':')[-1]) - except ValueError: - sel_tab_id = self.TabID.ABOUT - sel = self._tab_row.tabs[sel_tab_id].button - else: - sel = self._tab_row.tabs[current_tab].button - bui.containerwidget(edit=self._root_widget, selected_child=sel) - - except Exception: - logging.exception('Error restoring state for %s.', self) +__all__ = ['GatherTab', 'GatherWindow'] diff --git a/dist/ba_data/python/bauiv1lib/gather/_gather.py b/dist/ba_data/python/bauiv1lib/gather/_gather.py new file mode 100644 index 0000000..43843de --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/gather/_gather.py @@ -0,0 +1,361 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Provides UI for inviting/joining friends.""" + +from __future__ import annotations + +import weakref +import logging +from enum import Enum +from typing import override, TYPE_CHECKING + +from bauiv1lib.tabs import TabRow +import bauiv1 as bui + +if TYPE_CHECKING: + from bauiv1lib.play import PlaylistSelectContext + + +class GatherTab: + """Defines a tab for use in the gather UI.""" + + def __init__(self, window: GatherWindow) -> None: + self._window = weakref.ref(window) + + @property + def window(self) -> GatherWindow: + """The GatherWindow that this tab belongs to.""" + window = self._window() + if window is None: + raise bui.NotFoundError("GatherTab's window no longer exists.") + return window + + def on_activate( + self, + parent_widget: bui.Widget, + tab_button: bui.Widget, + region_width: float, + region_height: float, + region_left: float, + region_bottom: float, + ) -> bui.Widget: + """Called when the tab becomes the active one. + + The tab should create and return a container widget covering the + specified region. + """ + # pylint: disable=too-many-positional-arguments + raise RuntimeError('Should not get here.') + + def on_deactivate(self) -> None: + """Called when the tab will no longer be the active one.""" + + def save_state(self) -> None: + """Called when the parent window is saving state.""" + + def restore_state(self) -> None: + """Called when the parent window is restoring state.""" + + +class GatherWindow(bui.MainWindow): + """Window for joining/inviting friends.""" + + class TabID(Enum): + """Our available tab types.""" + + ABOUT = 'about' + INTERNET = 'internet' + PRIVATE = 'private' + NEARBY = 'nearby' + MANUAL = 'manual' + + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from bauiv1lib.gather.abouttab import AboutGatherTab + from bauiv1lib.gather.manualtab import ManualGatherTab + from bauiv1lib.gather.privatetab import PrivateGatherTab + from bauiv1lib.gather.publictab import PublicGatherTab + from bauiv1lib.gather.nearbytab import NearbyGatherTab + + plus = bui.app.plus + assert plus is not None + + bui.set_analytics_screen('Gather Window') + uiscale = bui.app.ui_v1.uiscale + self._width = ( + 1640 + if uiscale is bui.UIScale.SMALL + else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 + ) + self._height = ( + 1000 + if uiscale is bui.UIScale.SMALL + else 730 if uiscale is bui.UIScale.MEDIUM else 900 + ) + self._current_tab: GatherWindow.TabID | None = None + self._r = 'gatherWindow' + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.4 + if uiscale is bui.UIScale.SMALL + else 0.88 if uiscale is bui.UIScale.MEDIUM else 0.66 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 130, screensize[0] / scale) + target_height = min(self._height - 130, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 65 + self._scroll_bottom = yoffs - 93 - self._scroll_height + self._scroll_left = (self._width - self._scroll_width) * 0.5 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility=( + 'menu_tokens' + if uiscale is bui.UIScale.SMALL + else 'menu_full' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = None + else: + self._back_button = btn = bui.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', + position=(70, yoffs - 43), + size=(60, 60), + scale=1.1, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + bui.textwidget( + parent=self._root_widget, + position=( + ( + self._width * 0.5 + + ( + (self._scroll_width * -0.5 + 170.0 - 70.0) + if uiscale is bui.UIScale.SMALL + else 0.0 + ) + ), + yoffs - (64 if uiscale is bui.UIScale.SMALL else 4), + ), + size=(0, 0), + color=bui.app.ui_v1.title_color, + scale=1.3 if uiscale is bui.UIScale.SMALL else 1.0, + h_align='left' if uiscale is bui.UIScale.SMALL else 'center', + v_align='center', + text=(bui.Lstr(resource=f'{self._r}.titleText')), + maxwidth=135 if uiscale is bui.UIScale.SMALL else 320, + ) + + # Build up the set of tabs we want. + tabdefs: list[tuple[GatherWindow.TabID, bui.Lstr]] = [ + (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=f'{self._r}.publicText'), + ) + ) + tabdefs.append( + (self.TabID.PRIVATE, bui.Lstr(resource=f'{self._r}.privateText')) + ) + tabdefs.append( + (self.TabID.NEARBY, bui.Lstr(resource=f'{self._r}.nearbyText')) + ) + tabdefs.append( + (self.TabID.MANUAL, bui.Lstr(resource=f'{self._r}.manualText')) + ) + + tab_inset = 250.0 if uiscale is bui.UIScale.SMALL else 100.0 + + self._tab_row = TabRow( + self._root_widget, + tabdefs, + idprefix=self.main_window_id_prefix, + size=(self._scroll_width - 2.0 * tab_inset, 50), + pos=( + self._scroll_left + tab_inset, + self._scroll_bottom + self._scroll_height - 4.0, + ), + on_select_call=bui.WeakCall(self._set_tab), + ) + + # Now instantiate handlers for these tabs. + tabtypes: dict[GatherWindow.TabID, type[GatherTab]] = { + self.TabID.ABOUT: AboutGatherTab, + self.TabID.MANUAL: ManualGatherTab, + self.TabID.PRIVATE: PrivateGatherTab, + self.TabID.INTERNET: PublicGatherTab, + self.TabID.NEARBY: NearbyGatherTab, + } + self._tabs: dict[GatherWindow.TabID, GatherTab] = {} + for tab_id in self._tab_row.tabs: + tabtype = tabtypes.get(tab_id) + if tabtype is not None: + self._tabs[tab_id] = tabtype(self) + + # Eww; tokens meter may or may not be here; should be smarter + # about this. + bui.widget( + edit=self._tab_row.tabs[tabdefs[-1][0]].button, + right_widget=bui.get_special_widget('tokens_meter'), + ) + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._tab_row.tabs[tabdefs[0][0]].button, + left_widget=bui.get_special_widget('back_button'), + up_widget=bui.get_special_widget('back_button'), + ) + + # Not actually using a scroll widget anymore; just an image. + bui.imagewidget( + parent=self._root_widget, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), + texture=bui.gettexture('scrollWidget'), + mesh_transparent=bui.getmesh('softEdgeOutside'), + opacity=0.4, + ) + self._tab_container: bui.Widget | None = None + + self._restore_state() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True + + @override + def on_main_window_close(self) -> None: + self._save_state() + + def playlist_select( + self, + origin_widget: bui.Widget, + context: PlaylistSelectContext, + ) -> None: + """Called by the private-hosting tab to select a playlist.""" + from bauiv1lib.play import PlayWindow + + # Avoid redundant window spawns. + if not self.main_window_has_control(): + return + + new_window = self.main_window_replace( + lambda: PlayWindow( + origin_widget=origin_widget, playlist_select_context=context + ) + ) + assert new_window is not None + + # Grab the newly-set main-window's back-state; that will lead us + # back here once we're done going down our main-window + # rabbit-hole for playlist selection. + context.back_state = new_window.main_window_back_state + + def _set_tab(self, tab_id: TabID) -> None: + if self._current_tab is tab_id: + return + prev_tab_id = self._current_tab + self._current_tab = tab_id + + # We wanna preserve our current tab between runs. + cfg = bui.app.config + cfg['Gather Tab'] = tab_id.value + cfg.commit() + + # Update tab colors based on which is selected. + self._tab_row.update_appearance(tab_id) + + if prev_tab_id is not None: + prev_tab = self._tabs.get(prev_tab_id) + if prev_tab is not None: + prev_tab.on_deactivate() + + # Clear up prev container if it hasn't been done. + if self._tab_container: + self._tab_container.delete() + + tab = self._tabs.get(tab_id) + if tab is not None: + self._tab_container = tab.on_activate( + self._root_widget, + self._tab_row.tabs[tab_id].button, + self._scroll_width, + self._scroll_height, + self._scroll_left, + self._scroll_bottom, + ) + return + + def _save_state(self) -> None: + try: + for tab in self._tabs.values(): + tab.save_state() + except Exception: + logging.exception('Error saving state for %s.', self) + + def _restore_state(self) -> None: + try: + for tab in self._tabs.values(): + tab.restore_state() + + current_tab = self.TabID.ABOUT + gather_tab_val = bui.app.config.get('Gather Tab') + try: + stored_tab = self.TabID(gather_tab_val) + if stored_tab in self._tab_row.tabs: + current_tab = stored_tab + except ValueError: + pass + self._set_tab(current_tab) + + except Exception: + logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/gather/abouttab.py b/dist/ba_data/python/bauiv1lib/gather/abouttab.py index b677637..bd5442d 100644 --- a/dist/ba_data/python/bauiv1lib/gather/abouttab.py +++ b/dist/ba_data/python/bauiv1lib/gather/abouttab.py @@ -27,11 +27,14 @@ class AboutGatherTab(GatherTab): region_bottom: float, ) -> bui.Widget: # pylint: disable=too-many-locals + # pylint: disable=too-many-statements # pylint: disable=too-many-positional-arguments plus = bui.app.plus assert plus is not None + idprefix = f'{self.window.main_window_id_prefix}|about' + try_tickets = plus.get_v1_account_misc_read_val( 'friendTryTickets', None ) @@ -147,6 +150,7 @@ class AboutGatherTab(GatherTab): ) invite_button = bui.buttonwidget( parent=container, + id=f'{idprefix}|invitefriend', position=(region_width * 0.59, y - 25), size=(230, 50), color=(0.54, 0.42, 0.56), @@ -179,6 +183,7 @@ class AboutGatherTab(GatherTab): ) discord_button = bui.buttonwidget( parent=container, + id=f'{idprefix}|discordjoin', position=(region_width * 0.59, y - 25), size=(230, 50), color=(0.54, 0.42, 0.56), diff --git a/dist/ba_data/python/bauiv1lib/gather/manualtab.py b/dist/ba_data/python/bauiv1lib/gather/manualtab.py index 23d7621..5b38a89 100644 --- a/dist/ba_data/python/bauiv1lib/gather/manualtab.py +++ b/dist/ba_data/python/bauiv1lib/gather/manualtab.py @@ -81,6 +81,7 @@ class ManualGatherTab(GatherTab): def __init__(self, window: GatherWindow) -> None: super().__init__(window) + self._idprefix = f'{window.main_window_id_prefix}|manual' self._check_button: bui.Widget | None = None self._doing_access_check: bool | None = None self._access_check_count: int | None = None @@ -135,6 +136,7 @@ class ManualGatherTab(GatherTab): v = c_height - 30 self._join_by_address_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|joinbyaddress', position=(c_width * 0.5 - 245, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -156,6 +158,7 @@ class ManualGatherTab(GatherTab): ) self._favorites_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|favorites', position=(c_width * 0.5 + 45, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -271,6 +274,7 @@ class ManualGatherTab(GatherTab): ) txt = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|manualaddress', editable=True, description=bui.Lstr(resource='gatherWindow.' 'manualAddressText'), position=(c_width * 0.5 - 240 - 50, v - 30), @@ -298,6 +302,7 @@ class ManualGatherTab(GatherTab): ) txt2 = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|manualport', editable=True, description=bui.Lstr(resource='gatherWindow.' 'portText'), text=str(last_port), @@ -313,6 +318,7 @@ class ManualGatherTab(GatherTab): btn = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|manualconnect', size=(300, 70), label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'), position=(c_width * 0.5 - 300, v), @@ -321,6 +327,7 @@ class ManualGatherTab(GatherTab): ) savebutton = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|savefavorite', size=(300, 70), label=bui.Lstr(resource='gatherWindow.favoritesSaveText'), position=(c_width * 0.5 - 240 + 490 - 200, v), @@ -336,6 +343,7 @@ class ManualGatherTab(GatherTab): self._check_button = bui.textwidget( parent=self._container, size=(250, 60), + id=f'{self._idprefix}|showmyaddress', text=bui.Lstr(resource='gatherWindow.showMyAddressText'), v_align='center', h_align='center', @@ -408,6 +416,7 @@ class ManualGatherTab(GatherTab): self._favorites_connect_button = btn1 = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|favoritesconnect', size=(b_width, b_height), position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', @@ -426,6 +435,7 @@ class ManualGatherTab(GatherTab): btnv -= b_height + b_space_extra bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|favoritesedit', size=(b_width, b_height), position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', @@ -439,6 +449,7 @@ class ManualGatherTab(GatherTab): btnv -= b_height + b_space_extra bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|favoritesdelete', size=(b_width, b_height), position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', @@ -462,6 +473,7 @@ class ManualGatherTab(GatherTab): ) self._columnwidget = bui.columnwidget( parent=scrlw, + id=f'{self._idprefix}|favoritescolumn', left_border=10, border=2, margin=0, @@ -517,6 +529,7 @@ class ManualGatherTab(GatherTab): assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale self._favorite_edit_window = cnt = bui.containerwidget( + parent=bui.get_special_widget('overlay_stack'), scale=( 1.8 if uiscale is bui.UIScale.SMALL @@ -524,6 +537,7 @@ class ManualGatherTab(GatherTab): ), size=(c_width, c_height), transition='in_scale', + darken_behind=True, ) bui.textwidget( @@ -693,8 +707,8 @@ class ManualGatherTab(GatherTab): ], ), self._delete_saved_party, - 450, - 150, + width=450, + height=150, ) def _delete_saved_party(self) -> None: @@ -735,6 +749,7 @@ class ManualGatherTab(GatherTab): for i, server in enumerate(servers): txt = bui.textwidget( parent=self._columnwidget, + id=f'{self._idprefix}|favorite{i}', size=(self._favorites_scroll_width / t_scale, 30), selectable=True, color=(1.0, 1, 0.4), diff --git a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py index 5a6b5a9..013e2a2 100644 --- a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py +++ b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py @@ -23,16 +23,23 @@ class NetScanner: def __init__( self, + *, tab: GatherTab, scrollwidget: bui.Widget, tab_button: bui.Widget, width: float, + idprefix: str, ): + self._idprefix = idprefix self._tab = weakref.ref(tab) self._scrollwidget = scrollwidget self._tab_button = tab_button self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, border=2, margin=0, left_border=10 + parent=self._scrollwidget, + id=f'{self._idprefix}|col', + border=2, + margin=0, + left_border=10, ) bui.widget(edit=self._columnwidget, up_widget=tab_button) self._width = width @@ -92,6 +99,11 @@ class NetScanner: corner_scale=t_scale, maxwidth=(self._width / t_scale) * 0.93, ) + # We don't give these ids since they pop in and out and it + # doesn't make sense to save/restore selections for them. + # But we need to suppress the warning from that. + bui.widget(edit=txt3, allow_preserve_selection=False) + if host == last_selected_host: bui.containerwidget( edit=self._columnwidget, @@ -107,6 +119,7 @@ class NearbyGatherTab(GatherTab): def __init__(self, window: GatherWindow) -> None: super().__init__(window) + self._idprefix = f'{window.main_window_id_prefix}|nearby' self._net_scanner: NetScanner | None = None self._container: bui.Widget | None = None @@ -158,7 +171,11 @@ class NearbyGatherTab(GatherTab): ) self._net_scanner = NetScanner( - self, scrollw, tab_button, width=sub_scroll_width + idprefix=self._idprefix, + tab=self, + scrollwidget=scrollw, + tab_button=tab_button, + width=sub_scroll_width, ) bui.widget(edit=scrollw, autoselect=True, up_widget=tab_button) diff --git a/dist/ba_data/python/bauiv1lib/gather/privatetab.py b/dist/ba_data/python/bauiv1lib/gather/privatetab.py index 8c26a51..6b4bf9f 100644 --- a/dist/ba_data/python/bauiv1lib/gather/privatetab.py +++ b/dist/ba_data/python/bauiv1lib/gather/privatetab.py @@ -58,6 +58,7 @@ class PrivateGatherTab(GatherTab): def __init__(self, window: GatherWindow) -> None: super().__init__(window) + self._idprefix = f'{window.main_window_id_prefix}|private' self._container: bui.Widget | None = None self._state: State = State() self._last_datacode_refresh_time: float | None = None @@ -115,6 +116,7 @@ class PrivateGatherTab(GatherTab): v = self._c_height - 30.0 self._join_sub_tab_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|jointab', position=(self._c_width * 0.5 - 245, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -134,6 +136,7 @@ class PrivateGatherTab(GatherTab): ) self._host_sub_tab_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|hosttab', position=(self._c_width * 0.5 + 45, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -526,6 +529,7 @@ class PrivateGatherTab(GatherTab): self._join_party_code_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|joinpartycode', position=(self._c_width * 0.5 - 150, self._c_height - 250), flatness=1.0, scale=1.5, @@ -540,6 +544,7 @@ class PrivateGatherTab(GatherTab): ) btn = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|connect', size=(300, 70), label=bui.Lstr(resource='gatherWindow.manualConnectText'), position=(self._c_width * 0.5 - 150, self._c_height - 350), @@ -667,6 +672,7 @@ class PrivateGatherTab(GatherTab): ) self._host_playlist_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|host', size=(400, 70), color=(0.6, 0.5, 0.6), textcolor=(0.8, 0.75, 0.8), @@ -726,6 +732,7 @@ class PrivateGatherTab(GatherTab): cbtnoffs = 10 self._host_copy_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|hostcopy', size=(140, 40), color=(0.6, 0.5, 0.6), textcolor=(0.8, 0.75, 0.8), @@ -738,6 +745,7 @@ class PrivateGatherTab(GatherTab): cbtnoffs = -70 self._host_connect_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|hostconnect', size=(140, 40), color=(0.6, 0.5, 0.6), textcolor=(0.8, 0.75, 0.8), @@ -898,6 +906,7 @@ class PrivateGatherTab(GatherTab): waiting = self._waiting_for_start_stop_response self._host_start_stop_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|hoststartstop', size=(400, 80), color=( (0.6, 0.6, 0.6) diff --git a/dist/ba_data/python/bauiv1lib/gather/publictab.py b/dist/ba_data/python/bauiv1lib/gather/publictab.py index 03d99b6..8aa82bf 100644 --- a/dist/ba_data/python/bauiv1lib/gather/publictab.py +++ b/dist/ba_data/python/bauiv1lib/gather/publictab.py @@ -130,6 +130,11 @@ class UIRow: h_align='left', v_align='center', ) + # These are popping in and out too chaotically to try and do + # auto-select-save/restore, so we don't supply ids for them. We + # need to suppress the warning that comes with that though. + bui.widget(edit=self._name_widget, allow_preserve_selection=False) + bui.widget( edit=self._name_widget, left_widget=join_text, @@ -164,6 +169,12 @@ class UIRow: position=(sub_scroll_width - 270.0, 1 + vpos), scale=0.9, ) + # These are popping in and out too chaotically to try and do + # auto-select-save/restore, so we don't supply ids for them. + # We need to suppress the warning that comes with that + # though. + bui.widget(edit=self._stats_button, allow_preserve_selection=False) + if existing_selection == Selection( party.get_key(), SelectionComponent.STATS_BUTTON ): @@ -356,6 +367,7 @@ class PublicGatherTab(GatherTab): def __init__(self, window: GatherWindow) -> None: super().__init__(window) + self._idprefix = f'{window.main_window_id_prefix}|public' self._container: bui.Widget | None = None self._join_text: bui.Widget | None = None self._host_text: bui.Widget | None = None @@ -428,6 +440,7 @@ class PublicGatherTab(GatherTab): v = c_height - 30 self._join_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|jointab', position=(c_width * 0.5 - 245, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -451,6 +464,7 @@ class PublicGatherTab(GatherTab): ) self._host_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|hosttab', position=(c_width * 0.5 + 45, v - 13), color=(0.6, 1.0, 0.6), scale=1.3, @@ -593,6 +607,7 @@ class PublicGatherTab(GatherTab): filter_txt = bui.Lstr(resource='filterText') self._filter_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|filter', text=self._filter_value, size=(350, 45), position=(c_width * 0.5 - 150, v - 10), @@ -670,6 +685,7 @@ class PublicGatherTab(GatherTab): ) self._join_list_column = bui.containerwidget( parent=scrollw, + id=f'{self._idprefix}|joinlistcolumn', background=False, size=(400, 400), claims_left_right=True, @@ -755,6 +771,7 @@ class PublicGatherTab(GatherTab): ) self._host_name_text = bui.textwidget( parent=self._container, + id=f'{self._idprefix}|hostingname', editable=True, size=(535, 40), position=(230 + xoffs, v - 30), @@ -795,6 +812,7 @@ class PublicGatherTab(GatherTab): ) btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|maxsizeminus', size=(40, 40), on_activate_call=bui.WeakCall( self._on_max_public_party_size_minus_press @@ -805,6 +823,7 @@ class PublicGatherTab(GatherTab): ) btn2 = self._host_max_party_size_plus_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|maxsizeplus', size=(40, 40), on_activate_call=bui.WeakCall( self._on_max_public_party_size_plus_press @@ -827,6 +846,7 @@ class PublicGatherTab(GatherTab): ) self._host_toggle_button = bui.buttonwidget( parent=self._container, + id=f'{self._idprefix}|hosttoggle', label=label, size=(400, 80), on_activate_call=( diff --git a/dist/ba_data/python/bauiv1lib/getremote.py b/dist/ba_data/python/bauiv1lib/getremote.py index 06e0945..32f388d 100644 --- a/dist/ba_data/python/bauiv1lib/getremote.py +++ b/dist/ba_data/python/bauiv1lib/getremote.py @@ -20,7 +20,7 @@ class GetBSRemoteWindow(PopupWindow): scale = ( 2.3 if uiscale is bui.UIScale.SMALL - else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 + else 1.7 if uiscale is bui.UIScale.MEDIUM else 1.4 ) self._transitioning_out = False self._width = 570 @@ -54,7 +54,7 @@ class GetBSRemoteWindow(PopupWindow): size=(0, 0), h_align='center', v_align='center', - maxwidth=self._width * 0.9, + maxwidth=self._width * 0.8, position=(self._width * 0.5, 60), text=bui.Lstr( resource='remoteAppInfoShortText', diff --git a/dist/ba_data/python/bauiv1lib/gettokens.py b/dist/ba_data/python/bauiv1lib/gettokens.py index bd134e4..7602d03 100644 --- a/dist/ba_data/python/bauiv1lib/gettokens.py +++ b/dist/ba_data/python/bauiv1lib/gettokens.py @@ -357,6 +357,8 @@ class GetTokensWindow(bui.MainWindow): # We're affected by screen size only at small ui-scale. refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) + # Am seeing preserve-selection try to restore this sometimes. + bui.widget(edit=self._root_widget, allow_preserve_selection=False) if uiscale is bui.UIScale.SMALL: bui.containerwidget( @@ -366,6 +368,7 @@ class GetTokensWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(60, self._yoffs - 90), size=((60, 60)), scale=1.0, @@ -444,6 +447,10 @@ class GetTokensWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + 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: @@ -596,6 +603,7 @@ class GetTokensWindow(bui.MainWindow): ) tinfobtn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|learnmore', autoselect=True, label=bui.Lstr(resource='learnMoreText'), text_scale=0.7, @@ -634,6 +642,7 @@ class GetTokensWindow(bui.MainWindow): btn = bui.buttonwidget( autoselect=True, label='', + id=f'{self.main_window_id_prefix}|button{i}', color=buttondef.color, transition_delay=tdelay, up_widget=tinfobtn, @@ -741,6 +750,14 @@ class GetTokensWindow(bui.MainWindow): text=bui.Lstr(resource='removeInGameAdsTokenPurchaseText'), ) + # Most of our UI won't exist until this point so we need to + # explicitly restore state for selection restore to work. + # + # Note to self: perhaps we should *not* do this if significant + # time has passed since the window was made or if input commands + # have happened. + self.main_window_restore_shared_state() + def _purchase_press(self, itemid: str) -> None: plus = bui.app.plus @@ -802,8 +819,14 @@ def show_get_tokens_window(origin_widget: bui.Widget | None = None) -> None: # NOTE TO USERS: The code below is not the proper way to do things; # whenever possible one should use a MainWindow's - # main_window_replace() or main_window_back() methods. We just need - # to do things a bit more manually in this particular case. + # main_window_replace() or main_window_back() methods or + # bauiv1.auxiliary_window_activate(). We just need to do things a + # bit more manually in this particular case. + + # Basically we want to pop up our auxiliary window but we don't want + # to replace any existing auxiliary windows; we want our close + # button to go back to whatever was there already, no matter whether + # it was an auxiliary window or not. prev_main_window = bui.app.ui_v1.get_main_window() @@ -811,10 +834,12 @@ def show_get_tokens_window(origin_widget: bui.Widget | None = None) -> None: if isinstance(prev_main_window, GetTokensWindow): return + ui = bui.app.ui_v1 # Set our new main window. - bui.app.ui_v1.set_main_window( + ui.set_main_window( GetTokensWindow(origin_widget=origin_widget), - from_window=False, + from_window=False, # Don't check where we're coming from. + back_state=ui.save_current_main_window_state(), is_auxiliary=True, suppress_warning=True, ) diff --git a/dist/ba_data/python/bauiv1lib/help.py b/dist/ba_data/python/bauiv1lib/help.py index 4559ce3..73f7ee0 100644 --- a/dist/ba_data/python/bauiv1lib/help.py +++ b/dist/ba_data/python/bauiv1lib/help.py @@ -91,6 +91,7 @@ class HelpWindow(bui.MainWindow): else: btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(50, yoffs - 45), size=(60, 55), scale=0.8, @@ -117,7 +118,6 @@ class HelpWindow(bui.MainWindow): edit=self._scrollwidget, left_widget=bui.get_special_widget('back_button'), ) - bui.widget( edit=self._scrollwidget, right_widget=bui.get_special_widget('squad_button'), @@ -143,6 +143,7 @@ class HelpWindow(bui.MainWindow): self._subcontainer = bui.containerwidget( parent=self._scrollwidget, + id=f'{self.main_window_id_prefix}|sub', size=(self._sub_width, self._sub_height), background=False, claims_left_right=False, @@ -174,7 +175,8 @@ class HelpWindow(bui.MainWindow): ) spacing = 1.0 - h = self._sub_width * 0.5 + baseh = self._sub_width * 0.5 + h = baseh + 30 v = self._sub_height - 55 if uiscale is bui.UIScale.SMALL: v -= inline_title_height @@ -217,6 +219,7 @@ class HelpWindow(bui.MainWindow): position=(hval2 - 0.5 * icon_size, v - 0.45 * icon_size), texture=logo_tex, ) + h = baseh app = bui.app assert app.classic is not None @@ -284,6 +287,8 @@ class HelpWindow(bui.MainWindow): flatness=1.0, ) + h = baseh + 20 + v -= spacing * 40.0 txt_scale = 0.74 txt = bui.Lstr(resource=f'{self._r}.friendsText').evaluate() @@ -373,6 +378,8 @@ class HelpWindow(bui.MainWindow): v -= spacing * 150.0 + h = baseh + 30 + txt = bui.Lstr(resource=f'{self._r}.controlsText').evaluate() txt_scale = 1.4 txt_maxwidth = 480 @@ -405,6 +412,8 @@ class HelpWindow(bui.MainWindow): v -= spacing * 45.0 + h = baseh + txt_scale = 0.7 txt = bui.Lstr( resource=f'{self._r}.controlsSubtitleText', @@ -557,6 +566,8 @@ class HelpWindow(bui.MainWindow): v -= spacing * 280.0 + h = baseh + 30 + txt = bui.Lstr(resource=f'{self._r}.powerupsText').evaluate() txt_scale = 1.4 txt_maxwidth = 480 @@ -585,6 +596,8 @@ class HelpWindow(bui.MainWindow): texture=logo_tex, ) + h = baseh + 20 + v -= spacing * 50.0 txt_scale = getres(f'{self._r}.powerupsSubtitleTextScale') txt = bui.Lstr(resource=f'{self._r}.powerupsSubtitleText').evaluate() @@ -601,6 +614,8 @@ class HelpWindow(bui.MainWindow): flatness=1.0, ) + h = baseh + 20 + v -= spacing * 1.0 mm1 = -270 @@ -670,7 +685,7 @@ class HelpWindow(bui.MainWindow): position=(h + mm3, v), size=(0, 0), scale=txt_scale, - maxwidth=300, + maxwidth=290, flatness=1.0, text=txtl, h_align='left', @@ -691,3 +706,7 @@ class HelpWindow(bui.MainWindow): transition=transition, origin_widget=origin_widget ) ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True diff --git a/dist/ba_data/python/bauiv1lib/inbox.py b/dist/ba_data/python/bauiv1lib/inbox.py index d9f6534..1ec082d 100644 --- a/dist/ba_data/python/bauiv1lib/inbox.py +++ b/dist/ba_data/python/bauiv1lib/inbox.py @@ -13,6 +13,7 @@ from typing import override, assert_never, TYPE_CHECKING from efro.util import strict_partial, pairs_from_flat from efro.error import CommunicationError import bacommon.bs +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui if TYPE_CHECKING: @@ -29,7 +30,7 @@ class _Section: """Return rows of selectable controls.""" return [] - def emit(self, subcontainer: bui.Widget, y: float) -> None: + def emit(self, subcontainer: bui.Widget, y: float, idprefix: str) -> None: """Emit the section.""" @@ -75,7 +76,7 @@ class _TextSection(_Section): return self.full_height @override - def emit(self, subcontainer: bui.Widget, y: float) -> None: + def emit(self, subcontainer: bui.Widget, y: float, idprefix: str) -> None: bui.textwidget( parent=subcontainer, position=( @@ -132,9 +133,10 @@ class _ButtonSection(_Section): section_strong.call(section_strong) @override - def emit(self, subcontainer: bui.Widget, y: float) -> None: + def emit(self, subcontainer: bui.Widget, y: float, idprefix: str) -> None: self.button = bui.buttonwidget( parent=subcontainer, + id=f'{idprefix}|button', position=( self.sub_width * 0.5 - self.button_width * 0.5, y - self.spacing_top - self.button_height, @@ -184,7 +186,7 @@ class _DisplayItemsSection(_Section): return self.full_height @override - def emit(self, subcontainer: bui.Widget, y: float) -> None: + def emit(self, subcontainer: bui.Widget, y: float, idprefix: str) -> None: # pylint: disable=cyclic-import from baclassic import show_display_item @@ -267,7 +269,7 @@ class _ExpireTimeSection(_Section): bui.textwidget(edit=self._widget, text=val, color=color) @override - def emit(self, subcontainer: bui.Widget, y: float) -> None: + def emit(self, subcontainer: bui.Widget, y: float, idprefix: str) -> None: self._widget = bui.textwidget( parent=subcontainer, position=( @@ -290,9 +292,9 @@ class _ExpireTimeSection(_Section): @dataclass class _EntryDisplay: - interaction_style: bacommon.bs.BasicClientUI.InteractionStyle - button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel - button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel + interaction_style: bacommon.bs.BasicCloudDialog.InteractionStyle + button_label_positive: bacommon.bs.BasicCloudDialog.ButtonLabel + button_label_negative: bacommon.bs.BasicCloudDialog.ButtonLabel sections: list[_Section] id: str total_height: float @@ -351,10 +353,15 @@ class InboxWindow(bui.MainWindow): scroll_height = target_height - 31 scroll_bottom = yoffs - 59 - scroll_height + # Go with full screen area scrollable on small ui. + if uiscale is bui.UIScale.SMALL: + scroll_height += 36 + scroll_bottom -= 4 + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - toolbar_visibility=('menu_full'), + toolbar_visibility='menu_full', toolbar_cancel_button_style=( 'close' if auxiliary_style else 'back' ), @@ -374,6 +381,7 @@ class InboxWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', autoselect=True, position=(50, yoffs - 48), size=(60, 60), @@ -390,21 +398,6 @@ class InboxWindow(bui.MainWindow): edit=self._root_widget, cancel_button=self._back_button ) - self._title_text = bui.textwidget( - parent=self._root_widget, - position=( - self._width * 0.5, - yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), - ), - size=(0, 0), - h_align='center', - v_align='center', - scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, - text=bui.Lstr(resource='inboxText'), - maxwidth=200, - color=bui.app.ui_v1.title_color, - ) - # Shows 'loading', 'no messages', etc. self._infotext = bui.textwidget( parent=self._root_widget, @@ -427,6 +420,7 @@ class InboxWindow(bui.MainWindow): ) self._scrollwidget = bui.scrollwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|scroll', size=(scroll_width, scroll_height), position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), capture_arrows=True, @@ -446,12 +440,45 @@ class InboxWindow(bui.MainWindow): left_widget=bui.get_special_widget('back_button'), ) + # When we're doing fullscreen scrolling, fade content around + # toolbars. + if uiscale is bui.UIScale.SMALL: + scroll_fade_top( + self._root_widget, + self._width * 0.5 - scroll_width * 0.5, + scroll_bottom, + scroll_width, + scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - scroll_width * 0.5, + scroll_bottom, + scroll_width, + scroll_height, + ) + bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button, single_depth=True, ) + self._title_text = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), + ), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, + text=bui.Lstr(resource='inboxText'), + maxwidth=200, + color=bui.app.ui_v1.title_color, + ) + # Kick off request. plus = bui.app.plus if plus is None or plus.accounts.primary is None: @@ -474,6 +501,10 @@ class InboxWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _error(self, errmsg: bui.Lstr | str) -> None: """Put ourself in a permanent error state.""" bui.spinnerwidget(edit=self._loading_spinner, visible=False) @@ -486,7 +517,7 @@ class InboxWindow(bui.MainWindow): def _on_entry_display_press( self, display_weak: weakref.ReferenceType[_EntryDisplay], - action: bacommon.bs.ClientUIAction, + action: bacommon.bs.CloudDialogAction, ) -> None: display = display_weak() if display is None: @@ -500,7 +531,7 @@ class InboxWindow(bui.MainWindow): # interaction types. if ( display.interaction_style - is bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN + is bacommon.bs.BasicCloudDialog.InteractionStyle.UNKNOWN ): display.processing_complete = True self._close_soon_if_all_processed() @@ -523,7 +554,7 @@ class InboxWindow(bui.MainWindow): # Ask the master-server to run our action. with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.bs.ClientUIActionMessage(display.id, action), + bacommon.bs.CloudDialogActionMessage(display.id, action), on_response=bui.WeakCall( self._on_client_ui_action_response, display_weak, @@ -534,12 +565,12 @@ class InboxWindow(bui.MainWindow): # Tweak the UI to show that things are in motion. button = ( display.button_positive - if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE else display.button_negative ) button_spinner = ( display.button_spinner_positive - if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE else display.button_spinner_negative ) if button is not None: @@ -578,8 +609,8 @@ class InboxWindow(bui.MainWindow): def _on_client_ui_action_response( self, display_weak: weakref.ReferenceType[_EntryDisplay], - action: bacommon.bs.ClientUIAction, - response: bacommon.bs.ClientUIActionResponse | Exception, + action: bacommon.bs.CloudDialogAction, + response: bacommon.bs.CloudDialogActionResponse | Exception, ) -> None: # pylint: disable=too-many-branches @@ -602,12 +633,12 @@ class InboxWindow(bui.MainWindow): # Tweak the button to show results. button = ( display.button_positive - if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE else display.button_negative ) button_spinner = ( display.button_spinner_positive - if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + if action is bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE else display.button_spinner_negative ) # Always hide spinner at this point. @@ -717,6 +748,10 @@ class InboxWindow(bui.MainWindow): sub_width = 400.0 sub_height = margin_top + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + sub_height += 36 + # Construct entries for everything we'll display. for i, wrapper in enumerate(response.wrappers): @@ -724,9 +759,9 @@ class InboxWindow(bui.MainWindow): # textfin: str color: tuple[float, float, float] - interaction_style: bacommon.bs.BasicClientUI.InteractionStyle - button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel - button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel + interaction_style: bacommon.bs.BasicCloudDialog.InteractionStyle + button_label_positive: bacommon.bs.BasicCloudDialog.ButtonLabel + button_label_negative: bacommon.bs.BasicCloudDialog.ButtonLabel sections: list[_Section] = [] total_height = 80.0 @@ -734,7 +769,7 @@ class InboxWindow(bui.MainWindow): # Display only entries where we recognize all style/label # values and ui component types. if ( - isinstance(wrapper.ui, bacommon.bs.BasicClientUI) + isinstance(wrapper.ui, bacommon.bs.BasicCloudDialog) and not wrapper.ui.contains_unknown_elements() ): color = (0.55, 0.5, 0.7) @@ -742,14 +777,14 @@ class InboxWindow(bui.MainWindow): button_label_positive = wrapper.ui.button_label_positive button_label_negative = wrapper.ui.button_label_negative - idcls = bacommon.bs.BasicClientUIComponentTypeID + idcls = bacommon.bs.BasicCloudDialogComponentTypeID for component in wrapper.ui.components: ctypeid = component.get_type_id() section: _Section if ctypeid is idcls.TEXT: assert isinstance( - component, bacommon.bs.BasicClientUIComponentText + component, bacommon.bs.BasicCloudDialogComponentText ) section = _TextSection( sub_width=sub_width, @@ -767,7 +802,7 @@ class InboxWindow(bui.MainWindow): elif ctypeid is idcls.LINK: assert isinstance( - component, bacommon.bs.BasicClientUIComponentLink + component, bacommon.bs.BasicCloudDialogComponentLink ) def _do_open_url(url: str, sec: _ButtonSection) -> None: @@ -792,7 +827,7 @@ class InboxWindow(bui.MainWindow): elif ctypeid is idcls.DISPLAY_ITEMS: assert isinstance( component, - bacommon.bs.BasicClientUIDisplayItems, + bacommon.bs.BasicCloudDialogDisplayItems, ) section = _DisplayItemsSection( sub_width=sub_width, @@ -809,7 +844,7 @@ class InboxWindow(bui.MainWindow): assert isinstance( component, - bacommon.bs.BasicClientUIBsClassicTourneyResult, + bacommon.bs.BasicCloudDialogBsClassicTourneyResult, ) campaignname, levelname = component.game.split(':') assert bui.app.classic is not None @@ -934,7 +969,7 @@ class InboxWindow(bui.MainWindow): elif ctypeid is idcls.EXPIRE_TIME: assert isinstance( - component, bacommon.bs.BasicClientUIExpireTime + component, bacommon.bs.BasicCloudDialogExpireTime ) section = _ExpireTimeSection( sub_width=sub_width, @@ -957,11 +992,13 @@ class InboxWindow(bui.MainWindow): # 'upgrade your app to see this' message. color = (0.6, 0.6, 0.6) interaction_style = ( - bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN + bacommon.bs.BasicCloudDialog.InteractionStyle.UNKNOWN + ) + button_label_positive = ( + bacommon.bs.BasicCloudDialog.ButtonLabel.OK ) - button_label_positive = bacommon.bs.BasicClientUI.ButtonLabel.OK button_label_negative = ( - bacommon.bs.BasicClientUI.ButtonLabel.CANCEL + bacommon.bs.BasicCloudDialog.ButtonLabel.CANCEL ) section = _TextSection( @@ -989,7 +1026,7 @@ class InboxWindow(bui.MainWindow): sub_height += margin_bottom subcontainer = bui.containerwidget( - id='inboxsub', + id=f'{self.main_window_id_prefix}|subc', parent=self._scrollwidget, size=(sub_width, sub_height), background=False, @@ -1004,8 +1041,15 @@ class InboxWindow(bui.MainWindow): buttonrows: list[list[bui.Widget]] = [] y = sub_height - margin_top - for i, _wrapper in enumerate(response.wrappers): - entry_display = self._entry_displays[i] + + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + y -= 36 + + # for i, _wrapper in enumerate(response.wrappers): + for entry_display in self._entry_displays: + # entry_display = self._entry_displays[i] entry_display_weak = weakref.ref(entry_display) bwidth = 140 bheight = 40 @@ -1027,8 +1071,15 @@ class InboxWindow(bui.MainWindow): bui.widget(edit=img, depth_range=(0, 0.1)) # Section contents. - for sec in entry_display.sections: - sec.emit(subcontainer, ysection) + for s, sec in enumerate(entry_display.sections): + sec.emit( + subcontainer, + ysection, + ( + f'{self.main_window_id_prefix}|entry_{entry_display.id}' + f'|section{s}' + ), + ) # Wire up any widgets created by this section. sec_button_row = sec.get_button_row() if sec_button_row: @@ -1039,7 +1090,7 @@ class InboxWindow(bui.MainWindow): have_negative_button = ( entry_display.interaction_style is ( - bacommon.bs.BasicClientUI + bacommon.bs.BasicCloudDialog ).InteractionStyle.BUTTON_POSITIVE_NEGATIVE ) @@ -1053,6 +1104,10 @@ class InboxWindow(bui.MainWindow): ) entry_display.button_positive = btn = bui.buttonwidget( parent=subcontainer, + id=( + f'{self.main_window_id_prefix}|entry_{entry_display.id}' + f'|buttonpositive' + ), position=bpos, autoselect=True, size=(bwidth, bheight), @@ -1064,7 +1119,7 @@ class InboxWindow(bui.MainWindow): on_activate_call=bui.WeakCall( self._on_entry_display_press, entry_display_weak, - bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE, + bacommon.bs.CloudDialogAction.BUTTON_PRESS_POSITIVE, ), enable_sound=False, ) @@ -1084,6 +1139,10 @@ class InboxWindow(bui.MainWindow): bpos = (25, y - entry_display.total_height + 15.0) entry_display.button_negative = btn2 = bui.buttonwidget( parent=subcontainer, + id=( + f'{self.main_window_id_prefix}' + f'|entry_{entry_display.id}|buttonnegative' + ), position=bpos, autoselect=True, size=(bwidth, bheight), @@ -1095,7 +1154,7 @@ class InboxWindow(bui.MainWindow): on_activate_call=bui.WeakCall( self._on_entry_display_press, entry_display_weak, - (bacommon.bs.ClientUIAction).BUTTON_PRESS_NEGATIVE, + (bacommon.bs.CloudDialogAction).BUTTON_PRESS_NEGATIVE, ), enable_sound=False, ) @@ -1142,6 +1201,14 @@ class InboxWindow(bui.MainWindow): above_widget = buttons[0] + # Most of our UI won't exist until this point so we need to + # explicitly restore state for selection restore to work. + # + # Note to self: perhaps we should *not* do this if significant + # time has passed since the window was made or if input commands + # have happened. + self.main_window_restore_shared_state() + def _get_bs_classic_tourney_results_sections() -> list[_Section]: return [] diff --git a/dist/ba_data/python/bauiv1lib/ingamemenu.py b/dist/ba_data/python/bauiv1lib/ingamemenu.py index b06255d..f9fe988 100644 --- a/dist/ba_data/python/bauiv1lib/ingamemenu.py +++ b/dist/ba_data/python/bauiv1lib/ingamemenu.py @@ -58,6 +58,10 @@ class InGameMenuWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _refresh(self) -> None: # Clear everything that was there. @@ -91,6 +95,7 @@ class InGameMenuWindow(bui.MainWindow): if bs.is_in_replay(): self._end_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|end', position=(h - self._button_width * 0.5 * scale, v), scale=scale, size=(self._button_width, self._button_height), @@ -101,6 +106,7 @@ class InGameMenuWindow(bui.MainWindow): elif bs.get_foreground_host_session() is not None: self._end_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|end', position=(h - self._button_width * 0.5 * scale, v), scale=scale, size=(self._button_width, self._button_height), @@ -124,6 +130,7 @@ class InGameMenuWindow(bui.MainWindow): # button. self._end_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|end', position=(h - self._button_width * 0.5 * scale, v), scale=scale, size=(self._button_width, self._button_height), @@ -174,6 +181,7 @@ class InGameMenuWindow(bui.MainWindow): ) btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|replayspeedminus', position=( h - b_size - b_buffer_1, v - b_size - b_buffer_2 + v_offs, @@ -199,6 +207,7 @@ class InGameMenuWindow(bui.MainWindow): ) btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|replayspeedplus', position=(h + b_buffer_1, v - b_size - b_buffer_2 + v_offs), button_type='square', size=(b_size, b_size), @@ -221,6 +230,7 @@ class InGameMenuWindow(bui.MainWindow): ) self._pause_resume_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|pauseresume', position=(h - b_size * 0.5, v - b_size - b_buffer_2 + v_offs), button_type='square', size=(b_size, b_size), @@ -234,6 +244,7 @@ class InGameMenuWindow(bui.MainWindow): ) btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|rewind', position=( h - b_size * 1.5 - b_buffer_1 * 2, v - b_size - b_buffer_2 + v_offs, @@ -260,6 +271,7 @@ class InGameMenuWindow(bui.MainWindow): ) btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|forward', position=( h + b_size * 0.5 + b_buffer_1 * 2, v - b_size - b_buffer_2 + v_offs, @@ -376,6 +388,7 @@ class InGameMenuWindow(bui.MainWindow): self._p_index += 1 btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|resume', position=(h - self._button_width / 2, v), size=(self._button_width, self._button_height), scale=scale, @@ -386,7 +399,7 @@ class InGameMenuWindow(bui.MainWindow): bui.containerwidget(edit=self._root_widget, cancel_button=btn) # Add any custom options defined by the current game. - for entry in custom_menu_entries: + for i, entry in enumerate(custom_menu_entries): h, v, scale = positions[self._p_index] self._p_index += 1 @@ -401,6 +414,7 @@ class InGameMenuWindow(bui.MainWindow): bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|custom{i}', position=(h - self._button_width / 2, v), size=(self._button_width, self._button_height), scale=scale, @@ -417,6 +431,7 @@ class InGameMenuWindow(bui.MainWindow): self._p_index += 1 btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|leave', position=(h - self._button_width / 2, v), size=(self._button_width, self._button_height), scale=scale, diff --git a/dist/ba_data/python/bauiv1lib/inventory.py b/dist/ba_data/python/bauiv1lib/inventory.py index ce7dedb..256f5ae 100644 --- a/dist/ba_data/python/bauiv1lib/inventory.py +++ b/dist/ba_data/python/bauiv1lib/inventory.py @@ -19,9 +19,6 @@ class InventoryWindow(bui.MainWindow): auxiliary_style: bool = True, ): - bui.set_analytics_screen('Help Window') - - assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale self._width = 1400 if uiscale is bui.UIScale.SMALL else 750 self._height = ( @@ -87,6 +84,7 @@ class InventoryWindow(bui.MainWindow): else: btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', scale=0.8, position=(50, yoffs - 50), size=(50, 50) if auxiliary_style else (60, 55), @@ -108,6 +106,7 @@ class InventoryWindow(bui.MainWindow): button_width = 300 self._player_profiles_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|playerprofiles', position=(self._width * 0.5 - button_width * 0.5, yoffs - 200), autoselect=True, size=(button_width, 60), @@ -117,6 +116,8 @@ class InventoryWindow(bui.MainWindow): textcolor=(0.75, 0.7, 0.8), on_activate_call=self._player_profiles_press, ) + # Select this by default. + bui.containerwidget(edit=self._root_widget, selected_child=btn) bui.textwidget( parent=self._root_widget, position=(self._width * 0.5, yoffs - 250), @@ -132,12 +133,10 @@ class InventoryWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.profile.browser import ProfileBrowserWindow - # 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 - self.main_window_replace( - ProfileBrowserWindow(origin_widget=self._player_profiles_button) + lambda: ProfileBrowserWindow( + origin_widget=self._player_profiles_button + ) ) @override @@ -149,3 +148,7 @@ class InventoryWindow(bui.MainWindow): transition=transition, origin_widget=origin_widget ) ) + + @override + def main_window_should_preserve_selection(self) -> bool: + return True diff --git a/dist/ba_data/python/bauiv1lib/kiosk.py b/dist/ba_data/python/bauiv1lib/kiosk.py index 5ca2be1..64da3c5 100644 --- a/dist/ba_data/python/bauiv1lib/kiosk.py +++ b/dist/ba_data/python/bauiv1lib/kiosk.py @@ -374,6 +374,11 @@ class KioskWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # TODO: Wire this up. + return False + @override def on_main_window_close(self) -> None: self._save_state() @@ -529,13 +534,15 @@ class KioskWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.mainmenu import MainMenuWindow - # no-op if we're not in control. + # No-op if we're not in control. if not self.main_window_has_control(): return assert bui.app.classic is not None self._save_state() - bui.app.classic.did_menu_intro = True # prevent delayed transition-in - self.main_window_replace(MainMenuWindow()) + # Prevent delayed transition-in. + bui.app.classic.did_menu_intro = True + + self.main_window_replace(MainMenuWindow) diff --git a/dist/ba_data/python/bauiv1lib/league/rankwindow.py b/dist/ba_data/python/bauiv1lib/league/rankwindow.py index a4302c0..f145abb 100644 --- a/dist/ba_data/python/bauiv1lib/league/rankwindow.py +++ b/dist/ba_data/python/bauiv1lib/league/rankwindow.py @@ -9,6 +9,7 @@ import copy import logging from typing import TYPE_CHECKING, override +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top from bauiv1lib.popup import PopupMenu import bauiv1 as bui @@ -57,9 +58,6 @@ class LeagueRankWindow(bui.MainWindow): ) self._r = 'coopSelectWindow' self._rdict = bui.app.lang.get_resource(self._r) - # top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 - - # self._xoffs = 80.0 if uiscale is bui.UIScale.SMALL else 0 self._xoffs = 40 self._league_url_arg = '' @@ -86,19 +84,19 @@ class LeagueRankWindow(bui.MainWindow): yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 self._scroll_width = target_width - self._scroll_height = target_height - 35 + self._scroll_height = target_height - 50 scroll_bottom = yoffs - 80 - self._scroll_height + # Go with full-screen scrollable area in small ui. + if uiscale is bui.UIScale.SMALL: + self._scroll_height += 53 + scroll_bottom -= 1 + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - stack_offset=( - (0, 0) - if uiscale is bui.UIScale.SMALL - else (0, 10) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), scale=scale, - toolbar_visibility=('menu_full'), + toolbar_visibility='menu_full', toolbar_cancel_button_style=( 'close' if auxiliary_style else 'back' ), @@ -117,6 +115,7 @@ class LeagueRankWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(75 + x_inset, yoffs - 60), size=(60, 55), scale=1.2, @@ -135,6 +134,40 @@ class LeagueRankWindow(bui.MainWindow): selected_child=self._back_button, ) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + highlight=False, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), + center_small_content=True, + center_small_content_horizontally=True, + border_opacity=0.4, + ) + bui.widget(edit=self._scrollwidget, autoselect=True) + bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) + + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL: + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + yscale=0.5, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + self._title_text = bui.textwidget( parent=self._root_widget, position=( @@ -153,27 +186,17 @@ class LeagueRankWindow(bui.MainWindow): v_align='center', ) - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - highlight=False, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - scroll_bottom, - ), - center_small_content=True, - center_small_content_horizontally=True, - border_opacity=0.4, - ) - bui.widget(edit=self._scrollwidget, autoselect=True) - bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) - self._last_power_ranking_query_time: float | None = None self._doing_power_ranking_query = False self._subcontainer: bui.Widget | None = None self._subcontainerwidth = 1024 self._subcontainerheight = 573 + + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + self._subcontainerheight += 53 + self._power_ranking_score_widgets: list[bui.Widget] = [] self._season_popup_menu: PopupMenu | None = None @@ -185,7 +208,6 @@ class LeagueRankWindow(bui.MainWindow): self._account_state = plus.get_v1_account_state() self._refresh() - self._restore_state() # If we've got cached power-ranking data already, display it. assert bui.app.classic is not None @@ -209,8 +231,8 @@ class LeagueRankWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _on_achievements_press(self) -> None: from bauiv1lib.achievements import AchievementsWindow @@ -221,7 +243,9 @@ class LeagueRankWindow(bui.MainWindow): if self._season == 'a' or self._is_current_season: prab = self._power_ranking_achievements_button assert prab is not None - self.main_window_replace(AchievementsWindow(origin_widget=prab)) + self.main_window_replace( + lambda: AchievementsWindow(origin_widget=prab) + ) else: bui.screenmessage( bui.Lstr( @@ -317,9 +341,6 @@ class LeagueRankWindow(bui.MainWindow): self._league_rank_data = copy.deepcopy(data) self._update_for_league_rank_data(data) - def _restore_state(self) -> None: - pass - def _update(self, show: bool = False) -> None: plus = bui.app.plus assert plus is not None @@ -330,7 +351,6 @@ class LeagueRankWindow(bui.MainWindow): account_state = plus.get_v1_account_state() if account_state != self._account_state: self._account_state = account_state - self._save_state() self._refresh() # And power ranking too. @@ -384,6 +404,11 @@ class LeagueRankWindow(bui.MainWindow): v -= 0 + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + v -= 52 + h2 = 80 v2 = v - 60 worth_color = (0.6, 0.6, 0.65) @@ -410,6 +435,7 @@ class LeagueRankWindow(bui.MainWindow): self._power_ranking_achievements_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|ach', position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 80), icon=bui.gettexture('achievementsIcon'), @@ -440,6 +466,7 @@ class LeagueRankWindow(bui.MainWindow): self._power_ranking_trophies_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|trophies', position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 80), icon=bui.gettexture('medalSilver'), @@ -484,6 +511,7 @@ class LeagueRankWindow(bui.MainWindow): if plus.get_v1_account_misc_read_val('act', False): self._activity_mult_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|amult', position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 60), icon=bui.gettexture('heart'), @@ -515,6 +543,7 @@ class LeagueRankWindow(bui.MainWindow): self._up_to_date_bonus_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|uptodatebonus', position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 60), icon=bui.gettexture('logo'), @@ -712,6 +741,7 @@ class LeagueRankWindow(bui.MainWindow): self._see_more_button = bui.buttonwidget( parent=w_parent, + id=f'{self.main_window_id_prefix}|seemore', label=self._rdict.seeMoreText, position=(self._xoffs + h, v), color=(0.5, 0.5, 0.6), @@ -811,6 +841,11 @@ class LeagueRankWindow(bui.MainWindow): self._season = data['s'] if data is not None else None v = self._subcontainerheight - 20 + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + v -= 52 + popup_was_selected = False if self._season_popup_menu is not None: btn = self._season_popup_menu.get_button() @@ -853,6 +888,7 @@ class LeagueRankWindow(bui.MainWindow): assert self._subcontainer self._season_popup_menu = PopupMenu( parent=self._subcontainer, + button_id=f'{self.main_window_id_prefix}|season', position=(self._xoffs + 390, v - 45), width=150, button_size=(200, 50), @@ -1160,6 +1196,9 @@ class LeagueRankWindow(bui.MainWindow): v_align='center', scale=0.9, ) + # Giving these ids doesn't work cleanly with sel + # save/restore due to refreshing, so let's just not for now. + bui.widget(edit=txt, allow_preserve_selection=False) self._power_ranking_score_widgets.append(txt) bui.textwidget( edit=txt, @@ -1187,6 +1226,3 @@ class LeagueRankWindow(bui.MainWindow): self._requested_season = value self._last_power_ranking_query_time = None # Update asap. self._update(show=True) - - def _save_state(self) -> None: - pass diff --git a/dist/ba_data/python/bauiv1lib/mainmenu.py b/dist/ba_data/python/bauiv1lib/mainmenu.py index d3f4e84..f0e1bb3 100644 --- a/dist/ba_data/python/bauiv1lib/mainmenu.py +++ b/dist/ba_data/python/bauiv1lib/mainmenu.py @@ -22,6 +22,7 @@ class MainMenuWindow(bui.MainWindow): transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): + ui = bui.app.ui_v1 # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. @@ -30,13 +31,13 @@ class MainMenuWindow(bui.MainWindow): bui.set_analytics_screen('Main Menu') self._show_remote_app_info_on_first_launch() - uiscale = bui.app.ui_v1.uiscale + uiscale = ui.uiscale # Make a vanilla container; we'll modify it to our needs in # refresh. super().__init__( root_widget=bui.containerwidget( - toolbar_visibility=('menu_full_no_back') + toolbar_visibility=('menu_full_no_back'), ), transition=transition, origin_widget=origin_widget, @@ -44,10 +45,6 @@ class MainMenuWindow(bui.MainWindow): refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - # Grab this stuff in case it changes. - # self._is_demo = bui.app.env.demo - # self._is_arcade = bui.app.env.arcade - self._tdelay = 0.0 self._t_delay_inc = 0.02 self._t_delay_play = 1.7 @@ -65,22 +62,28 @@ class MainMenuWindow(bui.MainWindow): self._refresh() - self._restore_state() - - @override - def on_main_window_close(self) -> None: - self._save_state() - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) + + # Pull values from self here; if we do it in the lambda we'll + # keep self alive which we don't want. + # id_prefix = self._id_prefix + return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( - transition=transition, origin_widget=origin_widget - ) + transition=transition, + origin_widget=origin_widget, + # id_prefix=id_prefix, + ), + # restore_selection=True, ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + @staticmethod def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" @@ -257,7 +260,6 @@ class MainMenuWindow(bui.MainWindow): ), h_align='center', v_align='center', - # transition_delay=self._t_delay_play, transition_delay=thistdelay, ) @@ -267,20 +269,14 @@ class MainMenuWindow(bui.MainWindow): # In kiosk mode, provide a button to get back to the kiosk menu. if arcade_or_demo: - # h, v, scale = positions[self._p_index] h = self._width * 0.5 v = button_y_offs scale = 1.0 this_b_width = self._button_width * 0.4 * scale - # demo_menu_delay = ( - # 0.0 - # if self._t_delay_play == 0.0 - # else max(0, self._t_delay_play + 0.1) - # ) demo_menu_delay = 0.0 self._demo_menu_button = bui.buttonwidget( parent=self._root_widget, - id='demo', + id=f'{self.main_window_id_prefix}|demo', position=(self._width * 0.5 - this_b_width * 0.5, v + 90), size=(this_b_width, 45), autoselect=True, @@ -312,6 +308,7 @@ class MainMenuWindow(bui.MainWindow): thistdelay = self._tdelay + td2 * self._t_delay_inc self._gather_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|gather', position=(h - side_button_width * side_button_scale * 0.5, v), size=(side_button_width, side_button_height), scale=side_button_scale, @@ -359,7 +356,7 @@ class MainMenuWindow(bui.MainWindow): self._how_to_play_button = bui.buttonwidget( parent=self._root_widget, - id='howtoplay', + id=f'{self.main_window_id_prefix}|howtoplay', position=(h, v), autoselect=self._use_autoselect, size=(side_button_2_width, side_button_2_height * 2.0), @@ -380,8 +377,9 @@ class MainMenuWindow(bui.MainWindow): assert play_button_width is not None assert play_button_height is not None thistdelay = self._tdelay + td3 * self._t_delay_inc - self._play_button = start_button = bui.buttonwidget( + self._play_button = play_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|play', position=(h - play_button_width * 0.5 * play_button_scale, v), size=(play_button_width, play_button_height), autoselect=self._use_autoselect, @@ -393,12 +391,10 @@ class MainMenuWindow(bui.MainWindow): ) bui.containerwidget( edit=self._root_widget, - start_button=start_button, - selected_child=start_button, + start_button=play_button, + selected_child=play_button, ) - # self._tdelay += self._t_delay_inc - h = ( self._width * 0.5 + play_button_width * play_button_scale * 0.5 @@ -409,6 +405,7 @@ class MainMenuWindow(bui.MainWindow): thistdelay = self._tdelay + td4 * self._t_delay_inc self._watch_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|watch', position=(h - side_button_width * side_button_scale * 0.5, v), size=(side_button_width, side_button_height), scale=side_button_scale, @@ -457,6 +454,7 @@ class MainMenuWindow(bui.MainWindow): self._credits_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|credits', position=(h, v), button_type=None if self._have_quit_button else 'square', size=( @@ -477,6 +475,7 @@ class MainMenuWindow(bui.MainWindow): # credits button to get to it. self._quit_button = quit_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|quit', autoselect=self._use_autoselect, position=(h + 4.0, v), size=(side_button_2_width, side_button_2_height), @@ -534,111 +533,38 @@ class MainMenuWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.credits import CreditsWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - CreditsWindow(origin_widget=self._credits_button), + lambda: CreditsWindow(origin_widget=self._credits_button), ) def _howtoplay(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.help import HelpWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - HelpWindow(origin_widget=self._how_to_play_button), + lambda: HelpWindow(origin_widget=self._how_to_play_button), ) - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._play_button: - sel_name = 'Start' - elif sel == self._gather_button: - sel_name = 'Gather' - elif sel == self._watch_button: - sel_name = 'Watch' - elif sel == self._how_to_play_button: - sel_name = 'HowToPlay' - elif sel == self._credits_button: - sel_name = 'Credits' - elif sel == self._quit_button: - sel_name = 'Quit' - elif sel == self._demo_menu_button: - sel_name = 'DemoMenu' - else: - print(f'Unknown widget in main menu selection: {sel}.') - sel_name = 'Start' - bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - - sel: bui.Widget | None - - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - assert isinstance(sel_name, (str, type(None))) - if sel_name is None: - sel_name = 'Start' - if sel_name == 'HowToPlay': - sel = self._how_to_play_button - elif sel_name == 'Gather': - sel = self._gather_button - elif sel_name == 'Watch': - sel = self._watch_button - elif sel_name == 'Credits': - sel = self._credits_button - elif sel_name == 'Quit': - sel = self._quit_button - elif sel_name == 'DemoMenu': - sel = self._demo_menu_button - else: - sel = self._play_button - if sel is not None: - bui.containerwidget(edit=self._root_widget, selected_child=sel) - - except Exception: - logging.exception('Error restoring state for %s.', self) - def _gather_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.gather import GatherWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - GatherWindow(origin_widget=self._gather_button) + lambda: GatherWindow(origin_widget=self._gather_button) ) def _watch_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.watch import WatchWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - WatchWindow(origin_widget=self._watch_button), + lambda: WatchWindow(origin_widget=self._watch_button), ) def _play_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.play import PlayWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(PlayWindow(origin_widget=self._play_button)) + self.main_window_replace( + lambda: PlayWindow(origin_widget=self._play_button) + ) diff --git a/dist/ba_data/python/bauiv1lib/party.py b/dist/ba_data/python/bauiv1lib/party.py index 360d053..d1628f7 100644 --- a/dist/ba_data/python/bauiv1lib/party.py +++ b/dist/ba_data/python/bauiv1lib/party.py @@ -38,6 +38,7 @@ class PartyWindow(bui.Window): if uiscale is bui.UIScale.SMALL else 480 if uiscale is bui.UIScale.MEDIUM else 600 ) + self._idprefix = bui.app.ui_v1.new_id_prefix('party') self._display_old_msgs = True super().__init__( root_widget=bui.containerwidget( @@ -68,6 +69,7 @@ class PartyWindow(bui.Window): self._cancel_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self._idprefix}|cancel', scale=0.7, position=(30, self._height - 47), size=(50, 50), @@ -82,6 +84,7 @@ class PartyWindow(bui.Window): self._menu_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self._idprefix}|menu', scale=0.7, position=(self._width - 60, self._height - 47), size=(50, 50), @@ -138,13 +141,18 @@ class PartyWindow(bui.Window): self._scroll_width = self._width - 50 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, + id=f'{self._idprefix}|scroll', size=(self._scroll_width, self._height - 200), position=(30, 80), color=(0.4, 0.6, 0.3), border_opacity=0.6, ) self._columnwidget = bui.columnwidget( - parent=self._scrollwidget, border=2, left_border=-200, margin=0 + parent=self._scrollwidget, + id=f'{self._idprefix}|column', + border=2, + left_border=-200, + margin=0, ) bui.widget(edit=self._menu_button, down_widget=self._columnwidget) @@ -160,6 +168,7 @@ class PartyWindow(bui.Window): self._text_field = txt = bui.textwidget( parent=self._root_widget, + id=f'{self._idprefix}|messagetext', editable=True, size=(530, 40), position=(44, 39), @@ -190,6 +199,7 @@ class PartyWindow(bui.Window): btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self._idprefix}|send', size=(50, 35), label=bui.Lstr(resource=f'{self._r}.sendText'), button_type='square', diff --git a/dist/ba_data/python/bauiv1lib/play.py b/dist/ba_data/python/bauiv1lib/play.py index 0d03c55..5ad3c49 100644 --- a/dist/ba_data/python/bauiv1lib/play.py +++ b/dist/ba_data/python/bauiv1lib/play.py @@ -4,7 +4,7 @@ from __future__ import annotations -import logging +# import logging from typing import override, TYPE_CHECKING import bascenev1 as bs @@ -34,6 +34,8 @@ class PlayWindow(bui.MainWindow): import bacommon.cloud + ui = bui.app.ui_v1 + # TEMP TESTING if bool(False): print('HELLO FROM TEST') @@ -59,7 +61,7 @@ class PlayWindow(bui.MainWindow): self._playlist_select_context = playlist_select_context - uiscale = bui.app.ui_v1.uiscale + uiscale = ui.uiscale width = 1300 if uiscale is bui.UIScale.SMALL else 1000 height = 1000 if uiscale is bui.UIScale.SMALL else 550 @@ -127,6 +129,7 @@ class PlayWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(50, yoffs - 100), size=(60, 60), scale=1.1, @@ -158,7 +161,7 @@ class PlayWindow(bui.MainWindow): scale=1.2 if uiscale is bui.UIScale.SMALL else 1.7, res_scale=2.0, maxwidth=250, - color=bui.app.ui_v1.heading_color, + color=ui.heading_color, h_align='center', v_align='center', ) @@ -203,6 +206,7 @@ class PlayWindow(bui.MainWindow): if self._playlist_select_context is None: self._coop_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|coop', position=(hoffs, v), size=( scl * button_width, @@ -303,6 +307,7 @@ class PlayWindow(bui.MainWindow): self._teams_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|teams', position=(hoffs, v), size=( scl * button_width, @@ -424,6 +429,7 @@ class PlayWindow(bui.MainWindow): hoffs += scl * button_width + button_spacing self._free_for_all_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|ffa', position=(hoffs, v), size=(scl * button_width, scl * button_height), extra_touch_border_scale=0.1, @@ -555,8 +561,6 @@ class PlayWindow(bui.MainWindow): ), ) - self._restore_state() - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -565,17 +569,18 @@ class PlayWindow(bui.MainWindow): # Pull any values out of self here; if we do it in the lambda # we'll keep our window alive inadvertantly. playlist_select_context = self._playlist_select_context + return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget, playlist_select_context=playlist_select_context, - ) + ), ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True @staticmethod def _preload_modules() -> None: @@ -602,19 +607,15 @@ class PlayWindow(bui.MainWindow): return self.main_window_replace( - CoopBrowserWindow(origin_widget=self._coop_button) + lambda: CoopBrowserWindow(origin_widget=self._coop_button) ) def _team_tourney(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.playlist.browser import PlaylistBrowserWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - PlaylistBrowserWindow( + lambda: PlaylistBrowserWindow( origin_widget=self._teams_button, sessiontype=bs.DualTeamSession, playlist_select_context=self._playlist_select_context, @@ -625,12 +626,8 @@ class PlayWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.playlist.browser import PlaylistBrowserWindow - # no-op if we're not currently in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - PlaylistBrowserWindow( + lambda: PlaylistBrowserWindow( origin_widget=self._free_for_all_button, sessiontype=bs.FreeForAllSession, playlist_select_context=self._playlist_select_context, @@ -755,43 +752,3 @@ class PlayWindow(bui.MainWindow): color=eye_color, mesh_transparent=self._eyes_mesh, ) - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._teams_button: - sel_name = 'Team Games' - elif self._coop_button is not None and sel == self._coop_button: - sel_name = 'Co-op Games' - elif sel == self._free_for_all_button: - sel_name = 'Free-for-All Games' - elif sel == self._back_button: - sel_name = 'Back' - else: - raise ValueError(f'unrecognized selection {sel}') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - if sel_name == 'Team Games': - sel = self._teams_button - elif sel_name == 'Co-op Games' and self._coop_button is not None: - sel = self._coop_button - elif sel_name == 'Free-for-All Games': - sel = self._free_for_all_button - elif sel_name == 'Back' and self._back_button is not None: - sel = self._back_button - else: - sel = ( - self._coop_button - if self._coop_button is not None - else self._teams_button - ) - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/playlist/addgame.py b/dist/ba_data/python/bauiv1lib/playlist/addgame.py index ed62ad5..b2457a0 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/addgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/addgame.py @@ -202,6 +202,10 @@ class PlaylistAddGameWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _on_game_types_loaded( self, gametypes: list[type[bs.GameActivity]] ) -> None: @@ -288,7 +292,7 @@ class PlaylistAddGameWindow(bui.MainWindow): return self.main_window_replace( - StoreBrowserWindow( + lambda: StoreBrowserWindow( show_tab=StoreBrowserWindow.TabID.MINIGAMES, origin_widget=self._get_more_games_button, minimal_toolbars=True, diff --git a/dist/ba_data/python/bauiv1lib/playlist/browser.py b/dist/ba_data/python/bauiv1lib/playlist/browser.py index 2eb2232..77b30d5 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/browser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/browser.py @@ -10,6 +10,7 @@ import logging from typing import override, TYPE_CHECKING import bascenev1 as bs +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui if TYPE_CHECKING: @@ -27,7 +28,7 @@ class PlaylistBrowserWindow(bui.MainWindow): playlist_select_context: PlaylistSelectContext | None = None, ): # pylint: disable=cyclic-import - # pylint: disable=too-many-locals + # pylint: disable=too-many-statements from bauiv1lib.playlist import PlaylistTypeVars # Store state for when we exit the next game. @@ -87,6 +88,11 @@ class PlaylistBrowserWindow(bui.MainWindow): self._scroll_height = target_height - 31 scroll_bottom = yoffs - 60 - self._scroll_height + # Go with full-screen scrollable area in small ui. + if uiscale is bui.UIScale.SMALL: + self._scroll_height += 35 + scroll_bottom -= 2 + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), @@ -112,6 +118,7 @@ class PlaylistBrowserWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(59, yoffs - 45), size=(60, 54), scale=1.0, @@ -124,21 +131,6 @@ class PlaylistBrowserWindow(bui.MainWindow): edit=self._root_widget, cancel_button=self._back_button ) - self._title_text = bui.textwidget( - parent=self._root_widget, - position=( - self._width * 0.5, - yoffs - (45 if uiscale is bui.UIScale.SMALL else 20), - ), - size=(0, 0), - text=self._pvars.window_title_name, - scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3), - res_scale=1.5, - color=bui.app.ui_v1.heading_color, - h_align='center', - v_align='center', - ) - self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, @@ -155,43 +147,44 @@ class PlaylistBrowserWindow(bui.MainWindow): self._config_name_full = self._pvars.config_name + ' Playlists' self._last_config = None - # Add some blotches so our contents fades out as it approaches - # the bottom toolbar. - if uiscale is bui.UIScale.SMALL and playlist_select_context is None: - blotchwidth = 500.0 - blotchheight = 200.0 - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - - self._scroll_width * 0.5 - + 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), + # With full-screen scrolling, fade content as it approaches + # toolbars. + if uiscale is bui.UIScale.SMALL and bool(True): + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - + self._scroll_width * 0.5 - - 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + if playlist_select_context is None: + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + + self._title_text = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + yoffs - (45 if uiscale is bui.UIScale.SMALL else 20), + ), + size=(0, 0), + text=self._pvars.window_title_name, + scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3), + res_scale=1.5, + color=bui.app.ui_v1.heading_color, + h_align='center', + v_align='center', + ) + + # By default, start with the scroll-widget selected. + bui.containerwidget( + edit=self._root_widget, selected_child=self._scrollwidget + ) # Update now and once per second (this should do our initial # refresh). @@ -223,8 +216,8 @@ class PlaylistBrowserWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _ensure_standard_playlists_exist(self) -> None: plus = bui.app.plus @@ -396,7 +389,6 @@ class PlaylistBrowserWindow(bui.MainWindow): if not self._root_widget: return if self._subcontainer is not None: - self._save_state() self._subcontainer.delete() # Make sure config exists. @@ -438,6 +430,12 @@ class PlaylistBrowserWindow(bui.MainWindow): + 90 + extra_bottom_buffer ) + + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + self._sub_height += 35 + assert self._sub_width is not None assert self._sub_height is not None self._subcontainer = bui.containerwidget( @@ -459,11 +457,16 @@ class PlaylistBrowserWindow(bui.MainWindow): 40 if uiscale is bui.UIScale.SMALL and screensize[0] < 1400 else 0 ) + # For fullscreen scrollable, account for toolbar. + yoffs = 0 + if uiscale is bui.UIScale.SMALL: + yoffs -= 35 + assert bui.app.classic is not None bui.textwidget( parent=self._subcontainer, text=bui.Lstr(resource='playlistsText'), - position=(40 + xoffs, self._sub_height - 26), + position=(40 + xoffs, self._sub_height + yoffs - 26), size=(0, 0), scale=1.0, maxwidth=400, @@ -494,6 +497,7 @@ class PlaylistBrowserWindow(bui.MainWindow): + 8 + h_offs, self._sub_height + + yoffs - 47 - (y + 1) * (button_height + 2 * button_buffer_v), ) @@ -505,6 +509,11 @@ class PlaylistBrowserWindow(bui.MainWindow): label='', position=pos, ) + # We handle reselecting these manually below so don't + # provide them proper ids for auto-reselection. Let's + # suppress the warnings that usually happen in that + # case. + bui.widget(edit=btn, allow_preserve_selection=False) if x == 0 and uiscale is bui.UIScale.SMALL: bui.widget( @@ -527,7 +536,7 @@ class PlaylistBrowserWindow(bui.MainWindow): # Top row biases things up more to show header above it. if y == 0: bui.widget( - edit=btn, show_buffer_top=60, show_buffer_bottom=5 + edit=btn, show_buffer_top=80, show_buffer_bottom=5 ) else: bui.widget( @@ -710,6 +719,7 @@ class PlaylistBrowserWindow(bui.MainWindow): break self._customize_button = btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|customize', size=(100, 30), position=(34 + h_offs_bottom, 50 + extra_bottom_buffer), text_scale=0.6, @@ -720,7 +730,6 @@ class PlaylistBrowserWindow(bui.MainWindow): autoselect=True, ) bui.widget(edit=btn, show_buffer_top=22, show_buffer_bottom=60) - self._restore_state() def on_play_options_window_run_game(self) -> None: """(internal)""" @@ -775,7 +784,6 @@ class PlaylistBrowserWindow(bui.MainWindow): if not exists: return - self._save_state() PlayOptionsWindow( sessiontype=self._sessiontype, scale_origin=button.get_screen_space_center(), @@ -790,20 +798,14 @@ class PlaylistBrowserWindow(bui.MainWindow): PlaylistCustomizeBrowserWindow, ) - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - PlaylistCustomizeBrowserWindow( + lambda: PlaylistCustomizeBrowserWindow( origin_widget=self._customize_button, sessiontype=self._sessiontype, ) ) def _on_back_press(self) -> None: - # pylint: disable=cyclic-import - # from bauiv1lib.play import PlayWindow # no-op if we're not in control. if not self.main_window_has_control(): @@ -822,43 +824,3 @@ class PlaylistBrowserWindow(bui.MainWindow): cfg.commit() self.main_window_back() - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._back_button: - sel_name = 'Back' - elif sel == self._scrollwidget: - assert self._subcontainer is not None - subsel = self._subcontainer.get_selected_child() - if subsel == self._customize_button: - sel_name = 'Customize' - else: - sel_name = 'Scroll' - else: - raise RuntimeError('Unrecognized selected widget.') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'Scroll': - sel = self._scrollwidget - elif sel_name == 'Customize': - sel = self._scrollwidget - bui.containerwidget( - edit=self._subcontainer, - selected_child=self._customize_button, - visible_child=self._customize_button, - ) - else: - sel = self._scrollwidget - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py index 5a76d5f..a8b2476 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py @@ -103,6 +103,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(43, yoffs - 87), size=(60, 60), scale=0.77, @@ -134,11 +135,12 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): ymargin = 0.05 def _make_button( - i: int, label: bui.Lstr, call: Callable[[], None] + i: int, button_id: str, label: bui.Lstr, call: Callable[[], None] ) -> bui.Widget: v = self._scroll_bottom + self._button_height * i return bui.buttonwidget( parent=self._root_widget, + id=button_id, position=( h + xmargin * self._button_width, v + ymargin * self._button_height, @@ -158,6 +160,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): new_button = _make_button( 5, + f'{self.main_window_id_prefix}|new', bui.Lstr( resource='newText', fallback_resource=f'{self._r}.newText' ), @@ -165,6 +168,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): ) self._edit_button = _make_button( 4, + f'{self.main_window_id_prefix}|edit', bui.Lstr( resource='editText', fallback_resource=f'{self._r}.editText', @@ -174,6 +178,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): duplicate_button = _make_button( 3, + f'{self.main_window_id_prefix}|duplicate', bui.Lstr( resource='duplicateText', fallback_resource=f'{self._r}.duplicateText', @@ -183,6 +188,7 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): delete_button = _make_button( 2, + f'{self.main_window_id_prefix}|delete', bui.Lstr( resource='deleteText', fallback_resource=f'{self._r}.deleteText' ), @@ -190,11 +196,17 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): ) self._import_button = _make_button( - 1, bui.Lstr(resource='importText'), self._import_playlist + 1, + f'{self.main_window_id_prefix}|import', + bui.Lstr(resource='importText'), + self._import_playlist, ) share_button = _make_button( - 0, bui.Lstr(resource='shareText'), self._share_playlist + 0, + f'{self.main_window_id_prefix}|share', + bui.Lstr(resource='shareText'), + self._share_playlist, ) scrollwidget = bui.scrollwidget( @@ -284,6 +296,10 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + @override def on_main_window_close(self) -> None: if self._selected_playlist_name is not None: @@ -353,7 +369,15 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): on_activate_call=bui.Call(self._edit_button.activate), selectable=True, ) - bui.widget(edit=txtw, show_buffer_top=50, show_buffer_bottom=50) + # We don't give these widgets ids because we handle + # re-selecting them ourself, but we need to suppress the + # warning this usually causes. + bui.widget( + edit=txtw, + show_buffer_top=50, + show_buffer_bottom=50, + allow_preserve_selection=False, + ) # Hitting up from top widget should jump to 'back'. if index == 0: @@ -593,11 +617,11 @@ class PlaylistCustomizeBrowserWindow(bui.MainWindow): subs=[('${LIST}', self._selected_playlist_name)], ), self._do_delete_playlist, - 450, - 150, + width=450, + height=150, ) - def _get_playlist_display_name(self, playlist: str) -> bui.Lstr: + def _get_playlist_display_name(self, playlist: str | bui.Lstr) -> bui.Lstr: if playlist == '__default__': return self._pvars.default_list_name return ( diff --git a/dist/ba_data/python/bauiv1lib/playlist/edit.py b/dist/ba_data/python/bauiv1lib/playlist/edit.py index afb26ac..88735e4 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/edit.py +++ b/dist/ba_data/python/bauiv1lib/playlist/edit.py @@ -289,6 +289,10 @@ class PlaylistEditWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _set_ui_selection(self, selection: str) -> None: self._editcontroller.set_edit_ui_selection(selection) diff --git a/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py b/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py index 7126022..0a005f7 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py +++ b/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py @@ -92,8 +92,10 @@ class PlaylistEditController: # and that's all they can do. self._edit_ui_selection = 'add_button' - editwindow = PlaylistEditWindow(editcontroller=self) - from_window.main_window_replace(editwindow) + editwindow = from_window.main_window_replace( + lambda: PlaylistEditWindow(editcontroller=self) + ) + assert editwindow is not None # Once we've set our start window, store the back state. We'll # skip back to there once we're fully done. @@ -151,17 +153,17 @@ class PlaylistEditController: """(internal)""" from bauiv1lib.playlist.addgame import PlaylistAddGameWindow - # assert bui.app.classic is not None - # No op if we're not in control. if not from_window.main_window_has_control(): return - addwindow = PlaylistAddGameWindow(editcontroller=self) - from_window.main_window_replace(addwindow) + addwindow = from_window.main_window_replace( + lambda: PlaylistAddGameWindow(editcontroller=self) + ) + assert addwindow is not None # Once we're there, store the back state. We'll use that to jump - # back to our current location once the edit is done. + # back out to our current location once the edit is done. assert self._pre_game_add_state is None self._pre_game_add_state = addwindow.main_window_back_state @@ -197,16 +199,18 @@ class PlaylistEditController: assert self._sessiontype is not None # Jump into an edit window. - editwindow = PlaylistEditGameWindow( - gametype, - self._sessiontype, - copy.deepcopy(settings), - completion_call=self._edit_game_done, + editwindow = from_window.main_window_replace( + lambda: PlaylistEditGameWindow( + gametype, + self._sessiontype, + copy.deepcopy(settings), + completion_call=self._edit_game_done, + ) ) - from_window.main_window_replace(editwindow) + assert editwindow is not None # Once we're there, store the back state. We'll use that to jump - # back to our current location once the edit is done. + # back out to our current location once the edit is done. assert self._pre_game_edit_state is None self._pre_game_edit_state = editwindow.main_window_back_state diff --git a/dist/ba_data/python/bauiv1lib/playlist/editgame.py b/dist/ba_data/python/bauiv1lib/playlist/editgame.py index 9fac932..f83b2a4 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/editgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/editgame.py @@ -541,6 +541,10 @@ class PlaylistEditGameWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _get_localized_setting_name(self, name: str) -> bui.Lstr: return bui.Lstr(translate=('settingNames', name)) @@ -552,14 +556,14 @@ class PlaylistEditGameWindow(bui.MainWindow): if not self.main_window_has_control(): return - self._config = self._getconfig() + config = self._config = self._getconfig() # Replace ourself with the map-select UI. self.main_window_replace( - PlaylistMapSelectWindow( + lambda: PlaylistMapSelectWindow( self._gametype, self._sessiontype, - self._config, + config, self._edit_info, self._completion_call, ) diff --git a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py index 935accd..ad60a2a 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py +++ b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py @@ -148,6 +148,10 @@ class PlaylistMapSelectWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _refresh(self, select_get_more_maps_button: bool = False) -> None: # pylint: disable=too-many-statements # pylint: disable=too-many-branches @@ -301,7 +305,7 @@ class PlaylistMapSelectWindow(bui.MainWindow): self._selected_get_more_maps = True self.main_window_replace( - StoreBrowserWindow( + lambda: StoreBrowserWindow( show_tab=StoreBrowserWindow.TabID.MAPS, origin_widget=self._get_more_maps_button, minimal_toolbars=True, diff --git a/dist/ba_data/python/bauiv1lib/playoptions.py b/dist/ba_data/python/bauiv1lib/playoptions.py index 5481bec..70fcae0 100644 --- a/dist/ba_data/python/bauiv1lib/playoptions.py +++ b/dist/ba_data/python/bauiv1lib/playoptions.py @@ -40,6 +40,8 @@ class PlayOptionsWindow(PopupWindow): from bauiv1lib.playlist import PlaylistTypeVars from bauiv1lib.config import ConfigNumberEdit + ui = bui.app.ui_v1 + self._r = 'gameListWindow' self._delegate = delegate self._pvars = PlaylistTypeVars(sessiontype) @@ -145,7 +147,7 @@ class PlayOptionsWindow(PopupWindow): if show_shuffle_check_box: self._height += 40 - uiscale = bui.app.ui_v1.uiscale + uiscale = ui.uiscale scale = ( 1.69 if uiscale is bui.UIScale.SMALL @@ -184,6 +186,7 @@ class PlayOptionsWindow(PopupWindow): on_activate_call=self._on_cancel_press, autoselect=True, ) + bui.widget(edit=self._cancel_button, allow_preserve_selection=False) h_offs_img = self._width * 0.5 - c_width_total * 0.5 v_offs_img = self._height - 118 - scl * 125.0 + 50 @@ -247,6 +250,8 @@ class PlayOptionsWindow(PopupWindow): mesh_transparent=mesh_transparent if owned else None, mask_texture=mask_tex if owned else None, ) + bui.widget(edit=btn, allow_preserve_selection=False) + if row == 0 and col == 0: bui.widget(edit=self._cancel_button, down_widget=btn) if row == rows - 1: @@ -316,6 +321,11 @@ class PlayOptionsWindow(PopupWindow): textcolor=(0.8, 0.8, 0.8), label=bui.Lstr(resource='teamNamesColorText'), ) + bui.widget( + edit=self._custom_colors_names_button, + allow_preserve_selection=False, + ) + assert bui.app.classic is not None if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): bui.imagewidget( @@ -414,6 +424,7 @@ class PlayOptionsWindow(PopupWindow): ) ), ) + bui.widget(edit=self._ok_button, allow_preserve_selection=False) bui.widget( edit=self._ok_button, up_widget=self._show_tutorial_check_box @@ -505,8 +516,6 @@ class PlayOptionsWindow(PopupWindow): # Head back to the gather window in playlist-select mode or # start the game in regular mode. if self._playlist_select_context is not None: - # from bauiv1lib.gather import GatherWindow - if self._sessiontype is bs.FreeForAllSession: typename = 'ffa' elif self._sessiontype is bs.DualTeamSession: diff --git a/dist/ba_data/python/bauiv1lib/popup.py b/dist/ba_data/python/bauiv1lib/popup.py index 6641654..dfc87d5 100644 --- a/dist/ba_data/python/bauiv1lib/popup.py +++ b/dist/ba_data/python/bauiv1lib/popup.py @@ -102,8 +102,8 @@ class PopupWindow: on_cancel_call=self.on_popup_cancel, darken_behind=darken_behind, ) - # complain if we outlive our root widget - bui.uicleanupcheck(self, self.root_widget) + # Complain if we outlive our root widget. + bui.app.ui_v1.add_ui_cleanup_check(self, self.root_widget) def on_popup_cancel(self) -> None: """Called when the popup is canceled. @@ -253,6 +253,8 @@ class PopupMenuWindow(PopupWindow): selectable=(not inactive), glow_type='uniform', ) + bui.widget(edit=wdg, allow_preserve_selection=False) + if choice == self._current_choice: bui.containerwidget( edit=self._columnwidget, @@ -312,6 +314,7 @@ class PopupMenu: position: tuple[float, float], choices: Sequence[str], *, + button_id: str | None = None, current_choice: str | None = None, on_value_change_call: Callable[[str], Any] | None = None, opening_call: Callable[[], Any] | None = None, @@ -359,6 +362,7 @@ class PopupMenu: self._button = bui.buttonwidget( parent=self._parent, + id=button_id, position=(self._position[0], self._position[1]), autoselect=autoselect, size=self._button_size, @@ -375,7 +379,7 @@ class PopupMenu: self._window_widget: bui.Widget | None = None # Complain if we outlive our button. - bui.uicleanupcheck(self, self._button) + bui.app.ui_v1.add_ui_cleanup_check(self, self._button) def _make_popup(self) -> None: if not self._button: diff --git a/dist/ba_data/python/bauiv1lib/profile/browser.py b/dist/ba_data/python/bauiv1lib/profile/browser.py index 649dc2c..18a49f7 100644 --- a/dist/ba_data/python/bauiv1lib/profile/browser.py +++ b/dist/ba_data/python/bauiv1lib/profile/browser.py @@ -4,23 +4,24 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING, override import bauiv1 as bui import bascenev1 as bs if TYPE_CHECKING: - from typing import Any + from typing import Any, ClassVar class ProfileBrowserWindow(bui.MainWindow): """Window for browsing player profiles.""" + # Keep track of this at the class level to share between instances. + selected_profile: ClassVar[str | None] = None + def __init__( self, transition: str | None = 'in_right', - # in_main_menu: bool = True, selected_profile: str | None = None, origin_widget: bui.Widget | None = None, minimal_toolbar: bool = False, @@ -80,6 +81,7 @@ class ProfileBrowserWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(40 + x_inset, self._height - 59), size=(120, 60), scale=0.8, @@ -122,6 +124,7 @@ class ProfileBrowserWindow(bui.MainWindow): v -= 70.0 * scl self._new_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|new', position=(h, v), size=(80, 66.0 * scl), on_activate_call=self._new_profile, @@ -135,6 +138,7 @@ class ProfileBrowserWindow(bui.MainWindow): v -= 70.0 * scl self._edit_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|edit', position=(h, v), size=(80, 66.0 * scl), on_activate_call=self._edit_profile, @@ -148,6 +152,7 @@ class ProfileBrowserWindow(bui.MainWindow): v -= 70.0 * scl self._delete_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|delete', position=(h, v), size=(80, 66.0 * scl), on_activate_call=self._delete_profile, @@ -194,10 +199,10 @@ class ProfileBrowserWindow(bui.MainWindow): ) v -= 255 self._profiles: dict[str, dict[str, Any]] | None = None - self._selected_profile = selected_profile + if selected_profile is not None: + type(self).selected_profile = selected_profile self._profile_widgets: list[bui.Widget] = [] self._refresh() - self._restore_state() @override def get_main_window_state(self) -> bui.MainWindowState: @@ -215,8 +220,19 @@ class ProfileBrowserWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True + + # @override + # def main_window_do_save_shared_state(self, state: dict) -> None: + # state['selected_profile'] = self._selected_profile + + # @override + # def main_window_do_restore_shared_state(self, state: dict) -> None: + # pval = state.get('selected_profile') + # if isinstance(pval, str | None): + # print('RESTORING', pval) + # self._selected_profile = pval def _new_profile(self) -> None: # pylint: disable=cyclic-import @@ -263,19 +279,21 @@ class ProfileBrowserWindow(bui.MainWindow): bui.getsound('error').play() return - self.main_window_replace(EditProfileWindow(existing_profile=None)) + self.main_window_replace( + lambda: EditProfileWindow(existing_profile=None) + ) def _delete_profile(self) -> None: # pylint: disable=cyclic-import from bauiv1lib import confirm - if self._selected_profile is None: + if self.selected_profile is None: bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) ) return - if self._selected_profile == '__account__': + if self.selected_profile == '__account__': bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource=f'{self._r}.cantDeleteAccountProfileText'), @@ -285,18 +303,21 @@ class ProfileBrowserWindow(bui.MainWindow): confirm.ConfirmWindow( bui.Lstr( resource=f'{self._r}.deleteConfirmText', - subs=[('${PROFILE}', self._selected_profile)], + subs=[('${PROFILE}', self.selected_profile)], ), self._do_delete_profile, - 350, + width=350, ) def _do_delete_profile(self) -> None: plus = bui.app.plus assert plus is not None + # Go back to default selection. + type(self).selected_profile = None + plus.add_v1_account_transaction( - {'type': 'REMOVE_PLAYER_PROFILE', 'name': self._selected_profile} + {'type': 'REMOVE_PLAYER_PROFILE', 'name': self.selected_profile} ) plus.run_v1_account_transactions() bui.getsound('shieldDown').play() @@ -315,18 +336,20 @@ class ProfileBrowserWindow(bui.MainWindow): if not self.main_window_has_control(): return - if self._selected_profile is None: + if self.selected_profile is None: bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) ) return - self.main_window_replace(EditProfileWindow(self._selected_profile)) + self.main_window_replace( + lambda: EditProfileWindow(self.selected_profile) + ) def _select(self, name: str, index: int) -> None: del index # Unused. - self._selected_profile = name + type(self).selected_profile = name def _refresh(self) -> None: # pylint: disable=too-many-locals @@ -340,7 +363,7 @@ class ProfileBrowserWindow(bui.MainWindow): plus = bui.app.plus assert plus is not None - old_selection = self._selected_profile + old_selection = self.selected_profile # Delete old. while self._profile_widgets: @@ -390,6 +413,7 @@ class ProfileBrowserWindow(bui.MainWindow): assert isinstance(tval, str) txtw = bui.textwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|profile{index}', position=(5, y_val), size=((self._width - 210) / scl, 28), text=bui.Lstr(value=f' {tval}'), @@ -403,6 +427,9 @@ class ProfileBrowserWindow(bui.MainWindow): on_activate_call=bui.Call(self._edit_button.activate), selectable=True, ) + # We handle reselection of these manually; no need for ids. + bui.widget(edit=txtw, allow_preserve_selection=False) + character = bui.imagewidget( parent=self._subcontainer, position=(0, y_val), @@ -416,16 +443,16 @@ class ProfileBrowserWindow(bui.MainWindow): ) if index == 0: bui.widget(edit=txtw, up_widget=self._back_button) - if self._selected_profile is None: - self._selected_profile = p_name + if self.selected_profile is None: + type(self).selected_profile = p_name bui.widget(edit=txtw, show_buffer_top=40, show_buffer_bottom=40) self._profile_widgets.append(txtw) self._profile_widgets.append(character) - # Select/show this one if it was previously selected + # Select/show this one if it was previously selected. # (but defer till after this loop since our height is # still changing). - if p_name == old_selection: + if p_name == old_selection or widget_to_select is None: widget_to_select = txtw index += 1 @@ -447,46 +474,3 @@ class ProfileBrowserWindow(bui.MainWindow): session = bs.get_foreground_host_session() if session is not None: session.handlemessage(PlayerProfilesChangedMessage()) - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._new_button: - sel_name = 'New' - elif sel == self._edit_button: - sel_name = 'Edit' - elif sel == self._delete_button: - sel_name = 'Delete' - elif sel == self._scrollwidget: - sel_name = 'Scroll' - else: - sel_name = 'Back' - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - if sel_name == 'Scroll': - sel = self._scrollwidget - elif sel_name == 'New': - sel = self._new_button - elif sel_name == 'Delete': - sel = self._delete_button - elif sel_name == 'Edit': - sel = self._edit_button - elif sel_name == 'Back': - sel = self._back_button - else: - # By default we select our scroll widget if we have profiles; - # otherwise our new widget. - if not self._profile_widgets: - sel = self._new_button - else: - sel = self._scrollwidget - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/profile/edit.py b/dist/ba_data/python/bauiv1lib/profile/edit.py index 0cfac6b..275e269 100644 --- a/dist/ba_data/python/bauiv1lib/profile/edit.py +++ b/dist/ba_data/python/bauiv1lib/profile/edit.py @@ -22,20 +22,13 @@ class EditProfileWindow( def reload_window(self) -> None: """Transitions out and recreates ourself.""" - # no-op if we're not in control. - if not self.main_window_has_control(): - return - # Replace ourself with ourself, but keep the same back location. assert self.main_window_back_state is not None self.main_window_replace( - EditProfileWindow(self.getname()), + lambda: EditProfileWindow(self.getname()), back_state=self.main_window_back_state, ) - # def __del__(self) -> None: - # print(f'~EditProfileWindow({id(self)})') - def __init__( self, existing_profile: str | None, @@ -535,6 +528,11 @@ class EditProfileWindow( ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Not bothering with this for now. + return False + def assign_random_name(self) -> None: """Assigning a random name to the player.""" names = bs.get_random_names() @@ -652,11 +650,8 @@ class EditProfileWindow( """User wants to get more icons.""" from bauiv1lib.store.browser import StoreBrowserWindow - if not self.main_window_has_control(): - return - self.main_window_replace( - StoreBrowserWindow( + lambda: StoreBrowserWindow( minimal_toolbars=True, show_tab=StoreBrowserWindow.TabID.ICONS, ) @@ -680,11 +675,8 @@ class EditProfileWindow( def on_character_picker_get_more_press(self) -> None: from bauiv1lib.store.browser import StoreBrowserWindow - if not self.main_window_has_control(): - return - self.main_window_replace( - StoreBrowserWindow( + lambda: StoreBrowserWindow( minimal_toolbars=True, show_tab=StoreBrowserWindow.TabID.CHARACTERS, ) @@ -832,6 +824,9 @@ class EditProfileWindow( def save(self, transition_out: bool = True) -> bool: """Save has been selected.""" + # pylint: disable=cyclic-import + + from bauiv1lib.profile.browser import ProfileBrowserWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -856,6 +851,9 @@ class EditProfileWindow( bui.getsound('error').play() return False + # Set the profile-browser to have this one selected by default. + ProfileBrowserWindow.selected_profile = new_name + if transition_out: bui.getsound('gunCocking').play() diff --git a/dist/ba_data/python/bauiv1lib/purchase.py b/dist/ba_data/python/bauiv1lib/purchase.py index 5ee4497..911cd2c 100644 --- a/dist/ba_data/python/bauiv1lib/purchase.py +++ b/dist/ba_data/python/bauiv1lib/purchase.py @@ -36,6 +36,8 @@ class PurchaseWindow(bui.Window): ) if len(items) != 1: raise ValueError('expected exactly 1 item') + + self._idprefix = bui.app.ui_v1.new_id_prefix('purchase') self._items = list(items) self._width = 580 self._height = 520 @@ -81,6 +83,7 @@ class PurchaseWindow(bui.Window): instantiate_store_item_display( items[0], display, + idprefix=self._idprefix, parent_widget=self._root_widget, b_pos=( self._width * 0.5 diff --git a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py index f58a111..c5eb343 100644 --- a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py +++ b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py @@ -32,6 +32,7 @@ class ResourceTypeInfoWindow(PopupWindow): self._width = 570 self._height = 400 self._get_tokens_button: bui.Widget | None = None + self._idprefix = bui.app.ui_v1.new_id_prefix('resourcetypeinfo') bg_color = (0.5, 0.4, 0.6) super().__init__( size=(self._width, self._height), @@ -43,17 +44,15 @@ class ResourceTypeInfoWindow(PopupWindow): ) self._cancel_button = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|cancel', position=(40, self._height - 40), size=(50, 50), scale=0.7, - # label='', color=bg_color, on_activate_call=self._on_cancel_press, autoselect=True, label=bui.charstr(bui.SpecialChar.CLOSE), textcolor=(1, 1, 1), - # icon=bui.gettexture('crossOut'), - # iconscale=1.2, ) yoffs = self._height - 145 @@ -78,6 +77,7 @@ class ResourceTypeInfoWindow(PopupWindow): if not bui.app.classic.gold_pass: self._get_tokens_button = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|gettokens', position=( self._width * 0.5 - bwidth * 0.5, yoffs - 15.0 - bheight - max_rdesc_height, diff --git a/dist/ba_data/python/bauiv1lib/sendinfo.py b/dist/ba_data/python/bauiv1lib/sendinfo.py index a2cb5d2..ddb63cc 100644 --- a/dist/ba_data/python/bauiv1lib/sendinfo.py +++ b/dist/ba_data/python/bauiv1lib/sendinfo.py @@ -72,6 +72,7 @@ class SendInfoWindow(bui.MainWindow): if uiscale is not bui.UIScale.SMALL: close_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|close', position=(25, yoffs - 35), size=(60, 60), scale=0.7, @@ -114,6 +115,7 @@ class SendInfoWindow(bui.MainWindow): self._text_field = bui.textwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|desc', position=(width * 0.5 + txoffs + 125, v), size=(380, 46), text='', @@ -134,6 +136,7 @@ class SendInfoWindow(bui.MainWindow): b_width = 200 self._enter_button = btn2 = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|enter', position=(width * 0.5 - b_width * 0.5, v), size=(b_width, 60), scale=1.0, @@ -170,12 +173,14 @@ class SendInfoWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _activate_enter_button(self) -> None: self._enter_button.activate() def _do_enter(self) -> None: - # pylint: disable=cyclic-import - # from bauiv1lib.settings.advanced import AdvancedSettingsWindow plus = bui.app.plus assert plus is not None diff --git a/dist/ba_data/python/bauiv1lib/settings/advanced.py b/dist/ba_data/python/bauiv1lib/settings/advanced.py index e2f7f8b..d7a3f2b 100644 --- a/dist/ba_data/python/bauiv1lib/settings/advanced.py +++ b/dist/ba_data/python/bauiv1lib/settings/advanced.py @@ -1,6 +1,5 @@ # Released under the MIT License. See LICENSE for details. # -# pylint: disable=too-many-lines """UI functionality for advanced settings.""" @@ -12,6 +11,7 @@ from typing import TYPE_CHECKING, override from bacommon.locale import LocaleResolved from bauiv1lib.popup import PopupMenu +from bauiv1lib.utils import scroll_fade_bottom, scroll_fade_top import bauiv1 as bui if TYPE_CHECKING: @@ -73,6 +73,11 @@ class AdvancedSettingsWindow(bui.MainWindow): self._scroll_height = target_height - 25 scroll_bottom = yoffs - 56 - self._scroll_height + # Go with full-screen scrollable area in small ui. + if uiscale is bui.UIScale.SMALL: + self._scroll_height += 27 + scroll_bottom += 1 + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), @@ -100,6 +105,10 @@ class AdvancedSettingsWindow(bui.MainWindow): self._sub_width = min(550, self._scroll_width * 0.95) self._sub_height = 920.0 + # For fullscreen scrollable, account for toolbar. + if uiscale is bui.UIScale.SMALL: + self._sub_height += 27 + if self._show_always_use_internal_keyboard: self._sub_height += 62 @@ -132,6 +141,7 @@ class AdvancedSettingsWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(50, yoffs - 48), size=(60, 60), scale=0.8, @@ -144,6 +154,50 @@ class AdvancedSettingsWindow(bui.MainWindow): edit=self._root_widget, cancel_button=self._back_button ) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), + simple_culling_v=20.0, + highlight=False, + center_small_content_horizontally=True, + selection_loops_to_parent=True, + border_opacity=0.4, + ) + bui.containerwidget( + edit=self._root_widget, selected_child=self._scrollwidget + ) + bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) + + self._subcontainer = bui.containerwidget( + parent=self._scrollwidget, + size=(self._sub_width, self._sub_height), + background=False, + selection_loops_to_parent=True, + ) + + # With full-screen scrolling, fade content as it approaches + # toolbars. (but only in the main menu where we're showing said + # toolbars). + if uiscale is bui.UIScale.SMALL and bui.in_main_menu(): + scroll_fade_top( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + scroll_fade_bottom( + self._root_widget, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + self._scroll_width, + self._scroll_height, + ) + self._title_text = bui.textwidget( parent=self._root_widget, position=( @@ -158,66 +212,6 @@ class AdvancedSettingsWindow(bui.MainWindow): v_align='center', ) - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - size=(self._scroll_width, self._scroll_height), - position=( - self._width * 0.5 - self._scroll_width * 0.5, - scroll_bottom, - ), - simple_culling_v=20.0, - highlight=False, - center_small_content_horizontally=True, - selection_loops_to_parent=True, - border_opacity=0.4, - ) - bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) - self._subcontainer = bui.containerwidget( - parent=self._scrollwidget, - size=(self._sub_width, self._sub_height), - background=False, - selection_loops_to_parent=True, - ) - - # Add some blotches so our contents fades out as it approaches - # the bottom toolbar (but only in the main menu when there's - # something down there). - if uiscale is bui.UIScale.SMALL and bui.in_main_menu(): - blotchwidth = 500.0 - blotchheight = 200.0 - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - - self._scroll_width * 0.5 - + 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - bimg = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture('uiAtlas'), - mesh_transparent=bui.getmesh('windowBGBlotch'), - position=( - self._width * 0.5 - + self._scroll_width * 0.5 - - 60.0 - - blotchwidth * 0.5, - scroll_bottom - blotchheight * 0.5, - ), - size=(blotchwidth, blotchheight), - color=(0.4, 0.37, 0.49), - # color=(1, 0, 0), - ) - bui.widget(edit=bimg, depth_range=(0.9, 1.0)) - self._rebuild() # Rebuild periodically to pick up language changes/additions/etc. @@ -243,8 +237,8 @@ class AdvancedSettingsWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True @staticmethod def _preload_modules() -> None: @@ -342,6 +336,11 @@ class AdvancedSettingsWindow(bui.MainWindow): v = self._sub_height - 35 + # For fullscreen scrollable, account for toolbar. + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + v -= 27 + v -= self._spacing * 1.2 # Update our existing back button and title. @@ -412,6 +411,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._language_popup = PopupMenu( parent=self._subcontainer, + button_id=f'{self.main_window_id_prefix}|language', position=(210, v - 19), width=250, opening_call=bui.WeakCall(self._on_menu_open), @@ -440,6 +440,7 @@ class AdvancedSettingsWindow(bui.MainWindow): ), current_choice=cur_lang, ) + if self._back_button is not None: bui.widget( edit=self._language_popup.get_button(), @@ -469,6 +470,7 @@ class AdvancedSettingsWindow(bui.MainWindow): this_button_width = 410 self._translation_editor_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|translationedit', position=(self._sub_width / 2 - this_button_width / 2, v - 24), size=(this_button_width, 60), label=bui.Lstr( @@ -499,6 +501,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._language_inform_checkbox = cbw = bui.checkboxwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|langinform', position=(50, v - 50), size=(self._sub_width - 100, 30), autoselect=True, @@ -521,6 +524,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._kick_idle_players_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|kickidleplayers', position=(50, v), size=(self._sub_width - 100, 30), configkey='Kick Idle Players', @@ -532,6 +536,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._show_game_ping_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|showping', position=(50, v), size=(self._sub_width - 100, 30), configkey='Show Ping', @@ -543,6 +548,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._show_demos_when_idle_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|showdemoswhenidle', position=(50, v), size=(self._sub_width - 100, 30), configkey='Show Demos When Idle', @@ -554,6 +560,9 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._show_deprecated_login_types_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=( + f'{self.main_window_id_prefix}|showdeprecatedlogintypes' + ), position=(50, v), size=(self._sub_width - 100, 30), configkey='Show Deprecated Login Types', @@ -567,6 +576,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._disable_camera_shake_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|disablecamerashake', position=(50, v), size=(self._sub_width - 100, 30), configkey='Disable Camera Shake', @@ -580,6 +590,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._disable_gyro_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|disablegyro', position=(50, v), size=(self._sub_width - 100, 30), configkey='Disable Camera Gyro', @@ -595,17 +606,16 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._use_insecure_connections_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=( + f'{self.main_window_id_prefix}|useinsecureconnections' + ), position=(50, v), size=(self._sub_width - 100, 30), configkey='Use Insecure Connections', autoselect=True, - # displayname='USE INSECURE CONNECTIONS', displayname=bui.Lstr( resource=(f'{self._r}.insecureConnectionsText') ), - # displayname=bui.Lstr( - # resource=f'{self._r}.alwaysUseInternalKeyboardText' - # ), scale=1.0, maxwidth=430, ) @@ -613,10 +623,6 @@ class AdvancedSettingsWindow(bui.MainWindow): parent=self._subcontainer, position=(90, v - 20), size=(0, 0), - # text=( - # 'not recommended, but may allow online play\n' - # 'from restricted countries or networks' - # ), text=bui.Lstr( resource=(f'{self._r}.insecureConnectionsDescriptionText') ), @@ -636,6 +642,9 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 42 self._always_use_internal_keyboard_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=( + f'{self.main_window_id_prefix}|alwaysuseinternalkb' + ), position=(50, v), size=(self._sub_width - 100, 30), configkey='Always Use Internal Keyboard', @@ -671,6 +680,7 @@ class AdvancedSettingsWindow(bui.MainWindow): this_button_width = 410 self._modding_guide_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|moddingguide', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -685,6 +695,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._dev_tools_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|devtools', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -723,6 +734,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._show_user_mods_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|showusermods', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -735,6 +747,7 @@ class AdvancedSettingsWindow(bui.MainWindow): self._plugins_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|plugins', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -750,6 +763,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= self._extra_button_spacing self._vr_test_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|vrtest', position=(self._sub_width / 2 - this_button_width / 2, v - 14), size=(this_button_width, 60), autoselect=True, @@ -765,6 +779,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= self._extra_button_spacing self._net_test_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|nettest', position=(self._sub_width / 2 - this_button_width / 2, v - 14), size=(this_button_width, 60), autoselect=True, @@ -778,6 +793,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 70 self._benchmarks_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|benchmarks', position=(self._sub_width / 2 - this_button_width / 2, v - 14), size=(this_button_width, 60), autoselect=True, @@ -789,6 +805,7 @@ class AdvancedSettingsWindow(bui.MainWindow): v -= 100 self._send_info_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|sendinfo', position=(self._sub_width / 2 - this_button_width / 2, v - 14), size=(this_button_width, 60), autoselect=True, @@ -798,7 +815,7 @@ class AdvancedSettingsWindow(bui.MainWindow): ) for child in self._subcontainer.get_children(): - bui.widget(edit=child, show_buffer_bottom=60, show_buffer_top=20) + bui.widget(edit=child, show_buffer_bottom=80, show_buffer_top=80) pbtn = bui.get_special_widget('squad_button') bui.widget(edit=self._scrollwidget, right_widget=pbtn) @@ -808,8 +825,6 @@ class AdvancedSettingsWindow(bui.MainWindow): left_widget=bui.get_special_widget('back_button'), ) - self._restore_state() - def _show_restart_needed(self, value: Any) -> None: del value # Unused. bui.screenmessage( @@ -827,20 +842,14 @@ class AdvancedSettingsWindow(bui.MainWindow): def _on_vr_test_press(self) -> None: from bauiv1lib.settings.vrtesting import VRTestingWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(VRTestingWindow(transition='in_right')) + self.main_window_replace(lambda: VRTestingWindow(transition='in_right')) def _on_net_test_press(self) -> None: from bauiv1lib.settings.nettesting import NetTestingWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(NetTestingWindow(transition='in_right')) + self.main_window_replace( + lambda: NetTestingWindow(transition='in_right') + ) def _on_friend_promo_code_press(self) -> None: from bauiv1lib import appinvite @@ -857,192 +866,32 @@ class AdvancedSettingsWindow(bui.MainWindow): def _on_plugins_button_press(self) -> None: from bauiv1lib.settings.plugins import PluginWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - PluginWindow(origin_widget=self._plugins_button) + lambda: PluginWindow(origin_widget=self._plugins_button) ) def _on_dev_tools_button_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.devtools import DevToolsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - DevToolsWindow(origin_widget=self._dev_tools_button) + lambda: DevToolsWindow(origin_widget=self._dev_tools_button) ) def _on_send_info_press(self) -> None: from bauiv1lib.sendinfo import SendInfoWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - SendInfoWindow(origin_widget=self._send_info_button) + lambda: SendInfoWindow(origin_widget=self._send_info_button) ) def _on_benchmark_press(self) -> None: from bauiv1lib.settings.benchmarks import BenchmarksAndStressTestsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - BenchmarksAndStressTestsWindow(transition='in_right') + lambda: BenchmarksAndStressTestsWindow(transition='in_right') ) - def _save_state(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - try: - sel = self._root_widget.get_selected_child() - if sel == self._scrollwidget: - sel = self._subcontainer.get_selected_child() - if sel == self._vr_test_button: - sel_name = 'VRTest' - elif sel == self._net_test_button: - sel_name = 'NetTest' - elif sel == self._send_info_button: - sel_name = 'SendInfo' - elif sel == self._benchmarks_button: - sel_name = 'Benchmarks' - elif sel == self._kick_idle_players_check_box.widget: - sel_name = 'KickIdlePlayers' - elif sel == self._show_demos_when_idle_check_box.widget: - sel_name = 'ShowDemosWhenIdle' - elif sel == self._show_deprecated_login_types_check_box.widget: - sel_name = 'ShowDeprecatedLoginTypes' - elif sel == self._show_game_ping_check_box.widget: - sel_name = 'ShowPing' - elif sel == self._disable_camera_shake_check_box.widget: - sel_name = 'DisableCameraShake' - elif ( - self._always_use_internal_keyboard_check_box is not None - and sel - == self._always_use_internal_keyboard_check_box.widget - ): - sel_name = 'AlwaysUseInternalKeyboard' - elif ( - self._use_insecure_connections_check_box is not None - and sel == self._use_insecure_connections_check_box.widget - ): - sel_name = 'UseInsecureConnections' - elif ( - self._disable_gyro_check_box is not None - and sel == self._disable_gyro_check_box.widget - ): - sel_name = 'DisableGyro' - elif ( - self._language_popup is not None - and sel == self._language_popup.get_button() - ): - sel_name = 'Languages' - elif sel == self._translation_editor_button: - sel_name = 'TranslationEditor' - elif sel == self._show_user_mods_button: - sel_name = 'ShowUserMods' - elif sel == self._plugins_button: - sel_name = 'Plugins' - elif sel == self._dev_tools_button: - sel_name = 'DevTools' - elif sel == self._modding_guide_button: - sel_name = 'ModdingGuide' - elif sel == self._language_inform_checkbox: - sel_name = 'LangInform' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - elif sel == self._back_button: - sel_name = 'Back' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} - - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - if sel_name == 'Back': - sel = self._back_button - else: - bui.containerwidget( - edit=self._root_widget, selected_child=self._scrollwidget - ) - if sel_name == 'VRTest': - sel = self._vr_test_button - elif sel_name == 'NetTest': - sel = self._net_test_button - elif sel_name == 'SendInfo': - sel = self._send_info_button - elif sel_name == 'Benchmarks': - sel = self._benchmarks_button - elif sel_name == 'KickIdlePlayers': - sel = self._kick_idle_players_check_box.widget - elif sel_name == 'ShowDemosWhenIdle': - sel = self._show_demos_when_idle_check_box.widget - elif sel_name == 'ShowDeprecatedLoginTypes': - sel = self._show_deprecated_login_types_check_box.widget - elif sel_name == 'ShowPing': - sel = self._show_game_ping_check_box.widget - elif sel_name == 'DisableCameraShake': - sel = self._disable_camera_shake_check_box.widget - elif ( - sel_name == 'AlwaysUseInternalKeyboard' - and self._always_use_internal_keyboard_check_box is not None - ): - sel = self._always_use_internal_keyboard_check_box.widget - elif ( - sel_name == 'UseInsecureConnections' - and self._use_insecure_connections_check_box is not None - ): - sel = self._use_insecure_connections_check_box.widget - elif ( - sel_name == 'DisableGyro' - and self._disable_gyro_check_box is not None - ): - sel = self._disable_gyro_check_box.widget - elif ( - sel_name == 'Languages' and self._language_popup is not None - ): - sel = self._language_popup.get_button() - elif sel_name == 'TranslationEditor': - sel = self._translation_editor_button - elif sel_name == 'ShowUserMods': - sel = self._show_user_mods_button - elif sel_name == 'Plugins': - sel = self._plugins_button - elif sel_name == 'DevTools': - sel = self._dev_tools_button - elif sel_name == 'ModdingGuide': - sel = self._modding_guide_button - elif sel_name == 'LangInform': - sel = self._language_inform_checkbox - else: - sel = None - if sel is not None: - bui.containerwidget( - edit=self._subcontainer, - selected_child=sel, - visible_child=sel, - ) - except Exception: - logging.exception('Error restoring state for %s.', self) - def _on_menu_open(self) -> None: self._menu_open = True @@ -1062,9 +911,8 @@ class AdvancedSettingsWindow(bui.MainWindow): cfg.apply_and_commit() - self._save_state() + self.main_window_save_shared_state() - # bui.app.lang.setlanguage(None if choice == 'Auto' else choice) bui.apptimer(0.1, bui.WeakCall(self._rebuild)) def _completed_langs_cb(self, results: dict[str, Any] | None) -> None: diff --git a/dist/ba_data/python/bauiv1lib/settings/allsettings.py b/dist/ba_data/python/bauiv1lib/settings/allsettings.py index a46b936..7065061 100644 --- a/dist/ba_data/python/bauiv1lib/settings/allsettings.py +++ b/dist/ba_data/python/bauiv1lib/settings/allsettings.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, override -import logging import bauiv1 as bui @@ -61,10 +60,6 @@ class AllSettingsWindow(bui.MainWindow): # offset by half the width/height of our target area. yoffs = 0.5 * height + 0.5 * target_height + 30.0 - # scroll_width = target_width - # scroll_height = target_height - 25 - # scroll_bottom = yoffs - 54 - scroll_height - super().__init__( root_widget=bui.containerwidget( size=(width, height), @@ -90,6 +85,7 @@ class AllSettingsWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', autoselect=True, position=(50, yoffs - 80.0), size=(70, 70), @@ -134,18 +130,20 @@ class AllSettingsWindow(bui.MainWindow): y = height * 0.5 - bheight * 0.5 + ynudge def _button( + *, + widgetid: str, position: tuple[float, float], label: bui.Lstr, call: Callable[[], None], texture: bui.Texture, imgsize: float, - *, color: tuple[float, float, float] = (1.0, 1.0, 1.0), imgoffs: tuple[float, float] = (0.0, 0.0), ) -> bui.Widget: x, y = position btn = bui.buttonwidget( parent=self._root_widget, + id=widgetid, autoselect=True, position=(x, y), size=(bwidth, bheight), @@ -178,6 +176,7 @@ class AllSettingsWindow(bui.MainWindow): return btn self._controllers_button = _button( + widgetid=f'{self.main_window_id_prefix}|controllers', position=(x, y), label=bui.Lstr(resource=f'{self._r}.controllersText'), call=self._do_controllers, @@ -188,6 +187,7 @@ class AllSettingsWindow(bui.MainWindow): x += bwidth + margin self._graphics_button = _button( + widgetid=f'{self.main_window_id_prefix}|graphics', position=(x, y), label=bui.Lstr(resource=f'{self._r}.graphicsText'), call=self._do_graphics, @@ -198,6 +198,7 @@ class AllSettingsWindow(bui.MainWindow): x += bwidth + margin self._audio_button = _button( + widgetid=f'{self.main_window_id_prefix}|audio', position=(x, y), label=bui.Lstr(resource=f'{self._r}.audioText'), call=self._do_audio, @@ -208,6 +209,7 @@ class AllSettingsWindow(bui.MainWindow): x += bwidth + margin self._advanced_button = _button( + widgetid=f'{self.main_window_id_prefix}|advanced', position=(x, y), label=bui.Lstr(resource=f'{self._r}.advancedText'), call=self._do_advanced, @@ -217,6 +219,11 @@ class AllSettingsWindow(bui.MainWindow): imgoffs=(0, 5.0), ) + # Select controllers by default. + bui.containerwidget( + edit=self._root_widget, selected_child=self._controllers_button + ) + # Hmm; we're now wide enough that being limited to pressing up # might be ok. if bool(True): @@ -231,8 +238,6 @@ class AllSettingsWindow(bui.MainWindow): right_widget=bui.get_special_widget('squad_button'), ) - self._restore_state() - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -244,8 +249,8 @@ class AllSettingsWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True @staticmethod def _preload_modules() -> None: @@ -260,90 +265,32 @@ class AllSettingsWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.settings.controls import ControlsSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - ControlsSettingsWindow(origin_widget=self._controllers_button) + lambda: ControlsSettingsWindow( + origin_widget=self._controllers_button + ) ) def _do_graphics(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.graphics import GraphicsSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - GraphicsSettingsWindow(origin_widget=self._graphics_button) + lambda: GraphicsSettingsWindow(origin_widget=self._graphics_button) ) def _do_audio(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.audio import AudioSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - AudioSettingsWindow(origin_widget=self._audio_button) + lambda: AudioSettingsWindow(origin_widget=self._audio_button) ) def _do_advanced(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.advanced import AdvancedSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - AdvancedSettingsWindow(origin_widget=self._advanced_button) + lambda: AdvancedSettingsWindow(origin_widget=self._advanced_button) ) - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._controllers_button: - sel_name = 'Controllers' - elif sel == self._graphics_button: - sel_name = 'Graphics' - elif sel == self._audio_button: - sel_name = 'Audio' - elif sel == self._advanced_button: - sel_name = 'Advanced' - elif sel == self._back_button: - sel_name = 'Back' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - sel: bui.Widget | None - if sel_name == 'Controllers': - sel = self._controllers_button - elif sel_name == 'Graphics': - sel = self._graphics_button - elif sel_name == 'Audio': - sel = self._audio_button - elif sel_name == 'Advanced': - sel = self._advanced_button - elif sel_name == 'Back': - sel = self._back_button - else: - sel = self._controllers_button - if sel is not None: - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/settings/audio.py b/dist/ba_data/python/bauiv1lib/settings/audio.py index 20b76ec..14bf15c 100644 --- a/dist/ba_data/python/bauiv1lib/settings/audio.py +++ b/dist/ba_data/python/bauiv1lib/settings/audio.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, override -import logging import bauiv1 as bui @@ -80,6 +79,7 @@ class AudioSettingsWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(35, yoffs - 55), size=(60, 60), scale=0.8, @@ -114,6 +114,7 @@ class AudioSettingsWindow(bui.MainWindow): self._sound_volume_numedit = svne = ConfigNumberEdit( parent=self._root_widget, + idprefix=f'{self.main_window_id_prefix}|soundvolume', position=(x, y), xoffset=10, configkey='Sound Volume', @@ -130,6 +131,7 @@ class AudioSettingsWindow(bui.MainWindow): y -= spacing self._music_volume_numedit = ConfigNumberEdit( parent=self._root_widget, + idprefix=f'{self.main_window_id_prefix}|musicvolume', position=(x, y), xoffset=10, configkey='Music Volume', @@ -149,6 +151,7 @@ class AudioSettingsWindow(bui.MainWindow): y -= 1.2 * spacing self._soundtrack_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|soundtrack', position=(width * 0.5 - 155, y), size=(310, 50), autoselect=True, @@ -180,8 +183,6 @@ class AudioSettingsWindow(bui.MainWindow): edit=svne.minusbutton, up_widget=spback, left_widget=spback ) - self._restore_state() - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -193,8 +194,8 @@ class AudioSettingsWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _do_soundtracks(self) -> None: # pylint: disable=cyclic-import @@ -218,51 +219,7 @@ class AudioSettingsWindow(bui.MainWindow): return self.main_window_replace( - SoundtrackBrowserWindow(origin_widget=self._soundtrack_button) + lambda: SoundtrackBrowserWindow( + origin_widget=self._soundtrack_button + ) ) - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._sound_volume_numedit.minusbutton: - sel_name = 'SoundMinus' - elif sel == self._sound_volume_numedit.plusbutton: - sel_name = 'SoundPlus' - elif sel == self._music_volume_numedit.minusbutton: - sel_name = 'MusicMinus' - elif sel == self._music_volume_numedit.plusbutton: - sel_name = 'MusicPlus' - elif sel == self._soundtrack_button: - sel_name = 'Soundtrack' - elif sel == self._back_button: - sel_name = 'Back' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - sel: bui.Widget | None - if sel_name == 'SoundMinus': - sel = self._sound_volume_numedit.minusbutton - elif sel_name == 'SoundPlus': - sel = self._sound_volume_numedit.plusbutton - elif sel_name == 'MusicMinus': - sel = self._music_volume_numedit.minusbutton - elif sel_name == 'MusicPlus': - sel = self._music_volume_numedit.plusbutton - elif sel_name == 'Soundtrack': - sel = self._soundtrack_button - elif sel_name == 'Back': - sel = self._back_button - else: - sel = self._back_button - if sel: - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/settings/benchmarks.py b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py index 2399601..4296108 100644 --- a/dist/ba_data/python/bauiv1lib/settings/benchmarks.py +++ b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py @@ -88,6 +88,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(40, yoffs - 53), size=(60, 60), scale=0.8, @@ -136,6 +137,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): button_width = 300 btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|cpu', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), autoselect=True, @@ -149,6 +151,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|mediareload', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), autoselect=True, @@ -185,6 +188,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): popup.PopupMenu( parent=self._subcontainer, + button_id=f'{self.main_window_id_prefix}|playlisttype', position=(x_offs, v), width=150, choices=['Random', 'Teams', 'Free-For-All'], @@ -215,6 +219,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): self._stress_test_playlist_name_field = bui.textwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|playlistname', position=(x_offs + 5, v - 5), size=(250, 46), text=self._stress_test_playlist, @@ -256,6 +261,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): ) bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|pdec', position=(330 - x_sub, v - 11), size=(28, 28), label='-', @@ -266,6 +272,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): ) bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|pinc', position=(380 - x_sub, v - 11), size=(28, 28), label='+', @@ -301,6 +308,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): ) bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|rdurdec', position=(330 - x_sub, v - 11), size=(28, 28), label='-', @@ -313,6 +321,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): ) bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|rdurinc', position=(380 - x_sub, v - 11), size=(28, 28), label='+', @@ -326,6 +335,7 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): v -= 82 btn = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|runstress', position=((self._sub_width - button_width) * 0.5, v), size=(button_width, 60), autoselect=True, @@ -344,6 +354,10 @@ class BenchmarksAndStressTestsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _stress_test_player_count_decrement(self) -> None: self._stress_test_player_count = max( 1, self._stress_test_player_count - 1 diff --git a/dist/ba_data/python/bauiv1lib/settings/controls.py b/dist/ba_data/python/bauiv1lib/settings/controls.py index dc5855e..3ac88f6 100644 --- a/dist/ba_data/python/bauiv1lib/settings/controls.py +++ b/dist/ba_data/python/bauiv1lib/settings/controls.py @@ -141,6 +141,7 @@ class ControlsSettingsWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(35, height - 60), size=(60, 60), scale=0.8, @@ -182,6 +183,7 @@ class ControlsSettingsWindow(bui.MainWindow): if show_touch: self._touch_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|touch', position=((width - button_width) / 2, v), size=(button_width, 43), autoselect=True, @@ -206,6 +208,7 @@ class ControlsSettingsWindow(bui.MainWindow): if show_gamepads: self._gamepads_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|gamepads', position=((width - button_width) / 2 - 7, v), size=(button_width, 43), autoselect=True, @@ -236,6 +239,7 @@ class ControlsSettingsWindow(bui.MainWindow): if show_keyboard: self._keyboard_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|keyboard', position=((width - button_width) / 2 - 5, v), size=(button_width, 43), autoselect=True, @@ -263,6 +267,7 @@ class ControlsSettingsWindow(bui.MainWindow): if show_keyboard_p2: self._keyboard_2_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|keyboard2', position=((width - button_width) / 2 - 3, v), size=(button_width, 43), autoselect=True, @@ -279,6 +284,7 @@ class ControlsSettingsWindow(bui.MainWindow): if show_remote: self._idevices_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|mobile', position=((width - button_width) / 2 - 5, v), size=(button_width, 43), autoselect=True, @@ -345,8 +351,6 @@ class ControlsSettingsWindow(bui.MainWindow): ) v -= spacing - self._restore_state() - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -358,8 +362,8 @@ class ControlsSettingsWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _set_mac_controller_subsystem(self, val: str) -> None: cfg = bui.app.config @@ -370,92 +374,32 @@ class ControlsSettingsWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.settings.keyboard import ConfigKeyboardWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#1')) + lambda: ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#1')) ) def _config_keyboard2(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.keyboard import ConfigKeyboardWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - self.main_window_replace( - ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#2')) + lambda: ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#2')) ) def _do_mobile_devices(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.remoteapp import RemoteAppSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(RemoteAppSettingsWindow()) + self.main_window_replace(RemoteAppSettingsWindow) def _do_gamepads(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.gamepadselect import GamepadSelectWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(GamepadSelectWindow()) + self.main_window_replace(GamepadSelectWindow) def _do_touchscreen(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.touchscreen import TouchscreenSettingsWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(TouchscreenSettingsWindow()) - - def _save_state(self) -> None: - sel = self._root_widget.get_selected_child() - if sel == self._gamepads_button: - sel_name = 'GamePads' - elif sel == self._touch_button: - sel_name = 'Touch' - elif sel == self._keyboard_button: - sel_name = 'Keyboard' - elif sel == self._keyboard_2_button: - sel_name = 'Keyboard2' - elif sel == self._idevices_button: - sel_name = 'iDevices' - else: - sel_name = 'Back' - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - - def _restore_state(self) -> None: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - if sel_name == 'GamePads': - sel = self._gamepads_button - elif sel_name == 'Touch': - sel = self._touch_button - elif sel_name == 'Keyboard': - sel = self._keyboard_button - elif sel_name == 'Keyboard2': - sel = self._keyboard_2_button - elif sel_name == 'iDevices': - sel = self._idevices_button - elif sel_name == 'Back': - sel = self._back_button - else: - sel = ( - self._gamepads_button - if self._gamepads_button is not None - else self._back_button - ) - bui.containerwidget(edit=self._root_widget, selected_child=sel) + self.main_window_replace(TouchscreenSettingsWindow) diff --git a/dist/ba_data/python/bauiv1lib/settings/devtools.py b/dist/ba_data/python/bauiv1lib/settings/devtools.py index 93a381b..439d6b2 100644 --- a/dist/ba_data/python/bauiv1lib/settings/devtools.py +++ b/dist/ba_data/python/bauiv1lib/settings/devtools.py @@ -8,7 +8,6 @@ from typing import override import babase import bauiv1 as bui -from bauiv1lib.popup import PopupMenu from bauiv1lib.confirm import ConfirmWindow from bauiv1lib.config import ConfigCheckBox @@ -85,6 +84,7 @@ class DevToolsWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(53, yoffs - 50), size=(140, 60), scale=0.8, @@ -146,6 +146,7 @@ class DevToolsWindow(bui.MainWindow): v -= self._spacing * 2.5 self._show_dev_console_button_check_box = ConfigCheckBox( parent=self._subcontainer, + check_box_id=f'{self.main_window_id_prefix}|showdevsonsole', position=(90, v + 40), size=(self._sub_width - 100, 30), configkey='Show Dev Console Button', @@ -164,6 +165,7 @@ class DevToolsWindow(bui.MainWindow): v -= self._spacing * 1.2 self._create_user_system_scripts_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|createusersystemscripts', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -175,6 +177,7 @@ class DevToolsWindow(bui.MainWindow): v -= self._spacing * 2.5 self._delete_user_system_scripts_button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|deleteusersystemscripts', position=(self._sub_width / 2 - this_button_width / 2, v - 10), size=(this_button_width, 60), autoselect=True, @@ -185,42 +188,6 @@ class DevToolsWindow(bui.MainWindow): ), ) - # Currently this is not wired up. The current official way to test - # UIScales is either to use the switcher in the dev-console or to - # set the BA_UI_SCALE env var. - if bool(False): - v -= self._spacing * 2.5 - bui.textwidget( - parent=self._subcontainer, - position=(170, v + 10), - size=(0, 0), - text=bui.Lstr(resource='uiScaleText'), - color=app.ui_v1.title_color, - h_align='center', - v_align='center', - ) - - PopupMenu( - parent=self._subcontainer, - position=(230, v - 20), - button_size=(200.0, 60.0), - width=100.0, - choices=[ - 'auto', - 'small', - 'medium', - 'large', - ], - choices_display=[ - bui.Lstr(resource='autoText'), - bui.Lstr(resource='sizeSmallText'), - bui.Lstr(resource='sizeMediumText'), - bui.Lstr(resource='sizeLargeText'), - ], - current_choice=app.config.get('UI Scale', 'auto'), - on_value_change_call=self._set_uiscale, - ) - @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. @@ -231,6 +198,10 @@ class DevToolsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _set_uiscale(self, val: str) -> None: cfg = bui.app.config cfg['UI Scale'] = val diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepad.py b/dist/ba_data/python/bauiv1lib/settings/gamepad.py index 164866f..48740f0 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepad.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepad.py @@ -97,6 +97,11 @@ class GamepadSettingsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Not bothering with this for now. + return False + def _get_config_mapping(self, default: bool = False) -> None: for button in [ 'buttonJump', diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py index 198c7d8..dbbf268 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py @@ -122,16 +122,19 @@ class GamepadSelectWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Not really needed here. + return False + def gamepad_configure_callback(self, event: dict[str, Any]) -> None: """Respond to a gamepad button press during config selection.""" from bauiv1lib.settings.gamepad import GamepadSettingsWindow - if not self.main_window_has_control(): - return - # Ignore all but button-presses. if event['type'] not in ['BUTTONDOWN', 'HATMOTION']: return + bs.release_game_controller_input() assert bui.app.classic is not None @@ -148,11 +151,11 @@ class GamepadSelectWindow(bui.MainWindow): if device.allows_configuring: self.main_window_replace( - GamepadSettingsWindow(device), back_state=back_state + lambda: GamepadSettingsWindow(device), back_state=back_state ) else: self.main_window_replace( - _NotConfigurableWindow(device), back_state=back_state + lambda: _NotConfigurableWindow(device), back_state=back_state ) diff --git a/dist/ba_data/python/bauiv1lib/settings/graphics.py b/dist/ba_data/python/bauiv1lib/settings/graphics.py index 3663320..e538d3f 100644 --- a/dist/ba_data/python/bauiv1lib/settings/graphics.py +++ b/dist/ba_data/python/bauiv1lib/settings/graphics.py @@ -108,6 +108,7 @@ class GraphicsSettingsWindow(bui.MainWindow): else: back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(35, yoffs - 50), size=(60, 60), scale=0.8, @@ -152,6 +153,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) self._fullscreen_checkbox = bui.checkboxwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|fullscreen', position=(h_offs + 100, v), value=bui.fullscreen_control_get(), on_value_change_call=bui.fullscreen_control_set, @@ -185,6 +187,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|graphicsquality', position=(h_offs + 60, v - 50), width=150, scale=popup_menu_scale, @@ -219,6 +222,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) textures_popup = PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|texturequality', position=(h_offs + 230, v - 50), width=150, scale=popup_menu_scale, @@ -253,8 +257,8 @@ class GraphicsSettingsWindow(bui.MainWindow): v_align='center', ) - # On standard android we have 'Auto', 'Native', and a few - # HD standards. + # On standard android we have 'Auto', 'Native', and a few HD + # standards. if app.classic.platform == 'android': # on cardboard/daydream android we have a few # render-target-scale options @@ -265,6 +269,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) resolution_popup = PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|resolution', position=(h_offs + 60, v - 50), width=120, scale=popup_menu_scale, @@ -290,6 +295,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) resolution_popup = PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|resolution', position=(h_offs + 60, v - 50), width=120, scale=popup_menu_scale, @@ -309,6 +315,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) resolution_popup = PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|resolution', position=(h_offs + 60, v - 50), width=120, scale=popup_menu_scale, @@ -342,6 +349,7 @@ class GraphicsSettingsWindow(bui.MainWindow): ) vsync_popup = PopupMenu( parent=self._root_widget, + button_id=f'{self.main_window_id_prefix}|vsync', position=(h_offs + 230, v - 50), width=150, scale=popup_menu_scale, @@ -386,6 +394,7 @@ class GraphicsSettingsWindow(bui.MainWindow): self._last_max_fps_str = max_fps_str self._max_fps_text = bui.textwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|maxfps', position=(h_offs + 170, v - 5), size=(105, 30), text=max_fps_str, @@ -409,6 +418,7 @@ class GraphicsSettingsWindow(bui.MainWindow): fpsc = ConfigCheckBox( parent=self._root_widget, + check_box_id=f'{self.main_window_id_prefix}|showfps', position=(h_offs + 69, v - 6), size=(210, 30), scale=0.86, @@ -429,6 +439,7 @@ class GraphicsSettingsWindow(bui.MainWindow): if show_tv_mode: tvc = ConfigCheckBox( parent=self._root_widget, + check_box_id=f'{self.main_window_id_prefix}|tvborder', position=(h_offs + 240, v - 6), size=(210, 30), scale=0.86, @@ -457,6 +468,10 @@ class GraphicsSettingsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + @override def on_main_window_close(self) -> None: self._apply_max_fps() diff --git a/dist/ba_data/python/bauiv1lib/settings/keyboard.py b/dist/ba_data/python/bauiv1lib/settings/keyboard.py index 0225a6f..63f3cc1 100644 --- a/dist/ba_data/python/bauiv1lib/settings/keyboard.py +++ b/dist/ba_data/python/bauiv1lib/settings/keyboard.py @@ -78,6 +78,10 @@ class ConfigKeyboardWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _get_config_mapping(self, default: bool = False) -> None: for button in [ 'buttonJump', diff --git a/dist/ba_data/python/bauiv1lib/settings/nettesting.py b/dist/ba_data/python/bauiv1lib/settings/nettesting.py index 0074517..b6a521f 100644 --- a/dist/ba_data/python/bauiv1lib/settings/nettesting.py +++ b/dist/ba_data/python/bauiv1lib/settings/nettesting.py @@ -90,6 +90,7 @@ class NetTestingWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(46, yoffs - 77), size=(60, 60), scale=0.9, @@ -108,6 +109,7 @@ class NetTestingWindow(bui.MainWindow): xextra = -80 if uiscale is bui.UIScale.SMALL else 0 self._copy_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|copy', position=( self._width * 0.5 + scroll_width * 0.5 - 210 + 80 + xextra, yoffs - 79, @@ -121,6 +123,7 @@ class NetTestingWindow(bui.MainWindow): self._settings_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|settings', position=( self._width * 0.5 + scroll_width * 0.5 - 110 + 80 + xextra, yoffs - 77, @@ -151,7 +154,10 @@ class NetTestingWindow(bui.MainWindow): autoselect=True, border_opacity=0.4, ) - self._rows = bui.columnwidget(parent=self._scroll) + self._rows = bui.columnwidget( + parent=self._scroll, + id=f'{self.main_window_id_prefix}|content', + ) # Now kick off the tests. # Pass a weak-ref to this window so we don't keep it alive @@ -169,6 +175,10 @@ class NetTestingWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def print(self, text: str, color: tuple[float, float, float]) -> None: """Print text to our console thingie.""" for line in text.splitlines(): @@ -196,11 +206,7 @@ class NetTestingWindow(bui.MainWindow): def _show_val_testing(self) -> None: assert bui.app.classic is not None - # no-op if we're not in control. - if not self.main_window_has_control(): - return - - self.main_window_replace(get_net_val_testing_window()) + self.main_window_replace(get_net_val_testing_window) def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None: diff --git a/dist/ba_data/python/bauiv1lib/settings/plugins.py b/dist/ba_data/python/bauiv1lib/settings/plugins.py index 283e371..c6b78b2 100644 --- a/dist/ba_data/python/bauiv1lib/settings/plugins.py +++ b/dist/ba_data/python/bauiv1lib/settings/plugins.py @@ -4,7 +4,6 @@ from __future__ import annotations -import logging from enum import Enum from typing import TYPE_CHECKING, assert_never, override @@ -100,6 +99,7 @@ class PluginWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(53, yoffs - 49), size=(60, 60), scale=0.8, @@ -144,6 +144,7 @@ class PluginWindow(bui.MainWindow): self._category_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|category', scale=0.7, position=(settings_button_x - 105, button_row_yoffs - 60), size=(130, 60), @@ -156,6 +157,7 @@ class PluginWindow(bui.MainWindow): self._settings_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|settings', position=(settings_button_x, button_row_yoffs - 58), size=(40, 40), label='', @@ -216,6 +218,7 @@ class PluginWindow(bui.MainWindow): sub_height = len(plugspecs) * plug_line_height self._subcontainer = bui.containerwidget( parent=self._scrollwidget, + id=f'{self.main_window_id_prefix}|subc', size=(sub_width, sub_height), background=False, ) @@ -223,7 +226,6 @@ class PluginWindow(bui.MainWindow): bui.containerwidget( edit=self._root_widget, selected_child=self._scrollwidget ) - self._restore_state() @override def get_main_window_state(self) -> bui.MainWindowState: @@ -236,8 +238,8 @@ class PluginWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _check_value_changed(self, plug: bui.PluginSpec, value: bool) -> None: bui.screenmessage( @@ -254,11 +256,9 @@ class PluginWindow(bui.MainWindow): # pylint: disable=cyclic-import from bauiv1lib.settings.pluginsettings import PluginSettingsWindow - # no-op if we don't have control. - if not self.main_window_has_control(): - return - - self.main_window_replace(PluginSettingsWindow(transition='in_right')) + self.main_window_replace( + lambda: PluginSettingsWindow(transition='in_right') + ) def _show_category_options(self) -> None: uiscale = bui.app.ui_v1.uiscale @@ -366,6 +366,7 @@ class PluginWindow(bui.MainWindow): item_y = sub_height - (num_shown + 1) * plug_line_height check = bui.checkboxwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|enabled.{classpath}', text=bui.Lstr(value=classpath), autoselect=True, value=enabled, @@ -390,16 +391,15 @@ class PluginWindow(bui.MainWindow): ) ), ) - # noinspection PyUnresolvedReferences if plugin is not None and plugin.has_settings_ui(): button = bui.buttonwidget( parent=self._subcontainer, + id=f'{self.main_window_id_prefix}|settings.{classpath}', label=bui.Lstr(resource='mainMenu.settingsText'), autoselect=True, size=(100, 40), position=(sub_width - 130, item_y + 6), ) - # noinspection PyUnresolvedReferences bui.buttonwidget( edit=button, on_activate_call=bui.Call(plugin.show_settings_ui, button), @@ -435,39 +435,3 @@ class PluginWindow(bui.MainWindow): edit=self._no_plugins_installed_text, text=bui.Lstr(resource='noPluginsInstalledText'), ) - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - if sel == self._category_button: - sel_name = 'Category' - elif sel == self._settings_button: - sel_name = 'Settings' - elif sel == self._back_button: - sel_name = 'Back' - elif sel == self._scrollwidget: - sel_name = 'Scroll' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = sel_name - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self)) - sel: bui.Widget | None - if sel_name == 'Category': - sel = self._category_button - elif sel_name == 'Settings': - sel = self._settings_button - elif sel_name == 'Back': - sel = self._back_button - else: - sel = self._scrollwidget - if sel: - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py b/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py index 9d3344c..c0b8072 100644 --- a/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py +++ b/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py @@ -66,6 +66,7 @@ class PluginSettingsWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(55, self._yoffs - 33), size=(60, 60), scale=0.8, @@ -98,6 +99,7 @@ class PluginSettingsWindow(bui.MainWindow): self._enable_plugins_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|enableall', position=(x, y), size=(350, 60), autoselect=True, @@ -111,6 +113,7 @@ class PluginSettingsWindow(bui.MainWindow): y -= 70 self._disable_plugins_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|disableall', position=(x, y), size=(350, 60), autoselect=True, @@ -124,6 +127,7 @@ class PluginSettingsWindow(bui.MainWindow): y -= 70 self._enable_new_plugins_check_box = bui.checkboxwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|enablenew', position=(x, y), size=(350, 60), value=bui.app.config.get( @@ -163,6 +167,10 @@ class PluginSettingsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return True + def _enable_all_plugins(self) -> None: cfg = bui.app.config plugs: dict[str, dict] = cfg.setdefault('Plugins', {}) diff --git a/dist/ba_data/python/bauiv1lib/settings/remoteapp.py b/dist/ba_data/python/bauiv1lib/settings/remoteapp.py index 0c124d7..26ba174 100644 --- a/dist/ba_data/python/bauiv1lib/settings/remoteapp.py +++ b/dist/ba_data/python/bauiv1lib/settings/remoteapp.py @@ -166,6 +166,10 @@ class RemoteAppSettingsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + return False + def _on_check_changed(self, value: bool) -> None: cfg = bui.app.config cfg['Enable Remote App'] = not value diff --git a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py index 7dcfdf1..580097a 100644 --- a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py +++ b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py @@ -110,6 +110,11 @@ class TouchscreenSettingsWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # TODO: Wire this up. + return False + def _build_gui(self) -> None: # pylint: disable=too-many-locals from bauiv1lib.config import ConfigNumberEdit, ConfigCheckBox diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py index fbfc17c..6ab5c52 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py @@ -73,6 +73,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): else: self._back_button = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(50, yoffs - 60), size=(60, 60), scale=0.8, @@ -108,6 +109,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): v -= 60.0 * scl self._new_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|new', position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._new_soundtrack, @@ -137,6 +139,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): self._edit_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|edit', position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._edit_soundtrack, @@ -165,6 +168,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): self._duplicate_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|duplicate', position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._duplicate_soundtrack, @@ -193,6 +197,7 @@ class SoundtrackBrowserWindow(bui.MainWindow): self._delete_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|delete', position=(h, v), size=(100, 55.0 * scl), on_activate_call=self._delete_soundtrack, @@ -269,6 +274,11 @@ class SoundtrackBrowserWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Todo: wire this up. + return True + @override def on_main_window_close(self) -> None: self._save_state() @@ -323,8 +333,8 @@ class SoundtrackBrowserWindow(bui.MainWindow): subs=[('${NAME}', self._selected_soundtrack)], ), self._do_delete_soundtrack, - 450, - 150, + width=450, + height=150, ) def _duplicate_soundtrack(self) -> None: @@ -438,7 +448,9 @@ class SoundtrackBrowserWindow(bui.MainWindow): return self.main_window_replace( - SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack) + lambda: SoundtrackEditWindow( + existing_soundtrack=self._selected_soundtrack + ) ) def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr: @@ -481,6 +493,8 @@ class SoundtrackBrowserWindow(bui.MainWindow): on_activate_call=self._edit_soundtrack_with_sound, selectable=True, ) + # Handling reselection of these manually, so no ids. + bui.widget(edit=txtw, allow_preserve_selection=False) if index == 0: bui.widget(edit=txtw, up_widget=self._back_button) self._soundtrack_widgets.append(txtw) @@ -540,7 +554,9 @@ class SoundtrackBrowserWindow(bui.MainWindow): PurchaseWindow(items=['pro']) return - self.main_window_replace(SoundtrackEditWindow(existing_soundtrack=None)) + self.main_window_replace( + lambda: SoundtrackEditWindow(existing_soundtrack=None) + ) def _create_done(self, new_soundtrack: str) -> None: if new_soundtrack is not None: diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py index 8d02e7f..1dfd58a 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py @@ -242,6 +242,11 @@ class SoundtrackEditWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Todo: wire this up. + return False + def _refresh(self) -> None: for widget in self._col.get_children(): widget.delete() @@ -422,15 +427,17 @@ class SoundtrackEditWindow(bui.MainWindow): 'soundtrack': self._soundtrack, 'last_edited_song_type': song_type, } - new_win = music.get_music_player().select_entry( - bui.Call(self._restore_editor, state, song_type), - entry, - selection_target_name, + new_win = self.main_window_replace( + lambda: music.get_music_player().select_entry( + bui.Call(self._restore_editor, state, song_type), + entry, + selection_target_name, + ) ) - self.main_window_replace(new_win) # Once we've set the new window, grab the back-state; we'll use # that to jump back here after selection completes. + assert new_win is not None assert new_win.main_window_back_state is not None state['back_state'] = new_win.main_window_back_state diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py index ff47eac..4061aef 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py @@ -183,6 +183,11 @@ class SoundtrackEntryTypeSelectWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Todo: wire this up. + return False + def _on_mac_music_app_playlist_press(self) -> None: assert bui.app.classic is not None music = bui.app.classic.music @@ -190,10 +195,6 @@ class SoundtrackEntryTypeSelectWindow(bui.MainWindow): MacMusicAppPlaylistSelectWindow, ) - # no-op if we're not in control. - if not self.main_window_has_control(): - return - current_playlist_entry: str | None if ( music.get_soundtrack_entry_type(self._current_entry) @@ -206,7 +207,7 @@ class SoundtrackEntryTypeSelectWindow(bui.MainWindow): current_playlist_entry = None self.main_window_replace( - MacMusicAppPlaylistSelectWindow( + lambda: MacMusicAppPlaylistSelectWindow( self._callback, current_playlist_entry, self._current_entry ) ) @@ -216,15 +217,11 @@ class SoundtrackEntryTypeSelectWindow(bui.MainWindow): from baclassic.osmusic import OSMusicPlayer from bauiv1lib.fileselector import FileSelectorWindow - # no-op if we're not in control. - if not self.main_window_has_control(): - return - base_path = android_get_external_files_dir() assert bui.app.classic is not None self.main_window_replace( - FileSelectorWindow( + lambda: FileSelectorWindow( base_path, callback=self._music_file_selector_cb, show_base_path=False, @@ -239,15 +236,11 @@ class SoundtrackEntryTypeSelectWindow(bui.MainWindow): from bauiv1lib.fileselector import FileSelectorWindow from babase import android_get_external_files_dir - # no-op if we're not in control. - if not self.main_window_has_control(): - return - base_path = android_get_external_files_dir() assert bui.app.classic is not None self.main_window_replace( - FileSelectorWindow( + lambda: FileSelectorWindow( base_path, callback=self._music_folder_selector_cb, show_base_path=False, diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py index ee21dfe..2c47085 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py @@ -109,6 +109,11 @@ class MacMusicAppPlaylistSelectWindow(bui.MainWindow): ) ) + @override + def main_window_should_preserve_selection(self) -> bool: + # Todo: wire this up. + return False + def _playlists_cb(self, playlists: list[str]) -> None: if self._column: for widget in self._column.get_children(): diff --git a/dist/ba_data/python/bauiv1lib/store/browser.py b/dist/ba_data/python/bauiv1lib/store/browser.py index e6f4188..78991e4 100644 --- a/dist/ba_data/python/bauiv1lib/store/browser.py +++ b/dist/ba_data/python/bauiv1lib/store/browser.py @@ -123,6 +123,7 @@ class StoreBrowserWindow(bui.MainWindow): self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', position=(70, yoffs - 37), size=(60, 60), scale=1.1, @@ -148,6 +149,7 @@ class StoreBrowserWindow(bui.MainWindow): ): bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|restorepurchases', position=(self._width * 0.5 - 70, 16), size=(230, 50), scale=0.65, @@ -200,6 +202,7 @@ class StoreBrowserWindow(bui.MainWindow): self._tab_row = TabRow( self._root_widget, tabs_def, + idprefix=self.main_window_id_prefix, size=(self._scroll_width - 2.0 * tab_inset, 50), pos=( self._width * 0.5 - self._scroll_width * 0.5 + tab_inset, @@ -298,12 +301,15 @@ class StoreBrowserWindow(bui.MainWindow): right_widget=bui.get_special_widget('tickets_meter'), ) - # self._scroll_width = self._width - scroll_buffer_h - # self._scroll_height = self._height - 180 - self._scrollwidget: bui.Widget | None = None self._status_textwidget: bui.Widget | None = None - self._restore_state() + + # Restore/set tab. + try: + current_tab = self.TabID(bui.app.config.get('Store Tab')) + except ValueError: + current_tab = self.TabID.CHARACTERS + self._set_tab(current_tab) def _restore_purchases(self) -> None: from bauiv1lib.account.signin import show_sign_in_prompt @@ -378,8 +384,8 @@ class StoreBrowserWindow(bui.MainWindow): border_opacity=0.4, ) - # NOTE: this stuff is modified by the _Store class. - # Should maybe clean that up. + # NOTE: this stuff is modified by the _Store class. Should maybe + # clean that up. self.button_infos = {} self.update_buttons_timer = None @@ -820,6 +826,15 @@ class StoreBrowserWindow(bui.MainWindow): scrollwidget=self._scrollwidget, tab_button=self._tab_row.tabs[self._current_tab].button, ) + # Most of our UI won't exist until this point so we need + # to explicitly restore state for selection restore to + # work. + # + # Note to self: perhaps we should *not* do this if + # significant time has passed since the window was made + # or if input commands have happened. + self.main_window_restore_shared_state() + else: cnt = bui.containerwidget( parent=self._scrollwidget, @@ -856,74 +871,8 @@ class StoreBrowserWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - selected_tab_ids = [ - tab_id - for tab_id, tab in self._tab_row.tabs.items() - if sel == tab.button - ] - if sel == self._scrollwidget: - sel_name = 'Scroll' - elif sel == self._back_button: - sel_name = 'Back' - elif selected_tab_ids: - assert len(selected_tab_ids) == 1 - sel_name = f'Tab:{selected_tab_ids[0].value}' - else: - raise ValueError(f'unrecognized selection \'{sel}\'') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = { - 'sel_name': sel_name, - } - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - - try: - sel: bui.Widget | None - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - assert isinstance(sel_name, (str, type(None))) - - try: - current_tab = self.TabID(bui.app.config.get('Store Tab')) - except ValueError: - current_tab = self.TabID.CHARACTERS - - if self._show_tab is not None: - current_tab = self._show_tab - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'Scroll': - sel = self._scrollwidget - elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): - try: - sel_tab_id = self.TabID(sel_name.split(':')[-1]) - except ValueError: - sel_tab_id = self.TabID.CHARACTERS - sel = self._tab_row.tabs[sel_tab_id].button - else: - sel = self._tab_row.tabs[current_tab].button - - # If we were requested to show a tab, select it too. - if ( - self._show_tab is not None - and self._show_tab in self._tab_row.tabs - ): - sel = self._tab_row.tabs[self._show_tab].button - self._set_tab(current_tab) - if sel is not None: - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) + def main_window_should_preserve_selection(self) -> bool: + return True def _check_merch_availability_in_bg_thread() -> None: @@ -1203,6 +1152,7 @@ class _Store: instantiate_store_item_display( item_name, item, + idprefix=self._store_window.main_window_id_prefix, parent_widget=cnt2, b_pos=b_pos, boffs_h=boffs_h, diff --git a/dist/ba_data/python/bauiv1lib/store/item.py b/dist/ba_data/python/bauiv1lib/store/item.py index ca27e16..f598256 100644 --- a/dist/ba_data/python/bauiv1lib/store/item.py +++ b/dist/ba_data/python/bauiv1lib/store/item.py @@ -15,11 +15,12 @@ if TYPE_CHECKING: def instantiate_store_item_display( item_name: str, item: dict[str, Any], + *, parent_widget: bui.Widget, b_pos: tuple[float, float], b_width: float, b_height: float, - *, + idprefix: str, boffs_h: float = 0.0, boffs_h2: float = 0.0, boffs_v2: float = 0, @@ -27,7 +28,6 @@ def instantiate_store_item_display( button: bool = True, ) -> None: """(internal)""" - # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals @@ -55,6 +55,7 @@ def instantiate_store_item_display( if button: item['button'] = btn = bui.buttonwidget( parent=parent_widget, + id=f'{idprefix}|store_item.{item_name}', position=b_pos, transition_delay=delay, show_buffer_top=showbuffer, diff --git a/dist/ba_data/python/bauiv1lib/tabs.py b/dist/ba_data/python/bauiv1lib/tabs.py index 9e01cfa..a3d298d 100644 --- a/dist/ba_data/python/bauiv1lib/tabs.py +++ b/dist/ba_data/python/bauiv1lib/tabs.py @@ -11,6 +11,7 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Callable + from enum import Enum @dataclass @@ -22,7 +23,7 @@ class Tab: size: tuple[float, float] -class TabRow[T]: +class TabRow[T: Enum]: """Encapsulates a row of tab-styled buttons. Tabs are indexed by id which is an arbitrary user-provided type. @@ -36,6 +37,7 @@ class TabRow[T]: size: tuple[float, float], *, on_select_call: Callable[[T], None] | None = None, + idprefix: str | None = None, ) -> None: if not tabdefs: raise ValueError('At least one tab def is required') @@ -49,6 +51,11 @@ class TabRow[T]: size = (tab_button_width - tab_spacing, 50.0) btn = bui.buttonwidget( parent=parent, + id=( + None + if idprefix is None + else f'{idprefix}|tab_button.{tab_id.value}' + ), position=pos, autoselect=True, button_type='tab', diff --git a/dist/ba_data/python/bauiv1lib/teamnamescolors.py b/dist/ba_data/python/bauiv1lib/teamnamescolors.py index dc03060..4e4622e 100644 --- a/dist/ba_data/python/bauiv1lib/teamnamescolors.py +++ b/dist/ba_data/python/bauiv1lib/teamnamescolors.py @@ -20,6 +20,7 @@ class TeamNamesColorsWindow(PopupWindow): def __init__(self, scale_origin: tuple[float, float]): from bascenev1 import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES + self._idprefix = bui.app.ui_v1.new_id_prefix('teamnamescolors') self._width = 500 self._height = 330 self._transitioning_out = False @@ -56,6 +57,7 @@ class TeamNamesColorsWindow(PopupWindow): resetbtn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|reset', label=bui.Lstr(resource='settingsWindowAdvanced.resetText'), autoselect=True, scale=0.7, @@ -68,6 +70,7 @@ class TeamNamesColorsWindow(PopupWindow): self._color_buttons.append( bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|colorbutton{i}', autoselect=True, position=(50, 0 + 195 - 90 * i), on_activate_call=bui.Call(self._color_click, i), @@ -80,6 +83,7 @@ class TeamNamesColorsWindow(PopupWindow): self._color_text_fields.append( bui.textwidget( parent=self.root_widget, + id=f'{self._idprefix}|colortext{i}', position=(135, 0 + 201 - 90 * i), size=(280, 46), text=self._names[i], @@ -104,6 +108,7 @@ class TeamNamesColorsWindow(PopupWindow): cancelbtn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|cancel', label=bui.Lstr(resource='cancelText'), autoselect=True, on_activate_call=self._on_cancel_press, @@ -112,6 +117,7 @@ class TeamNamesColorsWindow(PopupWindow): ) okbtn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|ok', label=bui.Lstr(resource='okText'), autoselect=True, on_activate_call=self._ok, diff --git a/dist/ba_data/python/bauiv1lib/template.py b/dist/ba_data/python/bauiv1lib/template.py new file mode 100644 index 0000000..c9e25cb --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/template.py @@ -0,0 +1,264 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Useful starting points for new classes and whatnot.""" + +from __future__ import annotations + +import random +from typing import override + +import bauiv1 as bui + + +def show_template_main_window() -> None: + """Bust out a template-main-window.""" + + # Pop up an auxiliary window wherever we are in the nav stack. + bui.app.ui_v1.auxiliary_window_activate( + win_type=TemplateMainWindow, + win_create_call=lambda: TemplateMainWindow( + dummy_data=random.randrange(100, 1000) + ), + ) + + +class TemplateMainWindow(bui.MainWindow): + """An example of a well-behaved main-window.""" + + def __init__( + self, + dummy_data: int, + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + auxiliary_style: bool = True, + ): + ui = bui.app.ui_v1 + + # A simple number standing in for actual data (to show how we'd + # save/restore actual data). + self._dummy_data = dummy_data + + # We want to display differently whether we're an auxiliary + # window or not, but unfortunately that value is not yet + # available until we're added to the main-window-stack so it + # must be explicitly passed in. + self._auxiliary_style = auxiliary_style + + # Calc scale and size for our backing window. For medium & large + # ui-scale we aim for a window small enough to always be fully + # visible on-screen and for small mode we aim for a window big + # enough that we never see the window edges; only the window + # texture covering the whole screen. + uiscale = ui.uiscale + self._width = 1400 if uiscale is bui.UIScale.SMALL else 750 + self._height = 1200 if uiscale is bui.UIScale.SMALL else 500 + scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + + # Do some fancy math to calculate our visible area; this will be + # limited by the screen size in small mode and our backing size + # otherwise. + screensize = bui.get_virtual_screen_size() + self._vis_width = min(self._width - 100, screensize[0] / scale) + self._vis_height = min(self._height - 100, screensize[1] / scale) + self._vis_top = 0.5 * self._height + 0.5 * self._vis_height + self._vis_left = 0.5 * self._width - 0.5 * self._vis_width + + # Nudge our vis area up a bit when we can see the full backing + # (visual fudge factor). + if uiscale is not bui.UIScale.SMALL: + self._vis_top += 12.0 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility='menu_full', + toolbar_cancel_button_style=( + 'close' if auxiliary_style else 'back' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We respond to screen size changes only at small ui-scale; + # in other cases we assume our window remains fully visible + # always (flip to windowed mode and resize the app window to + # confirm this). + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + # Title. + bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._vis_top - 20), + size=(0, 0), + text=f'Template{self._dummy_data}', + color=ui.title_color, + scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, + # Make sure we avoid overlapping meters in small mode/etc. + maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), + h_align='center', + v_align='center', + ) + + # For small UI-scale we use the system back/close button; + # otherwise we make our own. + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + btn = bui.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|close', + scale=0.8, + position=(self._vis_left - 15, self._vis_top - 30), + size=(50, 50) if auxiliary_style else (60, 55), + extra_touch_border_scale=2.0, + button_type=None if auxiliary_style else 'backSmall', + on_activate_call=self.main_window_back, + autoselect=True, + label=bui.charstr( + bui.SpecialChar.CLOSE + if auxiliary_style + else bui.SpecialChar.BACK + ), + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + # Show our vis-area bounds (for debugging). + if bool(True): + # Skip top-left since its always overlapping back/close + # buttons. + if bool(False): + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TL', + h_align='left', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left + self._vis_width, self._vis_top), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='TR', + h_align='right', + v_align='top', + ) + bui.textwidget( + parent=self._root_widget, + position=(self._vis_left, self._vis_top - self._vis_height), + size=(0, 0), + color=(1, 1, 1, 0.5), + scale=0.5, + text='BL', + h_align='left', + v_align='bottom', + ) + bui.textwidget( + parent=self._root_widget, + position=( + self._vis_left + self._vis_width, + self._vis_top - self._vis_height, + ), + size=(0, 0), + scale=0.5, + color=(1, 1, 1, 0.5), + text='BR', + h_align='right', + v_align='bottom', + ) + + # Description. + bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._vis_top - 100), + size=(0, 0), + scale=0.6, + text=( + f'Use this class as reference for making' + f' a well-behaved MainWindow class or for\n' + 'navigating between MainWindows. It lives at' + f' {self.__module__}.{type(self).__qualname__}.\n' + f'vis-size=({round(self._vis_width)},' + f' {round(self._vis_height)})' + ), + h_align='center', + v_align='center', + ) + + # Make a few buttons to navigate to other MainWindows (simply + # our same class with random different dummy values). + button_width = 300 + for i in range(3): + child_dummy_data = self._dummy_data + (i + 1) * 17 + self._player_profiles_button = btn = bui.buttonwidget( + parent=self._root_widget, + id=f'{self.main_window_id_prefix}|button{i + 1}', + position=( + self._width * 0.5 - button_width * 0.5, + self._vis_top - 230 - i * 80, + ), + autoselect=True, + size=(button_width, 60), + label=f'Template{child_dummy_data}', + on_activate_call=bui.WeakCall( + self._child_press, child_dummy_data + ), + ) + + def _child_press(self, dummy_data: int) -> None: + # Navigate to a new one of us. + self.main_window_replace( + lambda: TemplateMainWindow( + auxiliary_style=False, dummy_data=dummy_data + ) + ) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # IMPORTANT - Pull values from self HERE; if we do it in the + # lambda below then the state will keep self alive which will + # lead to 'ui-not-getting-cleaned-up' warnings and memory leaks. + dummy_data = self._dummy_data + auxiliary_style = self._auxiliary_style + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + dummy_data=dummy_data, + auxiliary_style=auxiliary_style, + ), + ) + + @override + def main_window_should_preserve_selection(self) -> bool: + # If we return True here, the app will reselect the last + # selected widget when creating a new one of our windows. We + # just need to make sure all of our selectable widgets have + # unique ids starting with `self.main_window_id_prefix`. + return True + + @override + def get_main_window_shared_state_id(self) -> str | None: + # Here we return a unique id based on what we're displaying. + # This means each level will remember its selected button. If we + # remove this function override we get the default behavior + # where this state is shared for all instances of our class + # (thus selecting the second button will go to a new window with + # the second button already selected). + return f'template{self._dummy_data}' diff --git a/dist/ba_data/python/bauiv1lib/tournamententry.py b/dist/ba_data/python/bauiv1lib/tournamententry.py index 857b51c..b09cf5a 100644 --- a/dist/ba_data/python/bauiv1lib/tournamententry.py +++ b/dist/ba_data/python/bauiv1lib/tournamententry.py @@ -39,6 +39,7 @@ class TournamentEntryWindow(PopupWindow): assert bui.app.plus bui.set_analytics_screen('Tournament Entry Window') + self._idprefix = bui.app.ui_v1.new_id_prefix('tournamententry') self._tournament_id = tournament_id self._tournament_info = bui.app.classic.accounts.tournament_info[ self._tournament_id @@ -126,6 +127,7 @@ class TournamentEntryWindow(PopupWindow): self._cancel_button = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|cancel', position=(40, self._height - 34), size=(60, 60), scale=0.5, @@ -150,6 +152,7 @@ class TournamentEntryWindow(PopupWindow): btn = self._pay_with_tickets_button = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|paywithtickets', position=(30 + x_offs, 60 + off_p), autoselect=True, button_type='square', @@ -196,6 +199,7 @@ class TournamentEntryWindow(PopupWindow): if self._do_ad_btn: btn = self._pay_with_ad_btn = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|paywithad', position=(190, 60 + off_p), autoselect=True, button_type='square', @@ -274,6 +278,7 @@ class TournamentEntryWindow(PopupWindow): if self._do_practice: self._practice_button = bui.buttonwidget( parent=self.root_widget, + id=f'{self._idprefix}|practice', position=btn_pos, autoselect=True, size=btn_size, diff --git a/dist/ba_data/python/bauiv1lib/utils.py b/dist/ba_data/python/bauiv1lib/utils.py new file mode 100644 index 0000000..df9a4fa --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/utils.py @@ -0,0 +1,124 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Useful bits to use with UIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bauiv1 as bui + +if TYPE_CHECKING: + pass + + +def scroll_fade_top( + container: bui.Widget, + scrollleft: float, + scrollbottom: float, + scrollwidth: float, + scrollheight: float, + *, + yscale: float = 1.0, +) -> None: + """Make content appear to fade towards the top of a scroll area. + + This works by drawing background-texture-ish soft shapes obscuring + the edge of the scroll area. + """ + return _scroll_fade( + container, + scrollleft, + scrollbottom, + scrollwidth, + scrollheight, + yoffs=scrollheight, + center=True, + yscale=yscale, + ) + + +def scroll_fade_bottom( + container: bui.Widget, + scrollleft: float, + scrollbottom: float, + scrollwidth: float, + scrollheight: float, + *, + center: bool = False, + yscale: float = 1.0, +) -> None: + """Make content appear to fade towards the bottom of a scroll area. + + This works by drawing background-texture-ish soft shapes obscuring + the edge of the scroll area. + """ + return _scroll_fade( + container, + scrollleft, + scrollbottom, + scrollwidth, + scrollheight, + yoffs=0.0, + center=center, + yscale=yscale, + ) + + +def _scroll_fade( + container: bui.Widget, + scrollleft: float, + scrollbottom: float, + scrollwidth: float, + scrollheight: float, + *, + yoffs: float, + center: bool, + yscale: float, +) -> None: + + del scrollheight # Unused. + + clr = (0.4, 0.37, 0.49) + # clr = (1, 0, 0) + + blotchwidth = scrollwidth * 0.57 + blotchheight = scrollwidth * 0.23 + bimg = bui.imagewidget( + parent=container, + texture=bui.gettexture('uiAtlas'), + mesh_transparent=bui.getmesh('windowBGBlotch'), + position=( + scrollleft + 60.0 - blotchwidth * 0.5, + scrollbottom + yoffs - yscale * blotchheight * 0.5, + ), + size=(blotchwidth, yscale * blotchheight), + color=clr, + ) + bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + bimg = bui.imagewidget( + parent=container, + texture=bui.gettexture('uiAtlas'), + mesh_transparent=bui.getmesh('windowBGBlotch'), + position=( + scrollleft + scrollwidth - 60.0 - blotchwidth * 0.5, + scrollbottom + yoffs - yscale * blotchheight * 0.5, + ), + size=(blotchwidth, yscale * blotchheight), + color=clr, + ) + bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + + if center: + bimg = bui.imagewidget( + parent=container, + texture=bui.gettexture('uiAtlas'), + mesh_transparent=bui.getmesh('windowBGBlotch'), + position=( + scrollleft + scrollwidth * 0.5 - blotchwidth * 0.5, + scrollbottom + yoffs - yscale * blotchheight * 0.5, + ), + size=(blotchwidth, yscale * blotchheight), + color=clr, + ) + bui.widget(edit=bimg, depth_range=(0.9, 1.0)) diff --git a/dist/ba_data/python/bauiv1lib/watch.py b/dist/ba_data/python/bauiv1lib/watch.py index f7ca41e..2b8d923 100644 --- a/dist/ba_data/python/bauiv1lib/watch.py +++ b/dist/ba_data/python/bauiv1lib/watch.py @@ -98,6 +98,7 @@ class WatchWindow(bui.MainWindow): else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, + id=f'{self.main_window_id_prefix}|back', autoselect=True, position=(70, self.yoffs - 50), size=(60, 60), @@ -149,6 +150,7 @@ class WatchWindow(bui.MainWindow): ), size=(self._scroll_width - 2.0 * tab_bar_inset, 50), on_select_call=self._set_tab, + idprefix=self.main_window_id_prefix, ) first_tab = self._tab_row.tabs[tabdefs[0][0]] @@ -175,7 +177,11 @@ class WatchWindow(bui.MainWindow): ) self._tab_container: bui.Widget | None = None - self._restore_state() + try: + current_tab = self.TabID(bui.app.config.get('Watch Tab')) + except ValueError: + current_tab = self.TabID.MY_REPLAYS + self._set_tab(current_tab) @override def get_main_window_state(self) -> bui.MainWindowState: @@ -188,8 +194,8 @@ class WatchWindow(bui.MainWindow): ) @override - def on_main_window_close(self) -> None: - self._save_state() + def main_window_should_preserve_selection(self) -> bool: + return True def _set_tab(self, tab_id: TabID) -> None: # pylint: disable=too-many-locals @@ -204,7 +210,6 @@ class WatchWindow(bui.MainWindow): cfg.commit() # Update tab colors based on which is selected. - # tabs.update_tab_button_colors(self._tab_buttons, tab) self._tab_row.update_appearance(tab_id) if self._tab_container: @@ -240,7 +245,7 @@ class WatchWindow(bui.MainWindow): v = c_height - 30 bui.textwidget( parent=cnt, - position=(c_width * 0.5, v), + position=(c_width * 0.5, v + 6.0), color=(0.6, 1.0, 0.6), scale=0.7, size=(0, 0), @@ -290,6 +295,7 @@ class WatchWindow(bui.MainWindow): tscl = 1.0 if uiscale is bui.UIScale.SMALL else 1.2 self._my_replays_watch_replay_button = btn1 = bui.buttonwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|watch', size=(b_width, b_height), position=(btnh, btnv), button_type='square', @@ -310,6 +316,7 @@ class WatchWindow(bui.MainWindow): btnv -= b_height + b_space_extra bui.buttonwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|rename', size=(b_width, b_height), position=(btnh, btnv), button_type='square', @@ -323,6 +330,7 @@ class WatchWindow(bui.MainWindow): btnv -= b_height + b_space_extra bui.buttonwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|delete', size=(b_width, b_height), position=(btnh, btnv), button_type='square', @@ -342,7 +350,11 @@ class WatchWindow(bui.MainWindow): ) bui.containerwidget(edit=cnt, selected_child=scrlw) self._columnwidget = bui.columnwidget( - parent=scrlw, left_border=10, border=2, margin=0 + parent=scrlw, + id=f'{self.main_window_id_prefix}|column', + left_border=10, + border=2, + margin=0, ) bui.widget( @@ -430,6 +442,7 @@ class WatchWindow(bui.MainWindow): ) self._my_replay_rename_text = txt = bui.textwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|replayrenametext', size=(c_width * 0.8, 40), h_align='left', v_align='center', @@ -443,6 +456,7 @@ class WatchWindow(bui.MainWindow): ) cbtn = bui.buttonwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|replayrenamecancel', label=bui.Lstr(resource='cancelText'), on_activate_call=bui.Call( lambda c: bui.containerwidget(edit=c, transition='out_scale'), @@ -454,6 +468,7 @@ class WatchWindow(bui.MainWindow): ) okb = bui.buttonwidget( parent=cnt, + id=f'{self.main_window_id_prefix}|replayrename', label=bui.Lstr(resource=f'{self._r}.renameText'), size=(180, 60), position=(c_width - 230, 30), @@ -544,8 +559,8 @@ class WatchWindow(bui.MainWindow): ], ), bui.Call(self._delete_replay, self._my_replay_selected), - 450, - 150, + width=450, + height=150, ) def _get_replay_display_name(self, replay: str) -> str: @@ -594,6 +609,7 @@ class WatchWindow(bui.MainWindow): for i, name in enumerate(names): txt = bui.textwidget( parent=self._columnwidget, + id=f'{self.main_window_id_prefix}|replay{i}', size=(self._my_replays_scroll_width / t_scale, 30), selectable=True, color=( @@ -614,58 +630,3 @@ class WatchWindow(bui.MainWindow): up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button, ) self._my_replay_selected = name - - def _save_state(self) -> None: - try: - sel = self._root_widget.get_selected_child() - selected_tab_ids = [ - tab_id - for tab_id, tab in self._tab_row.tabs.items() - if sel == tab.button - ] - if sel == self._back_button: - sel_name = 'Back' - elif selected_tab_ids: - assert len(selected_tab_ids) == 1 - sel_name = f'Tab:{selected_tab_ids[0].value}' - elif sel == self._tab_container: - sel_name = 'TabContainer' - else: - raise ValueError(f'unrecognized selection {sel}') - assert bui.app.classic is not None - bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} - except Exception: - logging.exception('Error saving state for %s.', self) - - def _restore_state(self) -> None: - try: - sel: bui.Widget | None - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( - 'sel_name' - ) - assert isinstance(sel_name, (str, type(None))) - try: - current_tab = self.TabID(bui.app.config.get('Watch Tab')) - except ValueError: - current_tab = self.TabID.MY_REPLAYS - self._set_tab(current_tab) - - if sel_name == 'Back': - sel = self._back_button - elif sel_name == 'TabContainer': - sel = self._tab_container - elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): - try: - sel_tab_id = self.TabID(sel_name.split(':')[-1]) - except ValueError: - sel_tab_id = self.TabID.MY_REPLAYS - sel = self._tab_row.tabs[sel_tab_id].button - else: - if self._tab_container is not None: - sel = self._tab_container - else: - sel = self._tab_row.tabs[current_tab].button - bui.containerwidget(edit=self._root_widget, selected_child=sel) - except Exception: - logging.exception('Error restoring state for %s.', self) diff --git a/dist/ba_data/python/efro/dataclassio/_base.py b/dist/ba_data/python/efro/dataclassio/_base.py index dc89281..c8a3969 100644 --- a/dist/ba_data/python/efro/dataclassio/_base.py +++ b/dist/ba_data/python/efro/dataclassio/_base.py @@ -10,7 +10,6 @@ import datetime from enum import Enum from typing import TYPE_CHECKING, get_args, override, final -# noinspection PyProtectedMember from typing import _AnnotatedAlias # type: ignore if TYPE_CHECKING: diff --git a/dist/ba_data/python/efro/dataclassio/_inputter.py b/dist/ba_data/python/efro/dataclassio/_inputter.py index 2d85d14..8aff5b8 100644 --- a/dist/ba_data/python/efro/dataclassio/_inputter.py +++ b/dist/ba_data/python/efro/dataclassio/_inputter.py @@ -195,7 +195,6 @@ class _Inputter: ) return value - # noinspection PyPep8 if origin is typing.Union or origin is types.UnionType: # Currently, the only unions we support are None/Value # (translated from Optional), which we verified on prep. So diff --git a/dist/ba_data/python/efro/dataclassio/_prep.py b/dist/ba_data/python/efro/dataclassio/_prep.py index ebda4dd..4e9cd1d 100644 --- a/dist/ba_data/python/efro/dataclassio/_prep.py +++ b/dist/ba_data/python/efro/dataclassio/_prep.py @@ -17,7 +17,6 @@ import types import datetime from typing import TYPE_CHECKING, get_type_hints -# noinspection PyProtectedMember from efro.dataclassio._base import ( parse_annotated, _get_origin, @@ -200,7 +199,6 @@ class PrepSession: f' explicit prep call.' ) from exc - # noinspection PyDataclass fields = dataclasses.fields(cls) fields_by_name = {f.name: f for f in fields} @@ -280,7 +278,6 @@ class PrepSession: if issubclass(origin, IOMultiType): return - # noinspection PyPep8 if origin is typing.Union or origin is types.UnionType: self.prep_union( cls, attrname, anntype, recursion_level=recursion_level + 1 diff --git a/dist/ba_data/python/efro/message/_receiver.py b/dist/ba_data/python/efro/message/_receiver.py index 6249341..efb9947 100644 --- a/dist/ba_data/python/efro/message/_receiver.py +++ b/dist/ba_data/python/efro/message/_receiver.py @@ -136,6 +136,7 @@ class MessageReceiver: # This will contain NoneType for empty return cases, but we # expect it to be None. + # pylint: disable=unidiomatic-typecheck responsetypes = tuple( None if r is type(None) else r for r in responsetypes ) diff --git a/dist/ba_data/python/efro/terminal.py b/dist/ba_data/python/efro/terminal.py index 569eebb..0ccfadb 100644 --- a/dist/ba_data/python/efro/terminal.py +++ b/dist/ba_data/python/efro/terminal.py @@ -95,7 +95,6 @@ def _default_color_enabled() -> bool: return True -# noinspection PyPep8Naming def _windows_enable_color() -> bool: """Attempt to enable ANSI color on windows terminal; return success.""" # pylint: disable=invalid-name, import-error, undefined-variable diff --git a/dist/ba_data/python/efro/util.py b/dist/ba_data/python/efro/util.py index 438f442..aa2cd7b 100644 --- a/dist/ba_data/python/efro/util.py +++ b/dist/ba_data/python/efro/util.py @@ -337,7 +337,6 @@ class DispatchMethodWrapper[ArgT, RetT](): registry: dict[Any, Callable] -# noinspection PyProtectedMember,PyTypeHints def dispatchmethod[ArgT, RetT]( func: Callable[[Any, ArgT], RetT], ) -> DispatchMethodWrapper[ArgT, RetT]: @@ -824,7 +823,7 @@ def set_canonical_module_names(module_globals: dict[str, Any]) -> None: def timedelta_str( - timeval: datetime.timedelta | float, maxparts: int = 2, decimals: int = 0 + timeval: datetime.timedelta | float, *, maxparts: int = 2, decimals: int = 0 ) -> str: """Return a simple human readable time string for a length of time. @@ -833,8 +832,8 @@ def timedelta_str( Example output: - ``"23d 1h 2m 32s"`` (with maxparts == 4) - - ``"23d 1h"`` (with maxparts == 2) - - ``"23d 1.08h"`` (with maxparts == 2 and decimals == 2) + - ``"23d 1h"`` (with maxparts == 2) + - ``"23d 1.08h"`` (with maxparts == 2 and decimals == 2) Note that this is hard-coded in English and probably not especially performant. @@ -902,6 +901,7 @@ def timedelta_str( def ago_str( timeval: datetime.datetime, + *, maxparts: int = 1, now: datetime.datetime | None = None, decimals: int = 0, diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index 56b9c22..bb17ac3 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ