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

Window for browsing soundtracks.

SoundtrackBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 23    def __init__(
 24        self,
 25        transition: str | None = 'in_right',
 26        origin_widget: bui.Widget | None = None,
 27    ):
 28        # pylint: disable=too-many-statements
 29        # pylint: disable=too-many-locals
 30
 31        self._r = 'editSoundtrackWindow'
 32        assert bui.app.classic is not None
 33        uiscale = bui.app.ui_v1.uiscale
 34        self._width = 800 if uiscale is bui.UIScale.SMALL else 600
 35        x_inset = 100 if uiscale is bui.UIScale.SMALL else 0
 36        yoffs = -30 if uiscale is bui.UIScale.SMALL else 0
 37        self._height = (
 38            400
 39            if uiscale is bui.UIScale.SMALL
 40            else 370 if uiscale is bui.UIScale.MEDIUM else 440
 41        )
 42        spacing = 40.0
 43        v = self._height - 40.0 + yoffs
 44        v -= spacing * 1.0
 45
 46        super().__init__(
 47            root_widget=bui.containerwidget(
 48                size=(self._width, self._height),
 49                toolbar_visibility=(
 50                    'menu_minimal'
 51                    if uiscale is bui.UIScale.SMALL
 52                    else 'menu_full'
 53                ),
 54                scale=(
 55                    2.1
 56                    if uiscale is bui.UIScale.SMALL
 57                    else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0
 58                ),
 59                stack_offset=(
 60                    (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0)
 61                ),
 62            ),
 63            transition=transition,
 64            origin_widget=origin_widget,
 65        )
 66
 67        assert bui.app.classic is not None
 68        if uiscale is bui.UIScale.SMALL:
 69            self._back_button = None
 70        else:
 71            self._back_button = bui.buttonwidget(
 72                parent=self._root_widget,
 73                position=(45 + x_inset, self._height - 60 + yoffs),
 74                size=(120, 60),
 75                scale=0.8,
 76                label=bui.Lstr(resource='backText'),
 77                button_type='back',
 78                autoselect=True,
 79            )
 80            bui.buttonwidget(
 81                edit=self._back_button,
 82                button_type='backSmall',
 83                size=(60, 60),
 84                label=bui.charstr(bui.SpecialChar.BACK),
 85            )
 86        bui.textwidget(
 87            parent=self._root_widget,
 88            position=(
 89                self._width * 0.5,
 90                self._height
 91                - (46 if uiscale is bui.UIScale.SMALL else 35)
 92                + yoffs,
 93            ),
 94            size=(0, 0),
 95            maxwidth=300,
 96            text=bui.Lstr(resource=f'{self._r}.titleText'),
 97            color=bui.app.ui_v1.title_color,
 98            h_align='center',
 99            v_align='center',
100        )
101
102        h = 43 + x_inset
103        v = self._height - 60 + yoffs
104        b_color = (0.6, 0.53, 0.63)
105        b_textcolor = (0.75, 0.7, 0.8)
106        lock_tex = bui.gettexture('lock')
107        self._lock_images: list[bui.Widget] = []
108
109        scl = (
110            1.0
111            if uiscale is bui.UIScale.SMALL
112            else 1.13 if uiscale is bui.UIScale.MEDIUM else 1.4
113        )
114        v -= 60.0 * scl
115        self._new_button = btn = bui.buttonwidget(
116            parent=self._root_widget,
117            position=(h, v),
118            size=(100, 55.0 * scl),
119            on_activate_call=self._new_soundtrack,
120            color=b_color,
121            button_type='square',
122            autoselect=True,
123            textcolor=b_textcolor,
124            text_scale=0.7,
125            label=bui.Lstr(resource=f'{self._r}.newText'),
126        )
127        self._lock_images.append(
128            bui.imagewidget(
129                parent=self._root_widget,
130                size=(30, 30),
131                draw_controller=btn,
132                position=(h - 10, v + 55.0 * scl - 28),
133                texture=lock_tex,
134            )
135        )
136
137        if self._back_button is None:
138            bui.widget(
139                edit=btn,
140                left_widget=bui.get_special_widget('back_button'),
141            )
142        v -= 60.0 * scl
143
144        self._edit_button = btn = bui.buttonwidget(
145            parent=self._root_widget,
146            position=(h, v),
147            size=(100, 55.0 * scl),
148            on_activate_call=self._edit_soundtrack,
149            color=b_color,
150            button_type='square',
151            autoselect=True,
152            textcolor=b_textcolor,
153            text_scale=0.7,
154            label=bui.Lstr(resource=f'{self._r}.editText'),
155        )
156        self._lock_images.append(
157            bui.imagewidget(
158                parent=self._root_widget,
159                size=(30, 30),
160                draw_controller=btn,
161                position=(h - 10, v + 55.0 * scl - 28),
162                texture=lock_tex,
163            )
164        )
165        if self._back_button is None:
166            bui.widget(
167                edit=btn,
168                left_widget=bui.get_special_widget('back_button'),
169            )
170        v -= 60.0 * scl
171
172        self._duplicate_button = btn = bui.buttonwidget(
173            parent=self._root_widget,
174            position=(h, v),
175            size=(100, 55.0 * scl),
176            on_activate_call=self._duplicate_soundtrack,
177            button_type='square',
178            autoselect=True,
179            color=b_color,
180            textcolor=b_textcolor,
181            text_scale=0.7,
182            label=bui.Lstr(resource=f'{self._r}.duplicateText'),
183        )
184        self._lock_images.append(
185            bui.imagewidget(
186                parent=self._root_widget,
187                size=(30, 30),
188                draw_controller=btn,
189                position=(h - 10, v + 55.0 * scl - 28),
190                texture=lock_tex,
191            )
192        )
193        if self._back_button is None:
194            bui.widget(
195                edit=btn,
196                left_widget=bui.get_special_widget('back_button'),
197            )
198        v -= 60.0 * scl
199
200        self._delete_button = btn = bui.buttonwidget(
201            parent=self._root_widget,
202            position=(h, v),
203            size=(100, 55.0 * scl),
204            on_activate_call=self._delete_soundtrack,
205            color=b_color,
206            button_type='square',
207            autoselect=True,
208            textcolor=b_textcolor,
209            text_scale=0.7,
210            label=bui.Lstr(resource=f'{self._r}.deleteText'),
211        )
212        self._lock_images.append(
213            bui.imagewidget(
214                parent=self._root_widget,
215                size=(30, 30),
216                draw_controller=btn,
217                position=(h - 10, v + 55.0 * scl - 28),
218                texture=lock_tex,
219            )
220        )
221        if self._back_button is None:
222            bui.widget(
223                edit=btn,
224                left_widget=bui.get_special_widget('back_button'),
225            )
226
227        # Keep our lock images up to date/etc.
228        self._update_timer = bui.AppTimer(
229            1.0, bui.WeakCall(self._update), repeat=True
230        )
231        self._update()
232
233        v = self._height - 65 + yoffs
234        scroll_height = self._height - (
235            160 if uiscale is bui.UIScale.SMALL else 105
236        )
237        v -= scroll_height
238        self._scrollwidget = scrollwidget = bui.scrollwidget(
239            parent=self._root_widget,
240            position=(152 + x_inset, v),
241            highlight=False,
242            size=(self._width - (205 + 2 * x_inset), scroll_height),
243        )
244        bui.widget(
245            edit=self._scrollwidget,
246            left_widget=self._new_button,
247            right_widget=bui.get_special_widget('squad_button'),
248        )
249        self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0)
250
251        self._soundtracks: dict[str, Any] | None = None
252        self._selected_soundtrack: str | None = None
253        self._selected_soundtrack_index: int | None = None
254        self._soundtrack_widgets: list[bui.Widget] = []
255        self._allow_changing_soundtracks = False
256        self._refresh()
257        if self._back_button is not None:
258            bui.buttonwidget(
259                edit=self._back_button, on_activate_call=self.main_window_back
260            )
261            bui.containerwidget(
262                edit=self._root_widget, cancel_button=self._back_button
263            )
264        else:
265            bui.containerwidget(
266                edit=self._root_widget, on_cancel_call=self.main_window_back
267            )

Create a MainWindow given a root widget and transition info.

Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
269    @override
270    def get_main_window_state(self) -> bui.MainWindowState:
271        # Support recreating our window for back/refresh purposes.
272        cls = type(self)
273        return bui.BasicMainWindowState(
274            create_call=lambda transition, origin_widget: cls(
275                transition=transition, origin_widget=origin_widget
276            )
277        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
279    @override
280    def on_main_window_close(self) -> None:
281        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.