bauiv1lib.gather.publictab

Defines the public tab in the gather UI.

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

Available sub-tabs.

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

Info about a public party.

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

Return the key used to store this party.

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

Wrangles UI for a row in the party list.

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

Update for the given data.

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

State saved/restored only while the app is running.

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

Describes what part of an entry is selected.

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

Describes the currently selected list element.

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

Thread for fetching an address in the bg.

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

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

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

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

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

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

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

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

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

Method representing the thread's activity.

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

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

Thread for sending out game pings.

PingThread( address: str, port: int, call: Callable[[str, int, float | None], int | None])
279    def __init__(
280        self,
281        address: str,
282        port: int,
283        call: Callable[[str, int, float | None], int | None],
284    ):
285        super().__init__()
286        self._address = address
287        self._port = port
288        self._call = call

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

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

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

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

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

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

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

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

Method representing the thread's activity.

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

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

The public tab in the gather UI

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

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

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

Called when the parent window is saving state.

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

Called when the parent window is restoring state.

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

Called when a party is clicked or otherwise activated.

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

Set the sel.