# Released under the MIT License. See LICENSE for details.
#
"""UI functionality related to browsing player profiles."""
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
[docs]
class ProfileBrowserWindow(bui.MainWindow):
"""Window for browsing player profiles."""
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,
):
self._minimal_toolbar = minimal_toolbar
back_label = bui.Lstr(resource='backText')
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._width = 800.0 if uiscale is bui.UIScale.SMALL else 600.0
x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0
self._height = (
360.0
if uiscale is bui.UIScale.SMALL
else 385.0 if uiscale is bui.UIScale.MEDIUM else 410.0
)
# Need to handle out-transitions ourself for modal mode.
if origin_widget is not None:
self._transition_out = 'out_scale'
else:
self._transition_out = 'out_right'
self._r = 'playerProfilesWindow'
# Ensure we've got an account-profile in cases where we're signed in.
assert bui.app.classic is not None
bui.app.classic.accounts.ensure_have_account_player_profile()
top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + top_extra),
toolbar_visibility=(
'menu_minimal'
if (uiscale is bui.UIScale.SMALL or minimal_toolbar)
else 'menu_full'
),
scale=(
2.5
if uiscale is bui.UIScale.SMALL
else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, -14) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
if bui.app.ui_v1.uiscale is bui.UIScale.SMALL:
self._back_button = bui.get_special_widget('back_button')
bui.containerwidget(
edit=self._root_widget, on_cancel_call=self.main_window_back
)
else:
self._back_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(40 + x_inset, self._height - 59),
size=(120, 60),
scale=0.8,
label=back_label,
button_type='back',
autoselect=True,
on_activate_call=self.main_window_back,
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
bui.buttonwidget(
edit=btn,
button_type='backSmall',
size=(60, 60),
label=bui.charstr(bui.SpecialChar.BACK),
)
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 36),
size=(0, 0),
text=bui.Lstr(resource=f'{self._r}.titleText'),
maxwidth=300,
color=bui.app.ui_v1.title_color,
scale=0.9,
h_align='center',
v_align='center',
)
scroll_height = self._height - 140.0
self._scroll_width = self._width - (188 + x_inset * 2)
v = self._height - 84.0
h = 50 + x_inset
b_color = (0.6, 0.53, 0.63)
scl = (
1.055
if uiscale is bui.UIScale.SMALL
else 1.18 if uiscale is bui.UIScale.MEDIUM else 1.3
)
v -= 70.0 * scl
self._new_button = bui.buttonwidget(
parent=self._root_widget,
position=(h, v),
size=(80, 66.0 * scl),
on_activate_call=self._new_profile,
color=b_color,
button_type='square',
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=f'{self._r}.newButtonText'),
)
v -= 70.0 * scl
self._edit_button = bui.buttonwidget(
parent=self._root_widget,
position=(h, v),
size=(80, 66.0 * scl),
on_activate_call=self._edit_profile,
color=b_color,
button_type='square',
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=f'{self._r}.editButtonText'),
)
v -= 70.0 * scl
self._delete_button = bui.buttonwidget(
parent=self._root_widget,
position=(h, v),
size=(80, 66.0 * scl),
on_activate_call=self._delete_profile,
color=b_color,
button_type='square',
autoselect=True,
textcolor=(0.75, 0.7, 0.8),
text_scale=0.7,
label=bui.Lstr(resource=f'{self._r}.deleteButtonText'),
)
v = self._height - 87
bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 71),
size=(0, 0),
text=bui.Lstr(resource=f'{self._r}.explanationText'),
color=bui.app.ui_v1.infotextcolor,
maxwidth=self._width * 0.83,
scale=0.6,
h_align='center',
v_align='center',
)
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
highlight=False,
position=(140 + x_inset, v - scroll_height),
size=(self._scroll_width, scroll_height),
)
bui.widget(
edit=self._scrollwidget,
autoselect=True,
left_widget=self._new_button,
)
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(self._scroll_width, 32),
background=False,
)
v -= 255
self._profiles: dict[str, dict[str, Any]] | None = None
self._selected_profile = selected_profile
self._profile_widgets: list[bui.Widget] = []
self._refresh()
self._restore_state()
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
minimal_toolbar = self._minimal_toolbar
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
minimal_toolbar=minimal_toolbar,
)
)
[docs]
@override
def on_main_window_close(self) -> None:
self._save_state()
def _new_profile(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.profile.edit import EditProfileWindow
from bauiv1lib.purchase import PurchaseWindow
# No-op if we're not the in-control main window.
if not self.main_window_has_control():
return
plus = bui.app.plus
assert plus is not None
# Limit to a handful profiles if they don't have pro-options.
max_non_pro_profiles = plus.get_v1_account_misc_read_val('mnpp', 5)
assert self._profiles is not None
assert bui.app.classic is not None
if (
bool(False) # Phasing out pro.
and not bui.app.classic.accounts.have_pro_options()
and len(self._profiles) >= max_non_pro_profiles
):
PurchaseWindow(
items=['pro'],
header_text=bui.Lstr(
resource='unlockThisProfilesText',
subs=[('${NUM}', str(max_non_pro_profiles))],
),
)
return
# Clamp at 100 profiles (otherwise the server will and that's less
# elegant looking).
if len(self._profiles) > 100:
bui.screenmessage(
bui.Lstr(
translate=(
'serverResponses',
'Max number of profiles reached.',
)
),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
self.main_window_replace(EditProfileWindow(existing_profile=None))
def _delete_profile(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib import confirm
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__':
bui.getsound('error').play()
bui.screenmessage(
bui.Lstr(resource=f'{self._r}.cantDeleteAccountProfileText'),
color=(1, 0, 0),
)
return
confirm.ConfirmWindow(
bui.Lstr(
resource=f'{self._r}.deleteConfirmText',
subs=[('${PROFILE}', self._selected_profile)],
),
self._do_delete_profile,
350,
)
def _do_delete_profile(self) -> None:
plus = bui.app.plus
assert plus is not None
plus.add_v1_account_transaction(
{'type': 'REMOVE_PLAYER_PROFILE', 'name': self._selected_profile}
)
plus.run_v1_account_transactions()
bui.getsound('shieldDown').play()
self._refresh()
# Select profile list.
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
def _edit_profile(self) -> None:
# pylint: disable=cyclic-import
from bauiv1lib.profile.edit import EditProfileWindow
# No-op if we're not in control.
if not self.main_window_has_control():
return
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))
def _select(self, name: str, index: int) -> None:
del index # Unused.
self._selected_profile = name
def _refresh(self) -> None:
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
from efro.util import asserttype
from bascenev1 import PlayerProfilesChangedMessage
from bascenev1lib.actor import spazappearance
assert bui.app.classic is not None
plus = bui.app.plus
assert plus is not None
old_selection = self._selected_profile
# Delete old.
while self._profile_widgets:
self._profile_widgets.pop().delete()
self._profiles = bui.app.config.get('Player Profiles', {})
assert self._profiles is not None
items = list(self._profiles.items())
items.sort(key=lambda x: asserttype(x[0], str).lower())
spazzes = spazappearance.get_appearances()
spazzes.sort()
icon_textures = [
bui.gettexture(bui.app.classic.spaz_appearances[s].icon_texture)
for s in spazzes
]
icon_tint_textures = [
bui.gettexture(
bui.app.classic.spaz_appearances[s].icon_mask_texture
)
for s in spazzes
]
index = 0
y_val = 35 * (len(self._profiles) - 1)
account_name: str | None
if plus.get_v1_account_state() == 'signed_in':
account_name = plus.get_v1_account_display_string()
else:
account_name = None
widget_to_select = None
for p_name, p_info in items:
if p_name == '__account__' and account_name is None:
continue
color, _highlight = bui.app.classic.get_player_profile_colors(
p_name
)
scl = 1.1
tval = (
account_name
if p_name == '__account__'
else bui.app.classic.get_player_profile_icon(p_name) + p_name
)
try:
char_index = spazzes.index(p_info['character'])
except Exception:
char_index = spazzes.index('Spaz')
assert isinstance(tval, str)
txtw = bui.textwidget(
parent=self._subcontainer,
position=(5, y_val),
size=((self._width - 210) / scl, 28),
text=bui.Lstr(value=f' {tval}'),
h_align='left',
v_align='center',
on_select_call=bui.WeakCall(self._select, p_name, index),
maxwidth=self._scroll_width * 0.86,
corner_scale=scl,
color=bui.safecolor(color, 0.4),
always_highlight=True,
on_activate_call=bui.Call(self._edit_button.activate),
selectable=True,
)
character = bui.imagewidget(
parent=self._subcontainer,
position=(0, y_val),
size=(30, 30),
color=(1, 1, 1),
mask_texture=bui.gettexture('characterIconMask'),
tint_color=color,
tint2_color=_highlight,
texture=icon_textures[char_index],
tint_texture=icon_tint_textures[char_index],
)
if index == 0:
bui.widget(edit=txtw, up_widget=self._back_button)
if self._selected_profile is None:
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
# (but defer till after this loop since our height is
# still changing).
if p_name == old_selection:
widget_to_select = txtw
index += 1
y_val -= 35
bui.containerwidget(
edit=self._subcontainer,
size=(self._scroll_width, index * 35),
)
if widget_to_select is not None:
bui.containerwidget(
edit=self._subcontainer,
selected_child=widget_to_select,
visible_child=widget_to_select,
)
# If there's a team-chooser in existence, tell it the profile-list
# has probably changed.
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)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum