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

Party list/chat window.

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

Called when a new chat message comes through.

def popup_menu_selected_choice(self, popup_window: bastd.ui.popup.PopupMenuWindow, choice: str) -> None:
425    def popup_menu_selected_choice(
426        self, popup_window: popup.PopupMenuWindow, choice: str
427    ) -> None:
428        """Called when a choice is selected in the popup."""
429        del popup_window  # unused
430        if self._popup_type == 'partyMemberPress':
431            if self._popup_party_member_is_host:
432                ba.playsound(ba.getsound('error'))
433                ba.screenmessage(
434                    ba.Lstr(resource='internal.cantKickHostError'),
435                    color=(1, 0, 0),
436                )
437            else:
438                assert self._popup_party_member_client_id is not None
439
440                # Ban for 5 minutes.
441                result = ba.internal.disconnect_client(
442                    self._popup_party_member_client_id, ban_time=5 * 60
443                )
444                if not result:
445                    ba.playsound(ba.getsound('error'))
446                    ba.screenmessage(
447                        ba.Lstr(resource='getTicketsWindow.unavailableText'),
448                        color=(1, 0, 0),
449                    )
450        elif self._popup_type == 'menu':
451            if choice in ('mute', 'unmute'):
452                cfg = ba.app.config
453                cfg['Chat Muted'] = choice == 'mute'
454                cfg.apply_and_commit()
455                self._update()
456        else:
457            print(f'unhandled popup type: {self._popup_type}')

Called when a choice is selected in the popup.

def popup_menu_closing(self, popup_window: bastd.ui.popup.PopupWindow) -> None:
459    def popup_menu_closing(self, popup_window: popup.PopupWindow) -> None:
460        """Called when the popup is closing."""

Called when the popup is closing.

def close(self) -> None:
501    def close(self) -> None:
502        """Close the window."""
503        ba.containerwidget(edit=self._root_widget, transition='out_scale')

Close the window.

def close_with_sound(self) -> None:
505    def close_with_sound(self) -> None:
506        """Close the window and make a lovely sound."""
507        ba.playsound(ba.getsound('swish'))
508        self.close()

Close the window and make a lovely sound.

Inherited Members
ba.ui.Window
get_root_widget