# Released under the MIT License. See LICENSE for details.
#
"""UI for browsing the store."""
# pylint: disable=too-many-lines
from __future__ import annotations
import os
import time
import copy
import math
import logging
import weakref
import datetime
from enum import Enum
from threading import Thread
from typing import TYPE_CHECKING
from efro.util import utc_now
from efro.error import CommunicationError
import bacommon.cloud
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
MERCH_LINK_KEY = 'Merch Link'
[docs]
class StoreBrowserWindow(bui.Window):
"""Window for browsing the store."""
[docs]
class TabID(Enum):
"""Our available tab types."""
EXTRAS = 'extras'
MAPS = 'maps'
MINIGAMES = 'minigames'
CHARACTERS = 'characters'
ICONS = 'icons'
def __init__(
self,
transition: str = 'in_right',
modal: bool = False,
show_tab: StoreBrowserWindow.TabID | None = None,
on_close_call: Callable[[], Any] | None = None,
back_location: str | None = None,
origin_widget: bui.Widget | None = None,
):
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
from bauiv1lib.tabs import TabRow
from bauiv1 import SpecialChar
app = bui.app
assert app.classic is not None
uiscale = app.ui_v1.uiscale
bui.set_analytics_screen('Store Window')
scale_origin: tuple[float, float] | None
# If they provided an origin-widget, scale up from that.
if origin_widget is not None:
self._transition_out = 'out_scale'
scale_origin = origin_widget.get_screen_space_center()
transition = 'in_scale'
else:
self._transition_out = 'out_right'
scale_origin = None
self.button_infos: dict[str, dict[str, Any]] | None = None
self.update_buttons_timer: bui.AppTimer | None = None
self._status_textwidget_update_timer = None
self._back_location = back_location
self._on_close_call = on_close_call
self._show_tab = show_tab
self._modal = modal
self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040
self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0
self._height = (
578
if uiscale is bui.UIScale.SMALL
else 645 if uiscale is bui.UIScale.MEDIUM else 800
)
self._current_tab: StoreBrowserWindow.TabID | None = None
extra_top = 30 if uiscale is bui.UIScale.SMALL else 0
self.request: Any = None
self._r = 'store'
self._last_buy_time: float | None = None
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + extra_top),
transition=transition,
toolbar_visibility='menu_full',
scale=(
1.3
if uiscale is bui.UIScale.SMALL
else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8
),
scale_origin_stack_offset=scale_origin,
stack_offset=(
(0, -5)
if uiscale is bui.UIScale.SMALL
else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
),
)
)
self._back_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(70 + x_inset, self._height - 74),
size=(140, 60),
scale=1.1,
autoselect=True,
label=bui.Lstr(resource='doneText' if self._modal else 'backText'),
button_type=None if self._modal else 'back',
on_activate_call=self._back,
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
self._ticket_count_text: bui.Widget | None = None
self._get_tickets_button: bui.Widget | None = None
if app.classic.allow_ticket_purchases:
self._get_tickets_button = bui.buttonwidget(
parent=self._root_widget,
size=(210, 65),
on_activate_call=self._on_get_more_tickets_press,
autoselect=True,
scale=0.9,
text_scale=1.4,
left_widget=self._back_button,
color=(0.7, 0.5, 0.85),
textcolor=(0.2, 1.0, 0.2),
label=bui.Lstr(resource='getTicketsWindow.titleText'),
)
else:
self._ticket_count_text = bui.textwidget(
parent=self._root_widget,
size=(210, 64),
color=(0.2, 1.0, 0.2),
h_align='center',
v_align='center',
)
# Move this dynamically to keep it out of the way of the party icon.
self._update_get_tickets_button_pos()
self._get_ticket_pos_update_timer = bui.AppTimer(
1.0,
bui.WeakCall(self._update_get_tickets_button_pos),
repeat=True,
)
if self._get_tickets_button:
bui.widget(
edit=self._back_button, right_widget=self._get_tickets_button
)
self._ticket_text_update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update_tickets_text), repeat=True
)
self._update_tickets_text()
if (
app.classic.platform in ['mac', 'ios']
and app.classic.subplatform == 'appstore'
):
bui.buttonwidget(
parent=self._root_widget,
position=(self._width * 0.5 - 70, 16),
size=(230, 50),
scale=0.65,
on_activate_call=bui.WeakCall(self._restore_purchases),
color=(0.35, 0.3, 0.4),
selectable=False,
textcolor=(0.55, 0.5, 0.6),
label=bui.Lstr(
resource='getTicketsWindow.restorePurchasesText'
),
)
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 44),
size=(0, 0),
color=app.ui_v1.title_color,
scale=1.5,
h_align='center',
v_align='center',
text=bui.Lstr(resource='storeText'),
maxwidth=420,
)
if not self._modal:
bui.buttonwidget(
edit=self._back_button,
button_type='backSmall',
size=(60, 60),
label=bui.charstr(SpecialChar.BACK),
)
scroll_buffer_h = 130 + 2 * x_inset
tab_buffer_h = 250 + 2 * x_inset
tabs_def = [
(self.TabID.EXTRAS, bui.Lstr(resource=self._r + '.extrasText')),
(self.TabID.MAPS, bui.Lstr(resource=self._r + '.mapsText')),
(
self.TabID.MINIGAMES,
bui.Lstr(resource=self._r + '.miniGamesText'),
),
(
self.TabID.CHARACTERS,
bui.Lstr(resource=self._r + '.charactersText'),
),
(self.TabID.ICONS, bui.Lstr(resource=self._r + '.iconsText')),
]
self._tab_row = TabRow(
self._root_widget,
tabs_def,
pos=(tab_buffer_h * 0.5, self._height - 130),
size=(self._width - tab_buffer_h, 50),
on_select_call=self._set_tab,
)
self._purchasable_count_widgets: dict[
StoreBrowserWindow.TabID, dict[str, Any]
] = {}
# Create our purchasable-items tags and have them update over time.
for tab_id, tab in self._tab_row.tabs.items():
pos = tab.position
size = tab.size
button = tab.button
rad = 10
center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
img = bui.imagewidget(
parent=self._root_widget,
position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
size=(rad * 2.2, rad * 2.2),
texture=bui.gettexture('circleShadow'),
color=(1, 0, 0),
)
txt = bui.textwidget(
parent=self._root_widget,
position=center,
size=(0, 0),
h_align='center',
v_align='center',
maxwidth=1.4 * rad,
scale=0.6,
shadow=1.0,
flatness=1.0,
)
rad = 20
sale_img = bui.imagewidget(
parent=self._root_widget,
position=(center[0] - rad, center[1] - rad),
size=(rad * 2, rad * 2),
draw_controller=button,
texture=bui.gettexture('circleZigZag'),
color=(0.5, 0, 1.0),
)
sale_title_text = bui.textwidget(
parent=self._root_widget,
position=(center[0], center[1] + 0.24 * rad),
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=button,
maxwidth=1.4 * rad,
scale=0.6,
shadow=0.0,
flatness=1.0,
color=(0, 1, 0),
)
sale_time_text = bui.textwidget(
parent=self._root_widget,
position=(center[0], center[1] - 0.29 * rad),
size=(0, 0),
h_align='center',
v_align='center',
draw_controller=button,
maxwidth=1.4 * rad,
scale=0.4,
shadow=0.0,
flatness=1.0,
color=(0, 1, 0),
)
self._purchasable_count_widgets[tab_id] = {
'img': img,
'text': txt,
'sale_img': sale_img,
'sale_title_text': sale_title_text,
'sale_time_text': sale_time_text,
}
self._tab_update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update_tabs), repeat=True
)
self._update_tabs()
if self._get_tickets_button:
last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
bui.widget(
edit=self._get_tickets_button, down_widget=last_tab_button
)
bui.widget(
edit=last_tab_button,
up_widget=self._get_tickets_button,
right_widget=self._get_tickets_button,
)
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()
def _update_get_tickets_button_pos(self) -> None:
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
pos = (
self._width
- 252
- (
self._x_inset
+ (
47
if uiscale is bui.UIScale.SMALL
and bui.is_party_icon_visible()
else 0
)
),
self._height - 70,
)
if self._get_tickets_button:
bui.buttonwidget(edit=self._get_tickets_button, position=pos)
if self._ticket_count_text:
bui.textwidget(edit=self._ticket_count_text, position=pos)
def _restore_purchases(self) -> None:
from bauiv1lib import account
plus = bui.app.plus
assert plus is not None
if plus.accounts.primary is None:
account.show_sign_in_prompt()
else:
plus.restore_purchases()
def _update_tabs(self) -> None:
assert bui.app.classic is not None
store = bui.app.classic.store
if not self._root_widget:
return
for tab_id, tab_data in list(self._purchasable_count_widgets.items()):
sale_time = store.get_available_sale_time(tab_id.value)
if sale_time is not None:
bui.textwidget(
edit=tab_data['sale_title_text'],
text=bui.Lstr(resource='store.saleText'),
)
bui.textwidget(
edit=tab_data['sale_time_text'],
text=bui.timestring(sale_time / 1000.0, centi=False),
)
bui.imagewidget(edit=tab_data['sale_img'], opacity=1.0)
count = 0
else:
bui.textwidget(edit=tab_data['sale_title_text'], text='')
bui.textwidget(edit=tab_data['sale_time_text'], text='')
bui.imagewidget(edit=tab_data['sale_img'], opacity=0.0)
count = store.get_available_purchase_count(tab_id.value)
if count > 0:
bui.textwidget(edit=tab_data['text'], text=str(count))
bui.imagewidget(edit=tab_data['img'], opacity=1.0)
else:
bui.textwidget(edit=tab_data['text'], text='')
bui.imagewidget(edit=tab_data['img'], opacity=0.0)
def _update_tickets_text(self) -> None:
from bauiv1 import SpecialChar
if not self._root_widget:
return
plus = bui.app.plus
assert plus is not None
sval: str | bui.Lstr
if plus.get_v1_account_state() == 'signed_in':
sval = bui.charstr(SpecialChar.TICKET) + str(
plus.get_v1_account_ticket_count()
)
else:
sval = bui.Lstr(resource='getTicketsWindow.titleText')
if self._get_tickets_button:
bui.buttonwidget(edit=self._get_tickets_button, label=sval)
if self._ticket_count_text:
bui.textwidget(edit=self._ticket_count_text, text=sval)
def _set_tab(self, tab_id: TabID) -> None:
if self._current_tab is tab_id:
return
self._current_tab = tab_id
# We wanna preserve our current tab between runs.
cfg = bui.app.config
cfg['Store Tab'] = tab_id.value
cfg.commit()
# Update tab colors based on which is selected.
self._tab_row.update_appearance(tab_id)
# (Re)create scroll widget.
if self._scrollwidget:
self._scrollwidget.delete()
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
highlight=False,
position=(
(self._width - self._scroll_width) * 0.5,
self._height - self._scroll_height - 79 - 48,
),
size=(self._scroll_width, self._scroll_height),
claims_left_right=True,
claims_tab=True,
selection_loops_to_parent=True,
)
# NOTE: this stuff is modified by the _Store class.
# Should maybe clean that up.
self.button_infos = {}
self.update_buttons_timer = None
# Show status over top.
if self._status_textwidget:
self._status_textwidget.delete()
self._status_textwidget = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.5),
size=(0, 0),
color=(1, 0.7, 1, 0.5),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.loadingText'),
maxwidth=self._scroll_width * 0.9,
)
class _Request:
def __init__(self, window: StoreBrowserWindow):
self._window = weakref.ref(window)
data = {'tab': tab_id.value}
bui.apptimer(0.1, bui.WeakCall(self._on_response, data))
def _on_response(self, data: dict[str, Any] | None) -> None:
# FIXME: clean this up.
# pylint: disable=protected-access
window = self._window()
if window is not None and (window.request is self):
window.request = None
# noinspection PyProtectedMember
window._on_response(data)
# Kick off a server request.
self.request = _Request(self)
# Actually start the purchase locally.
def _purchase_check_result(
self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None
) -> None:
plus = bui.app.plus
assert plus is not None
if result is None:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
else:
if is_ticket_purchase:
if result['allow']:
price = plus.get_v1_account_misc_read_val(
'price.' + item, None
)
if (
price is None
or not isinstance(price, int)
or price <= 0
):
print(
'Error; got invalid local price of',
price,
'for item',
item,
)
bui.getsound('error').play()
else:
bui.getsound('click01').play()
plus.in_game_purchase(item, price)
else:
if result['reason'] == 'versionTooOld':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource='getTicketsWindow.versionTooOldText'
),
color=(1, 0, 0),
)
else:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource='getTicketsWindow.unavailableText'
),
color=(1, 0, 0),
)
# Real in-app purchase.
else:
if result['allow']:
plus.purchase(item)
else:
if result['reason'] == 'versionTooOld':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource='getTicketsWindow.versionTooOldText'
),
color=(1, 0, 0),
)
else:
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(
resource='getTicketsWindow.unavailableText'
),
color=(1, 0, 0),
)
def _do_purchase_check(
self, item: str, is_ticket_purchase: bool = False
) -> None:
app = bui.app
if app.classic is None:
logging.warning('_do_purchase_check() requires classic.')
return
# Here we ping the server to ask if it's valid for us to
# purchase this. Better to fail now than after we've
# paid locally.
app.classic.master_server_v1_get(
'bsAccountPurchaseCheck',
{
'item': item,
'platform': app.classic.platform,
'subplatform': app.classic.subplatform,
'version': app.env.engine_version,
'buildNumber': app.env.engine_build_number,
'purchaseType': 'ticket' if is_ticket_purchase else 'real',
},
callback=bui.WeakCall(
self._purchase_check_result, item, is_ticket_purchase
),
)
[docs]
def buy(self, item: str) -> None:
"""Attempt to purchase the provided item."""
from bauiv1lib import account
from bauiv1lib.confirm import ConfirmWindow
from bauiv1lib import getcurrency
assert bui.app.classic is not None
store = bui.app.classic.store
plus = bui.app.plus
assert plus is not None
# Prevent pressing buy within a few seconds of the last press
# (gives the buttons time to disable themselves and whatnot).
curtime = bui.apptime()
if (
self._last_buy_time is not None
and (curtime - self._last_buy_time) < 2.0
):
bui.getsound('error').play()
else:
if plus.get_v1_account_state() != 'signed_in':
account.show_sign_in_prompt()
else:
self._last_buy_time = curtime
# Merch is a special case - just a link.
if item == 'merch':
url = bui.app.config.get('Merch Link')
if isinstance(url, str):
bui.open_url(url)
# Pro is an actual IAP, and the rest are ticket purchases.
elif item == 'pro':
bui.getsound('click01').play()
# Purchase either pro or pro_sale depending on whether
# there is a sale going on.
self._do_purchase_check(
'pro'
if store.get_available_sale_time('extras') is None
else 'pro_sale'
)
else:
price = plus.get_v1_account_misc_read_val(
'price.' + item, None
)
our_tickets = plus.get_v1_account_ticket_count()
if price is not None and our_tickets < price:
bui.getsound('error').play()
getcurrency.show_get_tickets_prompt()
else:
def do_it() -> None:
self._do_purchase_check(
item, is_ticket_purchase=True
)
bui.getsound('swish').play()
ConfirmWindow(
bui.Lstr(
resource='store.purchaseConfirmText',
subs=[
(
'${ITEM}',
store.get_store_item_name_translated(
item
),
)
],
),
width=400,
height=120,
action=do_it,
ok_text=bui.Lstr(
resource='store.purchaseText',
fallback_resource='okText',
),
)
def _print_already_own(self, charname: str) -> None:
bui.screenmessage(
bui.Lstr(
resource=self._r + '.alreadyOwnText',
subs=[('${NAME}', charname)],
),
color=(1, 0, 0),
)
bui.getsound('error').play()
def _on_response(self, data: dict[str, Any] | None) -> None:
# pylint: disable=too-many-statements
assert bui.app.classic is not None
cstore = bui.app.classic.store
# clear status text..
if self._status_textwidget:
self._status_textwidget.delete()
self._status_textwidget_update_timer = None
if data is None:
self._status_textwidget = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.5),
size=(0, 0),
scale=1.3,
transition_delay=0.1,
color=(1, 0.3, 0.3, 1.0),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.loadErrorText'),
maxwidth=self._scroll_width * 0.9,
)
else:
class _Store:
def __init__(
self,
store_window: StoreBrowserWindow,
sdata: dict[str, Any],
width: float,
):
self._store_window = store_window
self._width = width
store_data = cstore.get_store_layout()
self._tab = sdata['tab']
self._sections = copy.deepcopy(store_data[sdata['tab']])
self._height: float | None = None
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
# Pre-calc a few things and add them to store-data.
for section in self._sections:
if self._tab == 'characters':
dummy_name = 'characters.foo'
elif self._tab == 'extras':
dummy_name = 'pro'
elif self._tab == 'maps':
dummy_name = 'maps.foo'
elif self._tab == 'icons':
dummy_name = 'icons.foo'
else:
dummy_name = ''
section['button_size'] = (
cstore.get_store_item_display_size(dummy_name)
)
section['v_spacing'] = (
-25
if (
self._tab == 'extras'
and uiscale is bui.UIScale.SMALL
)
else -17 if self._tab == 'characters' else 0
)
if 'title' not in section:
section['title'] = ''
section['x_offs'] = (
130
if self._tab == 'extras'
else 270 if self._tab == 'maps' else 0
)
section['y_offs'] = (
20
if (
self._tab == 'extras'
and uiscale is bui.UIScale.SMALL
and bui.app.config.get('Merch Link')
)
else (
55
if (
self._tab == 'extras'
and uiscale is bui.UIScale.SMALL
)
else -20 if self._tab == 'icons' else 0
)
)
def instantiate(
self, scrollwidget: bui.Widget, tab_button: bui.Widget
) -> None:
"""Create the store."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
from bauiv1lib.store.item import (
instantiate_store_item_display,
)
title_spacing = 40
button_border = 20
button_spacing = 4
boffs_h = 40
self._height = 80.0
# Calc total height.
for i, section in enumerate(self._sections):
if section['title'] != '':
assert self._height is not None
self._height += title_spacing
b_width, b_height = section['button_size']
b_column_count = int(
math.floor(
(self._width - boffs_h - 20)
/ (b_width + button_spacing)
)
)
b_row_count = int(
math.ceil(
float(len(section['items'])) / b_column_count
)
)
b_height_total = (
2 * button_border
+ b_row_count * b_height
+ (b_row_count - 1) * section['v_spacing']
)
self._height += b_height_total
assert self._height is not None
cnt2 = bui.containerwidget(
parent=scrollwidget,
scale=1.0,
size=(self._width, self._height),
background=False,
claims_left_right=True,
claims_tab=True,
selection_loops_to_parent=True,
)
v = self._height - 20
if self._tab == 'characters':
txt = bui.Lstr(
resource='store.howToSwitchCharactersText',
subs=[
(
'${SETTINGS}',
bui.Lstr(
resource=(
'accountSettingsWindow.titleText'
)
),
),
(
'${PLAYER_PROFILES}',
bui.Lstr(
resource=(
'playerProfilesWindow.titleText'
)
),
),
],
)
bui.textwidget(
parent=cnt2,
text=txt,
size=(0, 0),
position=(self._width * 0.5, self._height - 28),
h_align='center',
v_align='center',
color=(0.7, 1, 0.7, 0.4),
scale=0.7,
shadow=0,
flatness=1.0,
maxwidth=700,
transition_delay=0.4,
)
elif self._tab == 'icons':
txt = bui.Lstr(
resource='store.howToUseIconsText',
subs=[
(
'${SETTINGS}',
bui.Lstr(resource='mainMenu.settingsText'),
),
(
'${PLAYER_PROFILES}',
bui.Lstr(
resource=(
'playerProfilesWindow.titleText'
)
),
),
],
)
bui.textwidget(
parent=cnt2,
text=txt,
size=(0, 0),
position=(self._width * 0.5, self._height - 28),
h_align='center',
v_align='center',
color=(0.7, 1, 0.7, 0.4),
scale=0.7,
shadow=0,
flatness=1.0,
maxwidth=700,
transition_delay=0.4,
)
elif self._tab == 'maps':
assert self._width is not None
assert self._height is not None
txt = bui.Lstr(resource='store.howToUseMapsText')
bui.textwidget(
parent=cnt2,
text=txt,
size=(0, 0),
position=(self._width * 0.5, self._height - 28),
h_align='center',
v_align='center',
color=(0.7, 1, 0.7, 0.4),
scale=0.7,
shadow=0,
flatness=1.0,
maxwidth=700,
transition_delay=0.4,
)
prev_row_buttons: list | None = None
this_row_buttons = []
delay = 0.3
for section in self._sections:
if section['title'] != '':
bui.textwidget(
parent=cnt2,
position=(60, v - title_spacing * 0.8),
size=(0, 0),
scale=1.0,
transition_delay=delay,
color=(0.7, 0.9, 0.7, 1),
h_align='left',
v_align='center',
text=bui.Lstr(resource=section['title']),
maxwidth=self._width * 0.7,
)
v -= title_spacing
delay = max(0.100, delay - 0.100)
v -= button_border
b_width, b_height = section['button_size']
b_count = len(section['items'])
b_column_count = int(
math.floor(
(self._width - boffs_h - 20)
/ (b_width + button_spacing)
)
)
col = 0
item: dict[str, Any]
assert self._store_window.button_infos is not None
for i, item_name in enumerate(section['items']):
item = self._store_window.button_infos[
item_name
] = {}
item['call'] = bui.WeakCall(
self._store_window.buy, item_name
)
if 'x_offs' in section:
boffs_h2 = section['x_offs']
else:
boffs_h2 = 0
if 'y_offs' in section:
boffs_v2 = section['y_offs']
else:
boffs_v2 = 0
b_pos = (
boffs_h
+ boffs_h2
+ (b_width + button_spacing) * col,
v - b_height + boffs_v2,
)
instantiate_store_item_display(
item_name,
item,
parent_widget=cnt2,
b_pos=b_pos,
boffs_h=boffs_h,
b_width=b_width,
b_height=b_height,
boffs_h2=boffs_h2,
boffs_v2=boffs_v2,
delay=delay,
)
btn = item['button']
delay = max(0.1, delay - 0.1)
this_row_buttons.append(btn)
# Wire this button to the equivalent in the
# previous row.
if prev_row_buttons is not None:
if len(prev_row_buttons) > col:
bui.widget(
edit=btn,
up_widget=prev_row_buttons[col],
)
bui.widget(
edit=prev_row_buttons[col],
down_widget=btn,
)
# If we're the last button in our row,
# wire any in the previous row past
# our position to go to us if down is
# pressed.
if (
col + 1 == b_column_count
or i == b_count - 1
):
for b_prev in prev_row_buttons[
col + 1 :
]:
bui.widget(
edit=b_prev, down_widget=btn
)
else:
bui.widget(
edit=btn, up_widget=prev_row_buttons[-1]
)
else:
bui.widget(edit=btn, up_widget=tab_button)
col += 1
if col == b_column_count or i == b_count - 1:
prev_row_buttons = this_row_buttons
this_row_buttons = []
col = 0
v -= b_height
if i < b_count - 1:
v -= section['v_spacing']
v -= button_border
# Set a timer to update these buttons periodically as long
# as we're alive (so if we buy one it will grey out, etc).
self._store_window.update_buttons_timer = bui.AppTimer(
0.5,
bui.WeakCall(self._store_window.update_buttons),
repeat=True,
)
# Also update them immediately.
self._store_window.update_buttons()
if self._current_tab in (
self.TabID.EXTRAS,
self.TabID.MINIGAMES,
self.TabID.CHARACTERS,
self.TabID.MAPS,
self.TabID.ICONS,
):
store = _Store(self, data, self._scroll_width)
assert self._scrollwidget is not None
store.instantiate(
scrollwidget=self._scrollwidget,
tab_button=self._tab_row.tabs[self._current_tab].button,
)
else:
cnt = bui.containerwidget(
parent=self._scrollwidget,
scale=1.0,
size=(self._scroll_width, self._scroll_height * 0.95),
background=False,
claims_left_right=True,
claims_tab=True,
selection_loops_to_parent=True,
)
self._status_textwidget = bui.textwidget(
parent=cnt,
position=(
self._scroll_width * 0.5,
self._scroll_height * 0.5,
),
size=(0, 0),
scale=1.3,
transition_delay=0.1,
color=(1, 1, 0.3, 1.0),
h_align='center',
v_align='center',
text=bui.Lstr(resource=self._r + '.comingSoonText'),
maxwidth=self._scroll_width * 0.9,
)
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._get_tickets_button:
sel_name = 'GetTickets'
elif 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:
from efro.util import enum_by_value
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 = enum_by_value(
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 == 'GetTickets' and self._get_tickets_button:
sel = self._get_tickets_button
elif 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 = enum_by_value(
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 _on_get_more_tickets_press(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.account import show_sign_in_prompt
from bauiv1lib.getcurrency import GetCurrencyWindow
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
plus = bui.app.plus
assert plus is not None
if plus.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return
self._save_state()
bui.containerwidget(edit=self._root_widget, transition='out_left')
window = GetCurrencyWindow(
from_modal_store=self._modal,
store_back_location=self._back_location,
).get_root_widget()
if not self._modal:
assert bui.app.classic is not None
bui.app.ui_v1.set_main_menu_window(
window, from_window=self._root_widget
)
def _back(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.coop.browser import CoopBrowserWindow
from bauiv1lib.mainmenu import MainMenuWindow
# 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._save_state()
bui.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
if not self._modal:
assert bui.app.classic is not None
if self._back_location == 'CoopBrowserWindow':
bui.app.ui_v1.set_main_menu_window(
CoopBrowserWindow(transition='in_left').get_root_widget(),
from_window=self._root_widget,
)
else:
bui.app.ui_v1.set_main_menu_window(
MainMenuWindow(transition='in_left').get_root_widget(),
from_window=self._root_widget,
)
if self._on_close_call is not None:
self._on_close_call()
def _check_merch_availability_in_bg_thread() -> None:
# pylint: disable=cell-var-from-loop
# Merch is available from some countries only.
# Make a reasonable check to ask the master-server about this at
# launch and store the results.
plus = bui.app.plus
assert plus is not None
for _i in range(15):
try:
if plus.cloud.is_connected():
response = plus.cloud.send_message(
bacommon.cloud.MerchAvailabilityMessage()
)
def _store_in_logic_thread() -> None:
cfg = bui.app.config
current: str | None = cfg.get(MERCH_LINK_KEY)
if not isinstance(current, str | None):
current = None
if current != response.url:
cfg[MERCH_LINK_KEY] = response.url
cfg.commit()
# If we successfully get a response, kick it over to the
# logic thread to store and we're done.
bui.pushcall(_store_in_logic_thread, from_other_thread=True)
return
except CommunicationError:
pass
except Exception:
logging.warning(
'Unexpected error in merch-availability-check.', exc_info=True
)
time.sleep(1.1934) # A bit randomized to avoid aliasing.
# Slight hack; start checking merch availability in the bg (but only if
# it looks like we've been imported for use in a running app; don't want
# to do this during docs generation/etc.)
if (
os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1'
and bui.app.state is not bui.app.State.NOT_STARTED
):
Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()