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

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class State:
45@dataclass
46class State:
47    """Our core state that persists while the app is running."""
48
49    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):
  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) if waiting else None
 841            ),
 842            enable_sound=False,
 843            label=btnlabel,
 844            textcolor=((0.7, 0.7, 0.7) if disabled else None),
 845            position=(self._c_width * 0.5 - 200, v),
 846            on_activate_call=self._start_stop_button_press,
 847            autoselect=True,
 848        )
 849
 850    def _playlist_press(self) -> None:
 851        assert self._host_playlist_button is not None
 852        self.window.playlist_select(origin_widget=self._host_playlist_button)
 853
 854    def _host_copy_press(self) -> None:
 855        assert self._hostingstate.party_code is not None
 856        bui.clipboard_set_text(self._hostingstate.party_code)
 857        bui.screenmessage(bui.Lstr(resource='gatherWindow.copyCodeConfirmText'))
 858
 859    def _host_connect_press(self) -> None:
 860        assert self._hostingstate.party_code is not None
 861        self._connect_to_party_code(self._hostingstate.party_code)
 862
 863    def _debug_server_comm(self, msg: str) -> None:
 864        if DEBUG_SERVER_COMMUNICATION:
 865            print(
 866                f'PPTABCOM: {msg} at time '
 867                f'{time.time()-self._create_time:.2f}'
 868            )
 869
 870    def _connect_to_party_code(self, code: str) -> None:
 871        # Ignore attempted followup sends for a few seconds.
 872        # (this will reset if we get a response)
 873        plus = bui.app.plus
 874        assert plus is not None
 875
 876        now = time.time()
 877        if (
 878            self._connect_press_time is not None
 879            and now - self._connect_press_time < 5.0
 880        ):
 881            self._debug_server_comm(
 882                'not sending private party connect (too soon)'
 883            )
 884            return
 885        self._connect_press_time = now
 886
 887        self._debug_server_comm('sending private party connect')
 888        plus.add_v1_account_transaction(
 889            {
 890                'type': 'PRIVATE_PARTY_CONNECT',
 891                'expire_time': time.time() + 20,
 892                'code': code,
 893            },
 894            callback=bui.WeakCall(self._connect_response),
 895        )
 896        plus.run_v1_account_transactions()
 897
 898    def _start_stop_button_press(self) -> None:
 899        plus = bui.app.plus
 900        assert plus is not None
 901        if (
 902            self._waiting_for_start_stop_response
 903            or self._waiting_for_initial_state
 904        ):
 905            return
 906
 907        if plus.get_v1_account_state() != 'signed_in':
 908            bui.screenmessage(bui.Lstr(resource='notSignedInErrorText'))
 909            bui.getsound('error').play()
 910            self._refresh_sub_tab()
 911            return
 912
 913        if self._hostingstate.unavailable_error is not None:
 914            bui.getsound('error').play()
 915            return
 916
 917        bui.getsound('click01').play()
 918
 919        # If we're not hosting, start.
 920        if self._hostingstate.party_code is None:
 921            # If there's a ticket cost, make sure we have enough tickets.
 922            if self._hostingstate.tickets_to_host_now > 0:
 923                ticket_count: int | None
 924                try:
 925                    ticket_count = plus.get_v1_account_ticket_count()
 926                except Exception:
 927                    # FIXME: should add a bui.NotSignedInError we can use here.
 928                    ticket_count = None
 929                ticket_cost = self._hostingstate.tickets_to_host_now
 930                if ticket_count is not None and ticket_count < ticket_cost:
 931                    show_get_tickets_prompt()
 932                    bui.getsound('error').play()
 933                    return
 934            self._last_action_send_time = time.time()
 935            plus.add_v1_account_transaction(
 936                {
 937                    'type': 'PRIVATE_PARTY_START',
 938                    'config': dataclass_to_dict(self._hostingconfig),
 939                    'region_pings': bui.app.net.zone_pings,
 940                    'expire_time': time.time() + 20,
 941                },
 942                callback=bui.WeakCall(self._hosting_state_response),
 943            )
 944            plus.run_v1_account_transactions()
 945
 946        else:
 947            self._last_action_send_time = time.time()
 948            plus.add_v1_account_transaction(
 949                {
 950                    'type': 'PRIVATE_PARTY_STOP',
 951                    'expire_time': time.time() + 20,
 952                },
 953                callback=bui.WeakCall(self._hosting_state_response),
 954            )
 955            plus.run_v1_account_transactions()
 956        bui.getsound('click01').play()
 957
 958        self._waiting_for_start_stop_response = True
 959        self._refresh_sub_tab()
 960
 961    def _join_connect_press(self) -> None:
 962        # Error immediately if its an empty code.
 963        code: str | None = None
 964        if self._join_party_code_text:
 965            code = cast(str, bui.textwidget(query=self._join_party_code_text))
 966        if not code:
 967            bui.screenmessage(
 968                bui.Lstr(translate=('serverResponses', 'Invalid code.')),
 969                color=(1, 0, 0),
 970            )
 971            bui.getsound('error').play()
 972            return
 973
 974        self._connect_to_party_code(code)
 975
 976    def _connect_response(self, result: dict[str, Any] | None) -> None:
 977        try:
 978            self._connect_press_time = None
 979            if result is None:
 980                raise RuntimeError()
 981            cresult = dataclass_from_dict(
 982                PrivatePartyConnectResult, result, discard_unknown_attrs=True
 983            )
 984            if cresult.error is not None:
 985                self._debug_server_comm('got error connect response')
 986                bui.screenmessage(
 987                    bui.Lstr(translate=('serverResponses', cresult.error)),
 988                    (1, 0, 0),
 989                )
 990                bui.getsound('error').play()
 991                return
 992            self._debug_server_comm('got valid connect response')
 993            assert cresult.addr is not None and cresult.port is not None
 994            bs.connect_to_party(cresult.addr, port=cresult.port)
 995        except Exception:
 996            self._debug_server_comm('got connect response error')
 997            bui.getsound('error').play()
 998
 999    @override
1000    def save_state(self) -> None:
1001        assert bui.app.classic is not None
1002        bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)
1003
1004    @override
1005    def restore_state(self) -> None:
1006        assert bui.app.classic is not None
1007        state = bui.app.ui_v1.window_states.get(type(self))
1008        if state is None:
1009            state = State()
1010        assert isinstance(state, State)
1011        self._state = state

The private tab in the gather UI

PrivateGatherTab(window: bauiv1lib.gather.GatherWindow)
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()
@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:
 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

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:
258    @override
259    def on_deactivate(self) -> None:
260        self._update_timer = None

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

@override
def save_state(self) -> None:
 999    @override
1000    def save_state(self) -> None:
1001        assert bui.app.classic is not None
1002        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:
1004    @override
1005    def restore_state(self) -> None:
1006        assert bui.app.classic is not None
1007        state = bui.app.ui_v1.window_states.get(type(self))
1008        if state is None:
1009            state = State()
1010        assert isinstance(state, State)
1011        self._state = state

Called when the parent window is restoring state.