Source code for bauiv1lib.watch

# Released under the MIT License. See LICENSE for details.
#
"""Provides UI functionality for watching replays."""

from __future__ import annotations

import os
import logging
from enum import Enum
from typing import TYPE_CHECKING, cast, override

import bascenev1 as bs
import bauiv1 as bui

if TYPE_CHECKING:
    from typing import Any


[docs] class WatchWindow(bui.MainWindow): """Window for watching replays."""
[docs] class TabID(Enum): """Our available tab types.""" MY_REPLAYS = 'my_replays' TEST_TAB = 'test_tab'
def __init__( self, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-locals from bauiv1lib.tabs import TabRow bui.set_analytics_screen('Watch Window') self._tab_data: dict[str, Any] = {} self._my_replays_scroll_width: float | None = None self._my_replays_watch_replay_button: bui.Widget | None = None self._scrollwidget: bui.Widget | None = None self._columnwidget: bui.Widget | None = None self._my_replay_selected: str | None = None self._my_replays_rename_window: bui.Widget | None = None self._my_replay_rename_text: bui.Widget | None = None self._r = 'watchWindow' uiscale = bui.app.ui_v1.uiscale self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040 x_inset = 200 if uiscale is bui.UIScale.SMALL else 0 self._height = ( 570 if uiscale is bui.UIScale.SMALL else 670 if uiscale is bui.UIScale.MEDIUM else 800 ) self._current_tab: WatchWindow.TabID | None = None extra_top = 20 if uiscale is bui.UIScale.SMALL else 0 super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height + extra_top), toolbar_visibility=( 'menu_minimal' if uiscale is bui.UIScale.SMALL else 'menu_full' ), scale=( 1.32 if uiscale is bui.UIScale.SMALL else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65 ), stack_offset=( (0, 30) if uiscale is bui.UIScale.SMALL else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) ), ), transition=transition, origin_widget=origin_widget, ) if uiscale is bui.UIScale.SMALL: bui.containerwidget( edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, autoselect=True, position=(70 + x_inset, self._height - 74), size=(60, 60), scale=1.1, label=bui.charstr(bui.SpecialChar.BACK), button_type='backSmall', on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) bui.textwidget( parent=self._root_widget, position=( self._width * 0.5, self._height - (65 if uiscale is bui.UIScale.SMALL else 38), ), size=(0, 0), color=bui.app.ui_v1.title_color, scale=0.7 if uiscale is bui.UIScale.SMALL else 1.5, h_align='center', v_align='center', text=( '' if uiscale is bui.UIScale.SMALL else bui.Lstr(resource=f'{self._r}.titleText') ), maxwidth=400, ) tabdefs = [ ( self.TabID.MY_REPLAYS, bui.Lstr(resource=f'{self._r}.myReplaysText'), ), # (self.TabID.TEST_TAB, bui.Lstr(value='Testing')), ] scroll_buffer_h = 130 + 2 * x_inset tab_buffer_h = 750 + 2 * x_inset self._tab_row = TabRow( self._root_widget, tabdefs, pos=(tab_buffer_h * 0.5, self._height - 130), size=(self._width - tab_buffer_h, 50), on_select_call=self._set_tab, ) first_tab = self._tab_row.tabs[tabdefs[0][0]] last_tab = self._tab_row.tabs[tabdefs[-1][0]] bui.widget( edit=last_tab.button, right_widget=bui.get_special_widget('squad_button'), ) if uiscale is bui.UIScale.SMALL: bbtn = bui.get_special_widget('back_button') bui.widget(edit=first_tab.button, up_widget=bbtn, left_widget=bbtn) self._scroll_width = self._width - scroll_buffer_h self._scroll_height = self._height - 180 # Not actually using a scroll widget anymore; just an image. scroll_left = (self._width - self._scroll_width) * 0.5 scroll_bottom = self._height - self._scroll_height - 79 - 48 buffer_h = 10 buffer_v = 4 bui.imagewidget( parent=self._root_widget, position=(scroll_left - buffer_h, scroll_bottom - buffer_v), size=( self._scroll_width + 2 * buffer_h, self._scroll_height + 2 * buffer_v, ), texture=bui.gettexture('scrollWidget'), mesh_transparent=bui.getmesh('softEdgeOutside'), ) self._tab_container: bui.Widget | None = None self._restore_state()
[docs] @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget ) )
[docs] @override def on_main_window_close(self) -> None: self._save_state()
def _set_tab(self, tab_id: TabID) -> None: # pylint: disable=too-many-locals if self._current_tab == tab_id: return self._current_tab = tab_id # Preserve our current tab between runs. cfg = bui.app.config cfg['Watch Tab'] = tab_id.value cfg.commit() # Update tab colors based on which is selected. # tabs.update_tab_button_colors(self._tab_buttons, tab) self._tab_row.update_appearance(tab_id) if self._tab_container: self._tab_container.delete() scroll_left = (self._width - self._scroll_width) * 0.5 scroll_bottom = self._height - self._scroll_height - 79 - 48 # A place where tabs can store data to get cleared when # switching to a different tab self._tab_data = {} assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale if tab_id is self.TabID.MY_REPLAYS: c_width = self._scroll_width c_height = self._scroll_height - 20 sub_scroll_height = c_height - 63 self._my_replays_scroll_width = sub_scroll_width = ( 680 if uiscale is bui.UIScale.SMALL else 640 ) self._tab_container = cnt = bui.containerwidget( parent=self._root_widget, position=( scroll_left, scroll_bottom + (self._scroll_height - c_height) * 0.5, ), size=(c_width, c_height), background=False, selection_loops_to_parent=True, ) v = c_height - 30 bui.textwidget( parent=cnt, position=(c_width * 0.5, v), color=(0.6, 1.0, 0.6), scale=0.7, size=(0, 0), maxwidth=c_width * 0.9, h_align='center', v_align='center', text=bui.Lstr( resource='replayRenameWarningText', subs=[ ( '${REPLAY}', bui.Lstr(resource='replayNameDefaultText'), ) ], ), ) b_width = 140 if uiscale is bui.UIScale.SMALL else 178 b_height = ( 107 if uiscale is bui.UIScale.SMALL else 142 if uiscale is bui.UIScale.MEDIUM else 190 ) b_space_extra = ( 0 if uiscale is bui.UIScale.SMALL else -2 if uiscale is bui.UIScale.MEDIUM else -5 ) b_color = (0.6, 0.53, 0.63) b_textcolor = (0.75, 0.7, 0.8) btnv = ( c_height - ( 48 if uiscale is bui.UIScale.SMALL else 45 if uiscale is bui.UIScale.MEDIUM else 40 ) - b_height ) btnh = 40 if uiscale is bui.UIScale.SMALL else 40 smlh = 190 if uiscale is bui.UIScale.SMALL else 225 tscl = 1.0 if uiscale is bui.UIScale.SMALL else 1.2 self._my_replays_watch_replay_button = btn1 = bui.buttonwidget( parent=cnt, size=(b_width, b_height), position=(btnh, btnv), button_type='square', color=b_color, textcolor=b_textcolor, on_activate_call=self._on_my_replay_play_press, text_scale=tscl, label=bui.Lstr(resource=f'{self._r}.watchReplayButtonText'), autoselect=True, ) bui.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button) assert bui.app.classic is not None if uiscale is bui.UIScale.SMALL: bui.widget( edit=btn1, left_widget=bui.get_special_widget('back_button'), ) btnv -= b_height + b_space_extra bui.buttonwidget( parent=cnt, size=(b_width, b_height), position=(btnh, btnv), button_type='square', color=b_color, textcolor=b_textcolor, on_activate_call=self._on_my_replay_rename_press, text_scale=tscl, label=bui.Lstr(resource=f'{self._r}.renameReplayButtonText'), autoselect=True, ) btnv -= b_height + b_space_extra bui.buttonwidget( parent=cnt, size=(b_width, b_height), position=(btnh, btnv), button_type='square', color=b_color, textcolor=b_textcolor, on_activate_call=self._on_my_replay_delete_press, text_scale=tscl, label=bui.Lstr(resource=f'{self._r}.deleteReplayButtonText'), autoselect=True, ) v -= sub_scroll_height + 23 self._scrollwidget = scrlw = bui.scrollwidget( parent=cnt, position=(smlh, v), size=(sub_scroll_width, sub_scroll_height), ) bui.containerwidget(edit=cnt, selected_child=scrlw) self._columnwidget = bui.columnwidget( parent=scrlw, left_border=10, border=2, margin=0 ) bui.widget( edit=scrlw, autoselect=True, left_widget=btn1, up_widget=self._tab_row.tabs[tab_id].button, ) bui.widget( edit=self._tab_row.tabs[tab_id].button, down_widget=scrlw ) self._my_replay_selected = None self._refresh_my_replays() def _no_replay_selected_error(self) -> None: bui.screenmessage( bui.Lstr(resource=f'{self._r}.noReplaySelectedErrorText'), color=(1, 0, 0), ) bui.getsound('error').play() def _on_my_replay_play_press(self) -> None: if self._my_replay_selected is None: self._no_replay_selected_error() return bui.increment_analytics_count('Replay watch') # Save our place in the UI so we return there when done. if bui.app.classic is not None: bui.app.classic.save_ui_state() def do_it() -> None: try: # Reset to normal speed. bs.set_replay_speed_exponent(0) bui.fade_screen(True) assert self._my_replay_selected is not None bs.new_replay_session( f'{bui.get_replays_dir()}/{self._my_replay_selected}' ) except Exception: logging.exception('Error running replay session.') # Drop back into a fresh main menu session # in case we half-launched or something. from bascenev1lib import mainmenu bs.new_host_session(mainmenu.MainMenuSession) bui.fade_screen(False, endcall=bui.Call(bui.pushcall, do_it)) bui.containerwidget(edit=self._root_widget, transition='out_left') def _on_my_replay_rename_press(self) -> None: if self._my_replay_selected is None: self._no_replay_selected_error() return c_width = 600 c_height = 250 assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale self._my_replays_rename_window = cnt = bui.containerwidget( scale=( 1.8 if uiscale is bui.UIScale.SMALL else 1.55 if uiscale is bui.UIScale.MEDIUM else 1.0 ), size=(c_width, c_height), transition='in_scale', ) dname = self._get_replay_display_name(self._my_replay_selected) bui.textwidget( parent=cnt, size=(0, 0), h_align='center', v_align='center', text=bui.Lstr( resource=f'{self._r}.renameReplayText', subs=[('${REPLAY}', dname)], ), maxwidth=c_width * 0.8, position=(c_width * 0.5, c_height - 60), ) self._my_replay_rename_text = txt = bui.textwidget( parent=cnt, size=(c_width * 0.8, 40), h_align='left', v_align='center', text=dname, editable=True, description=bui.Lstr(resource=f'{self._r}.replayNameText'), position=(c_width * 0.1, c_height - 140), autoselect=True, maxwidth=c_width * 0.7, max_chars=200, ) cbtn = bui.buttonwidget( parent=cnt, label=bui.Lstr(resource='cancelText'), on_activate_call=bui.Call( lambda c: bui.containerwidget(edit=c, transition='out_scale'), cnt, ), size=(180, 60), position=(30, 30), autoselect=True, ) okb = bui.buttonwidget( parent=cnt, label=bui.Lstr(resource=f'{self._r}.renameText'), size=(180, 60), position=(c_width - 230, 30), on_activate_call=bui.Call( self._rename_my_replay, self._my_replay_selected ), autoselect=True, ) bui.widget(edit=cbtn, right_widget=okb) bui.widget(edit=okb, left_widget=cbtn) bui.textwidget(edit=txt, on_return_press_call=okb.activate) bui.containerwidget(edit=cnt, cancel_button=cbtn, start_button=okb) def _rename_my_replay(self, replay: str) -> None: new_name = None try: if not self._my_replay_rename_text: return new_name_raw = cast( str, bui.textwidget(query=self._my_replay_rename_text) ) new_name = new_name_raw + '.brp' # Ignore attempts to change it to what it already is # (or what it looks like to the user). if ( replay != new_name and self._get_replay_display_name(replay) != new_name_raw ): old_name_full = (bui.get_replays_dir() + '/' + replay).encode( 'utf-8' ) new_name_full = (bui.get_replays_dir() + '/' + new_name).encode( 'utf-8' ) # False alarm; bui.textwidget can return non-None val. # pylint: disable=unsupported-membership-test if os.path.exists(new_name_full): bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource=self._r + '.replayRenameErrorAlreadyExistsText' ), color=(1, 0, 0), ) elif any(char in new_name_raw for char in ['/', '\\', ':']): bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource=f'{self._r}.replayRenameErrorInvalidName' ), color=(1, 0, 0), ) else: bui.increment_analytics_count('Replay rename') os.rename(old_name_full, new_name_full) self._refresh_my_replays() bui.getsound('gunCocking').play() except Exception: logging.exception( "Error renaming replay '%s' to '%s'.", replay, new_name ) bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource=f'{self._r}.replayRenameErrorText'), color=(1, 0, 0), ) bui.containerwidget( edit=self._my_replays_rename_window, transition='out_scale' ) def _on_my_replay_delete_press(self) -> None: from bauiv1lib import confirm if self._my_replay_selected is None: self._no_replay_selected_error() return confirm.ConfirmWindow( bui.Lstr( resource=f'{self._r}.deleteConfirmText', subs=[ ( '${REPLAY}', self._get_replay_display_name(self._my_replay_selected), ) ], ), bui.Call(self._delete_replay, self._my_replay_selected), 450, 150, ) def _get_replay_display_name(self, replay: str) -> str: if replay.endswith('.brp'): replay = replay[:-4] if replay == '__lastReplay': return bui.Lstr(resource='replayNameDefaultText').evaluate() return replay def _delete_replay(self, replay: str) -> None: try: bui.increment_analytics_count('Replay delete') os.remove((bui.get_replays_dir() + '/' + replay).encode('utf-8')) self._refresh_my_replays() bui.getsound('shieldDown').play() if replay == self._my_replay_selected: self._my_replay_selected = None except Exception: logging.exception("Error deleting replay '%s'.", replay) bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource=f'{self._r}.replayDeleteErrorText'), color=(1, 0, 0), ) def _on_my_replay_select(self, replay: str) -> None: self._my_replay_selected = replay def _refresh_my_replays(self) -> None: assert self._columnwidget is not None for child in self._columnwidget.get_children(): child.delete() t_scale = 1.6 try: names = os.listdir(bui.get_replays_dir()) # Ignore random other files in there. names = [n for n in names if n.endswith('.brp')] names.sort(key=lambda x: x.lower()) except Exception: logging.exception('Error listing replays dir.') names = [] assert self._my_replays_scroll_width is not None assert self._my_replays_watch_replay_button is not None for i, name in enumerate(names): txt = bui.textwidget( parent=self._columnwidget, size=(self._my_replays_scroll_width / t_scale, 30), selectable=True, color=( (1.0, 1, 0.4) if name == '__lastReplay.brp' else (1, 1, 1) ), always_highlight=True, on_select_call=bui.Call(self._on_my_replay_select, name), on_activate_call=self._my_replays_watch_replay_button.activate, text=self._get_replay_display_name(name), h_align='left', v_align='center', corner_scale=t_scale, maxwidth=(self._my_replays_scroll_width / t_scale) * 0.93, ) if i == 0: bui.widget( edit=txt, up_widget=self._tab_row.tabs[self.TabID.MY_REPLAYS].button, ) self._my_replay_selected = name def _save_state(self) -> None: try: sel = self._root_widget.get_selected_child() selected_tab_ids = [ tab_id for tab_id, tab in self._tab_row.tabs.items() if sel == tab.button ] if sel == self._back_button: sel_name = 'Back' elif selected_tab_ids: assert len(selected_tab_ids) == 1 sel_name = f'Tab:{selected_tab_ids[0].value}' elif sel == self._tab_container: sel_name = 'TabContainer' else: raise ValueError(f'unrecognized selection {sel}') assert bui.app.classic is not None bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} except Exception: logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: try: sel: bui.Widget | None assert bui.app.classic is not None sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 'sel_name' ) assert isinstance(sel_name, (str, type(None))) try: current_tab = self.TabID(bui.app.config.get('Watch Tab')) except ValueError: current_tab = self.TabID.MY_REPLAYS self._set_tab(current_tab) if sel_name == 'Back': sel = self._back_button elif sel_name == 'TabContainer': sel = self._tab_container elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): try: sel_tab_id = self.TabID(sel_name.split(':')[-1]) except ValueError: sel_tab_id = self.TabID.MY_REPLAYS sel = self._tab_row.tabs[sel_tab_id].button else: if self._tab_container is not None: sel = self._tab_container else: sel = self._tab_row.tabs[current_tab].button bui.containerwidget(edit=self._root_widget, selected_child=sel) except Exception: logging.exception('Error restoring state for %s.', self)