bauiv1lib.gather.publictab

Defines the public tab in the gather UI.

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

Available sub-tabs.

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

Info about a public party.

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

Return the key used to store this party.

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

Wrangles UI for a row in the party list.

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

Update for the given data.

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

State saved/restored only while the app is running.

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

Describes what part of an entry is selected.

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

Describes the currently selected list element.

Selection( entry_key: str, component: SelectionComponent)
entry_key: str
component: SelectionComponent
class AddrFetchThread(threading.Thread):
244class AddrFetchThread(Thread):
245    """Thread for fetching an address in the bg."""
246
247    def __init__(self, call: Callable[[Any], Any]):
248        super().__init__()
249        self._call = call
250
251    @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()

Thread for fetching an address in the bg.

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

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

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

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

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

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

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

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

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

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):
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

Thread for sending out game pings.

PingThread( address: str, port: int, call: Callable[[str, int, float | None], int | None])
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

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:
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

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

The public tab in the gather UI

PublicGatherTab(window: bauiv1lib.gather.GatherWindow)
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
@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:
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

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

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

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

Called when the parent window is saving state.

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

Called when the parent window is restoring state.

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

Called when a party is clicked or otherwise activated.

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

Set the sel.