bauiv1lib.inbox

Provides a popup window to view achievements.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides a popup window to view achievements."""
  4
  5from __future__ import annotations
  6
  7import weakref
  8from dataclasses import dataclass
  9from typing import override
 10
 11from efro.error import CommunicationError
 12import bacommon.cloud
 13import bauiv1 as bui
 14
 15# Messages with format versions higher than this will show up as
 16# 'app needs to be updated to view this'
 17SUPPORTED_INBOX_MESSAGE_FORMAT_VERSION = 1
 18
 19
 20@dataclass
 21class _MessageEntry:
 22    type: bacommon.cloud.BSInboxEntryType
 23    id: str
 24    height: float
 25    text_height: float
 26    scale: float
 27    text: str
 28    color: tuple[float, float, float]
 29    backing: bui.Widget | None = None
 30    button_positive: bui.Widget | None = None
 31    button_negative: bui.Widget | None = None
 32    message_text: bui.Widget | None = None
 33    processing_complete: bool = False
 34
 35
 36class InboxWindow(bui.MainWindow):
 37    """Popup window to show account messages."""
 38
 39    def __init__(
 40        self,
 41        transition: str | None = 'in_right',
 42        origin_widget: bui.Widget | None = None,
 43    ):
 44
 45        assert bui.app.classic is not None
 46        uiscale = bui.app.ui_v1.uiscale
 47
 48        self._message_entries: list[_MessageEntry] = []
 49
 50        self._width = 600 if uiscale is bui.UIScale.SMALL else 450
 51        self._height = (
 52            375
 53            if uiscale is bui.UIScale.SMALL
 54            else 370 if uiscale is bui.UIScale.MEDIUM else 450
 55        )
 56        yoffs = -47 if uiscale is bui.UIScale.SMALL else 0
 57
 58        super().__init__(
 59            root_widget=bui.containerwidget(
 60                size=(self._width, self._height),
 61                toolbar_visibility=(
 62                    'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full'
 63                ),
 64                scale=(
 65                    2.3
 66                    if uiscale is bui.UIScale.SMALL
 67                    else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 68                ),
 69                stack_offset=(
 70                    (0, 0)
 71                    if uiscale is bui.UIScale.SMALL
 72                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 73                ),
 74            ),
 75            transition=transition,
 76            origin_widget=origin_widget,
 77        )
 78
 79        if uiscale is bui.UIScale.SMALL:
 80            bui.containerwidget(
 81                edit=self._root_widget, on_cancel_call=self.main_window_back
 82            )
 83            self._back_button = None
 84        else:
 85            self._back_button = bui.buttonwidget(
 86                parent=self._root_widget,
 87                autoselect=True,
 88                position=(50, self._height - 38 + yoffs),
 89                size=(60, 60),
 90                scale=0.6,
 91                label=bui.charstr(bui.SpecialChar.BACK),
 92                button_type='backSmall',
 93                on_activate_call=self.main_window_back,
 94            )
 95            bui.containerwidget(
 96                edit=self._root_widget, cancel_button=self._back_button
 97            )
 98
 99        self._title_text = bui.textwidget(
100            parent=self._root_widget,
101            position=(
102                self._width * 0.5,
103                self._height
104                - (27 if uiscale is bui.UIScale.SMALL else 20)
105                + yoffs,
106            ),
107            size=(0, 0),
108            h_align='center',
109            v_align='center',
110            scale=0.6,
111            text=bui.Lstr(resource='inboxText'),
112            maxwidth=200,
113            color=bui.app.ui_v1.title_color,
114        )
115
116        # Shows 'loading', 'no messages', etc.
117        self._infotext = bui.textwidget(
118            parent=self._root_widget,
119            position=(self._width * 0.5, self._height * 0.5),
120            maxwidth=self._width * 0.7,
121            scale=0.5,
122            flatness=1.0,
123            color=(0.4, 0.4, 0.5),
124            shadow=0.0,
125            text=bui.Lstr(resource='loadingText'),
126            size=(0, 0),
127            h_align='center',
128            v_align='center',
129        )
130        self._scrollwidget = bui.scrollwidget(
131            parent=self._root_widget,
132            size=(
133                self._width - 60,
134                self._height - (170 if uiscale is bui.UIScale.SMALL else 70),
135            ),
136            position=(
137                30,
138                (133 if uiscale is bui.UIScale.SMALL else 30) + yoffs,
139            ),
140            capture_arrows=True,
141            simple_culling_v=200,
142            claims_left_right=True,
143            claims_up_down=True,
144        )
145        bui.widget(edit=self._scrollwidget, autoselect=True)
146        if uiscale is bui.UIScale.SMALL:
147            bui.widget(
148                edit=self._scrollwidget,
149                left_widget=bui.get_special_widget('back_button'),
150            )
151
152        bui.containerwidget(
153            edit=self._root_widget,
154            cancel_button=self._back_button,
155            single_depth=True,
156        )
157
158        # Kick off request.
159        plus = bui.app.plus
160        if plus is None or plus.accounts.primary is None:
161            self._error(bui.Lstr(resource='notSignedInText'))
162            return
163
164        with plus.accounts.primary:
165            plus.cloud.send_message_cb(
166                bacommon.cloud.BSInboxRequestMessage(),
167                on_response=bui.WeakCall(self._on_inbox_request_response),
168            )
169
170    @override
171    def get_main_window_state(self) -> bui.MainWindowState:
172        # Support recreating our window for back/refresh purposes.
173        cls = type(self)
174        return bui.BasicMainWindowState(
175            create_call=lambda transition, origin_widget: cls(
176                transition=transition, origin_widget=origin_widget
177            )
178        )
179
180    def _error(self, errmsg: bui.Lstr | str) -> None:
181        """Put ourself in a permanent error state."""
182        bui.textwidget(
183            edit=self._infotext,
184            color=(1, 0, 0),
185            text=errmsg,
186        )
187
188    def _on_message_entry_press(
189        self,
190        entry_weak: weakref.ReferenceType[_MessageEntry],
191        process_type: bacommon.cloud.BSInboxEntryProcessType,
192    ) -> None:
193        entry = entry_weak()
194        if entry is None:
195            return
196
197        self._neuter_message_entry(entry)
198
199        # We don't do anything for invalid messages.
200        if entry.type is bacommon.cloud.BSInboxEntryType.UNKNOWN:
201            entry.processing_complete = True
202            self._close_soon_if_all_processed()
203            return
204
205        # Error if we're somehow signed out now.
206        plus = bui.app.plus
207        if plus is None or plus.accounts.primary is None:
208            bui.screenmessage(
209                bui.Lstr(resource='notSignedInText'), color=(1, 0, 0)
210            )
211            bui.getsound('error').play()
212            return
213
214        # Message the master-server to process the entry.
215        with plus.accounts.primary:
216            plus.cloud.send_message_cb(
217                bacommon.cloud.BSInboxEntryProcessMessage(
218                    entry.id, process_type
219                ),
220                on_response=bui.WeakCall(
221                    self._on_inbox_entry_process_response,
222                    entry_weak,
223                    process_type,
224                ),
225            )
226
227        # Tweak the button to show this is in progress.
228        button = (
229            entry.button_positive
230            if process_type is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
231            else entry.button_negative
232        )
233        if button is not None:
234            bui.buttonwidget(edit=button, label='...')
235
236    def _close_soon_if_all_processed(self) -> None:
237        bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed))
238
239    def _close_if_all_processed(self) -> None:
240        if not all(m.processing_complete for m in self._message_entries):
241            return
242
243        self.main_window_back()
244
245    def _neuter_message_entry(self, entry: _MessageEntry) -> None:
246        errsound = bui.getsound('error')
247        if entry.button_positive is not None:
248            bui.buttonwidget(
249                edit=entry.button_positive,
250                color=(0.5, 0.5, 0.5),
251                textcolor=(0.4, 0.4, 0.4),
252                on_activate_call=errsound.play,
253            )
254        if entry.button_negative is not None:
255            bui.buttonwidget(
256                edit=entry.button_negative,
257                color=(0.5, 0.5, 0.5),
258                textcolor=(0.4, 0.4, 0.4),
259                on_activate_call=errsound.play,
260            )
261        if entry.backing is not None:
262            bui.imagewidget(edit=entry.backing, color=(0.4, 0.4, 0.4))
263        if entry.message_text is not None:
264            bui.textwidget(edit=entry.message_text, color=(0.5, 0.5, 0.5, 0.5))
265
266    def _on_inbox_entry_process_response(
267        self,
268        entry_weak: weakref.ReferenceType[_MessageEntry],
269        process_type: bacommon.cloud.BSInboxEntryProcessType,
270        response: bacommon.cloud.BSInboxEntryProcessResponse | Exception,
271    ) -> None:
272        # pylint: disable=too-many-branches
273        entry = entry_weak()
274        if entry is None:
275            return
276
277        assert not entry.processing_complete
278        entry.processing_complete = True
279        self._close_soon_if_all_processed()
280
281        # No-op if our UI is dead or on its way out.
282        if not self._root_widget or self._root_widget.transitioning_out:
283            return
284
285        # Tweak the button to show results.
286        button = (
287            entry.button_positive
288            if process_type is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
289            else entry.button_negative
290        )
291
292        # See if we should show an error message.
293        if isinstance(response, Exception):
294            if isinstance(response, CommunicationError):
295                error_message = bui.Lstr(
296                    resource='internal.unavailableNoConnectionText'
297                )
298            else:
299                error_message = bui.Lstr(resource='errorText')
300        elif response.error is not None:
301            error_message = bui.Lstr(
302                translate=('serverResponses', response.error)
303            )
304        else:
305            error_message = None
306
307        # Show error message if so.
308        if error_message is not None:
309            bui.screenmessage(error_message, color=(1, 0, 0))
310            bui.getsound('error').play()
311            if button is not None:
312                bui.buttonwidget(
313                    edit=button, label=bui.Lstr(resource='errorText')
314                )
315            return
316
317        # Whee; no error. Mark as done.
318        if button is not None:
319            # If we have full unicode, just show a checkmark in all cases.
320            label: str | bui.Lstr
321            if bui.supports_unicode_display():
322                label = '✓'
323            else:
324                # For positive claim buttons, say 'success'.
325                # Otherwise default to 'done.'
326                if (
327                    entry.type
328                    in {
329                        bacommon.cloud.BSInboxEntryType.CLAIM,
330                        bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD,
331                    }
332                    and process_type
333                    is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
334                ):
335                    label = bui.Lstr(resource='successText')
336                else:
337                    label = bui.Lstr(resource='doneText')
338            bui.buttonwidget(edit=button, label=label)
339
340    def _on_inbox_request_response(
341        self, response: bacommon.cloud.BSInboxRequestResponse | Exception
342    ) -> None:
343        # pylint: disable=too-many-locals
344        # pylint: disable=too-many-statements
345        # pylint: disable=too-many-branches
346
347        # No-op if our UI is dead or on its way out.
348        if not self._root_widget or self._root_widget.transitioning_out:
349            return
350
351        errmsg: str | bui.Lstr
352        if isinstance(response, Exception):
353            errmsg = bui.Lstr(resource='internal.unavailableNoConnectionText')
354            is_error = True
355        else:
356            is_error = response.error is not None
357            errmsg = (
358                ''
359                if response.error is None
360                else bui.Lstr(translate=('serverResponses', response.error))
361            )
362
363        if is_error:
364            self._error(errmsg)
365            return
366
367        assert isinstance(response, bacommon.cloud.BSInboxRequestResponse)
368
369        # If we got no messages, don't touch anything. This keeps
370        # keyboard control working in the empty case.
371        if not response.entries:
372            bui.textwidget(
373                edit=self._infotext,
374                color=(0.4, 0.4, 0.5),
375                text=bui.Lstr(resource='noMessagesText'),
376            )
377            return
378
379        bui.textwidget(edit=self._infotext, text='')
380
381        sub_width = self._width - 90
382        sub_height = 0.0
383
384        # Run the math on row heights/etc.
385        for i, entry in enumerate(response.entries):
386            # We need to flatten text here so we can measure it.
387            textfin: str
388            color: tuple[float, float, float]
389
390            # Messages with either newer formatting or unrecognized
391            # types show up as 'upgrade your app to see this'.
392            if (
393                entry.format_version > SUPPORTED_INBOX_MESSAGE_FORMAT_VERSION
394                or entry.type is bacommon.cloud.BSInboxEntryType.UNKNOWN
395            ):
396                textfin = bui.Lstr(
397                    translate=(
398                        'serverResponses',
399                        'You must update the app to view this.',
400                    )
401                ).evaluate()
402                color = (0.6, 0.6, 0.6)
403            else:
404                # Translate raw response and apply any replacements.
405                textfin = bui.Lstr(
406                    translate=('serverResponses', entry.message)
407                ).evaluate()
408                assert len(entry.subs) % 2 == 0  # Should always be even.
409                for j in range(0, len(entry.subs) - 1, 2):
410                    textfin = textfin.replace(entry.subs[j], entry.subs[j + 1])
411                color = (0.55, 0.5, 0.7)
412
413            # Calc scale to fit width and then see what height we need
414            # at that scale.
415            t_width = max(
416                10.0, bui.get_string_width(textfin, suppress_warning=True)
417            )
418            scale = min(0.6, (sub_width * 0.9) / t_width)
419            t_height = (
420                max(10.0, bui.get_string_height(textfin, suppress_warning=True))
421                * scale
422            )
423            entry_height = 90.0 + t_height
424            self._message_entries.append(
425                _MessageEntry(
426                    type=entry.type,
427                    id=entry.id,
428                    height=entry_height,
429                    text_height=t_height,
430                    scale=scale,
431                    text=textfin,
432                    color=color,
433                )
434            )
435            sub_height += entry_height
436
437        subcontainer = bui.containerwidget(
438            id='inboxsub',
439            parent=self._scrollwidget,
440            size=(sub_width, sub_height),
441            background=False,
442            single_depth=True,
443            claims_left_right=True,
444            claims_up_down=True,
445        )
446
447        backing_tex = bui.gettexture('buttonSquareWide')
448
449        buttonrows: list[list[bui.Widget]] = []
450        y = sub_height
451        for i, _entry in enumerate(response.entries):
452            message_entry = self._message_entries[i]
453            message_entry_weak = weakref.ref(message_entry)
454            bwidth = 140
455            bheight = 40
456
457            # Backing.
458            message_entry.backing = img = bui.imagewidget(
459                parent=subcontainer,
460                position=(-0.022 * sub_width, y - message_entry.height * 1.09),
461                texture=backing_tex,
462                size=(sub_width * 1.07, message_entry.height * 1.15),
463                color=message_entry.color,
464                opacity=0.9,
465            )
466            bui.widget(edit=img, depth_range=(0, 0.1))
467
468            buttonrow: list[bui.Widget] = []
469            have_negative_button = (
470                message_entry.type
471                is bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD
472            )
473
474            message_entry.button_positive = btn = bui.buttonwidget(
475                parent=subcontainer,
476                position=(
477                    (
478                        (sub_width - bwidth - 25)
479                        if have_negative_button
480                        else ((sub_width - bwidth) * 0.5)
481                    ),
482                    y - message_entry.height + 15.0,
483                ),
484                size=(bwidth, bheight),
485                label=bui.Lstr(
486                    resource=(
487                        'claimText'
488                        if message_entry.type
489                        in {
490                            bacommon.cloud.BSInboxEntryType.CLAIM,
491                            bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD,
492                        }
493                        else 'okText'
494                    )
495                ),
496                color=message_entry.color,
497                textcolor=(0, 1, 0),
498                on_activate_call=bui.WeakCall(
499                    self._on_message_entry_press,
500                    message_entry_weak,
501                    bacommon.cloud.BSInboxEntryProcessType.POSITIVE,
502                ),
503            )
504            bui.widget(edit=btn, depth_range=(0.1, 1.0))
505            buttonrow.append(btn)
506
507            if have_negative_button:
508                message_entry.button_negative = btn2 = bui.buttonwidget(
509                    parent=subcontainer,
510                    position=(25, y - message_entry.height + 15.0),
511                    size=(bwidth, bheight),
512                    label=bui.Lstr(resource='discardText'),
513                    color=(0.85, 0.5, 0.7),
514                    textcolor=(1, 0.4, 0.4),
515                    on_activate_call=bui.WeakCall(
516                        self._on_message_entry_press,
517                        message_entry_weak,
518                        bacommon.cloud.BSInboxEntryProcessType.NEGATIVE,
519                    ),
520                )
521                bui.widget(edit=btn2, depth_range=(0.1, 1.0))
522                buttonrow.append(btn2)
523
524            buttonrows.append(buttonrow)
525
526            message_entry.message_text = bui.textwidget(
527                parent=subcontainer,
528                position=(
529                    sub_width * 0.5,
530                    y - message_entry.text_height * 0.5 - 23.0,
531                ),
532                scale=message_entry.scale,
533                flatness=1.0,
534                shadow=0.0,
535                text=message_entry.text,
536                size=(0, 0),
537                h_align='center',
538                v_align='center',
539            )
540            y -= message_entry.height
541
542        uiscale = bui.app.ui_v1.uiscale
543        above_widget = (
544            bui.get_special_widget('back_button')
545            if uiscale is bui.UIScale.SMALL
546            else self._back_button
547        )
548        assert above_widget is not None
549        for i, buttons in enumerate(buttonrows):
550            if i < len(buttonrows) - 1:
551                below_widget = buttonrows[i + 1][0]
552            else:
553                below_widget = None
554
555            assert buttons  # We should never have an empty row.
556            for j, button in enumerate(buttons):
557                bui.widget(
558                    edit=button,
559                    up_widget=above_widget,
560                    down_widget=(
561                        button if below_widget is None else below_widget
562                    ),
563                    right_widget=buttons[max(j - 1, 0)],
564                    left_widget=buttons[min(j + 1, len(buttons) - 1)],
565                )
566
567            above_widget = buttons[0]
SUPPORTED_INBOX_MESSAGE_FORMAT_VERSION = 1
class InboxWindow(bauiv1._uitypes.MainWindow):
 37class InboxWindow(bui.MainWindow):
 38    """Popup window to show account messages."""
 39
 40    def __init__(
 41        self,
 42        transition: str | None = 'in_right',
 43        origin_widget: bui.Widget | None = None,
 44    ):
 45
 46        assert bui.app.classic is not None
 47        uiscale = bui.app.ui_v1.uiscale
 48
 49        self._message_entries: list[_MessageEntry] = []
 50
 51        self._width = 600 if uiscale is bui.UIScale.SMALL else 450
 52        self._height = (
 53            375
 54            if uiscale is bui.UIScale.SMALL
 55            else 370 if uiscale is bui.UIScale.MEDIUM else 450
 56        )
 57        yoffs = -47 if uiscale is bui.UIScale.SMALL else 0
 58
 59        super().__init__(
 60            root_widget=bui.containerwidget(
 61                size=(self._width, self._height),
 62                toolbar_visibility=(
 63                    'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full'
 64                ),
 65                scale=(
 66                    2.3
 67                    if uiscale is bui.UIScale.SMALL
 68                    else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 69                ),
 70                stack_offset=(
 71                    (0, 0)
 72                    if uiscale is bui.UIScale.SMALL
 73                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 74                ),
 75            ),
 76            transition=transition,
 77            origin_widget=origin_widget,
 78        )
 79
 80        if uiscale is bui.UIScale.SMALL:
 81            bui.containerwidget(
 82                edit=self._root_widget, on_cancel_call=self.main_window_back
 83            )
 84            self._back_button = None
 85        else:
 86            self._back_button = bui.buttonwidget(
 87                parent=self._root_widget,
 88                autoselect=True,
 89                position=(50, self._height - 38 + yoffs),
 90                size=(60, 60),
 91                scale=0.6,
 92                label=bui.charstr(bui.SpecialChar.BACK),
 93                button_type='backSmall',
 94                on_activate_call=self.main_window_back,
 95            )
 96            bui.containerwidget(
 97                edit=self._root_widget, cancel_button=self._back_button
 98            )
 99
100        self._title_text = bui.textwidget(
101            parent=self._root_widget,
102            position=(
103                self._width * 0.5,
104                self._height
105                - (27 if uiscale is bui.UIScale.SMALL else 20)
106                + yoffs,
107            ),
108            size=(0, 0),
109            h_align='center',
110            v_align='center',
111            scale=0.6,
112            text=bui.Lstr(resource='inboxText'),
113            maxwidth=200,
114            color=bui.app.ui_v1.title_color,
115        )
116
117        # Shows 'loading', 'no messages', etc.
118        self._infotext = bui.textwidget(
119            parent=self._root_widget,
120            position=(self._width * 0.5, self._height * 0.5),
121            maxwidth=self._width * 0.7,
122            scale=0.5,
123            flatness=1.0,
124            color=(0.4, 0.4, 0.5),
125            shadow=0.0,
126            text=bui.Lstr(resource='loadingText'),
127            size=(0, 0),
128            h_align='center',
129            v_align='center',
130        )
131        self._scrollwidget = bui.scrollwidget(
132            parent=self._root_widget,
133            size=(
134                self._width - 60,
135                self._height - (170 if uiscale is bui.UIScale.SMALL else 70),
136            ),
137            position=(
138                30,
139                (133 if uiscale is bui.UIScale.SMALL else 30) + yoffs,
140            ),
141            capture_arrows=True,
142            simple_culling_v=200,
143            claims_left_right=True,
144            claims_up_down=True,
145        )
146        bui.widget(edit=self._scrollwidget, autoselect=True)
147        if uiscale is bui.UIScale.SMALL:
148            bui.widget(
149                edit=self._scrollwidget,
150                left_widget=bui.get_special_widget('back_button'),
151            )
152
153        bui.containerwidget(
154            edit=self._root_widget,
155            cancel_button=self._back_button,
156            single_depth=True,
157        )
158
159        # Kick off request.
160        plus = bui.app.plus
161        if plus is None or plus.accounts.primary is None:
162            self._error(bui.Lstr(resource='notSignedInText'))
163            return
164
165        with plus.accounts.primary:
166            plus.cloud.send_message_cb(
167                bacommon.cloud.BSInboxRequestMessage(),
168                on_response=bui.WeakCall(self._on_inbox_request_response),
169            )
170
171    @override
172    def get_main_window_state(self) -> bui.MainWindowState:
173        # Support recreating our window for back/refresh purposes.
174        cls = type(self)
175        return bui.BasicMainWindowState(
176            create_call=lambda transition, origin_widget: cls(
177                transition=transition, origin_widget=origin_widget
178            )
179        )
180
181    def _error(self, errmsg: bui.Lstr | str) -> None:
182        """Put ourself in a permanent error state."""
183        bui.textwidget(
184            edit=self._infotext,
185            color=(1, 0, 0),
186            text=errmsg,
187        )
188
189    def _on_message_entry_press(
190        self,
191        entry_weak: weakref.ReferenceType[_MessageEntry],
192        process_type: bacommon.cloud.BSInboxEntryProcessType,
193    ) -> None:
194        entry = entry_weak()
195        if entry is None:
196            return
197
198        self._neuter_message_entry(entry)
199
200        # We don't do anything for invalid messages.
201        if entry.type is bacommon.cloud.BSInboxEntryType.UNKNOWN:
202            entry.processing_complete = True
203            self._close_soon_if_all_processed()
204            return
205
206        # Error if we're somehow signed out now.
207        plus = bui.app.plus
208        if plus is None or plus.accounts.primary is None:
209            bui.screenmessage(
210                bui.Lstr(resource='notSignedInText'), color=(1, 0, 0)
211            )
212            bui.getsound('error').play()
213            return
214
215        # Message the master-server to process the entry.
216        with plus.accounts.primary:
217            plus.cloud.send_message_cb(
218                bacommon.cloud.BSInboxEntryProcessMessage(
219                    entry.id, process_type
220                ),
221                on_response=bui.WeakCall(
222                    self._on_inbox_entry_process_response,
223                    entry_weak,
224                    process_type,
225                ),
226            )
227
228        # Tweak the button to show this is in progress.
229        button = (
230            entry.button_positive
231            if process_type is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
232            else entry.button_negative
233        )
234        if button is not None:
235            bui.buttonwidget(edit=button, label='...')
236
237    def _close_soon_if_all_processed(self) -> None:
238        bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed))
239
240    def _close_if_all_processed(self) -> None:
241        if not all(m.processing_complete for m in self._message_entries):
242            return
243
244        self.main_window_back()
245
246    def _neuter_message_entry(self, entry: _MessageEntry) -> None:
247        errsound = bui.getsound('error')
248        if entry.button_positive is not None:
249            bui.buttonwidget(
250                edit=entry.button_positive,
251                color=(0.5, 0.5, 0.5),
252                textcolor=(0.4, 0.4, 0.4),
253                on_activate_call=errsound.play,
254            )
255        if entry.button_negative is not None:
256            bui.buttonwidget(
257                edit=entry.button_negative,
258                color=(0.5, 0.5, 0.5),
259                textcolor=(0.4, 0.4, 0.4),
260                on_activate_call=errsound.play,
261            )
262        if entry.backing is not None:
263            bui.imagewidget(edit=entry.backing, color=(0.4, 0.4, 0.4))
264        if entry.message_text is not None:
265            bui.textwidget(edit=entry.message_text, color=(0.5, 0.5, 0.5, 0.5))
266
267    def _on_inbox_entry_process_response(
268        self,
269        entry_weak: weakref.ReferenceType[_MessageEntry],
270        process_type: bacommon.cloud.BSInboxEntryProcessType,
271        response: bacommon.cloud.BSInboxEntryProcessResponse | Exception,
272    ) -> None:
273        # pylint: disable=too-many-branches
274        entry = entry_weak()
275        if entry is None:
276            return
277
278        assert not entry.processing_complete
279        entry.processing_complete = True
280        self._close_soon_if_all_processed()
281
282        # No-op if our UI is dead or on its way out.
283        if not self._root_widget or self._root_widget.transitioning_out:
284            return
285
286        # Tweak the button to show results.
287        button = (
288            entry.button_positive
289            if process_type is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
290            else entry.button_negative
291        )
292
293        # See if we should show an error message.
294        if isinstance(response, Exception):
295            if isinstance(response, CommunicationError):
296                error_message = bui.Lstr(
297                    resource='internal.unavailableNoConnectionText'
298                )
299            else:
300                error_message = bui.Lstr(resource='errorText')
301        elif response.error is not None:
302            error_message = bui.Lstr(
303                translate=('serverResponses', response.error)
304            )
305        else:
306            error_message = None
307
308        # Show error message if so.
309        if error_message is not None:
310            bui.screenmessage(error_message, color=(1, 0, 0))
311            bui.getsound('error').play()
312            if button is not None:
313                bui.buttonwidget(
314                    edit=button, label=bui.Lstr(resource='errorText')
315                )
316            return
317
318        # Whee; no error. Mark as done.
319        if button is not None:
320            # If we have full unicode, just show a checkmark in all cases.
321            label: str | bui.Lstr
322            if bui.supports_unicode_display():
323                label = '✓'
324            else:
325                # For positive claim buttons, say 'success'.
326                # Otherwise default to 'done.'
327                if (
328                    entry.type
329                    in {
330                        bacommon.cloud.BSInboxEntryType.CLAIM,
331                        bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD,
332                    }
333                    and process_type
334                    is bacommon.cloud.BSInboxEntryProcessType.POSITIVE
335                ):
336                    label = bui.Lstr(resource='successText')
337                else:
338                    label = bui.Lstr(resource='doneText')
339            bui.buttonwidget(edit=button, label=label)
340
341    def _on_inbox_request_response(
342        self, response: bacommon.cloud.BSInboxRequestResponse | Exception
343    ) -> None:
344        # pylint: disable=too-many-locals
345        # pylint: disable=too-many-statements
346        # pylint: disable=too-many-branches
347
348        # No-op if our UI is dead or on its way out.
349        if not self._root_widget or self._root_widget.transitioning_out:
350            return
351
352        errmsg: str | bui.Lstr
353        if isinstance(response, Exception):
354            errmsg = bui.Lstr(resource='internal.unavailableNoConnectionText')
355            is_error = True
356        else:
357            is_error = response.error is not None
358            errmsg = (
359                ''
360                if response.error is None
361                else bui.Lstr(translate=('serverResponses', response.error))
362            )
363
364        if is_error:
365            self._error(errmsg)
366            return
367
368        assert isinstance(response, bacommon.cloud.BSInboxRequestResponse)
369
370        # If we got no messages, don't touch anything. This keeps
371        # keyboard control working in the empty case.
372        if not response.entries:
373            bui.textwidget(
374                edit=self._infotext,
375                color=(0.4, 0.4, 0.5),
376                text=bui.Lstr(resource='noMessagesText'),
377            )
378            return
379
380        bui.textwidget(edit=self._infotext, text='')
381
382        sub_width = self._width - 90
383        sub_height = 0.0
384
385        # Run the math on row heights/etc.
386        for i, entry in enumerate(response.entries):
387            # We need to flatten text here so we can measure it.
388            textfin: str
389            color: tuple[float, float, float]
390
391            # Messages with either newer formatting or unrecognized
392            # types show up as 'upgrade your app to see this'.
393            if (
394                entry.format_version > SUPPORTED_INBOX_MESSAGE_FORMAT_VERSION
395                or entry.type is bacommon.cloud.BSInboxEntryType.UNKNOWN
396            ):
397                textfin = bui.Lstr(
398                    translate=(
399                        'serverResponses',
400                        'You must update the app to view this.',
401                    )
402                ).evaluate()
403                color = (0.6, 0.6, 0.6)
404            else:
405                # Translate raw response and apply any replacements.
406                textfin = bui.Lstr(
407                    translate=('serverResponses', entry.message)
408                ).evaluate()
409                assert len(entry.subs) % 2 == 0  # Should always be even.
410                for j in range(0, len(entry.subs) - 1, 2):
411                    textfin = textfin.replace(entry.subs[j], entry.subs[j + 1])
412                color = (0.55, 0.5, 0.7)
413
414            # Calc scale to fit width and then see what height we need
415            # at that scale.
416            t_width = max(
417                10.0, bui.get_string_width(textfin, suppress_warning=True)
418            )
419            scale = min(0.6, (sub_width * 0.9) / t_width)
420            t_height = (
421                max(10.0, bui.get_string_height(textfin, suppress_warning=True))
422                * scale
423            )
424            entry_height = 90.0 + t_height
425            self._message_entries.append(
426                _MessageEntry(
427                    type=entry.type,
428                    id=entry.id,
429                    height=entry_height,
430                    text_height=t_height,
431                    scale=scale,
432                    text=textfin,
433                    color=color,
434                )
435            )
436            sub_height += entry_height
437
438        subcontainer = bui.containerwidget(
439            id='inboxsub',
440            parent=self._scrollwidget,
441            size=(sub_width, sub_height),
442            background=False,
443            single_depth=True,
444            claims_left_right=True,
445            claims_up_down=True,
446        )
447
448        backing_tex = bui.gettexture('buttonSquareWide')
449
450        buttonrows: list[list[bui.Widget]] = []
451        y = sub_height
452        for i, _entry in enumerate(response.entries):
453            message_entry = self._message_entries[i]
454            message_entry_weak = weakref.ref(message_entry)
455            bwidth = 140
456            bheight = 40
457
458            # Backing.
459            message_entry.backing = img = bui.imagewidget(
460                parent=subcontainer,
461                position=(-0.022 * sub_width, y - message_entry.height * 1.09),
462                texture=backing_tex,
463                size=(sub_width * 1.07, message_entry.height * 1.15),
464                color=message_entry.color,
465                opacity=0.9,
466            )
467            bui.widget(edit=img, depth_range=(0, 0.1))
468
469            buttonrow: list[bui.Widget] = []
470            have_negative_button = (
471                message_entry.type
472                is bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD
473            )
474
475            message_entry.button_positive = btn = bui.buttonwidget(
476                parent=subcontainer,
477                position=(
478                    (
479                        (sub_width - bwidth - 25)
480                        if have_negative_button
481                        else ((sub_width - bwidth) * 0.5)
482                    ),
483                    y - message_entry.height + 15.0,
484                ),
485                size=(bwidth, bheight),
486                label=bui.Lstr(
487                    resource=(
488                        'claimText'
489                        if message_entry.type
490                        in {
491                            bacommon.cloud.BSInboxEntryType.CLAIM,
492                            bacommon.cloud.BSInboxEntryType.CLAIM_DISCARD,
493                        }
494                        else 'okText'
495                    )
496                ),
497                color=message_entry.color,
498                textcolor=(0, 1, 0),
499                on_activate_call=bui.WeakCall(
500                    self._on_message_entry_press,
501                    message_entry_weak,
502                    bacommon.cloud.BSInboxEntryProcessType.POSITIVE,
503                ),
504            )
505            bui.widget(edit=btn, depth_range=(0.1, 1.0))
506            buttonrow.append(btn)
507
508            if have_negative_button:
509                message_entry.button_negative = btn2 = bui.buttonwidget(
510                    parent=subcontainer,
511                    position=(25, y - message_entry.height + 15.0),
512                    size=(bwidth, bheight),
513                    label=bui.Lstr(resource='discardText'),
514                    color=(0.85, 0.5, 0.7),
515                    textcolor=(1, 0.4, 0.4),
516                    on_activate_call=bui.WeakCall(
517                        self._on_message_entry_press,
518                        message_entry_weak,
519                        bacommon.cloud.BSInboxEntryProcessType.NEGATIVE,
520                    ),
521                )
522                bui.widget(edit=btn2, depth_range=(0.1, 1.0))
523                buttonrow.append(btn2)
524
525            buttonrows.append(buttonrow)
526
527            message_entry.message_text = bui.textwidget(
528                parent=subcontainer,
529                position=(
530                    sub_width * 0.5,
531                    y - message_entry.text_height * 0.5 - 23.0,
532                ),
533                scale=message_entry.scale,
534                flatness=1.0,
535                shadow=0.0,
536                text=message_entry.text,
537                size=(0, 0),
538                h_align='center',
539                v_align='center',
540            )
541            y -= message_entry.height
542
543        uiscale = bui.app.ui_v1.uiscale
544        above_widget = (
545            bui.get_special_widget('back_button')
546            if uiscale is bui.UIScale.SMALL
547            else self._back_button
548        )
549        assert above_widget is not None
550        for i, buttons in enumerate(buttonrows):
551            if i < len(buttonrows) - 1:
552                below_widget = buttonrows[i + 1][0]
553            else:
554                below_widget = None
555
556            assert buttons  # We should never have an empty row.
557            for j, button in enumerate(buttons):
558                bui.widget(
559                    edit=button,
560                    up_widget=above_widget,
561                    down_widget=(
562                        button if below_widget is None else below_widget
563                    ),
564                    right_widget=buttons[max(j - 1, 0)],
565                    left_widget=buttons[min(j + 1, len(buttons) - 1)],
566                )
567
568            above_widget = buttons[0]

Popup window to show account messages.

InboxWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 40    def __init__(
 41        self,
 42        transition: str | None = 'in_right',
 43        origin_widget: bui.Widget | None = None,
 44    ):
 45
 46        assert bui.app.classic is not None
 47        uiscale = bui.app.ui_v1.uiscale
 48
 49        self._message_entries: list[_MessageEntry] = []
 50
 51        self._width = 600 if uiscale is bui.UIScale.SMALL else 450
 52        self._height = (
 53            375
 54            if uiscale is bui.UIScale.SMALL
 55            else 370 if uiscale is bui.UIScale.MEDIUM else 450
 56        )
 57        yoffs = -47 if uiscale is bui.UIScale.SMALL else 0
 58
 59        super().__init__(
 60            root_widget=bui.containerwidget(
 61                size=(self._width, self._height),
 62                toolbar_visibility=(
 63                    'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full'
 64                ),
 65                scale=(
 66                    2.3
 67                    if uiscale is bui.UIScale.SMALL
 68                    else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
 69                ),
 70                stack_offset=(
 71                    (0, 0)
 72                    if uiscale is bui.UIScale.SMALL
 73                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 74                ),
 75            ),
 76            transition=transition,
 77            origin_widget=origin_widget,
 78        )
 79
 80        if uiscale is bui.UIScale.SMALL:
 81            bui.containerwidget(
 82                edit=self._root_widget, on_cancel_call=self.main_window_back
 83            )
 84            self._back_button = None
 85        else:
 86            self._back_button = bui.buttonwidget(
 87                parent=self._root_widget,
 88                autoselect=True,
 89                position=(50, self._height - 38 + yoffs),
 90                size=(60, 60),
 91                scale=0.6,
 92                label=bui.charstr(bui.SpecialChar.BACK),
 93                button_type='backSmall',
 94                on_activate_call=self.main_window_back,
 95            )
 96            bui.containerwidget(
 97                edit=self._root_widget, cancel_button=self._back_button
 98            )
 99
100        self._title_text = bui.textwidget(
101            parent=self._root_widget,
102            position=(
103                self._width * 0.5,
104                self._height
105                - (27 if uiscale is bui.UIScale.SMALL else 20)
106                + yoffs,
107            ),
108            size=(0, 0),
109            h_align='center',
110            v_align='center',
111            scale=0.6,
112            text=bui.Lstr(resource='inboxText'),
113            maxwidth=200,
114            color=bui.app.ui_v1.title_color,
115        )
116
117        # Shows 'loading', 'no messages', etc.
118        self._infotext = bui.textwidget(
119            parent=self._root_widget,
120            position=(self._width * 0.5, self._height * 0.5),
121            maxwidth=self._width * 0.7,
122            scale=0.5,
123            flatness=1.0,
124            color=(0.4, 0.4, 0.5),
125            shadow=0.0,
126            text=bui.Lstr(resource='loadingText'),
127            size=(0, 0),
128            h_align='center',
129            v_align='center',
130        )
131        self._scrollwidget = bui.scrollwidget(
132            parent=self._root_widget,
133            size=(
134                self._width - 60,
135                self._height - (170 if uiscale is bui.UIScale.SMALL else 70),
136            ),
137            position=(
138                30,
139                (133 if uiscale is bui.UIScale.SMALL else 30) + yoffs,
140            ),
141            capture_arrows=True,
142            simple_culling_v=200,
143            claims_left_right=True,
144            claims_up_down=True,
145        )
146        bui.widget(edit=self._scrollwidget, autoselect=True)
147        if uiscale is bui.UIScale.SMALL:
148            bui.widget(
149                edit=self._scrollwidget,
150                left_widget=bui.get_special_widget('back_button'),
151            )
152
153        bui.containerwidget(
154            edit=self._root_widget,
155            cancel_button=self._back_button,
156            single_depth=True,
157        )
158
159        # Kick off request.
160        plus = bui.app.plus
161        if plus is None or plus.accounts.primary is None:
162            self._error(bui.Lstr(resource='notSignedInText'))
163            return
164
165        with plus.accounts.primary:
166            plus.cloud.send_message_cb(
167                bacommon.cloud.BSInboxRequestMessage(),
168                on_response=bui.WeakCall(self._on_inbox_request_response),
169            )

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:
171    @override
172    def get_main_window_state(self) -> bui.MainWindowState:
173        # Support recreating our window for back/refresh purposes.
174        cls = type(self)
175        return bui.BasicMainWindowState(
176            create_call=lambda transition, origin_widget: cls(
177                transition=transition, origin_widget=origin_widget
178            )
179        )

Return a WindowState to recreate this window, if supported.