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