rewrite: Config UI utilities.
This commit is contained in:
304
src/utils/ui/config.py
Normal file
304
src/utils/ui/config.py
Normal 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)
|
||||
Reference in New Issue
Block a user