# 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