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

Party list/chat window.

PartyWindow(origin: Sequence[float] = (0, 0))
 28    def __init__(self, origin: Sequence[float] = (0, 0)):
 29        bui.set_party_window_open(True)
 30        self._r = 'partyWindow'
 31        self._popup_type: str | None = None
 32        self._popup_party_member_client_id: int | None = None
 33        self._popup_party_member_is_host: bool | None = None
 34        self._width = 500
 35        assert bui.app.classic is not None
 36        uiscale = bui.app.ui_v1.uiscale
 37        self._height = (
 38            365
 39            if uiscale is bui.UIScale.SMALL
 40            else 480 if uiscale is bui.UIScale.MEDIUM else 600
 41        )
 42        self._display_old_msgs = True
 43        super().__init__(
 44            root_widget=bui.containerwidget(
 45                size=(self._width, self._height),
 46                transition='in_scale',
 47                color=(0.40, 0.55, 0.20),
 48                parent=bui.get_special_widget('overlay_stack'),
 49                on_outside_click_call=self.close_with_sound,
 50                scale_origin_stack_offset=origin,
 51                scale=(
 52                    1.6
 53                    if uiscale is bui.UIScale.SMALL
 54                    else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9
 55                ),
 56                stack_offset=(
 57                    (200, -10)
 58                    if uiscale is bui.UIScale.SMALL
 59                    else (
 60                        (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60)
 61                    )
 62                ),
 63            )
 64        )
 65
 66        self._cancel_button = bui.buttonwidget(
 67            parent=self._root_widget,
 68            scale=0.7,
 69            position=(30, self._height - 47),
 70            size=(50, 50),
 71            label='',
 72            on_activate_call=self.close,
 73            autoselect=True,
 74            color=(0.45, 0.63, 0.15),
 75            icon=bui.gettexture('crossOut'),
 76            iconscale=1.2,
 77        )
 78        bui.containerwidget(
 79            edit=self._root_widget, cancel_button=self._cancel_button
 80        )
 81
 82        self._menu_button = bui.buttonwidget(
 83            parent=self._root_widget,
 84            scale=0.7,
 85            position=(self._width - 60, self._height - 47),
 86            size=(50, 50),
 87            label='...',
 88            autoselect=True,
 89            button_type='square',
 90            on_activate_call=bui.WeakCall(self._on_menu_button_press),
 91            color=(0.55, 0.73, 0.25),
 92            iconscale=1.2,
 93        )
 94
 95        info = bs.get_connection_to_host_info_2()
 96
 97        if info is not None and info.name != '':
 98            title = bui.Lstr(value=info.name)
 99        else:
100            title = bui.Lstr(resource=f'{self._r}.titleText')
101
102        self._title_text = bui.textwidget(
103            parent=self._root_widget,
104            scale=0.9,
105            color=(0.5, 0.7, 0.5),
106            text=title,
107            size=(0, 0),
108            position=(self._width * 0.5, self._height - 29),
109            maxwidth=self._width * 0.7,
110            h_align='center',
111            v_align='center',
112        )
113
114        self._empty_str = bui.textwidget(
115            parent=self._root_widget,
116            scale=0.75,
117            size=(0, 0),
118            position=(self._width * 0.5, self._height - 65),
119            maxwidth=self._width * 0.85,
120            h_align='center',
121            v_align='center',
122        )
123
124        self._scroll_width = self._width - 50
125        self._scrollwidget = bui.scrollwidget(
126            parent=self._root_widget,
127            size=(self._scroll_width, self._height - 200),
128            position=(30, 80),
129            color=(0.4, 0.6, 0.3),
130        )
131        self._columnwidget = bui.columnwidget(
132            parent=self._scrollwidget, border=2, left_border=-200, margin=0
133        )
134        bui.widget(edit=self._menu_button, down_widget=self._columnwidget)
135
136        self._muted_text = bui.textwidget(
137            parent=self._root_widget,
138            position=(self._width * 0.5, self._height * 0.5),
139            size=(0, 0),
140            h_align='center',
141            v_align='center',
142            text=bui.Lstr(resource='chatMutedText'),
143        )
144        self._chat_texts: list[bui.Widget] = []
145
146        self._text_field = txt = bui.textwidget(
147            parent=self._root_widget,
148            editable=True,
149            size=(530, 40),
150            position=(44, 39),
151            text='',
152            maxwidth=494,
153            shadow=0.3,
154            flatness=1.0,
155            description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
156            autoselect=True,
157            v_align='center',
158            corner_scale=0.7,
159        )
160
161        bui.widget(
162            edit=self._scrollwidget,
163            autoselect=True,
164            left_widget=self._cancel_button,
165            up_widget=self._cancel_button,
166            down_widget=self._text_field,
167        )
168        bui.widget(
169            edit=self._columnwidget,
170            autoselect=True,
171            up_widget=self._cancel_button,
172            down_widget=self._text_field,
173        )
174        bui.containerwidget(edit=self._root_widget, selected_child=txt)
175
176        btn = bui.buttonwidget(
177            parent=self._root_widget,
178            size=(50, 35),
179            label=bui.Lstr(resource=f'{self._r}.sendText'),
180            button_type='square',
181            autoselect=True,
182            position=(self._width - 70, 35),
183            on_activate_call=self._send_chat_message,
184        )
185
186        bui.textwidget(edit=txt, on_return_press_call=btn.activate)
187        self._name_widgets: list[bui.Widget] = []
188        self._roster: list[dict[str, Any]] | None = None
189        self._update_timer = bui.AppTimer(
190            1.0, bui.WeakCall(self._update), repeat=True
191        )
192        self._update()
def on_chat_message(self, msg: str) -> None:
194    def on_chat_message(self, msg: str) -> None:
195        """Called when a new chat message comes through."""
196        if not bui.app.config.resolve('Chat Muted'):
197            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:
449    def popup_menu_selected_choice(
450        self, popup_window: PopupMenuWindow, choice: str
451    ) -> None:
452        """Called when a choice is selected in the popup."""
453        del popup_window  # unused
454        if self._popup_type == 'partyMemberPress':
455            if self._popup_party_member_is_host:
456                bui.getsound('error').play()
457                bui.screenmessage(
458                    bui.Lstr(resource='internal.cantKickHostError'),
459                    color=(1, 0, 0),
460                )
461            else:
462                assert self._popup_party_member_client_id is not None
463
464                # Ban for 5 minutes.
465                result = bs.disconnect_client(
466                    self._popup_party_member_client_id, ban_time=5 * 60
467                )
468                if not result:
469                    bui.getsound('error').play()
470                    bui.screenmessage(
471                        bui.Lstr(resource='getTicketsWindow.unavailableText'),
472                        color=(1, 0, 0),
473                    )
474        elif self._popup_type == 'menu':
475            if choice in ('mute', 'unmute'):
476                cfg = bui.app.config
477                cfg['Chat Muted'] = choice == 'mute'
478                cfg.apply_and_commit()
479                self._display_old_msgs = True
480                self._update()
481            if choice == 'add_to_favorites':
482                info = bs.get_connection_to_host_info_2()
483                if info is not None:
484                    self._add_to_favorites(
485                        name=info.name,
486                        address=info.address,
487                        port_num=info.port,
488                    )
489                else:
490                    # We should not allow the user to see this option
491                    # if they aren't in a server; this is our bad.
492                    bui.screenmessage(
493                        bui.Lstr(resource='errorText'), color=(1, 0, 0)
494                    )
495                    bui.getsound('error').play()
496        else:
497            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:
548    def popup_menu_closing(self, popup_window: PopupWindow) -> None:
549        """Called when the popup is closing."""

Called when the popup is closing.

def close(self) -> None:
587    def close(self) -> None:
588        """Close the window."""
589        # no-op if our underlying widget is dead or on its way out.
590        if not self._root_widget or self._root_widget.transitioning_out:
591            return
592
593        bui.containerwidget(edit=self._root_widget, transition='out_scale')

Close the window.

def close_with_sound(self) -> None:
595    def close_with_sound(self) -> None:
596        """Close the window and make a lovely sound."""
597        # no-op if our underlying widget is dead or on its way out.
598        if not self._root_widget or self._root_widget.transitioning_out:
599            return
600
601        bui.getsound('swish').play()
602        self.close()

Close the window and make a lovely sound.