diff --git a/discord/abc.py b/discord/abc.py index da3d6ddd18..da8e5d37cc 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ from .types.channel import GuildChannel as GuildChannelPayload from .types.channel import OverwriteType from .types.channel import PermissionOverwrite as PermissionOverwritePayload - from .ui.view import View + from .ui.view import BaseView from .user import ClientUser PartialMessageableChannel = Union[ @@ -1355,7 +1355,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1376,7 +1376,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1397,7 +1397,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1418,7 +1418,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1509,7 +1509,7 @@ async def send( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` A Discord UI View to add to the message. embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. @@ -1611,7 +1611,7 @@ async def send( if view: if not hasattr(view, "__discord_ui_view__"): raise InvalidArgument( - f"view parameter must be View not {view.__class__!r}" + f"view parameter must be BaseView not {view.__class__!r}" ) components = view.to_components() diff --git a/discord/channel.py b/discord/channel.py index e5e55f360c..0d21f38013 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -107,7 +107,7 @@ from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration - from .ui.view import View + from .ui.view import BaseView from .user import BaseUser, ClientUser, User from .webhook import Webhook @@ -1217,7 +1217,7 @@ async def create_thread( delete_message_after: float | None = None, nonce: int | str | None = None, allowed_mentions: AllowedMentions | None = None, - view: View | None = None, + view: BaseView | None = None, applied_tags: list[ForumTag] | None = None, suppress: bool = False, silent: bool = False, @@ -1262,7 +1262,7 @@ async def create_thread( to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` A Discord UI View to add to the message. applied_tags: List[:class:`discord.ForumTag`] A list of tags to apply to the new thread. @@ -1328,7 +1328,7 @@ async def create_thread( if view: if not hasattr(view, "__discord_ui_view__"): raise InvalidArgument( - f"view parameter must be View not {view.__class__!r}" + f"view parameter must be BaseView not {view.__class__!r}" ) components = view.to_components() diff --git a/discord/client.py b/discord/client.py index ed6cdb8991..4100b383a8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -59,7 +59,7 @@ from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory from .template import Template from .threads import Thread -from .ui.view import View +from .ui.view import BaseView from .user import ClientUser, User from .utils import MISSING from .voice_client import VoiceClient @@ -74,7 +74,7 @@ from .message import Message from .poll import Poll from .soundboard import SoundboardSound - from .ui.item import Item + from .ui.item import Item, ViewItem from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -546,19 +546,19 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: traceback.print_exc() async def on_view_error( - self, error: Exception, item: Item, interaction: Interaction + self, error: Exception, item: ViewItem, interaction: Interaction ) -> None: """|coro| The default view error handler provided by the client. - This only fires for a view if you did not define its :func:`~discord.ui.View.on_error`. + This only fires for a view if you did not define its :func:`~discord.ui.BaseView.on_error`. Parameters ---------- error: :class:`Exception` The exception that was raised. - item: :class:`Item` + item: :class:`ViewItem` The item that the user interacted with. interaction: :class:`Interaction` The interaction that was received. @@ -2037,8 +2037,8 @@ async def create_dm(self, user: Snowflake) -> DMChannel: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) - def add_view(self, view: View, *, message_id: int | None = None) -> None: - """Registers a :class:`~discord.ui.View` for persistent listening. + def add_view(self, view: BaseView, *, message_id: int | None = None) -> None: + """Registers a :class:`~discord.ui.BaseView` for persistent listening. This method should be used for when a view is comprised of components that last longer than the lifecycle of the program. @@ -2047,7 +2047,7 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: Parameters ---------- - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to register for dispatching. message_id: Optional[:class:`int`] The message ID that the view is attached to. This is currently used to @@ -2063,8 +2063,8 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: and all their components have an explicitly provided ``custom_id``. """ - if not isinstance(view, View): - raise TypeError(f"expected an instance of View not {view.__class__!r}") + if not isinstance(view, BaseView): + raise TypeError(f"expected an instance of BaseView not {view.__class__!r}") if not view.is_persistent(): raise ValueError( @@ -2075,7 +2075,7 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: self._connection.store_view(view, message_id) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: """A sequence of persistent views added to the client. .. versionadded:: 2.0 diff --git a/discord/components.py b/discord/components.py index 9f9e76aa8e..6fdce8a6f1 100644 --- a/discord/components.py +++ b/discord/components.py @@ -176,7 +176,7 @@ def __init__(self, data: ComponentPayload): @property def width(self): - """Return the sum of the children's widths.""" + """Return the sum of the items' widths.""" t = 0 for item in self.children: t += 1 if item.type is ComponentType.button else 5 @@ -192,6 +192,10 @@ def to_dict(self) -> ActionRowPayload: def walk_components(self) -> Iterator[Component]: yield from self.children + @property + def components(self) -> list[Component]: + return self.children + def get_component(self, id: str | int) -> Component | None: """Get a component from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the component will be retrieved by ``id``, otherwise by ``custom_id``. @@ -266,7 +270,7 @@ def __init__(self, data: InputTextComponentPayload): self.id: int | None = data.get("id") self.style: InputTextStyle = try_enum(InputTextStyle, data["style"]) self.custom_id = data["custom_id"] - self.label: str = data.get("label", None) + self.label: str | None = data.get("label", None) self.placeholder: str | None = data.get("placeholder", None) self.min_length: int | None = data.get("min_length", None) self.max_length: int | None = data.get("max_length", None) @@ -278,7 +282,6 @@ def to_dict(self) -> InputTextComponentPayload: "type": 4, "id": self.id, "style": self.style.value, - "label": self.label, } if self.custom_id: payload["custom_id"] = self.custom_id @@ -298,6 +301,9 @@ def to_dict(self) -> InputTextComponentPayload: if self.value: payload["value"] = self.value + if self.label: + payload["label"] = self.label + return payload # type: ignore @@ -1305,7 +1311,7 @@ class Label(Component): ``component`` may only be: - :class:`InputText` - - :class:`SelectMenu` (string) + - :class:`SelectMenu` This inherits from :class:`Component`. diff --git a/discord/enums.py b/discord/enums.py index cc2429a84c..457fbc07dc 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -734,6 +734,7 @@ class ComponentType(Enum): separator = 14 content_inventory_entry = 16 container = 17 + label = 18 def __int__(self): return self.value diff --git a/discord/interactions.py b/discord/interactions.py index c94c2bcea4..aaf1dee386 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -89,7 +89,7 @@ from .types.interactions import InteractionMetadata as InteractionMetadataPayload from .types.interactions import MessageInteraction as MessageInteractionPayload from .ui.modal import Modal - from .ui.view import View + from .ui.view import BaseView InteractionChannel = Union[ VoiceChannel, @@ -164,7 +164,7 @@ class Interaction: The command that this interaction belongs to. .. versionadded:: 2.7 - view: Optional[:class:`View`] + view: Optional[:class:`BaseView`] The view that this interaction belongs to. .. versionadded:: 2.7 @@ -257,7 +257,7 @@ def _from_data(self, data: InteractionPayload): ) self.command: ApplicationCommand | None = None - self.view: View | None = None + self.view: BaseView | None = None self.modal: Modal | None = None self.attachment_size_limit: int = data.get("attachment_size_limit") @@ -398,12 +398,7 @@ def response(self) -> InteractionResponse: @utils.cached_slot_property("_cs_followup") def followup(self) -> Webhook: """Returns the followup webhook for followup interactions.""" - payload = { - "id": self.application_id, - "type": 3, - "token": self.token, - } - return Webhook.from_state(data=payload, state=self._state) + return Webhook.from_interaction(interaction=self) def is_guild_authorised(self) -> bool: """:class:`bool`: Checks if the interaction is guild authorised. @@ -522,7 +517,7 @@ async def edit_original_response( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool = False, @@ -557,7 +552,7 @@ async def edit_original_response( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] @@ -947,7 +942,7 @@ async def send_message( *, embed: Embed = None, embeds: list[Embed] = None, - view: View = None, + view: BaseView = None, tts: bool = False, ephemeral: bool = False, allowed_mentions: AllowedMentions = None, @@ -972,7 +967,7 @@ async def send_message( ``embeds`` parameter. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to send with the message. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. @@ -1105,11 +1100,11 @@ async def send_message( self._responded = True await self._process_callback_response(callback_response) if view: + view.parent = self._parent if not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - view.parent = self._parent if view.is_dispatchable(): self._parent._state.store_view(view) @@ -1128,7 +1123,7 @@ async def edit_message( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, delete_after: float | None = None, suppress: bool | None = MISSING, allowed_mentions: AllowedMentions | None = None, @@ -1155,7 +1150,7 @@ async def edit_message( attachments: List[:class:`Attachment`] A list of attachments to keep in the message. If ``[]`` is passed then all attachments are removed. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] @@ -1486,7 +1481,7 @@ async def edit( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool | None = MISSING, @@ -1515,7 +1510,7 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] diff --git a/discord/message.py b/discord/message.py index e476ae8fcf..117050d2d3 100644 --- a/discord/message.py +++ b/discord/message.py @@ -92,7 +92,7 @@ from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload - from .ui.view import View + from .ui.view import BaseView from .user import User MR = TypeVar("MR", bound="MessageReference") @@ -1660,7 +1660,7 @@ async def edit( suppress: bool = ..., delete_after: float | None = ..., allowed_mentions: AllowedMentions | None = ..., - view: View | None = ..., + view: BaseView | None = ..., ) -> Message: ... async def edit( @@ -1674,7 +1674,7 @@ async def edit( suppress: bool = MISSING, delete_after: float | None = None, allowed_mentions: AllowedMentions | None = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, ) -> Message: """|coro| @@ -1723,7 +1723,7 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. @@ -2412,7 +2412,7 @@ async def edit(self, **fields: Any) -> Message | None: to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. diff --git a/discord/state.py b/discord/state.py index dc982d43bb..dcb63f9e87 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .sticker import GuildSticker from .threads import Thread, ThreadMember from .ui.modal import Modal, ModalStore -from .ui.view import View, ViewStore +from .ui.view import BaseView, ViewStore from .user import ClientUser, User if TYPE_CHECKING: @@ -399,17 +399,17 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: int | None = None) -> None: + def store_view(self, view: BaseView, message_id: int | None = None) -> None: self._view_store.add_view(view, message_id) def store_modal(self, modal: Modal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) - def prevent_view_updates_for(self, message_id: int) -> View | None: + def prevent_view_updates_for(self, message_id: int) -> BaseView | None: return self._view_store.remove_message_tracking(message_id) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: return self._view_store.persistent_views @property diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 473ac45563..37808cf427 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -8,11 +8,14 @@ :license: MIT, see LICENSE for more details. """ +from .action_row import * from .button import * from .container import * +from .core import * from .file import * from .input_text import * from .item import * +from .label import * from .media_gallery import * from .modal import * from .section import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 0000000000..d3607867b7 --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,428 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload + +from ..components import ActionRow as ActionRowComponent +from ..components import SelectDefaultValue, SelectOption, _component_factory +from ..enums import ButtonStyle, ChannelType, ComponentType +from ..utils import find, get +from .button import Button +from .file import File +from .item import ItemCallbackType, ViewItem +from .select import Select + +__all__ = ("ActionRow",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..emoji import AppEmoji, GuildEmoji + from ..partial_emoji import PartialEmoji, _EmojiTag + from ..types.components import ActionRow as ActionRowPayload + from .view import DesignerView + + +A = TypeVar("A", bound="ActionRow") +V = TypeVar("V", bound="DesignerView", covariant=True) + + +class ActionRow(ViewItem[V]): + """Represents a UI Action Row used in :class:`discord.ui.DesignerView`. + + The items supported are as follows: + + - :class:`discord.ui.Select` + - :class:`discord.ui.Button` + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`ViewItem` + The initial items in this action row. + id: Optional[:class:`int`] + The action's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "children", + "id", + ) + + __row_children_items__: ClassVar[list[ItemCallbackType]] = [] + + def __init_subclass__(cls) -> None: + children: list[ItemCallbackType] = [] + for base in reversed(cls.__mro__): + for member in base.__dict__.values(): + if hasattr(member, "__discord_ui_model_type__"): + children.append(member) + + cls.__row_children_items__ = children + + def __init__( + self, + *items: ViewItem, + id: int | None = None, + ): + super().__init__() + + self.children: list[ViewItem] = [] + + self._underlying = ActionRowComponent._raw_construct( + type=ComponentType.action_row, + id=id, + children=[], + ) + + for func in self.__row_children_items__: + item: ViewItem = func.__discord_ui_model_type__( + **func.__discord_ui_model_kwargs__ + ) + item.callback = partial(func, self, item) + self.add_item(item) + setattr(self, func.__name__, item) + for i in items: + self.add_item(i) + + def _add_component_from_item(self, item: ViewItem): + self._underlying.children.append(item._underlying) + + def _set_components(self, items: list[ViewItem]): + self._underlying.children.clear() + for item in items: + self._add_component_from_item(item) + + def add_item(self, item: ViewItem) -> Self: + """Adds an item to the action row. + + Parameters + ---------- + item: :class:`ViewItem` + The item to add to the action row. + + Raises + ------ + TypeError + A :class:`ViewItem` was not passed. + """ + + if not isinstance(item, (Select, Button)): + raise TypeError(f"expected Select or Button, not {item.__class__!r}") + if item.row: + raise ValueError(f"{item.__class__!r}.row is not supported in ActionRow") + if self.width + item.width > 5: + raise ValueError(f"Not enough space left on this ActionRow") + + item._view = self.view + item.parent = self + + self.children.append(item) + self._add_component_from_item(item) + return self + + def remove_item(self, item: ViewItem | str | int) -> Self: + """Removes an item from the action row. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively. + + Parameters + ---------- + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, ``id``, or item ``custom_id`` to remove from the action row. + """ + + if isinstance(item, (str, int)): + item = self.get_item(item) + try: + self.children.remove(item) + except ValueError: + pass + return self + + def get_item(self, id: str | int) -> ViewItem | None: + """Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. + If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + id: Union[:class:`str`, :class:`int`] + The id or custom_id of the item to get. + + Returns + ------- + Optional[:class:`ViewItem`] + The item with the matching ``id`` or ``custom_id`` if it exists. + """ + if not id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == id, self.children) + return child + + def add_button( + self, + *, + style: ButtonStyle = ButtonStyle.secondary, + label: str | None = None, + disabled: bool = False, + custom_id: str | None = None, + url: str | None = None, + emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, + sku_id: int | None = None, + id: int | None = None, + ) -> Self: + """Adds a :class:`Button` to the action row. + + To append a pre-existing :class:`Button`, use the + :meth:`add_item` method instead. + + Parameters + ---------- + style: :class:`discord.ButtonStyle` + The style of the button. + custom_id: Optional[:class:`str`] + The custom ID of the button that gets received during an interaction. + If this button is for a URL, it does not have a custom ID. + url: Optional[:class:`str`] + The URL this button sends you to. + disabled: :class:`bool` + Whether the button is disabled or not. + label: Optional[:class:`str`] + The label of the button, if any. Maximum of 80 chars. + emoji: Optional[Union[:class:`.PartialEmoji`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`str`]] + The emoji of the button, if any. + sku_id: Optional[Union[:class:`int`]] + The ID of the SKU this button refers to. + id: Optional[:class:`int`] + The button's ID. + """ + + button = Button( + style=style, + label=label, + disabled=disabled, + custom_id=custom_id, + url=url, + emoji=emoji, + sku_id=sku_id, + id=id, + ) + + return self.add_item(button) + + @overload + def add_select( + self, + select_type: Literal[ComponentType.string_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + options: list[SelectOption] | None = ..., + disabled: bool = ..., + id: int | None = ..., + ) -> None: ... + + @overload + def add_select( + self, + select_type: Literal[ComponentType.channel_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + channel_types: list[ChannelType] | None = ..., + disabled: bool = ..., + id: int | None = ..., + default_values: Sequence[SelectDefaultValue] | None = ..., + ) -> None: ... + + @overload + def add_select( + self, + select_type: Literal[ + ComponentType.user_select, + ComponentType.role_select, + ComponentType.mentionable_select, + ] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + id: int | None = ..., + default_values: Sequence[SelectDefaultValue] | None = ..., + ) -> None: ... + + def add_select( + self, + select_type: ComponentType = ComponentType.string_select, + *, + custom_id: str | None = None, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + options: list[SelectOption] | None = None, + channel_types: list[ChannelType] | None = None, + disabled: bool = False, + id: int | None = None, + default_values: Sequence[SelectDefaultValue] | None = None, + ) -> Self: + """Adds a :class:`Select` to the container. + + To append a pre-existing :class:`Select`, use the + :meth:`add_item` method instead. + + Parameters + ---------- + select_type: :class:`discord.ComponentType` + The type of select to create. Must be one of + :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, + :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, + or :attr:`discord.ComponentType.channel_select`. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + If not given then one is generated for you. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + max_values: :class:`int` + The maximum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + options: List[:class:`discord.SelectOption`] + A list of options that can be selected in this menu. + Only valid for selects of type :attr:`discord.ComponentType.string_select`. + channel_types: List[:class:`discord.ChannelType`] + A list of channel types that can be selected in this menu. + Only valid for selects of type :attr:`discord.ComponentType.channel_select`. + disabled: :class:`bool` + Whether the select is disabled or not. Defaults to ``False``. + id: Optional[:class:`int`] + The select menu's ID. + default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] + The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`. + + These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue` + instances. + """ + + select = Select( + select_type=select_type, + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + options=options or [], + channel_types=channel_types or [], + disabled=disabled, + id=id, + default_values=default_values, + ) + + return self.add_item(select) + + @ViewItem.view.setter + def view(self, value): + self._view = value + for item in self.children: + item.parent = self + item._view = value + + def is_dispatchable(self) -> bool: + return any(item.is_dispatchable() for item in self.children) + + def is_persistent(self) -> bool: + return all(item.is_persistent() for item in self.children) + + def refresh_component(self, component: ActionRowComponent) -> None: + self._underlying = component + for i, y in enumerate(component.components): + x = self.children[i] + x.refresh_component(y) + + def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: + """ + Disables all items in the row. + + Parameters + ---------- + exclusions: Optional[List[:class:`ViewItem`]] + A list of items in `self.children` to not disable. + """ + for item in self.walk_items(): + if exclusions is None or item not in exclusions: + item.disabled = True + return self + + def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: + """ + Enables all items in the row. + + Parameters + ---------- + exclusions: Optional[List[:class:`ViewItem`]] + A list of items in `self.children` to not enable. + """ + for item in self.walk_items(): + if exclusions is None or item not in exclusions: + item.disabled = False + return self + + @property + def width(self): + """Return the sum of the items' widths.""" + t = 0 + for item in self.children: + t += 1 if item._underlying.type is ComponentType.button else 5 + return t + + def walk_items(self) -> Iterator[ViewItem]: + yield from self.children + + def to_component_dict(self) -> ActionRowPayload: + self._set_components(self.children) + return super().to_component_dict() + + @classmethod + def from_component(cls: type[A], component: ActionRowComponent) -> A: + from .view import _component_to_item, _walk_all_components + + items = [ + _component_to_item(c) for c in _walk_all_components(component.components) + ] + return cls( + *items, + id=component.id, + ) + + callback = None diff --git a/discord/ui/button.py b/discord/ui/button.py index f08de8d193..922a00c514 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -32,7 +32,7 @@ from ..components import Button as ButtonComponent from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag -from .item import Item, ItemCallbackType +from .item import ItemCallbackType, ViewItem __all__ = ( "Button", @@ -41,13 +41,14 @@ if TYPE_CHECKING: from ..emoji import AppEmoji, GuildEmoji - from .view import View + from ..types.components import ButtonComponent as ButtonComponentPayload + from .view import BaseView B = TypeVar("B", bound="Button") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) -class Button(Item[V]): +class Button(ViewItem[V]): """Represents a UI button. .. versionadded:: 2.0 @@ -78,7 +79,7 @@ class Button(Item[V]): .. warning:: - This parameter does not work with V2 components or with more than 25 items in your view. + This parameter does not work in :class:`ActionRow`. id: Optional[:class:`int`] The button's ID. @@ -263,12 +264,8 @@ def from_component(cls: type[B], button: ButtonComponent) -> B: id=button.id, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - - def to_component_dict(self): - return self._underlying.to_dict() + def to_component_dict(self) -> ButtonComponentPayload: + return super().to_component_dict() def is_dispatchable(self) -> bool: return self.custom_id is not None @@ -298,7 +295,7 @@ def button( """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Button` being pressed and + the :class:`discord.ui.View`, :class:`discord.ui.ActionRow` or :class:`discord.ui.Section`, the :class:`discord.ui.Button` being pressed, and the :class:`discord.Interaction` you receive. .. note:: @@ -328,6 +325,10 @@ def button( like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. warning:: + + This parameter does not work in :class:`ActionRow`. """ def decorator(func: ItemCallbackType) -> ItemCallbackType: diff --git a/discord/ui/container.py b/discord/ui/container.py index e18b0ec0d2..3b852d0e56 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -1,7 +1,30 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations -from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, Iterator, TypeVar from ..colour import Colour from ..components import ActionRow @@ -9,10 +32,13 @@ from ..components import _component_factory from ..enums import ComponentType, SeparatorSpacingSize from ..utils import find, get +from .action_row import ActionRow +from .button import Button from .file import File -from .item import Item, ItemCallbackType +from .item import ItemCallbackType, ViewItem from .media_gallery import MediaGallery from .section import Section +from .select import Select from .separator import Separator from .text_display import TextDisplay from .view import _walk_all_components @@ -23,20 +49,19 @@ from typing_extensions import Self from ..types.components import ContainerComponent as ContainerComponentPayload - from .view import View + from .view import DesignerView C = TypeVar("C", bound="Container") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class Container(Item[V]): +class Container(ViewItem[V]): """Represents a UI Container. The current items supported are as follows: - - :class:`discord.ui.Button` - - :class:`discord.ui.Select` + - :class:`discord.ui.ActionRow` - :class:`discord.ui.Section` - :class:`discord.ui.TextDisplay` - :class:`discord.ui.MediaGallery` @@ -47,7 +72,7 @@ class Container(Item[V]): Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The initial items in this container. colour: Union[:class:`Colour`, :class:`int`] The accent colour of the container. Aliased to ``color`` as well. @@ -64,20 +89,17 @@ class Container(Item[V]): "id", ) - __container_children_items__: ClassVar[list[ItemCallbackType]] = [] - def __init_subclass__(cls) -> None: - children: list[ItemCallbackType] = [] for base in reversed(cls.__mro__): for member in base.__dict__.values(): if hasattr(member, "__discord_ui_model_type__"): - children.append(member) - - cls.__container_children_items__ = children + raise ValueError( + "The @button and @select decorators are incompatible with Container. Use ActionRow instead." + ) def __init__( self, - *items: Item, + *items: ViewItem, colour: int | Colour | None = None, color: int | Colour | None = None, spoiler: bool = False, @@ -85,7 +107,7 @@ def __init__( ): super().__init__() - self.items: list[Item] = [] + self.items: list[ViewItem] = [] self._underlying = ContainerComponent._raw_construct( type=ComponentType.container, @@ -95,56 +117,38 @@ def __init__( spoiler=spoiler, ) self.color = colour or color - - for func in self.__container_children_items__: - item: Item = func.__discord_ui_model_type__( - **func.__discord_ui_model_kwargs__ - ) - item.callback = partial(func, self, item) - self.add_item(item) - setattr(self, func.__name__, item) for i in items: self.add_item(i) - def _add_component_from_item(self, item: Item): - if item._underlying.is_v2(): - self._underlying.components.append(item._underlying) - else: - found = False - for row in reversed(self._underlying.components): - if ( - isinstance(row, ActionRow) and row.width + item.width <= 5 - ): # If a valid ActionRow exists - row.children.append(item._underlying) - found = True - elif not isinstance(row, ActionRow): - # create new row if last component is v2 - break - if not found: - row = ActionRow.with_components(item._underlying) - self._underlying.components.append(row) - - def _set_components(self, items: list[Item]): + def _add_component_from_item(self, item: ViewItem): + self._underlying.components.append(item._underlying) + + def _set_components(self, items: list[ViewItem]): self._underlying.components.clear() for item in items: self._add_component_from_item(item) - def add_item(self, item: Item) -> Self: + def add_item(self, item: ViewItem) -> Self: """Adds an item to the container. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the container. Raises ------ TypeError - An :class:`Item` was not passed. + A :class:`ViewItem` was not passed. """ - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") + + if isinstance(item, (Button, Select)): + raise TypeError( + f"{item.__class__!r} cannot be added directly. Use ActionRow instead." + ) item._view = self.view if hasattr(item, "items"): @@ -155,24 +159,27 @@ def add_item(self, item: Item) -> Self: self._add_component_from_item(item) return self - def remove_item(self, item: Item | str | int) -> Self: + def remove_item(self, item: ViewItem | str | int) -> Self: """Removes an item from the container. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively. Parameters ---------- - item: Union[:class:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, ``id``, or item ``custom_id`` to remove from the container. """ if isinstance(item, (str, int)): item = self.get_item(item) try: - self.items.remove(item) + if isinstance(item, Container): + self.items.remove(item) + else: + item.parent.remove_item(item) except ValueError: pass return self - def get_item(self, id: str | int) -> Item | None: + def get_item(self, id: str | int) -> ViewItem | None: """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search for nested items. @@ -184,7 +191,7 @@ def get_item(self, id: str | int) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ if not id: @@ -198,10 +205,31 @@ def get_item(self, id: str | int) -> Item | None: return child return child + def add_row( + self, + *items: ViewItem, + id: int | None = None, + ) -> Self: + """Adds an :class:`ActionRow` to the container. + + To append a pre-existing :class:`ActionRow`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: Union[:class:`Button`, :class:`Select`] + The items this action row contains. + id: Optiona[:class:`int`] + The action row's ID. + """ + + a = ActionRow(*items, id=id) + + return self.add_item(a) + def add_section( self, - *items: Item, - accessory: Item, + *items: ViewItem, + accessory: ViewItem, id: int | None = None, ) -> Self: """Adds a :class:`Section` to the container. @@ -211,10 +239,10 @@ def add_section( Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The items contained in this section, up to 3. Currently only supports :class:`~discord.ui.TextDisplay`. - accessory: Optional[:class:`Item`] + accessory: Optional[:class:`ViewItem`] The section's accessory. This is displayed in the top right of the section. Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. id: Optional[:class:`int`] @@ -242,7 +270,7 @@ def add_text(self, content: str, id: int | None = None) -> Self: def add_gallery( self, - *items: Item, + *items: ViewItem, id: int | None = None, ) -> Self: """Adds a :class:`MediaGallery` to the container. @@ -334,23 +362,15 @@ def colour(self, value: int | Colour | None): # type: ignore color = colour - @Item.view.setter + @ViewItem.view.setter def view(self, value): self._view = value for item in self.items: item.parent = self item._view = value - if hasattr(item, "items"): + if hasattr(item, "items") or hasattr(item, "children"): item.view = value - @property - def type(self) -> ComponentType: - return self._underlying.type - - @property - def width(self) -> int: - return 5 - def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.items) @@ -360,24 +380,18 @@ def is_persistent(self) -> bool: def refresh_component(self, component: ContainerComponent) -> None: self._underlying = component i = 0 - flattened = [] - for c in component.components: - if isinstance(c, ActionRow): - flattened += c.children - else: - flattened.append(c) - for y in flattened: + for y in component.components: x = self.items[i] x.refresh_component(y) i += 1 - def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Disables all buttons and select menus in the container. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not disable from the view. """ for item in self.walk_items(): @@ -387,13 +401,13 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = True return self - def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Enables all buttons and select menus in the container. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not enable from the view. """ for item in self.walk_items(): @@ -403,7 +417,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = False return self - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ViewItem]: for item in self.items: if hasattr(item, "walk_items"): yield from item.walk_items() @@ -412,7 +426,7 @@ def walk_items(self) -> Iterator[Item]: def to_component_dict(self) -> ContainerComponentPayload: self._set_components(self.items) - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[C], component: ContainerComponent) -> C: diff --git a/discord/ui/core.py b/discord/ui/core.py new file mode 100644 index 0000000000..b54cc2273c --- /dev/null +++ b/discord/ui/core.py @@ -0,0 +1,153 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import asyncio +import time +from itertools import groupby +from typing import TYPE_CHECKING, Any, Callable + +from ..utils import find, get +from .item import Item, ItemCallbackType + +__all__ = ("ItemInterface",) + + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import View + + +class ItemInterface: + """The base structure for classes that contain :class:`~discord.ui.Item`. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items contained in this structure. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. + If ``None`` then there is no timeout. + + Attributes + ---------- + timeout: Optional[:class:`float`] + Timeout from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + children: List[:class:`Item`] + The list of children attached to this structure. + """ + + def __init__( + self, + *items: Item, + timeout: float | None = 180.0, + ): + self.timeout: float | None = timeout + self.children: list[Item] = [] + for item in items: + self.add_item(item) + + loop = asyncio.get_running_loop() + self._cancel_callback: Callable[[View], None] | None = None + self._timeout_expiry: float | None = None + self._timeout_task: asyncio.Task[None] | None = None + self._stopped: asyncio.Future[bool] = loop.create_future() + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" + + async def _timeout_task_impl(self) -> None: + while True: + # Guard just in case someone changes the value of the timeout at runtime + if self.timeout is None: + return + + if self._timeout_expiry is None: + return self._dispatch_timeout() + + # Check if we've elapsed our currently set timeout + now = time.monotonic() + if now >= self._timeout_expiry: + return self._dispatch_timeout() + + # Wait N seconds to see if timeout data has been refreshed + await asyncio.sleep(self._timeout_expiry - now) + + @property + def _expires_at(self) -> float | None: + if self.timeout: + return time.monotonic() + self.timeout + return None + + def _dispatch_timeout(self): + raise NotImplementedError + + def to_components(self) -> list[dict[str, Any]]: + return [item.to_component_dict() for item in self.children] + + def get_item(self, custom_id: str | int) -> Item | None: + """Gets an item from this structure. Roughly equal to `utils.get(self.children, ...)`. + If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + This method will also search nested items. + + Parameters + ---------- + custom_id: Union[:class:`str`, :class:`int`] + The id of the item to get + + Returns + ------- + Optional[:class:`Item`] + The item with the matching ``custom_id`` or ``id`` if it exists. + """ + if not custom_id: + return None + attr = "id" if isinstance(custom_id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) + if not child: + for i in self.children: + if hasattr(i, "get_item"): + if child := i.get_item(custom_id): + return child + return child + + def add_item(self, item: Item) -> Self: + raise NotImplementedError + + def remove_item(self, item: Item) -> Self: + raise NotImplementedError + + def clear_items(self) -> None: + raise NotImplementedError + + async def on_timeout(self) -> None: + """|coro| + + A callback that is called when this structure's timeout elapses without being explicitly stopped. + """ diff --git a/discord/ui/file.py b/discord/ui/file.py index dc06b83648..32e1ac2f45 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from typing import TYPE_CHECKING, TypeVar @@ -5,20 +29,20 @@ from ..components import FileComponent, UnfurledMediaItem, _component_factory from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("File",) if TYPE_CHECKING: from ..types.components import FileComponent as FileComponentPayload - from .view import View + from .view import DesignerView F = TypeVar("F", bound="File") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class File(Item[V]): +class File(ViewItem[V]): """Represents a UI File. .. note:: @@ -54,14 +78,6 @@ def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): spoiler=spoiler, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - - @property - def width(self) -> int: - return 5 - @property def url(self) -> str: """The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" @@ -96,7 +112,7 @@ def refresh_component(self, component: FileComponent) -> None: self._underlying = component def to_component_dict(self) -> FileComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[F], component: FileComponent) -> F: diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 4f7828ce97..80845c5728 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import os @@ -5,6 +29,7 @@ from ..components import InputText as InputTextComponent from ..enums import ComponentType, InputTextStyle +from .item import ModalItem __all__ = ("InputText", "TextInput") @@ -13,7 +38,7 @@ from ..types.components import InputText as InputTextComponentPayload -class InputText: +class InputText(ModalItem): """Represents a UI text input field. .. versionadded:: 2.0 @@ -27,11 +52,6 @@ class InputText: label: :class:`str` The label for the input text field. Must be 45 characters or fewer. - description: Optional[:class:`str`] - The description for the input text field. - Must be 100 characters or fewer. - - .. versionadded:: 2.7 placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. Must be 100 characters or fewer. @@ -64,7 +84,6 @@ class InputText: "max_length", "custom_id", "id", - "description", ) def __init__( @@ -72,7 +91,7 @@ def __init__( *, style: InputTextStyle = InputTextStyle.short, custom_id: str | None = None, - label: str, + label: str | None = None, placeholder: str | None = None, min_length: int | None = None, max_length: int | None = None, @@ -80,13 +99,10 @@ def __init__( value: str | None = None, row: int | None = None, id: int | None = None, - description: str | None = None, ): super().__init__() - if len(str(label)) > 45: + if label and len(str(label)) > 45: raise ValueError("label must be 45 characters or fewer") - if description and len(description) > 100: - raise ValueError("description must be 100 characters or fewer") if min_length and (min_length < 0 or min_length > 4000): raise ValueError("min_length must be between 0 and 4000") if max_length and (max_length < 0 or max_length > 4000): @@ -100,7 +116,6 @@ def __init__( f"expected custom_id to be str, not {custom_id.__class__.__name__}" ) custom_id = os.urandom(16).hex() if custom_id is None else custom_id - self.description: str | None = description self._underlying = InputTextComponent._raw_construct( type=ComponentType.input_text, @@ -124,20 +139,11 @@ def __repr__(self) -> str: ) return f"<{self.__class__.__name__} {attrs}>" - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def style(self) -> InputTextStyle: """The style of the input text field.""" return self._underlying.style - @property - def id(self) -> int | None: - """The input text's ID. If not provided by the user, it is set sequentially by Discord.""" - return self._underlying.id - @style.setter def style(self, value: InputTextStyle): if not isinstance(value, InputTextStyle): @@ -243,16 +249,15 @@ def width(self) -> int: return 5 def to_component_dict(self) -> InputTextComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() def refresh_state(self, data) -> None: self._input_value = data["value"] - def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: + def refresh_from_modal( + self, interaction: Interaction, data: InputTextComponentPayload + ) -> None: return self.refresh_state(data) - def uses_label(self) -> bool: - return self.description is not None - TextInput = InputText diff --git a/discord/ui/item.py b/discord/ui/item.py index e2a568e345..bd76ad1b89 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -29,60 +29,46 @@ from ..interactions import Interaction -__all__ = ("Item",) +__all__ = ( + "Item", + "ViewItem", + "ModalItem", +) if TYPE_CHECKING: from ..components import Component from ..enums import ComponentType - from .view import View + from .core import ItemInterface + from .modal import BaseModal + from .view import BaseView I = TypeVar("I", bound="Item") -V = TypeVar("V", bound="View", covariant=True) +T = TypeVar("T", bound="ItemInterface", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) +M = TypeVar("M", bound="BaseModal", covariant=True) ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]] -class Item(Generic[V]): +class Item(Generic[T]): """Represents the base UI item that all UI components inherit from. - The following are the original items: - - - :class:`discord.ui.Button` - - :class:`discord.ui.Select` - - And the following are new items under the "Components V2" specification: - - - :class:`discord.ui.Section` - - :class:`discord.ui.TextDisplay` - - :class:`discord.ui.Thumbnail` - - :class:`discord.ui.MediaGallery` - - :class:`discord.ui.File` - - :class:`discord.ui.Separator` - - :class:`discord.ui.Container` - .. versionadded:: 2.0 .. versionchanged:: 2.7 - Added V2 Components. + Now used as base class for :class:`ViewItem` and :class:`ModalItem`. """ - __item_repr_attributes__: tuple[str, ...] = ("row",) + __item_repr_attributes__: tuple[str, ...] = ("id",) def __init__(self): - self._view: V | None = None - self._row: int | None = None - self._rendered_row: int | None = None self._underlying: Component | None = None - # This works mostly well but there is a gotcha with - # the interaction with from_component, since that technically provides - # a custom_id most dispatchable items would get this set to True even though - # it might not be provided by the library user. However, this edge case doesn't - # actually affect the intended purpose of this check because from_component is - # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False - self.parent: Item | View | None = self.view + self.parent: Item | ItemInterface | None = None def to_component_dict(self) -> dict[str, Any]: - raise NotImplementedError + if not self._underlying: + raise NotImplementedError + return self._underlying.to_dict() def refresh_component(self, component: Component) -> None: self._underlying = component @@ -90,16 +76,15 @@ def refresh_component(self, component: Component) -> None: def refresh_state(self, interaction: Interaction) -> None: return None - def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: - return None - @classmethod def from_component(cls: type[I], component: Component) -> I: return cls() @property def type(self) -> ComponentType: - raise NotImplementedError + if not self._underlying: + raise NotImplementedError + return self._underlying.type def is_dispatchable(self) -> bool: return False @@ -110,9 +95,6 @@ def is_storable(self) -> bool: def is_persistent(self) -> bool: return not self.is_dispatchable() or self._provided_custom_id - def uses_label(self) -> bool: - return False - def copy_text(self) -> str: return "" @@ -122,6 +104,56 @@ def __repr__(self) -> str: ) return f"<{self.__class__.__name__} {attrs}>" + @property + def id(self) -> int | None: + """Gets this item's ID. + + This can be set by the user when constructing an Item. If not, Discord will automatically provide one when the item's parent is sent. + + Returns + ------- + Optional[:class:`int`] + The ID of this item, or ``None`` if the user didn't set one. + """ + return self._underlying and self._underlying.id + + @id.setter + def id(self, value) -> None: + if not self._underlying: + return + self._underlying.id = value + + +class ViewItem(Item[V]): + """Represents an item used in Views. + + The following are the original items supported in :class:`discord.ui.View`: + + - :class:`discord.ui.Button` + - :class:`discord.ui.Select` + + And the following are new items under the "Components V2" specification for use in :class:`discord.ui.DesignerView`: + + - :class:`discord.ui.Section` + - :class:`discord.ui.TextDisplay` + - :class:`discord.ui.Thumbnail` + - :class:`discord.ui.MediaGallery` + - :class:`discord.ui.File` + - :class:`discord.ui.Separator` + - :class:`discord.ui.Container` + + Additionally, :class:`discord.ui.ActionRow` should be used in :class:`discord.ui.DesignerView` to support :class:`discord.ui.Button` and :class:`discord.ui.Select`. + + .. versionadded:: 2.7 + """ + + def __init__(self): + super().__init__() + self._view: V | None = None + self._row: int | None = None + self._rendered_row: int | None = None + self.parent: ViewItem | BaseView | None = self.view + @property def row(self) -> int | None: """Gets or sets the row position of this item within its parent view. @@ -164,39 +196,24 @@ def width(self) -> int: """ return 1 - @property - def id(self) -> int | None: - """Gets this item's ID. - - This can be set by the user when constructing an Item. If not, Discord will automatically provide one when the View is sent. - - Returns - ------- - Optional[:class:`int`] - The ID of this item, or ``None`` if the user didn't set one. - """ - return self._underlying and self._underlying.id - - @id.setter - def id(self, value) -> None: - if not self._underlying: - return - self._underlying.id = value - @property def view(self) -> V | None: """Gets the parent view associated with this item. - The view refers to the container that holds this item. This is typically set + The view refers to the structure that holds this item. This is typically set automatically when the item is added to a view. Returns ------- - Optional[:class:`View`] + Optional[:class:`BaseView`] The parent view of this item, or ``None`` if the item is not attached to any view. """ return self._view + @view.setter + def view(self, value) -> None: + self._view = value + async def callback(self, interaction: Interaction): """|coro| @@ -209,3 +226,45 @@ async def callback(self, interaction: Interaction): interaction: :class:`.Interaction` The interaction that triggered this UI item. """ + + +class ModalItem(Item[M]): + """Represents an item used in Modals. + + :class:`discord.ui.InputText` is the original item supported in :class:`discord.ui.Modal`. + + The following are newly available in :class:`discord.ui.DesignerModal`: + + - :class:`discord.ui.Label` + - :class:`discord.ui.TextDisplay` + + And :class:`discord.ui.Label` should be used in :class:`discord.ui.DesignerModal` to support the following items: + - :class:`discord.ui.InputText` + - :class:`discord.ui.Select` + + .. versionadded:: 2.7 + """ + + def __init__(self): + super().__init__() + self._modal: M | None = None + self.parent: ModalItem | BaseModal | None = self.modal + + def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: + return None + + @property + def modal(self) -> M | None: + """Gets the parent modal associated with this item. This is typically set + automatically when the item is added to a modal. + + Returns + ------- + Optional[:class:`BaseModal`] + The parent modal of this item, or ``None`` if the item is not attached to any modal. + """ + return self._modal + + @modal.setter + def modal(self, value) -> None: + self._modal = value diff --git a/discord/ui/label.py b/discord/ui/label.py new file mode 100644 index 0000000000..aff100862f --- /dev/null +++ b/discord/ui/label.py @@ -0,0 +1,381 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Iterator, Literal, TypeVar, overload + +from ..components import Label as LabelComponent +from ..components import SelectDefaultValue, SelectOption, _component_factory +from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle +from ..utils import find, get +from .button import Button +from .input_text import InputText +from .item import ItemCallbackType, ModalItem +from .select import Select + +__all__ = ("Label",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..emoji import AppEmoji, GuildEmoji + from ..interaction import Interaction + from ..partial_emoji import PartialEmoji, _EmojiTag + from ..types.components import LabelComponent as LabelComponentPayload + from .modal import DesignerModal + + +L = TypeVar("L", bound="Label") +M = TypeVar("M", bound="DesignerModal", covariant=True) + + +class Label(ModalItem[M]): + """Represents a UI Label used in :class:`discord.ui.DesignerModal`. + + The items currently supported are as follows: + + - :class:`discord.ui.Select` + - :class:`discord.ui.InputText` + + .. versionadded:: 2.7 + + Parameters + ---------- + item: :class:`ModalItem` + The initial item attached to this label. + label: :class:`str` + The label text. Must be 45 characters or fewer. + description: Optional[:class:`str`] + The description for this label. Must be 100 characters or fewer. + id: Optional[:class:`int`] + The label's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "item", + "id", + "label", + "description", + ) + + def __init__( + self, + label: str, + item: ModalItem = None, + *, + description: str | None = None, + id: int | None = None, + ): + super().__init__() + + self.item: ModalItem = None + + self._underlying = LabelComponent._raw_construct( + type=ComponentType.label, + id=id, + component=None, + label=label, + description=description, + ) + + if item: + self.set_item(item) + + @ModalItem.modal.setter + def modal(self, value): + self._modal = value + if self.item: + self.item.modal = value + + def _set_component_from_item(self, item: ModalItem): + self._underlying.component = item._underlying + + def set_item(self, item: ModalItem) -> Self: + """Set this label's item. + + Parameters + ---------- + item: Union[:class:`ModalItem`, :class:`InputText`] + The item to set. + Currently only supports :class:`~discord.ui.Select` and :class:`~discord.ui.InputText`. + + Raises + ------ + TypeError + A :class:`ModalItem` was not passed. + """ + + if not isinstance(item, ModalItem): + raise TypeError(f"expected ModalItem not {item.__class__!r}") + if isinstance(item, InputText) and item.label: + raise ValueError(f"InputText.label cannot be set inside Label") + if self.modal: + item.modal = self.modal + item.parent = self + + self.item = item + self._set_component_from_item(item) + return self + + def get_item(self, id: str | int) -> ModalItem | None: + """Get the item from this label if it matches the provided id. + If an ``int`` is provided, the item will match by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + id: Union[:class:`str`, :class:`int`] + The id or custom_id of the item to match. + + Returns + ------- + Optional[:class:`ModalItem`] + The item if its ``id`` or ``custom_id`` matches. + """ + if not id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + if getattr(self.item, attr, None) != id: + return None + return self.item + + def set_input_text( + self, + *, + style: InputTextStyle = InputTextStyle.short, + custom_id: str | None = None, + placeholder: str | None = None, + min_length: int | None = None, + max_length: int | None = None, + required: bool | None = True, + value: str | None = None, + id: int | None = None, + ) -> Self: + """Set this label's item to an input text. + + To set a pre-existing :class:`InputText`, use the + :meth:`set_item` method, instead. + + Parameters + ---------- + style: :class:`~discord.InputTextStyle` + The style of the input text field. + custom_id: Optional[:class:`str`] + The ID of the input text field that gets received during an interaction. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + Must be 100 characters or fewer. + min_length: Optional[:class:`int`] + The minimum number of characters that must be entered. + Defaults to 0 and must be less than 4000. + max_length: Optional[:class:`int`] + The maximum number of characters that can be entered. + Must be between 1 and 4000. + required: Optional[:class:`bool`] + Whether the input text field is required or not. Defaults to ``True``. + value: Optional[:class:`str`] + Pre-fills the input text field with this value. + Must be 4000 characters or fewer. + id: Optional[:class:`int`] + The button's ID. + """ + + text = InputText( + style=style, + custom_id=custom_id, + placeholder=placeholder, + min_length=min_length, + max_length=max_length, + required=required, + value=value, + id=id, + ) + + return self.set_item(text) + + @overload + def set_select( + self, + select_type: Literal[ComponentType.string_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + options: list[SelectOption] | None = ..., + required: bool = ..., + id: int | None = ..., + ) -> None: ... + + @overload + def set_select( + self, + select_type: Literal[ComponentType.channel_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + channel_types: list[ChannelType] | None = ..., + required: bool = ..., + id: int | None = ..., + default_values: Sequence[SelectDefaultValue] | None = ..., + ) -> None: ... + + @overload + def set_select( + self, + select_type: Literal[ + ComponentType.user_select, + ComponentType.role_select, + ComponentType.mentionable_select, + ] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + required: bool = ..., + id: int | None = ..., + default_values: Sequence[SelectDefaultValue] | None = ..., + ) -> None: ... + + def set_select( + self, + select_type: ComponentType = ComponentType.string_select, + *, + custom_id: str | None = None, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + options: list[SelectOption] | None = None, + channel_types: list[ChannelType] | None = None, + required: bool = True, + id: int | None = None, + default_values: Sequence[SelectDefaultValue] | None = ..., + ) -> Self: + """Set this label's item to a select menu. + + Parameters + ---------- + select_type: :class:`discord.ComponentType` + The type of select to create. Must be one of + :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, + :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, + or :attr:`discord.ComponentType.channel_select`. + custom_id: :class:`str` + The custom ID of the select menu that gets received during an interaction. + If not given then one is generated for you. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + min_values: :class:`int` + The minimum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + max_values: :class:`int` + The maximum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + options: List[:class:`discord.SelectOption`] + A list of options that can be selected in this menu. + Only valid for selects of type :attr:`discord.ComponentType.string_select`. + channel_types: List[:class:`discord.ChannelType`] + A list of channel types that can be selected in this menu. + Only valid for selects of type :attr:`discord.ComponentType.channel_select`. + required: :class:`bool` + Whether the select is required or not. Defaults to ``True``. + id: Optional[:class:`int`] + The select menu's ID. + default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] + The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`. + + These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue` + instances. + """ + + select = Select( + select_type=select_type, + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + options=options or [], + channel_types=channel_types or [], + required=required, + id=id, + default_values=default_values, + ) + + return self.set_item(select) + + @property + def label(self) -> str: + """The label text. Must be 45 characters or fewer.""" + return self._underlying.label + + @label.setter + def label(self, value: str) -> None: + self._underlying.label = value + + @property + def description(self) -> str | None: + """The description for this label. Must be 100 characters or fewer.""" + return self._underlying.description + + @description.setter + def description(self, value: str | None) -> None: + self._underlying.description = value + + def is_dispatchable(self) -> bool: + return self.item.is_dispatchable() + + def is_persistent(self) -> bool: + return self.item.is_persistent() + + def refresh_component(self, component: LabelComponent) -> None: + self._underlying = component + self.item.refresh_component(component.component) + + def walk_items(self) -> Iterator[ModalItem]: + yield from [self.item] + + def to_component_dict(self) -> LabelComponentPayload: + self._set_component_from_item(self.item) + return super().to_component_dict() + + def refresh_from_modal( + self, interaction: Interaction, data: LabelComponentPayload + ) -> None: + return self.item.refresh_from_modal(interaction, data.get("component", {})) + + @classmethod + def from_component(cls: type[L], component: LabelComponent) -> L: + from .view import _component_to_item + + item = _component_to_item(component.component) + return cls( + item, + id=component.id, + label=component.label, + description=component.description, + ) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b50daef71c..34a4282a37 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from typing import TYPE_CHECKING, TypeVar @@ -5,7 +29,7 @@ from ..components import MediaGallery as MediaGalleryComponent from ..components import MediaGalleryItem from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("MediaGallery",) @@ -13,14 +37,14 @@ from typing_extensions import Self from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload - from .view import View + from .view import DesignerView M = TypeVar("M", bound="MediaGallery") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class MediaGallery(Item[V]): +class MediaGallery(ViewItem[V]): """Represents a UI Media Gallery. Galleries may contain up to 10 :class:`MediaGalleryItem` objects. .. versionadded:: 2.7 @@ -105,20 +129,8 @@ def add_item( return self.append_item(item) - @Item.view.setter - def view(self, value): - self._view = value - - @property - def type(self) -> ComponentType: - return self._underlying.type - - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> MediaGalleryComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[M], component: MediaGalleryComponent) -> M: diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 32bf5853bc..b3b4527bf0 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import asyncio @@ -6,17 +30,21 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from ..enums import ComponentType from ..utils import find +from .core import ItemInterface from .input_text import InputText -from .item import Item +from .item import ModalItem +from .label import Label from .select import Select from .text_display import TextDisplay __all__ = ( + "BaseModal", "Modal", + "DesignerModal", "ModalStore", ) @@ -26,32 +54,27 @@ from ..interactions import Interaction from ..state import ConnectionState + from ..types.components import Component as ComponentPayload M = TypeVar("M", bound="Modal", covariant=True) -ModalItem = Union[InputText, Item[M]] - -class Modal: - """Represents a UI Modal dialog. +class BaseModal(ItemInterface): + """Represents a UI modal. This object must be inherited to create a UI within Discord. - .. versionadded:: 2.0 - - .. versionchanged:: 2.7 - - :class:`discord.ui.Select` and :class:`discord.ui.TextDisplay` can now be used in modals. + .. versionadded:: 2.7 Parameters ---------- - children: Union[:class:`InputText`, :class:`Item`] - The initial items that are displayed in the modal dialog. Currently supports :class:`discord.ui.Select` and :class:`discord.ui.TextDisplay`. + children: :class:`ModalItem` + The initial items that are displayed in the modal. title: :class:`str` - The title of the modal dialog. + The title of the modal. Must be 45 characters or fewer. custom_id: Optional[:class:`str`] - The ID of the modal dialog that gets received during an interaction. + The ID of the modal that gets received during an interaction. Must be 100 characters or fewer. timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. @@ -71,7 +94,6 @@ def __init__( custom_id: str | None = None, timeout: float | None = None, ) -> None: - self.timeout: float | None = timeout if not isinstance(custom_id, str) and custom_id is not None: raise TypeError( f"expected custom_id to be str, not {custom_id.__class__.__name__}" @@ -79,14 +101,11 @@ def __init__( self._custom_id: str | None = custom_id or os.urandom(16).hex() if len(title) > 45: raise ValueError("title must be 45 characters or fewer") + self._children: list[ModalItem] = [] + super().__init__(timeout=timeout) + for item in children: + self.add_item(item) self._title = title - self._children: list[ModalItem] = list(children) - self._weights = _ModalWeights(self._children) - loop = asyncio.get_running_loop() - self._stopped: asyncio.Future[bool] = loop.create_future() - self.__cancel_callback: Callable[[Modal], None] | None = None - self.__timeout_expiry: float | None = None - self.__timeout_task: asyncio.Task[None] | None = None self.loop = asyncio.get_event_loop() def __repr__(self) -> str: @@ -96,37 +115,14 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {attrs}>" def _start_listening_from_store(self, store: ModalStore) -> None: - self.__cancel_callback = partial(store.remove_modal) + self._cancel_callback = partial(store.remove_modal) if self.timeout: loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() - - self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) - - async def __timeout_task_impl(self) -> None: - while True: - # Guard just in case someone changes the value of the timeout at runtime - if self.timeout is None: - return - - if self.__timeout_expiry is None: - return self._dispatch_timeout() + if self._timeout_task is not None: + self._timeout_task.cancel() - # Check if we've elapsed our currently set timeout - now = time.monotonic() - if now >= self.__timeout_expiry: - return self._dispatch_timeout() - - # Wait N seconds to see if timeout data has been refreshed - await asyncio.sleep(self.__timeout_expiry - now) - - @property - def _expires_at(self) -> float | None: - if self.timeout: - return time.monotonic() + self.timeout - return None + self._timeout_expiry = time.monotonic() + self.timeout + self._timeout_task = loop.create_task(self._timeout_task_impl()) def _dispatch_timeout(self): if self._stopped.done(): @@ -139,7 +135,7 @@ def _dispatch_timeout(self): @property def title(self) -> str: - """The title of the modal dialog.""" + """The title of the modal.""" return self._title @title.setter @@ -152,23 +148,22 @@ def title(self, value: str): @property def children(self) -> list[ModalItem]: - """The child components associated with the modal dialog.""" + """The child items attached to the modal.""" return self._children @children.setter def children(self, value: list[ModalItem]): for item in value: - if not isinstance(item, (InputText, Item)): + if not isinstance(item, ModalItem): raise TypeError( - "all Modal children must be InputText or Item, not" + "all BaseModal children must be ModalItem, not" f" {item.__class__.__name__}" ) - self._weights = _ModalWeights(self._children) self._children = value @property def custom_id(self) -> str: - """The ID of the modal dialog that gets received during an interaction.""" + """The ID of the modal that gets received during an interaction.""" return self._custom_id @custom_id.setter @@ -184,87 +179,41 @@ def custom_id(self, value: str): async def callback(self, interaction: Interaction): """|coro| - The coroutine that is called when the modal dialog is submitted. + The coroutine that is called when the modal is submitted. Should be overridden to handle the values submitted by the user. Parameters ---------- interaction: :class:`~discord.Interaction` - The interaction that submitted the modal dialog. + The interaction that submitted the modal. """ self.stop() - def to_components(self) -> list[dict[str, Any]]: - def key(item: ModalItem) -> int: - return item._rendered_row or 0 - - children = sorted(self._children, key=key) - components: list[dict[str, Any]] = [] - for _, group in groupby(children, key=key): - labels = False - toplevel = False - children = [] - for item in group: - if item.uses_label() or isinstance(item, Select): - labels = True - elif isinstance(item, (TextDisplay,)): - toplevel = True - children.append(item) - if not children: - continue - - if labels: - for item in children: - component = item.to_component_dict() - label = component.pop("label", item.label) - components.append( - { - "type": 18, - "component": component, - "label": label, - "description": item.description, - } - ) - elif toplevel: - components += [item.to_component_dict() for item in children] - else: - components.append( - { - "type": 1, - "components": [item.to_component_dict() for item in children], - } - ) - - return components - def add_item(self, item: ModalItem) -> Self: - """Adds a component to the modal dialog. + """Adds a component to the modal. Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] - The item to add to the modal dialog + item: Union[class:`InputText`, :class:`ModalItem`] + The item to add to the modal """ if len(self._children) > 5: - raise ValueError("You can only have up to 5 items in a modal dialog.") + raise ValueError("You can only have up to 5 items in a modal.") - if not isinstance(item, (InputText, Item)): - raise TypeError(f"expected InputText or Item, not {item.__class__!r}") - if isinstance(item, (InputText, Select)) and not item.label: - raise ValueError("InputTexts and Selects must have a label set") + if not isinstance(item, ModalItem): + raise TypeError(f"expected ModalItem, not {item.__class__!r}") - self._weights.add_item(item) self._children.append(item) return self def remove_item(self, item: ModalItem) -> Self: - """Removes a component from the modal dialog. + """Removes a component from the modal. Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] - The item to remove from the modal dialog. + item: :class:`ModalItem` + The item to remove from the modal. """ try: self._children.remove(item) @@ -272,36 +221,17 @@ def remove_item(self, item: ModalItem) -> Self: pass return self - def get_item(self, id: str | int) -> ModalItem | None: - """Gets an item from the modal. Roughly equal to `utils.get(modal.children, ...)`. - If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. - - Parameters - ---------- - id: Union[:class:`int`, :class:`str`] - The id or custom_id of the item to get - - Returns - ------- - Optional[Union[class:`InputText`, :class:`Item`]] - The item with the matching ``custom_id`` or ``id`` if it exists. - """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - return find(lambda i: getattr(i, attr, None) == id, self.children) - def stop(self) -> None: - """Stops listening to interaction events from the modal dialog.""" + """Stops listening to interaction events from the modal.""" if not self._stopped.done(): self._stopped.set_result(True) - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None + self._timeout_expiry = None + if self._timeout_task is not None: + self._timeout_task.cancel() + self._timeout_task = None async def wait(self) -> bool: - """Waits for the modal dialog to be submitted.""" + """Waits for the modal to be submitted.""" return await self._stopped def to_dict(self): @@ -322,6 +252,8 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: ---------- error: :class:`Exception` The exception that was raised. + modal: :class:`BaseModal` + The modal that failed the dispatch. interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ @@ -334,10 +266,197 @@ async def on_timeout(self) -> None: """ +class Modal(BaseModal): + """Represents a UI modal for InputText components. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + .. versionchanged:: 2.7 + + Now inherits from :class:`BaseModal` + + Parameters + ---------- + children: Union[:class:`InputText`] + The initial items that are displayed in the modal. Only supports :class:`discord.ui.InputText`; for newer modal features, see :class:`DesignerModal`. + title: :class:`str` + The title of the modal. + Must be 45 characters or fewer. + custom_id: Optional[:class:`str`] + The ID of the modal that gets received during an interaction. + Must be 100 characters or fewer. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + def __init__( + self, + *children: InputText, + title: str, + custom_id: str | None = None, + timeout: float | None = None, + ) -> None: + super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) + self._weights = _ModalWeights(self._children) + + @property + def children(self) -> list[InputText]: + return self._children + + @children.setter + def children(self, value: list[InputText]): + for item in value: + if not isinstance(item, InputText): + raise TypeError( + "all Modal children must be InputText, not" + f" {item.__class__.__name__}" + ) + self._weights = _ModalWeights(self._children) + self._children = value + + def to_components(self) -> list[dict[str, Any]]: + def key(item: InputText) -> int: + return item._rendered_row or 0 + + children = sorted(self._children, key=key) + components: list[dict[str, Any]] = [] + for _, group in groupby(children, key=key): + children = [item.to_component_dict() for item in group] + if not children: + continue + + components.append( + { + "type": 1, + "components": children, + } + ) + + return components + + def add_item(self, item: InputText) -> Self: + """Adds an InputText component to the modal. + + Parameters + ---------- + item: :class:`InputText` + The item to add to the modal + """ + + if not isinstance(item, InputText): + raise TypeError(f"expected InputText not {item.__class__!r}") + + self._weights.add_item(item) + super().add_item(item) + return self + + def remove_item(self, item: InputText) -> Self: + """Removes an InputText from the modal. + + Parameters + ---------- + item: Union[class:`InputText`] + The item to remove from the modal. + """ + + super().remove_item(item) + try: + self.__weights.remove_item(item) + except ValueError: + pass + return self + + def refresh(self, interaction: Interaction, data: list[ComponentPayload]): + components = [ + component + for parent_component in data + for component in parent_component["components"] + ] + for component in components: + for child in self.children: + if child.custom_id == component["custom_id"]: # type: ignore + child.refresh_from_modal(interaction, component) + break + + +class DesignerModal(BaseModal): + """Represents a UI modal compatible with all modal features. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.7 + + Parameters + ---------- + children: Union[:class:`ModalItem`] + The initial items that are displayed in the modal.. + title: :class:`str` + The title of the modal. + Must be 45 characters or fewer. + custom_id: Optional[:class:`str`] + The ID of the modal that gets received during an interaction. + Must be 100 characters or fewer. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + def __init__( + self, + *children: ModalItem, + title: str, + custom_id: str | None = None, + timeout: float | None = None, + ) -> None: + super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) + + @property + def children(self) -> list[ModalItem]: + return self._children + + @children.setter + def children(self, value: list[ModalItem]): + for item in value: + if not isinstance(item, ModalItem): + raise TypeError( + "all DesignerModal children must be ModalItem, not" + f" {item.__class__.__name__}" + ) + if isinstance(item, (InputText,)): + raise TypeError( + f"DesignerModal does not accept InputText directly. Use Label instead." + ) + self._children = value + + def add_item(self, item: ModalItem) -> Self: + """Adds a component to the modal. + + Parameters + ---------- + item: Union[:class:`ModalItem`] + The item to add to the modal + """ + + if isinstance(item, (InputText,)): + raise TypeError( + f"DesignerModal does not accept InputText directly. Use Label instead." + ) + + super().add_item(item) + return self + + def refresh(self, interaction: Interaction, data: list[ComponentPayload]): + for component, child in zip(data, self.children): + child.refresh_from_modal(interaction, component) + + class _ModalWeights: __slots__ = ("weights",) - def __init__(self, children: list[ModalItem]): + def __init__(self, children: list[InputText]): self.weights: list[int] = [0, 0, 0, 0, 0] key = lambda i: sys.maxsize if i.row is None else i.row @@ -346,14 +465,14 @@ def __init__(self, children: list[ModalItem]): for item in group: self.add_item(item) - def find_open_space(self, item: ModalItem) -> int: + def find_open_space(self, item: InputText) -> int: for index, weight in enumerate(self.weights): if weight + item.width <= 5: return index raise ValueError("could not find open space for item") - def add_item(self, item: ModalItem) -> None: + def add_item(self, item: InputText) -> None: if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -367,7 +486,7 @@ def add_item(self, item: ModalItem) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: ModalItem) -> None: + def remove_item(self, item: InputText) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -379,40 +498,28 @@ def clear(self) -> None: class ModalStore: def __init__(self, state: ConnectionState) -> None: # (user_id, custom_id) : Modal - self._modals: dict[tuple[int, str], Modal] = {} + self._modals: dict[tuple[int, str], BaseModal] = {} self._state: ConnectionState = state - def add_modal(self, modal: Modal, user_id: int): + def add_modal(self, modal: BaseModal, user_id: int): self._modals[(user_id, modal.custom_id)] = modal modal._start_listening_from_store(self) - def remove_modal(self, modal: Modal, user_id): + def remove_modal(self, modal: BaseModal, user_id): modal.stop() self._modals.pop((user_id, modal.custom_id)) async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction): key = (user_id, custom_id) - value = self._modals.get(key) - if value is None: + modal = self._modals.get(key) + if modal is None: return - interaction.modal = value + interaction.modal = modal try: - components = [ - component - for parent_component in interaction.data["components"] - for component in ( - parent_component.get("components") - or ( - [parent_component.get("component")] - if parent_component.get("component") - else [parent_component] - ) - ) - ] - for component, child in zip(components, value.children): - child.refresh_from_modal(interaction, component) - await value.callback(interaction) - self.remove_modal(value, user_id) + components = interaction.data["components"] + modal.refresh(interaction, components) + await modal.callback(interaction) + self.remove_modal(modal, user_id) except Exception as e: - return await value.on_error(e, interaction) + return await modal.on_error(e, interaction) diff --git a/discord/ui/section.py b/discord/ui/section.py index 922f4819ad..79d092595c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from functools import partial @@ -8,7 +32,7 @@ from ..enums import ComponentType from ..utils import find, get from .button import Button -from .item import Item, ItemCallbackType +from .item import ItemCallbackType, ViewItem from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -18,25 +42,25 @@ from typing_extensions import Self from ..types.components import SectionComponent as SectionComponentPayload - from .view import View + from .view import DesignerView S = TypeVar("S", bound="Section") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class Section(Item[V]): +class Section(ViewItem[V]): """Represents a UI section. Sections must have 1-3 (inclusive) items and an accessory set. .. versionadded:: 2.7 Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The initial items contained in this section, up to 3. Currently only supports :class:`~discord.ui.TextDisplay`. Sections must have at least 1 item before being sent. - accessory: Optional[:class:`Item`] + accessory: Optional[:class:`ViewItem`] The section's accessory. This is displayed in the top right of the section. Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. Sections must have an accessory attached before being sent. @@ -61,11 +85,13 @@ def __init_subclass__(cls) -> None: cls.__section_accessory_item__ = accessory - def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): + def __init__( + self, *items: ViewItem, accessory: ViewItem = None, id: int | None = None + ): super().__init__() - self.items: list[Item] = [] - self.accessory: Item | None = None + self.items: list[ViewItem] = [] + self.accessory: ViewItem | None = None self._underlying = SectionComponent._raw_construct( type=ComponentType.section, @@ -74,7 +100,7 @@ def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): accessory=None, ) for func in self.__section_accessory_item__: - item: Item = func.__discord_ui_model_type__( + item: ViewItem = func.__discord_ui_model_type__( **func.__discord_ui_model_kwargs__ ) item.callback = partial(func, self, item) @@ -85,26 +111,26 @@ def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): for i in items: self.add_item(i) - def _add_component_from_item(self, item: Item): + def _add_component_from_item(self, item: ViewItem): self._underlying.components.append(item._underlying) - def _set_components(self, items: list[Item]): + def _set_components(self, items: list[ViewItem]): self._underlying.components.clear() for item in items: self._add_component_from_item(item) - def add_item(self, item: Item) -> Self: + def add_item(self, item: ViewItem) -> Self: """Adds an item to the section. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the section. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError Maximum number of items has been exceeded (3). """ @@ -112,21 +138,21 @@ def add_item(self, item: Item) -> Self: if len(self.items) >= 3: raise ValueError("maximum number of children exceeded") - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") item.parent = self self.items.append(item) self._add_component_from_item(item) return self - def remove_item(self, item: Item | str | int) -> Self: + def remove_item(self, item: ViewItem | str | int) -> Self: """Removes an item from the section. If an :class:`int` or :class:`str` is passed, the item will be removed by Item ``id`` or ``custom_id`` respectively. Parameters ---------- - item: Union[:class:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, item ``id``, or item ``custom_id`` to remove from the section. """ @@ -138,7 +164,7 @@ def remove_item(self, item: Item | str | int) -> Self: pass return self - def get_item(self, id: int | str) -> Item | None: + def get_item(self, id: int | str) -> ViewItem | None: """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. @@ -149,7 +175,7 @@ def get_item(self, id: int | str) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ViewItem`] The item with the matching ``id`` if it exists. """ if not id: @@ -183,23 +209,23 @@ def add_text(self, content: str, *, id: int | None = None) -> Self: return self.add_item(text) - def set_accessory(self, item: Item) -> Self: + def set_accessory(self, item: ViewItem) -> Self: """Set an item as the section's :attr:`accessory`. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to set as accessory. Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. """ - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") if self.view: item._view = self.view item.parent = self @@ -234,7 +260,7 @@ def set_thumbnail( return self.set_accessory(thumbnail) - @Item.view.setter + @ViewItem.view.setter def view(self, value): self._view = value for item in self.walk_items(): @@ -247,14 +273,6 @@ def copy_text(self) -> str: """ return "\n".join(t for i in self.items if (t := i.copy_text())) - @property - def type(self) -> ComponentType: - return self._underlying.type - - @property - def width(self) -> int: - return 5 - def is_dispatchable(self) -> bool: return self.accessory and self.accessory.is_dispatchable() @@ -270,14 +288,14 @@ def refresh_component(self, component: SectionComponent) -> None: if self.accessory and component.accessory: self.accessory.refresh_component(component.accessory) - def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Disables all buttons and select menus in the section. At the moment, this only disables :attr:`accessory` if it is a button. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not disable from the view. """ for item in self.walk_items(): @@ -287,14 +305,14 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = True return self - def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Enables all buttons and select menus in the section. At the moment, this only enables :attr:`accessory` if it is a button. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not enable from the view. """ for item in self.walk_items(): @@ -304,7 +322,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = False return self - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ViewItem]: r = self.items if self.accessory: yield from r + [self.accessory] @@ -315,7 +333,7 @@ def to_component_dict(self) -> SectionComponentPayload: self._set_components(self.items) if self.accessory: self.set_accessory(self.accessory) - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[S], component: SectionComponent) -> S: diff --git a/discord/ui/select.py b/discord/ui/select.py index a779016b4e..2dfe773622 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -44,7 +44,7 @@ from ..threads import Thread from ..user import User from ..utils import MISSING -from .item import Item, ItemCallbackType +from .item import ItemCallbackType, ModalItem, ViewItem __all__ = ( "Select", @@ -67,7 +67,8 @@ from ..abc import GuildChannel, Snowflake from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData - from .view import View + from .modal import DesignerModal + from .view import BaseView ST = TypeVar("ST", bound=Snowflake | str, covariant=True, default=Any) else: @@ -77,10 +78,11 @@ ST = TypeVar("ST", bound="Snowflake | str", covariant=True) S = TypeVar("S", bound="Select") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) +M = TypeVar("M", bound="DesignerModal", covariant=True) -class Select(Generic[V, ST], Item[V]): +class Select(Generic[V, M, ST], ViewItem[V], ModalItem[M]): """Represents a UI select menu. This is usually represented as a drop down menu. @@ -97,7 +99,7 @@ class Select(Generic[V, ST], Item[V]): .. versionchanged:: 2.7 - Can now be sent in :class:`discord.ui.Modal`. + Can now be sent in :class:`discord.ui.DesignerModal`. Parameters ---------- @@ -134,21 +136,11 @@ class Select(Generic[V, ST], Item[V]): rows. By default, items are arranged automatically into those 5 rows. If you'd like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). + ordering. The row number must be between 0 and 4 (i.e. zero indexed). Does not work in :class:`ActionRow` or :class:`Label`. id: Optional[:class:`int`] The select menu's ID. - label: Optional[:class:`str`] - The label for the select menu. Only useable in modals. - Must be 45 characters or fewer. - - .. versionadded:: 2.7 - description: Optional[:class:`str`] - The description for the select menu. Only useable in modals. - Must be 100 characters or fewer. - - .. versionadded:: 2.7 required: Optional[:class:`bool`] - Whether the select is required or not. Only useable in modals. Defaults to ``True`` in modals. + Whether the select is required or not. Only useable when added to :class:`Label` for modals. Defaults to ``True`` in modals. .. versionadded:: 2.7 default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] @@ -194,8 +186,6 @@ class Select(Generic[V, ST], Item[V]): "disabled", "custom_id", "id", - "label", - "description", "required", "default_values", ) @@ -213,8 +203,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., ) -> None: ... @@ -231,8 +219,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., default_values: Sequence[SelectDefaultValue | ST] | None = ..., ) -> None: ... @@ -253,8 +239,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., default_values: Sequence[SelectDefaultValue | ST] | None = ..., ) -> None: ... @@ -272,17 +256,11 @@ def __init__( disabled: bool = False, row: int | None = None, id: int | None = None, - label: str | None = None, - description: str | None = None, required: bool | None = None, default_values: Sequence[SelectDefaultValue | ST] | None = None, ) -> None: if options and select_type is not ComponentType.string_select: raise InvalidArgument("options parameter is only valid for string selects") - if label and len(label) > 45: - raise ValueError("label must be 45 characters or fewer") - if description and len(description) > 100: - raise ValueError("description must be 100 characters or fewer") if channel_types and select_type is not ComponentType.channel_select: raise InvalidArgument( "channel_types parameter is only valid for channel selects" @@ -303,9 +281,6 @@ def __init__( f"expected custom_id to be str, not {custom_id.__class__.__name__}" ) - self.label: str | None = label - self.description: str | None = description - self._provided_custom_id = custom_id is not None custom_id = os.urandom(16).hex() if custom_id is None else custom_id self._underlying: SelectMenu = SelectMenu._raw_construct( @@ -739,7 +714,7 @@ def width(self) -> int: return 5 def to_component_dict(self) -> SelectMenuPayload: - return self._underlying.to_dict() + return super().to_component_dict() def refresh_component(self, component: SelectMenu) -> None: self._underlying = component @@ -751,7 +726,9 @@ def refresh_state(self, interaction: Interaction | dict) -> None: self._selected_values = data.get("values", []) self._interaction = interaction - def refresh_from_modal(self, interaction: Interaction | dict, data: dict) -> None: + def refresh_from_modal( + self, interaction: Interaction | dict, data: SelectMenuPayload + ) -> None: self._selected_values = data.get("values", []) self._interaction = interaction @@ -772,19 +749,12 @@ def from_component(cls: type[S], component: SelectMenu) -> S: default_values=component.default_values, ) # type: ignore - @property - def type(self) -> ComponentType: - return self._underlying.type - def is_dispatchable(self) -> bool: return True def is_storable(self) -> bool: return True - def uses_label(self) -> bool: - return bool(self.label or self.description or (self.required is not None)) - if TYPE_CHECKING: StringSelect = Select[V, str] @@ -874,7 +844,7 @@ def select( """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and + the :class:`discord.ui.View`, :class:`discord.ui.ActionRow` or :class:`discord.ui.Section`, the :class:`discord.ui.Select` being pressed and the :class:`discord.Interaction` you receive. In order to get the selected items that the user has chosen within the callback diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 6b81674401..2ddfae8af2 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from typing import TYPE_CHECKING, TypeVar @@ -5,20 +29,20 @@ from ..components import Separator as SeparatorComponent from ..components import _component_factory from ..enums import ComponentType, SeparatorSpacingSize -from .item import Item +from .item import ViewItem __all__ = ("Separator",) if TYPE_CHECKING: from ..types.components import SeparatorComponent as SeparatorComponentPayload - from .view import View + from .view import DesignerView S = TypeVar("S", bound="Separator") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class Separator(Item[V]): +class Separator(ViewItem[V]): """Represents a UI Separator. .. versionadded:: 2.7 @@ -55,10 +79,6 @@ def __init__( spacing=spacing, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def divider(self) -> bool: """Whether the separator is a divider. Defaults to ``True``.""" @@ -77,12 +97,8 @@ def spacing(self) -> SeparatorSpacingSize: def spacing(self, value: SeparatorSpacingSize) -> None: self._underlying.spacing = value - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> SeparatorComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[S], component: SeparatorComponent) -> S: diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 6624500a3f..76ab9dbc50 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from typing import TYPE_CHECKING, TypeVar @@ -5,20 +29,23 @@ from ..components import TextDisplay as TextDisplayComponent from ..components import _component_factory from ..enums import ComponentType -from .item import Item +from .item import ModalItem, ViewItem __all__ = ("TextDisplay",) if TYPE_CHECKING: from ..types.components import TextDisplayComponent as TextDisplayComponentPayload - from .view import View + from .core import ItemInterface + from .modal import DesignerModal + from .view import DesignerView T = TypeVar("T", bound="TextDisplay") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) +M = TypeVar("M", bound="DesignerModal", covariant=True) -class TextDisplay(Item[V]): +class TextDisplay(ViewItem[V], ModalItem[M]): """Represents a UI text display. A message can have up to 4000 characters across all :class:`TextDisplay` objects combined. .. versionadded:: 2.7 @@ -49,10 +76,6 @@ def __init__( content=content, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def content(self) -> str: """The text display's content.""" @@ -62,12 +85,8 @@ def content(self) -> str: def content(self, value: str) -> None: self._underlying.content = value - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> TextDisplayComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() def copy_text(self) -> str: """Returns the content of this text display. Equivalent to the `Copy Text` option on Discord clients.""" diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index f14e3022eb..61c44bd2ce 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from typing import TYPE_CHECKING, TypeVar @@ -5,20 +29,20 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import UnfurledMediaItem, _component_factory from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("Thumbnail",) if TYPE_CHECKING: from ..types.components import ThumbnailComponent as ThumbnailComponentPayload - from .view import View + from .view import DesignerView T = TypeVar("T", bound="Thumbnail") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) -class Thumbnail(Item[V]): +class Thumbnail(ViewItem[V]): """Represents a UI Thumbnail. .. versionadded:: 2.7 @@ -62,14 +86,6 @@ def __init__( spoiler=spoiler, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - - @property - def width(self) -> int: - return 5 - @property def url(self) -> str: """The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL.""" @@ -99,7 +115,7 @@ def spoiler(self, spoiler: bool) -> None: self._underlying.spoiler = spoiler def to_component_dict(self) -> ThumbnailComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[T], component: ThumbnailComponent) -> T: diff --git a/discord/ui/view.py b/discord/ui/view.py index ddb5368b51..7110246ed1 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -31,7 +31,9 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar + +from typing_extensions import Self from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent @@ -47,9 +49,16 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory from ..utils import find -from .item import Item, ItemCallbackType +from .core import ItemInterface +from .item import ItemCallbackType, ViewItem -__all__ = ("View", "_component_to_item", "_walk_all_components") +__all__ = ( + "BaseView", + "View", + "DesignerView", + "_component_to_item", + "_walk_all_components", +) if TYPE_CHECKING: @@ -58,7 +67,7 @@ from ..state import ConnectionState from ..types.components import Component as ComponentPayload -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) def _walk_all_components(components: list[Component]) -> Iterator[Component]: @@ -79,7 +88,7 @@ def _walk_all_components_v2(components: list[Component]) -> Iterator[Component]: yield item -def _component_to_item(component: Component) -> Item[V]: +def _component_to_item(component: Component) -> ViewItem[V]: if isinstance(component, ButtonComponent): from .button import Button @@ -118,21 +127,20 @@ def _component_to_item(component: Component) -> Item[V]: return Container.from_component(component) if isinstance(component, ActionRowComponent): - # Handle ActionRow.children manually, or design ui.ActionRow? + from .action_row import ActionRow - return component + return ActionRow.from_component(component) if isinstance(component, LabelComponent): - ret = _component_to_item(component.component) - ret.label = component.label - ret.description = component.description - return ret - return Item.from_component(component) + from .label import Label + + return Label.from_component(component) + return ViewItem.from_component(component) class _ViewWeights: __slots__ = ("weights",) - def __init__(self, children: list[Item[V]]): + def __init__(self, children: list[ViewItem[V]]): self.weights: list[int] = [0, 0, 0, 0, 0] key = lambda i: sys.maxsize if i.row is None else i.row @@ -141,7 +149,7 @@ def __init__(self, children: list[Item[V]]): for item in group: self.add_item(item) - def find_open_space(self, item: Item[V]) -> int: + def find_open_space(self, item: ViewItem[V]) -> int: for index, weight in enumerate(self.weights): # check if open space AND (next row has no items OR this is the last row) if (weight + item.width <= 5) and ( @@ -152,12 +160,7 @@ def find_open_space(self, item: Item[V]) -> int: raise ValueError("could not find open space for item") - def add_item(self, item: Item[V]) -> None: - if ( - item._underlying.is_v2() or not self.fits_legacy(item) - ) and not self.requires_v2(): - self.weights.extend([0, 0, 0, 0, 0] * 7) - + def add_item(self, item: ViewItem[V]) -> None: if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -171,7 +174,7 @@ def add_item(self, item: Item[V]) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: Item[V]) -> None: + def remove_item(self, item: ViewItem[V]) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -179,291 +182,78 @@ def remove_item(self, item: Item[V]) -> None: def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] - def requires_v2(self) -> bool: - return sum(w > 0 for w in self.weights) > 5 or len(self.weights) > 5 - - def fits_legacy(self, item) -> bool: - if item.row is not None: - return item.row <= 4 - return self.weights[-1] + item.width <= 5 - - -class View: - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ---------- - *items: :class:`Item` - The initial items attached to this view. - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. - If ``None`` then there is no timeout. - Attributes - ---------- - timeout: Optional[:class:`float`] - Timeout from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. - children: List[:class:`Item`] - The list of children attached to this view. - disable_on_timeout: :class:`bool` - Whether to disable the view when the timeout is reached. Defaults to ``False``. - message: Optional[:class:`.Message`] - The message that this view is attached to. - If ``None`` then the view has not been sent with a message. - parent: Optional[:class:`.Interaction`] - The parent interaction which this view was sent from. - If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - """ +class BaseView(ItemInterface): + """The base class for UI views used in messages.""" __discord_ui_view__: ClassVar[bool] = True - __view_children_items__: ClassVar[list[ItemCallbackType]] = [] - - def __init_subclass__(cls) -> None: - children: list[ItemCallbackType] = [] - for base in reversed(cls.__mro__): - for member in base.__dict__.values(): - if hasattr(member, "__discord_ui_model_type__"): - children.append(member) - - if len(children) > 40: - raise TypeError("View cannot have more than 40 children") - - cls.__view_children_items__ = children + MAX_ITEMS: int def __init__( self, - *items: Item[V], + *items: ViewItem[V], timeout: float | None = 180.0, disable_on_timeout: bool = False, ): - self.timeout = timeout + super().__init__(*items, timeout=timeout) self.disable_on_timeout = disable_on_timeout - self.children: list[Item[V]] = [] - for func in self.__view_children_items__: - item: Item[V] = func.__discord_ui_model_type__( - **func.__discord_ui_model_kwargs__ - ) - item.callback = partial(func, self, item) - item._view = self - item.parent = self - setattr(self, func.__name__, item) - self.children.append(item) - - self.__weights = _ViewWeights(self.children) - for item in items: - self.add_item(item) - - loop = asyncio.get_running_loop() self.id: str = os.urandom(16).hex() - self.__cancel_callback: Callable[[View], None] | None = None - self.__timeout_expiry: float | None = None - self.__timeout_task: asyncio.Task[None] | None = None - self.__stopped: asyncio.Future[bool] = loop.create_future() self._message: Message | InteractionMessage | None = None self.parent: Interaction | None = None - def __repr__(self) -> str: - return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" - - async def __timeout_task_impl(self) -> None: - while True: - # Guard just in case someone changes the value of the timeout at runtime - if self.timeout is None: - return - - if self.__timeout_expiry is None: - return self._dispatch_timeout() - - # Check if we've elapsed our currently set timeout - now = time.monotonic() - if now >= self.__timeout_expiry: - return self._dispatch_timeout() - - # Wait N seconds to see if timeout data has been refreshed - await asyncio.sleep(self.__timeout_expiry - now) - - def to_components(self) -> list[dict[str, Any]]: - def key(item: Item[V]) -> int: - return item._rendered_row or 0 - - children = sorted(self.children, key=key) - components: list[dict[str, Any]] = [] - for _, group in groupby(children, key=key): - items = list(group) - children = [item.to_component_dict() for item in items] - if not children: - continue - - if any([i._underlying.is_v2() for i in items]): - components += children - else: - components.append( - { - "type": 1, - "components": children, - } - ) - - return components - - @classmethod - def from_message( - cls, message: Message, /, *, timeout: float | None = 180.0 - ) -> View: - """Converts a message's components into a :class:`View`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`View` first. - - Parameters - ---------- - message: :class:`.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - for component in _walk_all_components(message.components): - view.add_item(_component_to_item(component)) - return view - - @classmethod - def from_dict( - cls, - data: list[Component], - /, - *, - timeout: float | None = 180.0, - ) -> View: - """Converts a list of component dicts into a :class:`View`. - - Parameters - ---------- - data: List[:class:`.Component`] - The list of components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - components = [_component_factory(d) for d in data] - for component in _walk_all_components(components): - view.add_item(_component_to_item(component)) - return view - - @property - def _expires_at(self) -> float | None: - if self.timeout: - return time.monotonic() + self.timeout - return None - - def add_item(self, item: Item[V]) -> None: + def add_item(self, item: ViewItem[V]) -> Self: """Adds an item to the view. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the view. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError - Maximum number of children has been exceeded (40) - or the row the item is trying to be added to is full. + Maximum number of children has been exceeded """ - if len(self.children) >= 40: + if len(self.children) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") - - if item.uses_label(): - raise ValueError( - f"cannot use label, description or required on select menus in views." - ) - - self.__weights.add_item(item) + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") item.parent = self item._view = self - if hasattr(item, "items"): - item.view = self self.children.append(item) return self - def remove_item(self, item: Item[V] | int | str) -> None: + def remove_item(self, item: ViewItem[V] | int | str) -> None: """Removes an item from the view. If an :class:`int` or :class:`str` is passed, - the item will be removed by Item ``id`` or ``custom_id`` respectively. + the item will be removed by ViewItem ``id`` or ``custom_id`` respectively. Parameters ---------- - item: Union[:class:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, item ``id``, or item ``custom_id`` to remove from the view. """ if isinstance(item, (str, int)): item = self.get_item(item) try: - self.children.remove(item) + if isinstance(item.parent, BaseView): + self.children.remove(item) + else: + item.parent.remove_item(item) except ValueError: pass - else: - self.__weights.remove_item(item) return self def clear_items(self) -> None: - """Removes all items from the view.""" + """Removes all items from this view.""" self.children.clear() - self.__weights.clear() return self - def get_item(self, custom_id: str | int) -> Item[V] | None: - """Gets an item from the view. Roughly equal to `utils.get(view.children, ...)`. - If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. - This method will also search nested items. - - Parameters - ---------- - custom_id: Union[:class:`str`, :class:`int`] - The id of the item to get - - Returns - ------- - Optional[:class:`Item`] - The item with the matching ``custom_id`` or ``id`` if it exists. - """ - if not custom_id: - return None - attr = "id" if isinstance(custom_id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) - if not child: - for i in self.children: - if hasattr(i, "get_item"): - if child := i.get_item(custom_id): - return child - return child - async def interaction_check(self, interaction: Interaction) -> bool: """|coro| @@ -514,7 +304,7 @@ async def on_timeout(self) -> None: async def on_check_failure(self, interaction: Interaction) -> None: """|coro| - A callback that is called when a :meth:`View.interaction_check` returns ``False``. + A callback that is called when a :meth:`BaseView.interaction_check` returns ``False``. This can be used to send a response when a check failure occurs. Parameters @@ -524,7 +314,7 @@ async def on_check_failure(self, interaction: Interaction) -> None: """ async def on_error( - self, error: Exception, item: Item[V], interaction: Interaction + self, error: Exception, item: ViewItem[V], interaction: Interaction ) -> None: """|coro| @@ -537,17 +327,24 @@ async def on_error( ---------- error: :class:`Exception` The exception that was raised. - item: :class:`Item` + item: :class:`ViewItem` The item that failed the dispatch. interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ interaction.client.dispatch("view_error", error, item, interaction) - async def _scheduled_task(self, item: Item[V], interaction: Interaction): + def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + """ + return any([item._underlying.is_v2() for item in self.children]) + + async def _scheduled_task(self, item: ViewItem[V], interaction: Interaction): try: if self.timeout: - self.__timeout_expiry = time.monotonic() + self.timeout + self._timeout_expiry = time.monotonic() + self.timeout allow = await self.interaction_check(interaction) if not allow: @@ -558,26 +355,26 @@ async def _scheduled_task(self, item: Item[V], interaction: Interaction): return await self.on_error(e, item, interaction) def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) + self._cancel_callback = partial(store.remove_view) if self.timeout: loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() + if self._timeout_task is not None: + self._timeout_task.cancel() - self.__timeout_expiry = time.monotonic() + self.timeout - self.__timeout_task = loop.create_task(self.__timeout_task_impl()) + self._timeout_expiry = time.monotonic() + self.timeout + self._timeout_task = loop.create_task(self._timeout_task_impl()) def _dispatch_timeout(self): - if self.__stopped.done(): + if self._stopped.done(): return - self.__stopped.set_result(True) + self._stopped.set_result(True) asyncio.create_task( self.on_timeout(), name=f"discord-ui-view-timeout-{self.id}" ) - def _dispatch_item(self, item: Item[V], interaction: Interaction): - if self.__stopped.done(): + def _dispatch_item(self, item: ViewItem[V], interaction: Interaction): + if self._stopped.done(): return if interaction.message: @@ -588,55 +385,16 @@ def _dispatch_item(self, item: Item[V], interaction: Interaction): name=f"discord-ui-view-dispatch-{self.id}", ) - def refresh(self, components: list[Component]): - # Refreshes view data using discord's values - # Assumes the components and items are identical - if not components: - return - - i = 0 - flattened = [] - for c in components: - if isinstance(c, ActionRowComponent): - flattened += c.children - else: - flattened.append(c) - for c in flattened: - try: - item = self.children[i] - except: - break - else: - item.refresh_component(c) - i += 1 - - def stop(self) -> None: - """Stops listening to interaction events from this view. - - This operation cannot be undone. - """ - if not self.__stopped.done(): - self.__stopped.set_result(False) - - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None - - if self.__cancel_callback: - self.__cancel_callback(self) - self.__cancel_callback = None - def is_finished(self) -> bool: """Whether the view has finished interacting.""" - return self.__stopped.done() + return self._stopped.done() def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.children) def is_dispatching(self) -> bool: """Whether the view has been added for dispatching purposes.""" - return self.__cancel_callback is not None + return self._cancel_callback is not None def is_persistent(self) -> bool: """Whether the view is set up as persistent. @@ -648,15 +406,22 @@ def is_persistent(self) -> bool: item.is_persistent() for item in self.children ) - def is_components_v2(self) -> bool: - """Whether the view contains V2 components. + def stop(self) -> None: + """Stops listening to interaction events from this view. - A view containing V2 components cannot be sent alongside message content or embeds. + This operation cannot be undone. """ - return ( - any([item._underlying.is_v2() for item in self.children]) - or self.__weights.requires_v2() - ) + if not self._stopped.done(): + self._stopped.set_result(False) + + self._timeout_expiry = None + if self._timeout_task is not None: + self._timeout_task.cancel() + self._timeout_task = None + + if self._cancel_callback: + self._cancel_callback(self) + self._cancel_callback = None async def wait(self) -> bool: """Waits until the view has finished interacting. @@ -670,15 +435,15 @@ async def wait(self) -> bool: If ``True``, then the view timed out. If ``False`` then the view finished normally. """ - return await self.__stopped + return await self._stopped - def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + def disable_all_items(self, *, exclusions: list[ViewItem[V]] | None = None) -> Self: """ Disables all buttons and select menus in the view. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not disable from the view. """ for child in self.children: @@ -690,13 +455,13 @@ def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: child.disable_all_items(exclusions=exclusions) return self - def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + def enable_all_items(self, *, exclusions: list[ViewItem[V]] | None = None) -> Self: """ Enables all buttons and select menus in the view. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not enable from the view. """ for child in self.children: @@ -708,19 +473,19 @@ def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: child.enable_all_items(exclusions=exclusions) return self - def walk_children(self) -> Iterator[Item]: - for item in self.children: - if hasattr(item, "walk_items"): - yield from item.walk_items() - else: - yield item - def copy_text(self) -> str: """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. Equivalent to the `Copy Text` option on Discord clients. """ return "\n".join(t for i in self.children if (t := i.copy_text())) + def walk_children(self) -> Iterator[ViewItem]: + for item in self.children: + if hasattr(item, "walk_items"): + yield from item.walk_items() + else: + yield item + @property def message(self): return self._message @@ -730,16 +495,408 @@ def message(self, value): self._message = value -class ViewStore: - def __init__(self, state: ConnectionState): - # (component_type, message_id, custom_id): (View, Item) - self._views: dict[tuple[int, int | None, str], tuple[View, Item[V]]] = {} +class View(BaseView): + """Represents a UI view for v1 components :class:`~discord.ui.Button` and :class:`~discord.ui.Select`. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + .. versionchanged:: 2.7 + + Now inherits from :class:`BaseView` + + Parameters + ---------- + *items: :class:`ViewItem` + The initial items attached to this view. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. + If ``None`` then there is no timeout. + + Attributes + ---------- + timeout: Optional[:class:`float`] + Timeout from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + children: List[:class:`ViewItem`] + The list of children attached to this view. + disable_on_timeout: :class:`bool` + Whether to disable the view when the timeout is reached. Defaults to ``False``. + message: Optional[:class:`.Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. + """ + + __view_children_items__: ClassVar[list[ItemCallbackType]] = [] + MAX_ITEMS: int = 25 + + def __init_subclass__(cls) -> None: + children: list[ItemCallbackType] = [] + for base in reversed(cls.__mro__): + for member in base.__dict__.values(): + if hasattr(member, "__discord_ui_model_type__"): + children.append(member) + + if len(children) > 40: + raise TypeError("View cannot have more than 40 children") + + cls.__view_children_items__ = children + + def __init__( + self, + *items: ViewItem[V], + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + super().__init__(timeout=timeout, disable_on_timeout=disable_on_timeout) + + for func in self.__view_children_items__: + item: ViewItem[V] = func.__discord_ui_model_type__( + **func.__discord_ui_model_kwargs__ + ) + item.callback = partial(func, self, item) + item._view = self + item.parent = self + setattr(self, func.__name__, item) + self.children.append(item) + + self.__weights = _ViewWeights(self.children) + for item in items: + self.add_item(item) + + def to_components(self) -> list[dict[str, Any]]: + def key(item: ViewItem[V]) -> int: + return item._rendered_row or 0 + + children = sorted(self.children, key=key) + components: list[dict[str, Any]] = [] + for _, group in groupby(children, key=key): + items = list(group) + children = [item.to_component_dict() for item in items] + if not children: + continue + + components.append( + { + "type": 1, + "components": children, + } + ) + + return components + + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> View: + """Converts a message's components into a :class:`View`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ---------- + message: :class:`.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = View(timeout=timeout) + for component in _walk_all_components(message.components): + view.add_item(_component_to_item(component)) + return view + + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`View`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = View(timeout=timeout) + components = [_component_factory(d) for d in data] + for component in _walk_all_components(components): + view.add_item(_component_to_item(component)) + + def add_item(self, item: ViewItem[V]) -> Self: + """Adds an item to the view. Attempting to add a :class:`~discord.ui.ActionRow` will add its children instead. + + Parameters + ---------- + item: :class:`ViewItem` + The item to add to the view. + + Raises + ------ + TypeError + An :class:`ViewItem` was not passed. + ValueError + Maximum number of children has been exceeded (25) + or the row the item is trying to be added to is full. + """ + + if item._underlying.is_v2(): + raise ValueError( + f"cannot use V2 components in View. Use DesignerView instead." + ) + if isinstance(item._underlying, ActionRowComponent): + for i in item.children: + self.add_item(i) + return self + + super().add_item(item) + self.__weights.add_item(item) + return self + + def remove_item(self, item: ViewItem[V] | int | str) -> None: + """Removes an item from the view. If an :class:`int` or :class:`str` is passed, + the item will be removed by Item ``id`` or ``custom_id`` respectively. + + Parameters + ---------- + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to remove from the view. + """ + + super().remove_item(item) + try: + self.__weights.remove_item(item) + except ValueError: + pass + return self + + def clear_items(self) -> None: + """Removes all items from the view.""" + super().clear_items() + self.__weights.clear() + return self + + def refresh(self, components: list[Component]): + # This is pretty hacky at the moment + old_state: dict[tuple[int, str], ViewItem[V]] = { + (item.type.value, item.custom_id): item for item in self.children if item.is_dispatchable() # type: ignore + } + children: list[ViewItem[V]] = [ + item for item in self.children if not item.is_dispatchable() + ] + for component in _walk_all_components(components): + try: + older = old_state[(component.type.value, component.custom_id)] # type: ignore + except (KeyError, AttributeError): + item = _component_to_item(component) + if not item.is_dispatchable(): + continue + children.append(item) + else: + older.refresh_component(component) + children.append(older) + + self.children = children + + def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + + This is always ``False`` for :class:`View`. + """ + return False + + +class DesignerView(BaseView): + """Represents a UI view compatible with v2 components. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`ViewItem` + The initial items attached to this view. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. + If ``None`` then there is no timeout. + + Attributes + ---------- + timeout: Optional[:class:`float`] + Timeout from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + children: List[:class:`ViewItem`] + The list of items attached to this view. + disable_on_timeout: :class:`bool` + Whether to disable the view's items when the timeout is reached. Defaults to ``False``. + message: Optional[:class:`.Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. + """ + + MAX_ITEMS: int = 40 + + def __init_subclass__(cls) -> None: + for base in reversed(cls.__mro__): + for member in base.__dict__.values(): + if hasattr(member, "__discord_ui_model_type__"): + raise ValueError( + "The @button and @select decorators are incompatible with DesignerView. Use ActionRow instead." + ) + + def __init__( + self, + *items: ViewItem[V], + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout) + + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> View: + """Converts a message's components into a :class:`DesignerView`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ---------- + message: :class:`.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + for component in message.components: + view.add_item(_component_to_item(component)) + return view + + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`DesignerView`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`DesignerView` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + components = [_component_factory(d) for d in data] + for component in components: + view.add_item(_component_to_item(component)) + return view + + def add_item(self, item: ViewItem[V]) -> Self: + """Adds an item to the view. + + Parameters + ---------- + item: :class:`ViewItem` + The item to add to the view. + + Raises + ------ + TypeError + An :class:`ViewItem` was not passed. + ValueError + Maximum number of items has been exceeded (40) + """ + + if isinstance(item._underlying, (SelectComponent, ButtonComponent)): + raise ValueError( + f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." + ) + + super().add_item(item) + if hasattr(item, "items"): + item.view = self + return self + + def refresh(self, components: list[Component]): + # Refreshes view data using discord's values + # Assumes the components and items are identical + if not components: + return + + i = 0 + for c in components: + try: + item = self.children[i] + except: + break + else: + item.refresh_component(c) + i += 1 + + def is_components_v2(self) -> bool: + return len(self.children) > 5 or super().is_components_v2() + + +class ViewStore: + def __init__(self, state: ConnectionState): + # (component_type, message_id, custom_id): (BaseView, ViewItem) + self._views: dict[tuple[int, int | None, str], tuple[BaseView, ViewItem[V]]] = ( + {} + ) # message_id: View - self._synced_message_views: dict[int, View] = {} + self._synced_message_views: dict[int, BaseView] = {} self._state: ConnectionState = state @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: views = { view.id: view for (_, (view, _)) in self._views.items() @@ -756,7 +913,7 @@ def __verify_integrity(self): for k in to_remove: del self._views[k] - def add_view(self, view: View, message_id: int | None = None): + def add_view(self, view: BaseView, message_id: int | None = None): self.__verify_integrity() view._start_listening_from_store(self) @@ -767,7 +924,7 @@ def add_view(self, view: View, message_id: int | None = None): if message_id is not None: self._synced_message_views[message_id] = view - def remove_view(self, view: View): + def remove_view(self, view: BaseView): for item in view.walk_children(): if item.is_storable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore @@ -797,7 +954,7 @@ def dispatch(self, component_type: int, custom_id: str, interaction: Interaction def is_message_tracked(self, message_id: int): return message_id in self._synced_message_views - def remove_message_tracking(self, message_id: int) -> View | None: + def remove_message_tracking(self, message_id: int) -> BaseView | None: return self._synced_message_views.pop(message_id, None) def update_from_message(self, message_id: int, components: list[ComponentPayload]): diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index aec9115d5f..c9093f7781 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -74,13 +74,14 @@ from ..file import File from ..guild import Guild from ..http import Response + from ..interactions import Interaction from ..mentions import AllowedMentions from ..poll import Poll from ..state import ConnectionState from ..types.message import Message as MessagePayload from ..types.webhook import FollowerWebhook as FollowerWebhookPayload from ..types.webhook import Webhook as WebhookPayload - from ..ui.view import View + from ..ui.view import BaseView MISSING = utils.MISSING @@ -640,7 +641,7 @@ def handle_message_parameters( attachments: list[Attachment] = MISSING, embed: Embed | None = MISSING, embeds: list[Embed] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, poll: Poll | None = MISSING, applied_tags: list[Snowflake] = MISSING, allowed_mentions: AllowedMentions | None = MISSING, @@ -887,7 +888,7 @@ async def edit( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, suppress: bool | None = MISSING, ) -> WebhookMessage: @@ -926,7 +927,7 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. @@ -1036,6 +1037,7 @@ class BaseWebhook(Hashable): "source_channel", "source_guild", "_state", + "parent", ) def __init__( @@ -1203,6 +1205,12 @@ async def foo(): Only given if :attr:`type` is :attr:`WebhookType.channel_follower`. .. versionadded:: 2.0 + + parent: Optional[:class:`Interaction`] + The interaction this webhook belongs to. + Only set if :attr:`type` is :attr:`WebhookType.application`. + + .. versionadded:: 2.7 """ __slots__: tuple[str, ...] = ("session", "proxy", "proxy_auth") @@ -1215,11 +1223,13 @@ def __init__( proxy_auth: aiohttp.BasicAuth | None = None, token: str | None = None, state=None, + parent: Interaction | None = None, ): super().__init__(data, token, state) self.session = session self.proxy: str | None = proxy self.proxy_auth: aiohttp.BasicAuth | None = proxy_auth + self.parent: Interaction | None = parent def __repr__(self): return f"" @@ -1371,6 +1381,28 @@ def from_state(cls, data, state) -> Webhook: token=state.http.token, ) + @classmethod + def from_interaction(cls, interaction) -> Webhook: + state = interaction._state + data = { + "id": interaction.application_id, + "type": 3, + "token": interaction.token, + } + http = state.http + session = http._HTTPClient__session + proxy_auth = http.proxy_auth + proxy = http.proxy + return cls( + data, + session=session, + state=state, + proxy_auth=proxy_auth, + proxy=proxy, + token=state.http.token, + parent=interaction, + ) + async def fetch(self, *, prefer_auth: bool = True) -> Webhook: """|coro| @@ -1622,7 +1654,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1645,7 +1677,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1667,7 +1699,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1728,7 +1760,7 @@ async def send( Controls the mentions being processed in this message. .. versionadded:: 1.4 - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to send with the message. You can only send a view if this webhook is not partial and has state attached. A webhook has state attached if the webhook is managed by the @@ -1867,6 +1899,8 @@ async def send( if view and not view.is_finished(): message_id = None if msg is None else msg.id view.message = None if msg is None else msg + if self.parent and not view.parent: + view.parent = self.parent if msg: view.refresh(msg.components) if view.is_dispatchable(): @@ -1946,7 +1980,7 @@ async def edit_message( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, thread: Snowflake | None = MISSING, suppress: bool = False, @@ -1989,7 +2023,7 @@ async def edit_message( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. The webhook must have state attached, similar to :meth:`send`. diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index 8714f59b85..2338669f0a 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -33,16 +33,46 @@ Shortcut decorators Objects ------- +.. attributetable:: discord.ui.BaseView + +.. autoclass:: discord.ui.BaseView + :members: + .. attributetable:: discord.ui.View .. autoclass:: discord.ui.View :members: + :inherited-members: + +.. attributetable:: discord.ui.DesignerView + +.. autoclass:: discord.ui.DesignerView + :members: + :inherited-members: .. attributetable:: discord.ui.Item .. autoclass:: discord.ui.Item :members: +.. attributetable:: discord.ui.ViewItem + +.. autoclass:: discord.ui.ViewItem + :members: + :inherited-members: + +.. attributetable:: discord.ui.ModalItem + +.. autoclass:: discord.ui.ModalItem + :members: + :inherited-members: + +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + .. attributetable:: discord.ui.Button .. autoclass:: discord.ui.Button @@ -118,12 +148,30 @@ Objects :members: :inherited-members: +.. attributetable:: discord.ui.BaseModal + +.. autoclass:: discord.ui.BaseModal + :members: + :inherited-members: + .. attributetable:: discord.ui.Modal .. autoclass:: discord.ui.Modal :members: :inherited-members: +.. attributetable:: discord.ui.DesignerModal + +.. autoclass:: discord.ui.DesignerModal + :members: + :inherited-members: + +.. attributetable:: discord.ui.Label + +.. autoclass:: discord.ui.Label + :members: + :inherited-members: + .. attributetable:: discord.ui.InputText .. autoclass:: discord.ui.InputText diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index 4d10ddab0c..0320681811 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -11,31 +11,43 @@ ) -class MyModal(discord.ui.Modal): +class MyModal(discord.ui.DesignerModal): def __init__(self, *args, **kwargs) -> None: - super().__init__( + first_input = discord.ui.Label( + "Short Input", discord.ui.InputText( - label="Short Input", placeholder="Placeholder Test", ), + ) + second_input = discord.ui.Label( + "Longer Input", discord.ui.InputText( - label="Longer Input", + placeholder="Placeholder Test", value="Longer Value\nSuper Long Value", style=discord.InputTextStyle.long, - description="You can also describe the purpose of this input.", ), - discord.ui.TextDisplay("# Personal Questions"), + description="You can also describe the purpose of this input.", + ) + select = discord.ui.Label( + "What's your favorite color?", discord.ui.Select( - label="What's your favorite color?", placeholder="Select a color", options=[ discord.SelectOption(label="Red", emoji="🟥"), discord.SelectOption(label="Green", emoji="🟩"), discord.SelectOption(label="Blue", emoji="🟦"), ], - description="If it is not listed, skip this question.", required=False, ), + description="If it is not listed, skip this question.", + ) + super().__init__( + first_input, + second_input, + discord.ui.TextDisplay( + "# Personal Questions" + ), # TextDisplay does NOT use Label + select, *args, **kwargs, ) @@ -45,10 +57,10 @@ async def callback(self, interaction: discord.Interaction): title="Your Modal Results", fields=[ discord.EmbedField( - name="First Input", value=self.children[0].value, inline=False + name="First Input", value=self.children[0].item.value, inline=False ), discord.EmbedField( - name="Second Input", value=self.children[1].value, inline=False + name="Second Input", value=self.children[1].item.value, inline=False ), ], color=discord.Color.random(), diff --git a/examples/views/new_components.py b/examples/views/new_components.py index 53a05f7435..e022dab089 100644 --- a/examples/views/new_components.py +++ b/examples/views/new_components.py @@ -11,20 +11,29 @@ User, ) from discord.ui import ( + ActionRow, Button, Container, + DesignerView, MediaGallery, Section, Select, Separator, TextDisplay, Thumbnail, - View, button, ) -class MyView(View): +class MyRow(ActionRow): + + @button(label="Delete Message", style=ButtonStyle.red, id=200) + async def delete_button(self, button: Button, interaction: Interaction): + await interaction.response.defer(invisible=True) + await interaction.message.delete() + + +class MyView(DesignerView): def __init__(self, user: User): super().__init__(timeout=30) text1 = TextDisplay("### This is a sample `TextDisplay` in a `Section`.") @@ -55,11 +64,8 @@ def __init__(self, user: User): self.add_item( TextDisplay("Above is a `MediaGallery` containing two `MediaGalleryItem`s.") ) - - @button(label="Delete Message", style=ButtonStyle.red, id=200) - async def delete_button(self, button: Button, interaction: Interaction): - await interaction.response.defer(invisible=True) - await interaction.message.delete() + row = MyRow() + self.add_item(row) async def on_timeout(self): self.get_item(200).disabled = True