bauiv1lib.party

Provides party related UI.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides party related UI."""
  4
  5from __future__ import annotations
  6
  7import math
  8import logging
  9from typing import TYPE_CHECKING, cast, override
 10
 11import bauiv1 as bui
 12import bascenev1 as bs
 13from bauiv1lib.popup import PopupMenuWindow
 14
 15if TYPE_CHECKING:
 16    from typing import Sequence, Any
 17
 18    from bauiv1lib.popup import PopupWindow
 19
 20
 21class PartyWindow(bui.Window):
 22    """Party list/chat window."""
 23
 24    @override
 25    def __del__(self) -> None:
 26        bui.set_party_window_open(False)
 27        super().__del__()
 28
 29    def __init__(self, origin: Sequence[float] = (0, 0)):
 30        bui.set_party_window_open(True)
 31        self._r = 'partyWindow'
 32        self._popup_type: str | None = None
 33        self._popup_party_member_client_id: int | None = None
 34        self._popup_party_member_is_host: bool | None = None
 35        self._width = 500
 36        assert bui.app.classic is not None
 37        uiscale = bui.app.ui_v1.uiscale
 38        self._height = (
 39            365
 40            if uiscale is bui.UIScale.SMALL
 41            else 480 if uiscale is bui.UIScale.MEDIUM else 600
 42        )
 43        self._display_old_msgs = True
 44        super().__init__(
 45            root_widget=bui.containerwidget(
 46                size=(self._width, self._height),
 47                transition='in_scale',
 48                color=(0.40, 0.55, 0.20),
 49                parent=bui.get_special_widget('overlay_stack'),
 50                on_outside_click_call=self.close_with_sound,
 51                scale_origin_stack_offset=origin,
 52                scale=(
 53                    1.8
 54                    if uiscale is bui.UIScale.SMALL
 55                    else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9
 56                ),
 57                stack_offset=(
 58                    (200, -10)
 59                    if uiscale is bui.UIScale.SMALL
 60                    else (
 61                        (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60)
 62                    )
 63                ),
 64            ),
 65            # We exist in the overlay stack so main-windows being
 66            # recreated doesn't affect us.
 67            prevent_main_window_auto_recreate=False,
 68        )
 69
 70        self._cancel_button = bui.buttonwidget(
 71            parent=self._root_widget,
 72            scale=0.7,
 73            position=(30, self._height - 47),
 74            size=(50, 50),
 75            label='',
 76            on_activate_call=self.close,
 77            autoselect=True,
 78            color=(0.45, 0.63, 0.15),
 79            icon=bui.gettexture('crossOut'),
 80            iconscale=1.2,
 81        )
 82        bui.containerwidget(
 83            edit=self._root_widget, cancel_button=self._cancel_button
 84        )
 85
 86        self._menu_button = bui.buttonwidget(
 87            parent=self._root_widget,
 88            scale=0.7,
 89            position=(self._width - 60, self._height - 47),
 90            size=(50, 50),
 91            label='...',
 92            autoselect=True,
 93            button_type='square',
 94            on_activate_call=bui.WeakCall(self._on_menu_button_press),
 95            color=(0.55, 0.73, 0.25),
 96            iconscale=1.2,
 97        )
 98
 99        info = bs.get_connection_to_host_info_2()
100
101        if info is not None and info.name != '':
102            title = bui.Lstr(value=info.name)
103        else:
104            title = bui.Lstr(resource=f'{self._r}.titleText')
105
106        self._title_text = bui.textwidget(
107            parent=self._root_widget,
108            scale=0.9,
109            color=(0.5, 0.7, 0.5),
110            text=title,
111            size=(0, 0),
112            position=(self._width * 0.5, self._height - 29),
113            maxwidth=self._width * 0.7,
114            h_align='center',
115            v_align='center',
116        )
117
118        self._empty_str = bui.textwidget(
119            parent=self._root_widget,
120            scale=0.6,
121            size=(0, 0),
122            # color=(0.5, 1.0, 0.5),
123            shadow=0.3,
124            position=(self._width * 0.5, self._height - 57),
125            maxwidth=self._width * 0.85,
126            h_align='center',
127            v_align='center',
128        )
129        self._empty_str_2 = bui.textwidget(
130            parent=self._root_widget,
131            scale=0.5,
132            size=(0, 0),
133            color=(0.5, 1.0, 0.5),
134            shadow=0.1,
135            position=(self._width * 0.5, self._height - 75),
136            maxwidth=self._width * 0.85,
137            h_align='center',
138            v_align='center',
139        )
140
141        self._scroll_width = self._width - 50
142        self._scrollwidget = bui.scrollwidget(
143            parent=self._root_widget,
144            size=(self._scroll_width, self._height - 200),
145            position=(30, 80),
146            color=(0.4, 0.6, 0.3),
147            border_opacity=0.6,
148        )
149        self._columnwidget = bui.columnwidget(
150            parent=self._scrollwidget, border=2, left_border=-200, margin=0
151        )
152        bui.widget(edit=self._menu_button, down_widget=self._columnwidget)
153
154        self._muted_text = bui.textwidget(
155            parent=self._root_widget,
156            position=(self._width * 0.5, self._height * 0.5),
157            size=(0, 0),
158            h_align='center',
159            v_align='center',
160            text=bui.Lstr(resource='chatMutedText'),
161        )
162        self._chat_texts: list[bui.Widget] = []
163
164        self._text_field = txt = bui.textwidget(
165            parent=self._root_widget,
166            editable=True,
167            size=(530, 40),
168            position=(44, 39),
169            text='',
170            maxwidth=494,
171            shadow=0.3,
172            flatness=1.0,
173            description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
174            autoselect=True,
175            v_align='center',
176            corner_scale=0.7,
177        )
178
179        bui.widget(
180            edit=self._scrollwidget,
181            autoselect=True,
182            left_widget=self._cancel_button,
183            up_widget=self._cancel_button,
184            down_widget=self._text_field,
185        )
186        bui.widget(
187            edit=self._columnwidget,
188            autoselect=True,
189            up_widget=self._cancel_button,
190            down_widget=self._text_field,
191        )
192        bui.containerwidget(edit=self._root_widget, selected_child=txt)
193
194        btn = bui.buttonwidget(
195            parent=self._root_widget,
196            size=(50, 35),
197            label=bui.Lstr(resource=f'{self._r}.sendText'),
198            button_type='square',
199            autoselect=True,
200            position=(self._width - 70, 35),
201            on_activate_call=self._send_chat_message,
202        )
203
204        bui.textwidget(edit=txt, on_return_press_call=btn.activate)
205        self._name_widgets: list[bui.Widget] = []
206        self._roster: list[dict[str, Any]] | None = None
207        self._update_timer = bui.AppTimer(
208            1.0, bui.WeakCall(self._update), repeat=True
209        )
210        self._update()
211
212    def on_chat_message(self, msg: str) -> None:
213        """Called when a new chat message comes through."""
214        if not bui.app.config.resolve('Chat Muted'):
215            self._add_msg(msg)
216
217    def _add_msg(self, msg: str) -> None:
218        txt = bui.textwidget(
219            parent=self._columnwidget,
220            h_align='left',
221            v_align='center',
222            scale=0.55,
223            size=(900, 13),
224            text=msg,
225            autoselect=True,
226            maxwidth=self._scroll_width * 0.94,
227            shadow=0.3,
228            flatness=1.0,
229            on_activate_call=bui.Call(self._copy_msg, msg),
230            selectable=True,
231        )
232
233        self._chat_texts.append(txt)
234        while len(self._chat_texts) > 40:
235            self._chat_texts.pop(0).delete()
236        bui.containerwidget(edit=self._columnwidget, visible_child=txt)
237
238    def _copy_msg(self, msg: str) -> None:
239        if bui.clipboard_is_supported():
240            bui.clipboard_set_text(msg)
241            bui.screenmessage(
242                bui.Lstr(resource='copyConfirmText'), color=(0, 1, 0)
243            )
244
245    def _on_menu_button_press(self) -> None:
246        is_muted = bui.app.config.resolve('Chat Muted')
247        assert bui.app.classic is not None
248        uiscale = bui.app.ui_v1.uiscale
249
250        choices: list[str] = ['unmute' if is_muted else 'mute']
251        choices_display: list[bui.Lstr] = [
252            bui.Lstr(resource='chatUnMuteText' if is_muted else 'chatMuteText')
253        ]
254
255        # Allow the 'Add to Favorites' option only if we're actually
256        # connected to a party and if it doesn't seem to be a private
257        # party (those are dynamically assigned addresses and ports so
258        # it makes no sense to save them).
259        server_info = bs.get_connection_to_host_info_2()
260        if server_info is not None and not server_info.name.startswith(
261            'Private Party '
262        ):
263            choices.append('add_to_favorites')
264            choices_display.append(bui.Lstr(resource='addToFavoritesText'))
265
266        PopupMenuWindow(
267            position=self._menu_button.get_screen_space_center(),
268            scale=(
269                2.3
270                if uiscale is bui.UIScale.SMALL
271                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
272            ),
273            choices=choices,
274            choices_display=choices_display,
275            current_choice='unmute' if is_muted else 'mute',
276            delegate=self,
277        )
278        self._popup_type = 'menu'
279
280    def _update(self) -> None:
281        # pylint: disable=too-many-locals
282        # pylint: disable=too-many-branches
283        # pylint: disable=too-many-statements
284        # pylint: disable=too-many-nested-blocks
285
286        # update muted state
287        if bui.app.config.resolve('Chat Muted'):
288            bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3))
289            # clear any chat texts we're showing
290            if self._chat_texts:
291                while self._chat_texts:
292                    first = self._chat_texts.pop()
293                    first.delete()
294        else:
295            bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0))
296            # add all existing messages if chat is not muted
297            if self._display_old_msgs:
298                msgs = bs.get_chat_messages()
299                for msg in msgs:
300                    self._add_msg(msg)
301                self._display_old_msgs = False
302
303        # update roster section
304        roster = bs.get_game_roster()
305        if roster != self._roster:
306            self._roster = roster
307
308            # clear out old
309            for widget in self._name_widgets:
310                widget.delete()
311            self._name_widgets = []
312            if not self._roster:
313                top_section_height = 60
314                bui.textwidget(
315                    edit=self._empty_str,
316                    text=bui.Lstr(resource=f'{self._r}.emptyText'),
317                )
318                bui.textwidget(
319                    edit=self._empty_str_2,
320                    text=bui.Lstr(resource='gatherWindow.descriptionShortText'),
321                )
322                bui.scrollwidget(
323                    edit=self._scrollwidget,
324                    size=(
325                        self._width - 50,
326                        self._height - top_section_height - 110,
327                    ),
328                    position=(30, 80),
329                )
330            else:
331                columns = (
332                    1
333                    if len(self._roster) == 1
334                    else 2 if len(self._roster) == 2 else 3
335                )
336                rows = int(math.ceil(float(len(self._roster)) / columns))
337                c_width = (self._width * 0.9) / max(3, columns)
338                c_width_total = c_width * columns
339                c_height = 24
340                c_height_total = c_height * rows
341                for y in range(rows):
342                    for x in range(columns):
343                        index = y * columns + x
344                        if index < len(self._roster):
345                            t_scale = 0.65
346                            pos = (
347                                self._width * 0.53
348                                - c_width_total * 0.5
349                                + c_width * x
350                                - 23,
351                                self._height - 65 - c_height * y - 15,
352                            )
353
354                            # if there are players present for this client, use
355                            # their names as a display string instead of the
356                            # client spec-string
357                            try:
358                                if self._roster[index]['players']:
359                                    # if there's just one, use the full name;
360                                    # otherwise combine short names
361                                    if len(self._roster[index]['players']) == 1:
362                                        p_str = self._roster[index]['players'][
363                                            0
364                                        ]['name_full']
365                                    else:
366                                        p_str = '/'.join(
367                                            [
368                                                entry['name']
369                                                for entry in self._roster[
370                                                    index
371                                                ]['players']
372                                            ]
373                                        )
374                                        if len(p_str) > 25:
375                                            p_str = p_str[:25] + '...'
376                                else:
377                                    p_str = self._roster[index][
378                                        'display_string'
379                                    ]
380                            except Exception:
381                                logging.exception(
382                                    'Error calcing client name str.'
383                                )
384                                p_str = '???'
385
386                            widget = bui.textwidget(
387                                parent=self._root_widget,
388                                position=(pos[0], pos[1]),
389                                scale=t_scale,
390                                size=(c_width * 0.85, 30),
391                                maxwidth=c_width * 0.85,
392                                color=(1, 1, 1) if index == 0 else (1, 1, 1),
393                                selectable=True,
394                                autoselect=True,
395                                click_activate=True,
396                                text=bui.Lstr(value=p_str),
397                                h_align='left',
398                                v_align='center',
399                            )
400                            self._name_widgets.append(widget)
401
402                            # in newer versions client_id will be present and
403                            # we can use that to determine who the host is.
404                            # in older versions we assume the first client is
405                            # host
406                            if self._roster[index]['client_id'] is not None:
407                                is_host = self._roster[index]['client_id'] == -1
408                            else:
409                                is_host = index == 0
410
411                            # FIXME: Should pass client_id to these sort of
412                            #  calls; not spec-string (perhaps should wait till
413                            #  client_id is more readily available though).
414                            bui.textwidget(
415                                edit=widget,
416                                on_activate_call=bui.Call(
417                                    self._on_party_member_press,
418                                    self._roster[index]['client_id'],
419                                    is_host,
420                                    widget,
421                                ),
422                            )
423                            pos = (
424                                self._width * 0.53
425                                - c_width_total * 0.5
426                                + c_width * x,
427                                self._height - 65 - c_height * y,
428                            )
429
430                            # Make the assumption that the first roster
431                            # entry is the server.
432                            # FIXME: Shouldn't do this.
433                            if is_host:
434                                twd = min(
435                                    c_width * 0.85,
436                                    bui.get_string_width(
437                                        p_str, suppress_warning=True
438                                    )
439                                    * t_scale,
440                                )
441                                self._name_widgets.append(
442                                    bui.textwidget(
443                                        parent=self._root_widget,
444                                        position=(
445                                            pos[0] + twd + 1,
446                                            pos[1] - 0.5,
447                                        ),
448                                        size=(0, 0),
449                                        h_align='left',
450                                        v_align='center',
451                                        maxwidth=c_width * 0.96 - twd,
452                                        color=(0.1, 1, 0.1, 0.5),
453                                        text=bui.Lstr(
454                                            resource=f'{self._r}.hostText'
455                                        ),
456                                        scale=0.4,
457                                        shadow=0.1,
458                                        flatness=1.0,
459                                    )
460                                )
461                bui.textwidget(edit=self._empty_str, text='')
462                bui.textwidget(edit=self._empty_str_2, text='')
463                bui.scrollwidget(
464                    edit=self._scrollwidget,
465                    size=(
466                        self._width - 50,
467                        max(100, self._height - 139 - c_height_total),
468                    ),
469                    position=(30, 80),
470                )
471
472    def popup_menu_selected_choice(
473        self, popup_window: PopupMenuWindow, choice: str
474    ) -> None:
475        """Called when a choice is selected in the popup."""
476        del popup_window  # unused
477        if self._popup_type == 'partyMemberPress':
478            if self._popup_party_member_is_host:
479                bui.getsound('error').play()
480                bui.screenmessage(
481                    bui.Lstr(resource='internal.cantKickHostError'),
482                    color=(1, 0, 0),
483                )
484            else:
485                assert self._popup_party_member_client_id is not None
486
487                # Ban for 5 minutes.
488                result = bs.disconnect_client(
489                    self._popup_party_member_client_id, ban_time=5 * 60
490                )
491                if not result:
492                    bui.getsound('error').play()
493                    bui.screenmessage(
494                        bui.Lstr(resource='getTicketsWindow.unavailableText'),
495                        color=(1, 0, 0),
496                    )
497        elif self._popup_type == 'menu':
498            if choice in ('mute', 'unmute'):
499                cfg = bui.app.config
500                cfg['Chat Muted'] = choice == 'mute'
501                cfg.apply_and_commit()
502                self._display_old_msgs = True
503                self._update()
504            if choice == 'add_to_favorites':
505                info = bs.get_connection_to_host_info_2()
506                if info is not None:
507                    self._add_to_favorites(
508                        name=info.name,
509                        address=info.address,
510                        port_num=info.port,
511                    )
512                else:
513                    # We should not allow the user to see this option
514                    # if they aren't in a server; this is our bad.
515                    bui.screenmessage(
516                        bui.Lstr(resource='errorText'), color=(1, 0, 0)
517                    )
518                    bui.getsound('error').play()
519        else:
520            print(f'unhandled popup type: {self._popup_type}')
521
522    def _add_to_favorites(
523        self, name: str, address: str | None, port_num: int | None
524    ) -> None:
525        addr = address
526        if addr == '':
527            bui.screenmessage(
528                bui.Lstr(resource='internal.invalidAddressErrorText'),
529                color=(1, 0, 0),
530            )
531            bui.getsound('error').play()
532            return
533        port = port_num if port_num is not None else -1
534        if port > 65535 or port < 0:
535            bui.screenmessage(
536                bui.Lstr(resource='internal.invalidPortErrorText'),
537                color=(1, 0, 0),
538            )
539            bui.getsound('error').play()
540            return
541
542        # Avoid empty names.
543        if not name:
544            name = f'{addr}@{port}'
545
546        config = bui.app.config
547
548        if addr:
549            if not isinstance(config.get('Saved Servers'), dict):
550                config['Saved Servers'] = {}
551            config['Saved Servers'][f'{addr}@{port}'] = {
552                'addr': addr,
553                'port': port,
554                'name': name,
555            }
556            config.commit()
557            bui.getsound('gunCocking').play()
558            bui.screenmessage(
559                bui.Lstr(
560                    resource='addedToFavoritesText', subs=[('${NAME}', name)]
561                ),
562                color=(0, 1, 0),
563            )
564        else:
565            bui.screenmessage(
566                bui.Lstr(resource='internal.invalidAddressErrorText'),
567                color=(1, 0, 0),
568            )
569            bui.getsound('error').play()
570
571    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
572        """Called when the popup is closing."""
573
574    def _on_party_member_press(
575        self, client_id: int, is_host: bool, widget: bui.Widget
576    ) -> None:
577        # if we're the host, pop up 'kick' options for all non-host members
578        if bs.get_foreground_host_session() is not None:
579            kick_str = bui.Lstr(resource='kickText')
580        else:
581            # kick-votes appeared in build 14248
582            info = bs.get_connection_to_host_info_2()
583            if info is None or info.build_number < 14248:
584                return
585            kick_str = bui.Lstr(resource='kickVoteText')
586        assert bui.app.classic is not None
587        uiscale = bui.app.ui_v1.uiscale
588        PopupMenuWindow(
589            position=widget.get_screen_space_center(),
590            scale=(
591                2.3
592                if uiscale is bui.UIScale.SMALL
593                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
594            ),
595            choices=['kick'],
596            choices_display=[kick_str],
597            current_choice='kick',
598            delegate=self,
599        )
600        self._popup_type = 'partyMemberPress'
601        self._popup_party_member_client_id = client_id
602        self._popup_party_member_is_host = is_host
603
604    def _send_chat_message(self) -> None:
605        text = cast(str, bui.textwidget(query=self._text_field)).strip()
606        if text != '':
607            bs.chatmessage(text)
608            bui.textwidget(edit=self._text_field, text='')
609
610    def close(self) -> None:
611        """Close the window."""
612        # no-op if our underlying widget is dead or on its way out.
613        if not self._root_widget or self._root_widget.transitioning_out:
614            return
615
616        bui.containerwidget(edit=self._root_widget, transition='out_scale')
617
618    def close_with_sound(self) -> None:
619        """Close the window and make a lovely sound."""
620        # no-op if our underlying widget is dead or on its way out.
621        if not self._root_widget or self._root_widget.transitioning_out:
622            return
623
624        bui.getsound('swish').play()
625        self.close()
class PartyWindow(bauiv1._uitypes.Window):
 22class PartyWindow(bui.Window):
 23    """Party list/chat window."""
 24
 25    @override
 26    def __del__(self) -> None:
 27        bui.set_party_window_open(False)
 28        super().__del__()
 29
 30    def __init__(self, origin: Sequence[float] = (0, 0)):
 31        bui.set_party_window_open(True)
 32        self._r = 'partyWindow'
 33        self._popup_type: str | None = None
 34        self._popup_party_member_client_id: int | None = None
 35        self._popup_party_member_is_host: bool | None = None
 36        self._width = 500
 37        assert bui.app.classic is not None
 38        uiscale = bui.app.ui_v1.uiscale
 39        self._height = (
 40            365
 41            if uiscale is bui.UIScale.SMALL
 42            else 480 if uiscale is bui.UIScale.MEDIUM else 600
 43        )
 44        self._display_old_msgs = True
 45        super().__init__(
 46            root_widget=bui.containerwidget(
 47                size=(self._width, self._height),
 48                transition='in_scale',
 49                color=(0.40, 0.55, 0.20),
 50                parent=bui.get_special_widget('overlay_stack'),
 51                on_outside_click_call=self.close_with_sound,
 52                scale_origin_stack_offset=origin,
 53                scale=(
 54                    1.8
 55                    if uiscale is bui.UIScale.SMALL
 56                    else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9
 57                ),
 58                stack_offset=(
 59                    (200, -10)
 60                    if uiscale is bui.UIScale.SMALL
 61                    else (
 62                        (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60)
 63                    )
 64                ),
 65            ),
 66            # We exist in the overlay stack so main-windows being
 67            # recreated doesn't affect us.
 68            prevent_main_window_auto_recreate=False,
 69        )
 70
 71        self._cancel_button = bui.buttonwidget(
 72            parent=self._root_widget,
 73            scale=0.7,
 74            position=(30, self._height - 47),
 75            size=(50, 50),
 76            label='',
 77            on_activate_call=self.close,
 78            autoselect=True,
 79            color=(0.45, 0.63, 0.15),
 80            icon=bui.gettexture('crossOut'),
 81            iconscale=1.2,
 82        )
 83        bui.containerwidget(
 84            edit=self._root_widget, cancel_button=self._cancel_button
 85        )
 86
 87        self._menu_button = bui.buttonwidget(
 88            parent=self._root_widget,
 89            scale=0.7,
 90            position=(self._width - 60, self._height - 47),
 91            size=(50, 50),
 92            label='...',
 93            autoselect=True,
 94            button_type='square',
 95            on_activate_call=bui.WeakCall(self._on_menu_button_press),
 96            color=(0.55, 0.73, 0.25),
 97            iconscale=1.2,
 98        )
 99
100        info = bs.get_connection_to_host_info_2()
101
102        if info is not None and info.name != '':
103            title = bui.Lstr(value=info.name)
104        else:
105            title = bui.Lstr(resource=f'{self._r}.titleText')
106
107        self._title_text = bui.textwidget(
108            parent=self._root_widget,
109            scale=0.9,
110            color=(0.5, 0.7, 0.5),
111            text=title,
112            size=(0, 0),
113            position=(self._width * 0.5, self._height - 29),
114            maxwidth=self._width * 0.7,
115            h_align='center',
116            v_align='center',
117        )
118
119        self._empty_str = bui.textwidget(
120            parent=self._root_widget,
121            scale=0.6,
122            size=(0, 0),
123            # color=(0.5, 1.0, 0.5),
124            shadow=0.3,
125            position=(self._width * 0.5, self._height - 57),
126            maxwidth=self._width * 0.85,
127            h_align='center',
128            v_align='center',
129        )
130        self._empty_str_2 = bui.textwidget(
131            parent=self._root_widget,
132            scale=0.5,
133            size=(0, 0),
134            color=(0.5, 1.0, 0.5),
135            shadow=0.1,
136            position=(self._width * 0.5, self._height - 75),
137            maxwidth=self._width * 0.85,
138            h_align='center',
139            v_align='center',
140        )
141
142        self._scroll_width = self._width - 50
143        self._scrollwidget = bui.scrollwidget(
144            parent=self._root_widget,
145            size=(self._scroll_width, self._height - 200),
146            position=(30, 80),
147            color=(0.4, 0.6, 0.3),
148            border_opacity=0.6,
149        )
150        self._columnwidget = bui.columnwidget(
151            parent=self._scrollwidget, border=2, left_border=-200, margin=0
152        )
153        bui.widget(edit=self._menu_button, down_widget=self._columnwidget)
154
155        self._muted_text = bui.textwidget(
156            parent=self._root_widget,
157            position=(self._width * 0.5, self._height * 0.5),
158            size=(0, 0),
159            h_align='center',
160            v_align='center',
161            text=bui.Lstr(resource='chatMutedText'),
162        )
163        self._chat_texts: list[bui.Widget] = []
164
165        self._text_field = txt = bui.textwidget(
166            parent=self._root_widget,
167            editable=True,
168            size=(530, 40),
169            position=(44, 39),
170            text='',
171            maxwidth=494,
172            shadow=0.3,
173            flatness=1.0,
174            description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
175            autoselect=True,
176            v_align='center',
177            corner_scale=0.7,
178        )
179
180        bui.widget(
181            edit=self._scrollwidget,
182            autoselect=True,
183            left_widget=self._cancel_button,
184            up_widget=self._cancel_button,
185            down_widget=self._text_field,
186        )
187        bui.widget(
188            edit=self._columnwidget,
189            autoselect=True,
190            up_widget=self._cancel_button,
191            down_widget=self._text_field,
192        )
193        bui.containerwidget(edit=self._root_widget, selected_child=txt)
194
195        btn = bui.buttonwidget(
196            parent=self._root_widget,
197            size=(50, 35),
198            label=bui.Lstr(resource=f'{self._r}.sendText'),
199            button_type='square',
200            autoselect=True,
201            position=(self._width - 70, 35),
202            on_activate_call=self._send_chat_message,
203        )
204
205        bui.textwidget(edit=txt, on_return_press_call=btn.activate)
206        self._name_widgets: list[bui.Widget] = []
207        self._roster: list[dict[str, Any]] | None = None
208        self._update_timer = bui.AppTimer(
209            1.0, bui.WeakCall(self._update), repeat=True
210        )
211        self._update()
212
213    def on_chat_message(self, msg: str) -> None:
214        """Called when a new chat message comes through."""
215        if not bui.app.config.resolve('Chat Muted'):
216            self._add_msg(msg)
217
218    def _add_msg(self, msg: str) -> None:
219        txt = bui.textwidget(
220            parent=self._columnwidget,
221            h_align='left',
222            v_align='center',
223            scale=0.55,
224            size=(900, 13),
225            text=msg,
226            autoselect=True,
227            maxwidth=self._scroll_width * 0.94,
228            shadow=0.3,
229            flatness=1.0,
230            on_activate_call=bui.Call(self._copy_msg, msg),
231            selectable=True,
232        )
233
234        self._chat_texts.append(txt)
235        while len(self._chat_texts) > 40:
236            self._chat_texts.pop(0).delete()
237        bui.containerwidget(edit=self._columnwidget, visible_child=txt)
238
239    def _copy_msg(self, msg: str) -> None:
240        if bui.clipboard_is_supported():
241            bui.clipboard_set_text(msg)
242            bui.screenmessage(
243                bui.Lstr(resource='copyConfirmText'), color=(0, 1, 0)
244            )
245
246    def _on_menu_button_press(self) -> None:
247        is_muted = bui.app.config.resolve('Chat Muted')
248        assert bui.app.classic is not None
249        uiscale = bui.app.ui_v1.uiscale
250
251        choices: list[str] = ['unmute' if is_muted else 'mute']
252        choices_display: list[bui.Lstr] = [
253            bui.Lstr(resource='chatUnMuteText' if is_muted else 'chatMuteText')
254        ]
255
256        # Allow the 'Add to Favorites' option only if we're actually
257        # connected to a party and if it doesn't seem to be a private
258        # party (those are dynamically assigned addresses and ports so
259        # it makes no sense to save them).
260        server_info = bs.get_connection_to_host_info_2()
261        if server_info is not None and not server_info.name.startswith(
262            'Private Party '
263        ):
264            choices.append('add_to_favorites')
265            choices_display.append(bui.Lstr(resource='addToFavoritesText'))
266
267        PopupMenuWindow(
268            position=self._menu_button.get_screen_space_center(),
269            scale=(
270                2.3
271                if uiscale is bui.UIScale.SMALL
272                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
273            ),
274            choices=choices,
275            choices_display=choices_display,
276            current_choice='unmute' if is_muted else 'mute',
277            delegate=self,
278        )
279        self._popup_type = 'menu'
280
281    def _update(self) -> None:
282        # pylint: disable=too-many-locals
283        # pylint: disable=too-many-branches
284        # pylint: disable=too-many-statements
285        # pylint: disable=too-many-nested-blocks
286
287        # update muted state
288        if bui.app.config.resolve('Chat Muted'):
289            bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3))
290            # clear any chat texts we're showing
291            if self._chat_texts:
292                while self._chat_texts:
293                    first = self._chat_texts.pop()
294                    first.delete()
295        else:
296            bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0))
297            # add all existing messages if chat is not muted
298            if self._display_old_msgs:
299                msgs = bs.get_chat_messages()
300                for msg in msgs:
301                    self._add_msg(msg)
302                self._display_old_msgs = False
303
304        # update roster section
305        roster = bs.get_game_roster()
306        if roster != self._roster:
307            self._roster = roster
308
309            # clear out old
310            for widget in self._name_widgets:
311                widget.delete()
312            self._name_widgets = []
313            if not self._roster:
314                top_section_height = 60
315                bui.textwidget(
316                    edit=self._empty_str,
317                    text=bui.Lstr(resource=f'{self._r}.emptyText'),
318                )
319                bui.textwidget(
320                    edit=self._empty_str_2,
321                    text=bui.Lstr(resource='gatherWindow.descriptionShortText'),
322                )
323                bui.scrollwidget(
324                    edit=self._scrollwidget,
325                    size=(
326                        self._width - 50,
327                        self._height - top_section_height - 110,
328                    ),
329                    position=(30, 80),
330                )
331            else:
332                columns = (
333                    1
334                    if len(self._roster) == 1
335                    else 2 if len(self._roster) == 2 else 3
336                )
337                rows = int(math.ceil(float(len(self._roster)) / columns))
338                c_width = (self._width * 0.9) / max(3, columns)
339                c_width_total = c_width * columns
340                c_height = 24
341                c_height_total = c_height * rows
342                for y in range(rows):
343                    for x in range(columns):
344                        index = y * columns + x
345                        if index < len(self._roster):
346                            t_scale = 0.65
347                            pos = (
348                                self._width * 0.53
349                                - c_width_total * 0.5
350                                + c_width * x
351                                - 23,
352                                self._height - 65 - c_height * y - 15,
353                            )
354
355                            # if there are players present for this client, use
356                            # their names as a display string instead of the
357                            # client spec-string
358                            try:
359                                if self._roster[index]['players']:
360                                    # if there's just one, use the full name;
361                                    # otherwise combine short names
362                                    if len(self._roster[index]['players']) == 1:
363                                        p_str = self._roster[index]['players'][
364                                            0
365                                        ]['name_full']
366                                    else:
367                                        p_str = '/'.join(
368                                            [
369                                                entry['name']
370                                                for entry in self._roster[
371                                                    index
372                                                ]['players']
373                                            ]
374                                        )
375                                        if len(p_str) > 25:
376                                            p_str = p_str[:25] + '...'
377                                else:
378                                    p_str = self._roster[index][
379                                        'display_string'
380                                    ]
381                            except Exception:
382                                logging.exception(
383                                    'Error calcing client name str.'
384                                )
385                                p_str = '???'
386
387                            widget = bui.textwidget(
388                                parent=self._root_widget,
389                                position=(pos[0], pos[1]),
390                                scale=t_scale,
391                                size=(c_width * 0.85, 30),
392                                maxwidth=c_width * 0.85,
393                                color=(1, 1, 1) if index == 0 else (1, 1, 1),
394                                selectable=True,
395                                autoselect=True,
396                                click_activate=True,
397                                text=bui.Lstr(value=p_str),
398                                h_align='left',
399                                v_align='center',
400                            )
401                            self._name_widgets.append(widget)
402
403                            # in newer versions client_id will be present and
404                            # we can use that to determine who the host is.
405                            # in older versions we assume the first client is
406                            # host
407                            if self._roster[index]['client_id'] is not None:
408                                is_host = self._roster[index]['client_id'] == -1
409                            else:
410                                is_host = index == 0
411
412                            # FIXME: Should pass client_id to these sort of
413                            #  calls; not spec-string (perhaps should wait till
414                            #  client_id is more readily available though).
415                            bui.textwidget(
416                                edit=widget,
417                                on_activate_call=bui.Call(
418                                    self._on_party_member_press,
419                                    self._roster[index]['client_id'],
420                                    is_host,
421                                    widget,
422                                ),
423                            )
424                            pos = (
425                                self._width * 0.53
426                                - c_width_total * 0.5
427                                + c_width * x,
428                                self._height - 65 - c_height * y,
429                            )
430
431                            # Make the assumption that the first roster
432                            # entry is the server.
433                            # FIXME: Shouldn't do this.
434                            if is_host:
435                                twd = min(
436                                    c_width * 0.85,
437                                    bui.get_string_width(
438                                        p_str, suppress_warning=True
439                                    )
440                                    * t_scale,
441                                )
442                                self._name_widgets.append(
443                                    bui.textwidget(
444                                        parent=self._root_widget,
445                                        position=(
446                                            pos[0] + twd + 1,
447                                            pos[1] - 0.5,
448                                        ),
449                                        size=(0, 0),
450                                        h_align='left',
451                                        v_align='center',
452                                        maxwidth=c_width * 0.96 - twd,
453                                        color=(0.1, 1, 0.1, 0.5),
454                                        text=bui.Lstr(
455                                            resource=f'{self._r}.hostText'
456                                        ),
457                                        scale=0.4,
458                                        shadow=0.1,
459                                        flatness=1.0,
460                                    )
461                                )
462                bui.textwidget(edit=self._empty_str, text='')
463                bui.textwidget(edit=self._empty_str_2, text='')
464                bui.scrollwidget(
465                    edit=self._scrollwidget,
466                    size=(
467                        self._width - 50,
468                        max(100, self._height - 139 - c_height_total),
469                    ),
470                    position=(30, 80),
471                )
472
473    def popup_menu_selected_choice(
474        self, popup_window: PopupMenuWindow, choice: str
475    ) -> None:
476        """Called when a choice is selected in the popup."""
477        del popup_window  # unused
478        if self._popup_type == 'partyMemberPress':
479            if self._popup_party_member_is_host:
480                bui.getsound('error').play()
481                bui.screenmessage(
482                    bui.Lstr(resource='internal.cantKickHostError'),
483                    color=(1, 0, 0),
484                )
485            else:
486                assert self._popup_party_member_client_id is not None
487
488                # Ban for 5 minutes.
489                result = bs.disconnect_client(
490                    self._popup_party_member_client_id, ban_time=5 * 60
491                )
492                if not result:
493                    bui.getsound('error').play()
494                    bui.screenmessage(
495                        bui.Lstr(resource='getTicketsWindow.unavailableText'),
496                        color=(1, 0, 0),
497                    )
498        elif self._popup_type == 'menu':
499            if choice in ('mute', 'unmute'):
500                cfg = bui.app.config
501                cfg['Chat Muted'] = choice == 'mute'
502                cfg.apply_and_commit()
503                self._display_old_msgs = True
504                self._update()
505            if choice == 'add_to_favorites':
506                info = bs.get_connection_to_host_info_2()
507                if info is not None:
508                    self._add_to_favorites(
509                        name=info.name,
510                        address=info.address,
511                        port_num=info.port,
512                    )
513                else:
514                    # We should not allow the user to see this option
515                    # if they aren't in a server; this is our bad.
516                    bui.screenmessage(
517                        bui.Lstr(resource='errorText'), color=(1, 0, 0)
518                    )
519                    bui.getsound('error').play()
520        else:
521            print(f'unhandled popup type: {self._popup_type}')
522
523    def _add_to_favorites(
524        self, name: str, address: str | None, port_num: int | None
525    ) -> None:
526        addr = address
527        if addr == '':
528            bui.screenmessage(
529                bui.Lstr(resource='internal.invalidAddressErrorText'),
530                color=(1, 0, 0),
531            )
532            bui.getsound('error').play()
533            return
534        port = port_num if port_num is not None else -1
535        if port > 65535 or port < 0:
536            bui.screenmessage(
537                bui.Lstr(resource='internal.invalidPortErrorText'),
538                color=(1, 0, 0),
539            )
540            bui.getsound('error').play()
541            return
542
543        # Avoid empty names.
544        if not name:
545            name = f'{addr}@{port}'
546
547        config = bui.app.config
548
549        if addr:
550            if not isinstance(config.get('Saved Servers'), dict):
551                config['Saved Servers'] = {}
552            config['Saved Servers'][f'{addr}@{port}'] = {
553                'addr': addr,
554                'port': port,
555                'name': name,
556            }
557            config.commit()
558            bui.getsound('gunCocking').play()
559            bui.screenmessage(
560                bui.Lstr(
561                    resource='addedToFavoritesText', subs=[('${NAME}', name)]
562                ),
563                color=(0, 1, 0),
564            )
565        else:
566            bui.screenmessage(
567                bui.Lstr(resource='internal.invalidAddressErrorText'),
568                color=(1, 0, 0),
569            )
570            bui.getsound('error').play()
571
572    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
573        """Called when the popup is closing."""
574
575    def _on_party_member_press(
576        self, client_id: int, is_host: bool, widget: bui.Widget
577    ) -> None:
578        # if we're the host, pop up 'kick' options for all non-host members
579        if bs.get_foreground_host_session() is not None:
580            kick_str = bui.Lstr(resource='kickText')
581        else:
582            # kick-votes appeared in build 14248
583            info = bs.get_connection_to_host_info_2()
584            if info is None or info.build_number < 14248:
585                return
586            kick_str = bui.Lstr(resource='kickVoteText')
587        assert bui.app.classic is not None
588        uiscale = bui.app.ui_v1.uiscale
589        PopupMenuWindow(
590            position=widget.get_screen_space_center(),
591            scale=(
592                2.3
593                if uiscale is bui.UIScale.SMALL
594                else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
595            ),
596            choices=['kick'],
597            choices_display=[kick_str],
598            current_choice='kick',
599            delegate=self,
600        )
601        self._popup_type = 'partyMemberPress'
602        self._popup_party_member_client_id = client_id
603        self._popup_party_member_is_host = is_host
604
605    def _send_chat_message(self) -> None:
606        text = cast(str, bui.textwidget(query=self._text_field)).strip()
607        if text != '':
608            bs.chatmessage(text)
609            bui.textwidget(edit=self._text_field, text='')
610
611    def close(self) -> None:
612        """Close the window."""
613        # no-op if our underlying widget is dead or on its way out.
614        if not self._root_widget or self._root_widget.transitioning_out:
615            return
616
617        bui.containerwidget(edit=self._root_widget, transition='out_scale')
618
619    def close_with_sound(self) -> None:
620        """Close the window and make a lovely sound."""
621        # no-op if our underlying widget is dead or on its way out.
622        if not self._root_widget or self._root_widget.transitioning_out:
623            return
624
625        bui.getsound('swish').play()
626        self.close()

Party list/chat window.

PartyWindow(origin: Sequence[float] = (0, 0))
 30    def __init__(self, origin: Sequence[float] = (0, 0)):
 31        bui.set_party_window_open(True)
 32        self._r = 'partyWindow'
 33        self._popup_type: str | None = None
 34        self._popup_party_member_client_id: int | None = None
 35        self._popup_party_member_is_host: bool | None = None
 36        self._width = 500
 37        assert bui.app.classic is not None
 38        uiscale = bui.app.ui_v1.uiscale
 39        self._height = (
 40            365
 41            if uiscale is bui.UIScale.SMALL
 42            else 480 if uiscale is bui.UIScale.MEDIUM else 600
 43        )
 44        self._display_old_msgs = True
 45        super().__init__(
 46            root_widget=bui.containerwidget(
 47                size=(self._width, self._height),
 48                transition='in_scale',
 49                color=(0.40, 0.55, 0.20),
 50                parent=bui.get_special_widget('overlay_stack'),
 51                on_outside_click_call=self.close_with_sound,
 52                scale_origin_stack_offset=origin,
 53                scale=(
 54                    1.8
 55                    if uiscale is bui.UIScale.SMALL
 56                    else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9
 57                ),
 58                stack_offset=(
 59                    (200, -10)
 60                    if uiscale is bui.UIScale.SMALL
 61                    else (
 62                        (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60)
 63                    )
 64                ),
 65            ),
 66            # We exist in the overlay stack so main-windows being
 67            # recreated doesn't affect us.
 68            prevent_main_window_auto_recreate=False,
 69        )
 70
 71        self._cancel_button = bui.buttonwidget(
 72            parent=self._root_widget,
 73            scale=0.7,
 74            position=(30, self._height - 47),
 75            size=(50, 50),
 76            label='',
 77            on_activate_call=self.close,
 78            autoselect=True,
 79            color=(0.45, 0.63, 0.15),
 80            icon=bui.gettexture('crossOut'),
 81            iconscale=1.2,
 82        )
 83        bui.containerwidget(
 84            edit=self._root_widget, cancel_button=self._cancel_button
 85        )
 86
 87        self._menu_button = bui.buttonwidget(
 88            parent=self._root_widget,
 89            scale=0.7,
 90            position=(self._width - 60, self._height - 47),
 91            size=(50, 50),
 92            label='...',
 93            autoselect=True,
 94            button_type='square',
 95            on_activate_call=bui.WeakCall(self._on_menu_button_press),
 96            color=(0.55, 0.73, 0.25),
 97            iconscale=1.2,
 98        )
 99
100        info = bs.get_connection_to_host_info_2()
101
102        if info is not None and info.name != '':
103            title = bui.Lstr(value=info.name)
104        else:
105            title = bui.Lstr(resource=f'{self._r}.titleText')
106
107        self._title_text = bui.textwidget(
108            parent=self._root_widget,
109            scale=0.9,
110            color=(0.5, 0.7, 0.5),
111            text=title,
112            size=(0, 0),
113            position=(self._width * 0.5, self._height - 29),
114            maxwidth=self._width * 0.7,
115            h_align='center',
116            v_align='center',
117        )
118
119        self._empty_str = bui.textwidget(
120            parent=self._root_widget,
121            scale=0.6,
122            size=(0, 0),
123            # color=(0.5, 1.0, 0.5),
124            shadow=0.3,
125            position=(self._width * 0.5, self._height - 57),
126            maxwidth=self._width * 0.85,
127            h_align='center',
128            v_align='center',
129        )
130        self._empty_str_2 = bui.textwidget(
131            parent=self._root_widget,
132            scale=0.5,
133            size=(0, 0),
134            color=(0.5, 1.0, 0.5),
135            shadow=0.1,
136            position=(self._width * 0.5, self._height - 75),
137            maxwidth=self._width * 0.85,
138            h_align='center',
139            v_align='center',
140        )
141
142        self._scroll_width = self._width - 50
143        self._scrollwidget = bui.scrollwidget(
144            parent=self._root_widget,
145            size=(self._scroll_width, self._height - 200),
146            position=(30, 80),
147            color=(0.4, 0.6, 0.3),
148            border_opacity=0.6,
149        )
150        self._columnwidget = bui.columnwidget(
151            parent=self._scrollwidget, border=2, left_border=-200, margin=0
152        )
153        bui.widget(edit=self._menu_button, down_widget=self._columnwidget)
154
155        self._muted_text = bui.textwidget(
156            parent=self._root_widget,
157            position=(self._width * 0.5, self._height * 0.5),
158            size=(0, 0),
159            h_align='center',
160            v_align='center',
161            text=bui.Lstr(resource='chatMutedText'),
162        )
163        self._chat_texts: list[bui.Widget] = []
164
165        self._text_field = txt = bui.textwidget(
166            parent=self._root_widget,
167            editable=True,
168            size=(530, 40),
169            position=(44, 39),
170            text='',
171            maxwidth=494,
172            shadow=0.3,
173            flatness=1.0,
174            description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
175            autoselect=True,
176            v_align='center',
177            corner_scale=0.7,
178        )
179
180        bui.widget(
181            edit=self._scrollwidget,
182            autoselect=True,
183            left_widget=self._cancel_button,
184            up_widget=self._cancel_button,
185            down_widget=self._text_field,
186        )
187        bui.widget(
188            edit=self._columnwidget,
189            autoselect=True,
190            up_widget=self._cancel_button,
191            down_widget=self._text_field,
192        )
193        bui.containerwidget(edit=self._root_widget, selected_child=txt)
194
195        btn = bui.buttonwidget(
196            parent=self._root_widget,
197            size=(50, 35),
198            label=bui.Lstr(resource=f'{self._r}.sendText'),
199            button_type='square',
200            autoselect=True,
201            position=(self._width - 70, 35),
202            on_activate_call=self._send_chat_message,
203        )
204
205        bui.textwidget(edit=txt, on_return_press_call=btn.activate)
206        self._name_widgets: list[bui.Widget] = []
207        self._roster: list[dict[str, Any]] | None = None
208        self._update_timer = bui.AppTimer(
209            1.0, bui.WeakCall(self._update), repeat=True
210        )
211        self._update()
def on_chat_message(self, msg: str) -> None:
213    def on_chat_message(self, msg: str) -> None:
214        """Called when a new chat message comes through."""
215        if not bui.app.config.resolve('Chat Muted'):
216            self._add_msg(msg)

Called when a new chat message comes through.

def popup_menu_selected_choice(self, popup_window: bauiv1lib.popup.PopupMenuWindow, choice: str) -> None:
473    def popup_menu_selected_choice(
474        self, popup_window: PopupMenuWindow, choice: str
475    ) -> None:
476        """Called when a choice is selected in the popup."""
477        del popup_window  # unused
478        if self._popup_type == 'partyMemberPress':
479            if self._popup_party_member_is_host:
480                bui.getsound('error').play()
481                bui.screenmessage(
482                    bui.Lstr(resource='internal.cantKickHostError'),
483                    color=(1, 0, 0),
484                )
485            else:
486                assert self._popup_party_member_client_id is not None
487
488                # Ban for 5 minutes.
489                result = bs.disconnect_client(
490                    self._popup_party_member_client_id, ban_time=5 * 60
491                )
492                if not result:
493                    bui.getsound('error').play()
494                    bui.screenmessage(
495                        bui.Lstr(resource='getTicketsWindow.unavailableText'),
496                        color=(1, 0, 0),
497                    )
498        elif self._popup_type == 'menu':
499            if choice in ('mute', 'unmute'):
500                cfg = bui.app.config
501                cfg['Chat Muted'] = choice == 'mute'
502                cfg.apply_and_commit()
503                self._display_old_msgs = True
504                self._update()
505            if choice == 'add_to_favorites':
506                info = bs.get_connection_to_host_info_2()
507                if info is not None:
508                    self._add_to_favorites(
509                        name=info.name,
510                        address=info.address,
511                        port_num=info.port,
512                    )
513                else:
514                    # We should not allow the user to see this option
515                    # if they aren't in a server; this is our bad.
516                    bui.screenmessage(
517                        bui.Lstr(resource='errorText'), color=(1, 0, 0)
518                    )
519                    bui.getsound('error').play()
520        else:
521            print(f'unhandled popup type: {self._popup_type}')

Called when a choice is selected in the popup.

def popup_menu_closing(self, popup_window: bauiv1lib.popup.PopupWindow) -> None:
572    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
573        """Called when the popup is closing."""

Called when the popup is closing.

def close(self) -> None:
611    def close(self) -> None:
612        """Close the window."""
613        # no-op if our underlying widget is dead or on its way out.
614        if not self._root_widget or self._root_widget.transitioning_out:
615            return
616
617        bui.containerwidget(edit=self._root_widget, transition='out_scale')

Close the window.

def close_with_sound(self) -> None:
619    def close_with_sound(self) -> None:
620        """Close the window and make a lovely sound."""
621        # no-op if our underlying widget is dead or on its way out.
622        if not self._root_widget or self._root_widget.transitioning_out:
623            return
624
625        bui.getsound('swish').play()
626        self.close()

Close the window and make a lovely sound.