bauiv1lib.gather.publictab

Defines the public 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 public tab in the gather UI."""
   5
   6from __future__ import annotations
   7
   8import copy
   9import time
  10import logging
  11from threading import Thread
  12from enum import Enum
  13from dataclasses import dataclass
  14from typing import TYPE_CHECKING, cast
  15
  16from bauiv1lib.gather import GatherTab
  17import bauiv1 as bui
  18import bascenev1 as bs
  19
  20if TYPE_CHECKING:
  21    from typing import Callable, Any
  22
  23    from bauiv1lib.gather import GatherWindow
  24
  25# Print a bit of info about pings, queries, etc.
  26DEBUG_SERVER_COMMUNICATION = False
  27DEBUG_PROCESSING = False
  28
  29
  30class SubTabType(Enum):
  31    """Available sub-tabs."""
  32
  33    JOIN = 'join'
  34    HOST = 'host'
  35
  36
  37@dataclass
  38class PartyEntry:
  39    """Info about a public party."""
  40
  41    address: str
  42    index: int
  43    queue: str | None = None
  44    port: int = -1
  45    name: str = ''
  46    size: int = -1
  47    size_max: int = -1
  48    claimed: bool = False
  49    ping: float | None = None
  50    ping_interval: float = -1.0
  51    next_ping_time: float = -1.0
  52    ping_attempts: int = 0
  53    ping_responses: int = 0
  54    stats_addr: str | None = None
  55    clean_display_index: int | None = None
  56
  57    def get_key(self) -> str:
  58        """Return the key used to store this party."""
  59        return f'{self.address}_{self.port}'
  60
  61
  62class UIRow:
  63    """Wrangles UI for a row in the party list."""
  64
  65    def __init__(self) -> None:
  66        self._name_widget: bui.Widget | None = None
  67        self._size_widget: bui.Widget | None = None
  68        self._ping_widget: bui.Widget | None = None
  69        self._stats_button: bui.Widget | None = None
  70
  71    def __del__(self) -> None:
  72        self._clear()
  73
  74    def _clear(self) -> None:
  75        for widget in [
  76            self._name_widget,
  77            self._size_widget,
  78            self._ping_widget,
  79            self._stats_button,
  80        ]:
  81            if widget:
  82                widget.delete()
  83
  84    def update(
  85        self,
  86        index: int,
  87        party: PartyEntry,
  88        sub_scroll_width: float,
  89        sub_scroll_height: float,
  90        lineheight: float,
  91        columnwidget: bui.Widget,
  92        join_text: bui.Widget,
  93        filter_text: bui.Widget,
  94        existing_selection: Selection | None,
  95        tab: PublicGatherTab,
  96    ) -> None:
  97        """Update for the given data."""
  98        # pylint: disable=too-many-locals
  99
 100        plus = bui.app.plus
 101        assert plus is not None
 102
 103        # Quick-out: if we've been marked clean for a certain index and
 104        # we're still at that index, we're done.
 105        if party.clean_display_index == index:
 106            return
 107
 108        ping_good = plus.get_v1_account_misc_read_val('pingGood', 100)
 109        ping_med = plus.get_v1_account_misc_read_val('pingMed', 500)
 110
 111        self._clear()
 112        hpos = 20
 113        vpos = sub_scroll_height - lineheight * index - 50
 114        self._name_widget = bui.textwidget(
 115            text=bui.Lstr(value=party.name),
 116            parent=columnwidget,
 117            size=(sub_scroll_width * 0.63, 20),
 118            position=(0 + hpos, 4 + vpos),
 119            selectable=True,
 120            on_select_call=bui.WeakCall(
 121                tab.set_public_party_selection,
 122                Selection(party.get_key(), SelectionComponent.NAME),
 123            ),
 124            on_activate_call=bui.WeakCall(tab.on_public_party_activate, party),
 125            click_activate=True,
 126            maxwidth=sub_scroll_width * 0.45,
 127            corner_scale=1.4,
 128            autoselect=True,
 129            color=(1, 1, 1, 0.3 if party.ping is None else 1.0),
 130            h_align='left',
 131            v_align='center',
 132        )
 133        bui.widget(
 134            edit=self._name_widget,
 135            left_widget=join_text,
 136            show_buffer_top=64.0,
 137            show_buffer_bottom=64.0,
 138        )
 139        if existing_selection == Selection(
 140            party.get_key(), SelectionComponent.NAME
 141        ):
 142            bui.containerwidget(
 143                edit=columnwidget, selected_child=self._name_widget
 144            )
 145        if party.stats_addr:
 146            url = party.stats_addr.replace(
 147                '${ACCOUNT}',
 148                plus.get_v1_account_misc_read_val_2(
 149                    'resolvedAccountID', 'UNKNOWN'
 150                ),
 151            )
 152            self._stats_button = bui.buttonwidget(
 153                color=(0.3, 0.6, 0.94),
 154                textcolor=(1.0, 1.0, 1.0),
 155                label=bui.Lstr(resource='statsText'),
 156                parent=columnwidget,
 157                autoselect=True,
 158                on_activate_call=bui.Call(bui.open_url, url),
 159                on_select_call=bui.WeakCall(
 160                    tab.set_public_party_selection,
 161                    Selection(party.get_key(), SelectionComponent.STATS_BUTTON),
 162                ),
 163                size=(120, 40),
 164                position=(sub_scroll_width * 0.66 + hpos, 1 + vpos),
 165                scale=0.9,
 166            )
 167            if existing_selection == Selection(
 168                party.get_key(), SelectionComponent.STATS_BUTTON
 169            ):
 170                bui.containerwidget(
 171                    edit=columnwidget, selected_child=self._stats_button
 172                )
 173
 174        self._size_widget = bui.textwidget(
 175            text=str(party.size) + '/' + str(party.size_max),
 176            parent=columnwidget,
 177            size=(0, 0),
 178            position=(sub_scroll_width * 0.86 + hpos, 20 + vpos),
 179            scale=0.7,
 180            color=(0.8, 0.8, 0.8),
 181            h_align='right',
 182            v_align='center',
 183        )
 184
 185        if index == 0:
 186            bui.widget(edit=self._name_widget, up_widget=filter_text)
 187            if self._stats_button:
 188                bui.widget(edit=self._stats_button, up_widget=filter_text)
 189
 190        self._ping_widget = bui.textwidget(
 191            parent=columnwidget,
 192            size=(0, 0),
 193            position=(sub_scroll_width * 0.94 + hpos, 20 + vpos),
 194            scale=0.7,
 195            h_align='right',
 196            v_align='center',
 197        )
 198        if party.ping is None:
 199            bui.textwidget(
 200                edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5)
 201            )
 202        else:
 203            bui.textwidget(
 204                edit=self._ping_widget,
 205                text=str(int(party.ping)),
 206                color=(0, 1, 0)
 207                if party.ping <= ping_good
 208                else (1, 1, 0)
 209                if party.ping <= ping_med
 210                else (1, 0, 0),
 211            )
 212
 213        party.clean_display_index = index
 214
 215
 216@dataclass
 217class State:
 218    """State saved/restored only while the app is running."""
 219
 220    sub_tab: SubTabType = SubTabType.JOIN
 221    parties: list[tuple[str, PartyEntry]] | None = None
 222    next_entry_index: int = 0
 223    filter_value: str = ''
 224    have_server_list_response: bool = False
 225    have_valid_server_list: bool = False
 226
 227
 228class SelectionComponent(Enum):
 229    """Describes what part of an entry is selected."""
 230
 231    NAME = 'name'
 232    STATS_BUTTON = 'stats_button'
 233
 234
 235@dataclass
 236class Selection:
 237    """Describes the currently selected list element."""
 238
 239    entry_key: str
 240    component: SelectionComponent
 241
 242
 243class AddrFetchThread(Thread):
 244    """Thread for fetching an address in the bg."""
 245
 246    def __init__(self, call: Callable[[Any], Any]):
 247        super().__init__()
 248        self._call = call
 249
 250    def run(self) -> None:
 251        try:
 252            # FIXME: Update this to work with IPv6 at some point.
 253            import socket
 254
 255            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 256            sock.connect(('8.8.8.8', 80))
 257            val = sock.getsockname()[0]
 258            sock.close()
 259            bui.pushcall(bui.Call(self._call, val), from_other_thread=True)
 260        except Exception as exc:
 261            from efro.error import is_udp_communication_error
 262
 263            # Ignore expected network errors; log others.
 264            if is_udp_communication_error(exc):
 265                pass
 266            else:
 267                logging.exception('Error in addr-fetch-thread')
 268
 269
 270class PingThread(Thread):
 271    """Thread for sending out game pings."""
 272
 273    def __init__(
 274        self,
 275        address: str,
 276        port: int,
 277        call: Callable[[str, int, float | None], int | None],
 278    ):
 279        super().__init__()
 280        self._address = address
 281        self._port = port
 282        self._call = call
 283
 284    def run(self) -> None:
 285        assert bui.app.classic is not None
 286        bui.app.classic.ping_thread_count += 1
 287        sock: socket.socket | None = None
 288        try:
 289            import socket
 290
 291            socket_type = bui.get_ip_address_type(self._address)
 292            sock = socket.socket(socket_type, socket.SOCK_DGRAM)
 293            sock.connect((self._address, self._port))
 294
 295            accessible = False
 296            starttime = time.time()
 297
 298            # Send a few pings and wait a second for
 299            # a response.
 300            sock.settimeout(1)
 301            for _i in range(3):
 302                sock.send(b'\x0b')
 303                result: bytes | None
 304                try:
 305                    # 11: BA_PACKET_SIMPLE_PING
 306                    result = sock.recv(10)
 307                except Exception:
 308                    result = None
 309                if result == b'\x0c':
 310                    # 12: BA_PACKET_SIMPLE_PONG
 311                    accessible = True
 312                    break
 313                time.sleep(1)
 314            ping = (time.time() - starttime) * 1000.0
 315            bui.pushcall(
 316                bui.Call(
 317                    self._call,
 318                    self._address,
 319                    self._port,
 320                    ping if accessible else None,
 321                ),
 322                from_other_thread=True,
 323            )
 324        except Exception as exc:
 325            from efro.error import is_udp_communication_error
 326
 327            if is_udp_communication_error(exc):
 328                pass
 329            else:
 330                if bui.do_once():
 331                    logging.exception('Error on gather ping.')
 332        finally:
 333            try:
 334                if sock is not None:
 335                    sock.close()
 336            except Exception:
 337                if bui.do_once():
 338                    logging.exception('Error on gather ping cleanup')
 339
 340        bui.app.classic.ping_thread_count -= 1
 341
 342
 343class PublicGatherTab(GatherTab):
 344    """The public tab in the gather UI"""
 345
 346    def __init__(self, window: GatherWindow) -> None:
 347        super().__init__(window)
 348        self._container: bui.Widget | None = None
 349        self._join_text: bui.Widget | None = None
 350        self._host_text: bui.Widget | None = None
 351        self._filter_text: bui.Widget | None = None
 352        self._local_address: str | None = None
 353        self._last_connect_attempt_time: float | None = None
 354        self._sub_tab: SubTabType = SubTabType.JOIN
 355        self._selection: Selection | None = None
 356        self._refreshing_list = False
 357        self._update_timer: bui.AppTimer | None = None
 358        self._host_scrollwidget: bui.Widget | None = None
 359        self._host_name_text: bui.Widget | None = None
 360        self._host_toggle_button: bui.Widget | None = None
 361        self._last_server_list_query_time: float | None = None
 362        self._join_list_column: bui.Widget | None = None
 363        self._join_status_text: bui.Widget | None = None
 364        self._host_max_party_size_value: bui.Widget | None = None
 365        self._host_max_party_size_minus_button: (bui.Widget | None) = None
 366        self._host_max_party_size_plus_button: (bui.Widget | None) = None
 367        self._host_status_text: bui.Widget | None = None
 368        self._signed_in = False
 369        self._ui_rows: list[UIRow] = []
 370        self._refresh_ui_row = 0
 371        self._have_user_selected_row = False
 372        self._first_valid_server_list_time: float | None = None
 373
 374        # Parties indexed by id:
 375        self._parties: dict[str, PartyEntry] = {}
 376
 377        # Parties sorted in display order:
 378        self._parties_sorted: list[tuple[str, PartyEntry]] = []
 379        self._party_lists_dirty = True
 380
 381        # Sorted parties with filter applied:
 382        self._parties_displayed: dict[str, PartyEntry] = {}
 383
 384        self._next_entry_index = 0
 385        self._have_server_list_response = False
 386        self._have_valid_server_list = False
 387        self._filter_value = ''
 388        self._pending_party_infos: list[dict[str, Any]] = []
 389        self._last_sub_scroll_height = 0.0
 390
 391    def on_activate(
 392        self,
 393        parent_widget: bui.Widget,
 394        tab_button: bui.Widget,
 395        region_width: float,
 396        region_height: float,
 397        region_left: float,
 398        region_bottom: float,
 399    ) -> bui.Widget:
 400        c_width = region_width
 401        c_height = region_height - 20
 402        self._container = bui.containerwidget(
 403            parent=parent_widget,
 404            position=(
 405                region_left,
 406                region_bottom + (region_height - c_height) * 0.5,
 407            ),
 408            size=(c_width, c_height),
 409            background=False,
 410            selection_loops_to_parent=True,
 411        )
 412        v = c_height - 30
 413        self._join_text = bui.textwidget(
 414            parent=self._container,
 415            position=(c_width * 0.5 - 245, v - 13),
 416            color=(0.6, 1.0, 0.6),
 417            scale=1.3,
 418            size=(200, 30),
 419            maxwidth=250,
 420            h_align='left',
 421            v_align='center',
 422            click_activate=True,
 423            selectable=True,
 424            autoselect=True,
 425            on_activate_call=lambda: self._set_sub_tab(
 426                SubTabType.JOIN,
 427                region_width,
 428                region_height,
 429                playsound=True,
 430            ),
 431            text=bui.Lstr(
 432                resource='gatherWindow.' 'joinPublicPartyDescriptionText'
 433            ),
 434        )
 435        self._host_text = bui.textwidget(
 436            parent=self._container,
 437            position=(c_width * 0.5 + 45, v - 13),
 438            color=(0.6, 1.0, 0.6),
 439            scale=1.3,
 440            size=(200, 30),
 441            maxwidth=250,
 442            h_align='left',
 443            v_align='center',
 444            click_activate=True,
 445            selectable=True,
 446            autoselect=True,
 447            on_activate_call=lambda: self._set_sub_tab(
 448                SubTabType.HOST,
 449                region_width,
 450                region_height,
 451                playsound=True,
 452            ),
 453            text=bui.Lstr(
 454                resource='gatherWindow.' 'hostPublicPartyDescriptionText'
 455            ),
 456        )
 457        bui.widget(edit=self._join_text, up_widget=tab_button)
 458        bui.widget(
 459            edit=self._host_text,
 460            left_widget=self._join_text,
 461            up_widget=tab_button,
 462        )
 463        bui.widget(edit=self._join_text, right_widget=self._host_text)
 464
 465        # Attempt to fetch our local address so we have it for error messages.
 466        if self._local_address is None:
 467            AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start()
 468
 469        self._set_sub_tab(self._sub_tab, region_width, region_height)
 470        self._update_timer = bui.AppTimer(
 471            0.1, bui.WeakCall(self._update), repeat=True
 472        )
 473        return self._container
 474
 475    def on_deactivate(self) -> None:
 476        self._update_timer = None
 477
 478    def save_state(self) -> None:
 479        # Save off a small number of parties with the lowest ping; we'll
 480        # display these immediately when our UI comes back up which should
 481        # be enough to make things feel nice and crisp while we do a full
 482        # server re-query or whatnot.
 483        assert bui.app.classic is not None
 484        bui.app.ui_v1.window_states[type(self)] = State(
 485            sub_tab=self._sub_tab,
 486            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
 487            next_entry_index=self._next_entry_index,
 488            filter_value=self._filter_value,
 489            have_server_list_response=self._have_server_list_response,
 490            have_valid_server_list=self._have_valid_server_list,
 491        )
 492
 493    def restore_state(self) -> None:
 494        assert bui.app.classic is not None
 495        state = bui.app.ui_v1.window_states.get(type(self))
 496        if state is None:
 497            state = State()
 498        assert isinstance(state, State)
 499        self._sub_tab = state.sub_tab
 500
 501        # Restore the parties we stored.
 502        if state.parties:
 503            self._parties = {
 504                key: copy.copy(party) for key, party in state.parties
 505            }
 506            self._parties_sorted = list(self._parties.items())
 507            self._party_lists_dirty = True
 508
 509            self._next_entry_index = state.next_entry_index
 510
 511            # FIXME: should save/restore these too?..
 512            self._have_server_list_response = state.have_server_list_response
 513            self._have_valid_server_list = state.have_valid_server_list
 514        self._filter_value = state.filter_value
 515
 516    def _set_sub_tab(
 517        self,
 518        value: SubTabType,
 519        region_width: float,
 520        region_height: float,
 521        playsound: bool = False,
 522    ) -> None:
 523        assert self._container
 524        if playsound:
 525            bui.getsound('click01').play()
 526
 527        # Reset our selection.
 528        # (prevents selecting something way down the list if we switched away
 529        # and came back)
 530        self._selection = None
 531        self._have_user_selected_row = False
 532
 533        # Reset refresh to the top and make sure everything refreshes.
 534        self._refresh_ui_row = 0
 535        for party in self._parties.values():
 536            party.clean_display_index = None
 537
 538        self._sub_tab = value
 539        active_color = (0.6, 1.0, 0.6)
 540        inactive_color = (0.5, 0.4, 0.5)
 541        bui.textwidget(
 542            edit=self._join_text,
 543            color=active_color if value is SubTabType.JOIN else inactive_color,
 544        )
 545        bui.textwidget(
 546            edit=self._host_text,
 547            color=active_color if value is SubTabType.HOST else inactive_color,
 548        )
 549
 550        # Clear anything existing in the old sub-tab.
 551        for widget in self._container.get_children():
 552            if widget and widget not in {self._host_text, self._join_text}:
 553                widget.delete()
 554
 555        if value is SubTabType.JOIN:
 556            self._build_join_tab(region_width, region_height)
 557
 558        if value is SubTabType.HOST:
 559            self._build_host_tab(region_width, region_height)
 560
 561    def _build_join_tab(
 562        self, region_width: float, region_height: float
 563    ) -> None:
 564        c_width = region_width
 565        c_height = region_height - 20
 566        sub_scroll_height = c_height - 125
 567        sub_scroll_width = 830
 568        v = c_height - 35
 569        v -= 60
 570        filter_txt = bui.Lstr(resource='filterText')
 571        self._filter_text = bui.textwidget(
 572            parent=self._container,
 573            text=self._filter_value,
 574            size=(350, 45),
 575            position=(290, v - 10),
 576            h_align='left',
 577            v_align='center',
 578            editable=True,
 579            maxwidth=310,
 580            description=filter_txt,
 581        )
 582        bui.widget(edit=self._filter_text, up_widget=self._join_text)
 583        bui.textwidget(
 584            text=filter_txt,
 585            parent=self._container,
 586            size=(0, 0),
 587            position=(270, v + 13),
 588            maxwidth=150,
 589            scale=0.8,
 590            color=(0.5, 0.46, 0.5),
 591            flatness=1.0,
 592            h_align='right',
 593            v_align='center',
 594        )
 595
 596        bui.textwidget(
 597            text=bui.Lstr(resource='nameText'),
 598            parent=self._container,
 599            size=(0, 0),
 600            position=(90, v - 8),
 601            maxwidth=60,
 602            scale=0.6,
 603            color=(0.5, 0.46, 0.5),
 604            flatness=1.0,
 605            h_align='center',
 606            v_align='center',
 607        )
 608        bui.textwidget(
 609            text=bui.Lstr(resource='gatherWindow.partySizeText'),
 610            parent=self._container,
 611            size=(0, 0),
 612            position=(755, v - 8),
 613            maxwidth=60,
 614            scale=0.6,
 615            color=(0.5, 0.46, 0.5),
 616            flatness=1.0,
 617            h_align='center',
 618            v_align='center',
 619        )
 620        bui.textwidget(
 621            text=bui.Lstr(resource='gatherWindow.pingText'),
 622            parent=self._container,
 623            size=(0, 0),
 624            position=(825, v - 8),
 625            maxwidth=60,
 626            scale=0.6,
 627            color=(0.5, 0.46, 0.5),
 628            flatness=1.0,
 629            h_align='center',
 630            v_align='center',
 631        )
 632        v -= sub_scroll_height + 23
 633        self._host_scrollwidget = scrollw = bui.scrollwidget(
 634            parent=self._container,
 635            simple_culling_v=10,
 636            position=((c_width - sub_scroll_width) * 0.5, v),
 637            size=(sub_scroll_width, sub_scroll_height),
 638            claims_up_down=False,
 639            claims_left_right=True,
 640            autoselect=True,
 641        )
 642        self._join_list_column = bui.containerwidget(
 643            parent=scrollw,
 644            background=False,
 645            size=(400, 400),
 646            claims_left_right=True,
 647        )
 648        self._join_status_text = bui.textwidget(
 649            parent=self._container,
 650            text='',
 651            size=(0, 0),
 652            scale=0.9,
 653            flatness=1.0,
 654            shadow=0.0,
 655            h_align='center',
 656            v_align='top',
 657            maxwidth=c_width,
 658            color=(0.6, 0.6, 0.6),
 659            position=(c_width * 0.5, c_height * 0.5),
 660        )
 661
 662    def _build_host_tab(
 663        self, region_width: float, region_height: float
 664    ) -> None:
 665        c_width = region_width
 666        c_height = region_height - 20
 667        v = c_height - 35
 668        v -= 25
 669        is_public_enabled = bs.get_public_party_enabled()
 670        v -= 30
 671
 672        bui.textwidget(
 673            parent=self._container,
 674            size=(0, 0),
 675            h_align='center',
 676            v_align='center',
 677            maxwidth=c_width * 0.9,
 678            scale=0.7,
 679            flatness=1.0,
 680            color=(0.5, 0.46, 0.5),
 681            position=(region_width * 0.5, v + 10),
 682            text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'),
 683        )
 684        v -= 30
 685
 686        party_name_text = bui.Lstr(
 687            resource='gatherWindow.partyNameText',
 688            fallback_resource='editGameListWindow.nameText',
 689        )
 690        assert bui.app.classic is not None
 691        bui.textwidget(
 692            parent=self._container,
 693            size=(0, 0),
 694            h_align='right',
 695            v_align='center',
 696            maxwidth=200,
 697            scale=0.8,
 698            color=bui.app.ui_v1.infotextcolor,
 699            position=(210, v - 9),
 700            text=party_name_text,
 701        )
 702        self._host_name_text = bui.textwidget(
 703            parent=self._container,
 704            editable=True,
 705            size=(535, 40),
 706            position=(230, v - 30),
 707            text=bui.app.config.get('Public Party Name', ''),
 708            maxwidth=494,
 709            shadow=0.3,
 710            flatness=1.0,
 711            description=party_name_text,
 712            autoselect=True,
 713            v_align='center',
 714            corner_scale=1.0,
 715        )
 716
 717        v -= 60
 718        bui.textwidget(
 719            parent=self._container,
 720            size=(0, 0),
 721            h_align='right',
 722            v_align='center',
 723            maxwidth=200,
 724            scale=0.8,
 725            color=bui.app.ui_v1.infotextcolor,
 726            position=(210, v - 9),
 727            text=bui.Lstr(
 728                resource='maxPartySizeText',
 729                fallback_resource='maxConnectionsText',
 730            ),
 731        )
 732        self._host_max_party_size_value = bui.textwidget(
 733            parent=self._container,
 734            size=(0, 0),
 735            h_align='center',
 736            v_align='center',
 737            scale=1.2,
 738            color=(1, 1, 1),
 739            position=(240, v - 9),
 740            text=str(bs.get_public_party_max_size()),
 741        )
 742        btn1 = self._host_max_party_size_minus_button = bui.buttonwidget(
 743            parent=self._container,
 744            size=(40, 40),
 745            on_activate_call=bui.WeakCall(
 746                self._on_max_public_party_size_minus_press
 747            ),
 748            position=(280, v - 26),
 749            label='-',
 750            autoselect=True,
 751        )
 752        btn2 = self._host_max_party_size_plus_button = bui.buttonwidget(
 753            parent=self._container,
 754            size=(40, 40),
 755            on_activate_call=bui.WeakCall(
 756                self._on_max_public_party_size_plus_press
 757            ),
 758            position=(350, v - 26),
 759            label='+',
 760            autoselect=True,
 761        )
 762        v -= 50
 763        v -= 70
 764        if is_public_enabled:
 765            label = bui.Lstr(
 766                resource='gatherWindow.makePartyPrivateText',
 767                fallback_resource='gatherWindow.stopAdvertisingText',
 768            )
 769        else:
 770            label = bui.Lstr(
 771                resource='gatherWindow.makePartyPublicText',
 772                fallback_resource='gatherWindow.startAdvertisingText',
 773            )
 774        self._host_toggle_button = bui.buttonwidget(
 775            parent=self._container,
 776            label=label,
 777            size=(400, 80),
 778            on_activate_call=self._on_stop_advertising_press
 779            if is_public_enabled
 780            else self._on_start_advertizing_press,
 781            position=(c_width * 0.5 - 200, v),
 782            autoselect=True,
 783            up_widget=btn2,
 784        )
 785        bui.widget(edit=self._host_name_text, down_widget=btn2)
 786        bui.widget(edit=btn2, up_widget=self._host_name_text)
 787        bui.widget(edit=btn1, up_widget=self._host_name_text)
 788        bui.widget(edit=self._join_text, down_widget=self._host_name_text)
 789        v -= 10
 790        self._host_status_text = bui.textwidget(
 791            parent=self._container,
 792            text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'),
 793            size=(0, 0),
 794            scale=0.7,
 795            flatness=1.0,
 796            h_align='center',
 797            v_align='top',
 798            maxwidth=c_width * 0.9,
 799            color=(0.6, 0.56, 0.6),
 800            position=(c_width * 0.5, v),
 801        )
 802        v -= 90
 803        bui.textwidget(
 804            parent=self._container,
 805            text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'),
 806            size=(0, 0),
 807            scale=0.7,
 808            flatness=1.0,
 809            h_align='center',
 810            v_align='center',
 811            maxwidth=c_width * 0.9,
 812            color=(0.5, 0.46, 0.5),
 813            position=(c_width * 0.5, v),
 814        )
 815
 816        # If public sharing is already on,
 817        # launch a status-check immediately.
 818        if bs.get_public_party_enabled():
 819            self._do_status_check()
 820
 821    def _on_public_party_query_result(
 822        self, result: dict[str, Any] | None
 823    ) -> None:
 824        starttime = time.time()
 825        self._have_server_list_response = True
 826
 827        if result is None:
 828            self._have_valid_server_list = False
 829            return
 830
 831        if not self._have_valid_server_list:
 832            self._first_valid_server_list_time = time.time()
 833
 834        self._have_valid_server_list = True
 835        parties_in = result['l']
 836
 837        assert isinstance(parties_in, list)
 838        self._pending_party_infos += parties_in
 839
 840        # To avoid causing a stutter here, we do most processing of
 841        # these entries incrementally in our _update() method.
 842        # The one thing we do here is prune parties not contained in
 843        # this result.
 844        for partyval in list(self._parties.values()):
 845            partyval.claimed = False
 846        for party_in in parties_in:
 847            addr = party_in['a']
 848            assert isinstance(addr, str)
 849            port = party_in['p']
 850            assert isinstance(port, int)
 851            party_key = f'{addr}_{port}'
 852            party = self._parties.get(party_key)
 853            if party is not None:
 854                party.claimed = True
 855        self._parties = {
 856            key: val for key, val in list(self._parties.items()) if val.claimed
 857        }
 858        self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed]
 859        self._party_lists_dirty = True
 860
 861        # self._update_server_list()
 862        if DEBUG_PROCESSING:
 863            print(
 864                f'Handled public party query results in '
 865                f'{time.time()-starttime:.5f}s.'
 866            )
 867
 868    def _update(self) -> None:
 869        """Periodic updating."""
 870
 871        plus = bui.app.plus
 872        assert plus is not None
 873
 874        # Special case: if a party-queue window is up, don't do any of this
 875        # (keeps things smoother).
 876        # if bui.app.ui.have_party_queue_window:
 877        #     return
 878
 879        if self._sub_tab is SubTabType.JOIN:
 880            # Keep our filter-text up to date from the UI.
 881            text = self._filter_text
 882            if text:
 883                filter_value = cast(str, bui.textwidget(query=text))
 884                if filter_value != self._filter_value:
 885                    self._filter_value = filter_value
 886                    self._party_lists_dirty = True
 887
 888                    # Also wipe out party clean-row states.
 889                    # (otherwise if a party disappears from a row due to
 890                    # filtering and then reappears on that same row when
 891                    # the filter is removed it may not update)
 892                    for party in self._parties.values():
 893                        party.clean_display_index = None
 894
 895            self._query_party_list_periodically()
 896            self._ping_parties_periodically()
 897
 898        # If any new party infos have come in, apply some of them.
 899        self._process_pending_party_infos()
 900
 901        # Anytime we sign in/out, make sure we refresh our list.
 902        signed_in = plus.get_v1_account_state() == 'signed_in'
 903        if self._signed_in != signed_in:
 904            self._signed_in = signed_in
 905            self._party_lists_dirty = True
 906
 907        # Update sorting to account for ping updates, new parties, etc.
 908        self._update_party_lists()
 909
 910        # If we've got a party-name text widget, keep its value plugged
 911        # into our public host name.
 912        text = self._host_name_text
 913        if text:
 914            name = cast(str, bui.textwidget(query=self._host_name_text))
 915            bs.set_public_party_name(name)
 916
 917        # Update status text.
 918        status_text = self._join_status_text
 919        if status_text:
 920            if not signed_in:
 921                bui.textwidget(
 922                    edit=status_text, text=bui.Lstr(resource='notSignedInText')
 923                )
 924            else:
 925                # If we have a valid list, show no status; just the list.
 926                # Otherwise show either 'loading...' or 'error' depending
 927                # on whether this is our first go-round.
 928                if self._have_valid_server_list:
 929                    bui.textwidget(edit=status_text, text='')
 930                else:
 931                    if self._have_server_list_response:
 932                        bui.textwidget(
 933                            edit=status_text,
 934                            text=bui.Lstr(resource='errorText'),
 935                        )
 936                    else:
 937                        bui.textwidget(
 938                            edit=status_text,
 939                            text=bui.Lstr(
 940                                value='${A}...',
 941                                subs=[
 942                                    (
 943                                        '${A}',
 944                                        bui.Lstr(resource='store.loadingText'),
 945                                    )
 946                                ],
 947                            ),
 948                        )
 949
 950        self._update_party_rows()
 951
 952    def _update_party_rows(self) -> None:
 953        columnwidget = self._join_list_column
 954        if not columnwidget:
 955            return
 956
 957        assert self._join_text
 958        assert self._filter_text
 959
 960        # Janky - allow escaping when there's nothing in our list.
 961        assert self._host_scrollwidget
 962        bui.containerwidget(
 963            edit=self._host_scrollwidget,
 964            claims_up_down=(len(self._parties_displayed) > 0),
 965        )
 966
 967        # Clip if we have more UI rows than parties to show.
 968        clipcount = len(self._ui_rows) - len(self._parties_displayed)
 969        if clipcount > 0:
 970            clipcount = max(clipcount, 50)
 971            self._ui_rows = self._ui_rows[:-clipcount]
 972
 973        # If we have no parties to show, we're done.
 974        if not self._parties_displayed:
 975            return
 976
 977        sub_scroll_width = 830
 978        lineheight = 42
 979        sub_scroll_height = lineheight * len(self._parties_displayed) + 50
 980        bui.containerwidget(
 981            edit=columnwidget, size=(sub_scroll_width, sub_scroll_height)
 982        )
 983
 984        # Any time our height changes, reset the refresh back to the top
 985        # so we don't see ugly empty spaces appearing during initial list
 986        # filling.
 987        if sub_scroll_height != self._last_sub_scroll_height:
 988            self._refresh_ui_row = 0
 989            self._last_sub_scroll_height = sub_scroll_height
 990
 991            # Also note that we need to redisplay everything since its pos
 992            # will have changed.. :(
 993            for party in self._parties.values():
 994                party.clean_display_index = None
 995
 996        # Ew; this rebuilding generates deferred selection callbacks
 997        # so we need to push deferred notices so we know to ignore them.
 998        def refresh_on() -> None:
 999            self._refreshing_list = True
1000
1001        bui.pushcall(refresh_on)
1002
1003        # Ok, now here's the deal: we want to avoid creating/updating this
1004        # entire list at one time because it will lead to hitches. So we
1005        # refresh individual rows quickly in a loop.
1006        rowcount = min(12, len(self._parties_displayed))
1007
1008        party_vals_displayed = list(self._parties_displayed.values())
1009        while rowcount > 0:
1010            refresh_row = self._refresh_ui_row % len(self._parties_displayed)
1011            if refresh_row >= len(self._ui_rows):
1012                self._ui_rows.append(UIRow())
1013                refresh_row = len(self._ui_rows) - 1
1014
1015            # For the first few seconds after getting our first server-list,
1016            # refresh only the top section of the list; this allows the lowest
1017            # ping servers to show up more quickly.
1018            if self._first_valid_server_list_time is not None:
1019                if time.time() - self._first_valid_server_list_time < 4.0:
1020                    if refresh_row > 40:
1021                        refresh_row = 0
1022
1023            self._ui_rows[refresh_row].update(
1024                refresh_row,
1025                party_vals_displayed[refresh_row],
1026                sub_scroll_width=sub_scroll_width,
1027                sub_scroll_height=sub_scroll_height,
1028                lineheight=lineheight,
1029                columnwidget=columnwidget,
1030                join_text=self._join_text,
1031                existing_selection=self._selection,
1032                filter_text=self._filter_text,
1033                tab=self,
1034            )
1035            self._refresh_ui_row = refresh_row + 1
1036            rowcount -= 1
1037
1038        # So our selection callbacks can start firing..
1039        def refresh_off() -> None:
1040            self._refreshing_list = False
1041
1042        bui.pushcall(refresh_off)
1043
1044    def _process_pending_party_infos(self) -> None:
1045        starttime = time.time()
1046
1047        # We want to do this in small enough pieces to not cause UI hitches.
1048        chunksize = 30
1049        parties_in = self._pending_party_infos[:chunksize]
1050        self._pending_party_infos = self._pending_party_infos[chunksize:]
1051        for party_in in parties_in:
1052            addr = party_in['a']
1053            assert isinstance(addr, str)
1054            port = party_in['p']
1055            assert isinstance(port, int)
1056            party_key = f'{addr}_{port}'
1057            party = self._parties.get(party_key)
1058            if party is None:
1059                # If this party is new to us, init it.
1060                party = PartyEntry(
1061                    address=addr,
1062                    next_ping_time=bui.apptime() + 0.001 * party_in['pd'],
1063                    index=self._next_entry_index,
1064                )
1065                self._parties[party_key] = party
1066                self._parties_sorted.append((party_key, party))
1067                self._party_lists_dirty = True
1068                self._next_entry_index += 1
1069                assert isinstance(party.address, str)
1070                assert isinstance(party.next_ping_time, float)
1071
1072            # Now, new or not, update its values.
1073            party.queue = party_in.get('q')
1074            assert isinstance(party.queue, (str, type(None)))
1075            party.port = port
1076            party.name = party_in['n']
1077            assert isinstance(party.name, str)
1078            party.size = party_in['s']
1079            assert isinstance(party.size, int)
1080            party.size_max = party_in['sm']
1081            assert isinstance(party.size_max, int)
1082
1083            # Server provides this in milliseconds; we use seconds.
1084            party.ping_interval = 0.001 * party_in['pi']
1085            assert isinstance(party.ping_interval, float)
1086            party.stats_addr = party_in['sa']
1087            assert isinstance(party.stats_addr, (str, type(None)))
1088
1089            # Make sure the party's UI gets updated.
1090            party.clean_display_index = None
1091
1092        if DEBUG_PROCESSING and parties_in:
1093            print(
1094                f'Processed {len(parties_in)} raw party infos in'
1095                f' {time.time()-starttime:.5f}s.'
1096            )
1097
1098    def _update_party_lists(self) -> None:
1099        plus = bui.app.plus
1100        assert plus is not None
1101
1102        if not self._party_lists_dirty:
1103            return
1104        starttime = time.time()
1105        assert len(self._parties_sorted) == len(self._parties)
1106
1107        self._parties_sorted.sort(
1108            key=lambda p: (
1109                p[1].ping if p[1].ping is not None else 999999.0,
1110                p[1].index,
1111            )
1112        )
1113
1114        # If signed out or errored, show no parties.
1115        if (
1116            plus.get_v1_account_state() != 'signed_in'
1117            or not self._have_valid_server_list
1118        ):
1119            self._parties_displayed = {}
1120        else:
1121            if self._filter_value:
1122                filterval = self._filter_value.lower()
1123                self._parties_displayed = {
1124                    k: v
1125                    for k, v in self._parties_sorted
1126                    if filterval in v.name.lower()
1127                }
1128            else:
1129                self._parties_displayed = dict(self._parties_sorted)
1130
1131        # Any time our selection disappears from the displayed list, go back to
1132        # auto-selecting the top entry.
1133        if (
1134            self._selection is not None
1135            and self._selection.entry_key not in self._parties_displayed
1136        ):
1137            self._have_user_selected_row = False
1138
1139        # Whenever the user hasn't selected something, keep the first visible
1140        # row selected.
1141        if not self._have_user_selected_row and self._parties_displayed:
1142            firstpartykey = next(iter(self._parties_displayed))
1143            self._selection = Selection(firstpartykey, SelectionComponent.NAME)
1144
1145        self._party_lists_dirty = False
1146        if DEBUG_PROCESSING:
1147            print(
1148                f'Sorted {len(self._parties_sorted)} parties in'
1149                f' {time.time()-starttime:.5f}s.'
1150            )
1151
1152    def _query_party_list_periodically(self) -> None:
1153        now = bui.apptime()
1154
1155        plus = bui.app.plus
1156        assert plus is not None
1157
1158        # Fire off a new public-party query periodically.
1159        if (
1160            self._last_server_list_query_time is None
1161            or now - self._last_server_list_query_time
1162            > 0.001
1163            * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000)
1164        ):
1165            self._last_server_list_query_time = now
1166            if DEBUG_SERVER_COMMUNICATION:
1167                print('REQUESTING SERVER LIST')
1168            if plus.get_v1_account_state() == 'signed_in':
1169                plus.add_v1_account_transaction(
1170                    {
1171                        'type': 'PUBLIC_PARTY_QUERY',
1172                        'proto': bs.protocol_version(),
1173                        'lang': bui.app.lang.language,
1174                    },
1175                    callback=bui.WeakCall(self._on_public_party_query_result),
1176                )
1177                plus.run_v1_account_transactions()
1178            else:
1179                self._on_public_party_query_result(None)
1180
1181    def _ping_parties_periodically(self) -> None:
1182        assert bui.app.classic is not None
1183        now = bui.apptime()
1184
1185        # Go through our existing public party entries firing off pings
1186        # for any that have timed out.
1187        for party in list(self._parties.values()):
1188            if (
1189                party.next_ping_time <= now
1190                and bui.app.classic.ping_thread_count < 15
1191            ):
1192                # Crank the interval up for high-latency or non-responding
1193                # parties to save us some useless work.
1194                mult = 1
1195                if party.ping_responses == 0:
1196                    if party.ping_attempts > 4:
1197                        mult = 10
1198                    elif party.ping_attempts > 2:
1199                        mult = 5
1200                if party.ping is not None:
1201                    mult = (
1202                        10 if party.ping > 300 else 5 if party.ping > 150 else 2
1203                    )
1204
1205                interval = party.ping_interval * mult
1206                if DEBUG_SERVER_COMMUNICATION:
1207                    print(
1208                        f'pinging #{party.index} cur={party.ping} '
1209                        f'interval={interval} '
1210                        f'({party.ping_responses}/{party.ping_attempts})'
1211                    )
1212
1213                party.next_ping_time = now + party.ping_interval * mult
1214                party.ping_attempts += 1
1215
1216                PingThread(
1217                    party.address, party.port, bui.WeakCall(self._ping_callback)
1218                ).start()
1219
1220    def _ping_callback(
1221        self, address: str, port: int | None, result: float | None
1222    ) -> None:
1223        # Look for a widget corresponding to this target.
1224        # If we find one, update our list.
1225        party_key = f'{address}_{port}'
1226        party = self._parties.get(party_key)
1227        if party is not None:
1228            if result is not None:
1229                party.ping_responses += 1
1230
1231            # We now smooth ping a bit to reduce jumping around in the list
1232            # (only where pings are relatively good).
1233            current_ping = party.ping
1234            if current_ping is not None and result is not None and result < 150:
1235                smoothing = 0.7
1236                party.ping = (
1237                    smoothing * current_ping + (1.0 - smoothing) * result
1238                )
1239            else:
1240                party.ping = result
1241
1242            # Need to re-sort the list and update the row display.
1243            party.clean_display_index = None
1244            self._party_lists_dirty = True
1245
1246    def _fetch_local_addr_cb(self, val: str) -> None:
1247        self._local_address = str(val)
1248
1249    def _on_public_party_accessible_response(
1250        self, data: dict[str, Any] | None
1251    ) -> None:
1252        # If we've got status text widgets, update them.
1253        text = self._host_status_text
1254        if text:
1255            if data is None:
1256                bui.textwidget(
1257                    edit=text,
1258                    text=bui.Lstr(
1259                        resource='gatherWindow.' 'partyStatusNoConnectionText'
1260                    ),
1261                    color=(1, 0, 0),
1262                )
1263            else:
1264                if not data.get('accessible', False):
1265                    ex_line: str | bui.Lstr
1266                    if self._local_address is not None:
1267                        ex_line = bui.Lstr(
1268                            value='\n${A} ${B}',
1269                            subs=[
1270                                (
1271                                    '${A}',
1272                                    bui.Lstr(
1273                                        resource='gatherWindow.'
1274                                        'manualYourLocalAddressText'
1275                                    ),
1276                                ),
1277                                ('${B}', self._local_address),
1278                            ],
1279                        )
1280                    else:
1281                        ex_line = ''
1282                    bui.textwidget(
1283                        edit=text,
1284                        text=bui.Lstr(
1285                            value='${A}\n${B}${C}',
1286                            subs=[
1287                                (
1288                                    '${A}',
1289                                    bui.Lstr(
1290                                        resource='gatherWindow.'
1291                                        'partyStatusNotJoinableText'
1292                                    ),
1293                                ),
1294                                (
1295                                    '${B}',
1296                                    bui.Lstr(
1297                                        resource='gatherWindow.'
1298                                        'manualRouterForwardingText',
1299                                        subs=[
1300                                            (
1301                                                '${PORT}',
1302                                                str(bs.get_game_port()),
1303                                            )
1304                                        ],
1305                                    ),
1306                                ),
1307                                ('${C}', ex_line),
1308                            ],
1309                        ),
1310                        color=(1, 0, 0),
1311                    )
1312                else:
1313                    bui.textwidget(
1314                        edit=text,
1315                        text=bui.Lstr(
1316                            resource='gatherWindow.' 'partyStatusJoinableText'
1317                        ),
1318                        color=(0, 1, 0),
1319                    )
1320
1321    def _do_status_check(self) -> None:
1322        assert bui.app.classic is not None
1323        bui.textwidget(
1324            edit=self._host_status_text,
1325            color=(1, 1, 0),
1326            text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'),
1327        )
1328        bui.app.classic.master_server_v1_get(
1329            'bsAccessCheck',
1330            {'b': bui.app.env.build_number},
1331            callback=bui.WeakCall(self._on_public_party_accessible_response),
1332        )
1333
1334    def _on_start_advertizing_press(self) -> None:
1335        from bauiv1lib.account import show_sign_in_prompt
1336
1337        plus = bui.app.plus
1338        assert plus is not None
1339
1340        if plus.get_v1_account_state() != 'signed_in':
1341            show_sign_in_prompt()
1342            return
1343
1344        name = cast(str, bui.textwidget(query=self._host_name_text))
1345        if name == '':
1346            bui.screenmessage(
1347                bui.Lstr(resource='internal.invalidNameErrorText'),
1348                color=(1, 0, 0),
1349            )
1350            bui.getsound('error').play()
1351            return
1352        bs.set_public_party_name(name)
1353        cfg = bui.app.config
1354        cfg['Public Party Name'] = name
1355        cfg.commit()
1356        bui.getsound('shieldUp').play()
1357        bs.set_public_party_enabled(True)
1358
1359        # In GUI builds we want to authenticate clients only when hosting
1360        # public parties.
1361        bs.set_authenticate_clients(True)
1362
1363        self._do_status_check()
1364        bui.buttonwidget(
1365            edit=self._host_toggle_button,
1366            label=bui.Lstr(
1367                resource='gatherWindow.makePartyPrivateText',
1368                fallback_resource='gatherWindow.stopAdvertisingText',
1369            ),
1370            on_activate_call=self._on_stop_advertising_press,
1371        )
1372
1373    def _on_stop_advertising_press(self) -> None:
1374        bs.set_public_party_enabled(False)
1375
1376        # In GUI builds we want to authenticate clients only when hosting
1377        # public parties.
1378        bs.set_authenticate_clients(False)
1379        bui.getsound('shieldDown').play()
1380        text = self._host_status_text
1381        if text:
1382            bui.textwidget(
1383                edit=text,
1384                text=bui.Lstr(
1385                    resource='gatherWindow.' 'partyStatusNotPublicText'
1386                ),
1387                color=(0.6, 0.6, 0.6),
1388            )
1389        bui.buttonwidget(
1390            edit=self._host_toggle_button,
1391            label=bui.Lstr(
1392                resource='gatherWindow.makePartyPublicText',
1393                fallback_resource='gatherWindow.startAdvertisingText',
1394            ),
1395            on_activate_call=self._on_start_advertizing_press,
1396        )
1397
1398    def on_public_party_activate(self, party: PartyEntry) -> None:
1399        """Called when a party is clicked or otherwise activated."""
1400        self.save_state()
1401        if party.queue is not None:
1402            from bauiv1lib.partyqueue import PartyQueueWindow
1403
1404            bui.getsound('swish').play()
1405            PartyQueueWindow(party.queue, party.address, party.port)
1406        else:
1407            address = party.address
1408            port = party.port
1409
1410            # Rate limit this a bit.
1411            now = time.time()
1412            last_connect_time = self._last_connect_attempt_time
1413            if last_connect_time is None or now - last_connect_time > 2.0:
1414                bs.connect_to_party(address, port=port)
1415                self._last_connect_attempt_time = now
1416
1417    def set_public_party_selection(self, sel: Selection) -> None:
1418        """Set the sel."""
1419        if self._refreshing_list:
1420            return
1421        self._selection = sel
1422        self._have_user_selected_row = True
1423
1424    def _on_max_public_party_size_minus_press(self) -> None:
1425        val = max(1, bs.get_public_party_max_size() - 1)
1426        bs.set_public_party_max_size(val)
1427        bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
1428
1429    def _on_max_public_party_size_plus_press(self) -> None:
1430        val = bs.get_public_party_max_size()
1431        val += 1
1432        bs.set_public_party_max_size(val)
1433        bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
DEBUG_SERVER_COMMUNICATION = False
DEBUG_PROCESSING = False
class SubTabType(enum.Enum):
31class SubTabType(Enum):
32    """Available sub-tabs."""
33
34    JOIN = 'join'
35    HOST = 'host'

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class PartyEntry:
38@dataclass
39class PartyEntry:
40    """Info about a public party."""
41
42    address: str
43    index: int
44    queue: str | None = None
45    port: int = -1
46    name: str = ''
47    size: int = -1
48    size_max: int = -1
49    claimed: bool = False
50    ping: float | None = None
51    ping_interval: float = -1.0
52    next_ping_time: float = -1.0
53    ping_attempts: int = 0
54    ping_responses: int = 0
55    stats_addr: str | None = None
56    clean_display_index: int | None = None
57
58    def get_key(self) -> str:
59        """Return the key used to store this party."""
60        return f'{self.address}_{self.port}'

Info about a public party.

PartyEntry( address: str, index: int, queue: str | None = None, port: int = -1, name: str = '', size: int = -1, size_max: int = -1, claimed: bool = False, ping: float | None = None, ping_interval: float = -1.0, next_ping_time: float = -1.0, ping_attempts: int = 0, ping_responses: int = 0, stats_addr: str | None = None, clean_display_index: int | None = None)
address: str
index: int
queue: str | None = None
port: int = -1
name: str = ''
size: int = -1
size_max: int = -1
claimed: bool = False
ping: float | None = None
ping_interval: float = -1.0
next_ping_time: float = -1.0
ping_attempts: int = 0
ping_responses: int = 0
stats_addr: str | None = None
clean_display_index: int | None = None
def get_key(self) -> str:
58    def get_key(self) -> str:
59        """Return the key used to store this party."""
60        return f'{self.address}_{self.port}'

Return the key used to store this party.

class UIRow:
 63class UIRow:
 64    """Wrangles UI for a row in the party list."""
 65
 66    def __init__(self) -> None:
 67        self._name_widget: bui.Widget | None = None
 68        self._size_widget: bui.Widget | None = None
 69        self._ping_widget: bui.Widget | None = None
 70        self._stats_button: bui.Widget | None = None
 71
 72    def __del__(self) -> None:
 73        self._clear()
 74
 75    def _clear(self) -> None:
 76        for widget in [
 77            self._name_widget,
 78            self._size_widget,
 79            self._ping_widget,
 80            self._stats_button,
 81        ]:
 82            if widget:
 83                widget.delete()
 84
 85    def update(
 86        self,
 87        index: int,
 88        party: PartyEntry,
 89        sub_scroll_width: float,
 90        sub_scroll_height: float,
 91        lineheight: float,
 92        columnwidget: bui.Widget,
 93        join_text: bui.Widget,
 94        filter_text: bui.Widget,
 95        existing_selection: Selection | None,
 96        tab: PublicGatherTab,
 97    ) -> None:
 98        """Update for the given data."""
 99        # pylint: disable=too-many-locals
100
101        plus = bui.app.plus
102        assert plus is not None
103
104        # Quick-out: if we've been marked clean for a certain index and
105        # we're still at that index, we're done.
106        if party.clean_display_index == index:
107            return
108
109        ping_good = plus.get_v1_account_misc_read_val('pingGood', 100)
110        ping_med = plus.get_v1_account_misc_read_val('pingMed', 500)
111
112        self._clear()
113        hpos = 20
114        vpos = sub_scroll_height - lineheight * index - 50
115        self._name_widget = bui.textwidget(
116            text=bui.Lstr(value=party.name),
117            parent=columnwidget,
118            size=(sub_scroll_width * 0.63, 20),
119            position=(0 + hpos, 4 + vpos),
120            selectable=True,
121            on_select_call=bui.WeakCall(
122                tab.set_public_party_selection,
123                Selection(party.get_key(), SelectionComponent.NAME),
124            ),
125            on_activate_call=bui.WeakCall(tab.on_public_party_activate, party),
126            click_activate=True,
127            maxwidth=sub_scroll_width * 0.45,
128            corner_scale=1.4,
129            autoselect=True,
130            color=(1, 1, 1, 0.3 if party.ping is None else 1.0),
131            h_align='left',
132            v_align='center',
133        )
134        bui.widget(
135            edit=self._name_widget,
136            left_widget=join_text,
137            show_buffer_top=64.0,
138            show_buffer_bottom=64.0,
139        )
140        if existing_selection == Selection(
141            party.get_key(), SelectionComponent.NAME
142        ):
143            bui.containerwidget(
144                edit=columnwidget, selected_child=self._name_widget
145            )
146        if party.stats_addr:
147            url = party.stats_addr.replace(
148                '${ACCOUNT}',
149                plus.get_v1_account_misc_read_val_2(
150                    'resolvedAccountID', 'UNKNOWN'
151                ),
152            )
153            self._stats_button = bui.buttonwidget(
154                color=(0.3, 0.6, 0.94),
155                textcolor=(1.0, 1.0, 1.0),
156                label=bui.Lstr(resource='statsText'),
157                parent=columnwidget,
158                autoselect=True,
159                on_activate_call=bui.Call(bui.open_url, url),
160                on_select_call=bui.WeakCall(
161                    tab.set_public_party_selection,
162                    Selection(party.get_key(), SelectionComponent.STATS_BUTTON),
163                ),
164                size=(120, 40),
165                position=(sub_scroll_width * 0.66 + hpos, 1 + vpos),
166                scale=0.9,
167            )
168            if existing_selection == Selection(
169                party.get_key(), SelectionComponent.STATS_BUTTON
170            ):
171                bui.containerwidget(
172                    edit=columnwidget, selected_child=self._stats_button
173                )
174
175        self._size_widget = bui.textwidget(
176            text=str(party.size) + '/' + str(party.size_max),
177            parent=columnwidget,
178            size=(0, 0),
179            position=(sub_scroll_width * 0.86 + hpos, 20 + vpos),
180            scale=0.7,
181            color=(0.8, 0.8, 0.8),
182            h_align='right',
183            v_align='center',
184        )
185
186        if index == 0:
187            bui.widget(edit=self._name_widget, up_widget=filter_text)
188            if self._stats_button:
189                bui.widget(edit=self._stats_button, up_widget=filter_text)
190
191        self._ping_widget = bui.textwidget(
192            parent=columnwidget,
193            size=(0, 0),
194            position=(sub_scroll_width * 0.94 + hpos, 20 + vpos),
195            scale=0.7,
196            h_align='right',
197            v_align='center',
198        )
199        if party.ping is None:
200            bui.textwidget(
201                edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5)
202            )
203        else:
204            bui.textwidget(
205                edit=self._ping_widget,
206                text=str(int(party.ping)),
207                color=(0, 1, 0)
208                if party.ping <= ping_good
209                else (1, 1, 0)
210                if party.ping <= ping_med
211                else (1, 0, 0),
212            )
213
214        party.clean_display_index = index

Wrangles UI for a row in the party list.

def update( self, index: int, party: PartyEntry, sub_scroll_width: float, sub_scroll_height: float, lineheight: float, columnwidget: _bauiv1.Widget, join_text: _bauiv1.Widget, filter_text: _bauiv1.Widget, existing_selection: Selection | None, tab: PublicGatherTab) -> None:
 85    def update(
 86        self,
 87        index: int,
 88        party: PartyEntry,
 89        sub_scroll_width: float,
 90        sub_scroll_height: float,
 91        lineheight: float,
 92        columnwidget: bui.Widget,
 93        join_text: bui.Widget,
 94        filter_text: bui.Widget,
 95        existing_selection: Selection | None,
 96        tab: PublicGatherTab,
 97    ) -> None:
 98        """Update for the given data."""
 99        # pylint: disable=too-many-locals
100
101        plus = bui.app.plus
102        assert plus is not None
103
104        # Quick-out: if we've been marked clean for a certain index and
105        # we're still at that index, we're done.
106        if party.clean_display_index == index:
107            return
108
109        ping_good = plus.get_v1_account_misc_read_val('pingGood', 100)
110        ping_med = plus.get_v1_account_misc_read_val('pingMed', 500)
111
112        self._clear()
113        hpos = 20
114        vpos = sub_scroll_height - lineheight * index - 50
115        self._name_widget = bui.textwidget(
116            text=bui.Lstr(value=party.name),
117            parent=columnwidget,
118            size=(sub_scroll_width * 0.63, 20),
119            position=(0 + hpos, 4 + vpos),
120            selectable=True,
121            on_select_call=bui.WeakCall(
122                tab.set_public_party_selection,
123                Selection(party.get_key(), SelectionComponent.NAME),
124            ),
125            on_activate_call=bui.WeakCall(tab.on_public_party_activate, party),
126            click_activate=True,
127            maxwidth=sub_scroll_width * 0.45,
128            corner_scale=1.4,
129            autoselect=True,
130            color=(1, 1, 1, 0.3 if party.ping is None else 1.0),
131            h_align='left',
132            v_align='center',
133        )
134        bui.widget(
135            edit=self._name_widget,
136            left_widget=join_text,
137            show_buffer_top=64.0,
138            show_buffer_bottom=64.0,
139        )
140        if existing_selection == Selection(
141            party.get_key(), SelectionComponent.NAME
142        ):
143            bui.containerwidget(
144                edit=columnwidget, selected_child=self._name_widget
145            )
146        if party.stats_addr:
147            url = party.stats_addr.replace(
148                '${ACCOUNT}',
149                plus.get_v1_account_misc_read_val_2(
150                    'resolvedAccountID', 'UNKNOWN'
151                ),
152            )
153            self._stats_button = bui.buttonwidget(
154                color=(0.3, 0.6, 0.94),
155                textcolor=(1.0, 1.0, 1.0),
156                label=bui.Lstr(resource='statsText'),
157                parent=columnwidget,
158                autoselect=True,
159                on_activate_call=bui.Call(bui.open_url, url),
160                on_select_call=bui.WeakCall(
161                    tab.set_public_party_selection,
162                    Selection(party.get_key(), SelectionComponent.STATS_BUTTON),
163                ),
164                size=(120, 40),
165                position=(sub_scroll_width * 0.66 + hpos, 1 + vpos),
166                scale=0.9,
167            )
168            if existing_selection == Selection(
169                party.get_key(), SelectionComponent.STATS_BUTTON
170            ):
171                bui.containerwidget(
172                    edit=columnwidget, selected_child=self._stats_button
173                )
174
175        self._size_widget = bui.textwidget(
176            text=str(party.size) + '/' + str(party.size_max),
177            parent=columnwidget,
178            size=(0, 0),
179            position=(sub_scroll_width * 0.86 + hpos, 20 + vpos),
180            scale=0.7,
181            color=(0.8, 0.8, 0.8),
182            h_align='right',
183            v_align='center',
184        )
185
186        if index == 0:
187            bui.widget(edit=self._name_widget, up_widget=filter_text)
188            if self._stats_button:
189                bui.widget(edit=self._stats_button, up_widget=filter_text)
190
191        self._ping_widget = bui.textwidget(
192            parent=columnwidget,
193            size=(0, 0),
194            position=(sub_scroll_width * 0.94 + hpos, 20 + vpos),
195            scale=0.7,
196            h_align='right',
197            v_align='center',
198        )
199        if party.ping is None:
200            bui.textwidget(
201                edit=self._ping_widget, text='-', color=(0.5, 0.5, 0.5)
202            )
203        else:
204            bui.textwidget(
205                edit=self._ping_widget,
206                text=str(int(party.ping)),
207                color=(0, 1, 0)
208                if party.ping <= ping_good
209                else (1, 1, 0)
210                if party.ping <= ping_med
211                else (1, 0, 0),
212            )
213
214        party.clean_display_index = index

Update for the given data.

@dataclass
class State:
217@dataclass
218class State:
219    """State saved/restored only while the app is running."""
220
221    sub_tab: SubTabType = SubTabType.JOIN
222    parties: list[tuple[str, PartyEntry]] | None = None
223    next_entry_index: int = 0
224    filter_value: str = ''
225    have_server_list_response: bool = False
226    have_valid_server_list: bool = False

State saved/restored only while the app is running.

State( sub_tab: SubTabType = <SubTabType.JOIN: 'join'>, parties: list[tuple[str, PartyEntry]] | None = None, next_entry_index: int = 0, filter_value: str = '', have_server_list_response: bool = False, have_valid_server_list: bool = False)
sub_tab: SubTabType = <SubTabType.JOIN: 'join'>
parties: list[tuple[str, PartyEntry]] | None = None
next_entry_index: int = 0
filter_value: str = ''
have_server_list_response: bool = False
have_valid_server_list: bool = False
class SelectionComponent(enum.Enum):
229class SelectionComponent(Enum):
230    """Describes what part of an entry is selected."""
231
232    NAME = 'name'
233    STATS_BUTTON = 'stats_button'

Describes what part of an entry is selected.

NAME = <SelectionComponent.NAME: 'name'>
STATS_BUTTON = <SelectionComponent.STATS_BUTTON: 'stats_button'>
Inherited Members
enum.Enum
name
value
@dataclass
class Selection:
236@dataclass
237class Selection:
238    """Describes the currently selected list element."""
239
240    entry_key: str
241    component: SelectionComponent

Describes the currently selected list element.

Selection( entry_key: str, component: SelectionComponent)
entry_key: str
component: SelectionComponent
class AddrFetchThread(threading.Thread):
244class AddrFetchThread(Thread):
245    """Thread for fetching an address in the bg."""
246
247    def __init__(self, call: Callable[[Any], Any]):
248        super().__init__()
249        self._call = call
250
251    def run(self) -> None:
252        try:
253            # FIXME: Update this to work with IPv6 at some point.
254            import socket
255
256            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
257            sock.connect(('8.8.8.8', 80))
258            val = sock.getsockname()[0]
259            sock.close()
260            bui.pushcall(bui.Call(self._call, val), from_other_thread=True)
261        except Exception as exc:
262            from efro.error import is_udp_communication_error
263
264            # Ignore expected network errors; log others.
265            if is_udp_communication_error(exc):
266                pass
267            else:
268                logging.exception('Error in addr-fetch-thread')

Thread for fetching an address in the bg.

AddrFetchThread(call: Callable[[Any], Any])
247    def __init__(self, call: Callable[[Any], Any]):
248        super().__init__()
249        self._call = call

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

def run(self) -> None:
251    def run(self) -> None:
252        try:
253            # FIXME: Update this to work with IPv6 at some point.
254            import socket
255
256            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
257            sock.connect(('8.8.8.8', 80))
258            val = sock.getsockname()[0]
259            sock.close()
260            bui.pushcall(bui.Call(self._call, val), from_other_thread=True)
261        except Exception as exc:
262            from efro.error import is_udp_communication_error
263
264            # Ignore expected network errors; log others.
265            if is_udp_communication_error(exc):
266                pass
267            else:
268                logging.exception('Error in addr-fetch-thread')

Method representing the thread's activity.

You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.

Inherited Members
threading.Thread
start
join
name
ident
is_alive
daemon
isDaemon
setDaemon
getName
setName
native_id
class PingThread(threading.Thread):
271class PingThread(Thread):
272    """Thread for sending out game pings."""
273
274    def __init__(
275        self,
276        address: str,
277        port: int,
278        call: Callable[[str, int, float | None], int | None],
279    ):
280        super().__init__()
281        self._address = address
282        self._port = port
283        self._call = call
284
285    def run(self) -> None:
286        assert bui.app.classic is not None
287        bui.app.classic.ping_thread_count += 1
288        sock: socket.socket | None = None
289        try:
290            import socket
291
292            socket_type = bui.get_ip_address_type(self._address)
293            sock = socket.socket(socket_type, socket.SOCK_DGRAM)
294            sock.connect((self._address, self._port))
295
296            accessible = False
297            starttime = time.time()
298
299            # Send a few pings and wait a second for
300            # a response.
301            sock.settimeout(1)
302            for _i in range(3):
303                sock.send(b'\x0b')
304                result: bytes | None
305                try:
306                    # 11: BA_PACKET_SIMPLE_PING
307                    result = sock.recv(10)
308                except Exception:
309                    result = None
310                if result == b'\x0c':
311                    # 12: BA_PACKET_SIMPLE_PONG
312                    accessible = True
313                    break
314                time.sleep(1)
315            ping = (time.time() - starttime) * 1000.0
316            bui.pushcall(
317                bui.Call(
318                    self._call,
319                    self._address,
320                    self._port,
321                    ping if accessible else None,
322                ),
323                from_other_thread=True,
324            )
325        except Exception as exc:
326            from efro.error import is_udp_communication_error
327
328            if is_udp_communication_error(exc):
329                pass
330            else:
331                if bui.do_once():
332                    logging.exception('Error on gather ping.')
333        finally:
334            try:
335                if sock is not None:
336                    sock.close()
337            except Exception:
338                if bui.do_once():
339                    logging.exception('Error on gather ping cleanup')
340
341        bui.app.classic.ping_thread_count -= 1

Thread for sending out game pings.

PingThread( address: str, port: int, call: Callable[[str, int, float | None], int | None])
274    def __init__(
275        self,
276        address: str,
277        port: int,
278        call: Callable[[str, int, float | None], int | None],
279    ):
280        super().__init__()
281        self._address = address
282        self._port = port
283        self._call = call

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

def run(self) -> None:
285    def run(self) -> None:
286        assert bui.app.classic is not None
287        bui.app.classic.ping_thread_count += 1
288        sock: socket.socket | None = None
289        try:
290            import socket
291
292            socket_type = bui.get_ip_address_type(self._address)
293            sock = socket.socket(socket_type, socket.SOCK_DGRAM)
294            sock.connect((self._address, self._port))
295
296            accessible = False
297            starttime = time.time()
298
299            # Send a few pings and wait a second for
300            # a response.
301            sock.settimeout(1)
302            for _i in range(3):
303                sock.send(b'\x0b')
304                result: bytes | None
305                try:
306                    # 11: BA_PACKET_SIMPLE_PING
307                    result = sock.recv(10)
308                except Exception:
309                    result = None
310                if result == b'\x0c':
311                    # 12: BA_PACKET_SIMPLE_PONG
312                    accessible = True
313                    break
314                time.sleep(1)
315            ping = (time.time() - starttime) * 1000.0
316            bui.pushcall(
317                bui.Call(
318                    self._call,
319                    self._address,
320                    self._port,
321                    ping if accessible else None,
322                ),
323                from_other_thread=True,
324            )
325        except Exception as exc:
326            from efro.error import is_udp_communication_error
327
328            if is_udp_communication_error(exc):
329                pass
330            else:
331                if bui.do_once():
332                    logging.exception('Error on gather ping.')
333        finally:
334            try:
335                if sock is not None:
336                    sock.close()
337            except Exception:
338                if bui.do_once():
339                    logging.exception('Error on gather ping cleanup')
340
341        bui.app.classic.ping_thread_count -= 1

Method representing the thread's activity.

You may override this method in a subclass. The standard run() method invokes the callable object passed to the object's constructor as the target argument, if any, with sequential and keyword arguments taken from the args and kwargs arguments, respectively.

Inherited Members
threading.Thread
start
join
name
ident
is_alive
daemon
isDaemon
setDaemon
getName
setName
native_id
class PublicGatherTab(bauiv1lib.gather.GatherTab):
 344class PublicGatherTab(GatherTab):
 345    """The public tab in the gather UI"""
 346
 347    def __init__(self, window: GatherWindow) -> None:
 348        super().__init__(window)
 349        self._container: bui.Widget | None = None
 350        self._join_text: bui.Widget | None = None
 351        self._host_text: bui.Widget | None = None
 352        self._filter_text: bui.Widget | None = None
 353        self._local_address: str | None = None
 354        self._last_connect_attempt_time: float | None = None
 355        self._sub_tab: SubTabType = SubTabType.JOIN
 356        self._selection: Selection | None = None
 357        self._refreshing_list = False
 358        self._update_timer: bui.AppTimer | None = None
 359        self._host_scrollwidget: bui.Widget | None = None
 360        self._host_name_text: bui.Widget | None = None
 361        self._host_toggle_button: bui.Widget | None = None
 362        self._last_server_list_query_time: float | None = None
 363        self._join_list_column: bui.Widget | None = None
 364        self._join_status_text: bui.Widget | None = None
 365        self._host_max_party_size_value: bui.Widget | None = None
 366        self._host_max_party_size_minus_button: (bui.Widget | None) = None
 367        self._host_max_party_size_plus_button: (bui.Widget | None) = None
 368        self._host_status_text: bui.Widget | None = None
 369        self._signed_in = False
 370        self._ui_rows: list[UIRow] = []
 371        self._refresh_ui_row = 0
 372        self._have_user_selected_row = False
 373        self._first_valid_server_list_time: float | None = None
 374
 375        # Parties indexed by id:
 376        self._parties: dict[str, PartyEntry] = {}
 377
 378        # Parties sorted in display order:
 379        self._parties_sorted: list[tuple[str, PartyEntry]] = []
 380        self._party_lists_dirty = True
 381
 382        # Sorted parties with filter applied:
 383        self._parties_displayed: dict[str, PartyEntry] = {}
 384
 385        self._next_entry_index = 0
 386        self._have_server_list_response = False
 387        self._have_valid_server_list = False
 388        self._filter_value = ''
 389        self._pending_party_infos: list[dict[str, Any]] = []
 390        self._last_sub_scroll_height = 0.0
 391
 392    def on_activate(
 393        self,
 394        parent_widget: bui.Widget,
 395        tab_button: bui.Widget,
 396        region_width: float,
 397        region_height: float,
 398        region_left: float,
 399        region_bottom: float,
 400    ) -> bui.Widget:
 401        c_width = region_width
 402        c_height = region_height - 20
 403        self._container = bui.containerwidget(
 404            parent=parent_widget,
 405            position=(
 406                region_left,
 407                region_bottom + (region_height - c_height) * 0.5,
 408            ),
 409            size=(c_width, c_height),
 410            background=False,
 411            selection_loops_to_parent=True,
 412        )
 413        v = c_height - 30
 414        self._join_text = bui.textwidget(
 415            parent=self._container,
 416            position=(c_width * 0.5 - 245, v - 13),
 417            color=(0.6, 1.0, 0.6),
 418            scale=1.3,
 419            size=(200, 30),
 420            maxwidth=250,
 421            h_align='left',
 422            v_align='center',
 423            click_activate=True,
 424            selectable=True,
 425            autoselect=True,
 426            on_activate_call=lambda: self._set_sub_tab(
 427                SubTabType.JOIN,
 428                region_width,
 429                region_height,
 430                playsound=True,
 431            ),
 432            text=bui.Lstr(
 433                resource='gatherWindow.' 'joinPublicPartyDescriptionText'
 434            ),
 435        )
 436        self._host_text = bui.textwidget(
 437            parent=self._container,
 438            position=(c_width * 0.5 + 45, v - 13),
 439            color=(0.6, 1.0, 0.6),
 440            scale=1.3,
 441            size=(200, 30),
 442            maxwidth=250,
 443            h_align='left',
 444            v_align='center',
 445            click_activate=True,
 446            selectable=True,
 447            autoselect=True,
 448            on_activate_call=lambda: self._set_sub_tab(
 449                SubTabType.HOST,
 450                region_width,
 451                region_height,
 452                playsound=True,
 453            ),
 454            text=bui.Lstr(
 455                resource='gatherWindow.' 'hostPublicPartyDescriptionText'
 456            ),
 457        )
 458        bui.widget(edit=self._join_text, up_widget=tab_button)
 459        bui.widget(
 460            edit=self._host_text,
 461            left_widget=self._join_text,
 462            up_widget=tab_button,
 463        )
 464        bui.widget(edit=self._join_text, right_widget=self._host_text)
 465
 466        # Attempt to fetch our local address so we have it for error messages.
 467        if self._local_address is None:
 468            AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start()
 469
 470        self._set_sub_tab(self._sub_tab, region_width, region_height)
 471        self._update_timer = bui.AppTimer(
 472            0.1, bui.WeakCall(self._update), repeat=True
 473        )
 474        return self._container
 475
 476    def on_deactivate(self) -> None:
 477        self._update_timer = None
 478
 479    def save_state(self) -> None:
 480        # Save off a small number of parties with the lowest ping; we'll
 481        # display these immediately when our UI comes back up which should
 482        # be enough to make things feel nice and crisp while we do a full
 483        # server re-query or whatnot.
 484        assert bui.app.classic is not None
 485        bui.app.ui_v1.window_states[type(self)] = State(
 486            sub_tab=self._sub_tab,
 487            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
 488            next_entry_index=self._next_entry_index,
 489            filter_value=self._filter_value,
 490            have_server_list_response=self._have_server_list_response,
 491            have_valid_server_list=self._have_valid_server_list,
 492        )
 493
 494    def restore_state(self) -> None:
 495        assert bui.app.classic is not None
 496        state = bui.app.ui_v1.window_states.get(type(self))
 497        if state is None:
 498            state = State()
 499        assert isinstance(state, State)
 500        self._sub_tab = state.sub_tab
 501
 502        # Restore the parties we stored.
 503        if state.parties:
 504            self._parties = {
 505                key: copy.copy(party) for key, party in state.parties
 506            }
 507            self._parties_sorted = list(self._parties.items())
 508            self._party_lists_dirty = True
 509
 510            self._next_entry_index = state.next_entry_index
 511
 512            # FIXME: should save/restore these too?..
 513            self._have_server_list_response = state.have_server_list_response
 514            self._have_valid_server_list = state.have_valid_server_list
 515        self._filter_value = state.filter_value
 516
 517    def _set_sub_tab(
 518        self,
 519        value: SubTabType,
 520        region_width: float,
 521        region_height: float,
 522        playsound: bool = False,
 523    ) -> None:
 524        assert self._container
 525        if playsound:
 526            bui.getsound('click01').play()
 527
 528        # Reset our selection.
 529        # (prevents selecting something way down the list if we switched away
 530        # and came back)
 531        self._selection = None
 532        self._have_user_selected_row = False
 533
 534        # Reset refresh to the top and make sure everything refreshes.
 535        self._refresh_ui_row = 0
 536        for party in self._parties.values():
 537            party.clean_display_index = None
 538
 539        self._sub_tab = value
 540        active_color = (0.6, 1.0, 0.6)
 541        inactive_color = (0.5, 0.4, 0.5)
 542        bui.textwidget(
 543            edit=self._join_text,
 544            color=active_color if value is SubTabType.JOIN else inactive_color,
 545        )
 546        bui.textwidget(
 547            edit=self._host_text,
 548            color=active_color if value is SubTabType.HOST else inactive_color,
 549        )
 550
 551        # Clear anything existing in the old sub-tab.
 552        for widget in self._container.get_children():
 553            if widget and widget not in {self._host_text, self._join_text}:
 554                widget.delete()
 555
 556        if value is SubTabType.JOIN:
 557            self._build_join_tab(region_width, region_height)
 558
 559        if value is SubTabType.HOST:
 560            self._build_host_tab(region_width, region_height)
 561
 562    def _build_join_tab(
 563        self, region_width: float, region_height: float
 564    ) -> None:
 565        c_width = region_width
 566        c_height = region_height - 20
 567        sub_scroll_height = c_height - 125
 568        sub_scroll_width = 830
 569        v = c_height - 35
 570        v -= 60
 571        filter_txt = bui.Lstr(resource='filterText')
 572        self._filter_text = bui.textwidget(
 573            parent=self._container,
 574            text=self._filter_value,
 575            size=(350, 45),
 576            position=(290, v - 10),
 577            h_align='left',
 578            v_align='center',
 579            editable=True,
 580            maxwidth=310,
 581            description=filter_txt,
 582        )
 583        bui.widget(edit=self._filter_text, up_widget=self._join_text)
 584        bui.textwidget(
 585            text=filter_txt,
 586            parent=self._container,
 587            size=(0, 0),
 588            position=(270, v + 13),
 589            maxwidth=150,
 590            scale=0.8,
 591            color=(0.5, 0.46, 0.5),
 592            flatness=1.0,
 593            h_align='right',
 594            v_align='center',
 595        )
 596
 597        bui.textwidget(
 598            text=bui.Lstr(resource='nameText'),
 599            parent=self._container,
 600            size=(0, 0),
 601            position=(90, v - 8),
 602            maxwidth=60,
 603            scale=0.6,
 604            color=(0.5, 0.46, 0.5),
 605            flatness=1.0,
 606            h_align='center',
 607            v_align='center',
 608        )
 609        bui.textwidget(
 610            text=bui.Lstr(resource='gatherWindow.partySizeText'),
 611            parent=self._container,
 612            size=(0, 0),
 613            position=(755, v - 8),
 614            maxwidth=60,
 615            scale=0.6,
 616            color=(0.5, 0.46, 0.5),
 617            flatness=1.0,
 618            h_align='center',
 619            v_align='center',
 620        )
 621        bui.textwidget(
 622            text=bui.Lstr(resource='gatherWindow.pingText'),
 623            parent=self._container,
 624            size=(0, 0),
 625            position=(825, v - 8),
 626            maxwidth=60,
 627            scale=0.6,
 628            color=(0.5, 0.46, 0.5),
 629            flatness=1.0,
 630            h_align='center',
 631            v_align='center',
 632        )
 633        v -= sub_scroll_height + 23
 634        self._host_scrollwidget = scrollw = bui.scrollwidget(
 635            parent=self._container,
 636            simple_culling_v=10,
 637            position=((c_width - sub_scroll_width) * 0.5, v),
 638            size=(sub_scroll_width, sub_scroll_height),
 639            claims_up_down=False,
 640            claims_left_right=True,
 641            autoselect=True,
 642        )
 643        self._join_list_column = bui.containerwidget(
 644            parent=scrollw,
 645            background=False,
 646            size=(400, 400),
 647            claims_left_right=True,
 648        )
 649        self._join_status_text = bui.textwidget(
 650            parent=self._container,
 651            text='',
 652            size=(0, 0),
 653            scale=0.9,
 654            flatness=1.0,
 655            shadow=0.0,
 656            h_align='center',
 657            v_align='top',
 658            maxwidth=c_width,
 659            color=(0.6, 0.6, 0.6),
 660            position=(c_width * 0.5, c_height * 0.5),
 661        )
 662
 663    def _build_host_tab(
 664        self, region_width: float, region_height: float
 665    ) -> None:
 666        c_width = region_width
 667        c_height = region_height - 20
 668        v = c_height - 35
 669        v -= 25
 670        is_public_enabled = bs.get_public_party_enabled()
 671        v -= 30
 672
 673        bui.textwidget(
 674            parent=self._container,
 675            size=(0, 0),
 676            h_align='center',
 677            v_align='center',
 678            maxwidth=c_width * 0.9,
 679            scale=0.7,
 680            flatness=1.0,
 681            color=(0.5, 0.46, 0.5),
 682            position=(region_width * 0.5, v + 10),
 683            text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'),
 684        )
 685        v -= 30
 686
 687        party_name_text = bui.Lstr(
 688            resource='gatherWindow.partyNameText',
 689            fallback_resource='editGameListWindow.nameText',
 690        )
 691        assert bui.app.classic is not None
 692        bui.textwidget(
 693            parent=self._container,
 694            size=(0, 0),
 695            h_align='right',
 696            v_align='center',
 697            maxwidth=200,
 698            scale=0.8,
 699            color=bui.app.ui_v1.infotextcolor,
 700            position=(210, v - 9),
 701            text=party_name_text,
 702        )
 703        self._host_name_text = bui.textwidget(
 704            parent=self._container,
 705            editable=True,
 706            size=(535, 40),
 707            position=(230, v - 30),
 708            text=bui.app.config.get('Public Party Name', ''),
 709            maxwidth=494,
 710            shadow=0.3,
 711            flatness=1.0,
 712            description=party_name_text,
 713            autoselect=True,
 714            v_align='center',
 715            corner_scale=1.0,
 716        )
 717
 718        v -= 60
 719        bui.textwidget(
 720            parent=self._container,
 721            size=(0, 0),
 722            h_align='right',
 723            v_align='center',
 724            maxwidth=200,
 725            scale=0.8,
 726            color=bui.app.ui_v1.infotextcolor,
 727            position=(210, v - 9),
 728            text=bui.Lstr(
 729                resource='maxPartySizeText',
 730                fallback_resource='maxConnectionsText',
 731            ),
 732        )
 733        self._host_max_party_size_value = bui.textwidget(
 734            parent=self._container,
 735            size=(0, 0),
 736            h_align='center',
 737            v_align='center',
 738            scale=1.2,
 739            color=(1, 1, 1),
 740            position=(240, v - 9),
 741            text=str(bs.get_public_party_max_size()),
 742        )
 743        btn1 = self._host_max_party_size_minus_button = bui.buttonwidget(
 744            parent=self._container,
 745            size=(40, 40),
 746            on_activate_call=bui.WeakCall(
 747                self._on_max_public_party_size_minus_press
 748            ),
 749            position=(280, v - 26),
 750            label='-',
 751            autoselect=True,
 752        )
 753        btn2 = self._host_max_party_size_plus_button = bui.buttonwidget(
 754            parent=self._container,
 755            size=(40, 40),
 756            on_activate_call=bui.WeakCall(
 757                self._on_max_public_party_size_plus_press
 758            ),
 759            position=(350, v - 26),
 760            label='+',
 761            autoselect=True,
 762        )
 763        v -= 50
 764        v -= 70
 765        if is_public_enabled:
 766            label = bui.Lstr(
 767                resource='gatherWindow.makePartyPrivateText',
 768                fallback_resource='gatherWindow.stopAdvertisingText',
 769            )
 770        else:
 771            label = bui.Lstr(
 772                resource='gatherWindow.makePartyPublicText',
 773                fallback_resource='gatherWindow.startAdvertisingText',
 774            )
 775        self._host_toggle_button = bui.buttonwidget(
 776            parent=self._container,
 777            label=label,
 778            size=(400, 80),
 779            on_activate_call=self._on_stop_advertising_press
 780            if is_public_enabled
 781            else self._on_start_advertizing_press,
 782            position=(c_width * 0.5 - 200, v),
 783            autoselect=True,
 784            up_widget=btn2,
 785        )
 786        bui.widget(edit=self._host_name_text, down_widget=btn2)
 787        bui.widget(edit=btn2, up_widget=self._host_name_text)
 788        bui.widget(edit=btn1, up_widget=self._host_name_text)
 789        bui.widget(edit=self._join_text, down_widget=self._host_name_text)
 790        v -= 10
 791        self._host_status_text = bui.textwidget(
 792            parent=self._container,
 793            text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'),
 794            size=(0, 0),
 795            scale=0.7,
 796            flatness=1.0,
 797            h_align='center',
 798            v_align='top',
 799            maxwidth=c_width * 0.9,
 800            color=(0.6, 0.56, 0.6),
 801            position=(c_width * 0.5, v),
 802        )
 803        v -= 90
 804        bui.textwidget(
 805            parent=self._container,
 806            text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'),
 807            size=(0, 0),
 808            scale=0.7,
 809            flatness=1.0,
 810            h_align='center',
 811            v_align='center',
 812            maxwidth=c_width * 0.9,
 813            color=(0.5, 0.46, 0.5),
 814            position=(c_width * 0.5, v),
 815        )
 816
 817        # If public sharing is already on,
 818        # launch a status-check immediately.
 819        if bs.get_public_party_enabled():
 820            self._do_status_check()
 821
 822    def _on_public_party_query_result(
 823        self, result: dict[str, Any] | None
 824    ) -> None:
 825        starttime = time.time()
 826        self._have_server_list_response = True
 827
 828        if result is None:
 829            self._have_valid_server_list = False
 830            return
 831
 832        if not self._have_valid_server_list:
 833            self._first_valid_server_list_time = time.time()
 834
 835        self._have_valid_server_list = True
 836        parties_in = result['l']
 837
 838        assert isinstance(parties_in, list)
 839        self._pending_party_infos += parties_in
 840
 841        # To avoid causing a stutter here, we do most processing of
 842        # these entries incrementally in our _update() method.
 843        # The one thing we do here is prune parties not contained in
 844        # this result.
 845        for partyval in list(self._parties.values()):
 846            partyval.claimed = False
 847        for party_in in parties_in:
 848            addr = party_in['a']
 849            assert isinstance(addr, str)
 850            port = party_in['p']
 851            assert isinstance(port, int)
 852            party_key = f'{addr}_{port}'
 853            party = self._parties.get(party_key)
 854            if party is not None:
 855                party.claimed = True
 856        self._parties = {
 857            key: val for key, val in list(self._parties.items()) if val.claimed
 858        }
 859        self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed]
 860        self._party_lists_dirty = True
 861
 862        # self._update_server_list()
 863        if DEBUG_PROCESSING:
 864            print(
 865                f'Handled public party query results in '
 866                f'{time.time()-starttime:.5f}s.'
 867            )
 868
 869    def _update(self) -> None:
 870        """Periodic updating."""
 871
 872        plus = bui.app.plus
 873        assert plus is not None
 874
 875        # Special case: if a party-queue window is up, don't do any of this
 876        # (keeps things smoother).
 877        # if bui.app.ui.have_party_queue_window:
 878        #     return
 879
 880        if self._sub_tab is SubTabType.JOIN:
 881            # Keep our filter-text up to date from the UI.
 882            text = self._filter_text
 883            if text:
 884                filter_value = cast(str, bui.textwidget(query=text))
 885                if filter_value != self._filter_value:
 886                    self._filter_value = filter_value
 887                    self._party_lists_dirty = True
 888
 889                    # Also wipe out party clean-row states.
 890                    # (otherwise if a party disappears from a row due to
 891                    # filtering and then reappears on that same row when
 892                    # the filter is removed it may not update)
 893                    for party in self._parties.values():
 894                        party.clean_display_index = None
 895
 896            self._query_party_list_periodically()
 897            self._ping_parties_periodically()
 898
 899        # If any new party infos have come in, apply some of them.
 900        self._process_pending_party_infos()
 901
 902        # Anytime we sign in/out, make sure we refresh our list.
 903        signed_in = plus.get_v1_account_state() == 'signed_in'
 904        if self._signed_in != signed_in:
 905            self._signed_in = signed_in
 906            self._party_lists_dirty = True
 907
 908        # Update sorting to account for ping updates, new parties, etc.
 909        self._update_party_lists()
 910
 911        # If we've got a party-name text widget, keep its value plugged
 912        # into our public host name.
 913        text = self._host_name_text
 914        if text:
 915            name = cast(str, bui.textwidget(query=self._host_name_text))
 916            bs.set_public_party_name(name)
 917
 918        # Update status text.
 919        status_text = self._join_status_text
 920        if status_text:
 921            if not signed_in:
 922                bui.textwidget(
 923                    edit=status_text, text=bui.Lstr(resource='notSignedInText')
 924                )
 925            else:
 926                # If we have a valid list, show no status; just the list.
 927                # Otherwise show either 'loading...' or 'error' depending
 928                # on whether this is our first go-round.
 929                if self._have_valid_server_list:
 930                    bui.textwidget(edit=status_text, text='')
 931                else:
 932                    if self._have_server_list_response:
 933                        bui.textwidget(
 934                            edit=status_text,
 935                            text=bui.Lstr(resource='errorText'),
 936                        )
 937                    else:
 938                        bui.textwidget(
 939                            edit=status_text,
 940                            text=bui.Lstr(
 941                                value='${A}...',
 942                                subs=[
 943                                    (
 944                                        '${A}',
 945                                        bui.Lstr(resource='store.loadingText'),
 946                                    )
 947                                ],
 948                            ),
 949                        )
 950
 951        self._update_party_rows()
 952
 953    def _update_party_rows(self) -> None:
 954        columnwidget = self._join_list_column
 955        if not columnwidget:
 956            return
 957
 958        assert self._join_text
 959        assert self._filter_text
 960
 961        # Janky - allow escaping when there's nothing in our list.
 962        assert self._host_scrollwidget
 963        bui.containerwidget(
 964            edit=self._host_scrollwidget,
 965            claims_up_down=(len(self._parties_displayed) > 0),
 966        )
 967
 968        # Clip if we have more UI rows than parties to show.
 969        clipcount = len(self._ui_rows) - len(self._parties_displayed)
 970        if clipcount > 0:
 971            clipcount = max(clipcount, 50)
 972            self._ui_rows = self._ui_rows[:-clipcount]
 973
 974        # If we have no parties to show, we're done.
 975        if not self._parties_displayed:
 976            return
 977
 978        sub_scroll_width = 830
 979        lineheight = 42
 980        sub_scroll_height = lineheight * len(self._parties_displayed) + 50
 981        bui.containerwidget(
 982            edit=columnwidget, size=(sub_scroll_width, sub_scroll_height)
 983        )
 984
 985        # Any time our height changes, reset the refresh back to the top
 986        # so we don't see ugly empty spaces appearing during initial list
 987        # filling.
 988        if sub_scroll_height != self._last_sub_scroll_height:
 989            self._refresh_ui_row = 0
 990            self._last_sub_scroll_height = sub_scroll_height
 991
 992            # Also note that we need to redisplay everything since its pos
 993            # will have changed.. :(
 994            for party in self._parties.values():
 995                party.clean_display_index = None
 996
 997        # Ew; this rebuilding generates deferred selection callbacks
 998        # so we need to push deferred notices so we know to ignore them.
 999        def refresh_on() -> None:
1000            self._refreshing_list = True
1001
1002        bui.pushcall(refresh_on)
1003
1004        # Ok, now here's the deal: we want to avoid creating/updating this
1005        # entire list at one time because it will lead to hitches. So we
1006        # refresh individual rows quickly in a loop.
1007        rowcount = min(12, len(self._parties_displayed))
1008
1009        party_vals_displayed = list(self._parties_displayed.values())
1010        while rowcount > 0:
1011            refresh_row = self._refresh_ui_row % len(self._parties_displayed)
1012            if refresh_row >= len(self._ui_rows):
1013                self._ui_rows.append(UIRow())
1014                refresh_row = len(self._ui_rows) - 1
1015
1016            # For the first few seconds after getting our first server-list,
1017            # refresh only the top section of the list; this allows the lowest
1018            # ping servers to show up more quickly.
1019            if self._first_valid_server_list_time is not None:
1020                if time.time() - self._first_valid_server_list_time < 4.0:
1021                    if refresh_row > 40:
1022                        refresh_row = 0
1023
1024            self._ui_rows[refresh_row].update(
1025                refresh_row,
1026                party_vals_displayed[refresh_row],
1027                sub_scroll_width=sub_scroll_width,
1028                sub_scroll_height=sub_scroll_height,
1029                lineheight=lineheight,
1030                columnwidget=columnwidget,
1031                join_text=self._join_text,
1032                existing_selection=self._selection,
1033                filter_text=self._filter_text,
1034                tab=self,
1035            )
1036            self._refresh_ui_row = refresh_row + 1
1037            rowcount -= 1
1038
1039        # So our selection callbacks can start firing..
1040        def refresh_off() -> None:
1041            self._refreshing_list = False
1042
1043        bui.pushcall(refresh_off)
1044
1045    def _process_pending_party_infos(self) -> None:
1046        starttime = time.time()
1047
1048        # We want to do this in small enough pieces to not cause UI hitches.
1049        chunksize = 30
1050        parties_in = self._pending_party_infos[:chunksize]
1051        self._pending_party_infos = self._pending_party_infos[chunksize:]
1052        for party_in in parties_in:
1053            addr = party_in['a']
1054            assert isinstance(addr, str)
1055            port = party_in['p']
1056            assert isinstance(port, int)
1057            party_key = f'{addr}_{port}'
1058            party = self._parties.get(party_key)
1059            if party is None:
1060                # If this party is new to us, init it.
1061                party = PartyEntry(
1062                    address=addr,
1063                    next_ping_time=bui.apptime() + 0.001 * party_in['pd'],
1064                    index=self._next_entry_index,
1065                )
1066                self._parties[party_key] = party
1067                self._parties_sorted.append((party_key, party))
1068                self._party_lists_dirty = True
1069                self._next_entry_index += 1
1070                assert isinstance(party.address, str)
1071                assert isinstance(party.next_ping_time, float)
1072
1073            # Now, new or not, update its values.
1074            party.queue = party_in.get('q')
1075            assert isinstance(party.queue, (str, type(None)))
1076            party.port = port
1077            party.name = party_in['n']
1078            assert isinstance(party.name, str)
1079            party.size = party_in['s']
1080            assert isinstance(party.size, int)
1081            party.size_max = party_in['sm']
1082            assert isinstance(party.size_max, int)
1083
1084            # Server provides this in milliseconds; we use seconds.
1085            party.ping_interval = 0.001 * party_in['pi']
1086            assert isinstance(party.ping_interval, float)
1087            party.stats_addr = party_in['sa']
1088            assert isinstance(party.stats_addr, (str, type(None)))
1089
1090            # Make sure the party's UI gets updated.
1091            party.clean_display_index = None
1092
1093        if DEBUG_PROCESSING and parties_in:
1094            print(
1095                f'Processed {len(parties_in)} raw party infos in'
1096                f' {time.time()-starttime:.5f}s.'
1097            )
1098
1099    def _update_party_lists(self) -> None:
1100        plus = bui.app.plus
1101        assert plus is not None
1102
1103        if not self._party_lists_dirty:
1104            return
1105        starttime = time.time()
1106        assert len(self._parties_sorted) == len(self._parties)
1107
1108        self._parties_sorted.sort(
1109            key=lambda p: (
1110                p[1].ping if p[1].ping is not None else 999999.0,
1111                p[1].index,
1112            )
1113        )
1114
1115        # If signed out or errored, show no parties.
1116        if (
1117            plus.get_v1_account_state() != 'signed_in'
1118            or not self._have_valid_server_list
1119        ):
1120            self._parties_displayed = {}
1121        else:
1122            if self._filter_value:
1123                filterval = self._filter_value.lower()
1124                self._parties_displayed = {
1125                    k: v
1126                    for k, v in self._parties_sorted
1127                    if filterval in v.name.lower()
1128                }
1129            else:
1130                self._parties_displayed = dict(self._parties_sorted)
1131
1132        # Any time our selection disappears from the displayed list, go back to
1133        # auto-selecting the top entry.
1134        if (
1135            self._selection is not None
1136            and self._selection.entry_key not in self._parties_displayed
1137        ):
1138            self._have_user_selected_row = False
1139
1140        # Whenever the user hasn't selected something, keep the first visible
1141        # row selected.
1142        if not self._have_user_selected_row and self._parties_displayed:
1143            firstpartykey = next(iter(self._parties_displayed))
1144            self._selection = Selection(firstpartykey, SelectionComponent.NAME)
1145
1146        self._party_lists_dirty = False
1147        if DEBUG_PROCESSING:
1148            print(
1149                f'Sorted {len(self._parties_sorted)} parties in'
1150                f' {time.time()-starttime:.5f}s.'
1151            )
1152
1153    def _query_party_list_periodically(self) -> None:
1154        now = bui.apptime()
1155
1156        plus = bui.app.plus
1157        assert plus is not None
1158
1159        # Fire off a new public-party query periodically.
1160        if (
1161            self._last_server_list_query_time is None
1162            or now - self._last_server_list_query_time
1163            > 0.001
1164            * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000)
1165        ):
1166            self._last_server_list_query_time = now
1167            if DEBUG_SERVER_COMMUNICATION:
1168                print('REQUESTING SERVER LIST')
1169            if plus.get_v1_account_state() == 'signed_in':
1170                plus.add_v1_account_transaction(
1171                    {
1172                        'type': 'PUBLIC_PARTY_QUERY',
1173                        'proto': bs.protocol_version(),
1174                        'lang': bui.app.lang.language,
1175                    },
1176                    callback=bui.WeakCall(self._on_public_party_query_result),
1177                )
1178                plus.run_v1_account_transactions()
1179            else:
1180                self._on_public_party_query_result(None)
1181
1182    def _ping_parties_periodically(self) -> None:
1183        assert bui.app.classic is not None
1184        now = bui.apptime()
1185
1186        # Go through our existing public party entries firing off pings
1187        # for any that have timed out.
1188        for party in list(self._parties.values()):
1189            if (
1190                party.next_ping_time <= now
1191                and bui.app.classic.ping_thread_count < 15
1192            ):
1193                # Crank the interval up for high-latency or non-responding
1194                # parties to save us some useless work.
1195                mult = 1
1196                if party.ping_responses == 0:
1197                    if party.ping_attempts > 4:
1198                        mult = 10
1199                    elif party.ping_attempts > 2:
1200                        mult = 5
1201                if party.ping is not None:
1202                    mult = (
1203                        10 if party.ping > 300 else 5 if party.ping > 150 else 2
1204                    )
1205
1206                interval = party.ping_interval * mult
1207                if DEBUG_SERVER_COMMUNICATION:
1208                    print(
1209                        f'pinging #{party.index} cur={party.ping} '
1210                        f'interval={interval} '
1211                        f'({party.ping_responses}/{party.ping_attempts})'
1212                    )
1213
1214                party.next_ping_time = now + party.ping_interval * mult
1215                party.ping_attempts += 1
1216
1217                PingThread(
1218                    party.address, party.port, bui.WeakCall(self._ping_callback)
1219                ).start()
1220
1221    def _ping_callback(
1222        self, address: str, port: int | None, result: float | None
1223    ) -> None:
1224        # Look for a widget corresponding to this target.
1225        # If we find one, update our list.
1226        party_key = f'{address}_{port}'
1227        party = self._parties.get(party_key)
1228        if party is not None:
1229            if result is not None:
1230                party.ping_responses += 1
1231
1232            # We now smooth ping a bit to reduce jumping around in the list
1233            # (only where pings are relatively good).
1234            current_ping = party.ping
1235            if current_ping is not None and result is not None and result < 150:
1236                smoothing = 0.7
1237                party.ping = (
1238                    smoothing * current_ping + (1.0 - smoothing) * result
1239                )
1240            else:
1241                party.ping = result
1242
1243            # Need to re-sort the list and update the row display.
1244            party.clean_display_index = None
1245            self._party_lists_dirty = True
1246
1247    def _fetch_local_addr_cb(self, val: str) -> None:
1248        self._local_address = str(val)
1249
1250    def _on_public_party_accessible_response(
1251        self, data: dict[str, Any] | None
1252    ) -> None:
1253        # If we've got status text widgets, update them.
1254        text = self._host_status_text
1255        if text:
1256            if data is None:
1257                bui.textwidget(
1258                    edit=text,
1259                    text=bui.Lstr(
1260                        resource='gatherWindow.' 'partyStatusNoConnectionText'
1261                    ),
1262                    color=(1, 0, 0),
1263                )
1264            else:
1265                if not data.get('accessible', False):
1266                    ex_line: str | bui.Lstr
1267                    if self._local_address is not None:
1268                        ex_line = bui.Lstr(
1269                            value='\n${A} ${B}',
1270                            subs=[
1271                                (
1272                                    '${A}',
1273                                    bui.Lstr(
1274                                        resource='gatherWindow.'
1275                                        'manualYourLocalAddressText'
1276                                    ),
1277                                ),
1278                                ('${B}', self._local_address),
1279                            ],
1280                        )
1281                    else:
1282                        ex_line = ''
1283                    bui.textwidget(
1284                        edit=text,
1285                        text=bui.Lstr(
1286                            value='${A}\n${B}${C}',
1287                            subs=[
1288                                (
1289                                    '${A}',
1290                                    bui.Lstr(
1291                                        resource='gatherWindow.'
1292                                        'partyStatusNotJoinableText'
1293                                    ),
1294                                ),
1295                                (
1296                                    '${B}',
1297                                    bui.Lstr(
1298                                        resource='gatherWindow.'
1299                                        'manualRouterForwardingText',
1300                                        subs=[
1301                                            (
1302                                                '${PORT}',
1303                                                str(bs.get_game_port()),
1304                                            )
1305                                        ],
1306                                    ),
1307                                ),
1308                                ('${C}', ex_line),
1309                            ],
1310                        ),
1311                        color=(1, 0, 0),
1312                    )
1313                else:
1314                    bui.textwidget(
1315                        edit=text,
1316                        text=bui.Lstr(
1317                            resource='gatherWindow.' 'partyStatusJoinableText'
1318                        ),
1319                        color=(0, 1, 0),
1320                    )
1321
1322    def _do_status_check(self) -> None:
1323        assert bui.app.classic is not None
1324        bui.textwidget(
1325            edit=self._host_status_text,
1326            color=(1, 1, 0),
1327            text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'),
1328        )
1329        bui.app.classic.master_server_v1_get(
1330            'bsAccessCheck',
1331            {'b': bui.app.env.build_number},
1332            callback=bui.WeakCall(self._on_public_party_accessible_response),
1333        )
1334
1335    def _on_start_advertizing_press(self) -> None:
1336        from bauiv1lib.account import show_sign_in_prompt
1337
1338        plus = bui.app.plus
1339        assert plus is not None
1340
1341        if plus.get_v1_account_state() != 'signed_in':
1342            show_sign_in_prompt()
1343            return
1344
1345        name = cast(str, bui.textwidget(query=self._host_name_text))
1346        if name == '':
1347            bui.screenmessage(
1348                bui.Lstr(resource='internal.invalidNameErrorText'),
1349                color=(1, 0, 0),
1350            )
1351            bui.getsound('error').play()
1352            return
1353        bs.set_public_party_name(name)
1354        cfg = bui.app.config
1355        cfg['Public Party Name'] = name
1356        cfg.commit()
1357        bui.getsound('shieldUp').play()
1358        bs.set_public_party_enabled(True)
1359
1360        # In GUI builds we want to authenticate clients only when hosting
1361        # public parties.
1362        bs.set_authenticate_clients(True)
1363
1364        self._do_status_check()
1365        bui.buttonwidget(
1366            edit=self._host_toggle_button,
1367            label=bui.Lstr(
1368                resource='gatherWindow.makePartyPrivateText',
1369                fallback_resource='gatherWindow.stopAdvertisingText',
1370            ),
1371            on_activate_call=self._on_stop_advertising_press,
1372        )
1373
1374    def _on_stop_advertising_press(self) -> None:
1375        bs.set_public_party_enabled(False)
1376
1377        # In GUI builds we want to authenticate clients only when hosting
1378        # public parties.
1379        bs.set_authenticate_clients(False)
1380        bui.getsound('shieldDown').play()
1381        text = self._host_status_text
1382        if text:
1383            bui.textwidget(
1384                edit=text,
1385                text=bui.Lstr(
1386                    resource='gatherWindow.' 'partyStatusNotPublicText'
1387                ),
1388                color=(0.6, 0.6, 0.6),
1389            )
1390        bui.buttonwidget(
1391            edit=self._host_toggle_button,
1392            label=bui.Lstr(
1393                resource='gatherWindow.makePartyPublicText',
1394                fallback_resource='gatherWindow.startAdvertisingText',
1395            ),
1396            on_activate_call=self._on_start_advertizing_press,
1397        )
1398
1399    def on_public_party_activate(self, party: PartyEntry) -> None:
1400        """Called when a party is clicked or otherwise activated."""
1401        self.save_state()
1402        if party.queue is not None:
1403            from bauiv1lib.partyqueue import PartyQueueWindow
1404
1405            bui.getsound('swish').play()
1406            PartyQueueWindow(party.queue, party.address, party.port)
1407        else:
1408            address = party.address
1409            port = party.port
1410
1411            # Rate limit this a bit.
1412            now = time.time()
1413            last_connect_time = self._last_connect_attempt_time
1414            if last_connect_time is None or now - last_connect_time > 2.0:
1415                bs.connect_to_party(address, port=port)
1416                self._last_connect_attempt_time = now
1417
1418    def set_public_party_selection(self, sel: Selection) -> None:
1419        """Set the sel."""
1420        if self._refreshing_list:
1421            return
1422        self._selection = sel
1423        self._have_user_selected_row = True
1424
1425    def _on_max_public_party_size_minus_press(self) -> None:
1426        val = max(1, bs.get_public_party_max_size() - 1)
1427        bs.set_public_party_max_size(val)
1428        bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
1429
1430    def _on_max_public_party_size_plus_press(self) -> None:
1431        val = bs.get_public_party_max_size()
1432        val += 1
1433        bs.set_public_party_max_size(val)
1434        bui.textwidget(edit=self._host_max_party_size_value, text=str(val))

The public tab in the gather UI

PublicGatherTab(window: bauiv1lib.gather.GatherWindow)
347    def __init__(self, window: GatherWindow) -> None:
348        super().__init__(window)
349        self._container: bui.Widget | None = None
350        self._join_text: bui.Widget | None = None
351        self._host_text: bui.Widget | None = None
352        self._filter_text: bui.Widget | None = None
353        self._local_address: str | None = None
354        self._last_connect_attempt_time: float | None = None
355        self._sub_tab: SubTabType = SubTabType.JOIN
356        self._selection: Selection | None = None
357        self._refreshing_list = False
358        self._update_timer: bui.AppTimer | None = None
359        self._host_scrollwidget: bui.Widget | None = None
360        self._host_name_text: bui.Widget | None = None
361        self._host_toggle_button: bui.Widget | None = None
362        self._last_server_list_query_time: float | None = None
363        self._join_list_column: bui.Widget | None = None
364        self._join_status_text: bui.Widget | None = None
365        self._host_max_party_size_value: bui.Widget | None = None
366        self._host_max_party_size_minus_button: (bui.Widget | None) = None
367        self._host_max_party_size_plus_button: (bui.Widget | None) = None
368        self._host_status_text: bui.Widget | None = None
369        self._signed_in = False
370        self._ui_rows: list[UIRow] = []
371        self._refresh_ui_row = 0
372        self._have_user_selected_row = False
373        self._first_valid_server_list_time: float | None = None
374
375        # Parties indexed by id:
376        self._parties: dict[str, PartyEntry] = {}
377
378        # Parties sorted in display order:
379        self._parties_sorted: list[tuple[str, PartyEntry]] = []
380        self._party_lists_dirty = True
381
382        # Sorted parties with filter applied:
383        self._parties_displayed: dict[str, PartyEntry] = {}
384
385        self._next_entry_index = 0
386        self._have_server_list_response = False
387        self._have_valid_server_list = False
388        self._filter_value = ''
389        self._pending_party_infos: list[dict[str, Any]] = []
390        self._last_sub_scroll_height = 0.0
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:
392    def on_activate(
393        self,
394        parent_widget: bui.Widget,
395        tab_button: bui.Widget,
396        region_width: float,
397        region_height: float,
398        region_left: float,
399        region_bottom: float,
400    ) -> bui.Widget:
401        c_width = region_width
402        c_height = region_height - 20
403        self._container = bui.containerwidget(
404            parent=parent_widget,
405            position=(
406                region_left,
407                region_bottom + (region_height - c_height) * 0.5,
408            ),
409            size=(c_width, c_height),
410            background=False,
411            selection_loops_to_parent=True,
412        )
413        v = c_height - 30
414        self._join_text = bui.textwidget(
415            parent=self._container,
416            position=(c_width * 0.5 - 245, v - 13),
417            color=(0.6, 1.0, 0.6),
418            scale=1.3,
419            size=(200, 30),
420            maxwidth=250,
421            h_align='left',
422            v_align='center',
423            click_activate=True,
424            selectable=True,
425            autoselect=True,
426            on_activate_call=lambda: self._set_sub_tab(
427                SubTabType.JOIN,
428                region_width,
429                region_height,
430                playsound=True,
431            ),
432            text=bui.Lstr(
433                resource='gatherWindow.' 'joinPublicPartyDescriptionText'
434            ),
435        )
436        self._host_text = bui.textwidget(
437            parent=self._container,
438            position=(c_width * 0.5 + 45, v - 13),
439            color=(0.6, 1.0, 0.6),
440            scale=1.3,
441            size=(200, 30),
442            maxwidth=250,
443            h_align='left',
444            v_align='center',
445            click_activate=True,
446            selectable=True,
447            autoselect=True,
448            on_activate_call=lambda: self._set_sub_tab(
449                SubTabType.HOST,
450                region_width,
451                region_height,
452                playsound=True,
453            ),
454            text=bui.Lstr(
455                resource='gatherWindow.' 'hostPublicPartyDescriptionText'
456            ),
457        )
458        bui.widget(edit=self._join_text, up_widget=tab_button)
459        bui.widget(
460            edit=self._host_text,
461            left_widget=self._join_text,
462            up_widget=tab_button,
463        )
464        bui.widget(edit=self._join_text, right_widget=self._host_text)
465
466        # Attempt to fetch our local address so we have it for error messages.
467        if self._local_address is None:
468            AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start()
469
470        self._set_sub_tab(self._sub_tab, region_width, region_height)
471        self._update_timer = bui.AppTimer(
472            0.1, bui.WeakCall(self._update), repeat=True
473        )
474        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:
476    def on_deactivate(self) -> None:
477        self._update_timer = None

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

def save_state(self) -> None:
479    def save_state(self) -> None:
480        # Save off a small number of parties with the lowest ping; we'll
481        # display these immediately when our UI comes back up which should
482        # be enough to make things feel nice and crisp while we do a full
483        # server re-query or whatnot.
484        assert bui.app.classic is not None
485        bui.app.ui_v1.window_states[type(self)] = State(
486            sub_tab=self._sub_tab,
487            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
488            next_entry_index=self._next_entry_index,
489            filter_value=self._filter_value,
490            have_server_list_response=self._have_server_list_response,
491            have_valid_server_list=self._have_valid_server_list,
492        )

Called when the parent window is saving state.

def restore_state(self) -> None:
494    def restore_state(self) -> None:
495        assert bui.app.classic is not None
496        state = bui.app.ui_v1.window_states.get(type(self))
497        if state is None:
498            state = State()
499        assert isinstance(state, State)
500        self._sub_tab = state.sub_tab
501
502        # Restore the parties we stored.
503        if state.parties:
504            self._parties = {
505                key: copy.copy(party) for key, party in state.parties
506            }
507            self._parties_sorted = list(self._parties.items())
508            self._party_lists_dirty = True
509
510            self._next_entry_index = state.next_entry_index
511
512            # FIXME: should save/restore these too?..
513            self._have_server_list_response = state.have_server_list_response
514            self._have_valid_server_list = state.have_valid_server_list
515        self._filter_value = state.filter_value

Called when the parent window is restoring state.

def on_public_party_activate(self, party: PartyEntry) -> None:
1399    def on_public_party_activate(self, party: PartyEntry) -> None:
1400        """Called when a party is clicked or otherwise activated."""
1401        self.save_state()
1402        if party.queue is not None:
1403            from bauiv1lib.partyqueue import PartyQueueWindow
1404
1405            bui.getsound('swish').play()
1406            PartyQueueWindow(party.queue, party.address, party.port)
1407        else:
1408            address = party.address
1409            port = party.port
1410
1411            # Rate limit this a bit.
1412            now = time.time()
1413            last_connect_time = self._last_connect_attempt_time
1414            if last_connect_time is None or now - last_connect_time > 2.0:
1415                bs.connect_to_party(address, port=port)
1416                self._last_connect_attempt_time = now

Called when a party is clicked or otherwise activated.

def set_public_party_selection(self, sel: Selection) -> None:
1418    def set_public_party_selection(self, sel: Selection) -> None:
1419        """Set the sel."""
1420        if self._refreshing_list:
1421            return
1422        self._selection = sel
1423        self._have_user_selected_row = True

Set the sel.