bauiv1lib.soundtrack.edit

Provides UI for editing a soundtrack.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides UI for editing a soundtrack."""
  4
  5from __future__ import annotations
  6
  7import copy
  8import os
  9from typing import TYPE_CHECKING, cast, override
 10
 11import bascenev1 as bs
 12import bauiv1 as bui
 13
 14if TYPE_CHECKING:
 15    from typing import Any
 16
 17
 18class SoundtrackEditWindow(bui.MainWindow):
 19    """Window for editing a soundtrack."""
 20
 21    def __init__(
 22        self,
 23        existing_soundtrack: str | dict[str, Any] | None,
 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        appconfig = bui.app.config
 31        self._r = 'editSoundtrackWindow'
 32        self._folder_tex = bui.gettexture('folder')
 33        self._file_tex = bui.gettexture('file')
 34        assert bui.app.classic is not None
 35        uiscale = bui.app.ui_v1.uiscale
 36        self._width = 1200 if uiscale is bui.UIScale.SMALL else 648
 37        self._height = (
 38            800
 39            if uiscale is bui.UIScale.SMALL
 40            else 450 if uiscale is bui.UIScale.MEDIUM else 560
 41        )
 42
 43        # Do some fancy math to fill all available screen area up to the
 44        # size of our backing container. This lets us fit to the exact
 45        # screen shape at small ui scale.
 46        screensize = bui.get_virtual_screen_size()
 47        scale = (
 48            1.8
 49            if uiscale is bui.UIScale.SMALL
 50            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 51        )
 52        # Calc screen size in our local container space and clamp to a
 53        # bit smaller than our container size.
 54        target_width = min(self._width - 70, screensize[0] / scale)
 55        target_height = min(self._height - 80, screensize[1] / scale)
 56
 57        # To get top/left coords, go to the center of our window and
 58        # offset by half the width/height of our target area.
 59        yoffs = (
 60            0.5 * self._height
 61            + 0.5 * target_height
 62            + (10.0 if uiscale is bui.UIScale.SMALL else 20)
 63        )
 64
 65        self._scroll_width = target_width
 66        self._scroll_height = target_height - 113
 67        scroll_bottom = yoffs - 120 - self._scroll_height
 68
 69        x_inset = self._width * 0.5 - 0.5 * self._scroll_width
 70
 71        super().__init__(
 72            root_widget=bui.containerwidget(
 73                size=(self._width, self._height), scale=scale
 74            ),
 75            transition=transition,
 76            origin_widget=origin_widget,
 77            # We're affected by screen size only at small ui-scale.
 78            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 79        )
 80        cancel_button = bui.buttonwidget(
 81            parent=self._root_widget,
 82            position=(x_inset + 10, yoffs - 60),
 83            size=(160, 60),
 84            autoselect=True,
 85            label=bui.Lstr(resource='cancelText'),
 86            scale=0.8,
 87        )
 88        save_button = bui.buttonwidget(
 89            parent=self._root_widget,
 90            position=(
 91                self._width * 0.5
 92                + self._scroll_width * 0.5
 93                - (190 if uiscale is bui.UIScale.SMALL else 140),
 94                yoffs - 60,
 95            ),
 96            autoselect=True,
 97            size=(160, 60),
 98            label=bui.Lstr(resource='saveText'),
 99            scale=0.8,
100        )
101        bui.widget(edit=save_button, left_widget=cancel_button)
102        bui.widget(edit=cancel_button, right_widget=save_button)
103        bui.textwidget(
104            parent=self._root_widget,
105            position=(0, yoffs - 50),
106            size=(self._width, 25),
107            text=bui.Lstr(
108                resource=self._r
109                + (
110                    '.editSoundtrackText'
111                    if existing_soundtrack is not None
112                    else '.newSoundtrackText'
113                )
114            ),
115            color=bui.app.ui_v1.title_color,
116            h_align='center',
117            v_align='center',
118            maxwidth=270,
119        )
120        v = yoffs - 110
121        if 'Soundtracks' not in appconfig:
122            appconfig['Soundtracks'] = {}
123
124        self._soundtrack_name: str | None
125        self._existing_soundtrack = existing_soundtrack
126        self._existing_soundtrack_name: str | None
127        if existing_soundtrack is not None:
128            # if they passed just a name, pull info from that soundtrack
129            if isinstance(existing_soundtrack, str):
130                self._soundtrack = copy.deepcopy(
131                    appconfig['Soundtracks'][existing_soundtrack]
132                )
133                self._soundtrack_name = existing_soundtrack
134                self._existing_soundtrack_name = existing_soundtrack
135                self._last_edited_song_type = None
136            else:
137                # Otherwise they can pass info on an in-progress edit.
138                self._soundtrack = existing_soundtrack['soundtrack']
139                self._soundtrack_name = existing_soundtrack['name']
140                self._existing_soundtrack_name = existing_soundtrack[
141                    'existing_name'
142                ]
143                self._last_edited_song_type = existing_soundtrack[
144                    'last_edited_song_type'
145                ]
146        else:
147            self._soundtrack_name = None
148            self._existing_soundtrack_name = None
149            self._soundtrack = {}
150            self._last_edited_song_type = None
151
152        bui.textwidget(
153            parent=self._root_widget,
154            text=bui.Lstr(resource=f'{self._r}.nameText'),
155            maxwidth=80,
156            scale=0.8,
157            position=(105 + x_inset, v + 19),
158            color=(0.8, 0.8, 0.8, 0.5),
159            size=(0, 0),
160            h_align='right',
161            v_align='center',
162        )
163
164        # if there's no initial value, find a good initial unused name
165        if existing_soundtrack is None:
166            i = 1
167            st_name_text = bui.Lstr(
168                resource=f'{self._r}.newSoundtrackNameText'
169            ).evaluate()
170            if '${COUNT}' not in st_name_text:
171                # make sure we insert number *somewhere*
172                st_name_text = st_name_text + ' ${COUNT}'
173            while True:
174                self._soundtrack_name = st_name_text.replace('${COUNT}', str(i))
175                if self._soundtrack_name not in appconfig['Soundtracks']:
176                    break
177                i += 1
178
179        self._text_field = bui.textwidget(
180            parent=self._root_widget,
181            position=(120 + x_inset, v - 5),
182            size=(self._width - (160 + 2 * x_inset), 43),
183            text=self._soundtrack_name,
184            h_align='left',
185            v_align='center',
186            max_chars=32,
187            autoselect=True,
188            description=bui.Lstr(resource=f'{self._r}.nameText'),
189            editable=True,
190            padding=4,
191            on_return_press_call=self._do_it_with_sound,
192        )
193
194        self._scrollwidget = scrollwidget = bui.scrollwidget(
195            parent=self._root_widget,
196            highlight=False,
197            size=(self._scroll_width, self._scroll_height),
198            position=(
199                self._width * 0.5 - self._scroll_width * 0.5,
200                scroll_bottom,
201            ),
202            simple_culling_v=10,
203            claims_left_right=True,
204            selection_loops_to_parent=True,
205            border_opacity=0.4,
206        )
207        bui.widget(edit=self._text_field, down_widget=self._scrollwidget)
208        self._col = bui.columnwidget(
209            parent=scrollwidget,
210            claims_left_right=True,
211            selection_loops_to_parent=True,
212        )
213
214        self._song_type_buttons: dict[str, bui.Widget] = {}
215        self._refresh()
216        bui.buttonwidget(edit=cancel_button, on_activate_call=self._cancel)
217        bui.containerwidget(edit=self._root_widget, cancel_button=cancel_button)
218        bui.buttonwidget(edit=save_button, on_activate_call=self._do_it)
219        bui.containerwidget(edit=self._root_widget, start_button=save_button)
220        bui.widget(edit=self._text_field, up_widget=cancel_button)
221        bui.widget(edit=cancel_button, down_widget=self._text_field)
222
223    @override
224    def get_main_window_state(self) -> bui.MainWindowState:
225        # Support recreating our window for back/refresh purposes.
226        cls = type(self)
227
228        # Pull this out of self here; if we do it in the lambda we'll
229        # keep our window alive due to the 'self' reference.
230        existing_soundtrack = {
231            'name': self._soundtrack_name,
232            'existing_name': self._existing_soundtrack_name,
233            'soundtrack': self._soundtrack,
234            'last_edited_song_type': self._last_edited_song_type,
235        }
236
237        return bui.BasicMainWindowState(
238            create_call=lambda transition, origin_widget: cls(
239                transition=transition,
240                origin_widget=origin_widget,
241                existing_soundtrack=existing_soundtrack,
242            )
243        )
244
245    def _refresh(self) -> None:
246        for widget in self._col.get_children():
247            widget.delete()
248
249        types = [
250            'Menu',
251            'CharSelect',
252            'ToTheDeath',
253            'Onslaught',
254            'Keep Away',
255            'Race',
256            'Epic Race',
257            'ForwardMarch',
258            'FlagCatcher',
259            'Survival',
260            'Epic',
261            'Hockey',
262            'Football',
263            'Flying',
264            'Scary',
265            'Marching',
266            'GrandRomp',
267            'Chosen One',
268            'Scores',
269            'Victory',
270        ]
271
272        # FIXME: We should probably convert this to use translations.
273        type_names_translated = bui.app.lang.get_resource('soundtrackTypeNames')
274        prev_type_button: bui.Widget | None = None
275        prev_test_button: bui.Widget | None = None
276
277        for index, song_type in enumerate(types):
278            row = bui.rowwidget(
279                parent=self._col,
280                size=(self._scroll_width, 40),
281                claims_left_right=True,
282                selection_loops_to_parent=True,
283            )
284            type_name = type_names_translated.get(song_type, song_type)
285            bui.textwidget(
286                parent=row,
287                size=(self._scroll_width - 350, 25),
288                always_highlight=True,
289                text=type_name,
290                scale=0.7,
291                h_align='left',
292                v_align='center',
293                maxwidth=self._scroll_width - 360,
294            )
295
296            if song_type in self._soundtrack:
297                entry = self._soundtrack[song_type]
298            else:
299                entry = None
300
301            if entry is not None:
302                # Make sure they don't muck with this after it gets to us.
303                entry = copy.deepcopy(entry)
304
305            icon_type = self._get_entry_button_display_icon_type(entry)
306            self._song_type_buttons[song_type] = btn = bui.buttonwidget(
307                parent=row,
308                size=(230, 32),
309                label=self._get_entry_button_display_name(entry),
310                text_scale=0.6,
311                on_activate_call=bui.Call(
312                    self._get_entry, song_type, entry, type_name
313                ),
314                icon=(
315                    self._file_tex
316                    if icon_type == 'file'
317                    else self._folder_tex if icon_type == 'folder' else None
318                ),
319                icon_color=(
320                    (1.1, 0.8, 0.2) if icon_type == 'folder' else (1, 1, 1)
321                ),
322                left_widget=self._text_field,
323                iconscale=0.7,
324                autoselect=True,
325                up_widget=prev_type_button,
326            )
327            if index == 0:
328                bui.widget(edit=btn, up_widget=self._text_field)
329            bui.widget(edit=btn, down_widget=btn)
330
331            if (
332                self._last_edited_song_type is not None
333                and song_type == self._last_edited_song_type
334            ):
335                bui.containerwidget(
336                    edit=row, selected_child=btn, visible_child=btn
337                )
338                bui.containerwidget(
339                    edit=self._col, selected_child=row, visible_child=row
340                )
341                bui.containerwidget(
342                    edit=self._scrollwidget,
343                    selected_child=self._col,
344                    visible_child=self._col,
345                )
346                bui.containerwidget(
347                    edit=self._root_widget,
348                    selected_child=self._scrollwidget,
349                    visible_child=self._scrollwidget,
350                )
351
352            if prev_type_button is not None:
353                bui.widget(edit=prev_type_button, down_widget=btn)
354            prev_type_button = btn
355            bui.textwidget(parent=row, size=(10, 32), text='')  # spacing
356            assert bui.app.classic is not None
357            btn = bui.buttonwidget(
358                parent=row,
359                size=(50, 32),
360                label=bui.Lstr(resource=f'{self._r}.testText'),
361                text_scale=0.6,
362                on_activate_call=bui.Call(self._test, bs.MusicType(song_type)),
363                up_widget=(
364                    prev_test_button
365                    if prev_test_button is not None
366                    else self._text_field
367                ),
368            )
369            if prev_test_button is not None:
370                bui.widget(edit=prev_test_button, down_widget=btn)
371            bui.widget(edit=btn, down_widget=btn, right_widget=btn)
372            prev_test_button = btn
373
374    @classmethod
375    def _restore_editor(
376        cls, state: dict[str, Any], musictype: str, entry: Any
377    ) -> None:
378        assert bui.app.classic is not None
379        music = bui.app.classic.music
380
381        # Apply the change and recreate the window.
382        soundtrack = state['soundtrack']
383        existing_entry = (
384            None if musictype not in soundtrack else soundtrack[musictype]
385        )
386        if existing_entry != entry:
387            bui.getsound('gunCocking').play()
388
389        # Make sure this doesn't get mucked with after we get it.
390        if entry is not None:
391            entry = copy.deepcopy(entry)
392
393        entry_type = music.get_soundtrack_entry_type(entry)
394        if entry_type == 'default':
395            # For 'default' entries simply exclude them from the list.
396            if musictype in soundtrack:
397                del soundtrack[musictype]
398        else:
399            soundtrack[musictype] = entry
400
401        mainwindow = bui.app.ui_v1.get_main_window()
402        assert mainwindow is not None
403
404        mainwindow.main_window_back_state = state['back_state']
405        mainwindow.main_window_back()
406
407    def _get_entry(
408        self, song_type: str, entry: Any, selection_target_name: str
409    ) -> None:
410        assert bui.app.classic is not None
411        music = bui.app.classic.music
412
413        # no-op if we're not in control.
414        if not self.main_window_has_control():
415            return
416
417        if selection_target_name != '':
418            selection_target_name = "'" + selection_target_name + "'"
419        state = {
420            'name': self._soundtrack_name,
421            'existing_name': self._existing_soundtrack_name,
422            'soundtrack': self._soundtrack,
423            'last_edited_song_type': song_type,
424        }
425        new_win = music.get_music_player().select_entry(
426            bui.Call(self._restore_editor, state, song_type),
427            entry,
428            selection_target_name,
429        )
430        self.main_window_replace(new_win)
431
432        # Once we've set the new window, grab the back-state; we'll use
433        # that to jump back here after selection completes.
434        assert new_win.main_window_back_state is not None
435        state['back_state'] = new_win.main_window_back_state
436
437    def _test(self, song_type: bs.MusicType) -> None:
438        assert bui.app.classic is not None
439        music = bui.app.classic.music
440
441        # Warn if volume is zero.
442        if bui.app.config.resolve('Music Volume') < 0.01:
443            bui.getsound('error').play()
444            bui.screenmessage(
445                bui.Lstr(resource=f'{self._r}.musicVolumeZeroWarning'),
446                color=(1, 0.5, 0),
447            )
448        music.set_music_play_mode(bui.app.classic.MusicPlayMode.TEST)
449        music.do_play_music(
450            song_type,
451            mode=bui.app.classic.MusicPlayMode.TEST,
452            testsoundtrack=self._soundtrack,
453        )
454
455    def _get_entry_button_display_name(self, entry: Any) -> str | bui.Lstr:
456        assert bui.app.classic is not None
457        music = bui.app.classic.music
458        etype = music.get_soundtrack_entry_type(entry)
459        ename: str | bui.Lstr
460        if etype == 'default':
461            ename = bui.Lstr(resource=f'{self._r}.defaultGameMusicText')
462        elif etype in ('musicFile', 'musicFolder'):
463            ename = os.path.basename(music.get_soundtrack_entry_name(entry))
464        else:
465            ename = music.get_soundtrack_entry_name(entry)
466        return ename
467
468    def _get_entry_button_display_icon_type(self, entry: Any) -> str | None:
469        assert bui.app.classic is not None
470        music = bui.app.classic.music
471        etype = music.get_soundtrack_entry_type(entry)
472        if etype == 'musicFile':
473            return 'file'
474        if etype == 'musicFolder':
475            return 'folder'
476        return None
477
478    def _cancel(self) -> None:
479        # from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow
480
481        # no-op if our underlying widget is dead or on its way out.
482        if not self._root_widget or self._root_widget.transitioning_out:
483            return
484
485        assert bui.app.classic is not None
486        music = bui.app.classic.music
487
488        # Resets music back to normal.
489        music.set_music_play_mode(bui.app.classic.MusicPlayMode.REGULAR)
490
491        self.main_window_back()
492
493    def _do_it(self) -> None:
494
495        # no-op if our underlying widget is dead or on its way out.
496        if not self._root_widget or self._root_widget.transitioning_out:
497            return
498
499        assert bui.app.classic is not None
500        music = bui.app.classic.music
501        cfg = bui.app.config
502        new_name = cast(str, bui.textwidget(query=self._text_field))
503        if new_name != self._soundtrack_name and new_name in cfg['Soundtracks']:
504            bui.screenmessage(
505                bui.Lstr(resource=f'{self._r}.cantSaveAlreadyExistsText')
506            )
507            bui.getsound('error').play()
508            return
509        if not new_name:
510            bui.getsound('error').play()
511            return
512        if (
513            new_name
514            == bui.Lstr(
515                resource=f'{self._r}.defaultSoundtrackNameText'
516            ).evaluate()
517        ):
518            bui.screenmessage(
519                bui.Lstr(resource=f'{self._r}.cantOverwriteDefaultText')
520            )
521            bui.getsound('error').play()
522            return
523
524        # Make sure config exists.
525        if 'Soundtracks' not in cfg:
526            cfg['Soundtracks'] = {}
527
528        # If we had an old one, delete it.
529        if (
530            self._existing_soundtrack_name is not None
531            and self._existing_soundtrack_name in cfg['Soundtracks']
532        ):
533            del cfg['Soundtracks'][self._existing_soundtrack_name]
534        cfg['Soundtracks'][new_name] = self._soundtrack
535        cfg['Soundtrack'] = new_name
536
537        cfg.commit()
538        bui.getsound('gunCocking').play()
539
540        # Resets music back to normal.
541        music.set_music_play_mode(
542            bui.app.classic.MusicPlayMode.REGULAR, force_restart=True
543        )
544
545        self.main_window_back()
546
547    def _do_it_with_sound(self) -> None:
548        bui.getsound('swish').play()
549        self._do_it()
class SoundtrackEditWindow(bauiv1._uitypes.MainWindow):
 19class SoundtrackEditWindow(bui.MainWindow):
 20    """Window for editing a soundtrack."""
 21
 22    def __init__(
 23        self,
 24        existing_soundtrack: str | dict[str, Any] | None,
 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        appconfig = bui.app.config
 32        self._r = 'editSoundtrackWindow'
 33        self._folder_tex = bui.gettexture('folder')
 34        self._file_tex = bui.gettexture('file')
 35        assert bui.app.classic is not None
 36        uiscale = bui.app.ui_v1.uiscale
 37        self._width = 1200 if uiscale is bui.UIScale.SMALL else 648
 38        self._height = (
 39            800
 40            if uiscale is bui.UIScale.SMALL
 41            else 450 if uiscale is bui.UIScale.MEDIUM else 560
 42        )
 43
 44        # Do some fancy math to fill all available screen area up to the
 45        # size of our backing container. This lets us fit to the exact
 46        # screen shape at small ui scale.
 47        screensize = bui.get_virtual_screen_size()
 48        scale = (
 49            1.8
 50            if uiscale is bui.UIScale.SMALL
 51            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 52        )
 53        # Calc screen size in our local container space and clamp to a
 54        # bit smaller than our container size.
 55        target_width = min(self._width - 70, screensize[0] / scale)
 56        target_height = min(self._height - 80, screensize[1] / scale)
 57
 58        # To get top/left coords, go to the center of our window and
 59        # offset by half the width/height of our target area.
 60        yoffs = (
 61            0.5 * self._height
 62            + 0.5 * target_height
 63            + (10.0 if uiscale is bui.UIScale.SMALL else 20)
 64        )
 65
 66        self._scroll_width = target_width
 67        self._scroll_height = target_height - 113
 68        scroll_bottom = yoffs - 120 - self._scroll_height
 69
 70        x_inset = self._width * 0.5 - 0.5 * self._scroll_width
 71
 72        super().__init__(
 73            root_widget=bui.containerwidget(
 74                size=(self._width, self._height), scale=scale
 75            ),
 76            transition=transition,
 77            origin_widget=origin_widget,
 78            # We're affected by screen size only at small ui-scale.
 79            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 80        )
 81        cancel_button = bui.buttonwidget(
 82            parent=self._root_widget,
 83            position=(x_inset + 10, yoffs - 60),
 84            size=(160, 60),
 85            autoselect=True,
 86            label=bui.Lstr(resource='cancelText'),
 87            scale=0.8,
 88        )
 89        save_button = bui.buttonwidget(
 90            parent=self._root_widget,
 91            position=(
 92                self._width * 0.5
 93                + self._scroll_width * 0.5
 94                - (190 if uiscale is bui.UIScale.SMALL else 140),
 95                yoffs - 60,
 96            ),
 97            autoselect=True,
 98            size=(160, 60),
 99            label=bui.Lstr(resource='saveText'),
100            scale=0.8,
101        )
102        bui.widget(edit=save_button, left_widget=cancel_button)
103        bui.widget(edit=cancel_button, right_widget=save_button)
104        bui.textwidget(
105            parent=self._root_widget,
106            position=(0, yoffs - 50),
107            size=(self._width, 25),
108            text=bui.Lstr(
109                resource=self._r
110                + (
111                    '.editSoundtrackText'
112                    if existing_soundtrack is not None
113                    else '.newSoundtrackText'
114                )
115            ),
116            color=bui.app.ui_v1.title_color,
117            h_align='center',
118            v_align='center',
119            maxwidth=270,
120        )
121        v = yoffs - 110
122        if 'Soundtracks' not in appconfig:
123            appconfig['Soundtracks'] = {}
124
125        self._soundtrack_name: str | None
126        self._existing_soundtrack = existing_soundtrack
127        self._existing_soundtrack_name: str | None
128        if existing_soundtrack is not None:
129            # if they passed just a name, pull info from that soundtrack
130            if isinstance(existing_soundtrack, str):
131                self._soundtrack = copy.deepcopy(
132                    appconfig['Soundtracks'][existing_soundtrack]
133                )
134                self._soundtrack_name = existing_soundtrack
135                self._existing_soundtrack_name = existing_soundtrack
136                self._last_edited_song_type = None
137            else:
138                # Otherwise they can pass info on an in-progress edit.
139                self._soundtrack = existing_soundtrack['soundtrack']
140                self._soundtrack_name = existing_soundtrack['name']
141                self._existing_soundtrack_name = existing_soundtrack[
142                    'existing_name'
143                ]
144                self._last_edited_song_type = existing_soundtrack[
145                    'last_edited_song_type'
146                ]
147        else:
148            self._soundtrack_name = None
149            self._existing_soundtrack_name = None
150            self._soundtrack = {}
151            self._last_edited_song_type = None
152
153        bui.textwidget(
154            parent=self._root_widget,
155            text=bui.Lstr(resource=f'{self._r}.nameText'),
156            maxwidth=80,
157            scale=0.8,
158            position=(105 + x_inset, v + 19),
159            color=(0.8, 0.8, 0.8, 0.5),
160            size=(0, 0),
161            h_align='right',
162            v_align='center',
163        )
164
165        # if there's no initial value, find a good initial unused name
166        if existing_soundtrack is None:
167            i = 1
168            st_name_text = bui.Lstr(
169                resource=f'{self._r}.newSoundtrackNameText'
170            ).evaluate()
171            if '${COUNT}' not in st_name_text:
172                # make sure we insert number *somewhere*
173                st_name_text = st_name_text + ' ${COUNT}'
174            while True:
175                self._soundtrack_name = st_name_text.replace('${COUNT}', str(i))
176                if self._soundtrack_name not in appconfig['Soundtracks']:
177                    break
178                i += 1
179
180        self._text_field = bui.textwidget(
181            parent=self._root_widget,
182            position=(120 + x_inset, v - 5),
183            size=(self._width - (160 + 2 * x_inset), 43),
184            text=self._soundtrack_name,
185            h_align='left',
186            v_align='center',
187            max_chars=32,
188            autoselect=True,
189            description=bui.Lstr(resource=f'{self._r}.nameText'),
190            editable=True,
191            padding=4,
192            on_return_press_call=self._do_it_with_sound,
193        )
194
195        self._scrollwidget = scrollwidget = bui.scrollwidget(
196            parent=self._root_widget,
197            highlight=False,
198            size=(self._scroll_width, self._scroll_height),
199            position=(
200                self._width * 0.5 - self._scroll_width * 0.5,
201                scroll_bottom,
202            ),
203            simple_culling_v=10,
204            claims_left_right=True,
205            selection_loops_to_parent=True,
206            border_opacity=0.4,
207        )
208        bui.widget(edit=self._text_field, down_widget=self._scrollwidget)
209        self._col = bui.columnwidget(
210            parent=scrollwidget,
211            claims_left_right=True,
212            selection_loops_to_parent=True,
213        )
214
215        self._song_type_buttons: dict[str, bui.Widget] = {}
216        self._refresh()
217        bui.buttonwidget(edit=cancel_button, on_activate_call=self._cancel)
218        bui.containerwidget(edit=self._root_widget, cancel_button=cancel_button)
219        bui.buttonwidget(edit=save_button, on_activate_call=self._do_it)
220        bui.containerwidget(edit=self._root_widget, start_button=save_button)
221        bui.widget(edit=self._text_field, up_widget=cancel_button)
222        bui.widget(edit=cancel_button, down_widget=self._text_field)
223
224    @override
225    def get_main_window_state(self) -> bui.MainWindowState:
226        # Support recreating our window for back/refresh purposes.
227        cls = type(self)
228
229        # Pull this out of self here; if we do it in the lambda we'll
230        # keep our window alive due to the 'self' reference.
231        existing_soundtrack = {
232            'name': self._soundtrack_name,
233            'existing_name': self._existing_soundtrack_name,
234            'soundtrack': self._soundtrack,
235            'last_edited_song_type': self._last_edited_song_type,
236        }
237
238        return bui.BasicMainWindowState(
239            create_call=lambda transition, origin_widget: cls(
240                transition=transition,
241                origin_widget=origin_widget,
242                existing_soundtrack=existing_soundtrack,
243            )
244        )
245
246    def _refresh(self) -> None:
247        for widget in self._col.get_children():
248            widget.delete()
249
250        types = [
251            'Menu',
252            'CharSelect',
253            'ToTheDeath',
254            'Onslaught',
255            'Keep Away',
256            'Race',
257            'Epic Race',
258            'ForwardMarch',
259            'FlagCatcher',
260            'Survival',
261            'Epic',
262            'Hockey',
263            'Football',
264            'Flying',
265            'Scary',
266            'Marching',
267            'GrandRomp',
268            'Chosen One',
269            'Scores',
270            'Victory',
271        ]
272
273        # FIXME: We should probably convert this to use translations.
274        type_names_translated = bui.app.lang.get_resource('soundtrackTypeNames')
275        prev_type_button: bui.Widget | None = None
276        prev_test_button: bui.Widget | None = None
277
278        for index, song_type in enumerate(types):
279            row = bui.rowwidget(
280                parent=self._col,
281                size=(self._scroll_width, 40),
282                claims_left_right=True,
283                selection_loops_to_parent=True,
284            )
285            type_name = type_names_translated.get(song_type, song_type)
286            bui.textwidget(
287                parent=row,
288                size=(self._scroll_width - 350, 25),
289                always_highlight=True,
290                text=type_name,
291                scale=0.7,
292                h_align='left',
293                v_align='center',
294                maxwidth=self._scroll_width - 360,
295            )
296
297            if song_type in self._soundtrack:
298                entry = self._soundtrack[song_type]
299            else:
300                entry = None
301
302            if entry is not None:
303                # Make sure they don't muck with this after it gets to us.
304                entry = copy.deepcopy(entry)
305
306            icon_type = self._get_entry_button_display_icon_type(entry)
307            self._song_type_buttons[song_type] = btn = bui.buttonwidget(
308                parent=row,
309                size=(230, 32),
310                label=self._get_entry_button_display_name(entry),
311                text_scale=0.6,
312                on_activate_call=bui.Call(
313                    self._get_entry, song_type, entry, type_name
314                ),
315                icon=(
316                    self._file_tex
317                    if icon_type == 'file'
318                    else self._folder_tex if icon_type == 'folder' else None
319                ),
320                icon_color=(
321                    (1.1, 0.8, 0.2) if icon_type == 'folder' else (1, 1, 1)
322                ),
323                left_widget=self._text_field,
324                iconscale=0.7,
325                autoselect=True,
326                up_widget=prev_type_button,
327            )
328            if index == 0:
329                bui.widget(edit=btn, up_widget=self._text_field)
330            bui.widget(edit=btn, down_widget=btn)
331
332            if (
333                self._last_edited_song_type is not None
334                and song_type == self._last_edited_song_type
335            ):
336                bui.containerwidget(
337                    edit=row, selected_child=btn, visible_child=btn
338                )
339                bui.containerwidget(
340                    edit=self._col, selected_child=row, visible_child=row
341                )
342                bui.containerwidget(
343                    edit=self._scrollwidget,
344                    selected_child=self._col,
345                    visible_child=self._col,
346                )
347                bui.containerwidget(
348                    edit=self._root_widget,
349                    selected_child=self._scrollwidget,
350                    visible_child=self._scrollwidget,
351                )
352
353            if prev_type_button is not None:
354                bui.widget(edit=prev_type_button, down_widget=btn)
355            prev_type_button = btn
356            bui.textwidget(parent=row, size=(10, 32), text='')  # spacing
357            assert bui.app.classic is not None
358            btn = bui.buttonwidget(
359                parent=row,
360                size=(50, 32),
361                label=bui.Lstr(resource=f'{self._r}.testText'),
362                text_scale=0.6,
363                on_activate_call=bui.Call(self._test, bs.MusicType(song_type)),
364                up_widget=(
365                    prev_test_button
366                    if prev_test_button is not None
367                    else self._text_field
368                ),
369            )
370            if prev_test_button is not None:
371                bui.widget(edit=prev_test_button, down_widget=btn)
372            bui.widget(edit=btn, down_widget=btn, right_widget=btn)
373            prev_test_button = btn
374
375    @classmethod
376    def _restore_editor(
377        cls, state: dict[str, Any], musictype: str, entry: Any
378    ) -> None:
379        assert bui.app.classic is not None
380        music = bui.app.classic.music
381
382        # Apply the change and recreate the window.
383        soundtrack = state['soundtrack']
384        existing_entry = (
385            None if musictype not in soundtrack else soundtrack[musictype]
386        )
387        if existing_entry != entry:
388            bui.getsound('gunCocking').play()
389
390        # Make sure this doesn't get mucked with after we get it.
391        if entry is not None:
392            entry = copy.deepcopy(entry)
393
394        entry_type = music.get_soundtrack_entry_type(entry)
395        if entry_type == 'default':
396            # For 'default' entries simply exclude them from the list.
397            if musictype in soundtrack:
398                del soundtrack[musictype]
399        else:
400            soundtrack[musictype] = entry
401
402        mainwindow = bui.app.ui_v1.get_main_window()
403        assert mainwindow is not None
404
405        mainwindow.main_window_back_state = state['back_state']
406        mainwindow.main_window_back()
407
408    def _get_entry(
409        self, song_type: str, entry: Any, selection_target_name: str
410    ) -> None:
411        assert bui.app.classic is not None
412        music = bui.app.classic.music
413
414        # no-op if we're not in control.
415        if not self.main_window_has_control():
416            return
417
418        if selection_target_name != '':
419            selection_target_name = "'" + selection_target_name + "'"
420        state = {
421            'name': self._soundtrack_name,
422            'existing_name': self._existing_soundtrack_name,
423            'soundtrack': self._soundtrack,
424            'last_edited_song_type': song_type,
425        }
426        new_win = music.get_music_player().select_entry(
427            bui.Call(self._restore_editor, state, song_type),
428            entry,
429            selection_target_name,
430        )
431        self.main_window_replace(new_win)
432
433        # Once we've set the new window, grab the back-state; we'll use
434        # that to jump back here after selection completes.
435        assert new_win.main_window_back_state is not None
436        state['back_state'] = new_win.main_window_back_state
437
438    def _test(self, song_type: bs.MusicType) -> None:
439        assert bui.app.classic is not None
440        music = bui.app.classic.music
441
442        # Warn if volume is zero.
443        if bui.app.config.resolve('Music Volume') < 0.01:
444            bui.getsound('error').play()
445            bui.screenmessage(
446                bui.Lstr(resource=f'{self._r}.musicVolumeZeroWarning'),
447                color=(1, 0.5, 0),
448            )
449        music.set_music_play_mode(bui.app.classic.MusicPlayMode.TEST)
450        music.do_play_music(
451            song_type,
452            mode=bui.app.classic.MusicPlayMode.TEST,
453            testsoundtrack=self._soundtrack,
454        )
455
456    def _get_entry_button_display_name(self, entry: Any) -> str | bui.Lstr:
457        assert bui.app.classic is not None
458        music = bui.app.classic.music
459        etype = music.get_soundtrack_entry_type(entry)
460        ename: str | bui.Lstr
461        if etype == 'default':
462            ename = bui.Lstr(resource=f'{self._r}.defaultGameMusicText')
463        elif etype in ('musicFile', 'musicFolder'):
464            ename = os.path.basename(music.get_soundtrack_entry_name(entry))
465        else:
466            ename = music.get_soundtrack_entry_name(entry)
467        return ename
468
469    def _get_entry_button_display_icon_type(self, entry: Any) -> str | None:
470        assert bui.app.classic is not None
471        music = bui.app.classic.music
472        etype = music.get_soundtrack_entry_type(entry)
473        if etype == 'musicFile':
474            return 'file'
475        if etype == 'musicFolder':
476            return 'folder'
477        return None
478
479    def _cancel(self) -> None:
480        # from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow
481
482        # no-op if our underlying widget is dead or on its way out.
483        if not self._root_widget or self._root_widget.transitioning_out:
484            return
485
486        assert bui.app.classic is not None
487        music = bui.app.classic.music
488
489        # Resets music back to normal.
490        music.set_music_play_mode(bui.app.classic.MusicPlayMode.REGULAR)
491
492        self.main_window_back()
493
494    def _do_it(self) -> None:
495
496        # no-op if our underlying widget is dead or on its way out.
497        if not self._root_widget or self._root_widget.transitioning_out:
498            return
499
500        assert bui.app.classic is not None
501        music = bui.app.classic.music
502        cfg = bui.app.config
503        new_name = cast(str, bui.textwidget(query=self._text_field))
504        if new_name != self._soundtrack_name and new_name in cfg['Soundtracks']:
505            bui.screenmessage(
506                bui.Lstr(resource=f'{self._r}.cantSaveAlreadyExistsText')
507            )
508            bui.getsound('error').play()
509            return
510        if not new_name:
511            bui.getsound('error').play()
512            return
513        if (
514            new_name
515            == bui.Lstr(
516                resource=f'{self._r}.defaultSoundtrackNameText'
517            ).evaluate()
518        ):
519            bui.screenmessage(
520                bui.Lstr(resource=f'{self._r}.cantOverwriteDefaultText')
521            )
522            bui.getsound('error').play()
523            return
524
525        # Make sure config exists.
526        if 'Soundtracks' not in cfg:
527            cfg['Soundtracks'] = {}
528
529        # If we had an old one, delete it.
530        if (
531            self._existing_soundtrack_name is not None
532            and self._existing_soundtrack_name in cfg['Soundtracks']
533        ):
534            del cfg['Soundtracks'][self._existing_soundtrack_name]
535        cfg['Soundtracks'][new_name] = self._soundtrack
536        cfg['Soundtrack'] = new_name
537
538        cfg.commit()
539        bui.getsound('gunCocking').play()
540
541        # Resets music back to normal.
542        music.set_music_play_mode(
543            bui.app.classic.MusicPlayMode.REGULAR, force_restart=True
544        )
545
546        self.main_window_back()
547
548    def _do_it_with_sound(self) -> None:
549        bui.getsound('swish').play()
550        self._do_it()

Window for editing a soundtrack.

SoundtrackEditWindow( existing_soundtrack: str | dict[str, typing.Any] | None, transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 22    def __init__(
 23        self,
 24        existing_soundtrack: str | dict[str, Any] | None,
 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        appconfig = bui.app.config
 32        self._r = 'editSoundtrackWindow'
 33        self._folder_tex = bui.gettexture('folder')
 34        self._file_tex = bui.gettexture('file')
 35        assert bui.app.classic is not None
 36        uiscale = bui.app.ui_v1.uiscale
 37        self._width = 1200 if uiscale is bui.UIScale.SMALL else 648
 38        self._height = (
 39            800
 40            if uiscale is bui.UIScale.SMALL
 41            else 450 if uiscale is bui.UIScale.MEDIUM else 560
 42        )
 43
 44        # Do some fancy math to fill all available screen area up to the
 45        # size of our backing container. This lets us fit to the exact
 46        # screen shape at small ui scale.
 47        screensize = bui.get_virtual_screen_size()
 48        scale = (
 49            1.8
 50            if uiscale is bui.UIScale.SMALL
 51            else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0
 52        )
 53        # Calc screen size in our local container space and clamp to a
 54        # bit smaller than our container size.
 55        target_width = min(self._width - 70, screensize[0] / scale)
 56        target_height = min(self._height - 80, screensize[1] / scale)
 57
 58        # To get top/left coords, go to the center of our window and
 59        # offset by half the width/height of our target area.
 60        yoffs = (
 61            0.5 * self._height
 62            + 0.5 * target_height
 63            + (10.0 if uiscale is bui.UIScale.SMALL else 20)
 64        )
 65
 66        self._scroll_width = target_width
 67        self._scroll_height = target_height - 113
 68        scroll_bottom = yoffs - 120 - self._scroll_height
 69
 70        x_inset = self._width * 0.5 - 0.5 * self._scroll_width
 71
 72        super().__init__(
 73            root_widget=bui.containerwidget(
 74                size=(self._width, self._height), scale=scale
 75            ),
 76            transition=transition,
 77            origin_widget=origin_widget,
 78            # We're affected by screen size only at small ui-scale.
 79            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 80        )
 81        cancel_button = bui.buttonwidget(
 82            parent=self._root_widget,
 83            position=(x_inset + 10, yoffs - 60),
 84            size=(160, 60),
 85            autoselect=True,
 86            label=bui.Lstr(resource='cancelText'),
 87            scale=0.8,
 88        )
 89        save_button = bui.buttonwidget(
 90            parent=self._root_widget,
 91            position=(
 92                self._width * 0.5
 93                + self._scroll_width * 0.5
 94                - (190 if uiscale is bui.UIScale.SMALL else 140),
 95                yoffs - 60,
 96            ),
 97            autoselect=True,
 98            size=(160, 60),
 99            label=bui.Lstr(resource='saveText'),
100            scale=0.8,
101        )
102        bui.widget(edit=save_button, left_widget=cancel_button)
103        bui.widget(edit=cancel_button, right_widget=save_button)
104        bui.textwidget(
105            parent=self._root_widget,
106            position=(0, yoffs - 50),
107            size=(self._width, 25),
108            text=bui.Lstr(
109                resource=self._r
110                + (
111                    '.editSoundtrackText'
112                    if existing_soundtrack is not None
113                    else '.newSoundtrackText'
114                )
115            ),
116            color=bui.app.ui_v1.title_color,
117            h_align='center',
118            v_align='center',
119            maxwidth=270,
120        )
121        v = yoffs - 110
122        if 'Soundtracks' not in appconfig:
123            appconfig['Soundtracks'] = {}
124
125        self._soundtrack_name: str | None
126        self._existing_soundtrack = existing_soundtrack
127        self._existing_soundtrack_name: str | None
128        if existing_soundtrack is not None:
129            # if they passed just a name, pull info from that soundtrack
130            if isinstance(existing_soundtrack, str):
131                self._soundtrack = copy.deepcopy(
132                    appconfig['Soundtracks'][existing_soundtrack]
133                )
134                self._soundtrack_name = existing_soundtrack
135                self._existing_soundtrack_name = existing_soundtrack
136                self._last_edited_song_type = None
137            else:
138                # Otherwise they can pass info on an in-progress edit.
139                self._soundtrack = existing_soundtrack['soundtrack']
140                self._soundtrack_name = existing_soundtrack['name']
141                self._existing_soundtrack_name = existing_soundtrack[
142                    'existing_name'
143                ]
144                self._last_edited_song_type = existing_soundtrack[
145                    'last_edited_song_type'
146                ]
147        else:
148            self._soundtrack_name = None
149            self._existing_soundtrack_name = None
150            self._soundtrack = {}
151            self._last_edited_song_type = None
152
153        bui.textwidget(
154            parent=self._root_widget,
155            text=bui.Lstr(resource=f'{self._r}.nameText'),
156            maxwidth=80,
157            scale=0.8,
158            position=(105 + x_inset, v + 19),
159            color=(0.8, 0.8, 0.8, 0.5),
160            size=(0, 0),
161            h_align='right',
162            v_align='center',
163        )
164
165        # if there's no initial value, find a good initial unused name
166        if existing_soundtrack is None:
167            i = 1
168            st_name_text = bui.Lstr(
169                resource=f'{self._r}.newSoundtrackNameText'
170            ).evaluate()
171            if '${COUNT}' not in st_name_text:
172                # make sure we insert number *somewhere*
173                st_name_text = st_name_text + ' ${COUNT}'
174            while True:
175                self._soundtrack_name = st_name_text.replace('${COUNT}', str(i))
176                if self._soundtrack_name not in appconfig['Soundtracks']:
177                    break
178                i += 1
179
180        self._text_field = bui.textwidget(
181            parent=self._root_widget,
182            position=(120 + x_inset, v - 5),
183            size=(self._width - (160 + 2 * x_inset), 43),
184            text=self._soundtrack_name,
185            h_align='left',
186            v_align='center',
187            max_chars=32,
188            autoselect=True,
189            description=bui.Lstr(resource=f'{self._r}.nameText'),
190            editable=True,
191            padding=4,
192            on_return_press_call=self._do_it_with_sound,
193        )
194
195        self._scrollwidget = scrollwidget = bui.scrollwidget(
196            parent=self._root_widget,
197            highlight=False,
198            size=(self._scroll_width, self._scroll_height),
199            position=(
200                self._width * 0.5 - self._scroll_width * 0.5,
201                scroll_bottom,
202            ),
203            simple_culling_v=10,
204            claims_left_right=True,
205            selection_loops_to_parent=True,
206            border_opacity=0.4,
207        )
208        bui.widget(edit=self._text_field, down_widget=self._scrollwidget)
209        self._col = bui.columnwidget(
210            parent=scrollwidget,
211            claims_left_right=True,
212            selection_loops_to_parent=True,
213        )
214
215        self._song_type_buttons: dict[str, bui.Widget] = {}
216        self._refresh()
217        bui.buttonwidget(edit=cancel_button, on_activate_call=self._cancel)
218        bui.containerwidget(edit=self._root_widget, cancel_button=cancel_button)
219        bui.buttonwidget(edit=save_button, on_activate_call=self._do_it)
220        bui.containerwidget(edit=self._root_widget, start_button=save_button)
221        bui.widget(edit=self._text_field, up_widget=cancel_button)
222        bui.widget(edit=cancel_button, down_widget=self._text_field)

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:
224    @override
225    def get_main_window_state(self) -> bui.MainWindowState:
226        # Support recreating our window for back/refresh purposes.
227        cls = type(self)
228
229        # Pull this out of self here; if we do it in the lambda we'll
230        # keep our window alive due to the 'self' reference.
231        existing_soundtrack = {
232            'name': self._soundtrack_name,
233            'existing_name': self._existing_soundtrack_name,
234            'soundtrack': self._soundtrack,
235            'last_edited_song_type': self._last_edited_song_type,
236        }
237
238        return bui.BasicMainWindowState(
239            create_call=lambda transition, origin_widget: cls(
240                transition=transition,
241                origin_widget=origin_widget,
242                existing_soundtrack=existing_soundtrack,
243            )
244        )

Return a WindowState to recreate this window, if supported.