from typing import Optional from enum import IntEnum import asyncio import discord from discord.ui.button import ButtonStyle, button, Button from discord.ui.text_input import TextInput, TextStyle from discord.ui.select import select, Select, SelectOption from meta import LionBot, LionCog, conf from meta.errors import UserInputError from utils.lib import MessageArgs from utils.ui import LeoUI, ModalRetryUI, FastModal, error_handler_for from babel.translator import ctx_translator from gui.cards import ProfileCard, StatsCard from gui.base import CardMode from ..graphics.stats import get_stats_card from ..graphics.profile import get_profile_card from ..data import StatsData from .. import babel from .base import StatsUI _p = babel._p class ProfileEditor(FastModal): limit = 5 editor = TextInput( label='', style=TextStyle.long, max_length=100, required=False ) def setup_editor(self): t = ctx_translator.get().t self.editor.label = t(_p( 'modal:profile_editor|field:editor|label', "Profile Tags (One line per tag)" )) self.editor.placeholder = t(_p( 'modal:profile_editor|field:editor|placeholder', "Mathematician\n" "Loves Cats" )) def setup(self): t = ctx_translator.get().t self.title = t(_p( 'modal:profile_editor|title', "Profile Tag Editor" )) self.setup_editor() def __init__(self, **kwargs): self.setup() super().__init__(**kwargs) async def parse(self): new_tags = (tag.strip() for tag in self.editor.value.splitlines()) new_tags = [tag for tag in new_tags if tag] # Validate tags if len(new_tags) > ProfileEditor.limit: t = ctx_translator.get().t raise UserInputError( t(_p( 'modal:profile_editor|error:too_many_tags', "Too many tags! You can have at most `{limit}` profile tags." )).format(limit=ProfileEditor.limit) ) # TODO: Per tag length validation return new_tags @error_handler_for(UserInputError) async def rerequest(self, interaction: discord.Interaction, error: UserInputError): await ModalRetryUI(self, error.msg).respond_to(interaction) class StatType(IntEnum): VOICE = 0 TEXT = 1 ANKI = 2 def select_name(self): if self is self.VOICE: # TODO: Handle study and general modes name = _p( 'menu:stat_type|opt:voice|name', "Voice Statistics" ) elif self is self.TEXT: name = _p( 'menu:stat_type|opt:text|name', "Text Statistics" ) elif self is self.ANKI: name = _p( 'menu:stat_type|opt:anki|name', "Anki Statistics" ) return name @property def card_mode(self): # TODO: Need to support VOICE separately from STUDY if self is self.VOICE: return CardMode.VOICE elif self is self.TEXT: return CardMode.TEXT elif self is self.ANKI: return CardMode.ANKI class ProfileUI(StatsUI): def __init__(self, bot, user, guild, **kwargs): super().__init__(bot, user, guild, **kwargs) # State self._stat_type = StatType.VOICE self._showing_stats = False self._stat_message = None # Card data for rendering self._profile_card: Optional[ProfileCard] = None self._xp_card = None self._stats_card: Optional[StatsCard] = None self._stats_future: Optional[asyncio.Future] = None @select(placeholder="...") async def type_menu(self, selection: discord.Interaction, menu: Select): value = int(menu.values[0]) if self._stat_type != value: await selection.response.defer(thinking=True, ephemeral=True) self._stat_type = StatType(value) # Clear card state for reload self._stats_card = None if self._stats_future is not None and not self._stats_future.done(): self._stats_future.cancel() self._stats_future = None await self.refresh(thinking=selection) else: await selection.response.defer() async def type_menu_refresh(self): # TODO: Check enabled types t = self.bot.translator.t options = [] for item in StatType: option = SelectOption(label=t(item.select_name()), value=str(item.value)) option.default = item is self._stat_type options.append(option) self.type_menu.options = options @button(label="Edit Profile", style=ButtonStyle.blurple) async def edit_button(self, press: discord.Interaction, pressed: Button): """ Press to open the profile tag editor. Opens a ProfileEditor modal with error-rerun handling. """ t = self.bot.translator.t data: StatsData = self.bot.get_cog('StatsCog').data tags = await data.ProfileTag.fetch_tags(self.guild.id, self.userid) modal = ProfileEditor() modal.editor.default = '\n'.join(tags) @modal.submit_callback() async def parse_tags(interaction: discord.Interaction): new_tags = await modal.parse() await interaction.response.defer(thinking=True, ephemeral=True) # Set the new tags and refresh await data.ProfileTag.set_tags(self.guild.id, self.userid, new_tags) if self._original is not None: self._profile_card = None await self.refresh(thinking=interaction) else: # Corner case where the UI has expired or been closed embed = discord.Embed( colour=discord.Colour.brand_green(), description=t(_p( 'modal:profile_editor|resp:success', "Your profile has been updated!" )) ) await interaction.edit_original_response(embed=embed) await press.response.send_modal(modal) async def edit_button_refresh(self): t = self.bot.translator.t self.edit_button.label = t(_p( 'ui:profile_card|button:edit|label', "Edit Profile Badges" )) @button(label="Show Statistics", style=ButtonStyle.blurple) async def stats_button(self, press: discord.Interaction, pressed: Button): """ Press to show or hide the statistics panel. """ self._showing_stats = not self._showing_stats await press.response.defer(thinking=True, ephemeral=True) await self.refresh(thinking=press) async def stats_button_refresh(self): button = self.stats_button t = self.bot.translator.t if self._showing_stats: button.label = t(_p( 'ui:profile_card|button:statistics|label:hide', "Hide Statistics" )) else: button.label = t(_p( 'ui:profile_card|button:statistics|label:show', "Show Statistics" )) @button(label="Global Stats", style=ButtonStyle.blurple) async def global_button(self, press: discord.Interaction, pressed: Button): """ Switch between local and global statistics modes. This is only displayed when statistics are shown. Also saves the value to user preferences. """ await press.response.defer(thinking=True, ephemeral=True) self._showing_global = not self._showing_global # TODO: Asynchronously update user preferences # Clear card state for reload self._stats_card = None if self._stats_future is not None and not self._stats_future.done(): self._stats_future.cancel() self._stats_future = None await self.refresh(thinking=press if not self._showing_global else None) if self._showing_global: t = self.bot.translator.t embed = discord.Embed( colour=discord.Colour.orange(), description=t(_p( 'ui:Profile|button:global|resp:success', "You will now see statistics from all you servers (where applicable)! Press again to revert." )) ) await press.edit_original_response(embed=embed) async def global_button_refresh(self): button = self.global_button if self._showing_global: button.label = "Server Statistics" else: button.label = "Global Statistics" @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) async def close_button(self, press: discord.Interaction, pressed: Button): """ Delete the output message and close the UI. """ await press.response.defer() await self._original.delete_original_response() if self._stat_message is not None: await self._stat_message.delete() self._stat_message = None self._original = None await self.close() async def refresh_components(self): """ Refresh each UI component, and the overall layout. """ await asyncio.gather( self.edit_button_refresh(), self.global_button_refresh(), self.stats_button_refresh(), self.close_button_refresh(), self.type_menu_refresh() ) if self._showing_stats: self._layout = [ (self.type_menu,), (self.stats_button, self.global_button, self.edit_button, self.close_button) ] else: self._layout = [ (self.stats_button, self.edit_button, self.close_button) ] voting = self.bot.get_cog('TopggCog') if voting and not await voting.check_voted_recently(self.userid): premiumcog = self.bot.get_cog('PremiumCog') if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)): self._layout.append((voting.vote_button(),)) async def _render_stats(self): """ Create and render the profile card. """ card = await get_stats_card(self.bot, self.userid, self.guildid, self._stat_type.card_mode) await card.render() self._stats_card = card return card async def _render_profile(self): """ Create and render the XP and stats cards. """ card = await get_profile_card(self.bot, self.userid, self.guild.id) if card: await card.render() self._profile_card = card return card async def reload(self): """ Reload the UI data, applying cache where possible. """ # Render the cards if required tasks = [] if self._profile_card is None: profile_task = asyncio.create_task(self._render_profile()) tasks.append(profile_task) if self._stats_card is None: if self._stats_future is None or self._stats_future.done() or self._stats_future.cancelled(): self._stats_future = asyncio.create_task(self._render_stats()) if self._showing_stats: tasks.append(self._stats_future) if tasks: await asyncio.gather(*tasks) async def redraw(self, thinking: Optional[discord.Interaction] = None): """ Redraw the UI. If a thinking interaction is provided, deletes the response while redrawing. """ profile_args, stat_args = await self.make_message() if thinking is not None and not thinking.is_expired() and thinking.response.is_done(): asyncio.create_task(thinking.delete_original_response()) if stat_args is not None: send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=None)) if self._stat_message is None: self._stat_message = await self._original.followup.send(**stat_args.send_args, view=self) else: await self._stat_message.edit(**stat_args.edit_args, view=self) else: send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=self)) if self._stat_message is not None: await self._stat_message.delete() self._stat_message = None await send_task async def make_message(self) -> MessageArgs: """ Make the message arguments. Apply cache where possible. """ profile_args = MessageArgs(file=self._profile_card.as_file('profile.png')) if self._showing_stats: stats_args = MessageArgs(file=self._stats_card.as_file('stats.png')) else: stats_args = None return (profile_args, stats_args) async def run(self, interaction: discord.Interaction): """ Execute the UI using the given interaction. """ self._original = interaction # TODO: Switch to using data cache in reload self._showing_global = False await self.refresh()