rewrite: Config UI utilities.

This commit is contained in:
2023-05-14 12:27:13 +03:00
parent c5302adf66
commit b569cdecaf

304
src/utils/ui/config.py Normal file
View File

@@ -0,0 +1,304 @@
from typing import Optional
import asyncio
import discord
from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from meta.errors import UserInputError
from wards import i_low_management
from babel.translator import ctx_translator, LazyStr
from ..lib import tabulate
from . import LeoUI, util_babel, error_handler_for, FastModal, ModalRetryUI
_p = util_babel._p
class ConfigEditor(FastModal):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@error_handler_for(UserInputError)
async def rerequest(self, interaction, error):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class ConfigUI(LeoUI):
# TODO: Migrate to a subclass of MessageUI
_listening = {}
setting_classes = []
edit_modal_title = _p('ui:configui|modal:edit|title', "Setting Editor")
def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.guildid = guildid
self.channelid = channelid
# Original interaction, if this UI is sent as an interaction response
self._original: Optional[discord.Interaction] = None
# Message containing the UI, when the UI is sent as a followup
self._message: Optional[discord.Message] = None
# Refresh lock, to avoid cache collisions
self._refresh_lock = asyncio.Lock()
# Instances of the settings this UI is managing
self.instances = ()
async def interaction_check(self, interaction: discord.Interaction):
"""
Default requirement for a Config UI is low management (i.e. manage_guild permissions).
"""
return await i_low_management(interaction)
async def cleanup(self):
self._listening.pop(self.channelid, None)
for instance in self.instances:
instance.deregister_callback(self.id)
try:
if self._original is not None and not self._original.is_expired():
await self._original.edit_original_response(view=None)
self._original = None
if self._message is not None:
await self._message.edit(view=None)
self._message = None
except discord.HTTPException:
pass
@button(label="EDIT_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_button(self, press: discord.Interaction, pressed: Button):
"""
Bulk edit the current setting instances.
There must be no more than 5 instances for this to work!
They are all assumed to support `input_formatted` and `parse_string`.
Errors should raise instances of `UserInputError`, and will be caught for retry.
"""
t = ctx_translator.get().t
instances = self.instances
items = [setting.input_field for setting in instances]
strings = [item.value for item in items]
modal = ConfigEditor(*items, title=t(self.edit_modal_title))
@modal.submit_callback()
async def save_settings(interaction: discord.Interaction):
# NOTE: Cannot respond with a defer because we need ephemeral error
modified = []
for setting, field, original in zip(instances, items, strings):
if field.value != original:
# Setting was modified, attempt to parse
input_value = field.value.strip()
if not input_value:
# None input, reset the setting
new_data = None
else:
# If this raises a UserInputError, it will be caught and the modal retried
new_data = await setting._parse_string(setting.parent_id, input_value)
setting.data = new_data
modified.append(setting)
if modified:
await interaction.response.defer(thinking=True)
# Write the settings to disk
for setting in modified:
# TODO: Again, need a way of batching these
# Also probably put them in a transaction
await setting.write()
# TODO: Send modified ack
desc = '\n'.join(f"{conf.emojis.tick} {setting.update_message}" for setting in modified)
await interaction.edit_original_response(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description=desc
)
)
else:
await interaction.response.defer(thinking=False)
await press.response.send_modal(modal)
async def edit_button_refresh(self):
t = ctx_translator.get().t
self.edit_button.label = t(_p(
'ui:configui|button:edit|label',
"Bulk Edit"
))
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def close_button(self, press: discord.Interaction, pressed: Button):
"""
Close the UI, if possible.
"""
await press.response.defer()
try:
if self._original is not None and not self._original.is_expired():
await self._original.delete_original_response()
self._original = None
elif self._message is not None:
await self._message.delete()
self._message = None
except discord.HTTPException:
self._original = None
self._message = None
await self.close()
async def close_button_refresh(self):
pass
@button(label="RESET_PLACEHOLDER", style=ButtonStyle.red)
async def reset_button(self, press: discord.Interaction, pressed: Button):
"""
Reset the controlled settings.
"""
await press.response.defer()
for instance in self.instances:
instance.data = None
await instance.write()
async def reset_button_refresh(self):
t = self.bot.translator.t
self.reset_button.label = t(_p(
'ui:guild_config_base|button:reset|label', "Reset"
))
async def refresh_components(self):
"""
Refresh UI layout and individual components.
"""
raise NotImplementedError
async def reload(self):
"""
Reload UI data, including instantiating settings.
Default implementation directly re-instantiates each setting in self.setting_classes.
Should be overridden for conditional settings or more advanced caching methods.
"""
self.instances = tuple([
await cls.get(self.guildid) for cls in self.setting_classes
])
async def make_message(self):
"""
UI message arguments, to be calculated after reload.
"""
raise NotImplementedError
async def redraw(self, thinking: Optional[discord.Interaction] = None):
"""
Redraw the UI.
If a thinking interaction is provided,
deletes the response while redrawing.
"""
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())
try:
if self._original and not self._original.is_expired():
await self._original.edit_original_response(**args.edit_args, view=self)
elif self._message:
await self._message.edit(**args.edit_args, view=self)
else:
# Interaction expired or we already closed. Exit quietly.
await self.close()
except discord.HTTPException:
# Some unknown communication error, nothing we can safely do. Exit quietly.
await self.close()
async def refresh(self, *args, thinking: Optional[discord.Interaction] = None):
"""
Refresh the UI.
"""
async with self._refresh_lock:
# Refresh data
await self.reload()
# Refresh UI components and layout
await self.refresh_components()
# Redraw UI message
await self.redraw(thinking=thinking)
async def run(self, interaction: discord.Interaction):
if old := self._listening.get(self.channelid, None):
await old.close()
await self.reload()
await self.refresh_components()
args = await self.make_message()
if interaction.response.is_done():
# Start UI using followup message
self._message = await interaction.followup.send(**args.send_args, view=self)
else:
# Start UI using interaction response
self._original = interaction
await interaction.response.send_message(**args.send_args, view=self)
for instance in self.instances:
# Attach refresh callback to each instance
instance.register_callback(self.id)(self.refresh)
# Register this UI as listening for updates in this channel
self._listening[self.channelid] = self
class DashboardSection:
"""
Represents a section of a configuration Dashboard.
"""
section_name: LazyStr = None
setting_classes = []
configui = None
def __init__(self, bot: LionBot, guildid: int):
self.bot = bot
self.guildid = guildid
# List of instances of the contained setting classes
# Populated in load()
self.instances = []
async def load(self):
"""
Initialise the contained settings.
"""
instances = []
for cls in self.setting_classes:
instance = await cls.get(self.guildid)
instances.append(instance)
self.instances = instances
return self
def apply_to(self, page: discord.Embed):
"""
Apply this section to the given dashboard page.
Usually just defines and adds an embed field with the setting table.
"""
t = ctx_translator.get().t
# TODO: Header/description field
table = self.make_table()
page.add_field(
name=t(self.section_name),
value=table,
inline=False
)
def make_table(self):
rows = []
for setting in self.instances:
name = setting.display_name
value = setting.formatted
rows.append((name, value, setting.desc))
table_rows = tabulate(
*rows,
row_format="[`{invis}{key:<{pad}}{colon}`](https://lionbot.org \"{field[2]}\")\t{value}"
)
return '\n'.join(table_rows)