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