# Released under the MIT License. See LICENSE for details.
#
"""Provides party related UI."""
from __future__ import annotations
import math
import logging
from typing import TYPE_CHECKING, cast
import bauiv1 as bui
import bascenev1 as bs
from bauiv1lib.popup import PopupMenuWindow
if TYPE_CHECKING:
from typing import Sequence, Any
from bauiv1lib.popup import PopupWindow
[docs]
class PartyWindow(bui.Window):
"""Party list/chat window."""
def __del__(self) -> None:
bui.set_party_window_open(False)
def __init__(self, origin: Sequence[float] = (0, 0)):
bui.set_party_window_open(True)
self._r = 'partyWindow'
self._popup_type: str | None = None
self._popup_party_member_client_id: int | None = None
self._popup_party_member_is_host: bool | None = None
self._width = 500
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
self._height = (
365
if uiscale is bui.UIScale.SMALL
else 480 if uiscale is bui.UIScale.MEDIUM else 600
)
self._display_old_msgs = True
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height),
transition='in_scale',
color=(0.40, 0.55, 0.20),
parent=bui.get_special_widget('overlay_stack'),
on_outside_click_call=self.close_with_sound,
scale_origin_stack_offset=origin,
scale=(
1.8
if uiscale is bui.UIScale.SMALL
else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9
),
stack_offset=(
(200, -10)
if uiscale is bui.UIScale.SMALL
else (
(260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60)
)
),
),
# We exist in the overlay stack so main-windows being
# recreated doesn't affect us.
prevent_main_window_auto_recreate=False,
)
self._cancel_button = bui.buttonwidget(
parent=self._root_widget,
scale=0.7,
position=(30, self._height - 47),
size=(50, 50),
label='',
on_activate_call=self.close,
autoselect=True,
color=(0.45, 0.63, 0.15),
icon=bui.gettexture('crossOut'),
iconscale=1.2,
)
bui.containerwidget(
edit=self._root_widget, cancel_button=self._cancel_button
)
self._menu_button = bui.buttonwidget(
parent=self._root_widget,
scale=0.7,
position=(self._width - 60, self._height - 47),
size=(50, 50),
label='...',
autoselect=True,
button_type='square',
on_activate_call=bui.WeakCall(self._on_menu_button_press),
color=(0.55, 0.73, 0.25),
iconscale=1.2,
)
info = bs.get_connection_to_host_info_2()
if info is not None and info.name != '':
title = bui.Lstr(value=info.name)
else:
title = bui.Lstr(resource=f'{self._r}.titleText')
self._title_text = bui.textwidget(
parent=self._root_widget,
scale=0.9,
color=(0.5, 0.7, 0.5),
text=title,
size=(0, 0),
position=(self._width * 0.5, self._height - 29),
maxwidth=self._width * 0.7,
h_align='center',
v_align='center',
)
self._empty_str = bui.textwidget(
parent=self._root_widget,
scale=0.6,
size=(0, 0),
# color=(0.5, 1.0, 0.5),
shadow=0.3,
position=(self._width * 0.5, self._height - 57),
maxwidth=self._width * 0.85,
h_align='center',
v_align='center',
)
self._empty_str_2 = bui.textwidget(
parent=self._root_widget,
scale=0.5,
size=(0, 0),
color=(0.5, 1.0, 0.5),
shadow=0.1,
position=(self._width * 0.5, self._height - 75),
maxwidth=self._width * 0.85,
h_align='center',
v_align='center',
)
self._scroll_width = self._width - 50
self._scrollwidget = bui.scrollwidget(
parent=self._root_widget,
size=(self._scroll_width, self._height - 200),
position=(30, 80),
color=(0.4, 0.6, 0.3),
border_opacity=0.6,
)
self._columnwidget = bui.columnwidget(
parent=self._scrollwidget, border=2, left_border=-200, margin=0
)
bui.widget(edit=self._menu_button, down_widget=self._columnwidget)
self._muted_text = bui.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.5),
size=(0, 0),
h_align='center',
v_align='center',
text=bui.Lstr(resource='chatMutedText'),
)
self._chat_texts: list[bui.Widget] = []
self._text_field = txt = bui.textwidget(
parent=self._root_widget,
editable=True,
size=(530, 40),
position=(44, 39),
text='',
maxwidth=494,
shadow=0.3,
flatness=1.0,
description=bui.Lstr(resource=f'{self._r}.chatMessageText'),
autoselect=True,
v_align='center',
corner_scale=0.7,
)
bui.widget(
edit=self._scrollwidget,
autoselect=True,
left_widget=self._cancel_button,
up_widget=self._cancel_button,
down_widget=self._text_field,
)
bui.widget(
edit=self._columnwidget,
autoselect=True,
up_widget=self._cancel_button,
down_widget=self._text_field,
)
bui.containerwidget(edit=self._root_widget, selected_child=txt)
btn = bui.buttonwidget(
parent=self._root_widget,
size=(50, 35),
label=bui.Lstr(resource=f'{self._r}.sendText'),
button_type='square',
autoselect=True,
position=(self._width - 70, 35),
on_activate_call=self._send_chat_message,
)
bui.textwidget(edit=txt, on_return_press_call=btn.activate)
self._name_widgets: list[bui.Widget] = []
self._roster: list[dict[str, Any]] | None = None
self._update_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update), repeat=True
)
self._update()
[docs]
def on_chat_message(self, msg: str) -> None:
"""Called when a new chat message comes through."""
if not bui.app.config.resolve('Chat Muted'):
self._add_msg(msg)
def _add_msg(self, msg: str) -> None:
txt = bui.textwidget(
parent=self._columnwidget,
h_align='left',
v_align='center',
scale=0.55,
size=(900, 13),
text=msg,
autoselect=True,
maxwidth=self._scroll_width * 0.94,
shadow=0.3,
flatness=1.0,
on_activate_call=bui.Call(self._copy_msg, msg),
selectable=True,
)
self._chat_texts.append(txt)
while len(self._chat_texts) > 40:
self._chat_texts.pop(0).delete()
bui.containerwidget(edit=self._columnwidget, visible_child=txt)
def _copy_msg(self, msg: str) -> None:
if bui.clipboard_is_supported():
bui.clipboard_set_text(msg)
bui.screenmessage(
bui.Lstr(resource='copyConfirmText'), color=(0, 1, 0)
)
def _on_menu_button_press(self) -> None:
is_muted = bui.app.config.resolve('Chat Muted')
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
choices: list[str] = ['unmute' if is_muted else 'mute']
choices_display: list[bui.Lstr] = [
bui.Lstr(resource='chatUnMuteText' if is_muted else 'chatMuteText')
]
# Allow the 'Add to Favorites' option only if we're actually
# connected to a party and if it doesn't seem to be a private
# party (those are dynamically assigned addresses and ports so
# it makes no sense to save them).
server_info = bs.get_connection_to_host_info_2()
if server_info is not None and not server_info.name.startswith(
'Private Party '
):
choices.append('add_to_favorites')
choices_display.append(bui.Lstr(resource='addToFavoritesText'))
PopupMenuWindow(
position=self._menu_button.get_screen_space_center(),
scale=(
2.3
if uiscale is bui.UIScale.SMALL
else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
),
choices=choices,
choices_display=choices_display,
current_choice='unmute' if is_muted else 'mute',
delegate=self,
)
self._popup_type = 'menu'
def _update(self) -> None:
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
# pylint: disable=too-many-nested-blocks
# update muted state
if bui.app.config.resolve('Chat Muted'):
bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.3))
# clear any chat texts we're showing
if self._chat_texts:
while self._chat_texts:
first = self._chat_texts.pop()
first.delete()
else:
bui.textwidget(edit=self._muted_text, color=(1, 1, 1, 0.0))
# add all existing messages if chat is not muted
if self._display_old_msgs:
msgs = bs.get_chat_messages()
for msg in msgs:
self._add_msg(msg)
self._display_old_msgs = False
# update roster section
roster = bs.get_game_roster()
if roster != self._roster:
self._roster = roster
# clear out old
for widget in self._name_widgets:
widget.delete()
self._name_widgets = []
if not self._roster:
top_section_height = 60
bui.textwidget(
edit=self._empty_str,
text=bui.Lstr(resource=f'{self._r}.emptyText'),
)
bui.textwidget(
edit=self._empty_str_2,
text=bui.Lstr(resource='gatherWindow.descriptionShortText'),
)
bui.scrollwidget(
edit=self._scrollwidget,
size=(
self._width - 50,
self._height - top_section_height - 110,
),
position=(30, 80),
)
else:
columns = (
1
if len(self._roster) == 1
else 2 if len(self._roster) == 2 else 3
)
rows = int(math.ceil(float(len(self._roster)) / columns))
c_width = (self._width * 0.9) / max(3, columns)
c_width_total = c_width * columns
c_height = 24
c_height_total = c_height * rows
for y in range(rows):
for x in range(columns):
index = y * columns + x
if index < len(self._roster):
t_scale = 0.65
pos = (
self._width * 0.53
- c_width_total * 0.5
+ c_width * x
- 23,
self._height - 65 - c_height * y - 15,
)
# if there are players present for this client, use
# their names as a display string instead of the
# client spec-string
try:
if self._roster[index]['players']:
# if there's just one, use the full name;
# otherwise combine short names
if len(self._roster[index]['players']) == 1:
p_str = self._roster[index]['players'][
0
]['name_full']
else:
p_str = '/'.join(
[
entry['name']
for entry in self._roster[
index
]['players']
]
)
if len(p_str) > 25:
p_str = p_str[:25] + '...'
else:
p_str = self._roster[index][
'display_string'
]
except Exception:
logging.exception(
'Error calcing client name str.'
)
p_str = '???'
widget = bui.textwidget(
parent=self._root_widget,
position=(pos[0], pos[1]),
scale=t_scale,
size=(c_width * 0.85, 30),
maxwidth=c_width * 0.85,
color=(1, 1, 1) if index == 0 else (1, 1, 1),
selectable=True,
autoselect=True,
click_activate=True,
text=bui.Lstr(value=p_str),
h_align='left',
v_align='center',
)
self._name_widgets.append(widget)
# in newer versions client_id will be present and
# we can use that to determine who the host is.
# in older versions we assume the first client is
# host
if self._roster[index]['client_id'] is not None:
is_host = self._roster[index]['client_id'] == -1
else:
is_host = index == 0
# FIXME: Should pass client_id to these sort of
# calls; not spec-string (perhaps should wait till
# client_id is more readily available though).
bui.textwidget(
edit=widget,
on_activate_call=bui.Call(
self._on_party_member_press,
self._roster[index]['client_id'],
is_host,
widget,
),
)
pos = (
self._width * 0.53
- c_width_total * 0.5
+ c_width * x,
self._height - 65 - c_height * y,
)
# Make the assumption that the first roster
# entry is the server.
# FIXME: Shouldn't do this.
if is_host:
twd = min(
c_width * 0.85,
bui.get_string_width(
p_str, suppress_warning=True
)
* t_scale,
)
self._name_widgets.append(
bui.textwidget(
parent=self._root_widget,
position=(
pos[0] + twd + 1,
pos[1] - 0.5,
),
size=(0, 0),
h_align='left',
v_align='center',
maxwidth=c_width * 0.96 - twd,
color=(0.1, 1, 0.1, 0.5),
text=bui.Lstr(
resource=f'{self._r}.hostText'
),
scale=0.4,
shadow=0.1,
flatness=1.0,
)
)
bui.textwidget(edit=self._empty_str, text='')
bui.textwidget(edit=self._empty_str_2, text='')
bui.scrollwidget(
edit=self._scrollwidget,
size=(
self._width - 50,
max(100, self._height - 139 - c_height_total),
),
position=(30, 80),
)
def _add_to_favorites(
self, name: str, address: str | None, port_num: int | None
) -> None:
addr = address
if addr == '':
bui.screenmessage(
bui.Lstr(resource='internal.invalidAddressErrorText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
port = port_num if port_num is not None else -1
if port > 65535 or port < 0:
bui.screenmessage(
bui.Lstr(resource='internal.invalidPortErrorText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
return
# Avoid empty names.
if not name:
name = f'{addr}@{port}'
config = bui.app.config
if addr:
if not isinstance(config.get('Saved Servers'), dict):
config['Saved Servers'] = {}
config['Saved Servers'][f'{addr}@{port}'] = {
'addr': addr,
'port': port,
'name': name,
}
config.commit()
bui.getsound('gunCocking').play()
bui.screenmessage(
bui.Lstr(
resource='addedToFavoritesText', subs=[('${NAME}', name)]
),
color=(0, 1, 0),
)
else:
bui.screenmessage(
bui.Lstr(resource='internal.invalidAddressErrorText'),
color=(1, 0, 0),
)
bui.getsound('error').play()
def _on_party_member_press(
self, client_id: int, is_host: bool, widget: bui.Widget
) -> None:
# if we're the host, pop up 'kick' options for all non-host members
if bs.get_foreground_host_session() is not None:
kick_str = bui.Lstr(resource='kickText')
else:
# kick-votes appeared in build 14248
info = bs.get_connection_to_host_info_2()
if info is None or info.build_number < 14248:
return
kick_str = bui.Lstr(resource='kickVoteText')
assert bui.app.classic is not None
uiscale = bui.app.ui_v1.uiscale
PopupMenuWindow(
position=widget.get_screen_space_center(),
scale=(
2.3
if uiscale is bui.UIScale.SMALL
else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23
),
choices=['kick'],
choices_display=[kick_str],
current_choice='kick',
delegate=self,
)
self._popup_type = 'partyMemberPress'
self._popup_party_member_client_id = client_id
self._popup_party_member_is_host = is_host
def _send_chat_message(self) -> None:
text = cast(str, bui.textwidget(query=self._text_field)).strip()
if text != '':
bs.chatmessage(text)
bui.textwidget(edit=self._text_field, text='')
[docs]
def close(self) -> None:
"""Close the window."""
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
bui.containerwidget(edit=self._root_widget, transition='out_scale')
[docs]
def close_with_sound(self) -> None:
"""Close the window and make a lovely sound."""
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
bui.getsound('swish').play()
self.close()
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum