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

Update for the given data.

@dataclass
class State:
218@dataclass
219class State:
220    """State saved/restored only while the app is running."""
221
222    sub_tab: SubTabType = SubTabType.JOIN
223    parties: list[tuple[str, PartyEntry]] | None = None
224    next_entry_index: int = 0
225    filter_value: str = ''
226    have_server_list_response: bool = False
227    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):
230class SelectionComponent(Enum):
231    """Describes what part of an entry is selected."""
232
233    NAME = 'name'
234    STATS_BUTTON = 'stats_button'

Describes what part of an entry is selected.

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

Describes the currently selected list element.

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

Thread for fetching an address in the bg.

AddrFetchThread(call: Callable[[Any], Any])
248    def __init__(self, call: Callable[[Any], Any]):
249        super().__init__()
250        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.

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

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.

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

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

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

The public tab in the gather UI

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

Called when the tab becomes the active one.

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

@override
def on_deactivate(self) -> None:
490    @override
491    def on_deactivate(self) -> None:
492        self._update_timer = None

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

@override
def save_state(self) -> None:
494    @override
495    def save_state(self) -> None:
496        # Save off a small number of parties with the lowest ping; we'll
497        # display these immediately when our UI comes back up which
498        # should be enough to make things feel nice and crisp while we
499        # do a full server re-query or whatnot.
500        assert bui.app.classic is not None
501        bui.app.ui_v1.window_states[type(self)] = State(
502            sub_tab=self._sub_tab,
503            parties=[(i, copy.copy(p)) for i, p in self._parties_sorted[:40]],
504            next_entry_index=self._next_entry_index,
505            filter_value=self._filter_value,
506            have_server_list_response=self._have_server_list_response,
507            have_valid_server_list=self._have_valid_server_list,
508        )

Called when the parent window is saving state.

@override
def restore_state(self) -> None:
510    @override
511    def restore_state(self) -> None:
512        assert bui.app.classic is not None
513        state = bui.app.ui_v1.window_states.get(type(self))
514        if state is None:
515            state = State()
516        assert isinstance(state, State)
517        self._sub_tab = state.sub_tab
518
519        # Restore the parties we stored.
520        if state.parties:
521            self._parties = {
522                key: copy.copy(party) for key, party in state.parties
523            }
524            self._parties_sorted = list(self._parties.items())
525            self._party_lists_dirty = True
526
527            self._next_entry_index = state.next_entry_index
528
529            # FIXME: should save/restore these too?
530            self._have_server_list_response = state.have_server_list_response
531            self._have_valid_server_list = state.have_valid_server_list
532        self._filter_value = state.filter_value

Called when the parent window is restoring state.

def on_public_party_activate(self, party: PartyEntry) -> None:
1457    def on_public_party_activate(self, party: PartyEntry) -> None:
1458        """Called when a party is clicked or otherwise activated."""
1459        self.save_state()
1460        if party.queue is not None:
1461            from bauiv1lib.partyqueue import PartyQueueWindow
1462
1463            bui.getsound('swish').play()
1464            PartyQueueWindow(party.queue, party.address, party.port)
1465        else:
1466            address = party.address
1467            port = party.port
1468
1469            # Store UI location to return to when done.
1470            if bs.app.classic is not None:
1471                bs.app.classic.save_ui_state()
1472
1473            # Rate limit this a bit.
1474            now = time.time()
1475            last_connect_time = self._last_connect_attempt_time
1476            if last_connect_time is None or now - last_connect_time > 2.0:
1477                bs.connect_to_party(address, port=port)
1478                self._last_connect_attempt_time = now

Called when a party is clicked or otherwise activated.

def set_public_party_selection(self, sel: Selection) -> None:
1480    def set_public_party_selection(self, sel: Selection) -> None:
1481        """Set the sel."""
1482        if self._refreshing_list:
1483            return
1484        self._selection = sel
1485        self._have_user_selected_row = True

Set the sel.