bauiv1lib.gather.privatetab

Defines the Private tab in the gather UI.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3# pylint: disable=too-many-lines
   4"""Defines the Private tab in the gather UI."""
   5
   6from __future__ import annotations
   7
   8import os
   9import copy
  10import time
  11import logging
  12from enum import Enum
  13from dataclasses import dataclass
  14from typing import TYPE_CHECKING, cast
  15
  16from typing_extensions import override
  17from efro.dataclassio import dataclass_from_dict, dataclass_to_dict
  18from bacommon.net import (
  19    PrivateHostingState,
  20    PrivateHostingConfig,
  21    PrivatePartyConnectResult,
  22)
  23from bauiv1lib.gather import GatherTab
  24from bauiv1lib.getcurrency import GetCurrencyWindow, show_get_tickets_prompt
  25import bascenev1 as bs
  26import bauiv1 as bui
  27
  28if TYPE_CHECKING:
  29    from typing import Any
  30
  31    from bauiv1lib.gather import GatherWindow
  32
  33
  34# Print a bit of info about queries, etc.
  35DEBUG_SERVER_COMMUNICATION = os.environ.get('BA_DEBUG_PPTABCOM') == '1'
  36
  37
  38class SubTabType(Enum):
  39    """Available sub-tabs."""
  40
  41    JOIN = 'join'
  42    HOST = 'host'
  43
  44
  45@dataclass
  46class State:
  47    """Our core state that persists while the app is running."""
  48
  49    sub_tab: SubTabType = SubTabType.JOIN
  50
  51
  52class PrivateGatherTab(GatherTab):
  53    """The private tab in the gather UI"""
  54
  55    def __init__(self, window: GatherWindow) -> None:
  56        super().__init__(window)
  57        self._container: bui.Widget | None = None
  58        self._state: State = State()
  59        self._hostingstate = PrivateHostingState()
  60        self._join_sub_tab_text: bui.Widget | None = None
  61        self._host_sub_tab_text: bui.Widget | None = None
  62        self._update_timer: bui.AppTimer | None = None
  63        self._join_party_code_text: bui.Widget | None = None
  64        self._c_width: float = 0.0
  65        self._c_height: float = 0.0
  66        self._last_hosting_state_query_time: float | None = None
  67        self._waiting_for_initial_state = True
  68        self._waiting_for_start_stop_response = True
  69        self._host_playlist_button: bui.Widget | None = None
  70        self._host_copy_button: bui.Widget | None = None
  71        self._host_connect_button: bui.Widget | None = None
  72        self._host_start_stop_button: bui.Widget | None = None
  73        self._get_tickets_button: bui.Widget | None = None
  74        self._ticket_count_text: bui.Widget | None = None
  75        self._showing_not_signed_in_screen = False
  76        self._create_time = time.time()
  77        self._last_action_send_time: float | None = None
  78        self._connect_press_time: float | None = None
  79        try:
  80            self._hostingconfig = self._build_hosting_config()
  81        except Exception:
  82            logging.exception('Error building hosting config.')
  83            self._hostingconfig = PrivateHostingConfig()
  84
  85    @override
  86    def on_activate(
  87        self,
  88        parent_widget: bui.Widget,
  89        tab_button: bui.Widget,
  90        region_width: float,
  91        region_height: float,
  92        region_left: float,
  93        region_bottom: float,
  94    ) -> bui.Widget:
  95        self._c_width = region_width
  96        self._c_height = region_height - 20
  97        self._container = bui.containerwidget(
  98            parent=parent_widget,
  99            position=(
 100                region_left,
 101                region_bottom + (region_height - self._c_height) * 0.5,
 102            ),
 103            size=(self._c_width, self._c_height),
 104            background=False,
 105            selection_loops_to_parent=True,
 106        )
 107        v = self._c_height - 30.0
 108        self._join_sub_tab_text = bui.textwidget(
 109            parent=self._container,
 110            position=(self._c_width * 0.5 - 245, v - 13),
 111            color=(0.6, 1.0, 0.6),
 112            scale=1.3,
 113            size=(200, 30),
 114            maxwidth=250,
 115            h_align='left',
 116            v_align='center',
 117            click_activate=True,
 118            selectable=True,
 119            autoselect=True,
 120            on_activate_call=lambda: self._set_sub_tab(
 121                SubTabType.JOIN,
 122                playsound=True,
 123            ),
 124            text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'),
 125            glow_type='uniform',
 126        )
 127        self._host_sub_tab_text = bui.textwidget(
 128            parent=self._container,
 129            position=(self._c_width * 0.5 + 45, v - 13),
 130            color=(0.6, 1.0, 0.6),
 131            scale=1.3,
 132            size=(200, 30),
 133            maxwidth=250,
 134            h_align='left',
 135            v_align='center',
 136            click_activate=True,
 137            selectable=True,
 138            autoselect=True,
 139            on_activate_call=lambda: self._set_sub_tab(
 140                SubTabType.HOST,
 141                playsound=True,
 142            ),
 143            text=bui.Lstr(resource='gatherWindow.privatePartyHostText'),
 144            glow_type='uniform',
 145        )
 146        bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
 147        bui.widget(
 148            edit=self._host_sub_tab_text,
 149            left_widget=self._join_sub_tab_text,
 150            up_widget=tab_button,
 151        )
 152        bui.widget(
 153            edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text
 154        )
 155
 156        self._update_timer = bui.AppTimer(
 157            1.0, bui.WeakCall(self._update), repeat=True
 158        )
 159
 160        # Prevent taking any action until we've updated our state.
 161        self._waiting_for_initial_state = True
 162
 163        # This will get a state query sent out immediately.
 164        self._last_action_send_time = None  # Ensure we don't ignore response.
 165        self._last_hosting_state_query_time = None
 166        self._update()
 167
 168        self._set_sub_tab(self._state.sub_tab)
 169
 170        return self._container
 171
 172    def _build_hosting_config(self) -> PrivateHostingConfig:
 173        # pylint: disable=too-many-branches
 174        # pylint: disable=too-many-locals
 175        from bauiv1lib.playlist import PlaylistTypeVars
 176        from bascenev1 import filter_playlist
 177
 178        hcfg = PrivateHostingConfig()
 179        cfg = bui.app.config
 180        sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa')
 181        if not isinstance(sessiontypestr, str):
 182            raise RuntimeError(f'Invalid sessiontype {sessiontypestr}')
 183        hcfg.session_type = sessiontypestr
 184
 185        sessiontype: type[bs.Session]
 186        if hcfg.session_type == 'ffa':
 187            sessiontype = bs.FreeForAllSession
 188        elif hcfg.session_type == 'teams':
 189            sessiontype = bs.DualTeamSession
 190        else:
 191            raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}')
 192        pvars = PlaylistTypeVars(sessiontype)
 193
 194        playlist_name = bui.app.config.get(
 195            f'{pvars.config_name} Playlist Selection'
 196        )
 197        if not isinstance(playlist_name, str):
 198            playlist_name = '__default__'
 199        hcfg.playlist_name = (
 200            pvars.default_list_name.evaluate()
 201            if playlist_name == '__default__'
 202            else playlist_name
 203        )
 204
 205        playlist: list[dict[str, Any]] | None = None
 206        if playlist_name != '__default__':
 207            playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get(
 208                playlist_name
 209            )
 210        if playlist is None:
 211            playlist = pvars.get_default_list_call()
 212
 213        hcfg.playlist = filter_playlist(
 214            playlist, sessiontype, name=playlist_name
 215        )
 216
 217        randomize = cfg.get(f'{pvars.config_name} Playlist Randomize')
 218        if not isinstance(randomize, bool):
 219            randomize = False
 220        hcfg.randomize = randomize
 221
 222        tutorial = cfg.get('Show Tutorial')
 223        if not isinstance(tutorial, bool):
 224            tutorial = True
 225        hcfg.tutorial = tutorial
 226
 227        if hcfg.session_type == 'teams':
 228            ctn: list[str] | None = cfg.get('Custom Team Names')
 229            if ctn is not None:
 230                ctn_any: Any = ctn  # Actual value may not match type checker.
 231                if (
 232                    isinstance(ctn_any, (list, tuple))
 233                    and len(ctn) == 2
 234                    and all(isinstance(x, str) for x in ctn)
 235                ):
 236                    hcfg.custom_team_names = (ctn[0], ctn[1])
 237                else:
 238                    print(f'Found invalid custom-team-names data: {ctn}')
 239
 240            ctc: list[list[float]] | None = cfg.get('Custom Team Colors')
 241            if ctc is not None:
 242                ctc_any: Any = ctc  # Actual value may not match type checker.
 243                if (
 244                    isinstance(ctc_any, (list, tuple))
 245                    and len(ctc) == 2
 246                    and all(isinstance(x, (list, tuple)) for x in ctc)
 247                    and all(len(x) == 3 for x in ctc)
 248                ):
 249                    hcfg.custom_team_colors = (
 250                        (ctc[0][0], ctc[0][1], ctc[0][2]),
 251                        (ctc[1][0], ctc[1][1], ctc[1][2]),
 252                    )
 253                else:
 254                    print(f'Found invalid custom-team-colors data: {ctc}')
 255
 256        return hcfg
 257
 258    @override
 259    def on_deactivate(self) -> None:
 260        self._update_timer = None
 261
 262    def _update_currency_ui(self) -> None:
 263        # Keep currency count up to date if applicable.
 264        plus = bui.app.plus
 265        assert plus is not None
 266
 267        try:
 268            t_str = str(plus.get_v1_account_ticket_count())
 269        except Exception:
 270            t_str = '?'
 271        if self._get_tickets_button:
 272            bui.buttonwidget(
 273                edit=self._get_tickets_button,
 274                label=bui.charstr(bui.SpecialChar.TICKET) + t_str,
 275            )
 276        if self._ticket_count_text:
 277            bui.textwidget(
 278                edit=self._ticket_count_text,
 279                text=bui.charstr(bui.SpecialChar.TICKET) + t_str,
 280            )
 281
 282    def _update(self) -> None:
 283        """Periodic updating."""
 284
 285        plus = bui.app.plus
 286        assert plus is not None
 287
 288        now = bui.apptime()
 289
 290        self._update_currency_ui()
 291
 292        if self._state.sub_tab is SubTabType.HOST:
 293            # If we're not signed in, just refresh to show that.
 294            if (
 295                plus.get_v1_account_state() != 'signed_in'
 296                and self._showing_not_signed_in_screen
 297            ):
 298                self._refresh_sub_tab()
 299            else:
 300                # Query an updated state periodically.
 301                if (
 302                    self._last_hosting_state_query_time is None
 303                    or now - self._last_hosting_state_query_time > 15.0
 304                ):
 305                    self._debug_server_comm('querying private party state')
 306                    if plus.get_v1_account_state() == 'signed_in':
 307                        plus.add_v1_account_transaction(
 308                            {
 309                                'type': 'PRIVATE_PARTY_QUERY',
 310                                'expire_time': time.time() + 20,
 311                            },
 312                            callback=bui.WeakCall(
 313                                self._hosting_state_idle_response
 314                            ),
 315                        )
 316                        plus.run_v1_account_transactions()
 317                    else:
 318                        self._hosting_state_idle_response(None)
 319                    self._last_hosting_state_query_time = now
 320
 321    def _hosting_state_idle_response(
 322        self, result: dict[str, Any] | None
 323    ) -> None:
 324        # This simply passes through to our standard response handler.
 325        # The one exception is if we've recently sent an action to the
 326        # server (start/stop hosting/etc.) In that case we want to ignore
 327        # idle background updates and wait for the response to our action.
 328        # (this keeps the button showing 'one moment...' until the change
 329        # takes effect, etc.)
 330        if (
 331            self._last_action_send_time is not None
 332            and time.time() - self._last_action_send_time < 5.0
 333        ):
 334            self._debug_server_comm(
 335                'ignoring private party state response due to recent action'
 336            )
 337            return
 338        self._hosting_state_response(result)
 339
 340    def _hosting_state_response(self, result: dict[str, Any] | None) -> None:
 341        # Its possible for this to come back to us after our UI is dead;
 342        # ignore in that case.
 343        if not self._container:
 344            return
 345
 346        state: PrivateHostingState | None = None
 347        if result is not None:
 348            self._debug_server_comm('got private party state response')
 349            try:
 350                state = dataclass_from_dict(
 351                    PrivateHostingState, result, discard_unknown_attrs=True
 352                )
 353            except Exception:
 354                logging.exception('Got invalid PrivateHostingState data')
 355        else:
 356            self._debug_server_comm('private party state response errored')
 357
 358        # Hmm I guess let's just ignore failed responses?...
 359        # Or should we show some sort of error state to the user?...
 360        if result is None or state is None:
 361            return
 362
 363        self._waiting_for_initial_state = False
 364        self._waiting_for_start_stop_response = False
 365        self._hostingstate = state
 366        self._refresh_sub_tab()
 367
 368    def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None:
 369        assert self._container
 370        if playsound:
 371            bui.getsound('click01').play()
 372
 373        # If switching from join to host, do a fresh state query.
 374        if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST:
 375            # Prevent taking any action until we've gotten a fresh state.
 376            self._waiting_for_initial_state = True
 377
 378            # This will get a state query sent out immediately.
 379            self._last_hosting_state_query_time = None
 380            self._last_action_send_time = None  # So we don't ignore response.
 381            self._update()
 382
 383        self._state.sub_tab = value
 384        active_color = (0.6, 1.0, 0.6)
 385        inactive_color = (0.5, 0.4, 0.5)
 386        bui.textwidget(
 387            edit=self._join_sub_tab_text,
 388            color=active_color if value is SubTabType.JOIN else inactive_color,
 389        )
 390        bui.textwidget(
 391            edit=self._host_sub_tab_text,
 392            color=active_color if value is SubTabType.HOST else inactive_color,
 393        )
 394
 395        self._refresh_sub_tab()
 396
 397        # Kick off an update to get any needed messages sent/etc.
 398        bui.pushcall(self._update)
 399
 400    def _selwidgets(self) -> list[bui.Widget | None]:
 401        """An indexed list of widgets we can use for saving/restoring sel."""
 402        return [
 403            self._host_playlist_button,
 404            self._host_copy_button,
 405            self._host_connect_button,
 406            self._host_start_stop_button,
 407            self._get_tickets_button,
 408        ]
 409
 410    def _refresh_sub_tab(self) -> None:
 411        assert self._container
 412
 413        # Store an index for our current selection so we can
 414        # reselect the equivalent recreated widget if possible.
 415        selindex: int | None = None
 416        selchild = self._container.get_selected_child()
 417        if selchild is not None:
 418            try:
 419                selindex = self._selwidgets().index(selchild)
 420            except ValueError:
 421                pass
 422
 423        # Clear anything existing in the old sub-tab.
 424        for widget in self._container.get_children():
 425            if widget and widget not in {
 426                self._host_sub_tab_text,
 427                self._join_sub_tab_text,
 428            }:
 429                widget.delete()
 430
 431        if self._state.sub_tab is SubTabType.JOIN:
 432            self._build_join_tab()
 433        elif self._state.sub_tab is SubTabType.HOST:
 434            self._build_host_tab()
 435        else:
 436            raise RuntimeError('Invalid state.')
 437
 438        # Select the new equivalent widget if there is one.
 439        if selindex is not None:
 440            selwidget = self._selwidgets()[selindex]
 441            if selwidget:
 442                bui.containerwidget(
 443                    edit=self._container, selected_child=selwidget
 444                )
 445
 446    def _build_join_tab(self) -> None:
 447        bui.textwidget(
 448            parent=self._container,
 449            position=(self._c_width * 0.5, self._c_height - 140),
 450            color=(0.5, 0.46, 0.5),
 451            scale=1.5,
 452            size=(0, 0),
 453            maxwidth=250,
 454            h_align='center',
 455            v_align='center',
 456            text=bui.Lstr(resource='gatherWindow.partyCodeText'),
 457        )
 458
 459        self._join_party_code_text = bui.textwidget(
 460            parent=self._container,
 461            position=(self._c_width * 0.5 - 150, self._c_height - 250),
 462            flatness=1.0,
 463            scale=1.5,
 464            size=(300, 50),
 465            editable=True,
 466            max_chars=20,
 467            description=bui.Lstr(resource='gatherWindow.partyCodeText'),
 468            autoselect=True,
 469            h_align='left',
 470            v_align='center',
 471            text='',
 472        )
 473        btn = bui.buttonwidget(
 474            parent=self._container,
 475            size=(300, 70),
 476            label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'),
 477            position=(self._c_width * 0.5 - 150, self._c_height - 350),
 478            on_activate_call=self._join_connect_press,
 479            autoselect=True,
 480        )
 481        bui.textwidget(
 482            edit=self._join_party_code_text, on_return_press_call=btn.activate
 483        )
 484
 485    def _on_get_tickets_press(self) -> None:
 486        if self._waiting_for_start_stop_response:
 487            return
 488
 489        # Bring up get-tickets window and then kill ourself (we're on the
 490        # overlay layer so we'd show up above it).
 491        GetCurrencyWindow(modal=True, origin_widget=self._get_tickets_button)
 492
 493    def _build_host_tab(self) -> None:
 494        # pylint: disable=too-many-branches
 495        # pylint: disable=too-many-statements
 496        assert bui.app.classic is not None
 497
 498        plus = bui.app.plus
 499        assert plus is not None
 500
 501        hostingstate = self._hostingstate
 502        if plus.get_v1_account_state() != 'signed_in':
 503            bui.textwidget(
 504                parent=self._container,
 505                size=(0, 0),
 506                h_align='center',
 507                v_align='center',
 508                maxwidth=200,
 509                scale=0.8,
 510                color=(0.6, 0.56, 0.6),
 511                position=(self._c_width * 0.5, self._c_height * 0.5),
 512                text=bui.Lstr(resource='notSignedInErrorText'),
 513            )
 514            self._showing_not_signed_in_screen = True
 515            return
 516        self._showing_not_signed_in_screen = False
 517
 518        # At first we don't want to show anything until we've gotten a state.
 519        # Update: In this situation we now simply show our existing state
 520        # but give the start/stop button a loading message and disallow its
 521        # use. This keeps things a lot less jumpy looking and allows selecting
 522        # playlists/etc without having to wait for the server each time
 523        # back to the ui.
 524        if self._waiting_for_initial_state and bool(False):
 525            bui.textwidget(
 526                parent=self._container,
 527                size=(0, 0),
 528                h_align='center',
 529                v_align='center',
 530                maxwidth=200,
 531                scale=0.8,
 532                color=(0.6, 0.56, 0.6),
 533                position=(self._c_width * 0.5, self._c_height * 0.5),
 534                text=bui.Lstr(
 535                    value='${A}...',
 536                    subs=[('${A}', bui.Lstr(resource='store.loadingText'))],
 537                ),
 538            )
 539            return
 540
 541        # If we're not currently hosting and hosting requires tickets,
 542        # Show our count (possibly with a link to purchase more).
 543        if (
 544            not self._waiting_for_initial_state
 545            and hostingstate.party_code is None
 546            and hostingstate.tickets_to_host_now != 0
 547        ):
 548            if not bui.app.ui_v1.use_toolbars:
 549                if bui.app.classic.allow_ticket_purchases:
 550                    self._get_tickets_button = bui.buttonwidget(
 551                        parent=self._container,
 552                        position=(
 553                            self._c_width - 210 + 125,
 554                            self._c_height - 44,
 555                        ),
 556                        autoselect=True,
 557                        scale=0.6,
 558                        size=(120, 60),
 559                        textcolor=(0.2, 1, 0.2),
 560                        label=bui.charstr(bui.SpecialChar.TICKET),
 561                        color=(0.65, 0.5, 0.8),
 562                        on_activate_call=self._on_get_tickets_press,
 563                    )
 564                else:
 565                    self._ticket_count_text = bui.textwidget(
 566                        parent=self._container,
 567                        scale=0.6,
 568                        position=(
 569                            self._c_width - 210 + 125,
 570                            self._c_height - 44,
 571                        ),
 572                        color=(0.2, 1, 0.2),
 573                        h_align='center',
 574                        v_align='center',
 575                    )
 576                # Set initial ticket count.
 577                self._update_currency_ui()
 578
 579        v = self._c_height - 90
 580        if hostingstate.party_code is None:
 581            bui.textwidget(
 582                parent=self._container,
 583                size=(0, 0),
 584                h_align='center',
 585                v_align='center',
 586                maxwidth=self._c_width * 0.9,
 587                scale=0.7,
 588                flatness=1.0,
 589                color=(0.5, 0.46, 0.5),
 590                position=(self._c_width * 0.5, v),
 591                text=bui.Lstr(
 592                    resource='gatherWindow.privatePartyCloudDescriptionText'
 593                ),
 594            )
 595
 596        v -= 100
 597        if hostingstate.party_code is None:
 598            # We've got no current party running; show options to set one up.
 599            bui.textwidget(
 600                parent=self._container,
 601                size=(0, 0),
 602                h_align='right',
 603                v_align='center',
 604                maxwidth=200,
 605                scale=0.8,
 606                color=(0.6, 0.56, 0.6),
 607                position=(self._c_width * 0.5 - 210, v),
 608                text=bui.Lstr(resource='playlistText'),
 609            )
 610            self._host_playlist_button = bui.buttonwidget(
 611                parent=self._container,
 612                size=(400, 70),
 613                color=(0.6, 0.5, 0.6),
 614                textcolor=(0.8, 0.75, 0.8),
 615                label=self._hostingconfig.playlist_name,
 616                on_activate_call=self._playlist_press,
 617                position=(self._c_width * 0.5 - 200, v - 35),
 618                up_widget=self._host_sub_tab_text,
 619                autoselect=True,
 620            )
 621
 622            # If it appears we're coming back from playlist selection,
 623            # re-select our playlist button.
 624            if bui.app.ui_v1.selecting_private_party_playlist:
 625                bui.containerwidget(
 626                    edit=self._container,
 627                    selected_child=self._host_playlist_button,
 628                )
 629                bui.app.ui_v1.selecting_private_party_playlist = False
 630        else:
 631            # We've got a current party; show its info.
 632            bui.textwidget(
 633                parent=self._container,
 634                size=(0, 0),
 635                h_align='center',
 636                v_align='center',
 637                maxwidth=600,
 638                scale=0.9,
 639                color=(0.7, 0.64, 0.7),
 640                position=(self._c_width * 0.5, v + 90),
 641                text=bui.Lstr(resource='gatherWindow.partyServerRunningText'),
 642            )
 643            bui.textwidget(
 644                parent=self._container,
 645                size=(0, 0),
 646                h_align='center',
 647                v_align='center',
 648                maxwidth=600,
 649                scale=0.7,
 650                color=(0.7, 0.64, 0.7),
 651                position=(self._c_width * 0.5, v + 50),
 652                text=bui.Lstr(resource='gatherWindow.partyCodeText'),
 653            )
 654            bui.textwidget(
 655                parent=self._container,
 656                size=(0, 0),
 657                h_align='center',
 658                v_align='center',
 659                scale=2.0,
 660                color=(0.0, 1.0, 0.0),
 661                position=(self._c_width * 0.5, v + 10),
 662                text=hostingstate.party_code,
 663            )
 664
 665            # Also action buttons to copy it and connect to it.
 666            if bui.clipboard_is_supported():
 667                cbtnoffs = 10
 668                self._host_copy_button = bui.buttonwidget(
 669                    parent=self._container,
 670                    size=(140, 40),
 671                    color=(0.6, 0.5, 0.6),
 672                    textcolor=(0.8, 0.75, 0.8),
 673                    label=bui.Lstr(resource='gatherWindow.copyCodeText'),
 674                    on_activate_call=self._host_copy_press,
 675                    position=(self._c_width * 0.5 - 150, v - 70),
 676                    autoselect=True,
 677                )
 678            else:
 679                cbtnoffs = -70
 680            self._host_connect_button = bui.buttonwidget(
 681                parent=self._container,
 682                size=(140, 40),
 683                color=(0.6, 0.5, 0.6),
 684                textcolor=(0.8, 0.75, 0.8),
 685                label=bui.Lstr(resource='gatherWindow.manualConnectText'),
 686                on_activate_call=self._host_connect_press,
 687                position=(self._c_width * 0.5 + cbtnoffs, v - 70),
 688                autoselect=True,
 689            )
 690
 691        v -= 120
 692
 693        # Line above the main action button:
 694
 695        # If we don't want to show anything until we get a state:
 696        if self._waiting_for_initial_state:
 697            pass
 698        elif hostingstate.unavailable_error is not None:
 699            # If hosting is unavailable, show the associated reason.
 700            bui.textwidget(
 701                parent=self._container,
 702                size=(0, 0),
 703                h_align='center',
 704                v_align='center',
 705                maxwidth=self._c_width * 0.9,
 706                scale=0.7,
 707                flatness=1.0,
 708                color=(1.0, 0.0, 0.0),
 709                position=(self._c_width * 0.5, v),
 710                text=bui.Lstr(
 711                    translate=(
 712                        'serverResponses',
 713                        hostingstate.unavailable_error,
 714                    )
 715                ),
 716            )
 717        elif hostingstate.free_host_minutes_remaining is not None:
 718            # If we've been pre-approved to start/stop for free, show that.
 719            bui.textwidget(
 720                parent=self._container,
 721                size=(0, 0),
 722                h_align='center',
 723                v_align='center',
 724                maxwidth=self._c_width * 0.9,
 725                scale=0.7,
 726                flatness=1.0,
 727                color=(
 728                    (0.7, 0.64, 0.7)
 729                    if hostingstate.party_code
 730                    else (0.0, 1.0, 0.0)
 731                ),
 732                position=(self._c_width * 0.5, v),
 733                text=bui.Lstr(
 734                    resource='gatherWindow.startStopHostingMinutesText',
 735                    subs=[
 736                        (
 737                            '${MINUTES}',
 738                            f'{hostingstate.free_host_minutes_remaining:.0f}',
 739                        )
 740                    ],
 741                ),
 742            )
 743        else:
 744            # Otherwise tell whether the free cloud server is available
 745            # or will be at some point.
 746            if hostingstate.party_code is None:
 747                if hostingstate.tickets_to_host_now == 0:
 748                    bui.textwidget(
 749                        parent=self._container,
 750                        size=(0, 0),
 751                        h_align='center',
 752                        v_align='center',
 753                        maxwidth=self._c_width * 0.9,
 754                        scale=0.7,
 755                        flatness=1.0,
 756                        color=(0.0, 1.0, 0.0),
 757                        position=(self._c_width * 0.5, v),
 758                        text=bui.Lstr(
 759                            resource=(
 760                                'gatherWindow.freeCloudServerAvailableNowText'
 761                            )
 762                        ),
 763                    )
 764                else:
 765                    if hostingstate.minutes_until_free_host is None:
 766                        bui.textwidget(
 767                            parent=self._container,
 768                            size=(0, 0),
 769                            h_align='center',
 770                            v_align='center',
 771                            maxwidth=self._c_width * 0.9,
 772                            scale=0.7,
 773                            flatness=1.0,
 774                            color=(1.0, 0.6, 0.0),
 775                            position=(self._c_width * 0.5, v),
 776                            text=bui.Lstr(
 777                                resource=(
 778                                    'gatherWindow'
 779                                    '.freeCloudServerNotAvailableText'
 780                                )
 781                            ),
 782                        )
 783                    else:
 784                        availmins = hostingstate.minutes_until_free_host
 785                        bui.textwidget(
 786                            parent=self._container,
 787                            size=(0, 0),
 788                            h_align='center',
 789                            v_align='center',
 790                            maxwidth=self._c_width * 0.9,
 791                            scale=0.7,
 792                            flatness=1.0,
 793                            color=(1.0, 0.6, 0.0),
 794                            position=(self._c_width * 0.5, v),
 795                            text=bui.Lstr(
 796                                resource='gatherWindow.'
 797                                'freeCloudServerAvailableMinutesText',
 798                                subs=[('${MINUTES}', f'{availmins:.0f}')],
 799                            ),
 800                        )
 801
 802        v -= 100
 803
 804        if (
 805            self._waiting_for_start_stop_response
 806            or self._waiting_for_initial_state
 807        ):
 808            btnlabel = bui.Lstr(resource='oneMomentText')
 809        else:
 810            if hostingstate.unavailable_error is not None:
 811                btnlabel = bui.Lstr(
 812                    resource='gatherWindow.hostingUnavailableText'
 813                )
 814            elif hostingstate.party_code is None:
 815                ticon = bui.charstr(bui.SpecialChar.TICKET)
 816                nowtickets = hostingstate.tickets_to_host_now
 817                if nowtickets > 0:
 818                    btnlabel = bui.Lstr(
 819                        resource='gatherWindow.startHostingPaidText',
 820                        subs=[('${COST}', f'{ticon}{nowtickets}')],
 821                    )
 822                else:
 823                    btnlabel = bui.Lstr(
 824                        resource='gatherWindow.startHostingText'
 825                    )
 826            else:
 827                btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText')
 828
 829        disabled = (
 830            hostingstate.unavailable_error is not None
 831            or self._waiting_for_initial_state
 832        )
 833        waiting = self._waiting_for_start_stop_response
 834        self._host_start_stop_button = bui.buttonwidget(
 835            parent=self._container,
 836            size=(400, 80),
 837            color=(
 838                (0.6, 0.6, 0.6)
 839                if disabled
 840                else (0.5, 1.0, 0.5)
 841                if waiting
 842                else None
 843            ),
 844            enable_sound=False,
 845            label=btnlabel,
 846            textcolor=((0.7, 0.7, 0.7) if disabled else None),
 847            position=(self._c_width * 0.5 - 200, v),
 848            on_activate_call=self._start_stop_button_press,
 849            autoselect=True,
 850        )
 851
 852    def _playlist_press(self) -> None:
 853        assert self._host_playlist_button is not None
 854        self.window.playlist_select(origin_widget=self._host_playlist_button)
 855
 856    def _host_copy_press(self) -> None:
 857        assert self._hostingstate.party_code is not None
 858        bui.clipboard_set_text(self._hostingstate.party_code)
 859        bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText'))
 860
 861    def _host_connect_press(self) -> None:
 862        assert self._hostingstate.party_code is not None
 863        self._connect_to_party_code(self._hostingstate.party_code)
 864
 865    def _debug_server_comm(self, msg: str) -> None:
 866        if DEBUG_SERVER_COMMUNICATION:
 867            print(
 868                f'PPTABCOM: {msg} at time '
 869                f'{time.time()-self._create_time:.2f}'
 870            )
 871
 872    def _connect_to_party_code(self, code: str) -> None:
 873        # Ignore attempted followup sends for a few seconds.
 874        # (this will reset if we get a response)
 875        plus = bui.app.plus
 876        assert plus is not None
 877
 878        now = time.time()
 879        if (
 880            self._connect_press_time is not None
 881            and now - self._connect_press_time < 5.0
 882        ):
 883            self._debug_server_comm(
 884                'not sending private party connect (too soon)'
 885            )
 886            return
 887        self._connect_press_time = now
 888
 889        self._debug_server_comm('sending private party connect')
 890        plus.add_v1_account_transaction(
 891            {
 892                'type': 'PRIVATE_PARTY_CONNECT',
 893                'expire_time': time.time() + 20,
 894                'code': code,
 895            },
 896            callback=bui.WeakCall(self._connect_response),
 897        )
 898        plus.run_v1_account_transactions()
 899
 900    def _start_stop_button_press(self) -> None:
 901        plus = bui.app.plus
 902        assert plus is not None
 903        if (
 904            self._waiting_for_start_stop_response
 905            or self._waiting_for_initial_state
 906        ):
 907            return
 908
 909        if plus.get_v1_account_state() != 'signed_in':
 910            bui.screenmessage(bui.Lstr(resource='notSignedInErrorText'))
 911            bui.getsound('error').play()
 912            self._refresh_sub_tab()
 913            return
 914
 915        if self._hostingstate.unavailable_error is not None:
 916            bui.getsound('error').play()
 917            return
 918
 919        bui.getsound('click01').play()
 920
 921        # If we're not hosting, start.
 922        if self._hostingstate.party_code is None:
 923            # If there's a ticket cost, make sure we have enough tickets.
 924            if self._hostingstate.tickets_to_host_now > 0:
 925                ticket_count: int | None
 926                try:
 927                    ticket_count = plus.get_v1_account_ticket_count()
 928                except Exception:
 929                    # FIXME: should add a bui.NotSignedInError we can use here.
 930                    ticket_count = None
 931                ticket_cost = self._hostingstate.tickets_to_host_now
 932                if ticket_count is not None and ticket_count < ticket_cost:
 933                    show_get_tickets_prompt()
 934                    bui.getsound('error').play()
 935                    return
 936            self._last_action_send_time = time.time()
 937            plus.add_v1_account_transaction(
 938                {
 939                    'type': 'PRIVATE_PARTY_START',
 940                    'config': dataclass_to_dict(self._hostingconfig),
 941                    'region_pings': bui.app.net.zone_pings,
 942                    'expire_time': time.time() + 20,
 943                },
 944                callback=bui.WeakCall(self._hosting_state_response),
 945            )
 946            plus.run_v1_account_transactions()
 947
 948        else:
 949            self._last_action_send_time = time.time()
 950            plus.add_v1_account_transaction(
 951                {
 952                    'type': 'PRIVATE_PARTY_STOP',
 953                    'expire_time': time.time() + 20,
 954                },
 955                callback=bui.WeakCall(self._hosting_state_response),
 956            )
 957            plus.run_v1_account_transactions()
 958        bui.getsound('click01').play()
 959
 960        self._waiting_for_start_stop_response = True
 961        self._refresh_sub_tab()
 962
 963    def _join_connect_press(self) -> None:
 964        # Error immediately if its an empty code.
 965        code: str | None = None
 966        if self._join_party_code_text:
 967            code = cast(str, bui.textwidget(query=self._join_party_code_text))
 968        if not code:
 969            bui.screenmessage(
 970                bui.Lstr(translate=('serverResponses', 'Invalid code.')),
 971                color=(1, 0, 0),
 972            )
 973            bui.getsound('error').play()
 974            return
 975
 976        self._connect_to_party_code(code)
 977
 978    def _connect_response(self, result: dict[str, Any] | None) -> None:
 979        try:
 980            self._connect_press_time = None
 981            if result is None:
 982                raise RuntimeError()
 983            cresult = dataclass_from_dict(
 984                PrivatePartyConnectResult, result, discard_unknown_attrs=True
 985            )
 986            if cresult.error is not None:
 987                self._debug_server_comm('got error connect response')
 988                bui.screenmessage(
 989                    bui.Lstr(translate=('serverResponses', cresult.error)),
 990                    (1, 0, 0),
 991                )
 992                bui.getsound('error').play()
 993                return
 994            self._debug_server_comm('got valid connect response')
 995            assert cresult.addr is not None and cresult.port is not None
 996            bs.connect_to_party(cresult.addr, port=cresult.port)
 997        except Exception:
 998            self._debug_server_comm('got connect response error')
 999            bui.getsound('error').play()
1000
1001    @override
1002    def save_state(self) -> None:
1003        assert bui.app.classic is not None
1004        bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)
1005
1006    @override
1007    def restore_state(self) -> None:
1008        assert bui.app.classic is not None
1009        state = bui.app.ui_v1.window_states.get(type(self))
1010        if state is None:
1011            state = State()
1012        assert isinstance(state, State)
1013        self._state = state
DEBUG_SERVER_COMMUNICATION = False
class SubTabType(enum.Enum):
39class SubTabType(Enum):
40    """Available sub-tabs."""
41
42    JOIN = 'join'
43    HOST = 'host'

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class State:
46@dataclass
47class State:
48    """Our core state that persists while the app is running."""
49
50    sub_tab: SubTabType = SubTabType.JOIN

Our core state that persists while the app is running.

State( sub_tab: SubTabType = <SubTabType.JOIN: 'join'>)
sub_tab: SubTabType = <SubTabType.JOIN: 'join'>
class PrivateGatherTab(bauiv1lib.gather.GatherTab):
  53class PrivateGatherTab(GatherTab):
  54    """The private tab in the gather UI"""
  55
  56    def __init__(self, window: GatherWindow) -> None:
  57        super().__init__(window)
  58        self._container: bui.Widget | None = None
  59        self._state: State = State()
  60        self._hostingstate = PrivateHostingState()
  61        self._join_sub_tab_text: bui.Widget | None = None
  62        self._host_sub_tab_text: bui.Widget | None = None
  63        self._update_timer: bui.AppTimer | None = None
  64        self._join_party_code_text: bui.Widget | None = None
  65        self._c_width: float = 0.0
  66        self._c_height: float = 0.0
  67        self._last_hosting_state_query_time: float | None = None
  68        self._waiting_for_initial_state = True
  69        self._waiting_for_start_stop_response = True
  70        self._host_playlist_button: bui.Widget | None = None
  71        self._host_copy_button: bui.Widget | None = None
  72        self._host_connect_button: bui.Widget | None = None
  73        self._host_start_stop_button: bui.Widget | None = None
  74        self._get_tickets_button: bui.Widget | None = None
  75        self._ticket_count_text: bui.Widget | None = None
  76        self._showing_not_signed_in_screen = False
  77        self._create_time = time.time()
  78        self._last_action_send_time: float | None = None
  79        self._connect_press_time: float | None = None
  80        try:
  81            self._hostingconfig = self._build_hosting_config()
  82        except Exception:
  83            logging.exception('Error building hosting config.')
  84            self._hostingconfig = PrivateHostingConfig()
  85
  86    @override
  87    def on_activate(
  88        self,
  89        parent_widget: bui.Widget,
  90        tab_button: bui.Widget,
  91        region_width: float,
  92        region_height: float,
  93        region_left: float,
  94        region_bottom: float,
  95    ) -> bui.Widget:
  96        self._c_width = region_width
  97        self._c_height = region_height - 20
  98        self._container = bui.containerwidget(
  99            parent=parent_widget,
 100            position=(
 101                region_left,
 102                region_bottom + (region_height - self._c_height) * 0.5,
 103            ),
 104            size=(self._c_width, self._c_height),
 105            background=False,
 106            selection_loops_to_parent=True,
 107        )
 108        v = self._c_height - 30.0
 109        self._join_sub_tab_text = bui.textwidget(
 110            parent=self._container,
 111            position=(self._c_width * 0.5 - 245, v - 13),
 112            color=(0.6, 1.0, 0.6),
 113            scale=1.3,
 114            size=(200, 30),
 115            maxwidth=250,
 116            h_align='left',
 117            v_align='center',
 118            click_activate=True,
 119            selectable=True,
 120            autoselect=True,
 121            on_activate_call=lambda: self._set_sub_tab(
 122                SubTabType.JOIN,
 123                playsound=True,
 124            ),
 125            text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'),
 126            glow_type='uniform',
 127        )
 128        self._host_sub_tab_text = bui.textwidget(
 129            parent=self._container,
 130            position=(self._c_width * 0.5 + 45, v - 13),
 131            color=(0.6, 1.0, 0.6),
 132            scale=1.3,
 133            size=(200, 30),
 134            maxwidth=250,
 135            h_align='left',
 136            v_align='center',
 137            click_activate=True,
 138            selectable=True,
 139            autoselect=True,
 140            on_activate_call=lambda: self._set_sub_tab(
 141                SubTabType.HOST,
 142                playsound=True,
 143            ),
 144            text=bui.Lstr(resource='gatherWindow.privatePartyHostText'),
 145            glow_type='uniform',
 146        )
 147        bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
 148        bui.widget(
 149            edit=self._host_sub_tab_text,
 150            left_widget=self._join_sub_tab_text,
 151            up_widget=tab_button,
 152        )
 153        bui.widget(
 154            edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text
 155        )
 156
 157        self._update_timer = bui.AppTimer(
 158            1.0, bui.WeakCall(self._update), repeat=True
 159        )
 160
 161        # Prevent taking any action until we've updated our state.
 162        self._waiting_for_initial_state = True
 163
 164        # This will get a state query sent out immediately.
 165        self._last_action_send_time = None  # Ensure we don't ignore response.
 166        self._last_hosting_state_query_time = None
 167        self._update()
 168
 169        self._set_sub_tab(self._state.sub_tab)
 170
 171        return self._container
 172
 173    def _build_hosting_config(self) -> PrivateHostingConfig:
 174        # pylint: disable=too-many-branches
 175        # pylint: disable=too-many-locals
 176        from bauiv1lib.playlist import PlaylistTypeVars
 177        from bascenev1 import filter_playlist
 178
 179        hcfg = PrivateHostingConfig()
 180        cfg = bui.app.config
 181        sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa')
 182        if not isinstance(sessiontypestr, str):
 183            raise RuntimeError(f'Invalid sessiontype {sessiontypestr}')
 184        hcfg.session_type = sessiontypestr
 185
 186        sessiontype: type[bs.Session]
 187        if hcfg.session_type == 'ffa':
 188            sessiontype = bs.FreeForAllSession
 189        elif hcfg.session_type == 'teams':
 190            sessiontype = bs.DualTeamSession
 191        else:
 192            raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}')
 193        pvars = PlaylistTypeVars(sessiontype)
 194
 195        playlist_name = bui.app.config.get(
 196            f'{pvars.config_name} Playlist Selection'
 197        )
 198        if not isinstance(playlist_name, str):
 199            playlist_name = '__default__'
 200        hcfg.playlist_name = (
 201            pvars.default_list_name.evaluate()
 202            if playlist_name == '__default__'
 203            else playlist_name
 204        )
 205
 206        playlist: list[dict[str, Any]] | None = None
 207        if playlist_name != '__default__':
 208            playlist = cfg.get(f'{pvars.config_name} Playlists', {}).get(
 209                playlist_name
 210            )
 211        if playlist is None:
 212            playlist = pvars.get_default_list_call()
 213
 214        hcfg.playlist = filter_playlist(
 215            playlist, sessiontype, name=playlist_name
 216        )
 217
 218        randomize = cfg.get(f'{pvars.config_name} Playlist Randomize')
 219        if not isinstance(randomize, bool):
 220            randomize = False
 221        hcfg.randomize = randomize
 222
 223        tutorial = cfg.get('Show Tutorial')
 224        if not isinstance(tutorial, bool):
 225            tutorial = True
 226        hcfg.tutorial = tutorial
 227
 228        if hcfg.session_type == 'teams':
 229            ctn: list[str] | None = cfg.get('Custom Team Names')
 230            if ctn is not None:
 231                ctn_any: Any = ctn  # Actual value may not match type checker.
 232                if (
 233                    isinstance(ctn_any, (list, tuple))
 234                    and len(ctn) == 2
 235                    and all(isinstance(x, str) for x in ctn)
 236                ):
 237                    hcfg.custom_team_names = (ctn[0], ctn[1])
 238                else:
 239                    print(f'Found invalid custom-team-names data: {ctn}')
 240
 241            ctc: list[list[float]] | None = cfg.get('Custom Team Colors')
 242            if ctc is not None:
 243                ctc_any: Any = ctc  # Actual value may not match type checker.
 244                if (
 245                    isinstance(ctc_any, (list, tuple))
 246                    and len(ctc) == 2
 247                    and all(isinstance(x, (list, tuple)) for x in ctc)
 248                    and all(len(x) == 3 for x in ctc)
 249                ):
 250                    hcfg.custom_team_colors = (
 251                        (ctc[0][0], ctc[0][1], ctc[0][2]),
 252                        (ctc[1][0], ctc[1][1], ctc[1][2]),
 253                    )
 254                else:
 255                    print(f'Found invalid custom-team-colors data: {ctc}')
 256
 257        return hcfg
 258
 259    @override
 260    def on_deactivate(self) -> None:
 261        self._update_timer = None
 262
 263    def _update_currency_ui(self) -> None:
 264        # Keep currency count up to date if applicable.
 265        plus = bui.app.plus
 266        assert plus is not None
 267
 268        try:
 269            t_str = str(plus.get_v1_account_ticket_count())
 270        except Exception:
 271            t_str = '?'
 272        if self._get_tickets_button:
 273            bui.buttonwidget(
 274                edit=self._get_tickets_button,
 275                label=bui.charstr(bui.SpecialChar.TICKET) + t_str,
 276            )
 277        if self._ticket_count_text:
 278            bui.textwidget(
 279                edit=self._ticket_count_text,
 280                text=bui.charstr(bui.SpecialChar.TICKET) + t_str,
 281            )
 282
 283    def _update(self) -> None:
 284        """Periodic updating."""
 285
 286        plus = bui.app.plus
 287        assert plus is not None
 288
 289        now = bui.apptime()
 290
 291        self._update_currency_ui()
 292
 293        if self._state.sub_tab is SubTabType.HOST:
 294            # If we're not signed in, just refresh to show that.
 295            if (
 296                plus.get_v1_account_state() != 'signed_in'
 297                and self._showing_not_signed_in_screen
 298            ):
 299                self._refresh_sub_tab()
 300            else:
 301                # Query an updated state periodically.
 302                if (
 303                    self._last_hosting_state_query_time is None
 304                    or now - self._last_hosting_state_query_time > 15.0
 305                ):
 306                    self._debug_server_comm('querying private party state')
 307                    if plus.get_v1_account_state() == 'signed_in':
 308                        plus.add_v1_account_transaction(
 309                            {
 310                                'type': 'PRIVATE_PARTY_QUERY',
 311                                'expire_time': time.time() + 20,
 312                            },
 313                            callback=bui.WeakCall(
 314                                self._hosting_state_idle_response
 315                            ),
 316                        )
 317                        plus.run_v1_account_transactions()
 318                    else:
 319                        self._hosting_state_idle_response(None)
 320                    self._last_hosting_state_query_time = now
 321
 322    def _hosting_state_idle_response(
 323        self, result: dict[str, Any] | None
 324    ) -> None:
 325        # This simply passes through to our standard response handler.
 326        # The one exception is if we've recently sent an action to the
 327        # server (start/stop hosting/etc.) In that case we want to ignore
 328        # idle background updates and wait for the response to our action.
 329        # (this keeps the button showing 'one moment...' until the change
 330        # takes effect, etc.)
 331        if (
 332            self._last_action_send_time is not None
 333            and time.time() - self._last_action_send_time < 5.0
 334        ):
 335            self._debug_server_comm(
 336                'ignoring private party state response due to recent action'
 337            )
 338            return
 339        self._hosting_state_response(result)
 340
 341    def _hosting_state_response(self, result: dict[str, Any] | None) -> None:
 342        # Its possible for this to come back to us after our UI is dead;
 343        # ignore in that case.
 344        if not self._container:
 345            return
 346
 347        state: PrivateHostingState | None = None
 348        if result is not None:
 349            self._debug_server_comm('got private party state response')
 350            try:
 351                state = dataclass_from_dict(
 352                    PrivateHostingState, result, discard_unknown_attrs=True
 353                )
 354            except Exception:
 355                logging.exception('Got invalid PrivateHostingState data')
 356        else:
 357            self._debug_server_comm('private party state response errored')
 358
 359        # Hmm I guess let's just ignore failed responses?...
 360        # Or should we show some sort of error state to the user?...
 361        if result is None or state is None:
 362            return
 363
 364        self._waiting_for_initial_state = False
 365        self._waiting_for_start_stop_response = False
 366        self._hostingstate = state
 367        self._refresh_sub_tab()
 368
 369    def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None:
 370        assert self._container
 371        if playsound:
 372            bui.getsound('click01').play()
 373
 374        # If switching from join to host, do a fresh state query.
 375        if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST:
 376            # Prevent taking any action until we've gotten a fresh state.
 377            self._waiting_for_initial_state = True
 378
 379            # This will get a state query sent out immediately.
 380            self._last_hosting_state_query_time = None
 381            self._last_action_send_time = None  # So we don't ignore response.
 382            self._update()
 383
 384        self._state.sub_tab = value
 385        active_color = (0.6, 1.0, 0.6)
 386        inactive_color = (0.5, 0.4, 0.5)
 387        bui.textwidget(
 388            edit=self._join_sub_tab_text,
 389            color=active_color if value is SubTabType.JOIN else inactive_color,
 390        )
 391        bui.textwidget(
 392            edit=self._host_sub_tab_text,
 393            color=active_color if value is SubTabType.HOST else inactive_color,
 394        )
 395
 396        self._refresh_sub_tab()
 397
 398        # Kick off an update to get any needed messages sent/etc.
 399        bui.pushcall(self._update)
 400
 401    def _selwidgets(self) -> list[bui.Widget | None]:
 402        """An indexed list of widgets we can use for saving/restoring sel."""
 403        return [
 404            self._host_playlist_button,
 405            self._host_copy_button,
 406            self._host_connect_button,
 407            self._host_start_stop_button,
 408            self._get_tickets_button,
 409        ]
 410
 411    def _refresh_sub_tab(self) -> None:
 412        assert self._container
 413
 414        # Store an index for our current selection so we can
 415        # reselect the equivalent recreated widget if possible.
 416        selindex: int | None = None
 417        selchild = self._container.get_selected_child()
 418        if selchild is not None:
 419            try:
 420                selindex = self._selwidgets().index(selchild)
 421            except ValueError:
 422                pass
 423
 424        # Clear anything existing in the old sub-tab.
 425        for widget in self._container.get_children():
 426            if widget and widget not in {
 427                self._host_sub_tab_text,
 428                self._join_sub_tab_text,
 429            }:
 430                widget.delete()
 431
 432        if self._state.sub_tab is SubTabType.JOIN:
 433            self._build_join_tab()
 434        elif self._state.sub_tab is SubTabType.HOST:
 435            self._build_host_tab()
 436        else:
 437            raise RuntimeError('Invalid state.')
 438
 439        # Select the new equivalent widget if there is one.
 440        if selindex is not None:
 441            selwidget = self._selwidgets()[selindex]
 442            if selwidget:
 443                bui.containerwidget(
 444                    edit=self._container, selected_child=selwidget
 445                )
 446
 447    def _build_join_tab(self) -> None:
 448        bui.textwidget(
 449            parent=self._container,
 450            position=(self._c_width * 0.5, self._c_height - 140),
 451            color=(0.5, 0.46, 0.5),
 452            scale=1.5,
 453            size=(0, 0),
 454            maxwidth=250,
 455            h_align='center',
 456            v_align='center',
 457            text=bui.Lstr(resource='gatherWindow.partyCodeText'),
 458        )
 459
 460        self._join_party_code_text = bui.textwidget(
 461            parent=self._container,
 462            position=(self._c_width * 0.5 - 150, self._c_height - 250),
 463            flatness=1.0,
 464            scale=1.5,
 465            size=(300, 50),
 466            editable=True,
 467            max_chars=20,
 468            description=bui.Lstr(resource='gatherWindow.partyCodeText'),
 469            autoselect=True,
 470            h_align='left',
 471            v_align='center',
 472            text='',
 473        )
 474        btn = bui.buttonwidget(
 475            parent=self._container,
 476            size=(300, 70),
 477            label=bui.Lstr(resource='gatherWindow.' 'manualConnectText'),
 478            position=(self._c_width * 0.5 - 150, self._c_height - 350),
 479            on_activate_call=self._join_connect_press,
 480            autoselect=True,
 481        )
 482        bui.textwidget(
 483            edit=self._join_party_code_text, on_return_press_call=btn.activate
 484        )
 485
 486    def _on_get_tickets_press(self) -> None:
 487        if self._waiting_for_start_stop_response:
 488            return
 489
 490        # Bring up get-tickets window and then kill ourself (we're on the
 491        # overlay layer so we'd show up above it).
 492        GetCurrencyWindow(modal=True, origin_widget=self._get_tickets_button)
 493
 494    def _build_host_tab(self) -> None:
 495        # pylint: disable=too-many-branches
 496        # pylint: disable=too-many-statements
 497        assert bui.app.classic is not None
 498
 499        plus = bui.app.plus
 500        assert plus is not None
 501
 502        hostingstate = self._hostingstate
 503        if plus.get_v1_account_state() != 'signed_in':
 504            bui.textwidget(
 505                parent=self._container,
 506                size=(0, 0),
 507                h_align='center',
 508                v_align='center',
 509                maxwidth=200,
 510                scale=0.8,
 511                color=(0.6, 0.56, 0.6),
 512                position=(self._c_width * 0.5, self._c_height * 0.5),
 513                text=bui.Lstr(resource='notSignedInErrorText'),
 514            )
 515            self._showing_not_signed_in_screen = True
 516            return
 517        self._showing_not_signed_in_screen = False
 518
 519        # At first we don't want to show anything until we've gotten a state.
 520        # Update: In this situation we now simply show our existing state
 521        # but give the start/stop button a loading message and disallow its
 522        # use. This keeps things a lot less jumpy looking and allows selecting
 523        # playlists/etc without having to wait for the server each time
 524        # back to the ui.
 525        if self._waiting_for_initial_state and bool(False):
 526            bui.textwidget(
 527                parent=self._container,
 528                size=(0, 0),
 529                h_align='center',
 530                v_align='center',
 531                maxwidth=200,
 532                scale=0.8,
 533                color=(0.6, 0.56, 0.6),
 534                position=(self._c_width * 0.5, self._c_height * 0.5),
 535                text=bui.Lstr(
 536                    value='${A}...',
 537                    subs=[('${A}', bui.Lstr(resource='store.loadingText'))],
 538                ),
 539            )
 540            return
 541
 542        # If we're not currently hosting and hosting requires tickets,
 543        # Show our count (possibly with a link to purchase more).
 544        if (
 545            not self._waiting_for_initial_state
 546            and hostingstate.party_code is None
 547            and hostingstate.tickets_to_host_now != 0
 548        ):
 549            if not bui.app.ui_v1.use_toolbars:
 550                if bui.app.classic.allow_ticket_purchases:
 551                    self._get_tickets_button = bui.buttonwidget(
 552                        parent=self._container,
 553                        position=(
 554                            self._c_width - 210 + 125,
 555                            self._c_height - 44,
 556                        ),
 557                        autoselect=True,
 558                        scale=0.6,
 559                        size=(120, 60),
 560                        textcolor=(0.2, 1, 0.2),
 561                        label=bui.charstr(bui.SpecialChar.TICKET),
 562                        color=(0.65, 0.5, 0.8),
 563                        on_activate_call=self._on_get_tickets_press,
 564                    )
 565                else:
 566                    self._ticket_count_text = bui.textwidget(
 567                        parent=self._container,
 568                        scale=0.6,
 569                        position=(
 570                            self._c_width - 210 + 125,
 571                            self._c_height - 44,
 572                        ),
 573                        color=(0.2, 1, 0.2),
 574                        h_align='center',
 575                        v_align='center',
 576                    )
 577                # Set initial ticket count.
 578                self._update_currency_ui()
 579
 580        v = self._c_height - 90
 581        if hostingstate.party_code is None:
 582            bui.textwidget(
 583                parent=self._container,
 584                size=(0, 0),
 585                h_align='center',
 586                v_align='center',
 587                maxwidth=self._c_width * 0.9,
 588                scale=0.7,
 589                flatness=1.0,
 590                color=(0.5, 0.46, 0.5),
 591                position=(self._c_width * 0.5, v),
 592                text=bui.Lstr(
 593                    resource='gatherWindow.privatePartyCloudDescriptionText'
 594                ),
 595            )
 596
 597        v -= 100
 598        if hostingstate.party_code is None:
 599            # We've got no current party running; show options to set one up.
 600            bui.textwidget(
 601                parent=self._container,
 602                size=(0, 0),
 603                h_align='right',
 604                v_align='center',
 605                maxwidth=200,
 606                scale=0.8,
 607                color=(0.6, 0.56, 0.6),
 608                position=(self._c_width * 0.5 - 210, v),
 609                text=bui.Lstr(resource='playlistText'),
 610            )
 611            self._host_playlist_button = bui.buttonwidget(
 612                parent=self._container,
 613                size=(400, 70),
 614                color=(0.6, 0.5, 0.6),
 615                textcolor=(0.8, 0.75, 0.8),
 616                label=self._hostingconfig.playlist_name,
 617                on_activate_call=self._playlist_press,
 618                position=(self._c_width * 0.5 - 200, v - 35),
 619                up_widget=self._host_sub_tab_text,
 620                autoselect=True,
 621            )
 622
 623            # If it appears we're coming back from playlist selection,
 624            # re-select our playlist button.
 625            if bui.app.ui_v1.selecting_private_party_playlist:
 626                bui.containerwidget(
 627                    edit=self._container,
 628                    selected_child=self._host_playlist_button,
 629                )
 630                bui.app.ui_v1.selecting_private_party_playlist = False
 631        else:
 632            # We've got a current party; show its info.
 633            bui.textwidget(
 634                parent=self._container,
 635                size=(0, 0),
 636                h_align='center',
 637                v_align='center',
 638                maxwidth=600,
 639                scale=0.9,
 640                color=(0.7, 0.64, 0.7),
 641                position=(self._c_width * 0.5, v + 90),
 642                text=bui.Lstr(resource='gatherWindow.partyServerRunningText'),
 643            )
 644            bui.textwidget(
 645                parent=self._container,
 646                size=(0, 0),
 647                h_align='center',
 648                v_align='center',
 649                maxwidth=600,
 650                scale=0.7,
 651                color=(0.7, 0.64, 0.7),
 652                position=(self._c_width * 0.5, v + 50),
 653                text=bui.Lstr(resource='gatherWindow.partyCodeText'),
 654            )
 655            bui.textwidget(
 656                parent=self._container,
 657                size=(0, 0),
 658                h_align='center',
 659                v_align='center',
 660                scale=2.0,
 661                color=(0.0, 1.0, 0.0),
 662                position=(self._c_width * 0.5, v + 10),
 663                text=hostingstate.party_code,
 664            )
 665
 666            # Also action buttons to copy it and connect to it.
 667            if bui.clipboard_is_supported():
 668                cbtnoffs = 10
 669                self._host_copy_button = bui.buttonwidget(
 670                    parent=self._container,
 671                    size=(140, 40),
 672                    color=(0.6, 0.5, 0.6),
 673                    textcolor=(0.8, 0.75, 0.8),
 674                    label=bui.Lstr(resource='gatherWindow.copyCodeText'),
 675                    on_activate_call=self._host_copy_press,
 676                    position=(self._c_width * 0.5 - 150, v - 70),
 677                    autoselect=True,
 678                )
 679            else:
 680                cbtnoffs = -70
 681            self._host_connect_button = bui.buttonwidget(
 682                parent=self._container,
 683                size=(140, 40),
 684                color=(0.6, 0.5, 0.6),
 685                textcolor=(0.8, 0.75, 0.8),
 686                label=bui.Lstr(resource='gatherWindow.manualConnectText'),
 687                on_activate_call=self._host_connect_press,
 688                position=(self._c_width * 0.5 + cbtnoffs, v - 70),
 689                autoselect=True,
 690            )
 691
 692        v -= 120
 693
 694        # Line above the main action button:
 695
 696        # If we don't want to show anything until we get a state:
 697        if self._waiting_for_initial_state:
 698            pass
 699        elif hostingstate.unavailable_error is not None:
 700            # If hosting is unavailable, show the associated reason.
 701            bui.textwidget(
 702                parent=self._container,
 703                size=(0, 0),
 704                h_align='center',
 705                v_align='center',
 706                maxwidth=self._c_width * 0.9,
 707                scale=0.7,
 708                flatness=1.0,
 709                color=(1.0, 0.0, 0.0),
 710                position=(self._c_width * 0.5, v),
 711                text=bui.Lstr(
 712                    translate=(
 713                        'serverResponses',
 714                        hostingstate.unavailable_error,
 715                    )
 716                ),
 717            )
 718        elif hostingstate.free_host_minutes_remaining is not None:
 719            # If we've been pre-approved to start/stop for free, show that.
 720            bui.textwidget(
 721                parent=self._container,
 722                size=(0, 0),
 723                h_align='center',
 724                v_align='center',
 725                maxwidth=self._c_width * 0.9,
 726                scale=0.7,
 727                flatness=1.0,
 728                color=(
 729                    (0.7, 0.64, 0.7)
 730                    if hostingstate.party_code
 731                    else (0.0, 1.0, 0.0)
 732                ),
 733                position=(self._c_width * 0.5, v),
 734                text=bui.Lstr(
 735                    resource='gatherWindow.startStopHostingMinutesText',
 736                    subs=[
 737                        (
 738                            '${MINUTES}',
 739                            f'{hostingstate.free_host_minutes_remaining:.0f}',
 740                        )
 741                    ],
 742                ),
 743            )
 744        else:
 745            # Otherwise tell whether the free cloud server is available
 746            # or will be at some point.
 747            if hostingstate.party_code is None:
 748                if hostingstate.tickets_to_host_now == 0:
 749                    bui.textwidget(
 750                        parent=self._container,
 751                        size=(0, 0),
 752                        h_align='center',
 753                        v_align='center',
 754                        maxwidth=self._c_width * 0.9,
 755                        scale=0.7,
 756                        flatness=1.0,
 757                        color=(0.0, 1.0, 0.0),
 758                        position=(self._c_width * 0.5, v),
 759                        text=bui.Lstr(
 760                            resource=(
 761                                'gatherWindow.freeCloudServerAvailableNowText'
 762                            )
 763                        ),
 764                    )
 765                else:
 766                    if hostingstate.minutes_until_free_host is None:
 767                        bui.textwidget(
 768                            parent=self._container,
 769                            size=(0, 0),
 770                            h_align='center',
 771                            v_align='center',
 772                            maxwidth=self._c_width * 0.9,
 773                            scale=0.7,
 774                            flatness=1.0,
 775                            color=(1.0, 0.6, 0.0),
 776                            position=(self._c_width * 0.5, v),
 777                            text=bui.Lstr(
 778                                resource=(
 779                                    'gatherWindow'
 780                                    '.freeCloudServerNotAvailableText'
 781                                )
 782                            ),
 783                        )
 784                    else:
 785                        availmins = hostingstate.minutes_until_free_host
 786                        bui.textwidget(
 787                            parent=self._container,
 788                            size=(0, 0),
 789                            h_align='center',
 790                            v_align='center',
 791                            maxwidth=self._c_width * 0.9,
 792                            scale=0.7,
 793                            flatness=1.0,
 794                            color=(1.0, 0.6, 0.0),
 795                            position=(self._c_width * 0.5, v),
 796                            text=bui.Lstr(
 797                                resource='gatherWindow.'
 798                                'freeCloudServerAvailableMinutesText',
 799                                subs=[('${MINUTES}', f'{availmins:.0f}')],
 800                            ),
 801                        )
 802
 803        v -= 100
 804
 805        if (
 806            self._waiting_for_start_stop_response
 807            or self._waiting_for_initial_state
 808        ):
 809            btnlabel = bui.Lstr(resource='oneMomentText')
 810        else:
 811            if hostingstate.unavailable_error is not None:
 812                btnlabel = bui.Lstr(
 813                    resource='gatherWindow.hostingUnavailableText'
 814                )
 815            elif hostingstate.party_code is None:
 816                ticon = bui.charstr(bui.SpecialChar.TICKET)
 817                nowtickets = hostingstate.tickets_to_host_now
 818                if nowtickets > 0:
 819                    btnlabel = bui.Lstr(
 820                        resource='gatherWindow.startHostingPaidText',
 821                        subs=[('${COST}', f'{ticon}{nowtickets}')],
 822                    )
 823                else:
 824                    btnlabel = bui.Lstr(
 825                        resource='gatherWindow.startHostingText'
 826                    )
 827            else:
 828                btnlabel = bui.Lstr(resource='gatherWindow.stopHostingText')
 829
 830        disabled = (
 831            hostingstate.unavailable_error is not None
 832            or self._waiting_for_initial_state
 833        )
 834        waiting = self._waiting_for_start_stop_response
 835        self._host_start_stop_button = bui.buttonwidget(
 836            parent=self._container,
 837            size=(400, 80),
 838            color=(
 839                (0.6, 0.6, 0.6)
 840                if disabled
 841                else (0.5, 1.0, 0.5)
 842                if waiting
 843                else None
 844            ),
 845            enable_sound=False,
 846            label=btnlabel,
 847            textcolor=((0.7, 0.7, 0.7) if disabled else None),
 848            position=(self._c_width * 0.5 - 200, v),
 849            on_activate_call=self._start_stop_button_press,
 850            autoselect=True,
 851        )
 852
 853    def _playlist_press(self) -> None:
 854        assert self._host_playlist_button is not None
 855        self.window.playlist_select(origin_widget=self._host_playlist_button)
 856
 857    def _host_copy_press(self) -> None:
 858        assert self._hostingstate.party_code is not None
 859        bui.clipboard_set_text(self._hostingstate.party_code)
 860        bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText'))
 861
 862    def _host_connect_press(self) -> None:
 863        assert self._hostingstate.party_code is not None
 864        self._connect_to_party_code(self._hostingstate.party_code)
 865
 866    def _debug_server_comm(self, msg: str) -> None:
 867        if DEBUG_SERVER_COMMUNICATION:
 868            print(
 869                f'PPTABCOM: {msg} at time '
 870                f'{time.time()-self._create_time:.2f}'
 871            )
 872
 873    def _connect_to_party_code(self, code: str) -> None:
 874        # Ignore attempted followup sends for a few seconds.
 875        # (this will reset if we get a response)
 876        plus = bui.app.plus
 877        assert plus is not None
 878
 879        now = time.time()
 880        if (
 881            self._connect_press_time is not None
 882            and now - self._connect_press_time < 5.0
 883        ):
 884            self._debug_server_comm(
 885                'not sending private party connect (too soon)'
 886            )
 887            return
 888        self._connect_press_time = now
 889
 890        self._debug_server_comm('sending private party connect')
 891        plus.add_v1_account_transaction(
 892            {
 893                'type': 'PRIVATE_PARTY_CONNECT',
 894                'expire_time': time.time() + 20,
 895                'code': code,
 896            },
 897            callback=bui.WeakCall(self._connect_response),
 898        )
 899        plus.run_v1_account_transactions()
 900
 901    def _start_stop_button_press(self) -> None:
 902        plus = bui.app.plus
 903        assert plus is not None
 904        if (
 905            self._waiting_for_start_stop_response
 906            or self._waiting_for_initial_state
 907        ):
 908            return
 909
 910        if plus.get_v1_account_state() != 'signed_in':
 911            bui.screenmessage(bui.Lstr(resource='notSignedInErrorText'))
 912            bui.getsound('error').play()
 913            self._refresh_sub_tab()
 914            return
 915
 916        if self._hostingstate.unavailable_error is not None:
 917            bui.getsound('error').play()
 918            return
 919
 920        bui.getsound('click01').play()
 921
 922        # If we're not hosting, start.
 923        if self._hostingstate.party_code is None:
 924            # If there's a ticket cost, make sure we have enough tickets.
 925            if self._hostingstate.tickets_to_host_now > 0:
 926                ticket_count: int | None
 927                try:
 928                    ticket_count = plus.get_v1_account_ticket_count()
 929                except Exception:
 930                    # FIXME: should add a bui.NotSignedInError we can use here.
 931                    ticket_count = None
 932                ticket_cost = self._hostingstate.tickets_to_host_now
 933                if ticket_count is not None and ticket_count < ticket_cost:
 934                    show_get_tickets_prompt()
 935                    bui.getsound('error').play()
 936                    return
 937            self._last_action_send_time = time.time()
 938            plus.add_v1_account_transaction(
 939                {
 940                    'type': 'PRIVATE_PARTY_START',
 941                    'config': dataclass_to_dict(self._hostingconfig),
 942                    'region_pings': bui.app.net.zone_pings,
 943                    'expire_time': time.time() + 20,
 944                },
 945                callback=bui.WeakCall(self._hosting_state_response),
 946            )
 947            plus.run_v1_account_transactions()
 948
 949        else:
 950            self._last_action_send_time = time.time()
 951            plus.add_v1_account_transaction(
 952                {
 953                    'type': 'PRIVATE_PARTY_STOP',
 954                    'expire_time': time.time() + 20,
 955                },
 956                callback=bui.WeakCall(self._hosting_state_response),
 957            )
 958            plus.run_v1_account_transactions()
 959        bui.getsound('click01').play()
 960
 961        self._waiting_for_start_stop_response = True
 962        self._refresh_sub_tab()
 963
 964    def _join_connect_press(self) -> None:
 965        # Error immediately if its an empty code.
 966        code: str | None = None
 967        if self._join_party_code_text:
 968            code = cast(str, bui.textwidget(query=self._join_party_code_text))
 969        if not code:
 970            bui.screenmessage(
 971                bui.Lstr(translate=('serverResponses', 'Invalid code.')),
 972                color=(1, 0, 0),
 973            )
 974            bui.getsound('error').play()
 975            return
 976
 977        self._connect_to_party_code(code)
 978
 979    def _connect_response(self, result: dict[str, Any] | None) -> None:
 980        try:
 981            self._connect_press_time = None
 982            if result is None:
 983                raise RuntimeError()
 984            cresult = dataclass_from_dict(
 985                PrivatePartyConnectResult, result, discard_unknown_attrs=True
 986            )
 987            if cresult.error is not None:
 988                self._debug_server_comm('got error connect response')
 989                bui.screenmessage(
 990                    bui.Lstr(translate=('serverResponses', cresult.error)),
 991                    (1, 0, 0),
 992                )
 993                bui.getsound('error').play()
 994                return
 995            self._debug_server_comm('got valid connect response')
 996            assert cresult.addr is not None and cresult.port is not None
 997            bs.connect_to_party(cresult.addr, port=cresult.port)
 998        except Exception:
 999            self._debug_server_comm('got connect response error')
1000            bui.getsound('error').play()
1001
1002    @override
1003    def save_state(self) -> None:
1004        assert bui.app.classic is not None
1005        bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)
1006
1007    @override
1008    def restore_state(self) -> None:
1009        assert bui.app.classic is not None
1010        state = bui.app.ui_v1.window_states.get(type(self))
1011        if state is None:
1012            state = State()
1013        assert isinstance(state, State)
1014        self._state = state

The private tab in the gather UI

PrivateGatherTab(window: bauiv1lib.gather.GatherWindow)
56    def __init__(self, window: GatherWindow) -> None:
57        super().__init__(window)
58        self._container: bui.Widget | None = None
59        self._state: State = State()
60        self._hostingstate = PrivateHostingState()
61        self._join_sub_tab_text: bui.Widget | None = None
62        self._host_sub_tab_text: bui.Widget | None = None
63        self._update_timer: bui.AppTimer | None = None
64        self._join_party_code_text: bui.Widget | None = None
65        self._c_width: float = 0.0
66        self._c_height: float = 0.0
67        self._last_hosting_state_query_time: float | None = None
68        self._waiting_for_initial_state = True
69        self._waiting_for_start_stop_response = True
70        self._host_playlist_button: bui.Widget | None = None
71        self._host_copy_button: bui.Widget | None = None
72        self._host_connect_button: bui.Widget | None = None
73        self._host_start_stop_button: bui.Widget | None = None
74        self._get_tickets_button: bui.Widget | None = None
75        self._ticket_count_text: bui.Widget | None = None
76        self._showing_not_signed_in_screen = False
77        self._create_time = time.time()
78        self._last_action_send_time: float | None = None
79        self._connect_press_time: float | None = None
80        try:
81            self._hostingconfig = self._build_hosting_config()
82        except Exception:
83            logging.exception('Error building hosting config.')
84            self._hostingconfig = PrivateHostingConfig()
@override
def on_activate( self, parent_widget: _bauiv1.Widget, tab_button: _bauiv1.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float) -> _bauiv1.Widget:
 86    @override
 87    def on_activate(
 88        self,
 89        parent_widget: bui.Widget,
 90        tab_button: bui.Widget,
 91        region_width: float,
 92        region_height: float,
 93        region_left: float,
 94        region_bottom: float,
 95    ) -> bui.Widget:
 96        self._c_width = region_width
 97        self._c_height = region_height - 20
 98        self._container = bui.containerwidget(
 99            parent=parent_widget,
100            position=(
101                region_left,
102                region_bottom + (region_height - self._c_height) * 0.5,
103            ),
104            size=(self._c_width, self._c_height),
105            background=False,
106            selection_loops_to_parent=True,
107        )
108        v = self._c_height - 30.0
109        self._join_sub_tab_text = bui.textwidget(
110            parent=self._container,
111            position=(self._c_width * 0.5 - 245, v - 13),
112            color=(0.6, 1.0, 0.6),
113            scale=1.3,
114            size=(200, 30),
115            maxwidth=250,
116            h_align='left',
117            v_align='center',
118            click_activate=True,
119            selectable=True,
120            autoselect=True,
121            on_activate_call=lambda: self._set_sub_tab(
122                SubTabType.JOIN,
123                playsound=True,
124            ),
125            text=bui.Lstr(resource='gatherWindow.privatePartyJoinText'),
126            glow_type='uniform',
127        )
128        self._host_sub_tab_text = bui.textwidget(
129            parent=self._container,
130            position=(self._c_width * 0.5 + 45, v - 13),
131            color=(0.6, 1.0, 0.6),
132            scale=1.3,
133            size=(200, 30),
134            maxwidth=250,
135            h_align='left',
136            v_align='center',
137            click_activate=True,
138            selectable=True,
139            autoselect=True,
140            on_activate_call=lambda: self._set_sub_tab(
141                SubTabType.HOST,
142                playsound=True,
143            ),
144            text=bui.Lstr(resource='gatherWindow.privatePartyHostText'),
145            glow_type='uniform',
146        )
147        bui.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
148        bui.widget(
149            edit=self._host_sub_tab_text,
150            left_widget=self._join_sub_tab_text,
151            up_widget=tab_button,
152        )
153        bui.widget(
154            edit=self._join_sub_tab_text, right_widget=self._host_sub_tab_text
155        )
156
157        self._update_timer = bui.AppTimer(
158            1.0, bui.WeakCall(self._update), repeat=True
159        )
160
161        # Prevent taking any action until we've updated our state.
162        self._waiting_for_initial_state = True
163
164        # This will get a state query sent out immediately.
165        self._last_action_send_time = None  # Ensure we don't ignore response.
166        self._last_hosting_state_query_time = None
167        self._update()
168
169        self._set_sub_tab(self._state.sub_tab)
170
171        return self._container

Called when the tab becomes the active one.

The tab should create and return a container widget covering the specified region.

@override
def on_deactivate(self) -> None:
259    @override
260    def on_deactivate(self) -> None:
261        self._update_timer = None

Called when the tab will no longer be the active one.

@override
def save_state(self) -> None:
1002    @override
1003    def save_state(self) -> None:
1004        assert bui.app.classic is not None
1005        bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)

Called when the parent window is saving state.

@override
def restore_state(self) -> None:
1007    @override
1008    def restore_state(self) -> None:
1009        assert bui.app.classic is not None
1010        state = bui.app.ui_v1.window_states.get(type(self))
1011        if state is None:
1012            state = State()
1013        assert isinstance(state, State)
1014        self._state = state

Called when the parent window is restoring state.