bauiv1lib.gather.publictab

Defines the public tab in the gather UI.

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

Available sub-tabs.

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

Info about a public party.

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

Return the key used to store this party.

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

Wrangles UI for a row in the party list.

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

Update for the given data.

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

State saved/restored only while the app is running.

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

Describes what part of an entry is selected.

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

Describes the currently selected list element.

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

Thread for fetching an address in the bg.

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

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

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

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

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

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

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

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

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

Method representing the thread's activity.

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

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

Thread for sending out game pings.

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

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

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

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

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

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

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

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

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

Method representing the thread's activity.

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

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

The public tab in the gather UI

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

Called when the tab becomes the active one.

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

@override
def on_deactivate(self) -> None:
485    @override
486    def on_deactivate(self) -> None:
487        self._update_timer = None

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

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

Called when the parent window is saving state.

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

Called when the parent window is restoring state.

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

Called when a party is clicked or otherwise activated.

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

Set the sel.