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