# Released under the MIT License. See LICENSE for details.
#
"""Provides UI for selecting maps in playlists."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, override
import bauiv1 as bui
if TYPE_CHECKING:
from typing import Any, Callable
import bascenev1 as bs
[docs]
class PlaylistMapSelectWindow(bui.MainWindow):
"""Window to select a map."""
def __init__(
self,
gametype: type[bs.GameActivity],
sessiontype: type[bs.Session],
config: dict[str, Any],
edit_info: dict[str, Any],
completion_call: Callable[[dict[str, Any] | None, bui.MainWindow], Any],
transition: str | None = 'in_right',
origin_widget: bui.Widget | None = None,
select_get_more_maps_button: bool = False,
):
# pylint: disable=too-many-locals
# pylint: disable=too-many-positional-arguments
from bascenev1 import get_filtered_map_name
self._gametype = gametype
self._sessiontype = sessiontype
self._config = config
self._completion_call = completion_call
self._edit_info = edit_info
self._maps: list[tuple[str, bui.Texture]] = []
self._selected_get_more_maps = False
try:
self._previous_map = get_filtered_map_name(
config['settings']['map']
)
except Exception:
self._previous_map = ''
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
width = 815 if uiscale is bui.UIScale.SMALL else 615
x_inset = 100 if uiscale is bui.UIScale.SMALL else 0
height = (
420
if uiscale is bui.UIScale.SMALL
else 480 if uiscale is bui.UIScale.MEDIUM else 600
)
yoffs = -37 if uiscale is bui.UIScale.SMALL else 0
super().__init__(
root_widget=bui.containerwidget(
size=(width, height),
scale=(
2.3
if uiscale is bui.UIScale.SMALL
else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0
),
stack_offset=(
(0, 0) if uiscale is bui.UIScale.SMALL else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
self._cancel_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(38 + x_inset, height - 67 + yoffs),
size=(140, 50),
scale=0.9,
text_scale=1.0,
autoselect=True,
label=bui.Lstr(resource='cancelText'),
on_activate_call=self.main_window_back,
)
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
bui.textwidget(
parent=self._root_widget,
position=(width * 0.5, height - 46 + yoffs),
size=(0, 0),
maxwidth=260,
scale=1.1,
text=bui.Lstr(
resource='mapSelectTitleText',
subs=[('${GAME}', self._gametype.get_display_string())],
),
color=bui.app.ui_v1.title_color,
h_align='center',
v_align='center',
)
v = height - 70 + yoffs
self._scroll_width = width - (80 + 2 * x_inset)
self._scroll_height = height - (
170 if uiscale is bui.UIScale.SMALL else 140
)
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
position=(40 + x_inset, v - self._scroll_height),
size=(self._scroll_width, self._scroll_height),
border_opacity=0.4,
)
bui.containerwidget(
edit=self._root_widget, selected_child=self._scrollwidget
)
bui.containerwidget(edit=self._scrollwidget, claims_left_right=True)
self._subcontainer: bui.Widget | None = None
self._refresh(select_get_more_maps_button=select_get_more_maps_button)
[docs]
@override
def get_main_window_state(self) -> bui.MainWindowState:
# Support recreating our window for back/refresh purposes.
cls = type(self)
# Pull things out of self here; if we do it in the lambda we'll
# keep ourself alive.
gametype = self._gametype
sessiontype = self._sessiontype
config = self._config
edit_info = self._edit_info
completion_call = self._completion_call
select_get_more_maps = self._selected_get_more_maps
return bui.BasicMainWindowState(
create_call=lambda transition, origin_widget: cls(
transition=transition,
origin_widget=origin_widget,
gametype=gametype,
sessiontype=sessiontype,
config=config,
edit_info=edit_info,
completion_call=completion_call,
select_get_more_maps_button=select_get_more_maps,
)
)
def _refresh(self, select_get_more_maps_button: bool = False) -> None:
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
from bascenev1 import (
get_map_class,
get_map_display_string,
)
assert bui.app.classic is not None
store = bui.app.classic.store
# Kill old.
if self._subcontainer is not None:
self._subcontainer.delete()
mesh_opaque = bui.getmesh('level_select_button_opaque')
mesh_transparent = bui.getmesh('level_select_button_transparent')
self._maps = []
map_list = self._gametype.get_supported_maps(self._sessiontype)
map_list_sorted = list(map_list)
map_list_sorted.sort()
unowned_maps = store.get_unowned_maps()
for mapname in map_list_sorted:
# Disallow ones we don't own.
if mapname in unowned_maps:
continue
map_tex_name = get_map_class(mapname).get_preview_texture_name()
if map_tex_name is not None:
try:
map_tex = bui.gettexture(map_tex_name)
self._maps.append((mapname, map_tex))
except Exception:
print(f'Invalid map preview texture: "{map_tex_name}".')
else:
print('Error: no map preview texture for map:', mapname)
count = len(self._maps)
columns = 2
rows = int(math.ceil(float(count) / columns))
button_width = 220
button_height = button_width * 0.5
button_buffer_h = 16
button_buffer_v = 19
self._sub_width = self._scroll_width * 0.95
self._sub_height = (
5 + rows * (button_height + 2 * button_buffer_v) + 100
)
self._subcontainer = bui.containerwidget(
parent=self._scrollwidget,
size=(self._sub_width, self._sub_height),
background=False,
)
index = 0
mask_texture = bui.gettexture('mapPreviewMask')
h_offs = 130 if len(self._maps) == 1 else 0
for y in range(rows):
for x in range(columns):
pos = (
x * (button_width + 2 * button_buffer_h)
+ button_buffer_h
+ h_offs,
self._sub_height
- (y + 1) * (button_height + 2 * button_buffer_v)
+ 12,
)
btn = bui.buttonwidget(
parent=self._subcontainer,
button_type='square',
size=(button_width, button_height),
autoselect=True,
texture=self._maps[index][1],
mask_texture=mask_texture,
mesh_opaque=mesh_opaque,
mesh_transparent=mesh_transparent,
label='',
color=(1, 1, 1),
on_activate_call=bui.Call(
self._select_with_delay, self._maps[index][0]
),
position=pos,
)
if x == 0:
bui.widget(edit=btn, left_widget=self._cancel_button)
if y == 0:
bui.widget(edit=btn, up_widget=self._cancel_button)
if x == columns - 1:
bui.widget(
edit=btn,
right_widget=bui.get_special_widget('squad_button'),
)
bui.widget(edit=btn, show_buffer_top=60, show_buffer_bottom=60)
if self._maps[index][0] == self._previous_map:
bui.containerwidget(
edit=self._subcontainer,
selected_child=btn,
visible_child=btn,
)
name = get_map_display_string(self._maps[index][0])
bui.textwidget(
parent=self._subcontainer,
text=name,
position=(pos[0] + button_width * 0.5, pos[1] - 12),
size=(0, 0),
scale=0.5,
maxwidth=button_width,
draw_controller=btn,
h_align='center',
v_align='center',
color=(0.8, 0.8, 0.8, 0.8),
)
index += 1
if index >= count:
break
if index >= count:
break
self._get_more_maps_button = btn = bui.buttonwidget(
parent=self._subcontainer,
size=(self._sub_width * 0.8, 60),
position=(self._sub_width * 0.1, 30),
label=bui.Lstr(resource='mapSelectGetMoreMapsText'),
on_activate_call=self._on_store_press,
color=(0.6, 0.53, 0.63),
textcolor=(0.75, 0.7, 0.8),
autoselect=True,
)
bui.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30)
if select_get_more_maps_button:
bui.containerwidget(
edit=self._subcontainer, selected_child=btn, visible_child=btn
)
def _on_store_press(self) -> None:
from bauiv1lib.account.signin import show_sign_in_prompt
from bauiv1lib.store.browser import StoreBrowserWindow
# No-op if we're not in control.
if not self.main_window_has_control():
return
plus = bui.app.plus
assert plus is not None
if plus.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return
self._selected_get_more_maps = True
self.main_window_replace(
StoreBrowserWindow(
show_tab=StoreBrowserWindow.TabID.MAPS,
origin_widget=self._get_more_maps_button,
minimal_toolbars=True,
)
)
def _select(self, map_name: str) -> None:
# no-op if our underlying widget is dead or on its way out.
if not self.main_window_has_control():
return
self._config['settings']['map'] = map_name
self.main_window_back()
def _select_with_delay(self, map_name: str) -> None:
bui.lock_all_input()
bui.apptimer(0.1, bui.unlock_all_input)
bui.apptimer(0.1, bui.WeakCall(self._select, map_name))
# 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