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