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.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
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.