356 lines
12 KiB
Python
356 lines
12 KiB
Python
from typing import Optional, Callable, Any, Dict, Coroutine, Generic, TypeVar, List
|
|
import asyncio
|
|
from contextvars import copy_context
|
|
|
|
import discord
|
|
from discord import ui
|
|
from discord.ui.button import ButtonStyle, Button, button
|
|
from discord.ui.modal import Modal
|
|
from discord.ui.text_input import TextInput
|
|
|
|
from utils.lib import prop_tabulate, recover_context
|
|
from utils.ui import FastModal
|
|
from meta.config import conf
|
|
|
|
from .base import BaseSetting, ParentID, SettingData, SettingValue
|
|
|
|
|
|
ST = TypeVar('ST', bound='InteractiveSetting')
|
|
|
|
|
|
class SettingModal(FastModal):
|
|
input_field: TextInput = TextInput(label="Edit Setting")
|
|
|
|
def update_field(self, new_field):
|
|
self.remove_item(self.input_field)
|
|
self.add_item(new_field)
|
|
self.input_field = new_field
|
|
|
|
|
|
class SettingWidget(Generic[ST], ui.View):
|
|
# TODO: Permission restrictions and callback!
|
|
# Context variables for permitted user(s)? Subclass ui.View with PermittedView?
|
|
# Don't need to descend permissions to Modal
|
|
# Maybe combine with timeout manager
|
|
|
|
def __init__(self, setting: ST, auto_write=True, **kwargs):
|
|
self.setting = setting
|
|
self.update_children()
|
|
super().__init__(**kwargs)
|
|
self.auto_write = auto_write
|
|
|
|
self._interaction: Optional[discord.Interaction] = None
|
|
self._modal: Optional[SettingModal] = None
|
|
self._exports: List[ui.Item] = self.make_exports()
|
|
|
|
self._context = copy_context()
|
|
|
|
def update_children(self):
|
|
"""
|
|
Method called before base View initialisation.
|
|
Allows updating the children components (usually explicitly defined callbacks),
|
|
before Item instantiation.
|
|
"""
|
|
pass
|
|
|
|
def order_children(self, *children):
|
|
"""
|
|
Helper method to set and order the children using bound methods.
|
|
"""
|
|
child_map = {child.__name__: child for child in self.__view_children_items__}
|
|
self.__view_children_items__ = [child_map[child.__name__] for child in children]
|
|
|
|
def update_child(self, child, new_args):
|
|
args = getattr(child, '__discord_ui_model_kwargs__')
|
|
args |= new_args
|
|
|
|
def make_exports(self):
|
|
"""
|
|
Called post-instantiation to populate self._exports.
|
|
"""
|
|
return self.children
|
|
|
|
def refresh(self):
|
|
"""
|
|
Update widget components from current setting data, if applicable.
|
|
E.g. to update the default entry in a select list after a choice has been made,
|
|
or update button colours.
|
|
This does not trigger a discord ui update,
|
|
that is the responsibility of the interaction handler.
|
|
"""
|
|
pass
|
|
|
|
async def show(self, interaction: discord.Interaction, key: Any = None, override=False, **kwargs):
|
|
"""
|
|
Complete standard setting widget UI flow for this setting.
|
|
The SettingWidget components may be attached to other messages as needed,
|
|
and they may be triggered individually,
|
|
but this coroutine defines the standard interface.
|
|
Intended for use by any interaction which wants to "open the setting".
|
|
|
|
Extra keyword arguments are passed directly to the interaction reply (for e.g. ephemeral).
|
|
"""
|
|
if key is None:
|
|
# By default, only have one widget listener per interaction.
|
|
key = ('widget', interaction.id)
|
|
|
|
# If there is already a widget listening on this key, respect override
|
|
if self.setting.get_listener(key) and not override:
|
|
# Refuse to spawn another widget
|
|
return
|
|
|
|
async def update_callback(new_data):
|
|
self.setting.data = new_data
|
|
await interaction.edit_original_response(embed=self.setting.embed, view=self, **kwargs)
|
|
|
|
self.setting.register_callback(key)(update_callback)
|
|
await interaction.response.send_message(embed=self.setting.embed, view=self, **kwargs)
|
|
await self.wait()
|
|
try:
|
|
# Try and detach the view, since we aren't handling events anymore.
|
|
await interaction.edit_original_response(view=None)
|
|
except discord.HTTPException:
|
|
pass
|
|
self.setting.deregister_callback(key)
|
|
|
|
def attach(self, group_view: ui.View):
|
|
"""
|
|
Attach this setting widget to a view representing several settings.
|
|
"""
|
|
for item in self._exports:
|
|
group_view.add_item(item)
|
|
|
|
@button(style=ButtonStyle.secondary, label="Edit", row=4)
|
|
async def edit_button(self, interaction: discord.Interaction, button: ui.Button):
|
|
"""
|
|
Spawn a simple edit modal,
|
|
populated with `setting.input_field`.
|
|
"""
|
|
recover_context(self._context)
|
|
# Spawn the setting modal
|
|
await interaction.response.send_modal(self.modal)
|
|
|
|
@button(style=ButtonStyle.danger, label="Reset", row=4)
|
|
async def reset_button(self, interaction: discord.Interaction, button: Button):
|
|
recover_context(self._context)
|
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
|
await self.setting.interactive_set(None, interaction)
|
|
|
|
@property
|
|
def modal(self) -> Modal:
|
|
"""
|
|
Build a Modal dialogue for updating the setting.
|
|
Refreshes (and re-attaches) the input field each time this is called.
|
|
"""
|
|
if self._modal is not None:
|
|
self._modal.update_field(self.setting.input_field)
|
|
return self._modal
|
|
|
|
# TODO: Attach shared timeouts to the modal
|
|
self._modal = modal = SettingModal(
|
|
title=f"Edit {self.setting.display_name}",
|
|
)
|
|
modal.update_field(self.setting.input_field)
|
|
|
|
@modal.submit_callback()
|
|
async def edit_submit(interaction: discord.Interaction):
|
|
# TODO: Catch and handle UserInputError
|
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
|
data = await self.setting._parse_string(self.setting.parent_id, modal.input_field.value)
|
|
await self.setting.interactive_set(data, interaction)
|
|
|
|
return modal
|
|
|
|
|
|
class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
|
__slots__ = ('_widget',)
|
|
|
|
# Configuration interface descriptions
|
|
display_name: str # User readable name of the setting
|
|
desc: str # User readable brief description of the setting
|
|
long_desc: str # User readable long description of the setting
|
|
accepts: str # User readable description of the acceptable values
|
|
|
|
Widget = SettingWidget
|
|
|
|
# A list of callback coroutines to call when the setting updates
|
|
# This can be used globally to refresh state when the setting updates,
|
|
# Or locallly to e.g. refresh an active widget.
|
|
# The callbacks are called on write, so they may be bypassed by direct use of _writer!
|
|
_listeners_: Dict[Any, Callable[[Optional[SettingData]], Coroutine[Any, Any, None]]] = {}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self._widget: Optional[SettingWidget] = None
|
|
|
|
async def write(self, **kwargs) -> None:
|
|
await super().write(**kwargs)
|
|
for listener in self._listeners_.values():
|
|
asyncio.create_task(listener(self.data))
|
|
|
|
def get_listener(self, key):
|
|
return self._listeners_.get(key, None)
|
|
|
|
@classmethod
|
|
def register_callback(cls, name=None):
|
|
def wrapped(coro):
|
|
cls._listeners_[name or coro.__name__] = coro
|
|
return coro
|
|
return wrapped
|
|
|
|
@classmethod
|
|
def deregister_callback(cls, name):
|
|
cls._listeners_.pop(name, None)
|
|
|
|
@property
|
|
def update_message(self):
|
|
"""
|
|
Response message sent when the setting has successfully been updated.
|
|
Should generally be one line.
|
|
"""
|
|
if self.data is None:
|
|
return "Setting reset!"
|
|
else:
|
|
return f"Setting Updated! New value: {self.formatted}"
|
|
|
|
async def update_response(self, interaction: discord.Interaction, **kwargs):
|
|
"""
|
|
Respond to an interaction which triggered a setting update.
|
|
Usually just wraps `update_message` in an embed and sends it back.
|
|
Passes any extra `kwargs` to the message creation method.
|
|
"""
|
|
embed = discord.Embed(
|
|
description=f"{str(conf.emojis.tick)} {self.update_message}",
|
|
colour=discord.Color.green()
|
|
)
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(embed=embed, **kwargs)
|
|
else:
|
|
await interaction.response.send_message(embed=embed, **kwargs)
|
|
|
|
async def interactive_set(self, new_data: Optional[SettingData], interaction: discord.Interaction, **kwargs):
|
|
self.data = new_data
|
|
await self.write()
|
|
await self.update_response(interaction, **kwargs)
|
|
|
|
@property
|
|
def embed_field(self):
|
|
"""
|
|
Returns a {name, value} pair for use in an Embed field.
|
|
"""
|
|
name = self.display_name
|
|
value = f"{self.long_dec}\n{self.desc_table}"
|
|
return {'name': name, 'value': value}
|
|
|
|
@property
|
|
def embed(self):
|
|
"""
|
|
Returns a full embed describing this setting.
|
|
"""
|
|
embed = discord.Embed(
|
|
title="Configuration options for `{}`".format(self.display_name),
|
|
)
|
|
embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table)
|
|
return embed
|
|
|
|
@property
|
|
def desc_table(self):
|
|
fields = ("Current value", "Default value")
|
|
values = (self.formatted or "Not Set",
|
|
self._format_data(self.parent_id, self.default) or "None")
|
|
return prop_tabulate(fields, values)
|
|
|
|
@property
|
|
def input_field(self) -> TextInput:
|
|
"""
|
|
TextInput field used for string-based setting modification.
|
|
May be added to external modal for grouped setting editing.
|
|
This property is not persistent, and creates a new field each time.
|
|
"""
|
|
return TextInput(
|
|
label=self.display_name,
|
|
placeholder=self.accepts,
|
|
default=self.input_formatted,
|
|
required=False
|
|
)
|
|
|
|
@property
|
|
def widget(self):
|
|
"""
|
|
Returns the Discord UI View associated with the current setting.
|
|
"""
|
|
if self._widget is None:
|
|
self._widget = self.Widget(self)
|
|
return self._widget
|
|
|
|
@classmethod
|
|
def set_widget(cls, WidgetCls):
|
|
"""
|
|
Convenience decorator to create the widget class for this setting.
|
|
"""
|
|
cls.Widget = WidgetCls
|
|
return WidgetCls
|
|
|
|
@property
|
|
def formatted(self):
|
|
"""
|
|
Default user-readable form of the setting.
|
|
Should be a short single line.
|
|
"""
|
|
return self._format_data(self.parent_id, self.data)
|
|
|
|
@property
|
|
def input_formatted(self) -> str:
|
|
"""
|
|
Format the current value as a default value for an input field.
|
|
Returned string must be acceptable through parse_string.
|
|
Does not take into account defaults.
|
|
"""
|
|
if self._data is not None:
|
|
return str(self._data)
|
|
else:
|
|
return ""
|
|
|
|
@property
|
|
def summary(self):
|
|
"""
|
|
Formatted summary of the data.
|
|
May be implemented in `_format_data(..., summary=True, ...)` or overidden.
|
|
"""
|
|
return self._format_data(self.parent_id, self.data, summary=True)
|
|
|
|
@classmethod
|
|
async def from_string(cls, parent_id, userstr: str, **kwargs):
|
|
"""
|
|
Return a setting instance initialised from a parsed user string.
|
|
"""
|
|
data = await cls._parse_string(parent_id, userstr, **kwargs)
|
|
return cls(parent_id, data, **kwargs)
|
|
|
|
@classmethod
|
|
async def _parse_string(cls, parent_id, string: str, **kwargs) -> Optional[SettingData]:
|
|
"""
|
|
Parse user provided string (usually from a TextInput) into raw setting data.
|
|
Must be overriden by the setting if the setting is user-configurable.
|
|
Returns None if the setting was unset.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@classmethod
|
|
def _format_data(cls, parent_id, data, **kwargs):
|
|
"""
|
|
Convert raw setting data into a formatted user-readable string,
|
|
representing the current value.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
"""
|
|
command callback for set command?
|
|
autocomplete for set command?
|
|
|
|
Might be better in a ConfigSetting subclass.
|
|
But also mix into the base setting types.
|
|
"""
|