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