rewrite: Restructure to include GUI.
This commit is contained in:
393
src/settings/ui.py
Normal file
393
src/settings/ui.py
Normal file
@@ -0,0 +1,393 @@
|
||||
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 tabulate, recover_context
|
||||
from utils.ui import FastModal
|
||||
from meta.config import conf
|
||||
from babel.translator import ctx_translator
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def long_desc(self):
|
||||
t = ctx_translator.get().t
|
||||
return t(self._long_desc)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
t = ctx_translator.get().t
|
||||
return t(self._display_name)
|
||||
|
||||
@property
|
||||
def desc(self):
|
||||
t = ctx_translator.get().t
|
||||
return t(self._desc)
|
||||
|
||||
@property
|
||||
def accepts(self):
|
||||
t = ctx_translator.get().t
|
||||
return t(self._accepts)
|
||||
|
||||
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}"
|
||||
|
||||
@property
|
||||
def hover_desc(self):
|
||||
return '\n'.join((
|
||||
self.display_name,
|
||||
'=' * len(self.display_name),
|
||||
self.long_desc,
|
||||
f"\nAccepts: {self.accepts}"
|
||||
))
|
||||
|
||||
async def update_response(self, interaction: discord.Interaction, message: Optional[str] = None, **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)} {message or 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_desc}\n{self.desc_table}"
|
||||
return {'name': name, 'value': value}
|
||||
|
||||
@property
|
||||
def set_str(self):
|
||||
return None
|
||||
|
||||
@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):
|
||||
lines = []
|
||||
lines.append(('Currently', self.formatted or "Not Set"))
|
||||
if (default := self.default) is not None:
|
||||
lines.append(('By Default', self._format_data(self.parent_id, default) or "No Default"))
|
||||
if (set_str := self.set_str) is not None:
|
||||
lines.append(('Set Using', set_str))
|
||||
|
||||
return '\n'.join(tabulate(*lines))
|
||||
|
||||
@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.
|
||||
"""
|
||||
Reference in New Issue
Block a user