bauiv1lib.playlist.browser

Provides a window for browsing and launching game playlists.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides a window for browsing and launching game playlists."""
  4
  5from __future__ import annotations
  6
  7import copy
  8import math
  9import logging
 10from typing import override, TYPE_CHECKING
 11
 12import bascenev1 as bs
 13import bauiv1 as bui
 14
 15if TYPE_CHECKING:
 16    from bauiv1lib.play import PlaylistSelectContext
 17
 18
 19class PlaylistBrowserWindow(bui.MainWindow):
 20    """Window for starting teams games."""
 21
 22    def __init__(
 23        self,
 24        sessiontype: type[bs.Session],
 25        transition: str | None = 'in_right',
 26        origin_widget: bui.Widget | None = None,
 27        playlist_select_context: PlaylistSelectContext | None = None,
 28    ):
 29        # pylint: disable=cyclic-import
 30        from bauiv1lib.playlist import PlaylistTypeVars
 31
 32        # Store state for when we exit the next game.
 33        if issubclass(sessiontype, bs.DualTeamSession):
 34            bui.set_analytics_screen('Teams Window')
 35        elif issubclass(sessiontype, bs.FreeForAllSession):
 36            bui.set_analytics_screen('FreeForAll Window')
 37        else:
 38            raise TypeError(f'Invalid sessiontype: {sessiontype}.')
 39        self._pvars = PlaylistTypeVars(sessiontype)
 40
 41        self._sessiontype = sessiontype
 42
 43        self._customize_button: bui.Widget | None = None
 44        self._sub_width: float | None = None
 45        self._sub_height: float | None = None
 46        self._playlist_select_context = playlist_select_context
 47
 48        self._ensure_standard_playlists_exist()
 49
 50        # Get the current selection (if any).
 51        self._selected_playlist = bui.app.config.get(
 52            self._pvars.config_name + ' Playlist Selection'
 53        )
 54
 55        uiscale = bui.app.ui_v1.uiscale
 56        self._width = (
 57            1100.0
 58            if uiscale is bui.UIScale.SMALL
 59            else 800.0 if uiscale is bui.UIScale.MEDIUM else 1040
 60        )
 61        self._height = (
 62            600
 63            if uiscale is bui.UIScale.SMALL
 64            else 550 if uiscale is bui.UIScale.MEDIUM else 700
 65        )
 66
 67        # Do some fancy math to fill all available screen area up to the
 68        # size of our backing container. This lets us fit to the exact
 69        # screen shape at small ui scale.
 70        screensize = bui.get_virtual_screen_size()
 71        scale = (
 72            1.85
 73            if uiscale is bui.UIScale.SMALL
 74            else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.8
 75        )
 76        # Calc screen size in our local container space and clamp to a
 77        # bit smaller than our container size.
 78        target_width = min(self._width - 100, screensize[0] / scale)
 79        target_height = min(self._height - 100, screensize[1] / scale)
 80
 81        # To get top/left coords, go to the center of our window and
 82        # offset by half the width/height of our target area.
 83        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 84
 85        self._scroll_width = target_width
 86        self._scroll_height = target_height - 31
 87        scroll_bottom = yoffs - 60 - self._scroll_height
 88
 89        super().__init__(
 90            root_widget=bui.containerwidget(
 91                size=(self._width, self._height),
 92                toolbar_visibility=(
 93                    'menu_minimal'
 94                    if (
 95                        uiscale is bui.UIScale.SMALL
 96                        or playlist_select_context is not None
 97                    )
 98                    else 'menu_full'
 99                ),
100                scale=scale,
101            ),
102            transition=transition,
103            origin_widget=origin_widget,
104            # We're affected by screen size only at small ui-scale.
105            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
106        )
107
108        self._back_button: bui.Widget | None
109        if uiscale is bui.UIScale.SMALL:
110            self._back_button = None
111            bui.containerwidget(
112                edit=self._root_widget, on_cancel_call=self._on_back_press
113            )
114        else:
115            self._back_button = bui.buttonwidget(
116                parent=self._root_widget,
117                position=(59, yoffs - 45),
118                size=(60, 54),
119                scale=1.0,
120                on_activate_call=self._on_back_press,
121                autoselect=True,
122                label=bui.charstr(bui.SpecialChar.BACK),
123                button_type='backSmall',
124            )
125            bui.containerwidget(
126                edit=self._root_widget, cancel_button=self._back_button
127            )
128
129        self._title_text = bui.textwidget(
130            parent=self._root_widget,
131            position=(
132                self._width * 0.5,
133                yoffs - (45 if uiscale is bui.UIScale.SMALL else 20),
134            ),
135            size=(0, 0),
136            text=self._pvars.window_title_name,
137            scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3),
138            res_scale=1.5,
139            color=bui.app.ui_v1.heading_color,
140            h_align='center',
141            v_align='center',
142        )
143
144        self._scrollwidget = bui.scrollwidget(
145            parent=self._root_widget,
146            highlight=False,
147            size=(self._scroll_width, self._scroll_height),
148            position=(
149                self._width * 0.5 - self._scroll_width * 0.5,
150                scroll_bottom,
151            ),
152            border_opacity=0.4,
153            center_small_content_horizontally=True,
154        )
155        bui.containerwidget(edit=self._scrollwidget, claims_left_right=True)
156        self._subcontainer: bui.Widget | None = None
157        self._config_name_full = self._pvars.config_name + ' Playlists'
158        self._last_config = None
159
160        # Update now and once per second (this should do our initial
161        # refresh).
162        self._update()
163        self._update_timer = bui.AppTimer(
164            1.0, bui.WeakCall(self._update), repeat=True
165        )
166
167    @override
168    def get_main_window_state(self) -> bui.MainWindowState:
169        # Support recreating our window for back/refresh purposes.
170        cls = type(self)
171
172        # Pull things out of self here; if we do it below in the lambda
173        # then we keep self alive.
174        sessiontype = self._sessiontype
175
176        # Pull anything out of self here; if we do it in the lambda
177        # we'll inadvertanly keep self alive.
178        playlist_select_context = self._playlist_select_context
179
180        return bui.BasicMainWindowState(
181            create_call=lambda transition, origin_widget: cls(
182                transition=transition,
183                origin_widget=origin_widget,
184                sessiontype=sessiontype,
185                playlist_select_context=playlist_select_context,
186            )
187        )
188
189    @override
190    def on_main_window_close(self) -> None:
191        self._save_state()
192
193    def _ensure_standard_playlists_exist(self) -> None:
194        plus = bui.app.plus
195        assert plus is not None
196
197        # On new installations, go ahead and create a few playlists
198        # besides the hard-coded default one:
199        if not plus.get_v1_account_misc_val('madeStandardPlaylists', False):
200            plus.add_v1_account_transaction(
201                {
202                    'type': 'ADD_PLAYLIST',
203                    'playlistType': 'Free-for-All',
204                    'playlistName': bui.Lstr(
205                        resource='singleGamePlaylistNameText'
206                    )
207                    .evaluate()
208                    .replace(
209                        '${GAME}',
210                        bui.Lstr(
211                            translate=('gameNames', 'Death Match')
212                        ).evaluate(),
213                    ),
214                    'playlist': [
215                        {
216                            'type': 'bs_death_match.DeathMatchGame',
217                            'settings': {
218                                'Epic Mode': False,
219                                'Kills to Win Per Player': 10,
220                                'Respawn Times': 1.0,
221                                'Time Limit': 300,
222                                'map': 'Doom Shroom',
223                            },
224                        },
225                        {
226                            'type': 'bs_death_match.DeathMatchGame',
227                            'settings': {
228                                'Epic Mode': False,
229                                'Kills to Win Per Player': 10,
230                                'Respawn Times': 1.0,
231                                'Time Limit': 300,
232                                'map': 'Crag Castle',
233                            },
234                        },
235                    ],
236                }
237            )
238            plus.add_v1_account_transaction(
239                {
240                    'type': 'ADD_PLAYLIST',
241                    'playlistType': 'Team Tournament',
242                    'playlistName': bui.Lstr(
243                        resource='singleGamePlaylistNameText'
244                    )
245                    .evaluate()
246                    .replace(
247                        '${GAME}',
248                        bui.Lstr(
249                            translate=('gameNames', 'Capture the Flag')
250                        ).evaluate(),
251                    ),
252                    'playlist': [
253                        {
254                            'type': 'bs_capture_the_flag.CTFGame',
255                            'settings': {
256                                'map': 'Bridgit',
257                                'Score to Win': 3,
258                                'Flag Idle Return Time': 30,
259                                'Flag Touch Return Time': 0,
260                                'Respawn Times': 1.0,
261                                'Time Limit': 600,
262                                'Epic Mode': False,
263                            },
264                        },
265                        {
266                            'type': 'bs_capture_the_flag.CTFGame',
267                            'settings': {
268                                'map': 'Roundabout',
269                                'Score to Win': 2,
270                                'Flag Idle Return Time': 30,
271                                'Flag Touch Return Time': 0,
272                                'Respawn Times': 1.0,
273                                'Time Limit': 600,
274                                'Epic Mode': False,
275                            },
276                        },
277                        {
278                            'type': 'bs_capture_the_flag.CTFGame',
279                            'settings': {
280                                'map': 'Tip Top',
281                                'Score to Win': 2,
282                                'Flag Idle Return Time': 30,
283                                'Flag Touch Return Time': 3,
284                                'Respawn Times': 1.0,
285                                'Time Limit': 300,
286                                'Epic Mode': False,
287                            },
288                        },
289                    ],
290                }
291            )
292            plus.add_v1_account_transaction(
293                {
294                    'type': 'ADD_PLAYLIST',
295                    'playlistType': 'Team Tournament',
296                    'playlistName': bui.Lstr(
297                        translate=('playlistNames', 'Just Sports')
298                    ).evaluate(),
299                    'playlist': [
300                        {
301                            'type': 'bs_hockey.HockeyGame',
302                            'settings': {
303                                'Time Limit': 0,
304                                'map': 'Hockey Stadium',
305                                'Score to Win': 1,
306                                'Respawn Times': 1.0,
307                            },
308                        },
309                        {
310                            'type': 'bs_football.FootballTeamGame',
311                            'settings': {
312                                'Time Limit': 0,
313                                'map': 'Football Stadium',
314                                'Score to Win': 21,
315                                'Respawn Times': 1.0,
316                            },
317                        },
318                    ],
319                }
320            )
321            plus.add_v1_account_transaction(
322                {
323                    'type': 'ADD_PLAYLIST',
324                    'playlistType': 'Free-for-All',
325                    'playlistName': bui.Lstr(
326                        translate=('playlistNames', 'Just Epic')
327                    ).evaluate(),
328                    'playlist': [
329                        {
330                            'type': 'bs_elimination.EliminationGame',
331                            'settings': {
332                                'Time Limit': 120,
333                                'map': 'Tip Top',
334                                'Respawn Times': 1.0,
335                                'Lives Per Player': 1,
336                                'Epic Mode': 1,
337                            },
338                        }
339                    ],
340                }
341            )
342            plus.add_v1_account_transaction(
343                {
344                    'type': 'SET_MISC_VAL',
345                    'name': 'madeStandardPlaylists',
346                    'value': True,
347                }
348            )
349            plus.run_v1_account_transactions()
350
351    def _refresh(self) -> None:
352        # FIXME: Should tidy this up.
353        # pylint: disable=too-many-statements
354        # pylint: disable=too-many-branches
355        # pylint: disable=too-many-locals
356        # pylint: disable=too-many-nested-blocks
357        from efro.util import asserttype
358        from bascenev1 import get_map_class, filter_playlist
359
360        if not self._root_widget:
361            return
362        if self._subcontainer is not None:
363            self._save_state()
364            self._subcontainer.delete()
365
366        # Make sure config exists.
367        if self._config_name_full not in bui.app.config:
368            bui.app.config[self._config_name_full] = {}
369
370        items = list(bui.app.config[self._config_name_full].items())
371
372        # Make sure everything is unicode.
373        items = [
374            (i[0].decode(), i[1]) if not isinstance(i[0], str) else i
375            for i in items
376        ]
377
378        items.sort(key=lambda x2: asserttype(x2[0], str).lower())
379        items = [['__default__', None]] + items  # default is always first
380
381        button_width = 230
382        button_height = 230
383        button_buffer_h = -3
384        button_buffer_v = 0
385
386        count = len(items)
387        columns = max(
388            1,
389            math.floor(
390                self._scroll_width / (button_width + 2 * button_buffer_h)
391            ),
392        )
393        rows = int(math.ceil(float(count) / columns))
394
395        self._sub_width = columns * button_width + 2 * button_buffer_h
396
397        self._sub_height = (
398            40.0 + rows * (button_height + 2 * button_buffer_v) + 90
399        )
400        assert self._sub_width is not None
401        assert self._sub_height is not None
402        self._subcontainer = bui.containerwidget(
403            parent=self._scrollwidget,
404            size=(self._sub_width, self._sub_height),
405            background=False,
406        )
407
408        children = self._subcontainer.get_children()
409        for child in children:
410            child.delete()
411
412        # On small ui-scale, nudge 'Playlists' text to the right when
413        # we're small enough so that the back button doesn't partly
414        # obscure it.
415        uiscale = bui.app.ui_v1.uiscale
416        screensize = bui.get_virtual_screen_size()
417        xoffs = (
418            40 if uiscale is bui.UIScale.SMALL and screensize[0] < 1400 else 0
419        )
420
421        assert bui.app.classic is not None
422        bui.textwidget(
423            parent=self._subcontainer,
424            text=bui.Lstr(resource='playlistsText'),
425            position=(40 + xoffs, self._sub_height - 26),
426            size=(0, 0),
427            scale=1.0,
428            maxwidth=400,
429            color=bui.app.ui_v1.title_color,
430            h_align='left',
431            v_align='center',
432        )
433
434        index = 0
435        appconfig = bui.app.config
436
437        mesh_opaque = bui.getmesh('level_select_button_opaque')
438        mesh_transparent = bui.getmesh('level_select_button_transparent')
439        mask_tex = bui.gettexture('mapPreviewMask')
440
441        # h_offs = 225 if count == 1 else 115 if count == 2 else 0
442        h_offs = 2
443        h_offs_bottom = 0
444
445        uiscale = bui.app.ui_v1.uiscale
446        for y in range(rows):
447            for x in range(columns):
448                name = items[index][0]
449                assert name is not None
450                pos = (
451                    x * (button_width + 2 * button_buffer_h)
452                    + button_buffer_h
453                    + 8
454                    + h_offs,
455                    self._sub_height
456                    - 47
457                    - (y + 1) * (button_height + 2 * button_buffer_v),
458                )
459                btn = bui.buttonwidget(
460                    parent=self._subcontainer,
461                    button_type='square',
462                    size=(button_width, button_height),
463                    autoselect=True,
464                    label='',
465                    position=pos,
466                )
467
468                if x == 0 and uiscale is bui.UIScale.SMALL:
469                    bui.widget(
470                        edit=btn,
471                        left_widget=bui.get_special_widget('back_button'),
472                    )
473                if x == columns - 1 and uiscale is bui.UIScale.SMALL:
474                    bui.widget(
475                        edit=btn,
476                        right_widget=bui.get_special_widget('squad_button'),
477                    )
478                bui.buttonwidget(
479                    edit=btn,
480                    on_activate_call=bui.Call(
481                        self._on_playlist_press, btn, name
482                    ),
483                    on_select_call=bui.Call(self._on_playlist_select, name),
484                )
485
486                # Top row biases things up more to show header above it.
487                if y == 0:
488                    bui.widget(
489                        edit=btn, show_buffer_top=60, show_buffer_bottom=5
490                    )
491                else:
492                    bui.widget(
493                        edit=btn, show_buffer_top=30, show_buffer_bottom=30
494                    )
495
496                if self._selected_playlist == name:
497                    bui.containerwidget(
498                        edit=self._subcontainer,
499                        selected_child=btn,
500                        visible_child=btn,
501                    )
502
503                if self._back_button is not None:
504                    if y == 0:
505                        bui.widget(edit=btn, up_widget=self._back_button)
506                    if x == 0:
507                        bui.widget(edit=btn, left_widget=self._back_button)
508
509                print_name: str | bui.Lstr | None
510                if name == '__default__':
511                    print_name = self._pvars.default_list_name
512                else:
513                    print_name = name
514                bui.textwidget(
515                    parent=self._subcontainer,
516                    text=print_name,
517                    position=(
518                        pos[0] + button_width * 0.5,
519                        pos[1] + button_height * 0.79,
520                    ),
521                    size=(0, 0),
522                    scale=button_width * 0.003,
523                    maxwidth=button_width * 0.7,
524                    draw_controller=btn,
525                    h_align='center',
526                    v_align='center',
527                )
528
529                # Poke into this playlist and see if we can display some of
530                # its maps.
531                map_images = []
532                try:
533                    map_textures = []
534                    map_texture_entries = []
535                    if name == '__default__':
536                        playlist = self._pvars.get_default_list_call()
537                    else:
538                        if (
539                            name
540                            not in appconfig[
541                                self._pvars.config_name + ' Playlists'
542                            ]
543                        ):
544                            print(
545                                'NOT FOUND ERR',
546                                appconfig[
547                                    self._pvars.config_name + ' Playlists'
548                                ],
549                            )
550                        playlist = appconfig[
551                            self._pvars.config_name + ' Playlists'
552                        ][name]
553                    playlist = filter_playlist(
554                        playlist,
555                        self._sessiontype,
556                        remove_unowned=False,
557                        mark_unowned=True,
558                        name=name,
559                    )
560                    for entry in playlist:
561                        mapname = entry['settings']['map']
562                        maptype: type[bs.Map] | None
563                        try:
564                            maptype = get_map_class(mapname)
565                        except bui.NotFoundError:
566                            maptype = None
567                        if maptype is not None:
568                            tex_name = maptype.get_preview_texture_name()
569                            if tex_name is not None:
570                                map_textures.append(tex_name)
571                                map_texture_entries.append(entry)
572                        if len(map_textures) >= 6:
573                            break
574
575                    if len(map_textures) > 4:
576                        img_rows = 3
577                        img_columns = 2
578                        scl = 0.33
579                        h_offs_img = 30
580                        v_offs_img = 126
581                    elif len(map_textures) > 2:
582                        img_rows = 2
583                        img_columns = 2
584                        scl = 0.35
585                        h_offs_img = 24
586                        v_offs_img = 110
587                    elif len(map_textures) > 1:
588                        img_rows = 2
589                        img_columns = 1
590                        scl = 0.5
591                        h_offs_img = 47
592                        v_offs_img = 105
593                    else:
594                        img_rows = 1
595                        img_columns = 1
596                        scl = 0.75
597                        h_offs_img = 20
598                        v_offs_img = 65
599
600                    v = None
601                    for row in range(img_rows):
602                        for col in range(img_columns):
603                            tex_index = row * img_columns + col
604                            if tex_index < len(map_textures):
605                                entry = map_texture_entries[tex_index]
606
607                                owned = not (
608                                    (
609                                        'is_unowned_map' in entry
610                                        and entry['is_unowned_map']
611                                    )
612                                    or (
613                                        'is_unowned_game' in entry
614                                        and entry['is_unowned_game']
615                                    )
616                                )
617
618                                tex_name = map_textures[tex_index]
619                                h = pos[0] + h_offs_img + scl * 250 * col
620                                v = pos[1] + v_offs_img - scl * 130 * row
621                                map_images.append(
622                                    bui.imagewidget(
623                                        parent=self._subcontainer,
624                                        size=(scl * 250.0, scl * 125.0),
625                                        position=(h, v),
626                                        texture=bui.gettexture(tex_name),
627                                        opacity=1.0 if owned else 0.25,
628                                        draw_controller=btn,
629                                        mesh_opaque=mesh_opaque,
630                                        mesh_transparent=mesh_transparent,
631                                        mask_texture=mask_tex,
632                                    )
633                                )
634                                if not owned:
635                                    bui.imagewidget(
636                                        parent=self._subcontainer,
637                                        size=(scl * 100.0, scl * 100.0),
638                                        position=(h + scl * 75, v + scl * 10),
639                                        texture=bui.gettexture('lock'),
640                                        draw_controller=btn,
641                                    )
642                        if v is not None:
643                            v -= scl * 130.0
644
645                except Exception:
646                    logging.exception('Error listing playlist maps.')
647
648                if not map_images:
649                    bui.textwidget(
650                        parent=self._subcontainer,
651                        text='???',
652                        scale=1.5,
653                        size=(0, 0),
654                        color=(1, 1, 1, 0.5),
655                        h_align='center',
656                        v_align='center',
657                        draw_controller=btn,
658                        position=(
659                            pos[0] + button_width * 0.5,
660                            pos[1] + button_height * 0.5,
661                        ),
662                    )
663
664                index += 1
665
666                if index >= count:
667                    break
668            if index >= count:
669                break
670        self._customize_button = btn = bui.buttonwidget(
671            parent=self._subcontainer,
672            size=(100, 30),
673            position=(34 + h_offs_bottom, 50),
674            text_scale=0.6,
675            label=bui.Lstr(resource='customizeText'),
676            on_activate_call=self._on_customize_press,
677            color=(0.54, 0.52, 0.67),
678            textcolor=(0.7, 0.65, 0.7),
679            autoselect=True,
680        )
681        bui.widget(edit=btn, show_buffer_top=22, show_buffer_bottom=28)
682        self._restore_state()
683
684    def on_play_options_window_run_game(self) -> None:
685        """(internal)"""
686
687        # No-op if we're not in control.
688        if not self.main_window_has_control():
689            # if not self._root_widget:
690            return
691
692        if self._playlist_select_context is not None:
693            # Done doing a playlist selection; now back all the way out
694            # of our selection windows to our stored starting point.
695            if self._playlist_select_context.back_state is None:
696                logging.error(
697                    'No back state found'
698                    ' after playlist select context completion.'
699                )
700            else:
701                self.main_window_back_state = (
702                    self._playlist_select_context.back_state
703                )
704            self.main_window_back()
705        else:
706            # Launching a regular game session; simply get our window
707            # transitioning out.
708            self.main_window_close(transition='out_left')
709
710    def _on_playlist_select(self, playlist_name: str) -> None:
711        self._selected_playlist = playlist_name
712
713    def _update(self) -> None:
714        # Make sure config exists.
715        if self._config_name_full not in bui.app.config:
716            bui.app.config[self._config_name_full] = {}
717
718        cfg = bui.app.config[self._config_name_full]
719        if cfg != self._last_config:
720            self._last_config = copy.deepcopy(cfg)
721            self._refresh()
722
723    def _on_playlist_press(
724        self, button: bui.Widget, playlist_name: str
725    ) -> None:
726        # pylint: disable=cyclic-import
727        from bauiv1lib.playoptions import PlayOptionsWindow
728
729        # Make sure the target playlist still exists.
730        exists = (
731            playlist_name == '__default__'
732            or playlist_name in bui.app.config.get(self._config_name_full, {})
733        )
734        if not exists:
735            return
736
737        self._save_state()
738        PlayOptionsWindow(
739            sessiontype=self._sessiontype,
740            scale_origin=button.get_screen_space_center(),
741            playlist=playlist_name,
742            delegate=self,
743            playlist_select_context=self._playlist_select_context,
744        )
745
746    def _on_customize_press(self) -> None:
747        # pylint: disable=cyclic-import
748        from bauiv1lib.playlist.customizebrowser import (
749            PlaylistCustomizeBrowserWindow,
750        )
751
752        # no-op if we're not in control.
753        if not self.main_window_has_control():
754            return
755
756        self.main_window_replace(
757            PlaylistCustomizeBrowserWindow(
758                origin_widget=self._customize_button,
759                sessiontype=self._sessiontype,
760            )
761        )
762
763    def _on_back_press(self) -> None:
764        # pylint: disable=cyclic-import
765        # from bauiv1lib.play import PlayWindow
766
767        # no-op if we're not in control.
768        if not self.main_window_has_control():
769            return
770
771        # Store our selected playlist if that's changed.
772        if self._selected_playlist is not None:
773            prev_sel = bui.app.config.get(
774                self._pvars.config_name + ' Playlist Selection'
775            )
776            if self._selected_playlist != prev_sel:
777                cfg = bui.app.config
778                cfg[self._pvars.config_name + ' Playlist Selection'] = (
779                    self._selected_playlist
780                )
781                cfg.commit()
782
783        self.main_window_back()
784
785    def _save_state(self) -> None:
786        try:
787            sel = self._root_widget.get_selected_child()
788            if sel == self._back_button:
789                sel_name = 'Back'
790            elif sel == self._scrollwidget:
791                assert self._subcontainer is not None
792                subsel = self._subcontainer.get_selected_child()
793                if subsel == self._customize_button:
794                    sel_name = 'Customize'
795                else:
796                    sel_name = 'Scroll'
797            else:
798                raise RuntimeError('Unrecognized selected widget.')
799            assert bui.app.classic is not None
800            bui.app.ui_v1.window_states[type(self)] = sel_name
801        except Exception:
802            logging.exception('Error saving state for %s.', self)
803
804    def _restore_state(self) -> None:
805        try:
806            assert bui.app.classic is not None
807            sel_name = bui.app.ui_v1.window_states.get(type(self))
808            if sel_name == 'Back':
809                sel = self._back_button
810            elif sel_name == 'Scroll':
811                sel = self._scrollwidget
812            elif sel_name == 'Customize':
813                sel = self._scrollwidget
814                bui.containerwidget(
815                    edit=self._subcontainer,
816                    selected_child=self._customize_button,
817                    visible_child=self._customize_button,
818                )
819            else:
820                sel = self._scrollwidget
821            bui.containerwidget(edit=self._root_widget, selected_child=sel)
822        except Exception:
823            logging.exception('Error restoring state for %s.', self)
class PlaylistBrowserWindow(bauiv1._uitypes.MainWindow):
 20class PlaylistBrowserWindow(bui.MainWindow):
 21    """Window for starting teams games."""
 22
 23    def __init__(
 24        self,
 25        sessiontype: type[bs.Session],
 26        transition: str | None = 'in_right',
 27        origin_widget: bui.Widget | None = None,
 28        playlist_select_context: PlaylistSelectContext | None = None,
 29    ):
 30        # pylint: disable=cyclic-import
 31        from bauiv1lib.playlist import PlaylistTypeVars
 32
 33        # Store state for when we exit the next game.
 34        if issubclass(sessiontype, bs.DualTeamSession):
 35            bui.set_analytics_screen('Teams Window')
 36        elif issubclass(sessiontype, bs.FreeForAllSession):
 37            bui.set_analytics_screen('FreeForAll Window')
 38        else:
 39            raise TypeError(f'Invalid sessiontype: {sessiontype}.')
 40        self._pvars = PlaylistTypeVars(sessiontype)
 41
 42        self._sessiontype = sessiontype
 43
 44        self._customize_button: bui.Widget | None = None
 45        self._sub_width: float | None = None
 46        self._sub_height: float | None = None
 47        self._playlist_select_context = playlist_select_context
 48
 49        self._ensure_standard_playlists_exist()
 50
 51        # Get the current selection (if any).
 52        self._selected_playlist = bui.app.config.get(
 53            self._pvars.config_name + ' Playlist Selection'
 54        )
 55
 56        uiscale = bui.app.ui_v1.uiscale
 57        self._width = (
 58            1100.0
 59            if uiscale is bui.UIScale.SMALL
 60            else 800.0 if uiscale is bui.UIScale.MEDIUM else 1040
 61        )
 62        self._height = (
 63            600
 64            if uiscale is bui.UIScale.SMALL
 65            else 550 if uiscale is bui.UIScale.MEDIUM else 700
 66        )
 67
 68        # Do some fancy math to fill all available screen area up to the
 69        # size of our backing container. This lets us fit to the exact
 70        # screen shape at small ui scale.
 71        screensize = bui.get_virtual_screen_size()
 72        scale = (
 73            1.85
 74            if uiscale is bui.UIScale.SMALL
 75            else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.8
 76        )
 77        # Calc screen size in our local container space and clamp to a
 78        # bit smaller than our container size.
 79        target_width = min(self._width - 100, screensize[0] / scale)
 80        target_height = min(self._height - 100, screensize[1] / scale)
 81
 82        # To get top/left coords, go to the center of our window and
 83        # offset by half the width/height of our target area.
 84        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 85
 86        self._scroll_width = target_width
 87        self._scroll_height = target_height - 31
 88        scroll_bottom = yoffs - 60 - self._scroll_height
 89
 90        super().__init__(
 91            root_widget=bui.containerwidget(
 92                size=(self._width, self._height),
 93                toolbar_visibility=(
 94                    'menu_minimal'
 95                    if (
 96                        uiscale is bui.UIScale.SMALL
 97                        or playlist_select_context is not None
 98                    )
 99                    else 'menu_full'
100                ),
101                scale=scale,
102            ),
103            transition=transition,
104            origin_widget=origin_widget,
105            # We're affected by screen size only at small ui-scale.
106            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
107        )
108
109        self._back_button: bui.Widget | None
110        if uiscale is bui.UIScale.SMALL:
111            self._back_button = None
112            bui.containerwidget(
113                edit=self._root_widget, on_cancel_call=self._on_back_press
114            )
115        else:
116            self._back_button = bui.buttonwidget(
117                parent=self._root_widget,
118                position=(59, yoffs - 45),
119                size=(60, 54),
120                scale=1.0,
121                on_activate_call=self._on_back_press,
122                autoselect=True,
123                label=bui.charstr(bui.SpecialChar.BACK),
124                button_type='backSmall',
125            )
126            bui.containerwidget(
127                edit=self._root_widget, cancel_button=self._back_button
128            )
129
130        self._title_text = bui.textwidget(
131            parent=self._root_widget,
132            position=(
133                self._width * 0.5,
134                yoffs - (45 if uiscale is bui.UIScale.SMALL else 20),
135            ),
136            size=(0, 0),
137            text=self._pvars.window_title_name,
138            scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3),
139            res_scale=1.5,
140            color=bui.app.ui_v1.heading_color,
141            h_align='center',
142            v_align='center',
143        )
144
145        self._scrollwidget = bui.scrollwidget(
146            parent=self._root_widget,
147            highlight=False,
148            size=(self._scroll_width, self._scroll_height),
149            position=(
150                self._width * 0.5 - self._scroll_width * 0.5,
151                scroll_bottom,
152            ),
153            border_opacity=0.4,
154            center_small_content_horizontally=True,
155        )
156        bui.containerwidget(edit=self._scrollwidget, claims_left_right=True)
157        self._subcontainer: bui.Widget | None = None
158        self._config_name_full = self._pvars.config_name + ' Playlists'
159        self._last_config = None
160
161        # Update now and once per second (this should do our initial
162        # refresh).
163        self._update()
164        self._update_timer = bui.AppTimer(
165            1.0, bui.WeakCall(self._update), repeat=True
166        )
167
168    @override
169    def get_main_window_state(self) -> bui.MainWindowState:
170        # Support recreating our window for back/refresh purposes.
171        cls = type(self)
172
173        # Pull things out of self here; if we do it below in the lambda
174        # then we keep self alive.
175        sessiontype = self._sessiontype
176
177        # Pull anything out of self here; if we do it in the lambda
178        # we'll inadvertanly keep self alive.
179        playlist_select_context = self._playlist_select_context
180
181        return bui.BasicMainWindowState(
182            create_call=lambda transition, origin_widget: cls(
183                transition=transition,
184                origin_widget=origin_widget,
185                sessiontype=sessiontype,
186                playlist_select_context=playlist_select_context,
187            )
188        )
189
190    @override
191    def on_main_window_close(self) -> None:
192        self._save_state()
193
194    def _ensure_standard_playlists_exist(self) -> None:
195        plus = bui.app.plus
196        assert plus is not None
197
198        # On new installations, go ahead and create a few playlists
199        # besides the hard-coded default one:
200        if not plus.get_v1_account_misc_val('madeStandardPlaylists', False):
201            plus.add_v1_account_transaction(
202                {
203                    'type': 'ADD_PLAYLIST',
204                    'playlistType': 'Free-for-All',
205                    'playlistName': bui.Lstr(
206                        resource='singleGamePlaylistNameText'
207                    )
208                    .evaluate()
209                    .replace(
210                        '${GAME}',
211                        bui.Lstr(
212                            translate=('gameNames', 'Death Match')
213                        ).evaluate(),
214                    ),
215                    'playlist': [
216                        {
217                            'type': 'bs_death_match.DeathMatchGame',
218                            'settings': {
219                                'Epic Mode': False,
220                                'Kills to Win Per Player': 10,
221                                'Respawn Times': 1.0,
222                                'Time Limit': 300,
223                                'map': 'Doom Shroom',
224                            },
225                        },
226                        {
227                            'type': 'bs_death_match.DeathMatchGame',
228                            'settings': {
229                                'Epic Mode': False,
230                                'Kills to Win Per Player': 10,
231                                'Respawn Times': 1.0,
232                                'Time Limit': 300,
233                                'map': 'Crag Castle',
234                            },
235                        },
236                    ],
237                }
238            )
239            plus.add_v1_account_transaction(
240                {
241                    'type': 'ADD_PLAYLIST',
242                    'playlistType': 'Team Tournament',
243                    'playlistName': bui.Lstr(
244                        resource='singleGamePlaylistNameText'
245                    )
246                    .evaluate()
247                    .replace(
248                        '${GAME}',
249                        bui.Lstr(
250                            translate=('gameNames', 'Capture the Flag')
251                        ).evaluate(),
252                    ),
253                    'playlist': [
254                        {
255                            'type': 'bs_capture_the_flag.CTFGame',
256                            'settings': {
257                                'map': 'Bridgit',
258                                'Score to Win': 3,
259                                'Flag Idle Return Time': 30,
260                                'Flag Touch Return Time': 0,
261                                'Respawn Times': 1.0,
262                                'Time Limit': 600,
263                                'Epic Mode': False,
264                            },
265                        },
266                        {
267                            'type': 'bs_capture_the_flag.CTFGame',
268                            'settings': {
269                                'map': 'Roundabout',
270                                'Score to Win': 2,
271                                'Flag Idle Return Time': 30,
272                                'Flag Touch Return Time': 0,
273                                'Respawn Times': 1.0,
274                                'Time Limit': 600,
275                                'Epic Mode': False,
276                            },
277                        },
278                        {
279                            'type': 'bs_capture_the_flag.CTFGame',
280                            'settings': {
281                                'map': 'Tip Top',
282                                'Score to Win': 2,
283                                'Flag Idle Return Time': 30,
284                                'Flag Touch Return Time': 3,
285                                'Respawn Times': 1.0,
286                                'Time Limit': 300,
287                                'Epic Mode': False,
288                            },
289                        },
290                    ],
291                }
292            )
293            plus.add_v1_account_transaction(
294                {
295                    'type': 'ADD_PLAYLIST',
296                    'playlistType': 'Team Tournament',
297                    'playlistName': bui.Lstr(
298                        translate=('playlistNames', 'Just Sports')
299                    ).evaluate(),
300                    'playlist': [
301                        {
302                            'type': 'bs_hockey.HockeyGame',
303                            'settings': {
304                                'Time Limit': 0,
305                                'map': 'Hockey Stadium',
306                                'Score to Win': 1,
307                                'Respawn Times': 1.0,
308                            },
309                        },
310                        {
311                            'type': 'bs_football.FootballTeamGame',
312                            'settings': {
313                                'Time Limit': 0,
314                                'map': 'Football Stadium',
315                                'Score to Win': 21,
316                                'Respawn Times': 1.0,
317                            },
318                        },
319                    ],
320                }
321            )
322            plus.add_v1_account_transaction(
323                {
324                    'type': 'ADD_PLAYLIST',
325                    'playlistType': 'Free-for-All',
326                    'playlistName': bui.Lstr(
327                        translate=('playlistNames', 'Just Epic')
328                    ).evaluate(),
329                    'playlist': [
330                        {
331                            'type': 'bs_elimination.EliminationGame',
332                            'settings': {
333                                'Time Limit': 120,
334                                'map': 'Tip Top',
335                                'Respawn Times': 1.0,
336                                'Lives Per Player': 1,
337                                'Epic Mode': 1,
338                            },
339                        }
340                    ],
341                }
342            )
343            plus.add_v1_account_transaction(
344                {
345                    'type': 'SET_MISC_VAL',
346                    'name': 'madeStandardPlaylists',
347                    'value': True,
348                }
349            )
350            plus.run_v1_account_transactions()
351
352    def _refresh(self) -> None:
353        # FIXME: Should tidy this up.
354        # pylint: disable=too-many-statements
355        # pylint: disable=too-many-branches
356        # pylint: disable=too-many-locals
357        # pylint: disable=too-many-nested-blocks
358        from efro.util import asserttype
359        from bascenev1 import get_map_class, filter_playlist
360
361        if not self._root_widget:
362            return
363        if self._subcontainer is not None:
364            self._save_state()
365            self._subcontainer.delete()
366
367        # Make sure config exists.
368        if self._config_name_full not in bui.app.config:
369            bui.app.config[self._config_name_full] = {}
370
371        items = list(bui.app.config[self._config_name_full].items())
372
373        # Make sure everything is unicode.
374        items = [
375            (i[0].decode(), i[1]) if not isinstance(i[0], str) else i
376            for i in items
377        ]
378
379        items.sort(key=lambda x2: asserttype(x2[0], str).lower())
380        items = [['__default__', None]] + items  # default is always first
381
382        button_width = 230
383        button_height = 230
384        button_buffer_h = -3
385        button_buffer_v = 0
386
387        count = len(items)
388        columns = max(
389            1,
390            math.floor(
391                self._scroll_width / (button_width + 2 * button_buffer_h)
392            ),
393        )
394        rows = int(math.ceil(float(count) / columns))
395
396        self._sub_width = columns * button_width + 2 * button_buffer_h
397
398        self._sub_height = (
399            40.0 + rows * (button_height + 2 * button_buffer_v) + 90
400        )
401        assert self._sub_width is not None
402        assert self._sub_height is not None
403        self._subcontainer = bui.containerwidget(
404            parent=self._scrollwidget,
405            size=(self._sub_width, self._sub_height),
406            background=False,
407        )
408
409        children = self._subcontainer.get_children()
410        for child in children:
411            child.delete()
412
413        # On small ui-scale, nudge 'Playlists' text to the right when
414        # we're small enough so that the back button doesn't partly
415        # obscure it.
416        uiscale = bui.app.ui_v1.uiscale
417        screensize = bui.get_virtual_screen_size()
418        xoffs = (
419            40 if uiscale is bui.UIScale.SMALL and screensize[0] < 1400 else 0
420        )
421
422        assert bui.app.classic is not None
423        bui.textwidget(
424            parent=self._subcontainer,
425            text=bui.Lstr(resource='playlistsText'),
426            position=(40 + xoffs, self._sub_height - 26),
427            size=(0, 0),
428            scale=1.0,
429            maxwidth=400,
430            color=bui.app.ui_v1.title_color,
431            h_align='left',
432            v_align='center',
433        )
434
435        index = 0
436        appconfig = bui.app.config
437
438        mesh_opaque = bui.getmesh('level_select_button_opaque')
439        mesh_transparent = bui.getmesh('level_select_button_transparent')
440        mask_tex = bui.gettexture('mapPreviewMask')
441
442        # h_offs = 225 if count == 1 else 115 if count == 2 else 0
443        h_offs = 2
444        h_offs_bottom = 0
445
446        uiscale = bui.app.ui_v1.uiscale
447        for y in range(rows):
448            for x in range(columns):
449                name = items[index][0]
450                assert name is not None
451                pos = (
452                    x * (button_width + 2 * button_buffer_h)
453                    + button_buffer_h
454                    + 8
455                    + h_offs,
456                    self._sub_height
457                    - 47
458                    - (y + 1) * (button_height + 2 * button_buffer_v),
459                )
460                btn = bui.buttonwidget(
461                    parent=self._subcontainer,
462                    button_type='square',
463                    size=(button_width, button_height),
464                    autoselect=True,
465                    label='',
466                    position=pos,
467                )
468
469                if x == 0 and uiscale is bui.UIScale.SMALL:
470                    bui.widget(
471                        edit=btn,
472                        left_widget=bui.get_special_widget('back_button'),
473                    )
474                if x == columns - 1 and uiscale is bui.UIScale.SMALL:
475                    bui.widget(
476                        edit=btn,
477                        right_widget=bui.get_special_widget('squad_button'),
478                    )
479                bui.buttonwidget(
480                    edit=btn,
481                    on_activate_call=bui.Call(
482                        self._on_playlist_press, btn, name
483                    ),
484                    on_select_call=bui.Call(self._on_playlist_select, name),
485                )
486
487                # Top row biases things up more to show header above it.
488                if y == 0:
489                    bui.widget(
490                        edit=btn, show_buffer_top=60, show_buffer_bottom=5
491                    )
492                else:
493                    bui.widget(
494                        edit=btn, show_buffer_top=30, show_buffer_bottom=30
495                    )
496
497                if self._selected_playlist == name:
498                    bui.containerwidget(
499                        edit=self._subcontainer,
500                        selected_child=btn,
501                        visible_child=btn,
502                    )
503
504                if self._back_button is not None:
505                    if y == 0:
506                        bui.widget(edit=btn, up_widget=self._back_button)
507                    if x == 0:
508                        bui.widget(edit=btn, left_widget=self._back_button)
509
510                print_name: str | bui.Lstr | None
511                if name == '__default__':
512                    print_name = self._pvars.default_list_name
513                else:
514                    print_name = name
515                bui.textwidget(
516                    parent=self._subcontainer,
517                    text=print_name,
518                    position=(
519                        pos[0] + button_width * 0.5,
520                        pos[1] + button_height * 0.79,
521                    ),
522                    size=(0, 0),
523                    scale=button_width * 0.003,
524                    maxwidth=button_width * 0.7,
525                    draw_controller=btn,
526                    h_align='center',
527                    v_align='center',
528                )
529
530                # Poke into this playlist and see if we can display some of
531                # its maps.
532                map_images = []
533                try:
534                    map_textures = []
535                    map_texture_entries = []
536                    if name == '__default__':
537                        playlist = self._pvars.get_default_list_call()
538                    else:
539                        if (
540                            name
541                            not in appconfig[
542                                self._pvars.config_name + ' Playlists'
543                            ]
544                        ):
545                            print(
546                                'NOT FOUND ERR',
547                                appconfig[
548                                    self._pvars.config_name + ' Playlists'
549                                ],
550                            )
551                        playlist = appconfig[
552                            self._pvars.config_name + ' Playlists'
553                        ][name]
554                    playlist = filter_playlist(
555                        playlist,
556                        self._sessiontype,
557                        remove_unowned=False,
558                        mark_unowned=True,
559                        name=name,
560                    )
561                    for entry in playlist:
562                        mapname = entry['settings']['map']
563                        maptype: type[bs.Map] | None
564                        try:
565                            maptype = get_map_class(mapname)
566                        except bui.NotFoundError:
567                            maptype = None
568                        if maptype is not None:
569                            tex_name = maptype.get_preview_texture_name()
570                            if tex_name is not None:
571                                map_textures.append(tex_name)
572                                map_texture_entries.append(entry)
573                        if len(map_textures) >= 6:
574                            break
575
576                    if len(map_textures) > 4:
577                        img_rows = 3
578                        img_columns = 2
579                        scl = 0.33
580                        h_offs_img = 30
581                        v_offs_img = 126
582                    elif len(map_textures) > 2:
583                        img_rows = 2
584                        img_columns = 2
585                        scl = 0.35
586                        h_offs_img = 24
587                        v_offs_img = 110
588                    elif len(map_textures) > 1:
589                        img_rows = 2
590                        img_columns = 1
591                        scl = 0.5
592                        h_offs_img = 47
593                        v_offs_img = 105
594                    else:
595                        img_rows = 1
596                        img_columns = 1
597                        scl = 0.75
598                        h_offs_img = 20
599                        v_offs_img = 65
600
601                    v = None
602                    for row in range(img_rows):
603                        for col in range(img_columns):
604                            tex_index = row * img_columns + col
605                            if tex_index < len(map_textures):
606                                entry = map_texture_entries[tex_index]
607
608                                owned = not (
609                                    (
610                                        'is_unowned_map' in entry
611                                        and entry['is_unowned_map']
612                                    )
613                                    or (
614                                        'is_unowned_game' in entry
615                                        and entry['is_unowned_game']
616                                    )
617                                )
618
619                                tex_name = map_textures[tex_index]
620                                h = pos[0] + h_offs_img + scl * 250 * col
621                                v = pos[1] + v_offs_img - scl * 130 * row
622                                map_images.append(
623                                    bui.imagewidget(
624                                        parent=self._subcontainer,
625                                        size=(scl * 250.0, scl * 125.0),
626                                        position=(h, v),
627                                        texture=bui.gettexture(tex_name),
628                                        opacity=1.0 if owned else 0.25,
629                                        draw_controller=btn,
630                                        mesh_opaque=mesh_opaque,
631                                        mesh_transparent=mesh_transparent,
632                                        mask_texture=mask_tex,
633                                    )
634                                )
635                                if not owned:
636                                    bui.imagewidget(
637                                        parent=self._subcontainer,
638                                        size=(scl * 100.0, scl * 100.0),
639                                        position=(h + scl * 75, v + scl * 10),
640                                        texture=bui.gettexture('lock'),
641                                        draw_controller=btn,
642                                    )
643                        if v is not None:
644                            v -= scl * 130.0
645
646                except Exception:
647                    logging.exception('Error listing playlist maps.')
648
649                if not map_images:
650                    bui.textwidget(
651                        parent=self._subcontainer,
652                        text='???',
653                        scale=1.5,
654                        size=(0, 0),
655                        color=(1, 1, 1, 0.5),
656                        h_align='center',
657                        v_align='center',
658                        draw_controller=btn,
659                        position=(
660                            pos[0] + button_width * 0.5,
661                            pos[1] + button_height * 0.5,
662                        ),
663                    )
664
665                index += 1
666
667                if index >= count:
668                    break
669            if index >= count:
670                break
671        self._customize_button = btn = bui.buttonwidget(
672            parent=self._subcontainer,
673            size=(100, 30),
674            position=(34 + h_offs_bottom, 50),
675            text_scale=0.6,
676            label=bui.Lstr(resource='customizeText'),
677            on_activate_call=self._on_customize_press,
678            color=(0.54, 0.52, 0.67),
679            textcolor=(0.7, 0.65, 0.7),
680            autoselect=True,
681        )
682        bui.widget(edit=btn, show_buffer_top=22, show_buffer_bottom=28)
683        self._restore_state()
684
685    def on_play_options_window_run_game(self) -> None:
686        """(internal)"""
687
688        # No-op if we're not in control.
689        if not self.main_window_has_control():
690            # if not self._root_widget:
691            return
692
693        if self._playlist_select_context is not None:
694            # Done doing a playlist selection; now back all the way out
695            # of our selection windows to our stored starting point.
696            if self._playlist_select_context.back_state is None:
697                logging.error(
698                    'No back state found'
699                    ' after playlist select context completion.'
700                )
701            else:
702                self.main_window_back_state = (
703                    self._playlist_select_context.back_state
704                )
705            self.main_window_back()
706        else:
707            # Launching a regular game session; simply get our window
708            # transitioning out.
709            self.main_window_close(transition='out_left')
710
711    def _on_playlist_select(self, playlist_name: str) -> None:
712        self._selected_playlist = playlist_name
713
714    def _update(self) -> None:
715        # Make sure config exists.
716        if self._config_name_full not in bui.app.config:
717            bui.app.config[self._config_name_full] = {}
718
719        cfg = bui.app.config[self._config_name_full]
720        if cfg != self._last_config:
721            self._last_config = copy.deepcopy(cfg)
722            self._refresh()
723
724    def _on_playlist_press(
725        self, button: bui.Widget, playlist_name: str
726    ) -> None:
727        # pylint: disable=cyclic-import
728        from bauiv1lib.playoptions import PlayOptionsWindow
729
730        # Make sure the target playlist still exists.
731        exists = (
732            playlist_name == '__default__'
733            or playlist_name in bui.app.config.get(self._config_name_full, {})
734        )
735        if not exists:
736            return
737
738        self._save_state()
739        PlayOptionsWindow(
740            sessiontype=self._sessiontype,
741            scale_origin=button.get_screen_space_center(),
742            playlist=playlist_name,
743            delegate=self,
744            playlist_select_context=self._playlist_select_context,
745        )
746
747    def _on_customize_press(self) -> None:
748        # pylint: disable=cyclic-import
749        from bauiv1lib.playlist.customizebrowser import (
750            PlaylistCustomizeBrowserWindow,
751        )
752
753        # no-op if we're not in control.
754        if not self.main_window_has_control():
755            return
756
757        self.main_window_replace(
758            PlaylistCustomizeBrowserWindow(
759                origin_widget=self._customize_button,
760                sessiontype=self._sessiontype,
761            )
762        )
763
764    def _on_back_press(self) -> None:
765        # pylint: disable=cyclic-import
766        # from bauiv1lib.play import PlayWindow
767
768        # no-op if we're not in control.
769        if not self.main_window_has_control():
770            return
771
772        # Store our selected playlist if that's changed.
773        if self._selected_playlist is not None:
774            prev_sel = bui.app.config.get(
775                self._pvars.config_name + ' Playlist Selection'
776            )
777            if self._selected_playlist != prev_sel:
778                cfg = bui.app.config
779                cfg[self._pvars.config_name + ' Playlist Selection'] = (
780                    self._selected_playlist
781                )
782                cfg.commit()
783
784        self.main_window_back()
785
786    def _save_state(self) -> None:
787        try:
788            sel = self._root_widget.get_selected_child()
789            if sel == self._back_button:
790                sel_name = 'Back'
791            elif sel == self._scrollwidget:
792                assert self._subcontainer is not None
793                subsel = self._subcontainer.get_selected_child()
794                if subsel == self._customize_button:
795                    sel_name = 'Customize'
796                else:
797                    sel_name = 'Scroll'
798            else:
799                raise RuntimeError('Unrecognized selected widget.')
800            assert bui.app.classic is not None
801            bui.app.ui_v1.window_states[type(self)] = sel_name
802        except Exception:
803            logging.exception('Error saving state for %s.', self)
804
805    def _restore_state(self) -> None:
806        try:
807            assert bui.app.classic is not None
808            sel_name = bui.app.ui_v1.window_states.get(type(self))
809            if sel_name == 'Back':
810                sel = self._back_button
811            elif sel_name == 'Scroll':
812                sel = self._scrollwidget
813            elif sel_name == 'Customize':
814                sel = self._scrollwidget
815                bui.containerwidget(
816                    edit=self._subcontainer,
817                    selected_child=self._customize_button,
818                    visible_child=self._customize_button,
819                )
820            else:
821                sel = self._scrollwidget
822            bui.containerwidget(edit=self._root_widget, selected_child=sel)
823        except Exception:
824            logging.exception('Error restoring state for %s.', self)

Window for starting teams games.

PlaylistBrowserWindow( sessiontype: type[bascenev1.Session], transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None, playlist_select_context: bauiv1lib.play.PlaylistSelectContext | None = None)
 23    def __init__(
 24        self,
 25        sessiontype: type[bs.Session],
 26        transition: str | None = 'in_right',
 27        origin_widget: bui.Widget | None = None,
 28        playlist_select_context: PlaylistSelectContext | None = None,
 29    ):
 30        # pylint: disable=cyclic-import
 31        from bauiv1lib.playlist import PlaylistTypeVars
 32
 33        # Store state for when we exit the next game.
 34        if issubclass(sessiontype, bs.DualTeamSession):
 35            bui.set_analytics_screen('Teams Window')
 36        elif issubclass(sessiontype, bs.FreeForAllSession):
 37            bui.set_analytics_screen('FreeForAll Window')
 38        else:
 39            raise TypeError(f'Invalid sessiontype: {sessiontype}.')
 40        self._pvars = PlaylistTypeVars(sessiontype)
 41
 42        self._sessiontype = sessiontype
 43
 44        self._customize_button: bui.Widget | None = None
 45        self._sub_width: float | None = None
 46        self._sub_height: float | None = None
 47        self._playlist_select_context = playlist_select_context
 48
 49        self._ensure_standard_playlists_exist()
 50
 51        # Get the current selection (if any).
 52        self._selected_playlist = bui.app.config.get(
 53            self._pvars.config_name + ' Playlist Selection'
 54        )
 55
 56        uiscale = bui.app.ui_v1.uiscale
 57        self._width = (
 58            1100.0
 59            if uiscale is bui.UIScale.SMALL
 60            else 800.0 if uiscale is bui.UIScale.MEDIUM else 1040
 61        )
 62        self._height = (
 63            600
 64            if uiscale is bui.UIScale.SMALL
 65            else 550 if uiscale is bui.UIScale.MEDIUM else 700
 66        )
 67
 68        # Do some fancy math to fill all available screen area up to the
 69        # size of our backing container. This lets us fit to the exact
 70        # screen shape at small ui scale.
 71        screensize = bui.get_virtual_screen_size()
 72        scale = (
 73            1.85
 74            if uiscale is bui.UIScale.SMALL
 75            else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.8
 76        )
 77        # Calc screen size in our local container space and clamp to a
 78        # bit smaller than our container size.
 79        target_width = min(self._width - 100, screensize[0] / scale)
 80        target_height = min(self._height - 100, screensize[1] / scale)
 81
 82        # To get top/left coords, go to the center of our window and
 83        # offset by half the width/height of our target area.
 84        yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 85
 86        self._scroll_width = target_width
 87        self._scroll_height = target_height - 31
 88        scroll_bottom = yoffs - 60 - self._scroll_height
 89
 90        super().__init__(
 91            root_widget=bui.containerwidget(
 92                size=(self._width, self._height),
 93                toolbar_visibility=(
 94                    'menu_minimal'
 95                    if (
 96                        uiscale is bui.UIScale.SMALL
 97                        or playlist_select_context is not None
 98                    )
 99                    else 'menu_full'
100                ),
101                scale=scale,
102            ),
103            transition=transition,
104            origin_widget=origin_widget,
105            # We're affected by screen size only at small ui-scale.
106            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
107        )
108
109        self._back_button: bui.Widget | None
110        if uiscale is bui.UIScale.SMALL:
111            self._back_button = None
112            bui.containerwidget(
113                edit=self._root_widget, on_cancel_call=self._on_back_press
114            )
115        else:
116            self._back_button = bui.buttonwidget(
117                parent=self._root_widget,
118                position=(59, yoffs - 45),
119                size=(60, 54),
120                scale=1.0,
121                on_activate_call=self._on_back_press,
122                autoselect=True,
123                label=bui.charstr(bui.SpecialChar.BACK),
124                button_type='backSmall',
125            )
126            bui.containerwidget(
127                edit=self._root_widget, cancel_button=self._back_button
128            )
129
130        self._title_text = bui.textwidget(
131            parent=self._root_widget,
132            position=(
133                self._width * 0.5,
134                yoffs - (45 if uiscale is bui.UIScale.SMALL else 20),
135            ),
136            size=(0, 0),
137            text=self._pvars.window_title_name,
138            scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3),
139            res_scale=1.5,
140            color=bui.app.ui_v1.heading_color,
141            h_align='center',
142            v_align='center',
143        )
144
145        self._scrollwidget = bui.scrollwidget(
146            parent=self._root_widget,
147            highlight=False,
148            size=(self._scroll_width, self._scroll_height),
149            position=(
150                self._width * 0.5 - self._scroll_width * 0.5,
151                scroll_bottom,
152            ),
153            border_opacity=0.4,
154            center_small_content_horizontally=True,
155        )
156        bui.containerwidget(edit=self._scrollwidget, claims_left_right=True)
157        self._subcontainer: bui.Widget | None = None
158        self._config_name_full = self._pvars.config_name + ' Playlists'
159        self._last_config = None
160
161        # Update now and once per second (this should do our initial
162        # refresh).
163        self._update()
164        self._update_timer = bui.AppTimer(
165            1.0, bui.WeakCall(self._update), repeat=True
166        )

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.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
168    @override
169    def get_main_window_state(self) -> bui.MainWindowState:
170        # Support recreating our window for back/refresh purposes.
171        cls = type(self)
172
173        # Pull things out of self here; if we do it below in the lambda
174        # then we keep self alive.
175        sessiontype = self._sessiontype
176
177        # Pull anything out of self here; if we do it in the lambda
178        # we'll inadvertanly keep self alive.
179        playlist_select_context = self._playlist_select_context
180
181        return bui.BasicMainWindowState(
182            create_call=lambda transition, origin_widget: cls(
183                transition=transition,
184                origin_widget=origin_widget,
185                sessiontype=sessiontype,
186                playlist_select_context=playlist_select_context,
187            )
188        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
190    @override
191    def on_main_window_close(self) -> None:
192        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.