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