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

Called when the popup is closing.

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

Close the window.

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

Close the window and make a lovely sound.