From c48c512d889139eeda732ce4fc146bd50f39583e Mon Sep 17 00:00:00 2001 From: DA-344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:07:39 +0100 Subject: [PATCH] chore: some fixes of bugs reported on the bikeshedding post --- discord/ui/action_row.py | 11 ++--- discord/ui/button.py | 7 ++- discord/ui/container.py | 90 ++++++++++++++++++++++++++++++------- discord/ui/file.py | 4 +- discord/ui/item.py | 9 ++-- discord/ui/media_gallery.py | 4 +- discord/ui/section.py | 4 +- discord/ui/select.py | 44 +++++++++++++++++- discord/ui/separator.py | 4 +- discord/ui/text_display.py | 4 +- discord/ui/thumbnail.py | 8 ++-- discord/ui/view.py | 8 ++++ 12 files changed, 155 insertions(+), 42 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4daf02839..510d6175b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -94,19 +94,20 @@ class ActionRow(Item[V]): Parameters ---------- - id: Optional[:class:`str`] - The ID of this action row. Defaults to ``None``. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. """ __action_row_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] __discord_ui_action_row__: ClassVar[bool] = True + __pending_view__: ClassVar[bool] = True - def __init__(self, *, id: Optional[str] = None) -> None: + def __init__(self, *, id: Optional[int] = None) -> None: super().__init__() - - self.id: str = id or os.urandom(16).hex() self._children: List[Item[Any]] = self._init_children() + self.id = id + def __init_subclass__(cls) -> None: super().__init_subclass__() diff --git a/discord/ui/button.py b/discord/ui/button.py index df21c770f..82a485f91 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -83,6 +83,10 @@ class Button(Item[V]): nor ``custom_id``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + + .. versionadded:: 2.6 """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -106,6 +110,7 @@ class Button(Item[V]): emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, sku_id: Optional[int] = None, + id: Optional[int] = None, ): super().__init__() if custom_id is not None and (url is not None or sku_id is not None): @@ -147,7 +152,7 @@ class Button(Item[V]): ) self._parent: Optional[ActionRow] = None self.row = row - self.id = custom_id + self.id = id @property def style(self) -> ButtonStyle: diff --git a/discord/ui/container.py b/discord/ui/container.py index da1770028..b60c1ec40 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -23,10 +23,10 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union -from .item import Item -from .view import BaseView, _component_to_item, LayoutView +from .item import Item, ItemCallbackType +from .view import _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING @@ -36,13 +36,26 @@ if TYPE_CHECKING: from ..colour import Colour, Color from ..components import Container as ContainerComponent + from ..interactions import Interaction V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) -class Container(BaseView, Item[V]): +class _ContainerCallback: + __slots__ = ('container', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[Any, Any], container: Container, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.container: Container = container + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.container, interaction, self.item) + + +class Container(Item[V]): """Represents a UI container. .. versionadded:: 2.6 @@ -66,41 +79,86 @@ class Container(BaseView, Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ + __container_children_items__: ClassVar[List[Union[ItemCallbackType[Any, Any], Item[Any]]]] = [] + __pending_view__: ClassVar[bool] = True + def __init__( self, - children: List[Item[Any]] = MISSING, + children: List[Item[V]] = MISSING, *, accent_colour: Optional[Colour] = None, accent_color: Optional[Color] = None, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: - super().__init__(timeout=None) + self._children: List[Item[V]] = self._init_children() + if children is not MISSING: if len(children) + len(self._children) > 10: - raise ValueError('maximum number of components exceeded') - self._children.extend(children) + raise ValueError('maximum number of children exceeded') self.spoiler: bool = spoiler self._colour = accent_colour or accent_color self._view: Optional[V] = None - self._row: Optional[int] = None - self._rendered_row: Optional[int] = None - self.row: Optional[int] = row - self.id: Optional[str] = id + self.row = row + self.id = id + + def _init_children(self) -> List[Item[Any]]: + children = [] + + for raw in self.__container_children_items__: + if isinstance(raw, Item): + children.append(raw) + else: + # action rows can be created inside containers, and then callbacks can exist here + # so we create items based off them + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ContainerCallback(raw, self, item) # type: ignore + setattr(self, raw.__name__, item) + # this should not fail because in order for a function to be here it should be from + # an action row and must have passed the check in __init_subclass__, but still + # guarding it + parent = getattr(raw, '__discord_ui_parent__', None) + if parent is None: + raise RuntimeError(f'{raw.__name__} is not a valid item for a Container') + parent._children.append(item) + # we donnot append it to the children list because technically these buttons and + # selects are not from the container but the action row itself. + + return children + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, Union[ItemCallbackType[Any, Any], Item[Any]]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + if hasattr(member, '__discord_ui_model_type__') and hasattr(member, '__discord_ui_parent__'): + children[name] = member + + cls.__container_children_items__ = list(children.values()) + + def _update_children_view(self, view) -> None: + for child in self._children: + child._view = view + if getattr(child, '__pending_view__', False): + # if the item is an action row which child's view can be updated, then update it + child._update_children_view(view) # type: ignore @property - def children(self) -> List[Item[Self]]: + def children(self) -> List[Item[V]]: """List[:class:`Item`]: The children of this container.""" return self._children.copy() @children.setter - def children(self, value: List[Item[Any]]) -> None: + def children(self, value: List[Item[V]]) -> None: self._children = value @property diff --git a/discord/ui/file.py b/discord/ui/file.py index 2654d351c..7d065f0ff 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -59,7 +59,7 @@ class File(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -69,7 +69,7 @@ class File(Item[V]): *, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._underlying = FileComponent._raw_construct( diff --git a/discord/ui/item.py b/discord/ui/item.py index 1fa68b68c..bcee854a8 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -70,7 +70,7 @@ class Item(Generic[V]): # 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._id: Optional[str] = None + self._id: Optional[int] = None self._max_row: int = 5 if not self._is_v2() else 10 def to_component_dict(self) -> Dict[str, Any]: @@ -126,14 +126,13 @@ class Item(Generic[V]): return self._view @property - def id(self) -> Optional[str]: - """Optional[:class:`str`]: The ID of this component. For non v2 components this is the - equivalent to ``custom_id``. + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component. """ return self._id @id.setter - def id(self, value: Optional[str]) -> None: + def id(self, value: Optional[int]) -> None: self._id = value async def callback(self, interaction: Interaction[ClientT]) -> Any: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index f9e1fb264..ee0fb3cf0 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -60,7 +60,7 @@ class MediaGallery(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -69,7 +69,7 @@ class MediaGallery(Item[V]): items: List[MediaGalleryItem], *, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() diff --git a/discord/ui/section.py b/discord/ui/section.py index ba919beb8..0aa164d88 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -59,7 +59,7 @@ class Section(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -74,7 +74,7 @@ class Section(Item[V]): *, accessory: Item[Any], row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._children: List[Item[Any]] = [] diff --git a/discord/ui/select.py b/discord/ui/select.py index f5a9fcbee..efa8a9e68 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -239,6 +239,7 @@ class BaseSelect(Item[V]): options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__() self._provided_custom_id = custom_id is not MISSING @@ -259,7 +260,7 @@ class BaseSelect(Item[V]): ) self.row = row - self.id = custom_id if custom_id is not MISSING else None + self.id = id self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] @@ -393,6 +394,10 @@ class Select(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('options',) @@ -407,6 +412,7 @@ class Select(BaseSelect[V]): options: List[SelectOption] = MISSING, disabled: bool = False, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -417,6 +423,7 @@ class Select(BaseSelect[V]): disabled=disabled, options=options, row=row, + id=id, ) @property @@ -548,6 +555,10 @@ class UserSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -562,6 +573,7 @@ class UserSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -572,6 +584,7 @@ class UserSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -640,6 +653,10 @@ class RoleSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -654,6 +671,7 @@ class RoleSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -664,6 +682,7 @@ class RoleSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -728,6 +747,10 @@ class MentionableSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -742,6 +765,7 @@ class MentionableSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -752,6 +776,7 @@ class MentionableSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -822,6 +847,10 @@ class ChannelSelect(BaseSelect[V]): 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). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ( @@ -840,6 +869,7 @@ class ChannelSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -851,6 +881,7 @@ class ChannelSelect(BaseSelect[V]): row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -902,6 +933,7 @@ def select( max_values: int = ..., disabled: bool = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, SelectT]: ... @@ -919,6 +951,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, UserSelectT]: ... @@ -936,6 +969,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, RoleSelectT]: ... @@ -953,6 +987,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, ChannelSelectT]: ... @@ -970,6 +1005,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., + id: Optional[int] = ..., ) -> SelectCallbackDecorator[V, MentionableSelectT]: ... @@ -986,6 +1022,7 @@ def select( disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, + id: Optional[int] = None, ) -> SelectCallbackDecorator[V, BaseSelectT]: """A decorator that attaches a select menu to a component. @@ -1065,6 +1102,10 @@ def select( Number of items must be in range of ``min_values`` and ``max_values``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: @@ -1083,6 +1124,7 @@ def select( 'min_values': min_values, 'max_values': max_values, 'disabled': disabled, + 'id': id, } if issubclass(callback_cls, Select): func.__discord_ui_model_kwargs__['options'] = options diff --git a/discord/ui/separator.py b/discord/ui/separator.py index b9ff955ad..394e9ac78 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -58,7 +58,7 @@ class Separator(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -68,7 +68,7 @@ class Separator(Item[V]): visible: bool = True, spacing: SeparatorSize = SeparatorSize.small, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._underlying = SeparatorComponent._raw_construct( diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index e55c72ba4..8e22905eb 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -55,11 +55,11 @@ class TextDisplay(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ - def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[str] = None) -> None: + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: super().__init__() self.content: str = content diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 0e7def382..e9a2c13f5 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -48,8 +48,8 @@ class Thumbnail(Item[V]): Parameters ---------- media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] - The media of the thumbnail. This can be a string that points to a local - attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` + The media of the thumbnail. This can be a URL or a reference + to an attachment that matches the ``attachment://filename.extension`` structure. description: Optional[:class:`str`] The description of this thumbnail. Defaults to ``None``. @@ -62,7 +62,7 @@ class Thumbnail(Item[V]): 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 9 (i.e. zero indexed) - id: Optional[:class:`str`] + id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -73,7 +73,7 @@ class Thumbnail(Item[V]): description: Optional[str] = None, spoiler: bool = False, row: Optional[int] = None, - id: Optional[str] = None, + id: Optional[int] = None, ) -> None: super().__init__() diff --git a/discord/ui/view.py b/discord/ui/view.py index bafcfedff..9b0709fd4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -223,6 +223,8 @@ class BaseView: parent = getattr(raw, '__discord_ui_parent__', None) if parent and parent._view is None: parent._view = self + if getattr(raw, '__pending_view__', False): + raw._update_children_view(self) # type: ignore children.append(raw) else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) @@ -581,6 +583,8 @@ class View(BaseView): # NOTE: maybe add a deprecation warning in favour of Layo for name, member in base.__dict__.items(): if hasattr(member, '__discord_ui_model_type__'): children[name] = member + elif isinstance(member, Item) and member._is_v2(): + raise RuntimeError(f'{name} cannot be added to this View') if len(children) > 25: raise TypeError('View cannot have more than 25 children') @@ -707,10 +711,14 @@ class LayoutView(BaseView): def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} + row = 0 + for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): + member._rendered_row = member._row or row children[name] = member + row += 1 elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): children[name] = member