bauiv1lib.party
Provides party related UI.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Provides party related UI.""" 4 5from __future__ import annotations 6 7import math 8import logging 9from typing import TYPE_CHECKING, cast, override 10 11import bauiv1 as bui 12import bascenev1 as bs 13from bauiv1lib.popup import PopupMenuWindow 14 15if TYPE_CHECKING: 16 from typing import Sequence, Any 17 18 from bauiv1lib.popup import PopupWindow 19 20 21class PartyWindow(bui.Window): 22 """Party list/chat window.""" 23 24 @override 25 def __del__(self) -> None: 26 bui.set_party_window_open(False) 27 super().__del__() 28 29 def __init__(self, origin: Sequence[float] = (0, 0)): 30 bui.set_party_window_open(True) 31 self._r = 'partyWindow' 32 self._popup_type: str | None = None 33 self._popup_party_member_client_id: int | None = None 34 self._popup_party_member_is_host: bool | None = None 35 self._width = 500 36 assert bui.app.classic is not None 37 uiscale = bui.app.ui_v1.uiscale 38 self._height = ( 39 365 40 if uiscale is bui.UIScale.SMALL 41 else 480 if uiscale is bui.UIScale.MEDIUM else 600 42 ) 43 self._display_old_msgs = True 44 super().__init__( 45 root_widget=bui.containerwidget( 46 size=(self._width, self._height), 47 transition='in_scale', 48 color=(0.40, 0.55, 0.20), 49 parent=bui.get_special_widget('overlay_stack'), 50 on_outside_click_call=self.close_with_sound, 51 scale_origin_stack_offset=origin, 52 scale=( 53 1.8 54 if uiscale is bui.UIScale.SMALL 55 else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9 56 ), 57 stack_offset=( 58 (200, -10) 59 if uiscale is bui.UIScale.SMALL 60 else ( 61 (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60) 62 ) 63 ), 64 ), 65 # We exist in the overlay stack so main-windows being 66 # recreated doesn't affect us. 67 prevent_main_window_auto_recreate=False, 68 ) 69 70 self._cancel_button = bui.buttonwidget( 71 parent=self._root_widget, 72 scale=0.7, 73 position=(30, self._height - 47), 74 size=(50, 50), 75 label='', 76 on_activate_call=self.close, 77 autoselect=True, 78 color=(0.45, 0.63, 0.15), 79 icon=bui.gettexture('crossOut'), 80 iconscale=1.2, 81 ) 82 bui.containerwidget( 83 edit=self._root_widget, cancel_button=self._cancel_button 84 ) 85 86 self._menu_button = bui.buttonwidget( 87 parent=self._root_widget, 88 scale=0.7, 89 position=(self._width - 60, self._height - 47), 90 size=(50, 50), 91 label='...', 92 autoselect=True, 93 button_type='square', 94 on_activate_call=bui.WeakCall(self._on_menu_button_press), 95 color=(0.55, 0.73, 0.25), 96 iconscale=1.2, 97 ) 98 99 info = bs.get_connection_to_host_info_2() 100 101 if info is not None and info.name != '': 102 title = bui.Lstr(value=info.name) 103 else: 104 title = bui.Lstr(resource=f'{self._r}.titleText') 105 106 self._title_text = bui.textwidget( 107 parent=self._root_widget, 108 scale=0.9, 109 color=(0.5, 0.7, 0.5), 110 text=title, 111 size=(0, 0), 112 position=(self._width * 0.5, self._height - 29), 113 maxwidth=self._width * 0.7, 114 h_align='center', 115 v_align='center', 116 ) 117 118 self._empty_str = bui.textwidget( 119 parent=self._root_widget, 120 scale=0.6, 121 size=(0, 0), 122 # color=(0.5, 1.0, 0.5), 123 shadow=0.3, 124 position=(self._width * 0.5, self._height - 57), 125 maxwidth=self._width * 0.85, 126 h_align='center', 127 v_align='center', 128 ) 129 self._empty_str_2 = bui.textwidget( 130 parent=self._root_widget, 131 scale=0.5, 132 size=(0, 0), 133 color=(0.5, 1.0, 0.5), 134 shadow=0.1, 135 position=(self._width * 0.5, self._height - 75), 136 maxwidth=self._width * 0.85, 137 h_align='center', 138 v_align='center', 139 ) 140 141 self._scroll_width = self._width - 50 142 self._scrollwidget = bui.scrollwidget( 143 parent=self._root_widget, 144 size=(self._scroll_width, self._height - 200), 145 position=(30, 80), 146 color=(0.4, 0.6, 0.3), 147 border_opacity=0.6, 148 ) 149 self._columnwidget = bui.columnwidget( 150 parent=self._scrollwidget, border=2, left_border=-200, margin=0 151 ) 152 bui.widget(edit=self._menu_button, down_widget=self._columnwidget) 153 154 self._muted_text = bui.textwidget( 155 parent=self._root_widget, 156 position=(self._width * 0.5, self._height * 0.5), 157 size=(0, 0), 158 h_align='center', 159 v_align='center', 160 text=bui.Lstr(resource='chatMutedText'), 161 ) 162 self._chat_texts: list[bui.Widget] = [] 163 164 self._text_field = txt = bui.textwidget( 165 parent=self._root_widget, 166 editable=True, 167 size=(530, 40), 168 position=(44, 39), 169 text='', 170 maxwidth=494, 171 shadow=0.3, 172 flatness=1.0, 173 description=bui.Lstr(resource=f'{self._r}.chatMessageText'), 174 autoselect=True, 175 v_align='center', 176 corner_scale=0.7, 177 ) 178 179 bui.widget( 180 edit=self._scrollwidget, 181 autoselect=True, 182 left_widget=self._cancel_button, 183 up_widget=self._cancel_button, 184 down_widget=self._text_field, 185 ) 186 bui.widget( 187 edit=self._columnwidget, 188 autoselect=True, 189 up_widget=self._cancel_button, 190 down_widget=self._text_field, 191 ) 192 bui.containerwidget(edit=self._root_widget, selected_child=txt) 193 194 btn = bui.buttonwidget( 195 parent=self._root_widget, 196 size=(50, 35), 197 label=bui.Lstr(resource=f'{self._r}.sendText'), 198 button_type='square', 199 autoselect=True, 200 position=(self._width - 70, 35), 201 on_activate_call=self._send_chat_message, 202 ) 203 204 bui.textwidget(edit=txt, on_return_press_call=btn.activate) 205 self._name_widgets: list[bui.Widget] = [] 206 self._roster: list[dict[str, Any]] | None = None 207 self._update_timer = bui.AppTimer( 208 1.0, bui.WeakCall(self._update), repeat=True 209 ) 210 self._update() 211 212 def on_chat_message(self, msg: str) -> None: 213 """Called when a new chat message comes through.""" 214 if not bui.app.config.resolve('Chat Muted'): 215 self._add_msg(msg) 216 217 def _add_msg(self, msg: str) -> None: 218 txt = bui.textwidget( 219 parent=self._columnwidget, 220 h_align='left', 221 v_align='center', 222 scale=0.55, 223 size=(900, 13), 224 text=msg, 225 autoselect=True, 226 maxwidth=self._scroll_width * 0.94, 227 shadow=0.3, 228 flatness=1.0, 229 on_activate_call=bui.Call(self._copy_msg, msg), 230 selectable=True, 231 ) 232 233 self._chat_texts.append(txt) 234 while len(self._chat_texts) > 40: 235 self._chat_texts.pop(0).delete() 236 bui.containerwidget(edit=self._columnwidget, visible_child=txt) 237 238 def _copy_msg(self, msg: str) -> None: 239 if bui.clipboard_is_supported(): 240 bui.clipboard_set_text(msg) 241 bui.screenmessage( 242 bui.Lstr(resource='copyConfirmText'), color=(0, 1, 0) 243 ) 244 245 def _on_menu_button_press(self) -> None: 246 is_muted = bui.app.config.resolve('Chat Muted') 247 assert bui.app.classic is not None 248 uiscale = bui.app.ui_v1.uiscale 249 250 choices: list[str] = ['unmute' if is_muted else 'mute'] 251 choices_display: list[bui.Lstr] = [ 252 bui.Lstr(resource='chatUnMuteText' if is_muted else 'chatMuteText') 253 ] 254 255 # Allow the 'Add to Favorites' option only if we're actually 256 # connected to a party and if it doesn't seem to be a private 257 # party (those are dynamically assigned addresses and ports so 258 # it makes no sense to save them). 259 server_info = bs.get_connection_to_host_info_2() 260 if server_info is not None and not server_info.name.startswith( 261 'Private Party ' 262 ): 263 choices.append('add_to_favorites') 264 choices_display.append(bui.Lstr(resource='addToFavoritesText')) 265 266 PopupMenuWindow( 267 position=self._menu_button.get_screen_space_center(), 268 scale=( 269 2.3 270 if uiscale is bui.UIScale.SMALL 271 else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 272 ), 273 choices=choices, 274 choices_display=choices_display, 275 current_choice='unmute' if is_muted else 'mute', 276 delegate=self, 277 ) 278 self._popup_type = 'menu' 279 280 def _update(self) -> None: 281 # pylint: disable=too-many-locals 282 # pylint: disable=too-many-branches 283 # pylint: disable=too-many-statements 284 # pylint: disable=too-many-nested-blocks 285 286 # update muted state 287 if bui.app.config.resolve('Chat Muted'): 288 bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3)) 289 # clear any chat texts we're showing 290 if self._chat_texts: 291 while self._chat_texts: 292 first = self._chat_texts.pop() 293 first.delete() 294 else: 295 bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0)) 296 # add all existing messages if chat is not muted 297 if self._display_old_msgs: 298 msgs = bs.get_chat_messages() 299 for msg in msgs: 300 self._add_msg(msg) 301 self._display_old_msgs = False 302 303 # update roster section 304 roster = bs.get_game_roster() 305 if roster != self._roster: 306 self._roster = roster 307 308 # clear out old 309 for widget in self._name_widgets: 310 widget.delete() 311 self._name_widgets = [] 312 if not self._roster: 313 top_section_height = 60 314 bui.textwidget( 315 edit=self._empty_str, 316 text=bui.Lstr(resource=f'{self._r}.emptyText'), 317 ) 318 bui.textwidget( 319 edit=self._empty_str_2, 320 text=bui.Lstr(resource='gatherWindow.descriptionShortText'), 321 ) 322 bui.scrollwidget( 323 edit=self._scrollwidget, 324 size=( 325 self._width - 50, 326 self._height - top_section_height - 110, 327 ), 328 position=(30, 80), 329 ) 330 else: 331 columns = ( 332 1 333 if len(self._roster) == 1 334 else 2 if len(self._roster) == 2 else 3 335 ) 336 rows = int(math.ceil(float(len(self._roster)) / columns)) 337 c_width = (self._width * 0.9) / max(3, columns) 338 c_width_total = c_width * columns 339 c_height = 24 340 c_height_total = c_height * rows 341 for y in range(rows): 342 for x in range(columns): 343 index = y * columns + x 344 if index < len(self._roster): 345 t_scale = 0.65 346 pos = ( 347 self._width * 0.53 348 - c_width_total * 0.5 349 + c_width * x 350 - 23, 351 self._height - 65 - c_height * y - 15, 352 ) 353 354 # if there are players present for this client, use 355 # their names as a display string instead of the 356 # client spec-string 357 try: 358 if self._roster[index]['players']: 359 # if there's just one, use the full name; 360 # otherwise combine short names 361 if len(self._roster[index]['players']) == 1: 362 p_str = self._roster[index]['players'][ 363 0 364 ]['name_full'] 365 else: 366 p_str = '/'.join( 367 [ 368 entry['name'] 369 for entry in self._roster[ 370 index 371 ]['players'] 372 ] 373 ) 374 if len(p_str) > 25: 375 p_str = p_str[:25] + '...' 376 else: 377 p_str = self._roster[index][ 378 'display_string' 379 ] 380 except Exception: 381 logging.exception( 382 'Error calcing client name str.' 383 ) 384 p_str = '???' 385 386 widget = bui.textwidget( 387 parent=self._root_widget, 388 position=(pos[0], pos[1]), 389 scale=t_scale, 390 size=(c_width * 0.85, 30), 391 maxwidth=c_width * 0.85, 392 color=(1, 1, 1) if index == 0 else (1, 1, 1), 393 selectable=True, 394 autoselect=True, 395 click_activate=True, 396 text=bui.Lstr(value=p_str), 397 h_align='left', 398 v_align='center', 399 ) 400 self._name_widgets.append(widget) 401 402 # in newer versions client_id will be present and 403 # we can use that to determine who the host is. 404 # in older versions we assume the first client is 405 # host 406 if self._roster[index]['client_id'] is not None: 407 is_host = self._roster[index]['client_id'] == -1 408 else: 409 is_host = index == 0 410 411 # FIXME: Should pass client_id to these sort of 412 # calls; not spec-string (perhaps should wait till 413 # client_id is more readily available though). 414 bui.textwidget( 415 edit=widget, 416 on_activate_call=bui.Call( 417 self._on_party_member_press, 418 self._roster[index]['client_id'], 419 is_host, 420 widget, 421 ), 422 ) 423 pos = ( 424 self._width * 0.53 425 - c_width_total * 0.5 426 + c_width * x, 427 self._height - 65 - c_height * y, 428 ) 429 430 # Make the assumption that the first roster 431 # entry is the server. 432 # FIXME: Shouldn't do this. 433 if is_host: 434 twd = min( 435 c_width * 0.85, 436 bui.get_string_width( 437 p_str, suppress_warning=True 438 ) 439 * t_scale, 440 ) 441 self._name_widgets.append( 442 bui.textwidget( 443 parent=self._root_widget, 444 position=( 445 pos[0] + twd + 1, 446 pos[1] - 0.5, 447 ), 448 size=(0, 0), 449 h_align='left', 450 v_align='center', 451 maxwidth=c_width * 0.96 - twd, 452 color=(0.1, 1, 0.1, 0.5), 453 text=bui.Lstr( 454 resource=f'{self._r}.hostText' 455 ), 456 scale=0.4, 457 shadow=0.1, 458 flatness=1.0, 459 ) 460 ) 461 bui.textwidget(edit=self._empty_str, text='') 462 bui.textwidget(edit=self._empty_str_2, text='') 463 bui.scrollwidget( 464 edit=self._scrollwidget, 465 size=( 466 self._width - 50, 467 max(100, self._height - 139 - c_height_total), 468 ), 469 position=(30, 80), 470 ) 471 472 def popup_menu_selected_choice( 473 self, popup_window: PopupMenuWindow, choice: str 474 ) -> None: 475 """Called when a choice is selected in the popup.""" 476 del popup_window # unused 477 if self._popup_type == 'partyMemberPress': 478 if self._popup_party_member_is_host: 479 bui.getsound('error').play() 480 bui.screenmessage( 481 bui.Lstr(resource='internal.cantKickHostError'), 482 color=(1, 0, 0), 483 ) 484 else: 485 assert self._popup_party_member_client_id is not None 486 487 # Ban for 5 minutes. 488 result = bs.disconnect_client( 489 self._popup_party_member_client_id, ban_time=5 * 60 490 ) 491 if not result: 492 bui.getsound('error').play() 493 bui.screenmessage( 494 bui.Lstr(resource='getTicketsWindow.unavailableText'), 495 color=(1, 0, 0), 496 ) 497 elif self._popup_type == 'menu': 498 if choice in ('mute', 'unmute'): 499 cfg = bui.app.config 500 cfg['Chat Muted'] = choice == 'mute' 501 cfg.apply_and_commit() 502 self._display_old_msgs = True 503 self._update() 504 if choice == 'add_to_favorites': 505 info = bs.get_connection_to_host_info_2() 506 if info is not None: 507 self._add_to_favorites( 508 name=info.name, 509 address=info.address, 510 port_num=info.port, 511 ) 512 else: 513 # We should not allow the user to see this option 514 # if they aren't in a server; this is our bad. 515 bui.screenmessage( 516 bui.Lstr(resource='errorText'), color=(1, 0, 0) 517 ) 518 bui.getsound('error').play() 519 else: 520 print(f'unhandled popup type: {self._popup_type}') 521 522 def _add_to_favorites( 523 self, name: str, address: str | None, port_num: int | None 524 ) -> None: 525 addr = address 526 if addr == '': 527 bui.screenmessage( 528 bui.Lstr(resource='internal.invalidAddressErrorText'), 529 color=(1, 0, 0), 530 ) 531 bui.getsound('error').play() 532 return 533 port = port_num if port_num is not None else -1 534 if port > 65535 or port < 0: 535 bui.screenmessage( 536 bui.Lstr(resource='internal.invalidPortErrorText'), 537 color=(1, 0, 0), 538 ) 539 bui.getsound('error').play() 540 return 541 542 # Avoid empty names. 543 if not name: 544 name = f'{addr}@{port}' 545 546 config = bui.app.config 547 548 if addr: 549 if not isinstance(config.get('Saved Servers'), dict): 550 config['Saved Servers'] = {} 551 config['Saved Servers'][f'{addr}@{port}'] = { 552 'addr': addr, 553 'port': port, 554 'name': name, 555 } 556 config.commit() 557 bui.getsound('gunCocking').play() 558 bui.screenmessage( 559 bui.Lstr( 560 resource='addedToFavoritesText', subs=[('${NAME}', name)] 561 ), 562 color=(0, 1, 0), 563 ) 564 else: 565 bui.screenmessage( 566 bui.Lstr(resource='internal.invalidAddressErrorText'), 567 color=(1, 0, 0), 568 ) 569 bui.getsound('error').play() 570 571 def popup_menu_closing(self, popup_window: PopupWindow) -> None: 572 """Called when the popup is closing.""" 573 574 def _on_party_member_press( 575 self, client_id: int, is_host: bool, widget: bui.Widget 576 ) -> None: 577 # if we're the host, pop up 'kick' options for all non-host members 578 if bs.get_foreground_host_session() is not None: 579 kick_str = bui.Lstr(resource='kickText') 580 else: 581 # kick-votes appeared in build 14248 582 info = bs.get_connection_to_host_info_2() 583 if info is None or info.build_number < 14248: 584 return 585 kick_str = bui.Lstr(resource='kickVoteText') 586 assert bui.app.classic is not None 587 uiscale = bui.app.ui_v1.uiscale 588 PopupMenuWindow( 589 position=widget.get_screen_space_center(), 590 scale=( 591 2.3 592 if uiscale is bui.UIScale.SMALL 593 else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 594 ), 595 choices=['kick'], 596 choices_display=[kick_str], 597 current_choice='kick', 598 delegate=self, 599 ) 600 self._popup_type = 'partyMemberPress' 601 self._popup_party_member_client_id = client_id 602 self._popup_party_member_is_host = is_host 603 604 def _send_chat_message(self) -> None: 605 text = cast(str, bui.textwidget(query=self._text_field)).strip() 606 if text != '': 607 bs.chatmessage(text) 608 bui.textwidget(edit=self._text_field, text='') 609 610 def close(self) -> None: 611 """Close the window.""" 612 # no-op if our underlying widget is dead or on its way out. 613 if not self._root_widget or self._root_widget.transitioning_out: 614 return 615 616 bui.containerwidget(edit=self._root_widget, transition='out_scale') 617 618 def close_with_sound(self) -> None: 619 """Close the window and make a lovely sound.""" 620 # no-op if our underlying widget is dead or on its way out. 621 if not self._root_widget or self._root_widget.transitioning_out: 622 return 623 624 bui.getsound('swish').play() 625 self.close()
class
PartyWindow(bauiv1._uitypes.Window):
22class PartyWindow(bui.Window): 23 """Party list/chat window.""" 24 25 @override 26 def __del__(self) -> None: 27 bui.set_party_window_open(False) 28 super().__del__() 29 30 def __init__(self, origin: Sequence[float] = (0, 0)): 31 bui.set_party_window_open(True) 32 self._r = 'partyWindow' 33 self._popup_type: str | None = None 34 self._popup_party_member_client_id: int | None = None 35 self._popup_party_member_is_host: bool | None = None 36 self._width = 500 37 assert bui.app.classic is not None 38 uiscale = bui.app.ui_v1.uiscale 39 self._height = ( 40 365 41 if uiscale is bui.UIScale.SMALL 42 else 480 if uiscale is bui.UIScale.MEDIUM else 600 43 ) 44 self._display_old_msgs = True 45 super().__init__( 46 root_widget=bui.containerwidget( 47 size=(self._width, self._height), 48 transition='in_scale', 49 color=(0.40, 0.55, 0.20), 50 parent=bui.get_special_widget('overlay_stack'), 51 on_outside_click_call=self.close_with_sound, 52 scale_origin_stack_offset=origin, 53 scale=( 54 1.8 55 if uiscale is bui.UIScale.SMALL 56 else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9 57 ), 58 stack_offset=( 59 (200, -10) 60 if uiscale is bui.UIScale.SMALL 61 else ( 62 (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60) 63 ) 64 ), 65 ), 66 # We exist in the overlay stack so main-windows being 67 # recreated doesn't affect us. 68 prevent_main_window_auto_recreate=False, 69 ) 70 71 self._cancel_button = bui.buttonwidget( 72 parent=self._root_widget, 73 scale=0.7, 74 position=(30, self._height - 47), 75 size=(50, 50), 76 label='', 77 on_activate_call=self.close, 78 autoselect=True, 79 color=(0.45, 0.63, 0.15), 80 icon=bui.gettexture('crossOut'), 81 iconscale=1.2, 82 ) 83 bui.containerwidget( 84 edit=self._root_widget, cancel_button=self._cancel_button 85 ) 86 87 self._menu_button = bui.buttonwidget( 88 parent=self._root_widget, 89 scale=0.7, 90 position=(self._width - 60, self._height - 47), 91 size=(50, 50), 92 label='...', 93 autoselect=True, 94 button_type='square', 95 on_activate_call=bui.WeakCall(self._on_menu_button_press), 96 color=(0.55, 0.73, 0.25), 97 iconscale=1.2, 98 ) 99 100 info = bs.get_connection_to_host_info_2() 101 102 if info is not None and info.name != '': 103 title = bui.Lstr(value=info.name) 104 else: 105 title = bui.Lstr(resource=f'{self._r}.titleText') 106 107 self._title_text = bui.textwidget( 108 parent=self._root_widget, 109 scale=0.9, 110 color=(0.5, 0.7, 0.5), 111 text=title, 112 size=(0, 0), 113 position=(self._width * 0.5, self._height - 29), 114 maxwidth=self._width * 0.7, 115 h_align='center', 116 v_align='center', 117 ) 118 119 self._empty_str = bui.textwidget( 120 parent=self._root_widget, 121 scale=0.6, 122 size=(0, 0), 123 # color=(0.5, 1.0, 0.5), 124 shadow=0.3, 125 position=(self._width * 0.5, self._height - 57), 126 maxwidth=self._width * 0.85, 127 h_align='center', 128 v_align='center', 129 ) 130 self._empty_str_2 = bui.textwidget( 131 parent=self._root_widget, 132 scale=0.5, 133 size=(0, 0), 134 color=(0.5, 1.0, 0.5), 135 shadow=0.1, 136 position=(self._width * 0.5, self._height - 75), 137 maxwidth=self._width * 0.85, 138 h_align='center', 139 v_align='center', 140 ) 141 142 self._scroll_width = self._width - 50 143 self._scrollwidget = bui.scrollwidget( 144 parent=self._root_widget, 145 size=(self._scroll_width, self._height - 200), 146 position=(30, 80), 147 color=(0.4, 0.6, 0.3), 148 border_opacity=0.6, 149 ) 150 self._columnwidget = bui.columnwidget( 151 parent=self._scrollwidget, border=2, left_border=-200, margin=0 152 ) 153 bui.widget(edit=self._menu_button, down_widget=self._columnwidget) 154 155 self._muted_text = bui.textwidget( 156 parent=self._root_widget, 157 position=(self._width * 0.5, self._height * 0.5), 158 size=(0, 0), 159 h_align='center', 160 v_align='center', 161 text=bui.Lstr(resource='chatMutedText'), 162 ) 163 self._chat_texts: list[bui.Widget] = [] 164 165 self._text_field = txt = bui.textwidget( 166 parent=self._root_widget, 167 editable=True, 168 size=(530, 40), 169 position=(44, 39), 170 text='', 171 maxwidth=494, 172 shadow=0.3, 173 flatness=1.0, 174 description=bui.Lstr(resource=f'{self._r}.chatMessageText'), 175 autoselect=True, 176 v_align='center', 177 corner_scale=0.7, 178 ) 179 180 bui.widget( 181 edit=self._scrollwidget, 182 autoselect=True, 183 left_widget=self._cancel_button, 184 up_widget=self._cancel_button, 185 down_widget=self._text_field, 186 ) 187 bui.widget( 188 edit=self._columnwidget, 189 autoselect=True, 190 up_widget=self._cancel_button, 191 down_widget=self._text_field, 192 ) 193 bui.containerwidget(edit=self._root_widget, selected_child=txt) 194 195 btn = bui.buttonwidget( 196 parent=self._root_widget, 197 size=(50, 35), 198 label=bui.Lstr(resource=f'{self._r}.sendText'), 199 button_type='square', 200 autoselect=True, 201 position=(self._width - 70, 35), 202 on_activate_call=self._send_chat_message, 203 ) 204 205 bui.textwidget(edit=txt, on_return_press_call=btn.activate) 206 self._name_widgets: list[bui.Widget] = [] 207 self._roster: list[dict[str, Any]] | None = None 208 self._update_timer = bui.AppTimer( 209 1.0, bui.WeakCall(self._update), repeat=True 210 ) 211 self._update() 212 213 def on_chat_message(self, msg: str) -> None: 214 """Called when a new chat message comes through.""" 215 if not bui.app.config.resolve('Chat Muted'): 216 self._add_msg(msg) 217 218 def _add_msg(self, msg: str) -> None: 219 txt = bui.textwidget( 220 parent=self._columnwidget, 221 h_align='left', 222 v_align='center', 223 scale=0.55, 224 size=(900, 13), 225 text=msg, 226 autoselect=True, 227 maxwidth=self._scroll_width * 0.94, 228 shadow=0.3, 229 flatness=1.0, 230 on_activate_call=bui.Call(self._copy_msg, msg), 231 selectable=True, 232 ) 233 234 self._chat_texts.append(txt) 235 while len(self._chat_texts) > 40: 236 self._chat_texts.pop(0).delete() 237 bui.containerwidget(edit=self._columnwidget, visible_child=txt) 238 239 def _copy_msg(self, msg: str) -> None: 240 if bui.clipboard_is_supported(): 241 bui.clipboard_set_text(msg) 242 bui.screenmessage( 243 bui.Lstr(resource='copyConfirmText'), color=(0, 1, 0) 244 ) 245 246 def _on_menu_button_press(self) -> None: 247 is_muted = bui.app.config.resolve('Chat Muted') 248 assert bui.app.classic is not None 249 uiscale = bui.app.ui_v1.uiscale 250 251 choices: list[str] = ['unmute' if is_muted else 'mute'] 252 choices_display: list[bui.Lstr] = [ 253 bui.Lstr(resource='chatUnMuteText' if is_muted else 'chatMuteText') 254 ] 255 256 # Allow the 'Add to Favorites' option only if we're actually 257 # connected to a party and if it doesn't seem to be a private 258 # party (those are dynamically assigned addresses and ports so 259 # it makes no sense to save them). 260 server_info = bs.get_connection_to_host_info_2() 261 if server_info is not None and not server_info.name.startswith( 262 'Private Party ' 263 ): 264 choices.append('add_to_favorites') 265 choices_display.append(bui.Lstr(resource='addToFavoritesText')) 266 267 PopupMenuWindow( 268 position=self._menu_button.get_screen_space_center(), 269 scale=( 270 2.3 271 if uiscale is bui.UIScale.SMALL 272 else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 273 ), 274 choices=choices, 275 choices_display=choices_display, 276 current_choice='unmute' if is_muted else 'mute', 277 delegate=self, 278 ) 279 self._popup_type = 'menu' 280 281 def _update(self) -> None: 282 # pylint: disable=too-many-locals 283 # pylint: disable=too-many-branches 284 # pylint: disable=too-many-statements 285 # pylint: disable=too-many-nested-blocks 286 287 # update muted state 288 if bui.app.config.resolve('Chat Muted'): 289 bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3)) 290 # clear any chat texts we're showing 291 if self._chat_texts: 292 while self._chat_texts: 293 first = self._chat_texts.pop() 294 first.delete() 295 else: 296 bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0)) 297 # add all existing messages if chat is not muted 298 if self._display_old_msgs: 299 msgs = bs.get_chat_messages() 300 for msg in msgs: 301 self._add_msg(msg) 302 self._display_old_msgs = False 303 304 # update roster section 305 roster = bs.get_game_roster() 306 if roster != self._roster: 307 self._roster = roster 308 309 # clear out old 310 for widget in self._name_widgets: 311 widget.delete() 312 self._name_widgets = [] 313 if not self._roster: 314 top_section_height = 60 315 bui.textwidget( 316 edit=self._empty_str, 317 text=bui.Lstr(resource=f'{self._r}.emptyText'), 318 ) 319 bui.textwidget( 320 edit=self._empty_str_2, 321 text=bui.Lstr(resource='gatherWindow.descriptionShortText'), 322 ) 323 bui.scrollwidget( 324 edit=self._scrollwidget, 325 size=( 326 self._width - 50, 327 self._height - top_section_height - 110, 328 ), 329 position=(30, 80), 330 ) 331 else: 332 columns = ( 333 1 334 if len(self._roster) == 1 335 else 2 if len(self._roster) == 2 else 3 336 ) 337 rows = int(math.ceil(float(len(self._roster)) / columns)) 338 c_width = (self._width * 0.9) / max(3, columns) 339 c_width_total = c_width * columns 340 c_height = 24 341 c_height_total = c_height * rows 342 for y in range(rows): 343 for x in range(columns): 344 index = y * columns + x 345 if index < len(self._roster): 346 t_scale = 0.65 347 pos = ( 348 self._width * 0.53 349 - c_width_total * 0.5 350 + c_width * x 351 - 23, 352 self._height - 65 - c_height * y - 15, 353 ) 354 355 # if there are players present for this client, use 356 # their names as a display string instead of the 357 # client spec-string 358 try: 359 if self._roster[index]['players']: 360 # if there's just one, use the full name; 361 # otherwise combine short names 362 if len(self._roster[index]['players']) == 1: 363 p_str = self._roster[index]['players'][ 364 0 365 ]['name_full'] 366 else: 367 p_str = '/'.join( 368 [ 369 entry['name'] 370 for entry in self._roster[ 371 index 372 ]['players'] 373 ] 374 ) 375 if len(p_str) > 25: 376 p_str = p_str[:25] + '...' 377 else: 378 p_str = self._roster[index][ 379 'display_string' 380 ] 381 except Exception: 382 logging.exception( 383 'Error calcing client name str.' 384 ) 385 p_str = '???' 386 387 widget = bui.textwidget( 388 parent=self._root_widget, 389 position=(pos[0], pos[1]), 390 scale=t_scale, 391 size=(c_width * 0.85, 30), 392 maxwidth=c_width * 0.85, 393 color=(1, 1, 1) if index == 0 else (1, 1, 1), 394 selectable=True, 395 autoselect=True, 396 click_activate=True, 397 text=bui.Lstr(value=p_str), 398 h_align='left', 399 v_align='center', 400 ) 401 self._name_widgets.append(widget) 402 403 # in newer versions client_id will be present and 404 # we can use that to determine who the host is. 405 # in older versions we assume the first client is 406 # host 407 if self._roster[index]['client_id'] is not None: 408 is_host = self._roster[index]['client_id'] == -1 409 else: 410 is_host = index == 0 411 412 # FIXME: Should pass client_id to these sort of 413 # calls; not spec-string (perhaps should wait till 414 # client_id is more readily available though). 415 bui.textwidget( 416 edit=widget, 417 on_activate_call=bui.Call( 418 self._on_party_member_press, 419 self._roster[index]['client_id'], 420 is_host, 421 widget, 422 ), 423 ) 424 pos = ( 425 self._width * 0.53 426 - c_width_total * 0.5 427 + c_width * x, 428 self._height - 65 - c_height * y, 429 ) 430 431 # Make the assumption that the first roster 432 # entry is the server. 433 # FIXME: Shouldn't do this. 434 if is_host: 435 twd = min( 436 c_width * 0.85, 437 bui.get_string_width( 438 p_str, suppress_warning=True 439 ) 440 * t_scale, 441 ) 442 self._name_widgets.append( 443 bui.textwidget( 444 parent=self._root_widget, 445 position=( 446 pos[0] + twd + 1, 447 pos[1] - 0.5, 448 ), 449 size=(0, 0), 450 h_align='left', 451 v_align='center', 452 maxwidth=c_width * 0.96 - twd, 453 color=(0.1, 1, 0.1, 0.5), 454 text=bui.Lstr( 455 resource=f'{self._r}.hostText' 456 ), 457 scale=0.4, 458 shadow=0.1, 459 flatness=1.0, 460 ) 461 ) 462 bui.textwidget(edit=self._empty_str, text='') 463 bui.textwidget(edit=self._empty_str_2, text='') 464 bui.scrollwidget( 465 edit=self._scrollwidget, 466 size=( 467 self._width - 50, 468 max(100, self._height - 139 - c_height_total), 469 ), 470 position=(30, 80), 471 ) 472 473 def popup_menu_selected_choice( 474 self, popup_window: PopupMenuWindow, choice: str 475 ) -> None: 476 """Called when a choice is selected in the popup.""" 477 del popup_window # unused 478 if self._popup_type == 'partyMemberPress': 479 if self._popup_party_member_is_host: 480 bui.getsound('error').play() 481 bui.screenmessage( 482 bui.Lstr(resource='internal.cantKickHostError'), 483 color=(1, 0, 0), 484 ) 485 else: 486 assert self._popup_party_member_client_id is not None 487 488 # Ban for 5 minutes. 489 result = bs.disconnect_client( 490 self._popup_party_member_client_id, ban_time=5 * 60 491 ) 492 if not result: 493 bui.getsound('error').play() 494 bui.screenmessage( 495 bui.Lstr(resource='getTicketsWindow.unavailableText'), 496 color=(1, 0, 0), 497 ) 498 elif self._popup_type == 'menu': 499 if choice in ('mute', 'unmute'): 500 cfg = bui.app.config 501 cfg['Chat Muted'] = choice == 'mute' 502 cfg.apply_and_commit() 503 self._display_old_msgs = True 504 self._update() 505 if choice == 'add_to_favorites': 506 info = bs.get_connection_to_host_info_2() 507 if info is not None: 508 self._add_to_favorites( 509 name=info.name, 510 address=info.address, 511 port_num=info.port, 512 ) 513 else: 514 # We should not allow the user to see this option 515 # if they aren't in a server; this is our bad. 516 bui.screenmessage( 517 bui.Lstr(resource='errorText'), color=(1, 0, 0) 518 ) 519 bui.getsound('error').play() 520 else: 521 print(f'unhandled popup type: {self._popup_type}') 522 523 def _add_to_favorites( 524 self, name: str, address: str | None, port_num: int | None 525 ) -> None: 526 addr = address 527 if addr == '': 528 bui.screenmessage( 529 bui.Lstr(resource='internal.invalidAddressErrorText'), 530 color=(1, 0, 0), 531 ) 532 bui.getsound('error').play() 533 return 534 port = port_num if port_num is not None else -1 535 if port > 65535 or port < 0: 536 bui.screenmessage( 537 bui.Lstr(resource='internal.invalidPortErrorText'), 538 color=(1, 0, 0), 539 ) 540 bui.getsound('error').play() 541 return 542 543 # Avoid empty names. 544 if not name: 545 name = f'{addr}@{port}' 546 547 config = bui.app.config 548 549 if addr: 550 if not isinstance(config.get('Saved Servers'), dict): 551 config['Saved Servers'] = {} 552 config['Saved Servers'][f'{addr}@{port}'] = { 553 'addr': addr, 554 'port': port, 555 'name': name, 556 } 557 config.commit() 558 bui.getsound('gunCocking').play() 559 bui.screenmessage( 560 bui.Lstr( 561 resource='addedToFavoritesText', subs=[('${NAME}', name)] 562 ), 563 color=(0, 1, 0), 564 ) 565 else: 566 bui.screenmessage( 567 bui.Lstr(resource='internal.invalidAddressErrorText'), 568 color=(1, 0, 0), 569 ) 570 bui.getsound('error').play() 571 572 def popup_menu_closing(self, popup_window: PopupWindow) -> None: 573 """Called when the popup is closing.""" 574 575 def _on_party_member_press( 576 self, client_id: int, is_host: bool, widget: bui.Widget 577 ) -> None: 578 # if we're the host, pop up 'kick' options for all non-host members 579 if bs.get_foreground_host_session() is not None: 580 kick_str = bui.Lstr(resource='kickText') 581 else: 582 # kick-votes appeared in build 14248 583 info = bs.get_connection_to_host_info_2() 584 if info is None or info.build_number < 14248: 585 return 586 kick_str = bui.Lstr(resource='kickVoteText') 587 assert bui.app.classic is not None 588 uiscale = bui.app.ui_v1.uiscale 589 PopupMenuWindow( 590 position=widget.get_screen_space_center(), 591 scale=( 592 2.3 593 if uiscale is bui.UIScale.SMALL 594 else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 595 ), 596 choices=['kick'], 597 choices_display=[kick_str], 598 current_choice='kick', 599 delegate=self, 600 ) 601 self._popup_type = 'partyMemberPress' 602 self._popup_party_member_client_id = client_id 603 self._popup_party_member_is_host = is_host 604 605 def _send_chat_message(self) -> None: 606 text = cast(str, bui.textwidget(query=self._text_field)).strip() 607 if text != '': 608 bs.chatmessage(text) 609 bui.textwidget(edit=self._text_field, text='') 610 611 def close(self) -> None: 612 """Close the window.""" 613 # no-op if our underlying widget is dead or on its way out. 614 if not self._root_widget or self._root_widget.transitioning_out: 615 return 616 617 bui.containerwidget(edit=self._root_widget, transition='out_scale') 618 619 def close_with_sound(self) -> None: 620 """Close the window and make a lovely sound.""" 621 # no-op if our underlying widget is dead or on its way out. 622 if not self._root_widget or self._root_widget.transitioning_out: 623 return 624 625 bui.getsound('swish').play() 626 self.close()
Party list/chat window.
PartyWindow(origin: Sequence[float] = (0, 0))
30 def __init__(self, origin: Sequence[float] = (0, 0)): 31 bui.set_party_window_open(True) 32 self._r = 'partyWindow' 33 self._popup_type: str | None = None 34 self._popup_party_member_client_id: int | None = None 35 self._popup_party_member_is_host: bool | None = None 36 self._width = 500 37 assert bui.app.classic is not None 38 uiscale = bui.app.ui_v1.uiscale 39 self._height = ( 40 365 41 if uiscale is bui.UIScale.SMALL 42 else 480 if uiscale is bui.UIScale.MEDIUM else 600 43 ) 44 self._display_old_msgs = True 45 super().__init__( 46 root_widget=bui.containerwidget( 47 size=(self._width, self._height), 48 transition='in_scale', 49 color=(0.40, 0.55, 0.20), 50 parent=bui.get_special_widget('overlay_stack'), 51 on_outside_click_call=self.close_with_sound, 52 scale_origin_stack_offset=origin, 53 scale=( 54 1.8 55 if uiscale is bui.UIScale.SMALL 56 else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9 57 ), 58 stack_offset=( 59 (200, -10) 60 if uiscale is bui.UIScale.SMALL 61 else ( 62 (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60) 63 ) 64 ), 65 ), 66 # We exist in the overlay stack so main-windows being 67 # recreated doesn't affect us. 68 prevent_main_window_auto_recreate=False, 69 ) 70 71 self._cancel_button = bui.buttonwidget( 72 parent=self._root_widget, 73 scale=0.7, 74 position=(30, self._height - 47), 75 size=(50, 50), 76 label='', 77 on_activate_call=self.close, 78 autoselect=True, 79 color=(0.45, 0.63, 0.15), 80 icon=bui.gettexture('crossOut'), 81 iconscale=1.2, 82 ) 83 bui.containerwidget( 84 edit=self._root_widget, cancel_button=self._cancel_button 85 ) 86 87 self._menu_button = bui.buttonwidget( 88 parent=self._root_widget, 89 scale=0.7, 90 position=(self._width - 60, self._height - 47), 91 size=(50, 50), 92 label='...', 93 autoselect=True, 94 button_type='square', 95 on_activate_call=bui.WeakCall(self._on_menu_button_press), 96 color=(0.55, 0.73, 0.25), 97 iconscale=1.2, 98 ) 99 100 info = bs.get_connection_to_host_info_2() 101 102 if info is not None and info.name != '': 103 title = bui.Lstr(value=info.name) 104 else: 105 title = bui.Lstr(resource=f'{self._r}.titleText') 106 107 self._title_text = bui.textwidget( 108 parent=self._root_widget, 109 scale=0.9, 110 color=(0.5, 0.7, 0.5), 111 text=title, 112 size=(0, 0), 113 position=(self._width * 0.5, self._height - 29), 114 maxwidth=self._width * 0.7, 115 h_align='center', 116 v_align='center', 117 ) 118 119 self._empty_str = bui.textwidget( 120 parent=self._root_widget, 121 scale=0.6, 122 size=(0, 0), 123 # color=(0.5, 1.0, 0.5), 124 shadow=0.3, 125 position=(self._width * 0.5, self._height - 57), 126 maxwidth=self._width * 0.85, 127 h_align='center', 128 v_align='center', 129 ) 130 self._empty_str_2 = bui.textwidget( 131 parent=self._root_widget, 132 scale=0.5, 133 size=(0, 0), 134 color=(0.5, 1.0, 0.5), 135 shadow=0.1, 136 position=(self._width * 0.5, self._height - 75), 137 maxwidth=self._width * 0.85, 138 h_align='center', 139 v_align='center', 140 ) 141 142 self._scroll_width = self._width - 50 143 self._scrollwidget = bui.scrollwidget( 144 parent=self._root_widget, 145 size=(self._scroll_width, self._height - 200), 146 position=(30, 80), 147 color=(0.4, 0.6, 0.3), 148 border_opacity=0.6, 149 ) 150 self._columnwidget = bui.columnwidget( 151 parent=self._scrollwidget, border=2, left_border=-200, margin=0 152 ) 153 bui.widget(edit=self._menu_button, down_widget=self._columnwidget) 154 155 self._muted_text = bui.textwidget( 156 parent=self._root_widget, 157 position=(self._width * 0.5, self._height * 0.5), 158 size=(0, 0), 159 h_align='center', 160 v_align='center', 161 text=bui.Lstr(resource='chatMutedText'), 162 ) 163 self._chat_texts: list[bui.Widget] = [] 164 165 self._text_field = txt = bui.textwidget( 166 parent=self._root_widget, 167 editable=True, 168 size=(530, 40), 169 position=(44, 39), 170 text='', 171 maxwidth=494, 172 shadow=0.3, 173 flatness=1.0, 174 description=bui.Lstr(resource=f'{self._r}.chatMessageText'), 175 autoselect=True, 176 v_align='center', 177 corner_scale=0.7, 178 ) 179 180 bui.widget( 181 edit=self._scrollwidget, 182 autoselect=True, 183 left_widget=self._cancel_button, 184 up_widget=self._cancel_button, 185 down_widget=self._text_field, 186 ) 187 bui.widget( 188 edit=self._columnwidget, 189 autoselect=True, 190 up_widget=self._cancel_button, 191 down_widget=self._text_field, 192 ) 193 bui.containerwidget(edit=self._root_widget, selected_child=txt) 194 195 btn = bui.buttonwidget( 196 parent=self._root_widget, 197 size=(50, 35), 198 label=bui.Lstr(resource=f'{self._r}.sendText'), 199 button_type='square', 200 autoselect=True, 201 position=(self._width - 70, 35), 202 on_activate_call=self._send_chat_message, 203 ) 204 205 bui.textwidget(edit=txt, on_return_press_call=btn.activate) 206 self._name_widgets: list[bui.Widget] = [] 207 self._roster: list[dict[str, Any]] | None = None 208 self._update_timer = bui.AppTimer( 209 1.0, bui.WeakCall(self._update), repeat=True 210 ) 211 self._update()
def
on_chat_message(self, msg: str) -> None:
213 def on_chat_message(self, msg: str) -> None: 214 """Called when a new chat message comes through.""" 215 if not bui.app.config.resolve('Chat Muted'): 216 self._add_msg(msg)
Called when a new chat message comes through.
def
close(self) -> None:
611 def close(self) -> None: 612 """Close the window.""" 613 # no-op if our underlying widget is dead or on its way out. 614 if not self._root_widget or self._root_widget.transitioning_out: 615 return 616 617 bui.containerwidget(edit=self._root_widget, transition='out_scale')
Close the window.
def
close_with_sound(self) -> None:
619 def close_with_sound(self) -> None: 620 """Close the window and make a lovely sound.""" 621 # no-op if our underlying widget is dead or on its way out. 622 if not self._root_widget or self._root_widget.transitioning_out: 623 return 624 625 bui.getsound('swish').play() 626 self.close()
Close the window and make a lovely sound.