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

Called when the tab becomes the active one.

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

def on_deactivate(self) -> None:
255    def on_deactivate(self) -> None:
256        self._update_timer = None

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

def save_state(self) -> None:
997    def save_state(self) -> None:
998        assert bui.app.classic is not None
999        bui.app.ui_v1.window_states[type(self)] = copy.deepcopy(self._state)

Called when the parent window is saving state.

def restore_state(self) -> None:
1001    def restore_state(self) -> None:
1002        assert bui.app.classic is not None
1003        state = bui.app.ui_v1.window_states.get(type(self))
1004        if state is None:
1005            state = State()
1006        assert isinstance(state, State)
1007        self._state = state

Called when the parent window is restoring state.