bauiv1lib.gather.publictab

Defines the public tab in the gather UI.

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

Available sub-tabs.

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

Return the key used to store this party.

class UIRow:
 64class UIRow:
 65    """Wrangles UI for a row in the party list."""
 66
 67    def __init__(self) -> None:
 68        self._name_widget: bui.Widget | None = None
 69        self._size_widget: bui.Widget | None = None
 70        self._ping_widget: bui.Widget | None = None
 71        self._stats_button: bui.Widget | None = None
 72
 73    def __del__(self) -> None:
 74        self._clear()
 75
 76    def _clear(self) -> None:
 77        for widget in [
 78            self._name_widget,
 79            self._size_widget,
 80            self._ping_widget,
 81            self._stats_button,
 82        ]:
 83            if widget:
 84                widget.delete()
 85
 86    def update(
 87        self,
 88        index: int,
 89        party: PartyEntry,
 90        sub_scroll_width: float,
 91        sub_scroll_height: float,
 92        lineheight: float,
 93        columnwidget: bui.Widget,
 94        join_text: bui.Widget,
 95        filter_text: bui.Widget,
 96        existing_selection: Selection | None,
 97        tab: PublicGatherTab,
 98    ) -> None:
 99        """Update for the given data."""
100        # pylint: disable=too-many-locals
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=(0, 1, 0)
209                if party.ping <= ping_good
210                else (1, 1, 0)
211                if party.ping <= ping_med
212                else (1, 0, 0),
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:
 86    def update(
 87        self,
 88        index: int,
 89        party: PartyEntry,
 90        sub_scroll_width: float,
 91        sub_scroll_height: float,
 92        lineheight: float,
 93        columnwidget: bui.Widget,
 94        join_text: bui.Widget,
 95        filter_text: bui.Widget,
 96        existing_selection: Selection | None,
 97        tab: PublicGatherTab,
 98    ) -> None:
 99        """Update for the given data."""
100        # pylint: disable=too-many-locals
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=(0, 1, 0)
209                if party.ping <= ping_good
210                else (1, 1, 0)
211                if party.ping <= ping_med
212                else (1, 0, 0),
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'>
Inherited Members
enum.Enum
name
value
@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.

Inherited Members
threading.Thread
start
join
name
ident
is_alive
daemon
isDaemon
setDaemon
getName
setName
native_id
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.

Inherited Members
threading.Thread
start
join
name
ident
is_alive
daemon
isDaemon
setDaemon
getName
setName
native_id
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        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=(290, 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=(270, 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=(90, 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=(755, v - 8),
 627            maxwidth=60,
 628            scale=0.6,
 629            color=(0.5, 0.46, 0.5),
 630            flatness=1.0,
 631            h_align='center',
 632            v_align='center',
 633        )
 634        bui.textwidget(
 635            text=bui.Lstr(resource='gatherWindow.pingText'),
 636            parent=self._container,
 637            size=(0, 0),
 638            position=(825, v - 8),
 639            maxwidth=60,
 640            scale=0.6,
 641            color=(0.5, 0.46, 0.5),
 642            flatness=1.0,
 643            h_align='center',
 644            v_align='center',
 645        )
 646        v -= sub_scroll_height + 23
 647        self._host_scrollwidget = scrollw = bui.scrollwidget(
 648            parent=self._container,
 649            simple_culling_v=10,
 650            position=((c_width - sub_scroll_width) * 0.5, v),
 651            size=(sub_scroll_width, sub_scroll_height),
 652            claims_up_down=False,
 653            claims_left_right=True,
 654            autoselect=True,
 655        )
 656        self._join_list_column = bui.containerwidget(
 657            parent=scrollw,
 658            background=False,
 659            size=(400, 400),
 660            claims_left_right=True,
 661        )
 662        self._join_status_text = bui.textwidget(
 663            parent=self._container,
 664            text='',
 665            size=(0, 0),
 666            scale=0.9,
 667            flatness=1.0,
 668            shadow=0.0,
 669            h_align='center',
 670            v_align='top',
 671            maxwidth=c_width,
 672            color=(0.6, 0.6, 0.6),
 673            position=(c_width * 0.5, c_height * 0.5),
 674        )
 675        self._no_servers_found_text = bui.textwidget(
 676            parent=self._container,
 677            text='',
 678            size=(0, 0),
 679            scale=0.9,
 680            flatness=1.0,
 681            shadow=0.0,
 682            h_align='center',
 683            v_align='top',
 684            color=(0.6, 0.6, 0.6),
 685            position=(c_width * 0.5, c_height * 0.5),
 686        )
 687
 688    def _build_host_tab(
 689        self, region_width: float, region_height: float
 690    ) -> None:
 691        c_width = region_width
 692        c_height = region_height - 20
 693        v = c_height - 35
 694        v -= 25
 695        is_public_enabled = bs.get_public_party_enabled()
 696        v -= 30
 697
 698        bui.textwidget(
 699            parent=self._container,
 700            size=(0, 0),
 701            h_align='center',
 702            v_align='center',
 703            maxwidth=c_width * 0.9,
 704            scale=0.7,
 705            flatness=1.0,
 706            color=(0.5, 0.46, 0.5),
 707            position=(region_width * 0.5, v + 10),
 708            text=bui.Lstr(resource='gatherWindow.publicHostRouterConfigText'),
 709        )
 710        v -= 30
 711
 712        party_name_text = bui.Lstr(
 713            resource='gatherWindow.partyNameText',
 714            fallback_resource='editGameListWindow.nameText',
 715        )
 716        assert bui.app.classic is not None
 717        bui.textwidget(
 718            parent=self._container,
 719            size=(0, 0),
 720            h_align='right',
 721            v_align='center',
 722            maxwidth=200,
 723            scale=0.8,
 724            color=bui.app.ui_v1.infotextcolor,
 725            position=(210, v - 9),
 726            text=party_name_text,
 727        )
 728        self._host_name_text = bui.textwidget(
 729            parent=self._container,
 730            editable=True,
 731            size=(535, 40),
 732            position=(230, v - 30),
 733            text=bui.app.config.get('Public Party Name', ''),
 734            maxwidth=494,
 735            shadow=0.3,
 736            flatness=1.0,
 737            description=party_name_text,
 738            autoselect=True,
 739            v_align='center',
 740            corner_scale=1.0,
 741        )
 742
 743        v -= 60
 744        bui.textwidget(
 745            parent=self._container,
 746            size=(0, 0),
 747            h_align='right',
 748            v_align='center',
 749            maxwidth=200,
 750            scale=0.8,
 751            color=bui.app.ui_v1.infotextcolor,
 752            position=(210, v - 9),
 753            text=bui.Lstr(
 754                resource='maxPartySizeText',
 755                fallback_resource='maxConnectionsText',
 756            ),
 757        )
 758        self._host_max_party_size_value = bui.textwidget(
 759            parent=self._container,
 760            size=(0, 0),
 761            h_align='center',
 762            v_align='center',
 763            scale=1.2,
 764            color=(1, 1, 1),
 765            position=(240, v - 9),
 766            text=str(bs.get_public_party_max_size()),
 767        )
 768        btn1 = self._host_max_party_size_minus_button = bui.buttonwidget(
 769            parent=self._container,
 770            size=(40, 40),
 771            on_activate_call=bui.WeakCall(
 772                self._on_max_public_party_size_minus_press
 773            ),
 774            position=(280, v - 26),
 775            label='-',
 776            autoselect=True,
 777        )
 778        btn2 = self._host_max_party_size_plus_button = bui.buttonwidget(
 779            parent=self._container,
 780            size=(40, 40),
 781            on_activate_call=bui.WeakCall(
 782                self._on_max_public_party_size_plus_press
 783            ),
 784            position=(350, v - 26),
 785            label='+',
 786            autoselect=True,
 787        )
 788        v -= 50
 789        v -= 70
 790        if is_public_enabled:
 791            label = bui.Lstr(
 792                resource='gatherWindow.makePartyPrivateText',
 793                fallback_resource='gatherWindow.stopAdvertisingText',
 794            )
 795        else:
 796            label = bui.Lstr(
 797                resource='gatherWindow.makePartyPublicText',
 798                fallback_resource='gatherWindow.startAdvertisingText',
 799            )
 800        self._host_toggle_button = bui.buttonwidget(
 801            parent=self._container,
 802            label=label,
 803            size=(400, 80),
 804            on_activate_call=self._on_stop_advertising_press
 805            if is_public_enabled
 806            else self._on_start_advertizing_press,
 807            position=(c_width * 0.5 - 200, v),
 808            autoselect=True,
 809            up_widget=btn2,
 810        )
 811        bui.widget(edit=self._host_name_text, down_widget=btn2)
 812        bui.widget(edit=btn2, up_widget=self._host_name_text)
 813        bui.widget(edit=btn1, up_widget=self._host_name_text)
 814        bui.widget(edit=self._join_text, down_widget=self._host_name_text)
 815        v -= 10
 816        self._host_status_text = bui.textwidget(
 817            parent=self._container,
 818            text=bui.Lstr(resource='gatherWindow.' 'partyStatusNotPublicText'),
 819            size=(0, 0),
 820            scale=0.7,
 821            flatness=1.0,
 822            h_align='center',
 823            v_align='top',
 824            maxwidth=c_width * 0.9,
 825            color=(0.6, 0.56, 0.6),
 826            position=(c_width * 0.5, v),
 827        )
 828        v -= 90
 829        bui.textwidget(
 830            parent=self._container,
 831            text=bui.Lstr(resource='gatherWindow.dedicatedServerInfoText'),
 832            size=(0, 0),
 833            scale=0.7,
 834            flatness=1.0,
 835            h_align='center',
 836            v_align='center',
 837            maxwidth=c_width * 0.9,
 838            color=(0.5, 0.46, 0.5),
 839            position=(c_width * 0.5, v),
 840        )
 841
 842        # If public sharing is already on,
 843        # launch a status-check immediately.
 844        if bs.get_public_party_enabled():
 845            self._do_status_check()
 846
 847    def _on_public_party_query_result(
 848        self, result: dict[str, Any] | None
 849    ) -> None:
 850        starttime = time.time()
 851        self._have_server_list_response = True
 852
 853        if result is None:
 854            self._have_valid_server_list = False
 855            return
 856
 857        if not self._have_valid_server_list:
 858            self._first_valid_server_list_time = time.time()
 859
 860        self._have_valid_server_list = True
 861        parties_in = result['l']
 862
 863        assert isinstance(parties_in, list)
 864        self._pending_party_infos += parties_in
 865
 866        # To avoid causing a stutter here, we do most processing of
 867        # these entries incrementally in our _update() method.
 868        # The one thing we do here is prune parties not contained in
 869        # this result.
 870        for partyval in list(self._parties.values()):
 871            partyval.claimed = False
 872        for party_in in parties_in:
 873            addr = party_in['a']
 874            assert isinstance(addr, str)
 875            port = party_in['p']
 876            assert isinstance(port, int)
 877            party_key = f'{addr}_{port}'
 878            party = self._parties.get(party_key)
 879            if party is not None:
 880                party.claimed = True
 881        self._parties = {
 882            key: val for key, val in list(self._parties.items()) if val.claimed
 883        }
 884        self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed]
 885        self._party_lists_dirty = True
 886
 887        # self._update_server_list()
 888        if DEBUG_PROCESSING:
 889            print(
 890                f'Handled public party query results in '
 891                f'{time.time()-starttime:.5f}s.'
 892            )
 893
 894    def _update(self) -> None:
 895        """Periodic updating."""
 896
 897        plus = bui.app.plus
 898        assert plus is not None
 899
 900        # Special case: if a party-queue window is up, don't do any of this
 901        # (keeps things smoother).
 902        # if bui.app.ui.have_party_queue_window:
 903        #     return
 904
 905        if self._sub_tab is SubTabType.JOIN:
 906            # Keep our filter-text up to date from the UI.
 907            text = self._filter_text
 908            if text:
 909                filter_value = cast(str, bui.textwidget(query=text))
 910                if filter_value != self._filter_value:
 911                    self._filter_value = filter_value
 912                    self._party_lists_dirty = True
 913
 914                    # Also wipe out party clean-row states.
 915                    # (otherwise if a party disappears from a row due to
 916                    # filtering and then reappears on that same row when
 917                    # the filter is removed it may not update)
 918                    for party in self._parties.values():
 919                        party.clean_display_index = None
 920
 921            self._query_party_list_periodically()
 922            self._ping_parties_periodically()
 923
 924        # If any new party infos have come in, apply some of them.
 925        self._process_pending_party_infos()
 926
 927        # Anytime we sign in/out, make sure we refresh our list.
 928        signed_in = plus.get_v1_account_state() == 'signed_in'
 929        if self._signed_in != signed_in:
 930            self._signed_in = signed_in
 931            self._party_lists_dirty = True
 932
 933        # Update sorting to account for ping updates, new parties, etc.
 934        self._update_party_lists()
 935
 936        # If we've got a party-name text widget, keep its value plugged
 937        # into our public host name.
 938        text = self._host_name_text
 939        if text:
 940            name = cast(str, bui.textwidget(query=self._host_name_text))
 941            bs.set_public_party_name(name)
 942
 943        # Update status text.
 944        status_text = self._join_status_text
 945        if status_text:
 946            if not signed_in:
 947                bui.textwidget(
 948                    edit=status_text, text=bui.Lstr(resource='notSignedInText')
 949                )
 950            else:
 951                # If we have a valid list, show no status; just the list.
 952                # Otherwise show either 'loading...' or 'error' depending
 953                # on whether this is our first go-round.
 954                if self._have_valid_server_list:
 955                    bui.textwidget(edit=status_text, text='')
 956                else:
 957                    if self._have_server_list_response:
 958                        bui.textwidget(
 959                            edit=status_text,
 960                            text=bui.Lstr(resource='errorText'),
 961                        )
 962                    else:
 963                        bui.textwidget(
 964                            edit=status_text,
 965                            text=bui.Lstr(
 966                                value='${A}...',
 967                                subs=[
 968                                    (
 969                                        '${A}',
 970                                        bui.Lstr(resource='store.loadingText'),
 971                                    )
 972                                ],
 973                            ),
 974                        )
 975
 976        self._update_party_rows()
 977
 978    def _update_party_rows(self) -> None:
 979        plus = bui.app.plus
 980        assert plus is not None
 981
 982        columnwidget = self._join_list_column
 983        if not columnwidget:
 984            return
 985
 986        assert self._join_text
 987        assert self._filter_text
 988
 989        # Janky - allow escaping when there's nothing in our list.
 990        assert self._host_scrollwidget
 991        bui.containerwidget(
 992            edit=self._host_scrollwidget,
 993            claims_up_down=(len(self._parties_displayed) > 0),
 994        )
 995        bui.textwidget(edit=self._no_servers_found_text, text='')
 996
 997        # Clip if we have more UI rows than parties to show.
 998        clipcount = len(self._ui_rows) - len(self._parties_displayed)
 999        if clipcount > 0:
1000            clipcount = max(clipcount, 50)
1001            self._ui_rows = self._ui_rows[:-clipcount]
1002
1003        # If we have no parties to show, we're done.
1004        if not self._parties_displayed:
1005            text = self._join_status_text
1006            if (
1007                plus.get_v1_account_state() == 'signed_in'
1008                and cast(str, bui.textwidget(query=text)) == ''
1009            ):
1010                bui.textwidget(
1011                    edit=self._no_servers_found_text,
1012                    text=bui.Lstr(resource='noServersFoundText'),
1013                )
1014            return
1015
1016        sub_scroll_width = 830
1017        lineheight = 42
1018        sub_scroll_height = lineheight * len(self._parties_displayed) + 50
1019        bui.containerwidget(
1020            edit=columnwidget, size=(sub_scroll_width, sub_scroll_height)
1021        )
1022
1023        # Any time our height changes, reset the refresh back to the top
1024        # so we don't see ugly empty spaces appearing during initial list
1025        # filling.
1026        if sub_scroll_height != self._last_sub_scroll_height:
1027            self._refresh_ui_row = 0
1028            self._last_sub_scroll_height = sub_scroll_height
1029
1030            # Also note that we need to redisplay everything since its pos
1031            # will have changed.. :(
1032            for party in self._parties.values():
1033                party.clean_display_index = None
1034
1035        # Ew; this rebuilding generates deferred selection callbacks
1036        # so we need to push deferred notices so we know to ignore them.
1037        def refresh_on() -> None:
1038            self._refreshing_list = True
1039
1040        bui.pushcall(refresh_on)
1041
1042        # Ok, now here's the deal: we want to avoid creating/updating this
1043        # entire list at one time because it will lead to hitches. So we
1044        # refresh individual rows quickly in a loop.
1045        rowcount = min(12, len(self._parties_displayed))
1046
1047        party_vals_displayed = list(self._parties_displayed.values())
1048        while rowcount > 0:
1049            refresh_row = self._refresh_ui_row % len(self._parties_displayed)
1050            if refresh_row >= len(self._ui_rows):
1051                self._ui_rows.append(UIRow())
1052                refresh_row = len(self._ui_rows) - 1
1053
1054            # For the first few seconds after getting our first server-list,
1055            # refresh only the top section of the list; this allows the lowest
1056            # ping servers to show up more quickly.
1057            if self._first_valid_server_list_time is not None:
1058                if time.time() - self._first_valid_server_list_time < 4.0:
1059                    if refresh_row > 40:
1060                        refresh_row = 0
1061
1062            self._ui_rows[refresh_row].update(
1063                refresh_row,
1064                party_vals_displayed[refresh_row],
1065                sub_scroll_width=sub_scroll_width,
1066                sub_scroll_height=sub_scroll_height,
1067                lineheight=lineheight,
1068                columnwidget=columnwidget,
1069                join_text=self._join_text,
1070                existing_selection=self._selection,
1071                filter_text=self._filter_text,
1072                tab=self,
1073            )
1074            self._refresh_ui_row = refresh_row + 1
1075            rowcount -= 1
1076
1077        # So our selection callbacks can start firing..
1078        def refresh_off() -> None:
1079            self._refreshing_list = False
1080
1081        bui.pushcall(refresh_off)
1082
1083    def _process_pending_party_infos(self) -> None:
1084        starttime = time.time()
1085
1086        # We want to do this in small enough pieces to not cause UI hitches.
1087        chunksize = 30
1088        parties_in = self._pending_party_infos[:chunksize]
1089        self._pending_party_infos = self._pending_party_infos[chunksize:]
1090        for party_in in parties_in:
1091            addr = party_in['a']
1092            assert isinstance(addr, str)
1093            port = party_in['p']
1094            assert isinstance(port, int)
1095            party_key = f'{addr}_{port}'
1096            party = self._parties.get(party_key)
1097            if party is None:
1098                # If this party is new to us, init it.
1099                party = PartyEntry(
1100                    address=addr,
1101                    next_ping_time=bui.apptime() + 0.001 * party_in['pd'],
1102                    index=self._next_entry_index,
1103                )
1104                self._parties[party_key] = party
1105                self._parties_sorted.append((party_key, party))
1106                self._party_lists_dirty = True
1107                self._next_entry_index += 1
1108                assert isinstance(party.address, str)
1109                assert isinstance(party.next_ping_time, float)
1110
1111            # Now, new or not, update its values.
1112            party.queue = party_in.get('q')
1113            assert isinstance(party.queue, (str, type(None)))
1114            party.port = port
1115            party.name = party_in['n']
1116            assert isinstance(party.name, str)
1117            party.size = party_in['s']
1118            assert isinstance(party.size, int)
1119            party.size_max = party_in['sm']
1120            assert isinstance(party.size_max, int)
1121
1122            # Server provides this in milliseconds; we use seconds.
1123            party.ping_interval = 0.001 * party_in['pi']
1124            assert isinstance(party.ping_interval, float)
1125            party.stats_addr = party_in['sa']
1126            assert isinstance(party.stats_addr, (str, type(None)))
1127
1128            # Make sure the party's UI gets updated.
1129            party.clean_display_index = None
1130
1131        if DEBUG_PROCESSING and parties_in:
1132            print(
1133                f'Processed {len(parties_in)} raw party infos in'
1134                f' {time.time()-starttime:.5f}s.'
1135            )
1136
1137    def _update_party_lists(self) -> None:
1138        plus = bui.app.plus
1139        assert plus is not None
1140
1141        if not self._party_lists_dirty:
1142            return
1143        starttime = time.time()
1144        assert len(self._parties_sorted) == len(self._parties)
1145
1146        self._parties_sorted.sort(
1147            key=lambda p: (
1148                p[1].ping if p[1].ping is not None else 999999.0,
1149                p[1].index,
1150            )
1151        )
1152
1153        # If signed out or errored, show no parties.
1154        if (
1155            plus.get_v1_account_state() != 'signed_in'
1156            or not self._have_valid_server_list
1157        ):
1158            self._parties_displayed = {}
1159        else:
1160            if self._filter_value:
1161                filterval = self._filter_value.lower()
1162                self._parties_displayed = {
1163                    k: v
1164                    for k, v in self._parties_sorted
1165                    if filterval in v.name.lower()
1166                }
1167            else:
1168                self._parties_displayed = dict(self._parties_sorted)
1169
1170        # Any time our selection disappears from the displayed list, go back to
1171        # auto-selecting the top entry.
1172        if (
1173            self._selection is not None
1174            and self._selection.entry_key not in self._parties_displayed
1175        ):
1176            self._have_user_selected_row = False
1177
1178        # Whenever the user hasn't selected something, keep the first visible
1179        # row selected.
1180        if not self._have_user_selected_row and self._parties_displayed:
1181            firstpartykey = next(iter(self._parties_displayed))
1182            self._selection = Selection(firstpartykey, SelectionComponent.NAME)
1183
1184        self._party_lists_dirty = False
1185        if DEBUG_PROCESSING:
1186            print(
1187                f'Sorted {len(self._parties_sorted)} parties in'
1188                f' {time.time()-starttime:.5f}s.'
1189            )
1190
1191    def _query_party_list_periodically(self) -> None:
1192        now = bui.apptime()
1193
1194        plus = bui.app.plus
1195        assert plus is not None
1196
1197        # Fire off a new public-party query periodically.
1198        if (
1199            self._last_server_list_query_time is None
1200            or now - self._last_server_list_query_time
1201            > 0.001
1202            * plus.get_v1_account_misc_read_val('pubPartyRefreshMS', 10000)
1203        ):
1204            self._last_server_list_query_time = now
1205            if DEBUG_SERVER_COMMUNICATION:
1206                print('REQUESTING SERVER LIST')
1207            if plus.get_v1_account_state() == 'signed_in':
1208                plus.add_v1_account_transaction(
1209                    {
1210                        'type': 'PUBLIC_PARTY_QUERY',
1211                        'proto': bs.protocol_version(),
1212                        'lang': bui.app.lang.language,
1213                    },
1214                    callback=bui.WeakCall(self._on_public_party_query_result),
1215                )
1216                plus.run_v1_account_transactions()
1217            else:
1218                self._on_public_party_query_result(None)
1219
1220    def _ping_parties_periodically(self) -> None:
1221        assert bui.app.classic is not None
1222        now = bui.apptime()
1223
1224        # Go through our existing public party entries firing off pings
1225        # for any that have timed out.
1226        for party in list(self._parties.values()):
1227            if (
1228                party.next_ping_time <= now
1229                and bui.app.classic.ping_thread_count < 15
1230            ):
1231                # Crank the interval up for high-latency or non-responding
1232                # parties to save us some useless work.
1233                mult = 1
1234                if party.ping_responses == 0:
1235                    if party.ping_attempts > 4:
1236                        mult = 10
1237                    elif party.ping_attempts > 2:
1238                        mult = 5
1239                if party.ping is not None:
1240                    mult = (
1241                        10 if party.ping > 300 else 5 if party.ping > 150 else 2
1242                    )
1243
1244                interval = party.ping_interval * mult
1245                if DEBUG_SERVER_COMMUNICATION:
1246                    print(
1247                        f'pinging #{party.index} cur={party.ping} '
1248                        f'interval={interval} '
1249                        f'({party.ping_responses}/{party.ping_attempts})'
1250                    )
1251
1252                party.next_ping_time = now + party.ping_interval * mult
1253                party.ping_attempts += 1
1254
1255                PingThread(
1256                    party.address, party.port, bui.WeakCall(self._ping_callback)
1257                ).start()
1258
1259    def _ping_callback(
1260        self, address: str, port: int | None, result: float | None
1261    ) -> None:
1262        # Look for a widget corresponding to this target.
1263        # If we find one, update our list.
1264        party_key = f'{address}_{port}'
1265        party = self._parties.get(party_key)
1266        if party is not None:
1267            if result is not None:
1268                party.ping_responses += 1
1269
1270            # We now smooth ping a bit to reduce jumping around in the list
1271            # (only where pings are relatively good).
1272            current_ping = party.ping
1273            if current_ping is not None and result is not None and result < 150:
1274                smoothing = 0.7
1275                party.ping = (
1276                    smoothing * current_ping + (1.0 - smoothing) * result
1277                )
1278            else:
1279                party.ping = result
1280
1281            # Need to re-sort the list and update the row display.
1282            party.clean_display_index = None
1283            self._party_lists_dirty = True
1284
1285    def _fetch_local_addr_cb(self, val: str) -> None:
1286        self._local_address = str(val)
1287
1288    def _on_public_party_accessible_response(
1289        self, data: dict[str, Any] | None
1290    ) -> None:
1291        # If we've got status text widgets, update them.
1292        text = self._host_status_text
1293        if text:
1294            if data is None:
1295                bui.textwidget(
1296                    edit=text,
1297                    text=bui.Lstr(
1298                        resource='gatherWindow.' 'partyStatusNoConnectionText'
1299                    ),
1300                    color=(1, 0, 0),
1301                )
1302            else:
1303                if not data.get('accessible', False):
1304                    ex_line: str | bui.Lstr
1305                    if self._local_address is not None:
1306                        ex_line = bui.Lstr(
1307                            value='\n${A} ${B}',
1308                            subs=[
1309                                (
1310                                    '${A}',
1311                                    bui.Lstr(
1312                                        resource='gatherWindow.'
1313                                        'manualYourLocalAddressText'
1314                                    ),
1315                                ),
1316                                ('${B}', self._local_address),
1317                            ],
1318                        )
1319                    else:
1320                        ex_line = ''
1321                    bui.textwidget(
1322                        edit=text,
1323                        text=bui.Lstr(
1324                            value='${A}\n${B}${C}',
1325                            subs=[
1326                                (
1327                                    '${A}',
1328                                    bui.Lstr(
1329                                        resource='gatherWindow.'
1330                                        'partyStatusNotJoinableText'
1331                                    ),
1332                                ),
1333                                (
1334                                    '${B}',
1335                                    bui.Lstr(
1336                                        resource='gatherWindow.'
1337                                        'manualRouterForwardingText',
1338                                        subs=[
1339                                            (
1340                                                '${PORT}',
1341                                                str(bs.get_game_port()),
1342                                            )
1343                                        ],
1344                                    ),
1345                                ),
1346                                ('${C}', ex_line),
1347                            ],
1348                        ),
1349                        color=(1, 0, 0),
1350                    )
1351                else:
1352                    bui.textwidget(
1353                        edit=text,
1354                        text=bui.Lstr(
1355                            resource='gatherWindow.' 'partyStatusJoinableText'
1356                        ),
1357                        color=(0, 1, 0),
1358                    )
1359
1360    def _do_status_check(self) -> None:
1361        assert bui.app.classic is not None
1362        bui.textwidget(
1363            edit=self._host_status_text,
1364            color=(1, 1, 0),
1365            text=bui.Lstr(resource='gatherWindow.' 'partyStatusCheckingText'),
1366        )
1367        bui.app.classic.master_server_v1_get(
1368            'bsAccessCheck',
1369            {'b': bui.app.env.build_number},
1370            callback=bui.WeakCall(self._on_public_party_accessible_response),
1371        )
1372
1373    def _on_start_advertizing_press(self) -> None:
1374        from bauiv1lib.account import show_sign_in_prompt
1375
1376        plus = bui.app.plus
1377        assert plus is not None
1378
1379        if plus.get_v1_account_state() != 'signed_in':
1380            show_sign_in_prompt()
1381            return
1382
1383        name = cast(str, bui.textwidget(query=self._host_name_text))
1384        if name == '':
1385            bui.screenmessage(
1386                bui.Lstr(resource='internal.invalidNameErrorText'),
1387                color=(1, 0, 0),
1388            )
1389            bui.getsound('error').play()
1390            return
1391        bs.set_public_party_name(name)
1392        cfg = bui.app.config
1393        cfg['Public Party Name'] = name
1394        cfg.commit()
1395        bui.getsound('shieldUp').play()
1396        bs.set_public_party_enabled(True)
1397
1398        # In GUI builds we want to authenticate clients only when hosting
1399        # public parties.
1400        bs.set_authenticate_clients(True)
1401
1402        self._do_status_check()
1403        bui.buttonwidget(
1404            edit=self._host_toggle_button,
1405            label=bui.Lstr(
1406                resource='gatherWindow.makePartyPrivateText',
1407                fallback_resource='gatherWindow.stopAdvertisingText',
1408            ),
1409            on_activate_call=self._on_stop_advertising_press,
1410        )
1411
1412    def _on_stop_advertising_press(self) -> None:
1413        bs.set_public_party_enabled(False)
1414
1415        # In GUI builds we want to authenticate clients only when hosting
1416        # public parties.
1417        bs.set_authenticate_clients(False)
1418        bui.getsound('shieldDown').play()
1419        text = self._host_status_text
1420        if text:
1421            bui.textwidget(
1422                edit=text,
1423                text=bui.Lstr(
1424                    resource='gatherWindow.' 'partyStatusNotPublicText'
1425                ),
1426                color=(0.6, 0.6, 0.6),
1427            )
1428        bui.buttonwidget(
1429            edit=self._host_toggle_button,
1430            label=bui.Lstr(
1431                resource='gatherWindow.makePartyPublicText',
1432                fallback_resource='gatherWindow.startAdvertisingText',
1433            ),
1434            on_activate_call=self._on_start_advertizing_press,
1435        )
1436
1437    def on_public_party_activate(self, party: PartyEntry) -> None:
1438        """Called when a party is clicked or otherwise activated."""
1439        self.save_state()
1440        if party.queue is not None:
1441            from bauiv1lib.partyqueue import PartyQueueWindow
1442
1443            bui.getsound('swish').play()
1444            PartyQueueWindow(party.queue, party.address, party.port)
1445        else:
1446            address = party.address
1447            port = party.port
1448
1449            # Rate limit this a bit.
1450            now = time.time()
1451            last_connect_time = self._last_connect_attempt_time
1452            if last_connect_time is None or now - last_connect_time > 2.0:
1453                bs.connect_to_party(address, port=port)
1454                self._last_connect_attempt_time = now
1455
1456    def set_public_party_selection(self, sel: Selection) -> None:
1457        """Set the sel."""
1458        if self._refreshing_list:
1459            return
1460        self._selection = sel
1461        self._have_user_selected_row = True
1462
1463    def _on_max_public_party_size_minus_press(self) -> None:
1464        val = max(1, bs.get_public_party_max_size() - 1)
1465        bs.set_public_party_max_size(val)
1466        bui.textwidget(edit=self._host_max_party_size_value, text=str(val))
1467
1468    def _on_max_public_party_size_plus_press(self) -> None:
1469        val = bs.get_public_party_max_size()
1470        val += 1
1471        bs.set_public_party_max_size(val)
1472        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        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

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:
486    @override
487    def on_deactivate(self) -> None:
488        self._update_timer = None

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

@override
def save_state(self) -> None:
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        )

Called when the parent window is saving state.

@override
def restore_state(self) -> None:
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

Called when the parent window is restoring state.

def on_public_party_activate(self, party: PartyEntry) -> None:
1437    def on_public_party_activate(self, party: PartyEntry) -> None:
1438        """Called when a party is clicked or otherwise activated."""
1439        self.save_state()
1440        if party.queue is not None:
1441            from bauiv1lib.partyqueue import PartyQueueWindow
1442
1443            bui.getsound('swish').play()
1444            PartyQueueWindow(party.queue, party.address, party.port)
1445        else:
1446            address = party.address
1447            port = party.port
1448
1449            # Rate limit this a bit.
1450            now = time.time()
1451            last_connect_time = self._last_connect_attempt_time
1452            if last_connect_time is None or now - last_connect_time > 2.0:
1453                bs.connect_to_party(address, port=port)
1454                self._last_connect_attempt_time = now

Called when a party is clicked or otherwise activated.

def set_public_party_selection(self, sel: Selection) -> None:
1456    def set_public_party_selection(self, sel: Selection) -> None:
1457        """Set the sel."""
1458        if self._refreshing_list:
1459            return
1460        self._selection = sel
1461        self._have_user_selected_row = True

Set the sel.