bauiv1lib.watch

Provides UI functionality for watching replays.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides UI functionality for watching replays."""
  4
  5from __future__ import annotations
  6
  7import os
  8import logging
  9from enum import Enum
 10from typing import TYPE_CHECKING, cast, override
 11
 12import bascenev1 as bs
 13import bauiv1 as bui
 14
 15if TYPE_CHECKING:
 16    from typing import Any
 17
 18
 19class WatchWindow(bui.MainWindow):
 20    """Window for watching replays."""
 21
 22    class TabID(Enum):
 23        """Our available tab types."""
 24
 25        MY_REPLAYS = 'my_replays'
 26        TEST_TAB = 'test_tab'
 27
 28    def __init__(
 29        self,
 30        transition: str | None = 'in_right',
 31        origin_widget: bui.Widget | None = None,
 32    ):
 33        # pylint: disable=too-many-locals
 34        from bauiv1lib.tabs import TabRow
 35
 36        bui.set_analytics_screen('Watch Window')
 37        self._tab_data: dict[str, Any] = {}
 38        self._my_replays_scroll_width: float | None = None
 39        self._my_replays_watch_replay_button: bui.Widget | None = None
 40        self._scrollwidget: bui.Widget | None = None
 41        self._columnwidget: bui.Widget | None = None
 42        self._my_replay_selected: str | None = None
 43        self._my_replays_rename_window: bui.Widget | None = None
 44        self._my_replay_rename_text: bui.Widget | None = None
 45        self._r = 'watchWindow'
 46        uiscale = bui.app.ui_v1.uiscale
 47        self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040
 48        self._height = (
 49            900
 50            if uiscale is bui.UIScale.SMALL
 51            else 670 if uiscale is bui.UIScale.MEDIUM else 800
 52        )
 53        self._current_tab: WatchWindow.TabID | None = None
 54
 55        # Do some fancy math to fill all available screen area up to the
 56        # size of our backing container. This lets us fit to the exact
 57        # screen shape at small ui scale.
 58        screensize = bui.get_virtual_screen_size()
 59        scale = (
 60            1.5
 61            if uiscale is bui.UIScale.SMALL
 62            else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65
 63        )
 64        # Calc screen size in our local container space and clamp to a
 65        # bit smaller than our container size.
 66        target_width = min(self._width - 120, screensize[0] / scale)
 67        target_height = min(self._height - 120, screensize[1] / scale)
 68
 69        # To get top/left coords, go to the center of our window and
 70        # offset by half the width/height of our target area.
 71        self.yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 72
 73        self._scroll_width = target_width
 74        self._scroll_height = target_height - 55
 75        self._scroll_y = self.yoffs - 85 - self._scroll_height
 76
 77        super().__init__(
 78            root_widget=bui.containerwidget(
 79                size=(self._width, self._height),
 80                toolbar_visibility=(
 81                    'menu_minimal'
 82                    if uiscale is bui.UIScale.SMALL
 83                    else 'menu_full'
 84                ),
 85                scale=scale,
 86            ),
 87            transition=transition,
 88            origin_widget=origin_widget,
 89            # We're affected by screen size only at small ui-scale.
 90            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 91        )
 92
 93        if uiscale is bui.UIScale.SMALL:
 94            bui.containerwidget(
 95                edit=self._root_widget, on_cancel_call=self.main_window_back
 96            )
 97            self._back_button = None
 98        else:
 99            self._back_button = btn = bui.buttonwidget(
100                parent=self._root_widget,
101                autoselect=True,
102                position=(70, self.yoffs - 50),
103                size=(60, 60),
104                scale=1.1,
105                label=bui.charstr(bui.SpecialChar.BACK),
106                button_type='backSmall',
107                on_activate_call=self.main_window_back,
108            )
109            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
110
111        bui.textwidget(
112            parent=self._root_widget,
113            position=(
114                (
115                    self._width * 0.5
116                    + (
117                        (self._scroll_width * -0.5 + 93)
118                        if uiscale is bui.UIScale.SMALL
119                        else 0
120                    )
121                ),
122                self.yoffs - (63 if uiscale is bui.UIScale.SMALL else 10),
123            ),
124            size=(0, 0),
125            color=bui.app.ui_v1.title_color,
126            scale=1.3 if uiscale is bui.UIScale.SMALL else 1.5,
127            h_align='left' if uiscale is bui.UIScale.SMALL else 'center',
128            v_align='center',
129            text=bui.Lstr(resource=f'{self._r}.titleText'),
130            maxwidth=200,
131        )
132
133        tabdefs = [
134            (
135                self.TabID.MY_REPLAYS,
136                bui.Lstr(resource=f'{self._r}.myReplaysText'),
137            ),
138        ]
139
140        tab_bar_width = 200.0 * len(tabdefs)
141        tab_bar_inset = (self._scroll_width - tab_bar_width) * 0.5
142
143        self._tab_row = TabRow(
144            self._root_widget,
145            tabdefs,
146            pos=(
147                self._width * 0.5 - self._scroll_width * 0.5 + tab_bar_inset,
148                self._scroll_y + self._scroll_height - 4.0,
149            ),
150            size=(self._scroll_width - 2.0 * tab_bar_inset, 50),
151            on_select_call=self._set_tab,
152        )
153
154        first_tab = self._tab_row.tabs[tabdefs[0][0]]
155        last_tab = self._tab_row.tabs[tabdefs[-1][0]]
156        bui.widget(
157            edit=last_tab.button,
158            right_widget=bui.get_special_widget('squad_button'),
159        )
160        if uiscale is bui.UIScale.SMALL:
161            bbtn = bui.get_special_widget('back_button')
162            bui.widget(edit=first_tab.button, up_widget=bbtn, left_widget=bbtn)
163
164        # Not actually using a scroll widget anymore; just an image.
165        bui.imagewidget(
166            parent=self._root_widget,
167            size=(self._scroll_width, self._scroll_height),
168            position=(
169                self._width * 0.5 - self._scroll_width * 0.5,
170                self._scroll_y,
171            ),
172            texture=bui.gettexture('scrollWidget'),
173            mesh_transparent=bui.getmesh('softEdgeOutside'),
174            opacity=0.4,
175        )
176        self._tab_container: bui.Widget | None = None
177
178        self._restore_state()
179
180    @override
181    def get_main_window_state(self) -> bui.MainWindowState:
182        # Support recreating our window for back/refresh purposes.
183        cls = type(self)
184        return bui.BasicMainWindowState(
185            create_call=lambda transition, origin_widget: cls(
186                transition=transition, origin_widget=origin_widget
187            )
188        )
189
190    @override
191    def on_main_window_close(self) -> None:
192        self._save_state()
193
194    def _set_tab(self, tab_id: TabID) -> None:
195        # pylint: disable=too-many-locals
196
197        if self._current_tab == tab_id:
198            return
199        self._current_tab = tab_id
200
201        # Preserve our current tab between runs.
202        cfg = bui.app.config
203        cfg['Watch Tab'] = tab_id.value
204        cfg.commit()
205
206        # Update tab colors based on which is selected.
207        # tabs.update_tab_button_colors(self._tab_buttons, tab)
208        self._tab_row.update_appearance(tab_id)
209
210        if self._tab_container:
211            self._tab_container.delete()
212        scroll_left = (self._width - self._scroll_width) * 0.5
213        scroll_bottom = self._scroll_y
214
215        # A place where tabs can store data to get cleared when
216        # switching to a different tab
217        self._tab_data = {}
218
219        assert bui.app.classic is not None
220        uiscale = bui.app.ui_v1.uiscale
221        if tab_id is self.TabID.MY_REPLAYS:
222            c_width = self._scroll_width
223            c_height = self._scroll_height - 20
224            sub_scroll_height = c_height - 63
225            self._my_replays_scroll_width = sub_scroll_width = (
226                680 if uiscale is bui.UIScale.SMALL else 640
227            )
228
229            self._tab_container = cnt = bui.containerwidget(
230                parent=self._root_widget,
231                position=(
232                    scroll_left,
233                    scroll_bottom + (self._scroll_height - c_height) * 0.5,
234                ),
235                size=(c_width, c_height),
236                background=False,
237                selection_loops_to_parent=True,
238            )
239
240            v = c_height - 30
241            bui.textwidget(
242                parent=cnt,
243                position=(c_width * 0.5, v),
244                color=(0.6, 1.0, 0.6),
245                scale=0.7,
246                size=(0, 0),
247                maxwidth=c_width * 0.9,
248                h_align='center',
249                v_align='center',
250                text=bui.Lstr(
251                    resource='replayRenameWarningText',
252                    subs=[
253                        (
254                            '${REPLAY}',
255                            bui.Lstr(resource='replayNameDefaultText'),
256                        )
257                    ],
258                ),
259            )
260
261            b_width = 140 if uiscale is bui.UIScale.SMALL else 178
262            b_height = (
263                110
264                if uiscale is bui.UIScale.SMALL
265                else 142 if uiscale is bui.UIScale.MEDIUM else 180
266            )
267            b_space_extra = (
268                0
269                if uiscale is bui.UIScale.SMALL
270                else -2 if uiscale is bui.UIScale.MEDIUM else -5
271            )
272
273            b_color = (0.6, 0.53, 0.63)
274            b_textcolor = (0.75, 0.7, 0.8)
275            btnv = (
276                c_height
277                - (
278                    40
279                    if uiscale is bui.UIScale.SMALL
280                    else 40 if uiscale is bui.UIScale.MEDIUM else 40
281                )
282                - b_height
283            )
284            # Roughly center buttons and scroll-widget in the middle.
285            xextra = (
286                self._scroll_width - (sub_scroll_width + b_width)
287            ) * 0.5 - 50.0
288            btnh = (40 if uiscale is bui.UIScale.SMALL else 40) + xextra
289            smlh = (190 if uiscale is bui.UIScale.SMALL else 225) + xextra
290            tscl = 1.0 if uiscale is bui.UIScale.SMALL else 1.2
291            self._my_replays_watch_replay_button = btn1 = bui.buttonwidget(
292                parent=cnt,
293                size=(b_width, b_height),
294                position=(btnh, btnv),
295                button_type='square',
296                color=b_color,
297                textcolor=b_textcolor,
298                on_activate_call=self._on_my_replay_play_press,
299                text_scale=tscl,
300                label=bui.Lstr(resource=f'{self._r}.watchReplayButtonText'),
301                autoselect=True,
302            )
303            bui.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button)
304            assert bui.app.classic is not None
305            if uiscale is bui.UIScale.SMALL:
306                bui.widget(
307                    edit=btn1,
308                    left_widget=bui.get_special_widget('back_button'),
309                )
310            btnv -= b_height + b_space_extra
311            bui.buttonwidget(
312                parent=cnt,
313                size=(b_width, b_height),
314                position=(btnh, btnv),
315                button_type='square',
316                color=b_color,
317                textcolor=b_textcolor,
318                on_activate_call=self._on_my_replay_rename_press,
319                text_scale=tscl,
320                label=bui.Lstr(resource=f'{self._r}.renameReplayButtonText'),
321                autoselect=True,
322            )
323            btnv -= b_height + b_space_extra
324            bui.buttonwidget(
325                parent=cnt,
326                size=(b_width, b_height),
327                position=(btnh, btnv),
328                button_type='square',
329                color=b_color,
330                textcolor=b_textcolor,
331                on_activate_call=self._on_my_replay_delete_press,
332                text_scale=tscl,
333                label=bui.Lstr(resource=f'{self._r}.deleteReplayButtonText'),
334                autoselect=True,
335            )
336
337            v -= sub_scroll_height + 23
338            self._scrollwidget = scrlw = bui.scrollwidget(
339                parent=cnt,
340                position=(smlh, v),
341                size=(sub_scroll_width, sub_scroll_height),
342            )
343            bui.containerwidget(edit=cnt, selected_child=scrlw)
344            self._columnwidget = bui.columnwidget(
345                parent=scrlw, left_border=10, border=2, margin=0
346            )
347
348            bui.widget(
349                edit=scrlw,
350                autoselect=True,
351                left_widget=btn1,
352                up_widget=self._tab_row.tabs[tab_id].button,
353            )
354            bui.widget(
355                edit=self._tab_row.tabs[tab_id].button, down_widget=scrlw
356            )
357
358            self._my_replay_selected = None
359            self._refresh_my_replays()
360
361    def _no_replay_selected_error(self) -> None:
362        bui.screenmessage(
363            bui.Lstr(resource=f'{self._r}.noReplaySelectedErrorText'),
364            color=(1, 0, 0),
365        )
366        bui.getsound('error').play()
367
368    def _on_my_replay_play_press(self) -> None:
369        if self._my_replay_selected is None:
370            self._no_replay_selected_error()
371            return
372        bui.increment_analytics_count('Replay watch')
373
374        # Save our place in the UI so we return there when done.
375        if bui.app.classic is not None:
376            bui.app.classic.save_ui_state()
377
378        def do_it() -> None:
379            try:
380                # Reset to normal speed.
381                bs.set_replay_speed_exponent(0)
382                bui.fade_screen(True)
383                assert self._my_replay_selected is not None
384                bs.new_replay_session(
385                    f'{bui.get_replays_dir()}/{self._my_replay_selected}'
386                )
387            except Exception:
388                logging.exception('Error running replay session.')
389
390                # Drop back into a fresh main menu session
391                # in case we half-launched or something.
392                from bascenev1lib import mainmenu
393
394                bs.new_host_session(mainmenu.MainMenuSession)
395
396        bui.fade_screen(False, endcall=bui.Call(bui.pushcall, do_it))
397        bui.containerwidget(edit=self._root_widget, transition='out_left')
398
399    def _on_my_replay_rename_press(self) -> None:
400        if self._my_replay_selected is None:
401            self._no_replay_selected_error()
402            return
403        c_width = 600
404        c_height = 250
405        assert bui.app.classic is not None
406        uiscale = bui.app.ui_v1.uiscale
407        self._my_replays_rename_window = cnt = bui.containerwidget(
408            scale=(
409                1.8
410                if uiscale is bui.UIScale.SMALL
411                else 1.55 if uiscale is bui.UIScale.MEDIUM else 1.0
412            ),
413            size=(c_width, c_height),
414            transition='in_scale',
415            parent=bui.get_special_widget('overlay_stack'),
416        )
417        dname = self._get_replay_display_name(self._my_replay_selected)
418        bui.textwidget(
419            parent=cnt,
420            size=(0, 0),
421            h_align='center',
422            v_align='center',
423            text=bui.Lstr(
424                resource=f'{self._r}.renameReplayText',
425                subs=[('${REPLAY}', dname)],
426            ),
427            maxwidth=c_width * 0.8,
428            position=(c_width * 0.5, c_height - 60),
429        )
430        self._my_replay_rename_text = txt = bui.textwidget(
431            parent=cnt,
432            size=(c_width * 0.8, 40),
433            h_align='left',
434            v_align='center',
435            text=dname,
436            editable=True,
437            description=bui.Lstr(resource=f'{self._r}.replayNameText'),
438            position=(c_width * 0.1, c_height - 140),
439            autoselect=True,
440            maxwidth=c_width * 0.7,
441            max_chars=200,
442        )
443        cbtn = bui.buttonwidget(
444            parent=cnt,
445            label=bui.Lstr(resource='cancelText'),
446            on_activate_call=bui.Call(
447                lambda c: bui.containerwidget(edit=c, transition='out_scale'),
448                cnt,
449            ),
450            size=(180, 60),
451            position=(30, 30),
452            autoselect=True,
453        )
454        okb = bui.buttonwidget(
455            parent=cnt,
456            label=bui.Lstr(resource=f'{self._r}.renameText'),
457            size=(180, 60),
458            position=(c_width - 230, 30),
459            on_activate_call=bui.Call(
460                self._rename_my_replay, self._my_replay_selected
461            ),
462            autoselect=True,
463        )
464        bui.widget(edit=cbtn, right_widget=okb)
465        bui.widget(edit=okb, left_widget=cbtn)
466        bui.textwidget(edit=txt, on_return_press_call=okb.activate)
467        bui.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb)
468
469    def _rename_my_replay(self, replay: str) -> None:
470        new_name = None
471        try:
472            if not self._my_replay_rename_text:
473                return
474            new_name_raw = cast(
475                str, bui.textwidget(query=self._my_replay_rename_text)
476            )
477            new_name = new_name_raw + '.brp'
478
479            # Ignore attempts to change it to what it already is
480            # (or what it looks like to the user).
481            if (
482                replay != new_name
483                and self._get_replay_display_name(replay) != new_name_raw
484            ):
485                old_name_full = (bui.get_replays_dir() + '/' + replay).encode(
486                    'utf-8'
487                )
488                new_name_full = (bui.get_replays_dir() + '/' + new_name).encode(
489                    'utf-8'
490                )
491                # False alarm; bui.textwidget can return non-None val.
492                # pylint: disable=unsupported-membership-test
493                if os.path.exists(new_name_full):
494                    bui.getsound('error').play()
495                    bui.screenmessage(
496                        bui.Lstr(
497                            resource=self._r
498                            + '.replayRenameErrorAlreadyExistsText'
499                        ),
500                        color=(1, 0, 0),
501                    )
502                elif any(char in new_name_raw for char in ['/', '\\', ':']):
503                    bui.getsound('error').play()
504                    bui.screenmessage(
505                        bui.Lstr(
506                            resource=f'{self._r}.replayRenameErrorInvalidName'
507                        ),
508                        color=(1, 0, 0),
509                    )
510                else:
511                    bui.increment_analytics_count('Replay rename')
512                    os.rename(old_name_full, new_name_full)
513                    self._refresh_my_replays()
514                    bui.getsound('gunCocking').play()
515        except Exception:
516            logging.exception(
517                "Error renaming replay '%s' to '%s'.", replay, new_name
518            )
519            bui.getsound('error').play()
520            bui.screenmessage(
521                bui.Lstr(resource=f'{self._r}.replayRenameErrorText'),
522                color=(1, 0, 0),
523            )
524
525        bui.containerwidget(
526            edit=self._my_replays_rename_window, transition='out_scale'
527        )
528
529    def _on_my_replay_delete_press(self) -> None:
530        from bauiv1lib import confirm
531
532        if self._my_replay_selected is None:
533            self._no_replay_selected_error()
534            return
535        confirm.ConfirmWindow(
536            bui.Lstr(
537                resource=f'{self._r}.deleteConfirmText',
538                subs=[
539                    (
540                        '${REPLAY}',
541                        self._get_replay_display_name(self._my_replay_selected),
542                    )
543                ],
544            ),
545            bui.Call(self._delete_replay, self._my_replay_selected),
546            450,
547            150,
548        )
549
550    def _get_replay_display_name(self, replay: str) -> str:
551        if replay.endswith('.brp'):
552            replay = replay[:-4]
553        if replay == '__lastReplay':
554            return bui.Lstr(resource='replayNameDefaultText').evaluate()
555        return replay
556
557    def _delete_replay(self, replay: str) -> None:
558        try:
559            bui.increment_analytics_count('Replay delete')
560            os.remove((bui.get_replays_dir() + '/' + replay).encode('utf-8'))
561            self._refresh_my_replays()
562            bui.getsound('shieldDown').play()
563            if replay == self._my_replay_selected:
564                self._my_replay_selected = None
565        except Exception:
566            logging.exception("Error deleting replay '%s'.", replay)
567            bui.getsound('error').play()
568            bui.screenmessage(
569                bui.Lstr(resource=f'{self._r}.replayDeleteErrorText'),
570                color=(1, 0, 0),
571            )
572
573    def _on_my_replay_select(self, replay: str) -> None:
574        self._my_replay_selected = replay
575
576    def _refresh_my_replays(self) -> None:
577        assert self._columnwidget is not None
578        for child in self._columnwidget.get_children():
579            child.delete()
580        t_scale = 1.6
581        try:
582            names = os.listdir(bui.get_replays_dir())
583
584            # Ignore random other files in there.
585            names = [n for n in names if n.endswith('.brp')]
586            names.sort(key=lambda x: x.lower())
587        except Exception:
588            logging.exception('Error listing replays dir.')
589            names = []
590
591        assert self._my_replays_scroll_width is not None
592        assert self._my_replays_watch_replay_button is not None
593        for i, name in enumerate(names):
594            txt = bui.textwidget(
595                parent=self._columnwidget,
596                size=(self._my_replays_scroll_width / t_scale, 30),
597                selectable=True,
598                color=(
599                    (1.0, 1, 0.4) if name == '__lastReplay.brp' else (1, 1, 1)
600                ),
601                always_highlight=True,
602                on_select_call=bui.Call(self._on_my_replay_select, name),
603                on_activate_call=self._my_replays_watch_replay_button.activate,
604                text=self._get_replay_display_name(name),
605                h_align='left',
606                v_align='center',
607                corner_scale=t_scale,
608                maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93,
609            )
610            if i == 0:
611                bui.widget(
612                    edit=txt,
613                    up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button,
614                )
615                self._my_replay_selected = name
616
617    def _save_state(self) -> None:
618        try:
619            sel = self._root_widget.get_selected_child()
620            selected_tab_ids = [
621                tab_id
622                for tab_id, tab in self._tab_row.tabs.items()
623                if sel == tab.button
624            ]
625            if sel == self._back_button:
626                sel_name = 'Back'
627            elif selected_tab_ids:
628                assert len(selected_tab_ids) == 1
629                sel_name = f'Tab:{selected_tab_ids[0].value}'
630            elif sel == self._tab_container:
631                sel_name = 'TabContainer'
632            else:
633                raise ValueError(f'unrecognized selection {sel}')
634            assert bui.app.classic is not None
635            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
636        except Exception:
637            logging.exception('Error saving state for %s.', self)
638
639    def _restore_state(self) -> None:
640        try:
641            sel: bui.Widget | None
642            assert bui.app.classic is not None
643            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
644                'sel_name'
645            )
646            assert isinstance(sel_name, (str, type(None)))
647            try:
648                current_tab = self.TabID(bui.app.config.get('Watch Tab'))
649            except ValueError:
650                current_tab = self.TabID.MY_REPLAYS
651            self._set_tab(current_tab)
652
653            if sel_name == 'Back':
654                sel = self._back_button
655            elif sel_name == 'TabContainer':
656                sel = self._tab_container
657            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
658                try:
659                    sel_tab_id = self.TabID(sel_name.split(':')[-1])
660                except ValueError:
661                    sel_tab_id = self.TabID.MY_REPLAYS
662                sel = self._tab_row.tabs[sel_tab_id].button
663            else:
664                if self._tab_container is not None:
665                    sel = self._tab_container
666                else:
667                    sel = self._tab_row.tabs[current_tab].button
668            bui.containerwidget(edit=self._root_widget, selected_child=sel)
669        except Exception:
670            logging.exception('Error restoring state for %s.', self)
class WatchWindow(bauiv1._uitypes.MainWindow):
 20class WatchWindow(bui.MainWindow):
 21    """Window for watching replays."""
 22
 23    class TabID(Enum):
 24        """Our available tab types."""
 25
 26        MY_REPLAYS = 'my_replays'
 27        TEST_TAB = 'test_tab'
 28
 29    def __init__(
 30        self,
 31        transition: str | None = 'in_right',
 32        origin_widget: bui.Widget | None = None,
 33    ):
 34        # pylint: disable=too-many-locals
 35        from bauiv1lib.tabs import TabRow
 36
 37        bui.set_analytics_screen('Watch Window')
 38        self._tab_data: dict[str, Any] = {}
 39        self._my_replays_scroll_width: float | None = None
 40        self._my_replays_watch_replay_button: bui.Widget | None = None
 41        self._scrollwidget: bui.Widget | None = None
 42        self._columnwidget: bui.Widget | None = None
 43        self._my_replay_selected: str | None = None
 44        self._my_replays_rename_window: bui.Widget | None = None
 45        self._my_replay_rename_text: bui.Widget | None = None
 46        self._r = 'watchWindow'
 47        uiscale = bui.app.ui_v1.uiscale
 48        self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040
 49        self._height = (
 50            900
 51            if uiscale is bui.UIScale.SMALL
 52            else 670 if uiscale is bui.UIScale.MEDIUM else 800
 53        )
 54        self._current_tab: WatchWindow.TabID | None = None
 55
 56        # Do some fancy math to fill all available screen area up to the
 57        # size of our backing container. This lets us fit to the exact
 58        # screen shape at small ui scale.
 59        screensize = bui.get_virtual_screen_size()
 60        scale = (
 61            1.5
 62            if uiscale is bui.UIScale.SMALL
 63            else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65
 64        )
 65        # Calc screen size in our local container space and clamp to a
 66        # bit smaller than our container size.
 67        target_width = min(self._width - 120, screensize[0] / scale)
 68        target_height = min(self._height - 120, screensize[1] / scale)
 69
 70        # To get top/left coords, go to the center of our window and
 71        # offset by half the width/height of our target area.
 72        self.yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 73
 74        self._scroll_width = target_width
 75        self._scroll_height = target_height - 55
 76        self._scroll_y = self.yoffs - 85 - self._scroll_height
 77
 78        super().__init__(
 79            root_widget=bui.containerwidget(
 80                size=(self._width, self._height),
 81                toolbar_visibility=(
 82                    'menu_minimal'
 83                    if uiscale is bui.UIScale.SMALL
 84                    else 'menu_full'
 85                ),
 86                scale=scale,
 87            ),
 88            transition=transition,
 89            origin_widget=origin_widget,
 90            # We're affected by screen size only at small ui-scale.
 91            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 92        )
 93
 94        if uiscale is bui.UIScale.SMALL:
 95            bui.containerwidget(
 96                edit=self._root_widget, on_cancel_call=self.main_window_back
 97            )
 98            self._back_button = None
 99        else:
100            self._back_button = btn = bui.buttonwidget(
101                parent=self._root_widget,
102                autoselect=True,
103                position=(70, self.yoffs - 50),
104                size=(60, 60),
105                scale=1.1,
106                label=bui.charstr(bui.SpecialChar.BACK),
107                button_type='backSmall',
108                on_activate_call=self.main_window_back,
109            )
110            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
111
112        bui.textwidget(
113            parent=self._root_widget,
114            position=(
115                (
116                    self._width * 0.5
117                    + (
118                        (self._scroll_width * -0.5 + 93)
119                        if uiscale is bui.UIScale.SMALL
120                        else 0
121                    )
122                ),
123                self.yoffs - (63 if uiscale is bui.UIScale.SMALL else 10),
124            ),
125            size=(0, 0),
126            color=bui.app.ui_v1.title_color,
127            scale=1.3 if uiscale is bui.UIScale.SMALL else 1.5,
128            h_align='left' if uiscale is bui.UIScale.SMALL else 'center',
129            v_align='center',
130            text=bui.Lstr(resource=f'{self._r}.titleText'),
131            maxwidth=200,
132        )
133
134        tabdefs = [
135            (
136                self.TabID.MY_REPLAYS,
137                bui.Lstr(resource=f'{self._r}.myReplaysText'),
138            ),
139        ]
140
141        tab_bar_width = 200.0 * len(tabdefs)
142        tab_bar_inset = (self._scroll_width - tab_bar_width) * 0.5
143
144        self._tab_row = TabRow(
145            self._root_widget,
146            tabdefs,
147            pos=(
148                self._width * 0.5 - self._scroll_width * 0.5 + tab_bar_inset,
149                self._scroll_y + self._scroll_height - 4.0,
150            ),
151            size=(self._scroll_width - 2.0 * tab_bar_inset, 50),
152            on_select_call=self._set_tab,
153        )
154
155        first_tab = self._tab_row.tabs[tabdefs[0][0]]
156        last_tab = self._tab_row.tabs[tabdefs[-1][0]]
157        bui.widget(
158            edit=last_tab.button,
159            right_widget=bui.get_special_widget('squad_button'),
160        )
161        if uiscale is bui.UIScale.SMALL:
162            bbtn = bui.get_special_widget('back_button')
163            bui.widget(edit=first_tab.button, up_widget=bbtn, left_widget=bbtn)
164
165        # Not actually using a scroll widget anymore; just an image.
166        bui.imagewidget(
167            parent=self._root_widget,
168            size=(self._scroll_width, self._scroll_height),
169            position=(
170                self._width * 0.5 - self._scroll_width * 0.5,
171                self._scroll_y,
172            ),
173            texture=bui.gettexture('scrollWidget'),
174            mesh_transparent=bui.getmesh('softEdgeOutside'),
175            opacity=0.4,
176        )
177        self._tab_container: bui.Widget | None = None
178
179        self._restore_state()
180
181    @override
182    def get_main_window_state(self) -> bui.MainWindowState:
183        # Support recreating our window for back/refresh purposes.
184        cls = type(self)
185        return bui.BasicMainWindowState(
186            create_call=lambda transition, origin_widget: cls(
187                transition=transition, origin_widget=origin_widget
188            )
189        )
190
191    @override
192    def on_main_window_close(self) -> None:
193        self._save_state()
194
195    def _set_tab(self, tab_id: TabID) -> None:
196        # pylint: disable=too-many-locals
197
198        if self._current_tab == tab_id:
199            return
200        self._current_tab = tab_id
201
202        # Preserve our current tab between runs.
203        cfg = bui.app.config
204        cfg['Watch Tab'] = tab_id.value
205        cfg.commit()
206
207        # Update tab colors based on which is selected.
208        # tabs.update_tab_button_colors(self._tab_buttons, tab)
209        self._tab_row.update_appearance(tab_id)
210
211        if self._tab_container:
212            self._tab_container.delete()
213        scroll_left = (self._width - self._scroll_width) * 0.5
214        scroll_bottom = self._scroll_y
215
216        # A place where tabs can store data to get cleared when
217        # switching to a different tab
218        self._tab_data = {}
219
220        assert bui.app.classic is not None
221        uiscale = bui.app.ui_v1.uiscale
222        if tab_id is self.TabID.MY_REPLAYS:
223            c_width = self._scroll_width
224            c_height = self._scroll_height - 20
225            sub_scroll_height = c_height - 63
226            self._my_replays_scroll_width = sub_scroll_width = (
227                680 if uiscale is bui.UIScale.SMALL else 640
228            )
229
230            self._tab_container = cnt = bui.containerwidget(
231                parent=self._root_widget,
232                position=(
233                    scroll_left,
234                    scroll_bottom + (self._scroll_height - c_height) * 0.5,
235                ),
236                size=(c_width, c_height),
237                background=False,
238                selection_loops_to_parent=True,
239            )
240
241            v = c_height - 30
242            bui.textwidget(
243                parent=cnt,
244                position=(c_width * 0.5, v),
245                color=(0.6, 1.0, 0.6),
246                scale=0.7,
247                size=(0, 0),
248                maxwidth=c_width * 0.9,
249                h_align='center',
250                v_align='center',
251                text=bui.Lstr(
252                    resource='replayRenameWarningText',
253                    subs=[
254                        (
255                            '${REPLAY}',
256                            bui.Lstr(resource='replayNameDefaultText'),
257                        )
258                    ],
259                ),
260            )
261
262            b_width = 140 if uiscale is bui.UIScale.SMALL else 178
263            b_height = (
264                110
265                if uiscale is bui.UIScale.SMALL
266                else 142 if uiscale is bui.UIScale.MEDIUM else 180
267            )
268            b_space_extra = (
269                0
270                if uiscale is bui.UIScale.SMALL
271                else -2 if uiscale is bui.UIScale.MEDIUM else -5
272            )
273
274            b_color = (0.6, 0.53, 0.63)
275            b_textcolor = (0.75, 0.7, 0.8)
276            btnv = (
277                c_height
278                - (
279                    40
280                    if uiscale is bui.UIScale.SMALL
281                    else 40 if uiscale is bui.UIScale.MEDIUM else 40
282                )
283                - b_height
284            )
285            # Roughly center buttons and scroll-widget in the middle.
286            xextra = (
287                self._scroll_width - (sub_scroll_width + b_width)
288            ) * 0.5 - 50.0
289            btnh = (40 if uiscale is bui.UIScale.SMALL else 40) + xextra
290            smlh = (190 if uiscale is bui.UIScale.SMALL else 225) + xextra
291            tscl = 1.0 if uiscale is bui.UIScale.SMALL else 1.2
292            self._my_replays_watch_replay_button = btn1 = bui.buttonwidget(
293                parent=cnt,
294                size=(b_width, b_height),
295                position=(btnh, btnv),
296                button_type='square',
297                color=b_color,
298                textcolor=b_textcolor,
299                on_activate_call=self._on_my_replay_play_press,
300                text_scale=tscl,
301                label=bui.Lstr(resource=f'{self._r}.watchReplayButtonText'),
302                autoselect=True,
303            )
304            bui.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button)
305            assert bui.app.classic is not None
306            if uiscale is bui.UIScale.SMALL:
307                bui.widget(
308                    edit=btn1,
309                    left_widget=bui.get_special_widget('back_button'),
310                )
311            btnv -= b_height + b_space_extra
312            bui.buttonwidget(
313                parent=cnt,
314                size=(b_width, b_height),
315                position=(btnh, btnv),
316                button_type='square',
317                color=b_color,
318                textcolor=b_textcolor,
319                on_activate_call=self._on_my_replay_rename_press,
320                text_scale=tscl,
321                label=bui.Lstr(resource=f'{self._r}.renameReplayButtonText'),
322                autoselect=True,
323            )
324            btnv -= b_height + b_space_extra
325            bui.buttonwidget(
326                parent=cnt,
327                size=(b_width, b_height),
328                position=(btnh, btnv),
329                button_type='square',
330                color=b_color,
331                textcolor=b_textcolor,
332                on_activate_call=self._on_my_replay_delete_press,
333                text_scale=tscl,
334                label=bui.Lstr(resource=f'{self._r}.deleteReplayButtonText'),
335                autoselect=True,
336            )
337
338            v -= sub_scroll_height + 23
339            self._scrollwidget = scrlw = bui.scrollwidget(
340                parent=cnt,
341                position=(smlh, v),
342                size=(sub_scroll_width, sub_scroll_height),
343            )
344            bui.containerwidget(edit=cnt, selected_child=scrlw)
345            self._columnwidget = bui.columnwidget(
346                parent=scrlw, left_border=10, border=2, margin=0
347            )
348
349            bui.widget(
350                edit=scrlw,
351                autoselect=True,
352                left_widget=btn1,
353                up_widget=self._tab_row.tabs[tab_id].button,
354            )
355            bui.widget(
356                edit=self._tab_row.tabs[tab_id].button, down_widget=scrlw
357            )
358
359            self._my_replay_selected = None
360            self._refresh_my_replays()
361
362    def _no_replay_selected_error(self) -> None:
363        bui.screenmessage(
364            bui.Lstr(resource=f'{self._r}.noReplaySelectedErrorText'),
365            color=(1, 0, 0),
366        )
367        bui.getsound('error').play()
368
369    def _on_my_replay_play_press(self) -> None:
370        if self._my_replay_selected is None:
371            self._no_replay_selected_error()
372            return
373        bui.increment_analytics_count('Replay watch')
374
375        # Save our place in the UI so we return there when done.
376        if bui.app.classic is not None:
377            bui.app.classic.save_ui_state()
378
379        def do_it() -> None:
380            try:
381                # Reset to normal speed.
382                bs.set_replay_speed_exponent(0)
383                bui.fade_screen(True)
384                assert self._my_replay_selected is not None
385                bs.new_replay_session(
386                    f'{bui.get_replays_dir()}/{self._my_replay_selected}'
387                )
388            except Exception:
389                logging.exception('Error running replay session.')
390
391                # Drop back into a fresh main menu session
392                # in case we half-launched or something.
393                from bascenev1lib import mainmenu
394
395                bs.new_host_session(mainmenu.MainMenuSession)
396
397        bui.fade_screen(False, endcall=bui.Call(bui.pushcall, do_it))
398        bui.containerwidget(edit=self._root_widget, transition='out_left')
399
400    def _on_my_replay_rename_press(self) -> None:
401        if self._my_replay_selected is None:
402            self._no_replay_selected_error()
403            return
404        c_width = 600
405        c_height = 250
406        assert bui.app.classic is not None
407        uiscale = bui.app.ui_v1.uiscale
408        self._my_replays_rename_window = cnt = bui.containerwidget(
409            scale=(
410                1.8
411                if uiscale is bui.UIScale.SMALL
412                else 1.55 if uiscale is bui.UIScale.MEDIUM else 1.0
413            ),
414            size=(c_width, c_height),
415            transition='in_scale',
416            parent=bui.get_special_widget('overlay_stack'),
417        )
418        dname = self._get_replay_display_name(self._my_replay_selected)
419        bui.textwidget(
420            parent=cnt,
421            size=(0, 0),
422            h_align='center',
423            v_align='center',
424            text=bui.Lstr(
425                resource=f'{self._r}.renameReplayText',
426                subs=[('${REPLAY}', dname)],
427            ),
428            maxwidth=c_width * 0.8,
429            position=(c_width * 0.5, c_height - 60),
430        )
431        self._my_replay_rename_text = txt = bui.textwidget(
432            parent=cnt,
433            size=(c_width * 0.8, 40),
434            h_align='left',
435            v_align='center',
436            text=dname,
437            editable=True,
438            description=bui.Lstr(resource=f'{self._r}.replayNameText'),
439            position=(c_width * 0.1, c_height - 140),
440            autoselect=True,
441            maxwidth=c_width * 0.7,
442            max_chars=200,
443        )
444        cbtn = bui.buttonwidget(
445            parent=cnt,
446            label=bui.Lstr(resource='cancelText'),
447            on_activate_call=bui.Call(
448                lambda c: bui.containerwidget(edit=c, transition='out_scale'),
449                cnt,
450            ),
451            size=(180, 60),
452            position=(30, 30),
453            autoselect=True,
454        )
455        okb = bui.buttonwidget(
456            parent=cnt,
457            label=bui.Lstr(resource=f'{self._r}.renameText'),
458            size=(180, 60),
459            position=(c_width - 230, 30),
460            on_activate_call=bui.Call(
461                self._rename_my_replay, self._my_replay_selected
462            ),
463            autoselect=True,
464        )
465        bui.widget(edit=cbtn, right_widget=okb)
466        bui.widget(edit=okb, left_widget=cbtn)
467        bui.textwidget(edit=txt, on_return_press_call=okb.activate)
468        bui.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb)
469
470    def _rename_my_replay(self, replay: str) -> None:
471        new_name = None
472        try:
473            if not self._my_replay_rename_text:
474                return
475            new_name_raw = cast(
476                str, bui.textwidget(query=self._my_replay_rename_text)
477            )
478            new_name = new_name_raw + '.brp'
479
480            # Ignore attempts to change it to what it already is
481            # (or what it looks like to the user).
482            if (
483                replay != new_name
484                and self._get_replay_display_name(replay) != new_name_raw
485            ):
486                old_name_full = (bui.get_replays_dir() + '/' + replay).encode(
487                    'utf-8'
488                )
489                new_name_full = (bui.get_replays_dir() + '/' + new_name).encode(
490                    'utf-8'
491                )
492                # False alarm; bui.textwidget can return non-None val.
493                # pylint: disable=unsupported-membership-test
494                if os.path.exists(new_name_full):
495                    bui.getsound('error').play()
496                    bui.screenmessage(
497                        bui.Lstr(
498                            resource=self._r
499                            + '.replayRenameErrorAlreadyExistsText'
500                        ),
501                        color=(1, 0, 0),
502                    )
503                elif any(char in new_name_raw for char in ['/', '\\', ':']):
504                    bui.getsound('error').play()
505                    bui.screenmessage(
506                        bui.Lstr(
507                            resource=f'{self._r}.replayRenameErrorInvalidName'
508                        ),
509                        color=(1, 0, 0),
510                    )
511                else:
512                    bui.increment_analytics_count('Replay rename')
513                    os.rename(old_name_full, new_name_full)
514                    self._refresh_my_replays()
515                    bui.getsound('gunCocking').play()
516        except Exception:
517            logging.exception(
518                "Error renaming replay '%s' to '%s'.", replay, new_name
519            )
520            bui.getsound('error').play()
521            bui.screenmessage(
522                bui.Lstr(resource=f'{self._r}.replayRenameErrorText'),
523                color=(1, 0, 0),
524            )
525
526        bui.containerwidget(
527            edit=self._my_replays_rename_window, transition='out_scale'
528        )
529
530    def _on_my_replay_delete_press(self) -> None:
531        from bauiv1lib import confirm
532
533        if self._my_replay_selected is None:
534            self._no_replay_selected_error()
535            return
536        confirm.ConfirmWindow(
537            bui.Lstr(
538                resource=f'{self._r}.deleteConfirmText',
539                subs=[
540                    (
541                        '${REPLAY}',
542                        self._get_replay_display_name(self._my_replay_selected),
543                    )
544                ],
545            ),
546            bui.Call(self._delete_replay, self._my_replay_selected),
547            450,
548            150,
549        )
550
551    def _get_replay_display_name(self, replay: str) -> str:
552        if replay.endswith('.brp'):
553            replay = replay[:-4]
554        if replay == '__lastReplay':
555            return bui.Lstr(resource='replayNameDefaultText').evaluate()
556        return replay
557
558    def _delete_replay(self, replay: str) -> None:
559        try:
560            bui.increment_analytics_count('Replay delete')
561            os.remove((bui.get_replays_dir() + '/' + replay).encode('utf-8'))
562            self._refresh_my_replays()
563            bui.getsound('shieldDown').play()
564            if replay == self._my_replay_selected:
565                self._my_replay_selected = None
566        except Exception:
567            logging.exception("Error deleting replay '%s'.", replay)
568            bui.getsound('error').play()
569            bui.screenmessage(
570                bui.Lstr(resource=f'{self._r}.replayDeleteErrorText'),
571                color=(1, 0, 0),
572            )
573
574    def _on_my_replay_select(self, replay: str) -> None:
575        self._my_replay_selected = replay
576
577    def _refresh_my_replays(self) -> None:
578        assert self._columnwidget is not None
579        for child in self._columnwidget.get_children():
580            child.delete()
581        t_scale = 1.6
582        try:
583            names = os.listdir(bui.get_replays_dir())
584
585            # Ignore random other files in there.
586            names = [n for n in names if n.endswith('.brp')]
587            names.sort(key=lambda x: x.lower())
588        except Exception:
589            logging.exception('Error listing replays dir.')
590            names = []
591
592        assert self._my_replays_scroll_width is not None
593        assert self._my_replays_watch_replay_button is not None
594        for i, name in enumerate(names):
595            txt = bui.textwidget(
596                parent=self._columnwidget,
597                size=(self._my_replays_scroll_width / t_scale, 30),
598                selectable=True,
599                color=(
600                    (1.0, 1, 0.4) if name == '__lastReplay.brp' else (1, 1, 1)
601                ),
602                always_highlight=True,
603                on_select_call=bui.Call(self._on_my_replay_select, name),
604                on_activate_call=self._my_replays_watch_replay_button.activate,
605                text=self._get_replay_display_name(name),
606                h_align='left',
607                v_align='center',
608                corner_scale=t_scale,
609                maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93,
610            )
611            if i == 0:
612                bui.widget(
613                    edit=txt,
614                    up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button,
615                )
616                self._my_replay_selected = name
617
618    def _save_state(self) -> None:
619        try:
620            sel = self._root_widget.get_selected_child()
621            selected_tab_ids = [
622                tab_id
623                for tab_id, tab in self._tab_row.tabs.items()
624                if sel == tab.button
625            ]
626            if sel == self._back_button:
627                sel_name = 'Back'
628            elif selected_tab_ids:
629                assert len(selected_tab_ids) == 1
630                sel_name = f'Tab:{selected_tab_ids[0].value}'
631            elif sel == self._tab_container:
632                sel_name = 'TabContainer'
633            else:
634                raise ValueError(f'unrecognized selection {sel}')
635            assert bui.app.classic is not None
636            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
637        except Exception:
638            logging.exception('Error saving state for %s.', self)
639
640    def _restore_state(self) -> None:
641        try:
642            sel: bui.Widget | None
643            assert bui.app.classic is not None
644            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
645                'sel_name'
646            )
647            assert isinstance(sel_name, (str, type(None)))
648            try:
649                current_tab = self.TabID(bui.app.config.get('Watch Tab'))
650            except ValueError:
651                current_tab = self.TabID.MY_REPLAYS
652            self._set_tab(current_tab)
653
654            if sel_name == 'Back':
655                sel = self._back_button
656            elif sel_name == 'TabContainer':
657                sel = self._tab_container
658            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
659                try:
660                    sel_tab_id = self.TabID(sel_name.split(':')[-1])
661                except ValueError:
662                    sel_tab_id = self.TabID.MY_REPLAYS
663                sel = self._tab_row.tabs[sel_tab_id].button
664            else:
665                if self._tab_container is not None:
666                    sel = self._tab_container
667                else:
668                    sel = self._tab_row.tabs[current_tab].button
669            bui.containerwidget(edit=self._root_widget, selected_child=sel)
670        except Exception:
671            logging.exception('Error restoring state for %s.', self)

Window for watching replays.

WatchWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 29    def __init__(
 30        self,
 31        transition: str | None = 'in_right',
 32        origin_widget: bui.Widget | None = None,
 33    ):
 34        # pylint: disable=too-many-locals
 35        from bauiv1lib.tabs import TabRow
 36
 37        bui.set_analytics_screen('Watch Window')
 38        self._tab_data: dict[str, Any] = {}
 39        self._my_replays_scroll_width: float | None = None
 40        self._my_replays_watch_replay_button: bui.Widget | None = None
 41        self._scrollwidget: bui.Widget | None = None
 42        self._columnwidget: bui.Widget | None = None
 43        self._my_replay_selected: str | None = None
 44        self._my_replays_rename_window: bui.Widget | None = None
 45        self._my_replay_rename_text: bui.Widget | None = None
 46        self._r = 'watchWindow'
 47        uiscale = bui.app.ui_v1.uiscale
 48        self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040
 49        self._height = (
 50            900
 51            if uiscale is bui.UIScale.SMALL
 52            else 670 if uiscale is bui.UIScale.MEDIUM else 800
 53        )
 54        self._current_tab: WatchWindow.TabID | None = None
 55
 56        # Do some fancy math to fill all available screen area up to the
 57        # size of our backing container. This lets us fit to the exact
 58        # screen shape at small ui scale.
 59        screensize = bui.get_virtual_screen_size()
 60        scale = (
 61            1.5
 62            if uiscale is bui.UIScale.SMALL
 63            else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65
 64        )
 65        # Calc screen size in our local container space and clamp to a
 66        # bit smaller than our container size.
 67        target_width = min(self._width - 120, screensize[0] / scale)
 68        target_height = min(self._height - 120, screensize[1] / scale)
 69
 70        # To get top/left coords, go to the center of our window and
 71        # offset by half the width/height of our target area.
 72        self.yoffs = 0.5 * self._height + 0.5 * target_height + 30.0
 73
 74        self._scroll_width = target_width
 75        self._scroll_height = target_height - 55
 76        self._scroll_y = self.yoffs - 85 - self._scroll_height
 77
 78        super().__init__(
 79            root_widget=bui.containerwidget(
 80                size=(self._width, self._height),
 81                toolbar_visibility=(
 82                    'menu_minimal'
 83                    if uiscale is bui.UIScale.SMALL
 84                    else 'menu_full'
 85                ),
 86                scale=scale,
 87            ),
 88            transition=transition,
 89            origin_widget=origin_widget,
 90            # We're affected by screen size only at small ui-scale.
 91            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 92        )
 93
 94        if uiscale is bui.UIScale.SMALL:
 95            bui.containerwidget(
 96                edit=self._root_widget, on_cancel_call=self.main_window_back
 97            )
 98            self._back_button = None
 99        else:
100            self._back_button = btn = bui.buttonwidget(
101                parent=self._root_widget,
102                autoselect=True,
103                position=(70, self.yoffs - 50),
104                size=(60, 60),
105                scale=1.1,
106                label=bui.charstr(bui.SpecialChar.BACK),
107                button_type='backSmall',
108                on_activate_call=self.main_window_back,
109            )
110            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
111
112        bui.textwidget(
113            parent=self._root_widget,
114            position=(
115                (
116                    self._width * 0.5
117                    + (
118                        (self._scroll_width * -0.5 + 93)
119                        if uiscale is bui.UIScale.SMALL
120                        else 0
121                    )
122                ),
123                self.yoffs - (63 if uiscale is bui.UIScale.SMALL else 10),
124            ),
125            size=(0, 0),
126            color=bui.app.ui_v1.title_color,
127            scale=1.3 if uiscale is bui.UIScale.SMALL else 1.5,
128            h_align='left' if uiscale is bui.UIScale.SMALL else 'center',
129            v_align='center',
130            text=bui.Lstr(resource=f'{self._r}.titleText'),
131            maxwidth=200,
132        )
133
134        tabdefs = [
135            (
136                self.TabID.MY_REPLAYS,
137                bui.Lstr(resource=f'{self._r}.myReplaysText'),
138            ),
139        ]
140
141        tab_bar_width = 200.0 * len(tabdefs)
142        tab_bar_inset = (self._scroll_width - tab_bar_width) * 0.5
143
144        self._tab_row = TabRow(
145            self._root_widget,
146            tabdefs,
147            pos=(
148                self._width * 0.5 - self._scroll_width * 0.5 + tab_bar_inset,
149                self._scroll_y + self._scroll_height - 4.0,
150            ),
151            size=(self._scroll_width - 2.0 * tab_bar_inset, 50),
152            on_select_call=self._set_tab,
153        )
154
155        first_tab = self._tab_row.tabs[tabdefs[0][0]]
156        last_tab = self._tab_row.tabs[tabdefs[-1][0]]
157        bui.widget(
158            edit=last_tab.button,
159            right_widget=bui.get_special_widget('squad_button'),
160        )
161        if uiscale is bui.UIScale.SMALL:
162            bbtn = bui.get_special_widget('back_button')
163            bui.widget(edit=first_tab.button, up_widget=bbtn, left_widget=bbtn)
164
165        # Not actually using a scroll widget anymore; just an image.
166        bui.imagewidget(
167            parent=self._root_widget,
168            size=(self._scroll_width, self._scroll_height),
169            position=(
170                self._width * 0.5 - self._scroll_width * 0.5,
171                self._scroll_y,
172            ),
173            texture=bui.gettexture('scrollWidget'),
174            mesh_transparent=bui.getmesh('softEdgeOutside'),
175            opacity=0.4,
176        )
177        self._tab_container: bui.Widget | None = None
178
179        self._restore_state()

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.

yoffs
@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
181    @override
182    def get_main_window_state(self) -> bui.MainWindowState:
183        # Support recreating our window for back/refresh purposes.
184        cls = type(self)
185        return bui.BasicMainWindowState(
186            create_call=lambda transition, origin_widget: cls(
187                transition=transition, origin_widget=origin_widget
188            )
189        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
191    @override
192    def on_main_window_close(self) -> None:
193        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

class WatchWindow.TabID(enum.Enum):
23    class TabID(Enum):
24        """Our available tab types."""
25
26        MY_REPLAYS = 'my_replays'
27        TEST_TAB = 'test_tab'

Our available tab types.

MY_REPLAYS = <TabID.MY_REPLAYS: 'my_replays'>
TEST_TAB = <TabID.TEST_TAB: 'test_tab'>