bastd.ui.soundtrack.browser

Provides UI for browsing soundtracks.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides UI for browsing soundtracks."""
  4
  5from __future__ import annotations
  6
  7import copy
  8from typing import TYPE_CHECKING
  9
 10import _ba
 11import ba
 12
 13if TYPE_CHECKING:
 14    from typing import Any
 15
 16
 17class SoundtrackBrowserWindow(ba.Window):
 18    """Window for browsing soundtracks."""
 19
 20    def __init__(self,
 21                 transition: str = 'in_right',
 22                 origin_widget: ba.Widget | None = None):
 23        # pylint: disable=too-many-locals
 24        # pylint: disable=too-many-statements
 25
 26        # If they provided an origin-widget, scale up from that.
 27        scale_origin: tuple[float, float] | None
 28        if origin_widget is not None:
 29            self._transition_out = 'out_scale'
 30            scale_origin = origin_widget.get_screen_space_center()
 31            transition = 'in_scale'
 32        else:
 33            self._transition_out = 'out_right'
 34            scale_origin = None
 35
 36        self._r = 'editSoundtrackWindow'
 37        uiscale = ba.app.ui.uiscale
 38        self._width = 800 if uiscale is ba.UIScale.SMALL else 600
 39        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 40        self._height = (340 if uiscale is ba.UIScale.SMALL else
 41                        370 if uiscale is ba.UIScale.MEDIUM else 440)
 42        spacing = 40.0
 43        v = self._height - 40.0
 44        v -= spacing * 1.0
 45
 46        super().__init__(root_widget=ba.containerwidget(
 47            size=(self._width, self._height),
 48            transition=transition,
 49            toolbar_visibility='menu_minimal',
 50            scale_origin_stack_offset=scale_origin,
 51            scale=(2.3 if uiscale is ba.UIScale.SMALL else
 52                   1.6 if uiscale is ba.UIScale.MEDIUM else 1.0),
 53            stack_offset=(0, -18) if uiscale is ba.UIScale.SMALL else (0, 0)))
 54
 55        if ba.app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 56            self._back_button = None
 57        else:
 58            self._back_button = ba.buttonwidget(
 59                parent=self._root_widget,
 60                position=(45 + x_inset, self._height - 60),
 61                size=(120, 60),
 62                scale=0.8,
 63                label=ba.Lstr(resource='backText'),
 64                button_type='back',
 65                autoselect=True)
 66            ba.buttonwidget(edit=self._back_button,
 67                            button_type='backSmall',
 68                            size=(60, 60),
 69                            label=ba.charstr(ba.SpecialChar.BACK))
 70        ba.textwidget(parent=self._root_widget,
 71                      position=(self._width * 0.5, self._height - 35),
 72                      size=(0, 0),
 73                      maxwidth=300,
 74                      text=ba.Lstr(resource=self._r + '.titleText'),
 75                      color=ba.app.ui.title_color,
 76                      h_align='center',
 77                      v_align='center')
 78
 79        h = 43 + x_inset
 80        v = self._height - 60
 81        b_color = (0.6, 0.53, 0.63)
 82        b_textcolor = (0.75, 0.7, 0.8)
 83        lock_tex = ba.gettexture('lock')
 84        self._lock_images: list[ba.Widget] = []
 85
 86        scl = (1.0 if uiscale is ba.UIScale.SMALL else
 87               1.13 if uiscale is ba.UIScale.MEDIUM else 1.4)
 88        v -= 60.0 * scl
 89        self._new_button = btn = ba.buttonwidget(
 90            parent=self._root_widget,
 91            position=(h, v),
 92            size=(100, 55.0 * scl),
 93            on_activate_call=self._new_soundtrack,
 94            color=b_color,
 95            button_type='square',
 96            autoselect=True,
 97            textcolor=b_textcolor,
 98            text_scale=0.7,
 99            label=ba.Lstr(resource=self._r + '.newText'))
100        self._lock_images.append(
101            ba.imagewidget(parent=self._root_widget,
102                           size=(30, 30),
103                           draw_controller=btn,
104                           position=(h - 10, v + 55.0 * scl - 28),
105                           texture=lock_tex))
106
107        if self._back_button is None:
108            ba.widget(edit=btn,
109                      left_widget=_ba.get_special_widget('back_button'))
110        v -= 60.0 * scl
111
112        self._edit_button = btn = ba.buttonwidget(
113            parent=self._root_widget,
114            position=(h, v),
115            size=(100, 55.0 * scl),
116            on_activate_call=self._edit_soundtrack,
117            color=b_color,
118            button_type='square',
119            autoselect=True,
120            textcolor=b_textcolor,
121            text_scale=0.7,
122            label=ba.Lstr(resource=self._r + '.editText'))
123        self._lock_images.append(
124            ba.imagewidget(parent=self._root_widget,
125                           size=(30, 30),
126                           draw_controller=btn,
127                           position=(h - 10, v + 55.0 * scl - 28),
128                           texture=lock_tex))
129        if self._back_button is None:
130            ba.widget(edit=btn,
131                      left_widget=_ba.get_special_widget('back_button'))
132        v -= 60.0 * scl
133
134        self._duplicate_button = btn = ba.buttonwidget(
135            parent=self._root_widget,
136            position=(h, v),
137            size=(100, 55.0 * scl),
138            on_activate_call=self._duplicate_soundtrack,
139            button_type='square',
140            autoselect=True,
141            color=b_color,
142            textcolor=b_textcolor,
143            text_scale=0.7,
144            label=ba.Lstr(resource=self._r + '.duplicateText'))
145        self._lock_images.append(
146            ba.imagewidget(parent=self._root_widget,
147                           size=(30, 30),
148                           draw_controller=btn,
149                           position=(h - 10, v + 55.0 * scl - 28),
150                           texture=lock_tex))
151        if self._back_button is None:
152            ba.widget(edit=btn,
153                      left_widget=_ba.get_special_widget('back_button'))
154        v -= 60.0 * scl
155
156        self._delete_button = btn = ba.buttonwidget(
157            parent=self._root_widget,
158            position=(h, v),
159            size=(100, 55.0 * scl),
160            on_activate_call=self._delete_soundtrack,
161            color=b_color,
162            button_type='square',
163            autoselect=True,
164            textcolor=b_textcolor,
165            text_scale=0.7,
166            label=ba.Lstr(resource=self._r + '.deleteText'))
167        self._lock_images.append(
168            ba.imagewidget(parent=self._root_widget,
169                           size=(30, 30),
170                           draw_controller=btn,
171                           position=(h - 10, v + 55.0 * scl - 28),
172                           texture=lock_tex))
173        if self._back_button is None:
174            ba.widget(edit=btn,
175                      left_widget=_ba.get_special_widget('back_button'))
176
177        # Keep our lock images up to date/etc.
178        self._update_timer = ba.Timer(1.0,
179                                      ba.WeakCall(self._update),
180                                      timetype=ba.TimeType.REAL,
181                                      repeat=True)
182        self._update()
183
184        v = self._height - 65
185        scroll_height = self._height - 105
186        v -= scroll_height
187        self._scrollwidget = scrollwidget = ba.scrollwidget(
188            parent=self._root_widget,
189            position=(152 + x_inset, v),
190            highlight=False,
191            size=(self._width - (205 + 2 * x_inset), scroll_height))
192        ba.widget(edit=self._scrollwidget,
193                  left_widget=self._new_button,
194                  right_widget=_ba.get_special_widget('party_button')
195                  if ba.app.ui.use_toolbars else self._scrollwidget)
196        self._col = ba.columnwidget(parent=scrollwidget, border=2, margin=0)
197
198        self._soundtracks: dict[str, Any] | None = None
199        self._selected_soundtrack: str | None = None
200        self._selected_soundtrack_index: int | None = None
201        self._soundtrack_widgets: list[ba.Widget] = []
202        self._allow_changing_soundtracks = False
203        self._refresh()
204        if self._back_button is not None:
205            ba.buttonwidget(edit=self._back_button,
206                            on_activate_call=self._back)
207            ba.containerwidget(edit=self._root_widget,
208                               cancel_button=self._back_button)
209        else:
210            ba.containerwidget(edit=self._root_widget,
211                               on_cancel_call=self._back)
212
213    def _update(self) -> None:
214        have = ba.app.accounts_v1.have_pro_options()
215        for lock in self._lock_images:
216            ba.imagewidget(edit=lock, opacity=0.0 if have else 1.0)
217
218    def _do_delete_soundtrack(self) -> None:
219        cfg = ba.app.config
220        soundtracks = cfg.setdefault('Soundtracks', {})
221        if self._selected_soundtrack in soundtracks:
222            del soundtracks[self._selected_soundtrack]
223        cfg.commit()
224        ba.playsound(ba.getsound('shieldDown'))
225        assert self._selected_soundtrack_index is not None
226        assert self._soundtracks is not None
227        if self._selected_soundtrack_index >= len(self._soundtracks):
228            self._selected_soundtrack_index = len(self._soundtracks)
229        self._refresh()
230
231    def _delete_soundtrack(self) -> None:
232        # pylint: disable=cyclic-import
233        from bastd.ui.purchase import PurchaseWindow
234        from bastd.ui.confirm import ConfirmWindow
235        if not ba.app.accounts_v1.have_pro_options():
236            PurchaseWindow(items=['pro'])
237            return
238        if self._selected_soundtrack is None:
239            return
240        if self._selected_soundtrack == '__default__':
241            ba.playsound(ba.getsound('error'))
242            ba.screenmessage(ba.Lstr(resource=self._r +
243                                     '.cantDeleteDefaultText'),
244                             color=(1, 0, 0))
245        else:
246            ConfirmWindow(
247                ba.Lstr(resource=self._r + '.deleteConfirmText',
248                        subs=[('${NAME}', self._selected_soundtrack)]),
249                self._do_delete_soundtrack, 450, 150)
250
251    def _duplicate_soundtrack(self) -> None:
252        # pylint: disable=cyclic-import
253        from bastd.ui.purchase import PurchaseWindow
254        if not ba.app.accounts_v1.have_pro_options():
255            PurchaseWindow(items=['pro'])
256            return
257        cfg = ba.app.config
258        cfg.setdefault('Soundtracks', {})
259
260        if self._selected_soundtrack is None:
261            return
262        sdtk: dict[str, Any]
263        if self._selected_soundtrack == '__default__':
264            sdtk = {}
265        else:
266            sdtk = cfg['Soundtracks'][self._selected_soundtrack]
267
268        # Find a valid dup name that doesn't exist.
269        test_index = 1
270        copy_text = ba.Lstr(resource='copyOfText').evaluate()
271        # Get just 'Copy' or whatnot.
272        copy_word = copy_text.replace('${NAME}', '').strip()
273        base_name = self._get_soundtrack_display_name(
274            self._selected_soundtrack).evaluate()
275        assert isinstance(base_name, str)
276
277        # If it looks like a copy, strip digits and spaces off the end.
278        if copy_word in base_name:
279            while base_name[-1].isdigit() or base_name[-1] == ' ':
280                base_name = base_name[:-1]
281        while True:
282            if copy_word in base_name:
283                test_name = base_name
284            else:
285                test_name = copy_text.replace('${NAME}', base_name)
286            if test_index > 1:
287                test_name += ' ' + str(test_index)
288            if test_name not in cfg['Soundtracks']:
289                break
290            test_index += 1
291
292        cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk)
293        cfg.commit()
294        self._refresh(select_soundtrack=test_name)
295
296    def _select(self, name: str, index: int) -> None:
297        music = ba.app.music
298        self._selected_soundtrack_index = index
299        self._selected_soundtrack = name
300        cfg = ba.app.config
301        current_soundtrack = cfg.setdefault('Soundtrack', '__default__')
302
303        # If it varies from current, commit and play.
304        if current_soundtrack != name and self._allow_changing_soundtracks:
305            ba.playsound(ba.getsound('gunCocking'))
306            cfg['Soundtrack'] = self._selected_soundtrack
307            cfg.commit()
308
309            # Just play whats already playing.. this'll grab it from the
310            # new soundtrack.
311            music.do_play_music(music.music_types[ba.MusicPlayMode.REGULAR])
312
313    def _back(self) -> None:
314        # pylint: disable=cyclic-import
315        from bastd.ui.settings import audio
316        self._save_state()
317        ba.containerwidget(edit=self._root_widget,
318                           transition=self._transition_out)
319        ba.app.ui.set_main_menu_window(
320            audio.AudioSettingsWindow(transition='in_left').get_root_widget())
321
322    def _edit_soundtrack_with_sound(self) -> None:
323        # pylint: disable=cyclic-import
324        from bastd.ui.purchase import PurchaseWindow
325        if not ba.app.accounts_v1.have_pro_options():
326            PurchaseWindow(items=['pro'])
327            return
328        ba.playsound(ba.getsound('swish'))
329        self._edit_soundtrack()
330
331    def _edit_soundtrack(self) -> None:
332        # pylint: disable=cyclic-import
333        from bastd.ui.purchase import PurchaseWindow
334        from bastd.ui.soundtrack.edit import SoundtrackEditWindow
335        if not ba.app.accounts_v1.have_pro_options():
336            PurchaseWindow(items=['pro'])
337            return
338        if self._selected_soundtrack is None:
339            return
340        if self._selected_soundtrack == '__default__':
341            ba.playsound(ba.getsound('error'))
342            ba.screenmessage(ba.Lstr(resource=self._r +
343                                     '.cantEditDefaultText'),
344                             color=(1, 0, 0))
345            return
346
347        self._save_state()
348        ba.containerwidget(edit=self._root_widget, transition='out_left')
349        ba.app.ui.set_main_menu_window(
350            SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack
351                                 ).get_root_widget())
352
353    def _get_soundtrack_display_name(self, soundtrack: str) -> ba.Lstr:
354        if soundtrack == '__default__':
355            return ba.Lstr(resource=self._r + '.defaultSoundtrackNameText')
356        return ba.Lstr(value=soundtrack)
357
358    def _refresh(self, select_soundtrack: str | None = None) -> None:
359        from efro.util import asserttype
360        self._allow_changing_soundtracks = False
361        old_selection = self._selected_soundtrack
362
363        # If there was no prev selection, look in prefs.
364        if old_selection is None:
365            old_selection = ba.app.config.get('Soundtrack')
366        old_selection_index = self._selected_soundtrack_index
367
368        # Delete old.
369        while self._soundtrack_widgets:
370            self._soundtrack_widgets.pop().delete()
371
372        self._soundtracks = ba.app.config.get('Soundtracks', {})
373        assert self._soundtracks is not None
374        items = list(self._soundtracks.items())
375        items.sort(key=lambda x: asserttype(x[0], str).lower())
376        items = [('__default__', None)] + items  # default is always first
377        index = 0
378        for pname, _pval in items:
379            assert pname is not None
380            txtw = ba.textwidget(
381                parent=self._col,
382                size=(self._width - 40, 24),
383                text=self._get_soundtrack_display_name(pname),
384                h_align='left',
385                v_align='center',
386                maxwidth=self._width - 110,
387                always_highlight=True,
388                on_select_call=ba.WeakCall(self._select, pname, index),
389                on_activate_call=self._edit_soundtrack_with_sound,
390                selectable=True)
391            if index == 0:
392                ba.widget(edit=txtw, up_widget=self._back_button)
393            self._soundtrack_widgets.append(txtw)
394
395            # Select this one if the user requested it
396            if select_soundtrack is not None:
397                if pname == select_soundtrack:
398                    ba.columnwidget(edit=self._col,
399                                    selected_child=txtw,
400                                    visible_child=txtw)
401            else:
402                # Select this one if it was previously selected.
403                # Go by index if there's one.
404                if old_selection_index is not None:
405                    if index == old_selection_index:
406                        ba.columnwidget(edit=self._col,
407                                        selected_child=txtw,
408                                        visible_child=txtw)
409                else:  # Otherwise look by name.
410                    if pname == old_selection:
411                        ba.columnwidget(edit=self._col,
412                                        selected_child=txtw,
413                                        visible_child=txtw)
414            index += 1
415
416        # Explicitly run select callback on current one and re-enable
417        # callbacks.
418
419        # Eww need to run this in a timer so it happens after our select
420        # callbacks. With a small-enough time sometimes it happens before
421        # anyway. Ew. need a way to just schedule a callable i guess.
422        ba.timer(0.1,
423                 ba.WeakCall(self._set_allow_changing),
424                 timetype=ba.TimeType.REAL)
425
426    def _set_allow_changing(self) -> None:
427        self._allow_changing_soundtracks = True
428        assert self._selected_soundtrack is not None
429        assert self._selected_soundtrack_index is not None
430        self._select(self._selected_soundtrack,
431                     self._selected_soundtrack_index)
432
433    def _new_soundtrack(self) -> None:
434        # pylint: disable=cyclic-import
435        from bastd.ui.purchase import PurchaseWindow
436        from bastd.ui.soundtrack.edit import SoundtrackEditWindow
437        if not ba.app.accounts_v1.have_pro_options():
438            PurchaseWindow(items=['pro'])
439            return
440        self._save_state()
441        ba.containerwidget(edit=self._root_widget, transition='out_left')
442        SoundtrackEditWindow(existing_soundtrack=None)
443
444    def _create_done(self, new_soundtrack: str) -> None:
445        if new_soundtrack is not None:
446            ba.playsound(ba.getsound('gunCocking'))
447            self._refresh(select_soundtrack=new_soundtrack)
448
449    def _save_state(self) -> None:
450        try:
451            sel = self._root_widget.get_selected_child()
452            if sel == self._scrollwidget:
453                sel_name = 'Scroll'
454            elif sel == self._new_button:
455                sel_name = 'New'
456            elif sel == self._edit_button:
457                sel_name = 'Edit'
458            elif sel == self._duplicate_button:
459                sel_name = 'Duplicate'
460            elif sel == self._delete_button:
461                sel_name = 'Delete'
462            elif sel == self._back_button:
463                sel_name = 'Back'
464            else:
465                raise ValueError(f'unrecognized selection \'{sel}\'')
466            ba.app.ui.window_states[type(self)] = sel_name
467        except Exception:
468            ba.print_exception(f'Error saving state for {self}.')
469
470    def _restore_state(self) -> None:
471        try:
472            sel_name = ba.app.ui.window_states.get(type(self))
473            if sel_name == 'Scroll':
474                sel = self._scrollwidget
475            elif sel_name == 'New':
476                sel = self._new_button
477            elif sel_name == 'Edit':
478                sel = self._edit_button
479            elif sel_name == 'Duplicate':
480                sel = self._duplicate_button
481            elif sel_name == 'Delete':
482                sel = self._delete_button
483            else:
484                sel = self._scrollwidget
485            ba.containerwidget(edit=self._root_widget, selected_child=sel)
486        except Exception:
487            ba.print_exception(f'Error restoring state for {self}.')
class SoundtrackBrowserWindow(ba.ui.Window):
 18class SoundtrackBrowserWindow(ba.Window):
 19    """Window for browsing soundtracks."""
 20
 21    def __init__(self,
 22                 transition: str = 'in_right',
 23                 origin_widget: ba.Widget | None = None):
 24        # pylint: disable=too-many-locals
 25        # pylint: disable=too-many-statements
 26
 27        # If they provided an origin-widget, scale up from that.
 28        scale_origin: tuple[float, float] | None
 29        if origin_widget is not None:
 30            self._transition_out = 'out_scale'
 31            scale_origin = origin_widget.get_screen_space_center()
 32            transition = 'in_scale'
 33        else:
 34            self._transition_out = 'out_right'
 35            scale_origin = None
 36
 37        self._r = 'editSoundtrackWindow'
 38        uiscale = ba.app.ui.uiscale
 39        self._width = 800 if uiscale is ba.UIScale.SMALL else 600
 40        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 41        self._height = (340 if uiscale is ba.UIScale.SMALL else
 42                        370 if uiscale is ba.UIScale.MEDIUM else 440)
 43        spacing = 40.0
 44        v = self._height - 40.0
 45        v -= spacing * 1.0
 46
 47        super().__init__(root_widget=ba.containerwidget(
 48            size=(self._width, self._height),
 49            transition=transition,
 50            toolbar_visibility='menu_minimal',
 51            scale_origin_stack_offset=scale_origin,
 52            scale=(2.3 if uiscale is ba.UIScale.SMALL else
 53                   1.6 if uiscale is ba.UIScale.MEDIUM else 1.0),
 54            stack_offset=(0, -18) if uiscale is ba.UIScale.SMALL else (0, 0)))
 55
 56        if ba.app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 57            self._back_button = None
 58        else:
 59            self._back_button = ba.buttonwidget(
 60                parent=self._root_widget,
 61                position=(45 + x_inset, self._height - 60),
 62                size=(120, 60),
 63                scale=0.8,
 64                label=ba.Lstr(resource='backText'),
 65                button_type='back',
 66                autoselect=True)
 67            ba.buttonwidget(edit=self._back_button,
 68                            button_type='backSmall',
 69                            size=(60, 60),
 70                            label=ba.charstr(ba.SpecialChar.BACK))
 71        ba.textwidget(parent=self._root_widget,
 72                      position=(self._width * 0.5, self._height - 35),
 73                      size=(0, 0),
 74                      maxwidth=300,
 75                      text=ba.Lstr(resource=self._r + '.titleText'),
 76                      color=ba.app.ui.title_color,
 77                      h_align='center',
 78                      v_align='center')
 79
 80        h = 43 + x_inset
 81        v = self._height - 60
 82        b_color = (0.6, 0.53, 0.63)
 83        b_textcolor = (0.75, 0.7, 0.8)
 84        lock_tex = ba.gettexture('lock')
 85        self._lock_images: list[ba.Widget] = []
 86
 87        scl = (1.0 if uiscale is ba.UIScale.SMALL else
 88               1.13 if uiscale is ba.UIScale.MEDIUM else 1.4)
 89        v -= 60.0 * scl
 90        self._new_button = btn = ba.buttonwidget(
 91            parent=self._root_widget,
 92            position=(h, v),
 93            size=(100, 55.0 * scl),
 94            on_activate_call=self._new_soundtrack,
 95            color=b_color,
 96            button_type='square',
 97            autoselect=True,
 98            textcolor=b_textcolor,
 99            text_scale=0.7,
100            label=ba.Lstr(resource=self._r + '.newText'))
101        self._lock_images.append(
102            ba.imagewidget(parent=self._root_widget,
103                           size=(30, 30),
104                           draw_controller=btn,
105                           position=(h - 10, v + 55.0 * scl - 28),
106                           texture=lock_tex))
107
108        if self._back_button is None:
109            ba.widget(edit=btn,
110                      left_widget=_ba.get_special_widget('back_button'))
111        v -= 60.0 * scl
112
113        self._edit_button = btn = ba.buttonwidget(
114            parent=self._root_widget,
115            position=(h, v),
116            size=(100, 55.0 * scl),
117            on_activate_call=self._edit_soundtrack,
118            color=b_color,
119            button_type='square',
120            autoselect=True,
121            textcolor=b_textcolor,
122            text_scale=0.7,
123            label=ba.Lstr(resource=self._r + '.editText'))
124        self._lock_images.append(
125            ba.imagewidget(parent=self._root_widget,
126                           size=(30, 30),
127                           draw_controller=btn,
128                           position=(h - 10, v + 55.0 * scl - 28),
129                           texture=lock_tex))
130        if self._back_button is None:
131            ba.widget(edit=btn,
132                      left_widget=_ba.get_special_widget('back_button'))
133        v -= 60.0 * scl
134
135        self._duplicate_button = btn = ba.buttonwidget(
136            parent=self._root_widget,
137            position=(h, v),
138            size=(100, 55.0 * scl),
139            on_activate_call=self._duplicate_soundtrack,
140            button_type='square',
141            autoselect=True,
142            color=b_color,
143            textcolor=b_textcolor,
144            text_scale=0.7,
145            label=ba.Lstr(resource=self._r + '.duplicateText'))
146        self._lock_images.append(
147            ba.imagewidget(parent=self._root_widget,
148                           size=(30, 30),
149                           draw_controller=btn,
150                           position=(h - 10, v + 55.0 * scl - 28),
151                           texture=lock_tex))
152        if self._back_button is None:
153            ba.widget(edit=btn,
154                      left_widget=_ba.get_special_widget('back_button'))
155        v -= 60.0 * scl
156
157        self._delete_button = btn = ba.buttonwidget(
158            parent=self._root_widget,
159            position=(h, v),
160            size=(100, 55.0 * scl),
161            on_activate_call=self._delete_soundtrack,
162            color=b_color,
163            button_type='square',
164            autoselect=True,
165            textcolor=b_textcolor,
166            text_scale=0.7,
167            label=ba.Lstr(resource=self._r + '.deleteText'))
168        self._lock_images.append(
169            ba.imagewidget(parent=self._root_widget,
170                           size=(30, 30),
171                           draw_controller=btn,
172                           position=(h - 10, v + 55.0 * scl - 28),
173                           texture=lock_tex))
174        if self._back_button is None:
175            ba.widget(edit=btn,
176                      left_widget=_ba.get_special_widget('back_button'))
177
178        # Keep our lock images up to date/etc.
179        self._update_timer = ba.Timer(1.0,
180                                      ba.WeakCall(self._update),
181                                      timetype=ba.TimeType.REAL,
182                                      repeat=True)
183        self._update()
184
185        v = self._height - 65
186        scroll_height = self._height - 105
187        v -= scroll_height
188        self._scrollwidget = scrollwidget = ba.scrollwidget(
189            parent=self._root_widget,
190            position=(152 + x_inset, v),
191            highlight=False,
192            size=(self._width - (205 + 2 * x_inset), scroll_height))
193        ba.widget(edit=self._scrollwidget,
194                  left_widget=self._new_button,
195                  right_widget=_ba.get_special_widget('party_button')
196                  if ba.app.ui.use_toolbars else self._scrollwidget)
197        self._col = ba.columnwidget(parent=scrollwidget, border=2, margin=0)
198
199        self._soundtracks: dict[str, Any] | None = None
200        self._selected_soundtrack: str | None = None
201        self._selected_soundtrack_index: int | None = None
202        self._soundtrack_widgets: list[ba.Widget] = []
203        self._allow_changing_soundtracks = False
204        self._refresh()
205        if self._back_button is not None:
206            ba.buttonwidget(edit=self._back_button,
207                            on_activate_call=self._back)
208            ba.containerwidget(edit=self._root_widget,
209                               cancel_button=self._back_button)
210        else:
211            ba.containerwidget(edit=self._root_widget,
212                               on_cancel_call=self._back)
213
214    def _update(self) -> None:
215        have = ba.app.accounts_v1.have_pro_options()
216        for lock in self._lock_images:
217            ba.imagewidget(edit=lock, opacity=0.0 if have else 1.0)
218
219    def _do_delete_soundtrack(self) -> None:
220        cfg = ba.app.config
221        soundtracks = cfg.setdefault('Soundtracks', {})
222        if self._selected_soundtrack in soundtracks:
223            del soundtracks[self._selected_soundtrack]
224        cfg.commit()
225        ba.playsound(ba.getsound('shieldDown'))
226        assert self._selected_soundtrack_index is not None
227        assert self._soundtracks is not None
228        if self._selected_soundtrack_index >= len(self._soundtracks):
229            self._selected_soundtrack_index = len(self._soundtracks)
230        self._refresh()
231
232    def _delete_soundtrack(self) -> None:
233        # pylint: disable=cyclic-import
234        from bastd.ui.purchase import PurchaseWindow
235        from bastd.ui.confirm import ConfirmWindow
236        if not ba.app.accounts_v1.have_pro_options():
237            PurchaseWindow(items=['pro'])
238            return
239        if self._selected_soundtrack is None:
240            return
241        if self._selected_soundtrack == '__default__':
242            ba.playsound(ba.getsound('error'))
243            ba.screenmessage(ba.Lstr(resource=self._r +
244                                     '.cantDeleteDefaultText'),
245                             color=(1, 0, 0))
246        else:
247            ConfirmWindow(
248                ba.Lstr(resource=self._r + '.deleteConfirmText',
249                        subs=[('${NAME}', self._selected_soundtrack)]),
250                self._do_delete_soundtrack, 450, 150)
251
252    def _duplicate_soundtrack(self) -> None:
253        # pylint: disable=cyclic-import
254        from bastd.ui.purchase import PurchaseWindow
255        if not ba.app.accounts_v1.have_pro_options():
256            PurchaseWindow(items=['pro'])
257            return
258        cfg = ba.app.config
259        cfg.setdefault('Soundtracks', {})
260
261        if self._selected_soundtrack is None:
262            return
263        sdtk: dict[str, Any]
264        if self._selected_soundtrack == '__default__':
265            sdtk = {}
266        else:
267            sdtk = cfg['Soundtracks'][self._selected_soundtrack]
268
269        # Find a valid dup name that doesn't exist.
270        test_index = 1
271        copy_text = ba.Lstr(resource='copyOfText').evaluate()
272        # Get just 'Copy' or whatnot.
273        copy_word = copy_text.replace('${NAME}', '').strip()
274        base_name = self._get_soundtrack_display_name(
275            self._selected_soundtrack).evaluate()
276        assert isinstance(base_name, str)
277
278        # If it looks like a copy, strip digits and spaces off the end.
279        if copy_word in base_name:
280            while base_name[-1].isdigit() or base_name[-1] == ' ':
281                base_name = base_name[:-1]
282        while True:
283            if copy_word in base_name:
284                test_name = base_name
285            else:
286                test_name = copy_text.replace('${NAME}', base_name)
287            if test_index > 1:
288                test_name += ' ' + str(test_index)
289            if test_name not in cfg['Soundtracks']:
290                break
291            test_index += 1
292
293        cfg['Soundtracks'][test_name] = copy.deepcopy(sdtk)
294        cfg.commit()
295        self._refresh(select_soundtrack=test_name)
296
297    def _select(self, name: str, index: int) -> None:
298        music = ba.app.music
299        self._selected_soundtrack_index = index
300        self._selected_soundtrack = name
301        cfg = ba.app.config
302        current_soundtrack = cfg.setdefault('Soundtrack', '__default__')
303
304        # If it varies from current, commit and play.
305        if current_soundtrack != name and self._allow_changing_soundtracks:
306            ba.playsound(ba.getsound('gunCocking'))
307            cfg['Soundtrack'] = self._selected_soundtrack
308            cfg.commit()
309
310            # Just play whats already playing.. this'll grab it from the
311            # new soundtrack.
312            music.do_play_music(music.music_types[ba.MusicPlayMode.REGULAR])
313
314    def _back(self) -> None:
315        # pylint: disable=cyclic-import
316        from bastd.ui.settings import audio
317        self._save_state()
318        ba.containerwidget(edit=self._root_widget,
319                           transition=self._transition_out)
320        ba.app.ui.set_main_menu_window(
321            audio.AudioSettingsWindow(transition='in_left').get_root_widget())
322
323    def _edit_soundtrack_with_sound(self) -> None:
324        # pylint: disable=cyclic-import
325        from bastd.ui.purchase import PurchaseWindow
326        if not ba.app.accounts_v1.have_pro_options():
327            PurchaseWindow(items=['pro'])
328            return
329        ba.playsound(ba.getsound('swish'))
330        self._edit_soundtrack()
331
332    def _edit_soundtrack(self) -> None:
333        # pylint: disable=cyclic-import
334        from bastd.ui.purchase import PurchaseWindow
335        from bastd.ui.soundtrack.edit import SoundtrackEditWindow
336        if not ba.app.accounts_v1.have_pro_options():
337            PurchaseWindow(items=['pro'])
338            return
339        if self._selected_soundtrack is None:
340            return
341        if self._selected_soundtrack == '__default__':
342            ba.playsound(ba.getsound('error'))
343            ba.screenmessage(ba.Lstr(resource=self._r +
344                                     '.cantEditDefaultText'),
345                             color=(1, 0, 0))
346            return
347
348        self._save_state()
349        ba.containerwidget(edit=self._root_widget, transition='out_left')
350        ba.app.ui.set_main_menu_window(
351            SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack
352                                 ).get_root_widget())
353
354    def _get_soundtrack_display_name(self, soundtrack: str) -> ba.Lstr:
355        if soundtrack == '__default__':
356            return ba.Lstr(resource=self._r + '.defaultSoundtrackNameText')
357        return ba.Lstr(value=soundtrack)
358
359    def _refresh(self, select_soundtrack: str | None = None) -> None:
360        from efro.util import asserttype
361        self._allow_changing_soundtracks = False
362        old_selection = self._selected_soundtrack
363
364        # If there was no prev selection, look in prefs.
365        if old_selection is None:
366            old_selection = ba.app.config.get('Soundtrack')
367        old_selection_index = self._selected_soundtrack_index
368
369        # Delete old.
370        while self._soundtrack_widgets:
371            self._soundtrack_widgets.pop().delete()
372
373        self._soundtracks = ba.app.config.get('Soundtracks', {})
374        assert self._soundtracks is not None
375        items = list(self._soundtracks.items())
376        items.sort(key=lambda x: asserttype(x[0], str).lower())
377        items = [('__default__', None)] + items  # default is always first
378        index = 0
379        for pname, _pval in items:
380            assert pname is not None
381            txtw = ba.textwidget(
382                parent=self._col,
383                size=(self._width - 40, 24),
384                text=self._get_soundtrack_display_name(pname),
385                h_align='left',
386                v_align='center',
387                maxwidth=self._width - 110,
388                always_highlight=True,
389                on_select_call=ba.WeakCall(self._select, pname, index),
390                on_activate_call=self._edit_soundtrack_with_sound,
391                selectable=True)
392            if index == 0:
393                ba.widget(edit=txtw, up_widget=self._back_button)
394            self._soundtrack_widgets.append(txtw)
395
396            # Select this one if the user requested it
397            if select_soundtrack is not None:
398                if pname == select_soundtrack:
399                    ba.columnwidget(edit=self._col,
400                                    selected_child=txtw,
401                                    visible_child=txtw)
402            else:
403                # Select this one if it was previously selected.
404                # Go by index if there's one.
405                if old_selection_index is not None:
406                    if index == old_selection_index:
407                        ba.columnwidget(edit=self._col,
408                                        selected_child=txtw,
409                                        visible_child=txtw)
410                else:  # Otherwise look by name.
411                    if pname == old_selection:
412                        ba.columnwidget(edit=self._col,
413                                        selected_child=txtw,
414                                        visible_child=txtw)
415            index += 1
416
417        # Explicitly run select callback on current one and re-enable
418        # callbacks.
419
420        # Eww need to run this in a timer so it happens after our select
421        # callbacks. With a small-enough time sometimes it happens before
422        # anyway. Ew. need a way to just schedule a callable i guess.
423        ba.timer(0.1,
424                 ba.WeakCall(self._set_allow_changing),
425                 timetype=ba.TimeType.REAL)
426
427    def _set_allow_changing(self) -> None:
428        self._allow_changing_soundtracks = True
429        assert self._selected_soundtrack is not None
430        assert self._selected_soundtrack_index is not None
431        self._select(self._selected_soundtrack,
432                     self._selected_soundtrack_index)
433
434    def _new_soundtrack(self) -> None:
435        # pylint: disable=cyclic-import
436        from bastd.ui.purchase import PurchaseWindow
437        from bastd.ui.soundtrack.edit import SoundtrackEditWindow
438        if not ba.app.accounts_v1.have_pro_options():
439            PurchaseWindow(items=['pro'])
440            return
441        self._save_state()
442        ba.containerwidget(edit=self._root_widget, transition='out_left')
443        SoundtrackEditWindow(existing_soundtrack=None)
444
445    def _create_done(self, new_soundtrack: str) -> None:
446        if new_soundtrack is not None:
447            ba.playsound(ba.getsound('gunCocking'))
448            self._refresh(select_soundtrack=new_soundtrack)
449
450    def _save_state(self) -> None:
451        try:
452            sel = self._root_widget.get_selected_child()
453            if sel == self._scrollwidget:
454                sel_name = 'Scroll'
455            elif sel == self._new_button:
456                sel_name = 'New'
457            elif sel == self._edit_button:
458                sel_name = 'Edit'
459            elif sel == self._duplicate_button:
460                sel_name = 'Duplicate'
461            elif sel == self._delete_button:
462                sel_name = 'Delete'
463            elif sel == self._back_button:
464                sel_name = 'Back'
465            else:
466                raise ValueError(f'unrecognized selection \'{sel}\'')
467            ba.app.ui.window_states[type(self)] = sel_name
468        except Exception:
469            ba.print_exception(f'Error saving state for {self}.')
470
471    def _restore_state(self) -> None:
472        try:
473            sel_name = ba.app.ui.window_states.get(type(self))
474            if sel_name == 'Scroll':
475                sel = self._scrollwidget
476            elif sel_name == 'New':
477                sel = self._new_button
478            elif sel_name == 'Edit':
479                sel = self._edit_button
480            elif sel_name == 'Duplicate':
481                sel = self._duplicate_button
482            elif sel_name == 'Delete':
483                sel = self._delete_button
484            else:
485                sel = self._scrollwidget
486            ba.containerwidget(edit=self._root_widget, selected_child=sel)
487        except Exception:
488            ba.print_exception(f'Error restoring state for {self}.')

Window for browsing soundtracks.

SoundtrackBrowserWindow( transition: str = 'in_right', origin_widget: _ba.Widget | None = None)
 21    def __init__(self,
 22                 transition: str = 'in_right',
 23                 origin_widget: ba.Widget | None = None):
 24        # pylint: disable=too-many-locals
 25        # pylint: disable=too-many-statements
 26
 27        # If they provided an origin-widget, scale up from that.
 28        scale_origin: tuple[float, float] | None
 29        if origin_widget is not None:
 30            self._transition_out = 'out_scale'
 31            scale_origin = origin_widget.get_screen_space_center()
 32            transition = 'in_scale'
 33        else:
 34            self._transition_out = 'out_right'
 35            scale_origin = None
 36
 37        self._r = 'editSoundtrackWindow'
 38        uiscale = ba.app.ui.uiscale
 39        self._width = 800 if uiscale is ba.UIScale.SMALL else 600
 40        x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 41        self._height = (340 if uiscale is ba.UIScale.SMALL else
 42                        370 if uiscale is ba.UIScale.MEDIUM else 440)
 43        spacing = 40.0
 44        v = self._height - 40.0
 45        v -= spacing * 1.0
 46
 47        super().__init__(root_widget=ba.containerwidget(
 48            size=(self._width, self._height),
 49            transition=transition,
 50            toolbar_visibility='menu_minimal',
 51            scale_origin_stack_offset=scale_origin,
 52            scale=(2.3 if uiscale is ba.UIScale.SMALL else
 53                   1.6 if uiscale is ba.UIScale.MEDIUM else 1.0),
 54            stack_offset=(0, -18) if uiscale is ba.UIScale.SMALL else (0, 0)))
 55
 56        if ba.app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 57            self._back_button = None
 58        else:
 59            self._back_button = ba.buttonwidget(
 60                parent=self._root_widget,
 61                position=(45 + x_inset, self._height - 60),
 62                size=(120, 60),
 63                scale=0.8,
 64                label=ba.Lstr(resource='backText'),
 65                button_type='back',
 66                autoselect=True)
 67            ba.buttonwidget(edit=self._back_button,
 68                            button_type='backSmall',
 69                            size=(60, 60),
 70                            label=ba.charstr(ba.SpecialChar.BACK))
 71        ba.textwidget(parent=self._root_widget,
 72                      position=(self._width * 0.5, self._height - 35),
 73                      size=(0, 0),
 74                      maxwidth=300,
 75                      text=ba.Lstr(resource=self._r + '.titleText'),
 76                      color=ba.app.ui.title_color,
 77                      h_align='center',
 78                      v_align='center')
 79
 80        h = 43 + x_inset
 81        v = self._height - 60
 82        b_color = (0.6, 0.53, 0.63)
 83        b_textcolor = (0.75, 0.7, 0.8)
 84        lock_tex = ba.gettexture('lock')
 85        self._lock_images: list[ba.Widget] = []
 86
 87        scl = (1.0 if uiscale is ba.UIScale.SMALL else
 88               1.13 if uiscale is ba.UIScale.MEDIUM else 1.4)
 89        v -= 60.0 * scl
 90        self._new_button = btn = ba.buttonwidget(
 91            parent=self._root_widget,
 92            position=(h, v),
 93            size=(100, 55.0 * scl),
 94            on_activate_call=self._new_soundtrack,
 95            color=b_color,
 96            button_type='square',
 97            autoselect=True,
 98            textcolor=b_textcolor,
 99            text_scale=0.7,
100            label=ba.Lstr(resource=self._r + '.newText'))
101        self._lock_images.append(
102            ba.imagewidget(parent=self._root_widget,
103                           size=(30, 30),
104                           draw_controller=btn,
105                           position=(h - 10, v + 55.0 * scl - 28),
106                           texture=lock_tex))
107
108        if self._back_button is None:
109            ba.widget(edit=btn,
110                      left_widget=_ba.get_special_widget('back_button'))
111        v -= 60.0 * scl
112
113        self._edit_button = btn = ba.buttonwidget(
114            parent=self._root_widget,
115            position=(h, v),
116            size=(100, 55.0 * scl),
117            on_activate_call=self._edit_soundtrack,
118            color=b_color,
119            button_type='square',
120            autoselect=True,
121            textcolor=b_textcolor,
122            text_scale=0.7,
123            label=ba.Lstr(resource=self._r + '.editText'))
124        self._lock_images.append(
125            ba.imagewidget(parent=self._root_widget,
126                           size=(30, 30),
127                           draw_controller=btn,
128                           position=(h - 10, v + 55.0 * scl - 28),
129                           texture=lock_tex))
130        if self._back_button is None:
131            ba.widget(edit=btn,
132                      left_widget=_ba.get_special_widget('back_button'))
133        v -= 60.0 * scl
134
135        self._duplicate_button = btn = ba.buttonwidget(
136            parent=self._root_widget,
137            position=(h, v),
138            size=(100, 55.0 * scl),
139            on_activate_call=self._duplicate_soundtrack,
140            button_type='square',
141            autoselect=True,
142            color=b_color,
143            textcolor=b_textcolor,
144            text_scale=0.7,
145            label=ba.Lstr(resource=self._r + '.duplicateText'))
146        self._lock_images.append(
147            ba.imagewidget(parent=self._root_widget,
148                           size=(30, 30),
149                           draw_controller=btn,
150                           position=(h - 10, v + 55.0 * scl - 28),
151                           texture=lock_tex))
152        if self._back_button is None:
153            ba.widget(edit=btn,
154                      left_widget=_ba.get_special_widget('back_button'))
155        v -= 60.0 * scl
156
157        self._delete_button = btn = ba.buttonwidget(
158            parent=self._root_widget,
159            position=(h, v),
160            size=(100, 55.0 * scl),
161            on_activate_call=self._delete_soundtrack,
162            color=b_color,
163            button_type='square',
164            autoselect=True,
165            textcolor=b_textcolor,
166            text_scale=0.7,
167            label=ba.Lstr(resource=self._r + '.deleteText'))
168        self._lock_images.append(
169            ba.imagewidget(parent=self._root_widget,
170                           size=(30, 30),
171                           draw_controller=btn,
172                           position=(h - 10, v + 55.0 * scl - 28),
173                           texture=lock_tex))
174        if self._back_button is None:
175            ba.widget(edit=btn,
176                      left_widget=_ba.get_special_widget('back_button'))
177
178        # Keep our lock images up to date/etc.
179        self._update_timer = ba.Timer(1.0,
180                                      ba.WeakCall(self._update),
181                                      timetype=ba.TimeType.REAL,
182                                      repeat=True)
183        self._update()
184
185        v = self._height - 65
186        scroll_height = self._height - 105
187        v -= scroll_height
188        self._scrollwidget = scrollwidget = ba.scrollwidget(
189            parent=self._root_widget,
190            position=(152 + x_inset, v),
191            highlight=False,
192            size=(self._width - (205 + 2 * x_inset), scroll_height))
193        ba.widget(edit=self._scrollwidget,
194                  left_widget=self._new_button,
195                  right_widget=_ba.get_special_widget('party_button')
196                  if ba.app.ui.use_toolbars else self._scrollwidget)
197        self._col = ba.columnwidget(parent=scrollwidget, border=2, margin=0)
198
199        self._soundtracks: dict[str, Any] | None = None
200        self._selected_soundtrack: str | None = None
201        self._selected_soundtrack_index: int | None = None
202        self._soundtrack_widgets: list[ba.Widget] = []
203        self._allow_changing_soundtracks = False
204        self._refresh()
205        if self._back_button is not None:
206            ba.buttonwidget(edit=self._back_button,
207                            on_activate_call=self._back)
208            ba.containerwidget(edit=self._root_widget,
209                               cancel_button=self._back_button)
210        else:
211            ba.containerwidget(edit=self._root_widget,
212                               on_cancel_call=self._back)
Inherited Members
ba.ui.Window
get_root_widget