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 self._height = ( 900 if uiscale is bui.UIScale.SMALL else 670 if uiscale is bui.UIScale.MEDIUM else 800 ) self._current_tab: WatchWindow.TabID | None = None # Do some fancy math to fill all available screen area up to the # size of our backing container. This lets us fit to the exact # screen shape at small ui scale. screensize = bui.get_virtual_screen_size() scale = ( 1.5 if uiscale is bui.UIScale.SMALL else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65 ) # Calc screen size in our local container space and clamp to a # bit smaller than our container size. target_width = min(self._width - 120, screensize[0] / scale) target_height = min(self._height - 120, screensize[1] / scale) # To get top/left coords, go to the center of our window and # offset by half the width/height of our target area. self.yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 self._scroll_width = target_width self._scroll_height = target_height - 55 self._scroll_y = self.yoffs - 85 - self._scroll_height super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), toolbar_visibility=( 'menu_minimal' if uiscale is bui.UIScale.SMALL else 'menu_full' ), scale=scale, ), transition=transition, origin_widget=origin_widget, # We're affected by screen size only at small ui-scale. refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) 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, self.yoffs - 50), 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._scroll_width * -0.5 + 93) if uiscale is bui.UIScale.SMALL else 0 ) ), self.yoffs - (63 if uiscale is bui.UIScale.SMALL else 10), ), size=(0, 0), color=bui.app.ui_v1.title_color, scale=1.3 if uiscale is bui.UIScale.SMALL else 1.5, h_align='left' if uiscale is bui.UIScale.SMALL else 'center', v_align='center', text=bui.Lstr(resource=f'{self._r}.titleText'), maxwidth=200, ) tabdefs = [ ( self.TabID.MY_REPLAYS, bui.Lstr(resource=f'{self._r}.myReplaysText'), ), ] tab_bar_width = 200.0 * len(tabdefs) tab_bar_inset = (self._scroll_width - tab_bar_width) * 0.5 self._tab_row = TabRow( self._root_widget, tabdefs, pos=( self._width * 0.5 - self._scroll_width * 0.5 + tab_bar_inset, self._scroll_y + self._scroll_height - 4.0, ), size=(self._scroll_width - 2.0 * tab_bar_inset, 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) # Not actually using a scroll widget anymore; just an image. bui.imagewidget( parent=self._root_widget, size=(self._scroll_width, self._scroll_height), position=( self._width * 0.5 - self._scroll_width * 0.5, self._scroll_y, ), texture=bui.gettexture('scrollWidget'), mesh_transparent=bui.getmesh('softEdgeOutside'), opacity=0.4, ) 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._scroll_y # 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 = ( 110 if uiscale is bui.UIScale.SMALL else 142 if uiscale is bui.UIScale.MEDIUM else 180 ) 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 - ( 40 if uiscale is bui.UIScale.SMALL else 40 if uiscale is bui.UIScale.MEDIUM else 40 ) - b_height ) # Roughly center buttons and scroll-widget in the middle. xextra = ( self._scroll_width - (sub_scroll_width + b_width) ) * 0.5 - 50.0 btnh = (40 if uiscale is bui.UIScale.SMALL else 40) + xextra smlh = (190 if uiscale is bui.UIScale.SMALL else 225) + xextra 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', parent=bui.get_special_widget('overlay_stack'), ) 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)
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum