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.
Inherited Members
- enum.Enum
- name
- value