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

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

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

Called when the parent window is saving state.

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

Called when the parent window is restoring state.

def on_public_party_activate(self, party: PartyEntry) -> None:
1442    def on_public_party_activate(self, party: PartyEntry) -> None:
1443        """Called when a party is clicked or otherwise activated."""
1444        self.save_state()
1445        if party.queue is not None:
1446            from bauiv1lib.partyqueue import PartyQueueWindow
1447
1448            bui.getsound('swish').play()
1449            PartyQueueWindow(party.queue, party.address, party.port)
1450        else:
1451            address = party.address
1452            port = party.port
1453
1454            # Store UI location to return to when done.
1455            if bs.app.classic is not None:
1456                bs.app.classic.save_ui_state()
1457
1458            # Rate limit this a bit.
1459            now = time.time()
1460            last_connect_time = self._last_connect_attempt_time
1461            if last_connect_time is None or now - last_connect_time > 2.0:
1462                bs.connect_to_party(address, port=port)
1463                self._last_connect_attempt_time = now

Called when a party is clicked or otherwise activated.

def set_public_party_selection(self, sel: Selection) -> None:
1465    def set_public_party_selection(self, sel: Selection) -> None:
1466        """Set the sel."""
1467        if self._refreshing_list:
1468            return
1469        self._selection = sel
1470        self._have_user_selected_row = True

Set the sel.