bastd.ui.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
  8from enum import Enum
  9from typing import TYPE_CHECKING, cast
 10
 11import _ba
 12import ba
 13
 14if TYPE_CHECKING:
 15    from typing import Any
 16
 17
 18class WatchWindow(ba.Window):
 19    """Window for watching replays."""
 20
 21    class TabID(Enum):
 22        """Our available tab types."""
 23        MY_REPLAYS = 'my_replays'
 24        TEST_TAB = 'test_tab'
 25
 26    def __init__(self,
 27                 transition: str | None = 'in_right',
 28                 origin_widget: ba.Widget | None = None):
 29        # pylint: disable=too-many-locals
 30        # pylint: disable=too-many-statements
 31        from bastd.ui.tabs import TabRow
 32        ba.set_analytics_screen('Watch Window')
 33        scale_origin: tuple[float, float] | None
 34        if origin_widget is not None:
 35            self._transition_out = 'out_scale'
 36            scale_origin = origin_widget.get_screen_space_center()
 37            transition = 'in_scale'
 38        else:
 39            self._transition_out = 'out_right'
 40            scale_origin = None
 41        ba.app.ui.set_main_menu_location('Watch')
 42        self._tab_data: dict[str, Any] = {}
 43        self._my_replays_scroll_width: float | None = None
 44        self._my_replays_watch_replay_button: ba.Widget | None = None
 45        self._scrollwidget: ba.Widget | None = None
 46        self._columnwidget: ba.Widget | None = None
 47        self._my_replay_selected: str | None = None
 48        self._my_replays_rename_window: ba.Widget | None = None
 49        self._my_replay_rename_text: ba.Widget | None = None
 50        self._r = 'watchWindow'
 51        uiscale = ba.app.ui.uiscale
 52        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
 53        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 54        self._height = (578 if uiscale is ba.UIScale.SMALL else
 55                        670 if uiscale is ba.UIScale.MEDIUM else 800)
 56        self._current_tab: WatchWindow.TabID | None = None
 57        extra_top = 20 if uiscale is ba.UIScale.SMALL else 0
 58
 59        super().__init__(root_widget=ba.containerwidget(
 60            size=(self._width, self._height + extra_top),
 61            transition=transition,
 62            toolbar_visibility='menu_minimal',
 63            scale_origin_stack_offset=scale_origin,
 64            scale=(1.3 if uiscale is ba.UIScale.SMALL else
 65                   0.97 if uiscale is ba.UIScale.MEDIUM else 0.8),
 66            stack_offset=(0, -10) if uiscale is ba.UIScale.SMALL else (
 67                0, 15) if uiscale is ba.UIScale.MEDIUM else (0, 0)))
 68
 69        if uiscale is ba.UIScale.SMALL and ba.app.ui.use_toolbars:
 70            ba.containerwidget(edit=self._root_widget,
 71                               on_cancel_call=self._back)
 72            self._back_button = None
 73        else:
 74            self._back_button = btn = ba.buttonwidget(
 75                parent=self._root_widget,
 76                autoselect=True,
 77                position=(70 + x_inset, self._height - 74),
 78                size=(140, 60),
 79                scale=1.1,
 80                label=ba.Lstr(resource='backText'),
 81                button_type='back',
 82                on_activate_call=self._back)
 83            ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 84            ba.buttonwidget(edit=btn,
 85                            button_type='backSmall',
 86                            size=(60, 60),
 87                            label=ba.charstr(ba.SpecialChar.BACK))
 88
 89        ba.textwidget(parent=self._root_widget,
 90                      position=(self._width * 0.5, self._height - 38),
 91                      size=(0, 0),
 92                      color=ba.app.ui.title_color,
 93                      scale=1.5,
 94                      h_align='center',
 95                      v_align='center',
 96                      text=ba.Lstr(resource=self._r + '.titleText'),
 97                      maxwidth=400)
 98
 99        tabdefs = [
100            (self.TabID.MY_REPLAYS,
101             ba.Lstr(resource=self._r + '.myReplaysText')),
102            # (self.TabID.TEST_TAB, ba.Lstr(value='Testing')),
103        ]
104
105        scroll_buffer_h = 130 + 2 * x_inset
106        tab_buffer_h = 750 + 2 * x_inset
107
108        self._tab_row = TabRow(self._root_widget,
109                               tabdefs,
110                               pos=(tab_buffer_h * 0.5, self._height - 130),
111                               size=(self._width - tab_buffer_h, 50),
112                               on_select_call=self._set_tab)
113
114        if ba.app.ui.use_toolbars:
115            first_tab = self._tab_row.tabs[tabdefs[0][0]]
116            last_tab = self._tab_row.tabs[tabdefs[-1][0]]
117            ba.widget(edit=last_tab.button,
118                      right_widget=_ba.get_special_widget('party_button'))
119            if uiscale is ba.UIScale.SMALL:
120                bbtn = _ba.get_special_widget('back_button')
121                ba.widget(edit=first_tab.button,
122                          up_widget=bbtn,
123                          left_widget=bbtn)
124
125        self._scroll_width = self._width - scroll_buffer_h
126        self._scroll_height = self._height - 180
127
128        # Not actually using a scroll widget anymore; just an image.
129        scroll_left = (self._width - self._scroll_width) * 0.5
130        scroll_bottom = self._height - self._scroll_height - 79 - 48
131        buffer_h = 10
132        buffer_v = 4
133        ba.imagewidget(parent=self._root_widget,
134                       position=(scroll_left - buffer_h,
135                                 scroll_bottom - buffer_v),
136                       size=(self._scroll_width + 2 * buffer_h,
137                             self._scroll_height + 2 * buffer_v),
138                       texture=ba.gettexture('scrollWidget'),
139                       model_transparent=ba.getmodel('softEdgeOutside'))
140        self._tab_container: ba.Widget | None = None
141
142        self._restore_state()
143
144    def _set_tab(self, tab_id: TabID) -> None:
145        # pylint: disable=too-many-locals
146
147        if self._current_tab == tab_id:
148            return
149        self._current_tab = tab_id
150
151        # Preserve our current tab between runs.
152        cfg = ba.app.config
153        cfg['Watch Tab'] = tab_id.value
154        cfg.commit()
155
156        # Update tab colors based on which is selected.
157        # tabs.update_tab_button_colors(self._tab_buttons, tab)
158        self._tab_row.update_appearance(tab_id)
159
160        if self._tab_container:
161            self._tab_container.delete()
162        scroll_left = (self._width - self._scroll_width) * 0.5
163        scroll_bottom = self._height - self._scroll_height - 79 - 48
164
165        # A place where tabs can store data to get cleared when
166        # switching to a different tab
167        self._tab_data = {}
168
169        uiscale = ba.app.ui.uiscale
170        if tab_id is self.TabID.MY_REPLAYS:
171            c_width = self._scroll_width
172            c_height = self._scroll_height - 20
173            sub_scroll_height = c_height - 63
174            self._my_replays_scroll_width = sub_scroll_width = (
175                680 if uiscale is ba.UIScale.SMALL else 640)
176
177            self._tab_container = cnt = ba.containerwidget(
178                parent=self._root_widget,
179                position=(scroll_left, scroll_bottom +
180                          (self._scroll_height - c_height) * 0.5),
181                size=(c_width, c_height),
182                background=False,
183                selection_loops_to_parent=True)
184
185            v = c_height - 30
186            ba.textwidget(parent=cnt,
187                          position=(c_width * 0.5, v),
188                          color=(0.6, 1.0, 0.6),
189                          scale=0.7,
190                          size=(0, 0),
191                          maxwidth=c_width * 0.9,
192                          h_align='center',
193                          v_align='center',
194                          text=ba.Lstr(
195                              resource='replayRenameWarningText',
196                              subs=[('${REPLAY}',
197                                     ba.Lstr(resource='replayNameDefaultText'))
198                                    ]))
199
200            b_width = 140 if uiscale is ba.UIScale.SMALL else 178
201            b_height = (107 if uiscale is ba.UIScale.SMALL else
202                        142 if uiscale is ba.UIScale.MEDIUM else 190)
203            b_space_extra = (0 if uiscale is ba.UIScale.SMALL else
204                             -2 if uiscale is ba.UIScale.MEDIUM else -5)
205
206            b_color = (0.6, 0.53, 0.63)
207            b_textcolor = (0.75, 0.7, 0.8)
208            btnv = (c_height - (48 if uiscale is ba.UIScale.SMALL else
209                                45 if uiscale is ba.UIScale.MEDIUM else 40) -
210                    b_height)
211            btnh = 40 if uiscale is ba.UIScale.SMALL else 40
212            smlh = 190 if uiscale is ba.UIScale.SMALL else 225
213            tscl = 1.0 if uiscale is ba.UIScale.SMALL else 1.2
214            self._my_replays_watch_replay_button = btn1 = ba.buttonwidget(
215                parent=cnt,
216                size=(b_width, b_height),
217                position=(btnh, btnv),
218                button_type='square',
219                color=b_color,
220                textcolor=b_textcolor,
221                on_activate_call=self._on_my_replay_play_press,
222                text_scale=tscl,
223                label=ba.Lstr(resource=self._r + '.watchReplayButtonText'),
224                autoselect=True)
225            ba.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button)
226            if uiscale is ba.UIScale.SMALL and ba.app.ui.use_toolbars:
227                ba.widget(edit=btn1,
228                          left_widget=_ba.get_special_widget('back_button'))
229            btnv -= b_height + b_space_extra
230            ba.buttonwidget(parent=cnt,
231                            size=(b_width, b_height),
232                            position=(btnh, btnv),
233                            button_type='square',
234                            color=b_color,
235                            textcolor=b_textcolor,
236                            on_activate_call=self._on_my_replay_rename_press,
237                            text_scale=tscl,
238                            label=ba.Lstr(resource=self._r +
239                                          '.renameReplayButtonText'),
240                            autoselect=True)
241            btnv -= b_height + b_space_extra
242            ba.buttonwidget(parent=cnt,
243                            size=(b_width, b_height),
244                            position=(btnh, btnv),
245                            button_type='square',
246                            color=b_color,
247                            textcolor=b_textcolor,
248                            on_activate_call=self._on_my_replay_delete_press,
249                            text_scale=tscl,
250                            label=ba.Lstr(resource=self._r +
251                                          '.deleteReplayButtonText'),
252                            autoselect=True)
253
254            v -= sub_scroll_height + 23
255            self._scrollwidget = scrlw = ba.scrollwidget(
256                parent=cnt,
257                position=(smlh, v),
258                size=(sub_scroll_width, sub_scroll_height))
259            ba.containerwidget(edit=cnt, selected_child=scrlw)
260            self._columnwidget = ba.columnwidget(parent=scrlw,
261                                                 left_border=10,
262                                                 border=2,
263                                                 margin=0)
264
265            ba.widget(edit=scrlw,
266                      autoselect=True,
267                      left_widget=btn1,
268                      up_widget=self._tab_row.tabs[tab_id].button)
269            ba.widget(edit=self._tab_row.tabs[tab_id].button,
270                      down_widget=scrlw)
271
272            self._my_replay_selected = None
273            self._refresh_my_replays()
274
275    def _no_replay_selected_error(self) -> None:
276        ba.screenmessage(ba.Lstr(resource=self._r +
277                                 '.noReplaySelectedErrorText'),
278                         color=(1, 0, 0))
279        ba.playsound(ba.getsound('error'))
280
281    def _on_my_replay_play_press(self) -> None:
282        if self._my_replay_selected is None:
283            self._no_replay_selected_error()
284            return
285        _ba.increment_analytics_count('Replay watch')
286
287        def do_it() -> None:
288            try:
289                # Reset to normal speed.
290                _ba.set_replay_speed_exponent(0)
291                _ba.fade_screen(True)
292                assert self._my_replay_selected is not None
293                _ba.new_replay_session(_ba.get_replays_dir() + '/' +
294                                       self._my_replay_selected)
295            except Exception:
296                ba.print_exception('Error running replay session.')
297
298                # Drop back into a fresh main menu session
299                # in case we half-launched or something.
300                from bastd import mainmenu
301                _ba.new_host_session(mainmenu.MainMenuSession)
302
303        _ba.fade_screen(False, endcall=ba.Call(ba.pushcall, do_it))
304        ba.containerwidget(edit=self._root_widget, transition='out_left')
305
306    def _on_my_replay_rename_press(self) -> None:
307        if self._my_replay_selected is None:
308            self._no_replay_selected_error()
309            return
310        c_width = 600
311        c_height = 250
312        uiscale = ba.app.ui.uiscale
313        self._my_replays_rename_window = cnt = ba.containerwidget(
314            scale=(1.8 if uiscale is ba.UIScale.SMALL else
315                   1.55 if uiscale is ba.UIScale.MEDIUM else 1.0),
316            size=(c_width, c_height),
317            transition='in_scale')
318        dname = self._get_replay_display_name(self._my_replay_selected)
319        ba.textwidget(parent=cnt,
320                      size=(0, 0),
321                      h_align='center',
322                      v_align='center',
323                      text=ba.Lstr(resource=self._r + '.renameReplayText',
324                                   subs=[('${REPLAY}', dname)]),
325                      maxwidth=c_width * 0.8,
326                      position=(c_width * 0.5, c_height - 60))
327        self._my_replay_rename_text = txt = ba.textwidget(
328            parent=cnt,
329            size=(c_width * 0.8, 40),
330            h_align='left',
331            v_align='center',
332            text=dname,
333            editable=True,
334            description=ba.Lstr(resource=self._r + '.replayNameText'),
335            position=(c_width * 0.1, c_height - 140),
336            autoselect=True,
337            maxwidth=c_width * 0.7,
338            max_chars=200)
339        cbtn = ba.buttonwidget(
340            parent=cnt,
341            label=ba.Lstr(resource='cancelText'),
342            on_activate_call=ba.Call(
343                lambda c: ba.containerwidget(edit=c, transition='out_scale'),
344                cnt),
345            size=(180, 60),
346            position=(30, 30),
347            autoselect=True)
348        okb = ba.buttonwidget(parent=cnt,
349                              label=ba.Lstr(resource=self._r + '.renameText'),
350                              size=(180, 60),
351                              position=(c_width - 230, 30),
352                              on_activate_call=ba.Call(
353                                  self._rename_my_replay,
354                                  self._my_replay_selected),
355                              autoselect=True)
356        ba.widget(edit=cbtn, right_widget=okb)
357        ba.widget(edit=okb, left_widget=cbtn)
358        ba.textwidget(edit=txt, on_return_press_call=okb.activate)
359        ba.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb)
360
361    def _rename_my_replay(self, replay: str) -> None:
362        new_name = None
363        try:
364            if not self._my_replay_rename_text:
365                return
366            new_name_raw = cast(
367                str, ba.textwidget(query=self._my_replay_rename_text))
368            new_name = new_name_raw + '.brp'
369
370            # Ignore attempts to change it to what it already is
371            # (or what it looks like to the user).
372            if (replay != new_name
373                    and self._get_replay_display_name(replay) != new_name_raw):
374                old_name_full = (_ba.get_replays_dir() + '/' +
375                                 replay).encode('utf-8')
376                new_name_full = (_ba.get_replays_dir() + '/' +
377                                 new_name).encode('utf-8')
378                # False alarm; ba.textwidget can return non-None val.
379                # pylint: disable=unsupported-membership-test
380                if os.path.exists(new_name_full):
381                    ba.playsound(ba.getsound('error'))
382                    ba.screenmessage(
383                        ba.Lstr(resource=self._r +
384                                '.replayRenameErrorAlreadyExistsText'),
385                        color=(1, 0, 0))
386                elif any(char in new_name_raw for char in ['/', '\\', ':']):
387                    ba.playsound(ba.getsound('error'))
388                    ba.screenmessage(ba.Lstr(resource=self._r +
389                                             '.replayRenameErrorInvalidName'),
390                                     color=(1, 0, 0))
391                else:
392                    _ba.increment_analytics_count('Replay rename')
393                    os.rename(old_name_full, new_name_full)
394                    self._refresh_my_replays()
395                    ba.playsound(ba.getsound('gunCocking'))
396        except Exception:
397            ba.print_exception(
398                f"Error renaming replay '{replay}' to '{new_name}'.")
399            ba.playsound(ba.getsound('error'))
400            ba.screenmessage(
401                ba.Lstr(resource=self._r + '.replayRenameErrorText'),
402                color=(1, 0, 0),
403            )
404
405        ba.containerwidget(edit=self._my_replays_rename_window,
406                           transition='out_scale')
407
408    def _on_my_replay_delete_press(self) -> None:
409        from bastd.ui import confirm
410        if self._my_replay_selected is None:
411            self._no_replay_selected_error()
412            return
413        confirm.ConfirmWindow(
414            ba.Lstr(resource=self._r + '.deleteConfirmText',
415                    subs=[('${REPLAY}',
416                           self._get_replay_display_name(
417                               self._my_replay_selected))]),
418            ba.Call(self._delete_replay, self._my_replay_selected), 450, 150)
419
420    def _get_replay_display_name(self, replay: str) -> str:
421        if replay.endswith('.brp'):
422            replay = replay[:-4]
423        if replay == '__lastReplay':
424            return ba.Lstr(resource='replayNameDefaultText').evaluate()
425        return replay
426
427    def _delete_replay(self, replay: str) -> None:
428        try:
429            _ba.increment_analytics_count('Replay delete')
430            os.remove((_ba.get_replays_dir() + '/' + replay).encode('utf-8'))
431            self._refresh_my_replays()
432            ba.playsound(ba.getsound('shieldDown'))
433            if replay == self._my_replay_selected:
434                self._my_replay_selected = None
435        except Exception:
436            ba.print_exception(f"Error deleting replay '{replay}'.")
437            ba.playsound(ba.getsound('error'))
438            ba.screenmessage(
439                ba.Lstr(resource=self._r + '.replayDeleteErrorText'),
440                color=(1, 0, 0),
441            )
442
443    def _on_my_replay_select(self, replay: str) -> None:
444        self._my_replay_selected = replay
445
446    def _refresh_my_replays(self) -> None:
447        assert self._columnwidget is not None
448        for child in self._columnwidget.get_children():
449            child.delete()
450        t_scale = 1.6
451        try:
452            names = os.listdir(_ba.get_replays_dir())
453
454            # Ignore random other files in there.
455            names = [n for n in names if n.endswith('.brp')]
456            names.sort(key=lambda x: x.lower())
457        except Exception:
458            ba.print_exception('Error listing replays dir.')
459            names = []
460
461        assert self._my_replays_scroll_width is not None
462        assert self._my_replays_watch_replay_button is not None
463        for i, name in enumerate(names):
464            txt = ba.textwidget(
465                parent=self._columnwidget,
466                size=(self._my_replays_scroll_width / t_scale, 30),
467                selectable=True,
468                color=(1.0, 1, 0.4) if name == '__lastReplay.brp' else
469                (1, 1, 1),
470                always_highlight=True,
471                on_select_call=ba.Call(self._on_my_replay_select, name),
472                on_activate_call=self._my_replays_watch_replay_button.activate,
473                text=self._get_replay_display_name(name),
474                h_align='left',
475                v_align='center',
476                corner_scale=t_scale,
477                maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93)
478            if i == 0:
479                ba.widget(
480                    edit=txt,
481                    up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button)
482
483    def _save_state(self) -> None:
484        try:
485            sel = self._root_widget.get_selected_child()
486            selected_tab_ids = [
487                tab_id for tab_id, tab in self._tab_row.tabs.items()
488                if sel == tab.button
489            ]
490            if sel == self._back_button:
491                sel_name = 'Back'
492            elif selected_tab_ids:
493                assert len(selected_tab_ids) == 1
494                sel_name = f'Tab:{selected_tab_ids[0].value}'
495            elif sel == self._tab_container:
496                sel_name = 'TabContainer'
497            else:
498                raise ValueError(f'unrecognized selection {sel}')
499            ba.app.ui.window_states[type(self)] = {'sel_name': sel_name}
500        except Exception:
501            ba.print_exception(f'Error saving state for {self}.')
502
503    def _restore_state(self) -> None:
504        from efro.util import enum_by_value
505        try:
506            sel: ba.Widget | None
507            sel_name = ba.app.ui.window_states.get(type(self),
508                                                   {}).get('sel_name')
509            assert isinstance(sel_name, (str, type(None)))
510            try:
511                current_tab = enum_by_value(self.TabID,
512                                            ba.app.config.get('Watch Tab'))
513            except ValueError:
514                current_tab = self.TabID.MY_REPLAYS
515            self._set_tab(current_tab)
516
517            if sel_name == 'Back':
518                sel = self._back_button
519            elif sel_name == 'TabContainer':
520                sel = self._tab_container
521            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
522                try:
523                    sel_tab_id = enum_by_value(self.TabID,
524                                               sel_name.split(':')[-1])
525                except ValueError:
526                    sel_tab_id = self.TabID.MY_REPLAYS
527                sel = self._tab_row.tabs[sel_tab_id].button
528            else:
529                if self._tab_container is not None:
530                    sel = self._tab_container
531                else:
532                    sel = self._tab_row.tabs[current_tab].button
533            ba.containerwidget(edit=self._root_widget, selected_child=sel)
534        except Exception:
535            ba.print_exception(f'Error restoring state for {self}.')
536
537    def _back(self) -> None:
538        from bastd.ui.mainmenu import MainMenuWindow
539        self._save_state()
540        ba.containerwidget(edit=self._root_widget,
541                           transition=self._transition_out)
542        ba.app.ui.set_main_menu_window(
543            MainMenuWindow(transition='in_left').get_root_widget())
class WatchWindow(ba.ui.Window):
 19class WatchWindow(ba.Window):
 20    """Window for watching replays."""
 21
 22    class TabID(Enum):
 23        """Our available tab types."""
 24        MY_REPLAYS = 'my_replays'
 25        TEST_TAB = 'test_tab'
 26
 27    def __init__(self,
 28                 transition: str | None = 'in_right',
 29                 origin_widget: ba.Widget | None = None):
 30        # pylint: disable=too-many-locals
 31        # pylint: disable=too-many-statements
 32        from bastd.ui.tabs import TabRow
 33        ba.set_analytics_screen('Watch Window')
 34        scale_origin: tuple[float, float] | None
 35        if origin_widget is not None:
 36            self._transition_out = 'out_scale'
 37            scale_origin = origin_widget.get_screen_space_center()
 38            transition = 'in_scale'
 39        else:
 40            self._transition_out = 'out_right'
 41            scale_origin = None
 42        ba.app.ui.set_main_menu_location('Watch')
 43        self._tab_data: dict[str, Any] = {}
 44        self._my_replays_scroll_width: float | None = None
 45        self._my_replays_watch_replay_button: ba.Widget | None = None
 46        self._scrollwidget: ba.Widget | None = None
 47        self._columnwidget: ba.Widget | None = None
 48        self._my_replay_selected: str | None = None
 49        self._my_replays_rename_window: ba.Widget | None = None
 50        self._my_replay_rename_text: ba.Widget | None = None
 51        self._r = 'watchWindow'
 52        uiscale = ba.app.ui.uiscale
 53        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
 54        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 55        self._height = (578 if uiscale is ba.UIScale.SMALL else
 56                        670 if uiscale is ba.UIScale.MEDIUM else 800)
 57        self._current_tab: WatchWindow.TabID | None = None
 58        extra_top = 20 if uiscale is ba.UIScale.SMALL else 0
 59
 60        super().__init__(root_widget=ba.containerwidget(
 61            size=(self._width, self._height + extra_top),
 62            transition=transition,
 63            toolbar_visibility='menu_minimal',
 64            scale_origin_stack_offset=scale_origin,
 65            scale=(1.3 if uiscale is ba.UIScale.SMALL else
 66                   0.97 if uiscale is ba.UIScale.MEDIUM else 0.8),
 67            stack_offset=(0, -10) if uiscale is ba.UIScale.SMALL else (
 68                0, 15) if uiscale is ba.UIScale.MEDIUM else (0, 0)))
 69
 70        if uiscale is ba.UIScale.SMALL and ba.app.ui.use_toolbars:
 71            ba.containerwidget(edit=self._root_widget,
 72                               on_cancel_call=self._back)
 73            self._back_button = None
 74        else:
 75            self._back_button = btn = ba.buttonwidget(
 76                parent=self._root_widget,
 77                autoselect=True,
 78                position=(70 + x_inset, self._height - 74),
 79                size=(140, 60),
 80                scale=1.1,
 81                label=ba.Lstr(resource='backText'),
 82                button_type='back',
 83                on_activate_call=self._back)
 84            ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 85            ba.buttonwidget(edit=btn,
 86                            button_type='backSmall',
 87                            size=(60, 60),
 88                            label=ba.charstr(ba.SpecialChar.BACK))
 89
 90        ba.textwidget(parent=self._root_widget,
 91                      position=(self._width * 0.5, self._height - 38),
 92                      size=(0, 0),
 93                      color=ba.app.ui.title_color,
 94                      scale=1.5,
 95                      h_align='center',
 96                      v_align='center',
 97                      text=ba.Lstr(resource=self._r + '.titleText'),
 98                      maxwidth=400)
 99
100        tabdefs = [
101            (self.TabID.MY_REPLAYS,
102             ba.Lstr(resource=self._r + '.myReplaysText')),
103            # (self.TabID.TEST_TAB, ba.Lstr(value='Testing')),
104        ]
105
106        scroll_buffer_h = 130 + 2 * x_inset
107        tab_buffer_h = 750 + 2 * x_inset
108
109        self._tab_row = TabRow(self._root_widget,
110                               tabdefs,
111                               pos=(tab_buffer_h * 0.5, self._height - 130),
112                               size=(self._width - tab_buffer_h, 50),
113                               on_select_call=self._set_tab)
114
115        if ba.app.ui.use_toolbars:
116            first_tab = self._tab_row.tabs[tabdefs[0][0]]
117            last_tab = self._tab_row.tabs[tabdefs[-1][0]]
118            ba.widget(edit=last_tab.button,
119                      right_widget=_ba.get_special_widget('party_button'))
120            if uiscale is ba.UIScale.SMALL:
121                bbtn = _ba.get_special_widget('back_button')
122                ba.widget(edit=first_tab.button,
123                          up_widget=bbtn,
124                          left_widget=bbtn)
125
126        self._scroll_width = self._width - scroll_buffer_h
127        self._scroll_height = self._height - 180
128
129        # Not actually using a scroll widget anymore; just an image.
130        scroll_left = (self._width - self._scroll_width) * 0.5
131        scroll_bottom = self._height - self._scroll_height - 79 - 48
132        buffer_h = 10
133        buffer_v = 4
134        ba.imagewidget(parent=self._root_widget,
135                       position=(scroll_left - buffer_h,
136                                 scroll_bottom - buffer_v),
137                       size=(self._scroll_width + 2 * buffer_h,
138                             self._scroll_height + 2 * buffer_v),
139                       texture=ba.gettexture('scrollWidget'),
140                       model_transparent=ba.getmodel('softEdgeOutside'))
141        self._tab_container: ba.Widget | None = None
142
143        self._restore_state()
144
145    def _set_tab(self, tab_id: TabID) -> None:
146        # pylint: disable=too-many-locals
147
148        if self._current_tab == tab_id:
149            return
150        self._current_tab = tab_id
151
152        # Preserve our current tab between runs.
153        cfg = ba.app.config
154        cfg['Watch Tab'] = tab_id.value
155        cfg.commit()
156
157        # Update tab colors based on which is selected.
158        # tabs.update_tab_button_colors(self._tab_buttons, tab)
159        self._tab_row.update_appearance(tab_id)
160
161        if self._tab_container:
162            self._tab_container.delete()
163        scroll_left = (self._width - self._scroll_width) * 0.5
164        scroll_bottom = self._height - self._scroll_height - 79 - 48
165
166        # A place where tabs can store data to get cleared when
167        # switching to a different tab
168        self._tab_data = {}
169
170        uiscale = ba.app.ui.uiscale
171        if tab_id is self.TabID.MY_REPLAYS:
172            c_width = self._scroll_width
173            c_height = self._scroll_height - 20
174            sub_scroll_height = c_height - 63
175            self._my_replays_scroll_width = sub_scroll_width = (
176                680 if uiscale is ba.UIScale.SMALL else 640)
177
178            self._tab_container = cnt = ba.containerwidget(
179                parent=self._root_widget,
180                position=(scroll_left, scroll_bottom +
181                          (self._scroll_height - c_height) * 0.5),
182                size=(c_width, c_height),
183                background=False,
184                selection_loops_to_parent=True)
185
186            v = c_height - 30
187            ba.textwidget(parent=cnt,
188                          position=(c_width * 0.5, v),
189                          color=(0.6, 1.0, 0.6),
190                          scale=0.7,
191                          size=(0, 0),
192                          maxwidth=c_width * 0.9,
193                          h_align='center',
194                          v_align='center',
195                          text=ba.Lstr(
196                              resource='replayRenameWarningText',
197                              subs=[('${REPLAY}',
198                                     ba.Lstr(resource='replayNameDefaultText'))
199                                    ]))
200
201            b_width = 140 if uiscale is ba.UIScale.SMALL else 178
202            b_height = (107 if uiscale is ba.UIScale.SMALL else
203                        142 if uiscale is ba.UIScale.MEDIUM else 190)
204            b_space_extra = (0 if uiscale is ba.UIScale.SMALL else
205                             -2 if uiscale is ba.UIScale.MEDIUM else -5)
206
207            b_color = (0.6, 0.53, 0.63)
208            b_textcolor = (0.75, 0.7, 0.8)
209            btnv = (c_height - (48 if uiscale is ba.UIScale.SMALL else
210                                45 if uiscale is ba.UIScale.MEDIUM else 40) -
211                    b_height)
212            btnh = 40 if uiscale is ba.UIScale.SMALL else 40
213            smlh = 190 if uiscale is ba.UIScale.SMALL else 225
214            tscl = 1.0 if uiscale is ba.UIScale.SMALL else 1.2
215            self._my_replays_watch_replay_button = btn1 = ba.buttonwidget(
216                parent=cnt,
217                size=(b_width, b_height),
218                position=(btnh, btnv),
219                button_type='square',
220                color=b_color,
221                textcolor=b_textcolor,
222                on_activate_call=self._on_my_replay_play_press,
223                text_scale=tscl,
224                label=ba.Lstr(resource=self._r + '.watchReplayButtonText'),
225                autoselect=True)
226            ba.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button)
227            if uiscale is ba.UIScale.SMALL and ba.app.ui.use_toolbars:
228                ba.widget(edit=btn1,
229                          left_widget=_ba.get_special_widget('back_button'))
230            btnv -= b_height + b_space_extra
231            ba.buttonwidget(parent=cnt,
232                            size=(b_width, b_height),
233                            position=(btnh, btnv),
234                            button_type='square',
235                            color=b_color,
236                            textcolor=b_textcolor,
237                            on_activate_call=self._on_my_replay_rename_press,
238                            text_scale=tscl,
239                            label=ba.Lstr(resource=self._r +
240                                          '.renameReplayButtonText'),
241                            autoselect=True)
242            btnv -= b_height + b_space_extra
243            ba.buttonwidget(parent=cnt,
244                            size=(b_width, b_height),
245                            position=(btnh, btnv),
246                            button_type='square',
247                            color=b_color,
248                            textcolor=b_textcolor,
249                            on_activate_call=self._on_my_replay_delete_press,
250                            text_scale=tscl,
251                            label=ba.Lstr(resource=self._r +
252                                          '.deleteReplayButtonText'),
253                            autoselect=True)
254
255            v -= sub_scroll_height + 23
256            self._scrollwidget = scrlw = ba.scrollwidget(
257                parent=cnt,
258                position=(smlh, v),
259                size=(sub_scroll_width, sub_scroll_height))
260            ba.containerwidget(edit=cnt, selected_child=scrlw)
261            self._columnwidget = ba.columnwidget(parent=scrlw,
262                                                 left_border=10,
263                                                 border=2,
264                                                 margin=0)
265
266            ba.widget(edit=scrlw,
267                      autoselect=True,
268                      left_widget=btn1,
269                      up_widget=self._tab_row.tabs[tab_id].button)
270            ba.widget(edit=self._tab_row.tabs[tab_id].button,
271                      down_widget=scrlw)
272
273            self._my_replay_selected = None
274            self._refresh_my_replays()
275
276    def _no_replay_selected_error(self) -> None:
277        ba.screenmessage(ba.Lstr(resource=self._r +
278                                 '.noReplaySelectedErrorText'),
279                         color=(1, 0, 0))
280        ba.playsound(ba.getsound('error'))
281
282    def _on_my_replay_play_press(self) -> None:
283        if self._my_replay_selected is None:
284            self._no_replay_selected_error()
285            return
286        _ba.increment_analytics_count('Replay watch')
287
288        def do_it() -> None:
289            try:
290                # Reset to normal speed.
291                _ba.set_replay_speed_exponent(0)
292                _ba.fade_screen(True)
293                assert self._my_replay_selected is not None
294                _ba.new_replay_session(_ba.get_replays_dir() + '/' +
295                                       self._my_replay_selected)
296            except Exception:
297                ba.print_exception('Error running replay session.')
298
299                # Drop back into a fresh main menu session
300                # in case we half-launched or something.
301                from bastd import mainmenu
302                _ba.new_host_session(mainmenu.MainMenuSession)
303
304        _ba.fade_screen(False, endcall=ba.Call(ba.pushcall, do_it))
305        ba.containerwidget(edit=self._root_widget, transition='out_left')
306
307    def _on_my_replay_rename_press(self) -> None:
308        if self._my_replay_selected is None:
309            self._no_replay_selected_error()
310            return
311        c_width = 600
312        c_height = 250
313        uiscale = ba.app.ui.uiscale
314        self._my_replays_rename_window = cnt = ba.containerwidget(
315            scale=(1.8 if uiscale is ba.UIScale.SMALL else
316                   1.55 if uiscale is ba.UIScale.MEDIUM else 1.0),
317            size=(c_width, c_height),
318            transition='in_scale')
319        dname = self._get_replay_display_name(self._my_replay_selected)
320        ba.textwidget(parent=cnt,
321                      size=(0, 0),
322                      h_align='center',
323                      v_align='center',
324                      text=ba.Lstr(resource=self._r + '.renameReplayText',
325                                   subs=[('${REPLAY}', dname)]),
326                      maxwidth=c_width * 0.8,
327                      position=(c_width * 0.5, c_height - 60))
328        self._my_replay_rename_text = txt = ba.textwidget(
329            parent=cnt,
330            size=(c_width * 0.8, 40),
331            h_align='left',
332            v_align='center',
333            text=dname,
334            editable=True,
335            description=ba.Lstr(resource=self._r + '.replayNameText'),
336            position=(c_width * 0.1, c_height - 140),
337            autoselect=True,
338            maxwidth=c_width * 0.7,
339            max_chars=200)
340        cbtn = ba.buttonwidget(
341            parent=cnt,
342            label=ba.Lstr(resource='cancelText'),
343            on_activate_call=ba.Call(
344                lambda c: ba.containerwidget(edit=c, transition='out_scale'),
345                cnt),
346            size=(180, 60),
347            position=(30, 30),
348            autoselect=True)
349        okb = ba.buttonwidget(parent=cnt,
350                              label=ba.Lstr(resource=self._r + '.renameText'),
351                              size=(180, 60),
352                              position=(c_width - 230, 30),
353                              on_activate_call=ba.Call(
354                                  self._rename_my_replay,
355                                  self._my_replay_selected),
356                              autoselect=True)
357        ba.widget(edit=cbtn, right_widget=okb)
358        ba.widget(edit=okb, left_widget=cbtn)
359        ba.textwidget(edit=txt, on_return_press_call=okb.activate)
360        ba.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb)
361
362    def _rename_my_replay(self, replay: str) -> None:
363        new_name = None
364        try:
365            if not self._my_replay_rename_text:
366                return
367            new_name_raw = cast(
368                str, ba.textwidget(query=self._my_replay_rename_text))
369            new_name = new_name_raw + '.brp'
370
371            # Ignore attempts to change it to what it already is
372            # (or what it looks like to the user).
373            if (replay != new_name
374                    and self._get_replay_display_name(replay) != new_name_raw):
375                old_name_full = (_ba.get_replays_dir() + '/' +
376                                 replay).encode('utf-8')
377                new_name_full = (_ba.get_replays_dir() + '/' +
378                                 new_name).encode('utf-8')
379                # False alarm; ba.textwidget can return non-None val.
380                # pylint: disable=unsupported-membership-test
381                if os.path.exists(new_name_full):
382                    ba.playsound(ba.getsound('error'))
383                    ba.screenmessage(
384                        ba.Lstr(resource=self._r +
385                                '.replayRenameErrorAlreadyExistsText'),
386                        color=(1, 0, 0))
387                elif any(char in new_name_raw for char in ['/', '\\', ':']):
388                    ba.playsound(ba.getsound('error'))
389                    ba.screenmessage(ba.Lstr(resource=self._r +
390                                             '.replayRenameErrorInvalidName'),
391                                     color=(1, 0, 0))
392                else:
393                    _ba.increment_analytics_count('Replay rename')
394                    os.rename(old_name_full, new_name_full)
395                    self._refresh_my_replays()
396                    ba.playsound(ba.getsound('gunCocking'))
397        except Exception:
398            ba.print_exception(
399                f"Error renaming replay '{replay}' to '{new_name}'.")
400            ba.playsound(ba.getsound('error'))
401            ba.screenmessage(
402                ba.Lstr(resource=self._r + '.replayRenameErrorText'),
403                color=(1, 0, 0),
404            )
405
406        ba.containerwidget(edit=self._my_replays_rename_window,
407                           transition='out_scale')
408
409    def _on_my_replay_delete_press(self) -> None:
410        from bastd.ui import confirm
411        if self._my_replay_selected is None:
412            self._no_replay_selected_error()
413            return
414        confirm.ConfirmWindow(
415            ba.Lstr(resource=self._r + '.deleteConfirmText',
416                    subs=[('${REPLAY}',
417                           self._get_replay_display_name(
418                               self._my_replay_selected))]),
419            ba.Call(self._delete_replay, self._my_replay_selected), 450, 150)
420
421    def _get_replay_display_name(self, replay: str) -> str:
422        if replay.endswith('.brp'):
423            replay = replay[:-4]
424        if replay == '__lastReplay':
425            return ba.Lstr(resource='replayNameDefaultText').evaluate()
426        return replay
427
428    def _delete_replay(self, replay: str) -> None:
429        try:
430            _ba.increment_analytics_count('Replay delete')
431            os.remove((_ba.get_replays_dir() + '/' + replay).encode('utf-8'))
432            self._refresh_my_replays()
433            ba.playsound(ba.getsound('shieldDown'))
434            if replay == self._my_replay_selected:
435                self._my_replay_selected = None
436        except Exception:
437            ba.print_exception(f"Error deleting replay '{replay}'.")
438            ba.playsound(ba.getsound('error'))
439            ba.screenmessage(
440                ba.Lstr(resource=self._r + '.replayDeleteErrorText'),
441                color=(1, 0, 0),
442            )
443
444    def _on_my_replay_select(self, replay: str) -> None:
445        self._my_replay_selected = replay
446
447    def _refresh_my_replays(self) -> None:
448        assert self._columnwidget is not None
449        for child in self._columnwidget.get_children():
450            child.delete()
451        t_scale = 1.6
452        try:
453            names = os.listdir(_ba.get_replays_dir())
454
455            # Ignore random other files in there.
456            names = [n for n in names if n.endswith('.brp')]
457            names.sort(key=lambda x: x.lower())
458        except Exception:
459            ba.print_exception('Error listing replays dir.')
460            names = []
461
462        assert self._my_replays_scroll_width is not None
463        assert self._my_replays_watch_replay_button is not None
464        for i, name in enumerate(names):
465            txt = ba.textwidget(
466                parent=self._columnwidget,
467                size=(self._my_replays_scroll_width / t_scale, 30),
468                selectable=True,
469                color=(1.0, 1, 0.4) if name == '__lastReplay.brp' else
470                (1, 1, 1),
471                always_highlight=True,
472                on_select_call=ba.Call(self._on_my_replay_select, name),
473                on_activate_call=self._my_replays_watch_replay_button.activate,
474                text=self._get_replay_display_name(name),
475                h_align='left',
476                v_align='center',
477                corner_scale=t_scale,
478                maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93)
479            if i == 0:
480                ba.widget(
481                    edit=txt,
482                    up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button)
483
484    def _save_state(self) -> None:
485        try:
486            sel = self._root_widget.get_selected_child()
487            selected_tab_ids = [
488                tab_id for tab_id, tab in self._tab_row.tabs.items()
489                if sel == tab.button
490            ]
491            if sel == self._back_button:
492                sel_name = 'Back'
493            elif selected_tab_ids:
494                assert len(selected_tab_ids) == 1
495                sel_name = f'Tab:{selected_tab_ids[0].value}'
496            elif sel == self._tab_container:
497                sel_name = 'TabContainer'
498            else:
499                raise ValueError(f'unrecognized selection {sel}')
500            ba.app.ui.window_states[type(self)] = {'sel_name': sel_name}
501        except Exception:
502            ba.print_exception(f'Error saving state for {self}.')
503
504    def _restore_state(self) -> None:
505        from efro.util import enum_by_value
506        try:
507            sel: ba.Widget | None
508            sel_name = ba.app.ui.window_states.get(type(self),
509                                                   {}).get('sel_name')
510            assert isinstance(sel_name, (str, type(None)))
511            try:
512                current_tab = enum_by_value(self.TabID,
513                                            ba.app.config.get('Watch Tab'))
514            except ValueError:
515                current_tab = self.TabID.MY_REPLAYS
516            self._set_tab(current_tab)
517
518            if sel_name == 'Back':
519                sel = self._back_button
520            elif sel_name == 'TabContainer':
521                sel = self._tab_container
522            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
523                try:
524                    sel_tab_id = enum_by_value(self.TabID,
525                                               sel_name.split(':')[-1])
526                except ValueError:
527                    sel_tab_id = self.TabID.MY_REPLAYS
528                sel = self._tab_row.tabs[sel_tab_id].button
529            else:
530                if self._tab_container is not None:
531                    sel = self._tab_container
532                else:
533                    sel = self._tab_row.tabs[current_tab].button
534            ba.containerwidget(edit=self._root_widget, selected_child=sel)
535        except Exception:
536            ba.print_exception(f'Error restoring state for {self}.')
537
538    def _back(self) -> None:
539        from bastd.ui.mainmenu import MainMenuWindow
540        self._save_state()
541        ba.containerwidget(edit=self._root_widget,
542                           transition=self._transition_out)
543        ba.app.ui.set_main_menu_window(
544            MainMenuWindow(transition='in_left').get_root_widget())

Window for watching replays.

WatchWindow( transition: str | None = 'in_right', origin_widget: _ba.Widget | None = None)
 27    def __init__(self,
 28                 transition: str | None = 'in_right',
 29                 origin_widget: ba.Widget | None = None):
 30        # pylint: disable=too-many-locals
 31        # pylint: disable=too-many-statements
 32        from bastd.ui.tabs import TabRow
 33        ba.set_analytics_screen('Watch Window')
 34        scale_origin: tuple[float, float] | None
 35        if origin_widget is not None:
 36            self._transition_out = 'out_scale'
 37            scale_origin = origin_widget.get_screen_space_center()
 38            transition = 'in_scale'
 39        else:
 40            self._transition_out = 'out_right'
 41            scale_origin = None
 42        ba.app.ui.set_main_menu_location('Watch')
 43        self._tab_data: dict[str, Any] = {}
 44        self._my_replays_scroll_width: float | None = None
 45        self._my_replays_watch_replay_button: ba.Widget | None = None
 46        self._scrollwidget: ba.Widget | None = None
 47        self._columnwidget: ba.Widget | None = None
 48        self._my_replay_selected: str | None = None
 49        self._my_replays_rename_window: ba.Widget | None = None
 50        self._my_replay_rename_text: ba.Widget | None = None
 51        self._r = 'watchWindow'
 52        uiscale = ba.app.ui.uiscale
 53        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
 54        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 55        self._height = (578 if uiscale is ba.UIScale.SMALL else
 56                        670 if uiscale is ba.UIScale.MEDIUM else 800)
 57        self._current_tab: WatchWindow.TabID | None = None
 58        extra_top = 20 if uiscale is ba.UIScale.SMALL else 0
 59
 60        super().__init__(root_widget=ba.containerwidget(
 61            size=(self._width, self._height + extra_top),
 62            transition=transition,
 63            toolbar_visibility='menu_minimal',
 64            scale_origin_stack_offset=scale_origin,
 65            scale=(1.3 if uiscale is ba.UIScale.SMALL else
 66                   0.97 if uiscale is ba.UIScale.MEDIUM else 0.8),
 67            stack_offset=(0, -10) if uiscale is ba.UIScale.SMALL else (
 68                0, 15) if uiscale is ba.UIScale.MEDIUM else (0, 0)))
 69
 70        if uiscale is ba.UIScale.SMALL and ba.app.ui.use_toolbars:
 71            ba.containerwidget(edit=self._root_widget,
 72                               on_cancel_call=self._back)
 73            self._back_button = None
 74        else:
 75            self._back_button = btn = ba.buttonwidget(
 76                parent=self._root_widget,
 77                autoselect=True,
 78                position=(70 + x_inset, self._height - 74),
 79                size=(140, 60),
 80                scale=1.1,
 81                label=ba.Lstr(resource='backText'),
 82                button_type='back',
 83                on_activate_call=self._back)
 84            ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 85            ba.buttonwidget(edit=btn,
 86                            button_type='backSmall',
 87                            size=(60, 60),
 88                            label=ba.charstr(ba.SpecialChar.BACK))
 89
 90        ba.textwidget(parent=self._root_widget,
 91                      position=(self._width * 0.5, self._height - 38),
 92                      size=(0, 0),
 93                      color=ba.app.ui.title_color,
 94                      scale=1.5,
 95                      h_align='center',
 96                      v_align='center',
 97                      text=ba.Lstr(resource=self._r + '.titleText'),
 98                      maxwidth=400)
 99
100        tabdefs = [
101            (self.TabID.MY_REPLAYS,
102             ba.Lstr(resource=self._r + '.myReplaysText')),
103            # (self.TabID.TEST_TAB, ba.Lstr(value='Testing')),
104        ]
105
106        scroll_buffer_h = 130 + 2 * x_inset
107        tab_buffer_h = 750 + 2 * x_inset
108
109        self._tab_row = TabRow(self._root_widget,
110                               tabdefs,
111                               pos=(tab_buffer_h * 0.5, self._height - 130),
112                               size=(self._width - tab_buffer_h, 50),
113                               on_select_call=self._set_tab)
114
115        if ba.app.ui.use_toolbars:
116            first_tab = self._tab_row.tabs[tabdefs[0][0]]
117            last_tab = self._tab_row.tabs[tabdefs[-1][0]]
118            ba.widget(edit=last_tab.button,
119                      right_widget=_ba.get_special_widget('party_button'))
120            if uiscale is ba.UIScale.SMALL:
121                bbtn = _ba.get_special_widget('back_button')
122                ba.widget(edit=first_tab.button,
123                          up_widget=bbtn,
124                          left_widget=bbtn)
125
126        self._scroll_width = self._width - scroll_buffer_h
127        self._scroll_height = self._height - 180
128
129        # Not actually using a scroll widget anymore; just an image.
130        scroll_left = (self._width - self._scroll_width) * 0.5
131        scroll_bottom = self._height - self._scroll_height - 79 - 48
132        buffer_h = 10
133        buffer_v = 4
134        ba.imagewidget(parent=self._root_widget,
135                       position=(scroll_left - buffer_h,
136                                 scroll_bottom - buffer_v),
137                       size=(self._scroll_width + 2 * buffer_h,
138                             self._scroll_height + 2 * buffer_v),
139                       texture=ba.gettexture('scrollWidget'),
140                       model_transparent=ba.getmodel('softEdgeOutside'))
141        self._tab_container: ba.Widget | None = None
142
143        self._restore_state()
Inherited Members
ba.ui.Window
get_root_widget
class WatchWindow.TabID(enum.Enum):
22    class TabID(Enum):
23        """Our available tab types."""
24        MY_REPLAYS = 'my_replays'
25        TEST_TAB = 'test_tab'

Our available tab types.

MY_REPLAYS = <TabID.MY_REPLAYS: 'my_replays'>
TEST_TAB = <TabID.TEST_TAB: 'test_tab'>
Inherited Members
enum.Enum
name
value