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

Popup window to show account messages.

InboxWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
229    def __init__(
230        self,
231        transition: str | None = 'in_right',
232        origin_widget: bui.Widget | None = None,
233    ):
234
235        assert bui.app.classic is not None
236        uiscale = bui.app.ui_v1.uiscale
237
238        self._entry_displays: list[_EntryDisplay] = []
239
240        self._width = 800 if uiscale is bui.UIScale.SMALL else 500
241        self._height = (
242            485
243            if uiscale is bui.UIScale.SMALL
244            else 370 if uiscale is bui.UIScale.MEDIUM else 450
245        )
246        yoffs = -42 if uiscale is bui.UIScale.SMALL else 0
247
248        super().__init__(
249            root_widget=bui.containerwidget(
250                size=(self._width, self._height),
251                toolbar_visibility=(
252                    'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full'
253                ),
254                scale=(
255                    1.74
256                    if uiscale is bui.UIScale.SMALL
257                    else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.15
258                ),
259                stack_offset=(
260                    (0, 0)
261                    if uiscale is bui.UIScale.SMALL
262                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
263                ),
264            ),
265            transition=transition,
266            origin_widget=origin_widget,
267        )
268
269        if uiscale is bui.UIScale.SMALL:
270            bui.containerwidget(
271                edit=self._root_widget, on_cancel_call=self.main_window_back
272            )
273            self._back_button = None
274        else:
275            self._back_button = bui.buttonwidget(
276                parent=self._root_widget,
277                autoselect=True,
278                position=(50, self._height - 38 + yoffs),
279                size=(60, 60),
280                scale=0.6,
281                label=bui.charstr(bui.SpecialChar.BACK),
282                button_type='backSmall',
283                on_activate_call=self.main_window_back,
284            )
285            bui.containerwidget(
286                edit=self._root_widget, cancel_button=self._back_button
287            )
288
289        self._title_text = bui.textwidget(
290            parent=self._root_widget,
291            position=(
292                self._width * 0.5,
293                self._height
294                - (45 if uiscale is bui.UIScale.SMALL else 20)
295                + yoffs,
296            ),
297            size=(0, 0),
298            h_align='center',
299            v_align='center',
300            scale=0.6,
301            text=bui.Lstr(resource='inboxText'),
302            maxwidth=200,
303            color=bui.app.ui_v1.title_color,
304        )
305
306        # Shows 'loading', 'no messages', etc.
307        self._infotext = bui.textwidget(
308            parent=self._root_widget,
309            position=(self._width * 0.5, self._height * 0.5),
310            maxwidth=self._width * 0.7,
311            scale=0.5,
312            flatness=1.0,
313            color=(0.4, 0.4, 0.5),
314            shadow=0.0,
315            text='',
316            size=(0, 0),
317            h_align='center',
318            v_align='center',
319        )
320        self._loading_spinner = bui.spinnerwidget(
321            parent=self._root_widget,
322            position=(self._width * 0.5, self._height * 0.5),
323        )
324        self._scrollwidget = bui.scrollwidget(
325            parent=self._root_widget,
326            size=(
327                self._width - 60,
328                self._height - (170 if uiscale is bui.UIScale.SMALL else 80),
329            ),
330            position=(
331                30,
332                (110 if uiscale is bui.UIScale.SMALL else 34) + yoffs,
333            ),
334            capture_arrows=True,
335            simple_culling_v=200,
336            claims_left_right=True,
337            claims_up_down=True,
338            center_small_content_horizontally=True,
339            border_opacity=0.4,
340        )
341        bui.widget(edit=self._scrollwidget, autoselect=True)
342        if uiscale is bui.UIScale.SMALL:
343            bui.widget(
344                edit=self._scrollwidget,
345                left_widget=bui.get_special_widget('back_button'),
346            )
347
348        bui.containerwidget(
349            edit=self._root_widget,
350            cancel_button=self._back_button,
351            single_depth=True,
352        )
353
354        # Kick off request.
355        plus = bui.app.plus
356        if plus is None or plus.accounts.primary is None:
357            self._error(bui.Lstr(resource='notSignedInText'))
358            return
359
360        with plus.accounts.primary:
361            plus.cloud.send_message_cb(
362                bacommon.bs.InboxRequestMessage(),
363                on_response=bui.WeakCall(self._on_inbox_request_response),
364            )

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.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
366    @override
367    def get_main_window_state(self) -> bui.MainWindowState:
368        # Support recreating our window for back/refresh purposes.
369        cls = type(self)
370        return bui.BasicMainWindowState(
371            create_call=lambda transition, origin_widget: cls(
372                transition=transition, origin_widget=origin_widget
373            )
374        )

Return a WindowState to recreate this window, if supported.