bauiv1lib.inbox
Provides a popup window to view achievements.
1# Released under the MIT License. See LICENSE for details. 2# 3# pylint: disable=too-many-lines 4"""Provides a popup window to view achievements.""" 5 6from __future__ import annotations 7 8import weakref 9from functools import partial 10from dataclasses import dataclass 11from typing import override, assert_never, TYPE_CHECKING 12 13from efro.util import strict_partial, pairs_from_flat 14from efro.error import CommunicationError 15import bacommon.bs 16import bauiv1 as bui 17 18if TYPE_CHECKING: 19 import datetime 20 from typing import Callable 21 22 23class _Section: 24 def get_height(self) -> float: 25 """Return section height.""" 26 raise NotImplementedError() 27 28 def get_button_row(self) -> list[bui.Widget]: 29 """Return rows of selectable controls.""" 30 return [] 31 32 def emit(self, subcontainer: bui.Widget, y: float) -> None: 33 """Emit the section.""" 34 35 36class _TextSection(_Section): 37 38 def __init__( 39 self, 40 *, 41 sub_width: float, 42 text: bui.Lstr | str, 43 spacing_top: float = 0.0, 44 spacing_bottom: float = 0.0, 45 scale: float = 0.6, 46 color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), 47 ) -> None: 48 self.sub_width = sub_width 49 self.spacing_top = spacing_top 50 self.spacing_bottom = spacing_bottom 51 self.color = color 52 53 # We need to bake this down since we plug its final size into 54 # our math. 55 self.textbaked = text.evaluate() if isinstance(text, bui.Lstr) else text 56 57 # Calc scale to fit width and then see what height we need at 58 # that scale. 59 t_width = max( 60 10.0, 61 bui.get_string_width(self.textbaked, suppress_warning=True) * scale, 62 ) 63 self.text_scale = scale * min(1.0, (sub_width * 0.9) / t_width) 64 65 self.text_height = ( 66 0.0 67 if not self.textbaked 68 else bui.get_string_height(self.textbaked, suppress_warning=True) 69 ) * self.text_scale 70 71 self.full_height = self.text_height + spacing_top + spacing_bottom 72 73 @override 74 def get_height(self) -> float: 75 return self.full_height 76 77 @override 78 def emit(self, subcontainer: bui.Widget, y: float) -> None: 79 bui.textwidget( 80 parent=subcontainer, 81 position=( 82 self.sub_width * 0.5, 83 y - self.spacing_top - self.text_height * 0.5, 84 ), 85 color=self.color, 86 scale=self.text_scale, 87 flatness=1.0, 88 shadow=1.0, 89 text=self.textbaked, 90 size=(0, 0), 91 h_align='center', 92 v_align='center', 93 ) 94 95 96class _ButtonSection(_Section): 97 98 def __init__( 99 self, 100 *, 101 sub_width: float, 102 label: bui.Lstr | str, 103 color: tuple[float, float, float], 104 label_color: tuple[float, float, float], 105 call: Callable[[_ButtonSection], None], 106 spacing_top: float = 0.0, 107 spacing_bottom: float = 0.0, 108 ) -> None: 109 self.sub_width = sub_width 110 self.spacing_top = spacing_top 111 self.spacing_bottom = spacing_bottom 112 self.color = color 113 self.label_color = label_color 114 self.button: bui.Widget | None = None 115 self.call = call 116 self.labelfin = label 117 self.button_width = 130 118 self.button_height = 30 119 self.full_height = self.button_height + spacing_top + spacing_bottom 120 121 @override 122 def get_height(self) -> float: 123 return self.full_height 124 125 @staticmethod 126 def weak_call(section: weakref.ref[_ButtonSection]) -> None: 127 """Call button section call if section still exists.""" 128 section_strong = section() 129 if section_strong is None: 130 return 131 132 section_strong.call(section_strong) 133 134 @override 135 def emit(self, subcontainer: bui.Widget, y: float) -> None: 136 self.button = bui.buttonwidget( 137 parent=subcontainer, 138 position=( 139 self.sub_width * 0.5 - self.button_width * 0.5, 140 y - self.spacing_top - self.button_height, 141 ), 142 autoselect=True, 143 label=self.labelfin, 144 textcolor=self.label_color, 145 text_scale=0.55, 146 size=(self.button_width, self.button_height), 147 color=self.color, 148 on_activate_call=strict_partial(self.weak_call, weakref.ref(self)), 149 ) 150 bui.widget(edit=self.button, depth_range=(0.1, 1.0)) 151 152 @override 153 def get_button_row(self) -> list[bui.Widget]: 154 """Return rows of selectable controls.""" 155 assert self.button is not None 156 return [self.button] 157 158 159class _DisplayItemsSection(_Section): 160 161 def __init__( 162 self, 163 *, 164 sub_width: float, 165 items: list[bacommon.bs.DisplayItemWrapper], 166 width: float = 100.0, 167 spacing_top: float = 0.0, 168 spacing_bottom: float = 0.0, 169 ) -> None: 170 self.display_item_width = width 171 172 # FIXME - ask for this somewhere in case it changes. 173 self.display_item_height = self.display_item_width * 0.666 174 self.items = items 175 self.sub_width = sub_width 176 self.spacing_top = spacing_top 177 self.spacing_bottom = spacing_bottom 178 self.full_height = ( 179 self.display_item_height + spacing_top + spacing_bottom 180 ) 181 182 @override 183 def get_height(self) -> float: 184 return self.full_height 185 186 @override 187 def emit(self, subcontainer: bui.Widget, y: float) -> None: 188 # pylint: disable=cyclic-import 189 from baclassic import show_display_item 190 191 xspacing = 1.1 * self.display_item_width 192 total_width = ( 193 0 if not self.items else ((len(self.items) - 1) * xspacing) 194 ) 195 x = -0.5 * total_width 196 for item in self.items: 197 show_display_item( 198 item, 199 subcontainer, 200 pos=( 201 self.sub_width * 0.5 + x, 202 y - self.spacing_top - self.display_item_height * 0.5, 203 ), 204 width=self.display_item_width, 205 ) 206 x += xspacing 207 208 209class _ExpireTimeSection(_Section): 210 211 def __init__( 212 self, 213 *, 214 sub_width: float, 215 time: datetime.datetime, 216 spacing_top: float = 0.0, 217 spacing_bottom: float = 0.0, 218 ) -> None: 219 self.time = time 220 self.sub_width = sub_width 221 self.spacing_top = spacing_top 222 self.spacing_bottom = spacing_bottom 223 self.color = (1.0, 0.0, 1.0) 224 self._timer: bui.AppTimer | None = None 225 self._widget: bui.Widget | None = None 226 self.text_scale = 0.4 227 self.text_height = 30.0 * self.text_scale 228 self.full_height = self.text_height + spacing_top + spacing_bottom 229 230 @override 231 def get_height(self) -> float: 232 return self.full_height 233 234 def _update(self) -> None: 235 if not self._widget: 236 return 237 238 now = bui.utc_now_cloud() 239 240 val: bui.Lstr 241 if now < self.time: 242 color = (1.0, 1.0, 1.0, 0.3) 243 val = bui.Lstr( 244 resource='expiresInText', 245 subs=[ 246 ( 247 '${T}', 248 bui.timestring( 249 (self.time - now).total_seconds(), centi=False 250 ), 251 ), 252 ], 253 ) 254 else: 255 color = (1.0, 0.3, 0.3, 0.5) 256 val = bui.Lstr( 257 resource='expiredAgoText', 258 subs=[ 259 ( 260 '${T}', 261 bui.timestring( 262 (now - self.time).total_seconds(), centi=False 263 ), 264 ), 265 ], 266 ) 267 bui.textwidget(edit=self._widget, text=val, color=color) 268 269 @override 270 def emit(self, subcontainer: bui.Widget, y: float) -> None: 271 self._widget = bui.textwidget( 272 parent=subcontainer, 273 position=( 274 self.sub_width * 0.5, 275 y - self.spacing_top - self.text_height * 0.5, 276 ), 277 color=self.color, 278 scale=self.text_scale, 279 flatness=1.0, 280 shadow=1.0, 281 text='', 282 maxwidth=self.sub_width * 0.7, 283 size=(0, 0), 284 h_align='center', 285 v_align='center', 286 ) 287 self._timer = bui.AppTimer(1.0, bui.WeakCall(self._update), repeat=True) 288 self._update() 289 290 291@dataclass 292class _EntryDisplay: 293 interaction_style: bacommon.bs.BasicClientUI.InteractionStyle 294 button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel 295 button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel 296 sections: list[_Section] 297 id: str 298 total_height: float 299 color: tuple[float, float, float] 300 backing: bui.Widget | None = None 301 button_positive: bui.Widget | None = None 302 button_spinner_positive: bui.Widget | None = None 303 button_negative: bui.Widget | None = None 304 button_spinner_negative: bui.Widget | None = None 305 processing_complete: bool = False 306 307 308class InboxWindow(bui.MainWindow): 309 """Popup window to show account messages.""" 310 311 def __init__( 312 self, 313 transition: str | None = 'in_right', 314 origin_widget: bui.Widget | None = None, 315 ): 316 317 assert bui.app.classic is not None 318 uiscale = bui.app.ui_v1.uiscale 319 320 self._entry_displays: list[_EntryDisplay] = [] 321 322 self._width = 900 if uiscale is bui.UIScale.SMALL else 500 323 self._height = ( 324 600 325 if uiscale is bui.UIScale.SMALL 326 else 460 if uiscale is bui.UIScale.MEDIUM else 600 327 ) 328 329 # Do some fancy math to fill all available screen area up to the 330 # size of our backing container. This lets us fit to the exact 331 # screen shape at small ui scale. 332 screensize = bui.get_virtual_screen_size() 333 scale = ( 334 1.9 335 if uiscale is bui.UIScale.SMALL 336 else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 337 ) 338 # Calc screen size in our local container space and clamp to a 339 # bit smaller than our container size. 340 target_width = min(self._width - 60, screensize[0] / scale) 341 target_height = min(self._height - 70, screensize[1] / scale) 342 343 # To get top/left coords, go to the center of our window and offset 344 # by half the width/height of our target area. 345 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 346 347 scroll_width = target_width 348 scroll_height = target_height - 31 349 scroll_bottom = yoffs - 59 - scroll_height 350 351 super().__init__( 352 root_widget=bui.containerwidget( 353 size=(self._width, self._height), 354 toolbar_visibility=( 355 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' 356 ), 357 scale=scale, 358 ), 359 transition=transition, 360 origin_widget=origin_widget, 361 # We're affected by screen size only at small ui-scale. 362 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 363 ) 364 365 if uiscale is bui.UIScale.SMALL: 366 bui.containerwidget( 367 edit=self._root_widget, on_cancel_call=self.main_window_back 368 ) 369 self._back_button = None 370 else: 371 self._back_button = bui.buttonwidget( 372 parent=self._root_widget, 373 autoselect=True, 374 position=(50, yoffs - 48), 375 size=(60, 60), 376 scale=0.6, 377 label=bui.charstr(bui.SpecialChar.BACK), 378 button_type='backSmall', 379 on_activate_call=self.main_window_back, 380 ) 381 bui.containerwidget( 382 edit=self._root_widget, cancel_button=self._back_button 383 ) 384 385 self._title_text = bui.textwidget( 386 parent=self._root_widget, 387 position=( 388 self._width * 0.5, 389 yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), 390 ), 391 size=(0, 0), 392 h_align='center', 393 v_align='center', 394 scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, 395 text=bui.Lstr(resource='inboxText'), 396 maxwidth=200, 397 color=bui.app.ui_v1.title_color, 398 ) 399 400 # Shows 'loading', 'no messages', etc. 401 self._infotext = bui.textwidget( 402 parent=self._root_widget, 403 position=(self._width * 0.5, self._height * 0.5), 404 maxwidth=self._width * 0.7, 405 scale=0.5, 406 flatness=1.0, 407 color=(0.4, 0.4, 0.5), 408 shadow=0.0, 409 text='', 410 size=(0, 0), 411 h_align='center', 412 v_align='center', 413 ) 414 self._loading_spinner = bui.spinnerwidget( 415 parent=self._root_widget, 416 position=(self._width * 0.5, self._height * 0.5), 417 style='bomb', 418 size=48, 419 ) 420 self._scrollwidget = bui.scrollwidget( 421 parent=self._root_widget, 422 size=(scroll_width, scroll_height), 423 position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), 424 capture_arrows=True, 425 simple_culling_v=200, 426 claims_left_right=True, 427 claims_up_down=True, 428 center_small_content_horizontally=True, 429 border_opacity=0.4, 430 ) 431 bui.widget(edit=self._scrollwidget, autoselect=True) 432 if uiscale is bui.UIScale.SMALL: 433 bui.widget( 434 edit=self._scrollwidget, 435 left_widget=bui.get_special_widget('back_button'), 436 ) 437 438 bui.containerwidget( 439 edit=self._root_widget, 440 cancel_button=self._back_button, 441 single_depth=True, 442 ) 443 444 # Kick off request. 445 plus = bui.app.plus 446 if plus is None or plus.accounts.primary is None: 447 self._error(bui.Lstr(resource='notSignedInText')) 448 return 449 450 with plus.accounts.primary: 451 plus.cloud.send_message_cb( 452 bacommon.bs.InboxRequestMessage(), 453 on_response=bui.WeakCall(self._on_inbox_request_response), 454 ) 455 456 @override 457 def get_main_window_state(self) -> bui.MainWindowState: 458 # Support recreating our window for back/refresh purposes. 459 cls = type(self) 460 return bui.BasicMainWindowState( 461 create_call=lambda transition, origin_widget: cls( 462 transition=transition, origin_widget=origin_widget 463 ) 464 ) 465 466 def _error(self, errmsg: bui.Lstr | str) -> None: 467 """Put ourself in a permanent error state.""" 468 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 469 bui.textwidget( 470 edit=self._infotext, 471 color=(1, 0, 0), 472 text=errmsg, 473 ) 474 475 def _on_entry_display_press( 476 self, 477 display_weak: weakref.ReferenceType[_EntryDisplay], 478 action: bacommon.bs.ClientUIAction, 479 ) -> None: 480 display = display_weak() 481 if display is None: 482 return 483 484 bui.getsound('click01').play() 485 486 self._neuter_entry_display(display) 487 488 # We currently only recognize basic entries and their possible 489 # interaction types. 490 if ( 491 display.interaction_style 492 is bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN 493 ): 494 display.processing_complete = True 495 self._close_soon_if_all_processed() 496 return 497 498 # Error if we're somehow signed out now. 499 plus = bui.app.plus 500 if plus is None or plus.accounts.primary is None: 501 bui.screenmessage( 502 bui.Lstr(resource='notSignedInText'), color=(1, 0, 0) 503 ) 504 bui.getsound('error').play() 505 return 506 507 # Ask the master-server to run our action. 508 with plus.accounts.primary: 509 plus.cloud.send_message_cb( 510 bacommon.bs.ClientUIActionMessage(display.id, action), 511 on_response=bui.WeakCall( 512 self._on_client_ui_action_response, 513 display_weak, 514 action, 515 ), 516 ) 517 518 # Tweak the UI to show that things are in motion. 519 button = ( 520 display.button_positive 521 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 522 else display.button_negative 523 ) 524 button_spinner = ( 525 display.button_spinner_positive 526 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 527 else display.button_spinner_negative 528 ) 529 if button is not None: 530 bui.buttonwidget(edit=button, label='') 531 if button_spinner is not None: 532 bui.spinnerwidget(edit=button_spinner, visible=True) 533 534 def _close_soon_if_all_processed(self) -> None: 535 bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed)) 536 537 def _close_if_all_processed(self) -> None: 538 if not all(m.processing_complete for m in self._entry_displays): 539 return 540 541 self.main_window_back() 542 543 def _neuter_entry_display(self, entry: _EntryDisplay) -> None: 544 errsound = bui.getsound('error') 545 if entry.button_positive is not None: 546 bui.buttonwidget( 547 edit=entry.button_positive, 548 color=(0.5, 0.5, 0.5), 549 textcolor=(0.4, 0.4, 0.4), 550 on_activate_call=errsound.play, 551 ) 552 if entry.button_negative is not None: 553 bui.buttonwidget( 554 edit=entry.button_negative, 555 color=(0.5, 0.5, 0.5), 556 textcolor=(0.4, 0.4, 0.4), 557 on_activate_call=errsound.play, 558 ) 559 if entry.backing is not None: 560 bui.imagewidget(edit=entry.backing, color=(0.4, 0.4, 0.4)) 561 562 def _on_client_ui_action_response( 563 self, 564 display_weak: weakref.ReferenceType[_EntryDisplay], 565 action: bacommon.bs.ClientUIAction, 566 response: bacommon.bs.ClientUIActionResponse | Exception, 567 ) -> None: 568 # pylint: disable=too-many-branches 569 display = display_weak() 570 if display is None: 571 return 572 573 assert not display.processing_complete 574 display.processing_complete = True 575 self._close_soon_if_all_processed() 576 577 # No-op if our UI is dead or on its way out. 578 if not self._root_widget or self._root_widget.transitioning_out: 579 return 580 581 # Tweak the button to show results. 582 button = ( 583 display.button_positive 584 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 585 else display.button_negative 586 ) 587 button_spinner = ( 588 display.button_spinner_positive 589 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 590 else display.button_spinner_negative 591 ) 592 # Always hide spinner at this point. 593 if button_spinner is not None: 594 bui.spinnerwidget(edit=button_spinner, visible=False) 595 596 # See if we should show an error message. 597 if isinstance(response, Exception): 598 if isinstance(response, CommunicationError): 599 error_message = bui.Lstr( 600 resource='internal.unavailableNoConnectionText' 601 ) 602 else: 603 error_message = bui.Lstr(resource='errorText') 604 elif response.error_type is not None: 605 # If error_type is set, error should be also. 606 assert response.error_message is not None 607 error_message = bui.Lstr( 608 translate=('serverResponses', response.error_message) 609 ) 610 else: 611 error_message = None 612 613 # Show error message if so. 614 if error_message is not None: 615 bui.screenmessage(error_message, color=(1, 0, 0)) 616 bui.getsound('error').play() 617 if button is not None: 618 bui.buttonwidget( 619 edit=button, label=bui.Lstr(resource='errorText') 620 ) 621 return 622 623 # Success! 624 assert not isinstance(response, Exception) 625 626 # Run any bundled effects. 627 assert bui.app.classic is not None 628 bui.app.classic.run_bs_client_effects(response.effects) 629 630 # Whee; no error. Mark as done. 631 if button is not None: 632 # If we have full unicode, just show a checkmark in all cases. 633 label: str | bui.Lstr 634 if bui.supports_unicode_display(): 635 label = '✓' 636 else: 637 label = bui.Lstr(resource='doneText') 638 bui.buttonwidget(edit=button, label=label) 639 640 def _on_inbox_request_response( 641 self, response: bacommon.bs.InboxRequestResponse | Exception 642 ) -> None: 643 # pylint: disable=too-many-locals 644 # pylint: disable=too-many-statements 645 # pylint: disable=too-many-branches 646 647 # No-op if our UI is dead or on its way out. 648 if not self._root_widget or self._root_widget.transitioning_out: 649 return 650 651 errmsg: str | bui.Lstr 652 if isinstance(response, Exception): 653 errmsg = bui.Lstr(resource='internal.unavailableNoConnectionText') 654 is_error = True 655 else: 656 is_error = response.error is not None 657 errmsg = ( 658 '' 659 if response.error is None 660 else bui.Lstr(translate=('serverResponses', response.error)) 661 ) 662 663 if is_error: 664 self._error(errmsg) 665 return 666 667 assert isinstance(response, bacommon.bs.InboxRequestResponse) 668 669 # If we got no messages, don't touch anything. This keeps 670 # keyboard control working in the empty case. 671 if not response.wrappers: 672 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 673 bui.textwidget( 674 edit=self._infotext, 675 color=(0.4, 0.4, 0.5), 676 text=bui.Lstr(resource='noMessagesText'), 677 ) 678 return 679 680 bui.scrollwidget(edit=self._scrollwidget, highlight=False) 681 682 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 683 bui.textwidget(edit=self._infotext, text='') 684 685 uiscale = bui.app.ui_v1.uiscale 686 687 margin_top = 0.0 if uiscale is bui.UIScale.SMALL else 10.0 688 margin_v = 0.0 if uiscale is bui.UIScale.SMALL else 5.0 689 690 # Need this to avoid the dock blocking access to buttons on our 691 # bottom message. 692 margin_bottom = 60.0 if uiscale is bui.UIScale.SMALL else 10.0 693 694 # Even though our window size varies with uiscale, we want 695 # notifications to target a fixed width. 696 sub_width = 400.0 697 sub_height = margin_top 698 699 # Construct entries for everything we'll display. 700 for i, wrapper in enumerate(response.wrappers): 701 702 # We need to flatten text here so we can measure it. 703 # textfin: str 704 color: tuple[float, float, float] 705 706 interaction_style: bacommon.bs.BasicClientUI.InteractionStyle 707 button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel 708 button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel 709 710 sections: list[_Section] = [] 711 total_height = 80.0 712 713 # Display only entries where we recognize all style/label 714 # values and ui component types. 715 if ( 716 isinstance(wrapper.ui, bacommon.bs.BasicClientUI) 717 and not wrapper.ui.contains_unknown_elements() 718 ): 719 color = (0.55, 0.5, 0.7) 720 interaction_style = wrapper.ui.interaction_style 721 button_label_positive = wrapper.ui.button_label_positive 722 button_label_negative = wrapper.ui.button_label_negative 723 724 idcls = bacommon.bs.BasicClientUIComponentTypeID 725 for component in wrapper.ui.components: 726 ctypeid = component.get_type_id() 727 section: _Section 728 729 if ctypeid is idcls.TEXT: 730 assert isinstance( 731 component, bacommon.bs.BasicClientUIComponentText 732 ) 733 section = _TextSection( 734 sub_width=sub_width, 735 text=bui.Lstr( 736 translate=('serverResponses', component.text), 737 subs=pairs_from_flat(component.subs), 738 ), 739 color=component.color, 740 scale=component.scale, 741 spacing_top=component.spacing_top, 742 spacing_bottom=component.spacing_bottom, 743 ) 744 total_height += section.get_height() 745 sections.append(section) 746 747 elif ctypeid is idcls.LINK: 748 assert isinstance( 749 component, bacommon.bs.BasicClientUIComponentLink 750 ) 751 752 def _do_open_url(url: str, sec: _ButtonSection) -> None: 753 del sec # Unused. 754 bui.open_url(url) 755 756 section = _ButtonSection( 757 sub_width=sub_width, 758 label=bui.Lstr( 759 translate=('serverResponses', component.label), 760 subs=pairs_from_flat(component.subs), 761 ), 762 color=color, 763 call=partial(_do_open_url, component.url), 764 label_color=(0.5, 0.7, 0.6), 765 spacing_top=component.spacing_top, 766 spacing_bottom=component.spacing_bottom, 767 ) 768 total_height += section.get_height() 769 sections.append(section) 770 771 elif ctypeid is idcls.DISPLAY_ITEMS: 772 assert isinstance( 773 component, 774 bacommon.bs.BasicClientUIDisplayItems, 775 ) 776 section = _DisplayItemsSection( 777 sub_width=sub_width, 778 items=component.items, 779 width=component.width, 780 spacing_top=component.spacing_top, 781 spacing_bottom=component.spacing_bottom, 782 ) 783 total_height += section.get_height() 784 sections.append(section) 785 786 elif ctypeid is idcls.BS_CLASSIC_TOURNEY_RESULT: 787 from bascenev1 import get_trophy_string 788 789 assert isinstance( 790 component, 791 bacommon.bs.BasicClientUIBsClassicTourneyResult, 792 ) 793 campaignname, levelname = component.game.split(':') 794 assert bui.app.classic is not None 795 campaign = bui.app.classic.getcampaign(campaignname) 796 797 tourney_name = bui.Lstr( 798 value='${A} ${B}', 799 subs=[ 800 ( 801 '${A}', 802 campaign.getlevel(levelname).displayname, 803 ), 804 ( 805 '${B}', 806 bui.Lstr( 807 resource='playerCountAbbreviatedText', 808 subs=[ 809 ('${COUNT}', str(component.players)) 810 ], 811 ), 812 ), 813 ], 814 ) 815 816 if component.trophy is not None: 817 trophy_prefix = ( 818 get_trophy_string(component.trophy) + ' ' 819 ) 820 else: 821 trophy_prefix = '' 822 823 section = _TextSection( 824 sub_width=sub_width, 825 text=bui.Lstr( 826 value='${P}${V}', 827 subs=[ 828 ('${P}', trophy_prefix), 829 ( 830 '${V}', 831 bui.Lstr( 832 translate=( 833 'serverResponses', 834 'You placed #${RANK}' 835 ' in a tournament!', 836 ), 837 subs=[ 838 ('${RANK}', str(component.rank)) 839 ], 840 ), 841 ), 842 ], 843 ), 844 color=(1.0, 1.0, 1.0, 1.0), 845 scale=0.6, 846 ) 847 total_height += section.get_height() 848 sections.append(section) 849 850 section = _TextSection( 851 sub_width=sub_width, 852 text=tourney_name, 853 spacing_top=5, 854 color=(0.7, 0.7, 1.0, 1.0), 855 scale=0.7, 856 ) 857 total_height += section.get_height() 858 sections.append(section) 859 860 def _do_tourney_scores( 861 tournament_id: str, sec: _ButtonSection 862 ) -> None: 863 from bauiv1lib.tournamentscores import ( 864 TournamentScoresWindow, 865 ) 866 867 assert sec.button is not None 868 _ = ( 869 TournamentScoresWindow( 870 tournament_id=tournament_id, 871 position=( 872 sec.button 873 ).get_screen_space_center(), 874 ), 875 ) 876 877 section = _ButtonSection( 878 sub_width=sub_width, 879 label=bui.Lstr( 880 resource='tournamentFinalStandingsText' 881 ), 882 color=color, 883 call=partial( 884 _do_tourney_scores, component.tournament_id 885 ), 886 label_color=(0.5, 0.7, 0.6), 887 spacing_top=7.0, 888 spacing_bottom=0.0 if component.prizes else 7.0, 889 ) 890 total_height += section.get_height() 891 sections.append(section) 892 893 if component.prizes: 894 section = _TextSection( 895 sub_width=sub_width, 896 text=bui.Lstr(resource='yourPrizeText'), 897 spacing_top=6, 898 color=(1.0, 1.0, 1.0, 0.4), 899 scale=0.35, 900 ) 901 total_height += section.get_height() 902 sections.append(section) 903 904 section = _DisplayItemsSection( 905 sub_width=sub_width, 906 items=component.prizes, 907 width=70.0, 908 spacing_top=0.0, 909 spacing_bottom=0.0, 910 ) 911 total_height += section.get_height() 912 sections.append(section) 913 914 elif ctypeid is idcls.EXPIRE_TIME: 915 assert isinstance( 916 component, bacommon.bs.BasicClientUIExpireTime 917 ) 918 section = _ExpireTimeSection( 919 sub_width=sub_width, 920 time=component.time, 921 spacing_top=component.spacing_top, 922 spacing_bottom=component.spacing_bottom, 923 ) 924 total_height += section.get_height() 925 sections.append(section) 926 927 elif ctypeid is idcls.UNKNOWN: 928 raise RuntimeError('Should not get here.') 929 930 else: 931 # Make sure we handle all types. 932 assert_never(ctypeid) 933 else: 934 935 # Display anything with unknown components as an 936 # 'upgrade your app to see this' message. 937 color = (0.6, 0.6, 0.6) 938 interaction_style = ( 939 bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN 940 ) 941 button_label_positive = bacommon.bs.BasicClientUI.ButtonLabel.OK 942 button_label_negative = ( 943 bacommon.bs.BasicClientUI.ButtonLabel.CANCEL 944 ) 945 946 section = _TextSection( 947 sub_width=sub_width, 948 text=bui.Lstr( 949 value='You must update the app to view this.' 950 ), 951 ) 952 total_height += section.get_height() 953 sections.append(section) 954 955 self._entry_displays.append( 956 _EntryDisplay( 957 interaction_style=interaction_style, 958 button_label_positive=button_label_positive, 959 button_label_negative=button_label_negative, 960 id=wrapper.id, 961 sections=sections, 962 total_height=total_height, 963 color=color, 964 ) 965 ) 966 sub_height += margin_v + total_height 967 968 sub_height += margin_bottom 969 970 subcontainer = bui.containerwidget( 971 id='inboxsub', 972 parent=self._scrollwidget, 973 size=(sub_width, sub_height), 974 background=False, 975 single_depth=True, 976 claims_left_right=True, 977 claims_up_down=True, 978 ) 979 980 backing_tex = bui.gettexture('buttonSquareWide') 981 982 assert bui.app.classic is not None 983 984 buttonrows: list[list[bui.Widget]] = [] 985 y = sub_height - margin_top 986 for i, _wrapper in enumerate(response.wrappers): 987 entry_display = self._entry_displays[i] 988 entry_display_weak = weakref.ref(entry_display) 989 bwidth = 140 990 bheight = 40 991 992 ysection = y - 23.0 993 994 # Backing. 995 entry_display.backing = img = bui.imagewidget( 996 parent=subcontainer, 997 position=( 998 -0.022 * sub_width, 999 y - entry_display.total_height * 1.09, 1000 ), 1001 texture=backing_tex, 1002 size=(sub_width * 1.07, entry_display.total_height * 1.15), 1003 color=entry_display.color, 1004 opacity=0.9, 1005 ) 1006 bui.widget(edit=img, depth_range=(0, 0.1)) 1007 1008 # Section contents. 1009 for sec in entry_display.sections: 1010 sec.emit(subcontainer, ysection) 1011 # Wire up any widgets created by this section. 1012 sec_button_row = sec.get_button_row() 1013 if sec_button_row: 1014 buttonrows.append(sec_button_row) 1015 ysection -= sec.get_height() 1016 1017 buttonrow: list[bui.Widget] = [] 1018 have_negative_button = ( 1019 entry_display.interaction_style 1020 is ( 1021 bacommon.bs.BasicClientUI 1022 ).InteractionStyle.BUTTON_POSITIVE_NEGATIVE 1023 ) 1024 1025 bpos = ( 1026 ( 1027 (sub_width - bwidth - 25) 1028 if have_negative_button 1029 else ((sub_width - bwidth) * 0.5) 1030 ), 1031 y - entry_display.total_height + 15.0, 1032 ) 1033 entry_display.button_positive = btn = bui.buttonwidget( 1034 parent=subcontainer, 1035 position=bpos, 1036 autoselect=True, 1037 size=(bwidth, bheight), 1038 label=bui.app.classic.basic_client_ui_button_label_str( 1039 entry_display.button_label_positive 1040 ), 1041 color=entry_display.color, 1042 textcolor=(0, 1, 0), 1043 on_activate_call=bui.WeakCall( 1044 self._on_entry_display_press, 1045 entry_display_weak, 1046 bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE, 1047 ), 1048 enable_sound=False, 1049 ) 1050 bui.widget(edit=btn, depth_range=(0.1, 1.0)) 1051 buttonrow.append(btn) 1052 spinner = entry_display.button_spinner_positive = bui.spinnerwidget( 1053 parent=subcontainer, 1054 position=( 1055 bpos[0] + 0.5 * bwidth, 1056 bpos[1] + 0.5 * bheight, 1057 ), 1058 visible=False, 1059 ) 1060 bui.widget(edit=spinner, depth_range=(0.1, 1.0)) 1061 1062 if have_negative_button: 1063 bpos = (25, y - entry_display.total_height + 15.0) 1064 entry_display.button_negative = btn2 = bui.buttonwidget( 1065 parent=subcontainer, 1066 position=bpos, 1067 autoselect=True, 1068 size=(bwidth, bheight), 1069 label=bui.app.classic.basic_client_ui_button_label_str( 1070 entry_display.button_label_negative 1071 ), 1072 color=(0.85, 0.5, 0.7), 1073 textcolor=(1, 0.4, 0.4), 1074 on_activate_call=bui.WeakCall( 1075 self._on_entry_display_press, 1076 entry_display_weak, 1077 (bacommon.bs.ClientUIAction).BUTTON_PRESS_NEGATIVE, 1078 ), 1079 enable_sound=False, 1080 ) 1081 bui.widget(edit=btn2, depth_range=(0.1, 1.0)) 1082 buttonrow.append(btn2) 1083 spinner = entry_display.button_spinner_negative = ( 1084 bui.spinnerwidget( 1085 parent=subcontainer, 1086 position=( 1087 bpos[0] + 0.5 * bwidth, 1088 bpos[1] + 0.5 * bheight, 1089 ), 1090 visible=False, 1091 ) 1092 ) 1093 bui.widget(edit=spinner, depth_range=(0.1, 1.0)) 1094 1095 buttonrows.append(buttonrow) 1096 1097 y -= margin_v + entry_display.total_height 1098 1099 uiscale = bui.app.ui_v1.uiscale 1100 above_widget = ( 1101 bui.get_special_widget('back_button') 1102 if uiscale is bui.UIScale.SMALL 1103 else self._back_button 1104 ) 1105 assert above_widget is not None 1106 for i, buttons in enumerate(buttonrows): 1107 if i < len(buttonrows) - 1: 1108 below_widget = buttonrows[i + 1][0] 1109 else: 1110 below_widget = None 1111 1112 assert buttons # We should never have an empty row. 1113 for j, button in enumerate(buttons): 1114 bui.widget( 1115 edit=button, 1116 up_widget=above_widget, 1117 down_widget=below_widget, 1118 # down_widget=( 1119 # button if below_widget is None else below_widget 1120 # ), 1121 right_widget=buttons[max(j - 1, 0)], 1122 left_widget=buttons[min(j + 1, len(buttons) - 1)], 1123 ) 1124 1125 above_widget = buttons[0] 1126 1127 1128def _get_bs_classic_tourney_results_sections() -> list[_Section]: 1129 return []
class
InboxWindow(bauiv1._uitypes.MainWindow):
309class InboxWindow(bui.MainWindow): 310 """Popup window to show account messages.""" 311 312 def __init__( 313 self, 314 transition: str | None = 'in_right', 315 origin_widget: bui.Widget | None = None, 316 ): 317 318 assert bui.app.classic is not None 319 uiscale = bui.app.ui_v1.uiscale 320 321 self._entry_displays: list[_EntryDisplay] = [] 322 323 self._width = 900 if uiscale is bui.UIScale.SMALL else 500 324 self._height = ( 325 600 326 if uiscale is bui.UIScale.SMALL 327 else 460 if uiscale is bui.UIScale.MEDIUM else 600 328 ) 329 330 # Do some fancy math to fill all available screen area up to the 331 # size of our backing container. This lets us fit to the exact 332 # screen shape at small ui scale. 333 screensize = bui.get_virtual_screen_size() 334 scale = ( 335 1.9 336 if uiscale is bui.UIScale.SMALL 337 else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 338 ) 339 # Calc screen size in our local container space and clamp to a 340 # bit smaller than our container size. 341 target_width = min(self._width - 60, screensize[0] / scale) 342 target_height = min(self._height - 70, screensize[1] / scale) 343 344 # To get top/left coords, go to the center of our window and offset 345 # by half the width/height of our target area. 346 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 347 348 scroll_width = target_width 349 scroll_height = target_height - 31 350 scroll_bottom = yoffs - 59 - scroll_height 351 352 super().__init__( 353 root_widget=bui.containerwidget( 354 size=(self._width, self._height), 355 toolbar_visibility=( 356 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' 357 ), 358 scale=scale, 359 ), 360 transition=transition, 361 origin_widget=origin_widget, 362 # We're affected by screen size only at small ui-scale. 363 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 364 ) 365 366 if uiscale is bui.UIScale.SMALL: 367 bui.containerwidget( 368 edit=self._root_widget, on_cancel_call=self.main_window_back 369 ) 370 self._back_button = None 371 else: 372 self._back_button = bui.buttonwidget( 373 parent=self._root_widget, 374 autoselect=True, 375 position=(50, yoffs - 48), 376 size=(60, 60), 377 scale=0.6, 378 label=bui.charstr(bui.SpecialChar.BACK), 379 button_type='backSmall', 380 on_activate_call=self.main_window_back, 381 ) 382 bui.containerwidget( 383 edit=self._root_widget, cancel_button=self._back_button 384 ) 385 386 self._title_text = bui.textwidget( 387 parent=self._root_widget, 388 position=( 389 self._width * 0.5, 390 yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), 391 ), 392 size=(0, 0), 393 h_align='center', 394 v_align='center', 395 scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, 396 text=bui.Lstr(resource='inboxText'), 397 maxwidth=200, 398 color=bui.app.ui_v1.title_color, 399 ) 400 401 # Shows 'loading', 'no messages', etc. 402 self._infotext = bui.textwidget( 403 parent=self._root_widget, 404 position=(self._width * 0.5, self._height * 0.5), 405 maxwidth=self._width * 0.7, 406 scale=0.5, 407 flatness=1.0, 408 color=(0.4, 0.4, 0.5), 409 shadow=0.0, 410 text='', 411 size=(0, 0), 412 h_align='center', 413 v_align='center', 414 ) 415 self._loading_spinner = bui.spinnerwidget( 416 parent=self._root_widget, 417 position=(self._width * 0.5, self._height * 0.5), 418 style='bomb', 419 size=48, 420 ) 421 self._scrollwidget = bui.scrollwidget( 422 parent=self._root_widget, 423 size=(scroll_width, scroll_height), 424 position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), 425 capture_arrows=True, 426 simple_culling_v=200, 427 claims_left_right=True, 428 claims_up_down=True, 429 center_small_content_horizontally=True, 430 border_opacity=0.4, 431 ) 432 bui.widget(edit=self._scrollwidget, autoselect=True) 433 if uiscale is bui.UIScale.SMALL: 434 bui.widget( 435 edit=self._scrollwidget, 436 left_widget=bui.get_special_widget('back_button'), 437 ) 438 439 bui.containerwidget( 440 edit=self._root_widget, 441 cancel_button=self._back_button, 442 single_depth=True, 443 ) 444 445 # Kick off request. 446 plus = bui.app.plus 447 if plus is None or plus.accounts.primary is None: 448 self._error(bui.Lstr(resource='notSignedInText')) 449 return 450 451 with plus.accounts.primary: 452 plus.cloud.send_message_cb( 453 bacommon.bs.InboxRequestMessage(), 454 on_response=bui.WeakCall(self._on_inbox_request_response), 455 ) 456 457 @override 458 def get_main_window_state(self) -> bui.MainWindowState: 459 # Support recreating our window for back/refresh purposes. 460 cls = type(self) 461 return bui.BasicMainWindowState( 462 create_call=lambda transition, origin_widget: cls( 463 transition=transition, origin_widget=origin_widget 464 ) 465 ) 466 467 def _error(self, errmsg: bui.Lstr | str) -> None: 468 """Put ourself in a permanent error state.""" 469 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 470 bui.textwidget( 471 edit=self._infotext, 472 color=(1, 0, 0), 473 text=errmsg, 474 ) 475 476 def _on_entry_display_press( 477 self, 478 display_weak: weakref.ReferenceType[_EntryDisplay], 479 action: bacommon.bs.ClientUIAction, 480 ) -> None: 481 display = display_weak() 482 if display is None: 483 return 484 485 bui.getsound('click01').play() 486 487 self._neuter_entry_display(display) 488 489 # We currently only recognize basic entries and their possible 490 # interaction types. 491 if ( 492 display.interaction_style 493 is bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN 494 ): 495 display.processing_complete = True 496 self._close_soon_if_all_processed() 497 return 498 499 # Error if we're somehow signed out now. 500 plus = bui.app.plus 501 if plus is None or plus.accounts.primary is None: 502 bui.screenmessage( 503 bui.Lstr(resource='notSignedInText'), color=(1, 0, 0) 504 ) 505 bui.getsound('error').play() 506 return 507 508 # Ask the master-server to run our action. 509 with plus.accounts.primary: 510 plus.cloud.send_message_cb( 511 bacommon.bs.ClientUIActionMessage(display.id, action), 512 on_response=bui.WeakCall( 513 self._on_client_ui_action_response, 514 display_weak, 515 action, 516 ), 517 ) 518 519 # Tweak the UI to show that things are in motion. 520 button = ( 521 display.button_positive 522 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 523 else display.button_negative 524 ) 525 button_spinner = ( 526 display.button_spinner_positive 527 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 528 else display.button_spinner_negative 529 ) 530 if button is not None: 531 bui.buttonwidget(edit=button, label='') 532 if button_spinner is not None: 533 bui.spinnerwidget(edit=button_spinner, visible=True) 534 535 def _close_soon_if_all_processed(self) -> None: 536 bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed)) 537 538 def _close_if_all_processed(self) -> None: 539 if not all(m.processing_complete for m in self._entry_displays): 540 return 541 542 self.main_window_back() 543 544 def _neuter_entry_display(self, entry: _EntryDisplay) -> None: 545 errsound = bui.getsound('error') 546 if entry.button_positive is not None: 547 bui.buttonwidget( 548 edit=entry.button_positive, 549 color=(0.5, 0.5, 0.5), 550 textcolor=(0.4, 0.4, 0.4), 551 on_activate_call=errsound.play, 552 ) 553 if entry.button_negative is not None: 554 bui.buttonwidget( 555 edit=entry.button_negative, 556 color=(0.5, 0.5, 0.5), 557 textcolor=(0.4, 0.4, 0.4), 558 on_activate_call=errsound.play, 559 ) 560 if entry.backing is not None: 561 bui.imagewidget(edit=entry.backing, color=(0.4, 0.4, 0.4)) 562 563 def _on_client_ui_action_response( 564 self, 565 display_weak: weakref.ReferenceType[_EntryDisplay], 566 action: bacommon.bs.ClientUIAction, 567 response: bacommon.bs.ClientUIActionResponse | Exception, 568 ) -> None: 569 # pylint: disable=too-many-branches 570 display = display_weak() 571 if display is None: 572 return 573 574 assert not display.processing_complete 575 display.processing_complete = True 576 self._close_soon_if_all_processed() 577 578 # No-op if our UI is dead or on its way out. 579 if not self._root_widget or self._root_widget.transitioning_out: 580 return 581 582 # Tweak the button to show results. 583 button = ( 584 display.button_positive 585 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 586 else display.button_negative 587 ) 588 button_spinner = ( 589 display.button_spinner_positive 590 if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE 591 else display.button_spinner_negative 592 ) 593 # Always hide spinner at this point. 594 if button_spinner is not None: 595 bui.spinnerwidget(edit=button_spinner, visible=False) 596 597 # See if we should show an error message. 598 if isinstance(response, Exception): 599 if isinstance(response, CommunicationError): 600 error_message = bui.Lstr( 601 resource='internal.unavailableNoConnectionText' 602 ) 603 else: 604 error_message = bui.Lstr(resource='errorText') 605 elif response.error_type is not None: 606 # If error_type is set, error should be also. 607 assert response.error_message is not None 608 error_message = bui.Lstr( 609 translate=('serverResponses', response.error_message) 610 ) 611 else: 612 error_message = None 613 614 # Show error message if so. 615 if error_message is not None: 616 bui.screenmessage(error_message, color=(1, 0, 0)) 617 bui.getsound('error').play() 618 if button is not None: 619 bui.buttonwidget( 620 edit=button, label=bui.Lstr(resource='errorText') 621 ) 622 return 623 624 # Success! 625 assert not isinstance(response, Exception) 626 627 # Run any bundled effects. 628 assert bui.app.classic is not None 629 bui.app.classic.run_bs_client_effects(response.effects) 630 631 # Whee; no error. Mark as done. 632 if button is not None: 633 # If we have full unicode, just show a checkmark in all cases. 634 label: str | bui.Lstr 635 if bui.supports_unicode_display(): 636 label = '✓' 637 else: 638 label = bui.Lstr(resource='doneText') 639 bui.buttonwidget(edit=button, label=label) 640 641 def _on_inbox_request_response( 642 self, response: bacommon.bs.InboxRequestResponse | Exception 643 ) -> None: 644 # pylint: disable=too-many-locals 645 # pylint: disable=too-many-statements 646 # pylint: disable=too-many-branches 647 648 # No-op if our UI is dead or on its way out. 649 if not self._root_widget or self._root_widget.transitioning_out: 650 return 651 652 errmsg: str | bui.Lstr 653 if isinstance(response, Exception): 654 errmsg = bui.Lstr(resource='internal.unavailableNoConnectionText') 655 is_error = True 656 else: 657 is_error = response.error is not None 658 errmsg = ( 659 '' 660 if response.error is None 661 else bui.Lstr(translate=('serverResponses', response.error)) 662 ) 663 664 if is_error: 665 self._error(errmsg) 666 return 667 668 assert isinstance(response, bacommon.bs.InboxRequestResponse) 669 670 # If we got no messages, don't touch anything. This keeps 671 # keyboard control working in the empty case. 672 if not response.wrappers: 673 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 674 bui.textwidget( 675 edit=self._infotext, 676 color=(0.4, 0.4, 0.5), 677 text=bui.Lstr(resource='noMessagesText'), 678 ) 679 return 680 681 bui.scrollwidget(edit=self._scrollwidget, highlight=False) 682 683 bui.spinnerwidget(edit=self._loading_spinner, visible=False) 684 bui.textwidget(edit=self._infotext, text='') 685 686 uiscale = bui.app.ui_v1.uiscale 687 688 margin_top = 0.0 if uiscale is bui.UIScale.SMALL else 10.0 689 margin_v = 0.0 if uiscale is bui.UIScale.SMALL else 5.0 690 691 # Need this to avoid the dock blocking access to buttons on our 692 # bottom message. 693 margin_bottom = 60.0 if uiscale is bui.UIScale.SMALL else 10.0 694 695 # Even though our window size varies with uiscale, we want 696 # notifications to target a fixed width. 697 sub_width = 400.0 698 sub_height = margin_top 699 700 # Construct entries for everything we'll display. 701 for i, wrapper in enumerate(response.wrappers): 702 703 # We need to flatten text here so we can measure it. 704 # textfin: str 705 color: tuple[float, float, float] 706 707 interaction_style: bacommon.bs.BasicClientUI.InteractionStyle 708 button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel 709 button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel 710 711 sections: list[_Section] = [] 712 total_height = 80.0 713 714 # Display only entries where we recognize all style/label 715 # values and ui component types. 716 if ( 717 isinstance(wrapper.ui, bacommon.bs.BasicClientUI) 718 and not wrapper.ui.contains_unknown_elements() 719 ): 720 color = (0.55, 0.5, 0.7) 721 interaction_style = wrapper.ui.interaction_style 722 button_label_positive = wrapper.ui.button_label_positive 723 button_label_negative = wrapper.ui.button_label_negative 724 725 idcls = bacommon.bs.BasicClientUIComponentTypeID 726 for component in wrapper.ui.components: 727 ctypeid = component.get_type_id() 728 section: _Section 729 730 if ctypeid is idcls.TEXT: 731 assert isinstance( 732 component, bacommon.bs.BasicClientUIComponentText 733 ) 734 section = _TextSection( 735 sub_width=sub_width, 736 text=bui.Lstr( 737 translate=('serverResponses', component.text), 738 subs=pairs_from_flat(component.subs), 739 ), 740 color=component.color, 741 scale=component.scale, 742 spacing_top=component.spacing_top, 743 spacing_bottom=component.spacing_bottom, 744 ) 745 total_height += section.get_height() 746 sections.append(section) 747 748 elif ctypeid is idcls.LINK: 749 assert isinstance( 750 component, bacommon.bs.BasicClientUIComponentLink 751 ) 752 753 def _do_open_url(url: str, sec: _ButtonSection) -> None: 754 del sec # Unused. 755 bui.open_url(url) 756 757 section = _ButtonSection( 758 sub_width=sub_width, 759 label=bui.Lstr( 760 translate=('serverResponses', component.label), 761 subs=pairs_from_flat(component.subs), 762 ), 763 color=color, 764 call=partial(_do_open_url, component.url), 765 label_color=(0.5, 0.7, 0.6), 766 spacing_top=component.spacing_top, 767 spacing_bottom=component.spacing_bottom, 768 ) 769 total_height += section.get_height() 770 sections.append(section) 771 772 elif ctypeid is idcls.DISPLAY_ITEMS: 773 assert isinstance( 774 component, 775 bacommon.bs.BasicClientUIDisplayItems, 776 ) 777 section = _DisplayItemsSection( 778 sub_width=sub_width, 779 items=component.items, 780 width=component.width, 781 spacing_top=component.spacing_top, 782 spacing_bottom=component.spacing_bottom, 783 ) 784 total_height += section.get_height() 785 sections.append(section) 786 787 elif ctypeid is idcls.BS_CLASSIC_TOURNEY_RESULT: 788 from bascenev1 import get_trophy_string 789 790 assert isinstance( 791 component, 792 bacommon.bs.BasicClientUIBsClassicTourneyResult, 793 ) 794 campaignname, levelname = component.game.split(':') 795 assert bui.app.classic is not None 796 campaign = bui.app.classic.getcampaign(campaignname) 797 798 tourney_name = bui.Lstr( 799 value='${A} ${B}', 800 subs=[ 801 ( 802 '${A}', 803 campaign.getlevel(levelname).displayname, 804 ), 805 ( 806 '${B}', 807 bui.Lstr( 808 resource='playerCountAbbreviatedText', 809 subs=[ 810 ('${COUNT}', str(component.players)) 811 ], 812 ), 813 ), 814 ], 815 ) 816 817 if component.trophy is not None: 818 trophy_prefix = ( 819 get_trophy_string(component.trophy) + ' ' 820 ) 821 else: 822 trophy_prefix = '' 823 824 section = _TextSection( 825 sub_width=sub_width, 826 text=bui.Lstr( 827 value='${P}${V}', 828 subs=[ 829 ('${P}', trophy_prefix), 830 ( 831 '${V}', 832 bui.Lstr( 833 translate=( 834 'serverResponses', 835 'You placed #${RANK}' 836 ' in a tournament!', 837 ), 838 subs=[ 839 ('${RANK}', str(component.rank)) 840 ], 841 ), 842 ), 843 ], 844 ), 845 color=(1.0, 1.0, 1.0, 1.0), 846 scale=0.6, 847 ) 848 total_height += section.get_height() 849 sections.append(section) 850 851 section = _TextSection( 852 sub_width=sub_width, 853 text=tourney_name, 854 spacing_top=5, 855 color=(0.7, 0.7, 1.0, 1.0), 856 scale=0.7, 857 ) 858 total_height += section.get_height() 859 sections.append(section) 860 861 def _do_tourney_scores( 862 tournament_id: str, sec: _ButtonSection 863 ) -> None: 864 from bauiv1lib.tournamentscores import ( 865 TournamentScoresWindow, 866 ) 867 868 assert sec.button is not None 869 _ = ( 870 TournamentScoresWindow( 871 tournament_id=tournament_id, 872 position=( 873 sec.button 874 ).get_screen_space_center(), 875 ), 876 ) 877 878 section = _ButtonSection( 879 sub_width=sub_width, 880 label=bui.Lstr( 881 resource='tournamentFinalStandingsText' 882 ), 883 color=color, 884 call=partial( 885 _do_tourney_scores, component.tournament_id 886 ), 887 label_color=(0.5, 0.7, 0.6), 888 spacing_top=7.0, 889 spacing_bottom=0.0 if component.prizes else 7.0, 890 ) 891 total_height += section.get_height() 892 sections.append(section) 893 894 if component.prizes: 895 section = _TextSection( 896 sub_width=sub_width, 897 text=bui.Lstr(resource='yourPrizeText'), 898 spacing_top=6, 899 color=(1.0, 1.0, 1.0, 0.4), 900 scale=0.35, 901 ) 902 total_height += section.get_height() 903 sections.append(section) 904 905 section = _DisplayItemsSection( 906 sub_width=sub_width, 907 items=component.prizes, 908 width=70.0, 909 spacing_top=0.0, 910 spacing_bottom=0.0, 911 ) 912 total_height += section.get_height() 913 sections.append(section) 914 915 elif ctypeid is idcls.EXPIRE_TIME: 916 assert isinstance( 917 component, bacommon.bs.BasicClientUIExpireTime 918 ) 919 section = _ExpireTimeSection( 920 sub_width=sub_width, 921 time=component.time, 922 spacing_top=component.spacing_top, 923 spacing_bottom=component.spacing_bottom, 924 ) 925 total_height += section.get_height() 926 sections.append(section) 927 928 elif ctypeid is idcls.UNKNOWN: 929 raise RuntimeError('Should not get here.') 930 931 else: 932 # Make sure we handle all types. 933 assert_never(ctypeid) 934 else: 935 936 # Display anything with unknown components as an 937 # 'upgrade your app to see this' message. 938 color = (0.6, 0.6, 0.6) 939 interaction_style = ( 940 bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN 941 ) 942 button_label_positive = bacommon.bs.BasicClientUI.ButtonLabel.OK 943 button_label_negative = ( 944 bacommon.bs.BasicClientUI.ButtonLabel.CANCEL 945 ) 946 947 section = _TextSection( 948 sub_width=sub_width, 949 text=bui.Lstr( 950 value='You must update the app to view this.' 951 ), 952 ) 953 total_height += section.get_height() 954 sections.append(section) 955 956 self._entry_displays.append( 957 _EntryDisplay( 958 interaction_style=interaction_style, 959 button_label_positive=button_label_positive, 960 button_label_negative=button_label_negative, 961 id=wrapper.id, 962 sections=sections, 963 total_height=total_height, 964 color=color, 965 ) 966 ) 967 sub_height += margin_v + total_height 968 969 sub_height += margin_bottom 970 971 subcontainer = bui.containerwidget( 972 id='inboxsub', 973 parent=self._scrollwidget, 974 size=(sub_width, sub_height), 975 background=False, 976 single_depth=True, 977 claims_left_right=True, 978 claims_up_down=True, 979 ) 980 981 backing_tex = bui.gettexture('buttonSquareWide') 982 983 assert bui.app.classic is not None 984 985 buttonrows: list[list[bui.Widget]] = [] 986 y = sub_height - margin_top 987 for i, _wrapper in enumerate(response.wrappers): 988 entry_display = self._entry_displays[i] 989 entry_display_weak = weakref.ref(entry_display) 990 bwidth = 140 991 bheight = 40 992 993 ysection = y - 23.0 994 995 # Backing. 996 entry_display.backing = img = bui.imagewidget( 997 parent=subcontainer, 998 position=( 999 -0.022 * sub_width, 1000 y - entry_display.total_height * 1.09, 1001 ), 1002 texture=backing_tex, 1003 size=(sub_width * 1.07, entry_display.total_height * 1.15), 1004 color=entry_display.color, 1005 opacity=0.9, 1006 ) 1007 bui.widget(edit=img, depth_range=(0, 0.1)) 1008 1009 # Section contents. 1010 for sec in entry_display.sections: 1011 sec.emit(subcontainer, ysection) 1012 # Wire up any widgets created by this section. 1013 sec_button_row = sec.get_button_row() 1014 if sec_button_row: 1015 buttonrows.append(sec_button_row) 1016 ysection -= sec.get_height() 1017 1018 buttonrow: list[bui.Widget] = [] 1019 have_negative_button = ( 1020 entry_display.interaction_style 1021 is ( 1022 bacommon.bs.BasicClientUI 1023 ).InteractionStyle.BUTTON_POSITIVE_NEGATIVE 1024 ) 1025 1026 bpos = ( 1027 ( 1028 (sub_width - bwidth - 25) 1029 if have_negative_button 1030 else ((sub_width - bwidth) * 0.5) 1031 ), 1032 y - entry_display.total_height + 15.0, 1033 ) 1034 entry_display.button_positive = btn = bui.buttonwidget( 1035 parent=subcontainer, 1036 position=bpos, 1037 autoselect=True, 1038 size=(bwidth, bheight), 1039 label=bui.app.classic.basic_client_ui_button_label_str( 1040 entry_display.button_label_positive 1041 ), 1042 color=entry_display.color, 1043 textcolor=(0, 1, 0), 1044 on_activate_call=bui.WeakCall( 1045 self._on_entry_display_press, 1046 entry_display_weak, 1047 bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE, 1048 ), 1049 enable_sound=False, 1050 ) 1051 bui.widget(edit=btn, depth_range=(0.1, 1.0)) 1052 buttonrow.append(btn) 1053 spinner = entry_display.button_spinner_positive = bui.spinnerwidget( 1054 parent=subcontainer, 1055 position=( 1056 bpos[0] + 0.5 * bwidth, 1057 bpos[1] + 0.5 * bheight, 1058 ), 1059 visible=False, 1060 ) 1061 bui.widget(edit=spinner, depth_range=(0.1, 1.0)) 1062 1063 if have_negative_button: 1064 bpos = (25, y - entry_display.total_height + 15.0) 1065 entry_display.button_negative = btn2 = bui.buttonwidget( 1066 parent=subcontainer, 1067 position=bpos, 1068 autoselect=True, 1069 size=(bwidth, bheight), 1070 label=bui.app.classic.basic_client_ui_button_label_str( 1071 entry_display.button_label_negative 1072 ), 1073 color=(0.85, 0.5, 0.7), 1074 textcolor=(1, 0.4, 0.4), 1075 on_activate_call=bui.WeakCall( 1076 self._on_entry_display_press, 1077 entry_display_weak, 1078 (bacommon.bs.ClientUIAction).BUTTON_PRESS_NEGATIVE, 1079 ), 1080 enable_sound=False, 1081 ) 1082 bui.widget(edit=btn2, depth_range=(0.1, 1.0)) 1083 buttonrow.append(btn2) 1084 spinner = entry_display.button_spinner_negative = ( 1085 bui.spinnerwidget( 1086 parent=subcontainer, 1087 position=( 1088 bpos[0] + 0.5 * bwidth, 1089 bpos[1] + 0.5 * bheight, 1090 ), 1091 visible=False, 1092 ) 1093 ) 1094 bui.widget(edit=spinner, depth_range=(0.1, 1.0)) 1095 1096 buttonrows.append(buttonrow) 1097 1098 y -= margin_v + entry_display.total_height 1099 1100 uiscale = bui.app.ui_v1.uiscale 1101 above_widget = ( 1102 bui.get_special_widget('back_button') 1103 if uiscale is bui.UIScale.SMALL 1104 else self._back_button 1105 ) 1106 assert above_widget is not None 1107 for i, buttons in enumerate(buttonrows): 1108 if i < len(buttonrows) - 1: 1109 below_widget = buttonrows[i + 1][0] 1110 else: 1111 below_widget = None 1112 1113 assert buttons # We should never have an empty row. 1114 for j, button in enumerate(buttons): 1115 bui.widget( 1116 edit=button, 1117 up_widget=above_widget, 1118 down_widget=below_widget, 1119 # down_widget=( 1120 # button if below_widget is None else below_widget 1121 # ), 1122 right_widget=buttons[max(j - 1, 0)], 1123 left_widget=buttons[min(j + 1, len(buttons) - 1)], 1124 ) 1125 1126 above_widget = buttons[0]
Popup window to show account messages.
InboxWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
312 def __init__( 313 self, 314 transition: str | None = 'in_right', 315 origin_widget: bui.Widget | None = None, 316 ): 317 318 assert bui.app.classic is not None 319 uiscale = bui.app.ui_v1.uiscale 320 321 self._entry_displays: list[_EntryDisplay] = [] 322 323 self._width = 900 if uiscale is bui.UIScale.SMALL else 500 324 self._height = ( 325 600 326 if uiscale is bui.UIScale.SMALL 327 else 460 if uiscale is bui.UIScale.MEDIUM else 600 328 ) 329 330 # Do some fancy math to fill all available screen area up to the 331 # size of our backing container. This lets us fit to the exact 332 # screen shape at small ui scale. 333 screensize = bui.get_virtual_screen_size() 334 scale = ( 335 1.9 336 if uiscale is bui.UIScale.SMALL 337 else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 338 ) 339 # Calc screen size in our local container space and clamp to a 340 # bit smaller than our container size. 341 target_width = min(self._width - 60, screensize[0] / scale) 342 target_height = min(self._height - 70, screensize[1] / scale) 343 344 # To get top/left coords, go to the center of our window and offset 345 # by half the width/height of our target area. 346 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 347 348 scroll_width = target_width 349 scroll_height = target_height - 31 350 scroll_bottom = yoffs - 59 - scroll_height 351 352 super().__init__( 353 root_widget=bui.containerwidget( 354 size=(self._width, self._height), 355 toolbar_visibility=( 356 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' 357 ), 358 scale=scale, 359 ), 360 transition=transition, 361 origin_widget=origin_widget, 362 # We're affected by screen size only at small ui-scale. 363 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 364 ) 365 366 if uiscale is bui.UIScale.SMALL: 367 bui.containerwidget( 368 edit=self._root_widget, on_cancel_call=self.main_window_back 369 ) 370 self._back_button = None 371 else: 372 self._back_button = bui.buttonwidget( 373 parent=self._root_widget, 374 autoselect=True, 375 position=(50, yoffs - 48), 376 size=(60, 60), 377 scale=0.6, 378 label=bui.charstr(bui.SpecialChar.BACK), 379 button_type='backSmall', 380 on_activate_call=self.main_window_back, 381 ) 382 bui.containerwidget( 383 edit=self._root_widget, cancel_button=self._back_button 384 ) 385 386 self._title_text = bui.textwidget( 387 parent=self._root_widget, 388 position=( 389 self._width * 0.5, 390 yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), 391 ), 392 size=(0, 0), 393 h_align='center', 394 v_align='center', 395 scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, 396 text=bui.Lstr(resource='inboxText'), 397 maxwidth=200, 398 color=bui.app.ui_v1.title_color, 399 ) 400 401 # Shows 'loading', 'no messages', etc. 402 self._infotext = bui.textwidget( 403 parent=self._root_widget, 404 position=(self._width * 0.5, self._height * 0.5), 405 maxwidth=self._width * 0.7, 406 scale=0.5, 407 flatness=1.0, 408 color=(0.4, 0.4, 0.5), 409 shadow=0.0, 410 text='', 411 size=(0, 0), 412 h_align='center', 413 v_align='center', 414 ) 415 self._loading_spinner = bui.spinnerwidget( 416 parent=self._root_widget, 417 position=(self._width * 0.5, self._height * 0.5), 418 style='bomb', 419 size=48, 420 ) 421 self._scrollwidget = bui.scrollwidget( 422 parent=self._root_widget, 423 size=(scroll_width, scroll_height), 424 position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), 425 capture_arrows=True, 426 simple_culling_v=200, 427 claims_left_right=True, 428 claims_up_down=True, 429 center_small_content_horizontally=True, 430 border_opacity=0.4, 431 ) 432 bui.widget(edit=self._scrollwidget, autoselect=True) 433 if uiscale is bui.UIScale.SMALL: 434 bui.widget( 435 edit=self._scrollwidget, 436 left_widget=bui.get_special_widget('back_button'), 437 ) 438 439 bui.containerwidget( 440 edit=self._root_widget, 441 cancel_button=self._back_button, 442 single_depth=True, 443 ) 444 445 # Kick off request. 446 plus = bui.app.plus 447 if plus is None or plus.accounts.primary is None: 448 self._error(bui.Lstr(resource='notSignedInText')) 449 return 450 451 with plus.accounts.primary: 452 plus.cloud.send_message_cb( 453 bacommon.bs.InboxRequestMessage(), 454 on_response=bui.WeakCall(self._on_inbox_request_response), 455 )
Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.
457 @override 458 def get_main_window_state(self) -> bui.MainWindowState: 459 # Support recreating our window for back/refresh purposes. 460 cls = type(self) 461 return bui.BasicMainWindowState( 462 create_call=lambda transition, origin_widget: cls( 463 transition=transition, origin_widget=origin_widget 464 ) 465 )
Return a WindowState to recreate this window, if supported.