diff --git a/discord/components.py b/discord/components.py index ac4ff987e..ef7d67670 100644 --- a/discord/components.py +++ b/discord/components.py @@ -967,7 +967,7 @@ class MediaGalleryItem: def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: media = data['media'] self = cls( - media=media['url'], + media=UnfurledMediaItem._from_data(media, state), description=data.get('description'), spoiler=data.get('spoiler', False), ) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4101eb2dd..1df526cba 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -45,7 +45,8 @@ from typing import ( from .item import Item, ItemCallbackType from .button import Button from .dynamic import DynamicItem -from .select import select as _select, Select, SelectCallbackDecorator, UserSelect, RoleSelect, ChannelSelect, MentionableSelect +from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect +from ..components import ActionRow as ActionRowComponent from ..enums import ButtonStyle, ComponentType, ChannelType from ..partial_emoji import PartialEmoji from ..utils import MISSING @@ -61,7 +62,8 @@ if TYPE_CHECKING: ChannelSelectT, RoleSelectT, UserSelectT, - SelectT + SelectT, + SelectCallbackDecorator, ) from ..emoji import Emoji from ..components import SelectOption @@ -125,7 +127,7 @@ class ActionRow(Item[V]): for func in self.__action_row_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ActionRowCallback(func, self, item) + item.callback = _ActionRowCallback(func, self, item) # type: ignore item._parent = getattr(func, '__discord_ui_parent__', self) # type: ignore setattr(self, func.__name__, item) children.append(item) @@ -478,3 +480,11 @@ class ActionRow(Item[V]): return r return decorator # type: ignore + + @classmethod + def from_component(cls, component: ActionRowComponent) -> ActionRow: + from .view import _component_to_item + self = cls() + for cmp in component.children: + self.add_item(_component_to_item(cmp)) + return self diff --git a/discord/ui/button.py b/discord/ui/button.py index f15910eff..df21c770f 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -42,12 +42,12 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import BaseView from .action_row import ActionRow from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Button(Item[V]): @@ -147,6 +147,7 @@ class Button(Item[V]): ) self._parent: Optional[ActionRow] = None self.row = row + self.id = custom_id @property def style(self) -> ButtonStyle: diff --git a/discord/ui/container.py b/discord/ui/container.py index 2acf95d20..1b50eceb9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -26,7 +26,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, TypeVar from .item import Item -from .view import View, _component_to_item +from .view import View, _component_to_item, LayoutView from .dynamic import DynamicItem from ..enums import ComponentType from ..utils import MISSING @@ -37,7 +37,7 @@ if TYPE_CHECKING: from ..colour import Colour, Color from ..components import Container as ContainerComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) @@ -69,6 +69,8 @@ class Container(View, 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`] + The ID of this component. This must be unique across the view. """ __discord_ui_container__ = True @@ -82,6 +84,7 @@ class Container(View, Item[V]): spoiler: bool = False, timeout: Optional[float] = 180, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__(timeout=timeout) if children is not MISSING: @@ -95,6 +98,7 @@ class Container(View, Item[V]): self._row: Optional[int] = None self._rendered_row: Optional[int] = None self.row: Optional[int] = row + self.id: Optional[str] = id @property def children(self) -> List[Item[Self]]: diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 0b65e90f3..ee3ad30d5 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,14 @@ if TYPE_CHECKING: from ..interactions import Interaction from ..components import Component from ..enums import ComponentType - from .view import View + from .view import BaseView - V = TypeVar('V', bound='View', covariant=True, default=View) + V = TypeVar('V', bound='BaseView', covariant=True, default=BaseView) else: - V = TypeVar('V', bound='View', covariant=True) + V = TypeVar('V', bound='BaseView', covariant=True) -class DynamicItem(Generic[BaseT], Item['View']): +class DynamicItem(Generic[BaseT], Item['BaseView']): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. diff --git a/discord/ui/file.py b/discord/ui/file.py index 3ff6c7d0f..2654d351c 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -32,9 +32,9 @@ from ..enums import ComponentType if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('File',) @@ -59,6 +59,8 @@ 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`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -67,6 +69,7 @@ class File(Item[V]): *, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._underlying = FileComponent._raw_construct( @@ -75,6 +78,7 @@ class File(Item[V]): ) self.row = row + self.id = id def _is_v2(self): return True diff --git a/discord/ui/item.py b/discord/ui/item.py index bbd90464a..1fa68b68c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -37,11 +37,11 @@ __all__ = ( if TYPE_CHECKING: from ..enums import ComponentType - from .view import View + from .view import BaseView from ..components import Component I = TypeVar('I', bound='Item[Any]') -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -70,6 +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._max_row: int = 5 if not self._is_v2() else 10 def to_component_dict(self) -> Dict[str, Any]: @@ -124,6 +125,17 @@ class Item(Generic[V]): """Optional[:class:`View`]: The underlying view for this item.""" 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``. + """ + return self._id + + @id.setter + def id(self, value: Optional[str]) -> None: + self._id = value + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 4bc6c826f..f9e1fb264 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -35,9 +35,9 @@ from ..components import ( if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('MediaGallery',) @@ -60,9 +60,17 @@ 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`] + The ID of this component. This must be unique across the view. """ - def __init__(self, items: List[MediaGalleryItem], *, row: Optional[int] = None) -> None: + def __init__( + self, + items: List[MediaGalleryItem], + *, + row: Optional[int] = None, + id: Optional[str] = None, + ) -> None: super().__init__() self._underlying = MediaGalleryComponent._raw_construct( @@ -70,6 +78,7 @@ class MediaGallery(Item[V]): ) self.row = row + self.id = id @property def items(self) -> List[MediaGalleryItem]: diff --git a/discord/ui/section.py b/discord/ui/section.py index 5a0ec7f27..ba919beb8 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -33,10 +33,10 @@ from ..utils import MISSING if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView from ..components import SectionComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Section',) @@ -59,6 +59,8 @@ 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`] + The ID of this component. This must be unique across the view. """ __slots__ = ( @@ -72,6 +74,7 @@ class Section(Item[V]): *, accessory: Item[Any], row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._children: List[Item[Any]] = [] @@ -84,6 +87,7 @@ class Section(Item[V]): self.accessory: Item[Any] = accessory self.row = row + self.id = id @property def type(self) -> Literal[ComponentType.section]: diff --git a/discord/ui/select.py b/discord/ui/select.py index b2534e146..f5a9fcbee 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -72,7 +72,7 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import TypeAlias, TypeGuard - from .view import View + from .view import BaseView from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData @@ -102,7 +102,7 @@ if TYPE_CHECKING: Thread, ] -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') SelectT = TypeVar('SelectT', bound='Select[Any]') UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]') @@ -259,6 +259,7 @@ class BaseSelect(Item[V]): ) self.row = row + self.id = custom_id if custom_id is not MISSING else None self._parent: Optional[ActionRow] = None self._values: List[PossibleValue] = [] diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 33401f880..b9ff955ad 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -32,9 +32,9 @@ from ..enums import SeparatorSize, ComponentType if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Separator',) @@ -58,6 +58,8 @@ 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`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -66,6 +68,7 @@ class Separator(Item[V]): visible: bool = True, spacing: SeparatorSize = SeparatorSize.small, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() self._underlying = SeparatorComponent._raw_construct( @@ -74,6 +77,7 @@ class Separator(Item[V]): ) self.row = row + self.id = id def _is_v2(self): return True diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 1bf88678d..e55c72ba4 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -32,9 +32,9 @@ from ..enums import ComponentType if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('TextDisplay',) @@ -55,13 +55,16 @@ 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`] + The ID of this component. This must be unique across the view. """ - def __init__(self, content: str, *, row: Optional[int] = None) -> None: + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[str] = None) -> None: super().__init__() self.content: str = content self.row = row + self.id = id def to_component_dict(self): return { diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index cf9bfd3cc..0e7def382 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -32,10 +32,10 @@ from ..components import UnfurledMediaItem if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import LayoutView from ..components import ThumbnailComponent -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Thumbnail',) @@ -62,6 +62,8 @@ 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`] + The ID of this component. This must be unique across the view. """ def __init__( @@ -71,6 +73,7 @@ class Thumbnail(Item[V]): description: Optional[str] = None, spoiler: bool = False, row: Optional[int] = None, + id: Optional[str] = None, ) -> None: super().__init__() @@ -79,6 +82,7 @@ class Thumbnail(Item[V]): self.spoiler: bool = spoiler self.row = row + self.id = id @property def width(self): diff --git a/discord/ui/view.py b/discord/ui/view.py index 9ea612aeb..c63ac00e7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,6 +36,7 @@ from typing import ( TYPE_CHECKING, Tuple, Type, + Union, ) from functools import partial from itertools import groupby @@ -46,7 +47,6 @@ import sys import time import os from .item import Item, ItemCallbackType -from .action_row import ActionRow from .dynamic import DynamicItem from ..components import ( Component, @@ -61,10 +61,12 @@ from ..components import ( SeparatorComponent, ThumbnailComponent, ) +from ..utils import get as _utils_get # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on @@ -80,6 +82,8 @@ if TYPE_CHECKING: from ..state import ConnectionState from .modal import Modal + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -188,57 +192,18 @@ class _ViewCallback: return self.callback(self.view, interaction, self.item) -class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ----------- - 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. - """ - - __discord_ui_view__: ClassVar[bool] = True +class BaseView: + __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False __discord_ui_container__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] - - def __init_subclass__(cls) -> None: - super().__init_subclass__() - - children: Dict[str, ItemCallbackType[Any, Any]] = {} - for base in reversed(cls.__mro__): - for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__'): - children[name] = member - - if len(children) > 25: - raise TypeError('View cannot have more than 25 children') - - cls.__view_children_items__ = list(children.values()) - - def _init_children(self) -> List[Item[Self]]: - children = [] - - for func in self.__view_children_items__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - setattr(self, func.__name__, item) - children.append(item) - return children + __view_children_items__: ClassVar[List[ItemLike]] = [] - def __init__(self, *, timeout: Optional[float] = 180.0): + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None - self.__cancel_callback: Optional[Callable[[View], None]] = None + self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() @@ -246,12 +211,32 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? def _is_v2(self) -> bool: return False - @property - def width(self): - return 5 - def __repr__(self) -> str: - return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' + return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}' + + def _init_children(self) -> List[Item[Self]]: + children = [] + + for raw in self.__view_children_items__: + if isinstance(raw, Item): + raw._view = self + parent = getattr(raw, '__discord_ui_parent__', None) + if parent and parent._view is None: + parent._view = self + item = raw + else: + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(raw, self, item) # type: ignore + item._view = self + setattr(self, raw.__name__, item) + parent = getattr(raw, '__discord_ui_parent__', None) + if parent: + if not self._is_v2(): + raise RuntimeError('This view cannot have v2 items') + parent._children.append(item) + children.append(item) + + return children async def __timeout_task_impl(self) -> None: while True: @@ -279,24 +264,7 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? return any(c._is_v2() for c in self.children) def to_components(self) -> List[Dict[str, Any]]: - def key(item: Item) -> 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 + return NotImplemented def _refresh_timeout(self) -> None: if self.__timeout: @@ -327,7 +295,7 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? return self._children.copy() @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Any: """Converts a message's components into a :class:`View`. The :attr:`.Message.components` of a message are read-only @@ -341,28 +309,8 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? 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) - row = 0 - for component in message.components: - if isinstance(component, ActionRowComponent): - for child in component.children: - item = _component_to_item(child) - item.row = row - view.add_item(item) - row += 1 - else: - item = _component_to_item(component) - item.row = row - view.add_item(item) - - return view + pass def add_item(self, item: Item[Any]) -> Self: """Adds an item to the view. @@ -385,18 +333,10 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? you tried to add is not allowed in this View. """ - if len(self._children) >= 25: - raise ValueError('maximum number of children exceeded') - if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._is_v2() and not self._is_v2(): - raise ValueError( - 'The item can only be added on LayoutView' - ) - - self.__weights.add_item(item) + raise ValueError('v2 items cannot be added to this view') item._view = self self._children.append(item) @@ -418,8 +358,6 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? self._children.remove(item) except ValueError: pass - else: - self.__weights.remove_item(item) return self def clear_items(self) -> Self: @@ -429,9 +367,30 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? chaining. """ self._children.clear() - self.__weights.clear() return self + def get_item_by_id(self, id: str, /) -> Optional[Item[Self]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: :class:`str` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self._children, id=id) + async def interaction_check(self, interaction: Interaction, /) -> bool: """|coro| @@ -599,61 +558,167 @@ class View: # NOTE: maybe add a deprecation warning in favour of LayoutView? return await self.__stopped -class LayoutView(View): - __view_children_items__: ClassVar[List[Item[Any]]] = [] - __view_pending_children__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] +class View(BaseView): # NOTE: maybe add a deprecation warning in favour of LayoutView? + """Represents a UI view. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + Parameters + ----------- + 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. + """ + + __discord_ui_view__: ClassVar[bool] = True + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemCallbackType[Any, Any]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + + if len(children) > 25: + raise TypeError('View cannot have more than 25 children') + + cls.__view_children_items__ = list(children.values()) + + def __init__(self, *, timeout: Optional[float] = 180.0): + super().__init__(timeout=timeout) + self.__weights = _ViewWeights(self._children) + + @property + def width(self): + return 5 + + def to_components(self) -> List[Dict[str, Any]]: + def key(item: Item) -> 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 + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 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:`discord.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) + row = 0 + for component in message.components: + if isinstance(component, ActionRowComponent): + for child in component.children: + item = _component_to_item(child) + item.row = row + if item._is_v2(): + raise RuntimeError('v2 components cannot be added to this View') + view.add_item(item) + row += 1 + else: + item = _component_to_item(component) + item.row = row + if item._is_v2(): + raise RuntimeError('v2 components cannot be added to this View') + view.add_item(item) + + return view - def __init__(self, *, timeout: Optional[float] = 180) -> None: + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 25: + raise ValueError('maximum number of children exceeded') + + super().add_item(item) + try: + self.__weights.add_item(item) + except ValueError as e: + # if the item has no space left then remove it from _children + self._children.remove(item) + raise e + + return self + + def remove_item(self, item: Item[Any]) -> Self: + try: + self._children.remove(item) + except ValueError: + pass + else: + self.__weights.remove_item(item) + return self + + def clear_items(self) -> Self: + super().clear_items() + self.__weights.clear() + return self + + +class LayoutView(BaseView): + """Represents a layout view for components v2. + + Unline :class:`View` this allows for components v2 to exist + within it. + + .. versionadded:: 2.6 + + Parameters + ---------- + 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, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) - self.__weights.weights.extend([0, 0, 0, 0, 0]) def __init_subclass__(cls) -> None: children: Dict[str, Item[Any]] = {} - pending: Dict[str, ItemCallbackType[Any, Any]] = {} for base in reversed(cls.__mro__): for name, member in base.__dict__.items(): if isinstance(member, Item): children[name] = member elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): - pending[name] = member + children[name] = member if len(children) > 10: raise TypeError('LayoutView cannot have more than 10 top-level children') cls.__view_children_items__ = list(children.values()) - cls.__view_pending_children__ = list(pending.values()) - - def _init_children(self) -> List[Item[Self]]: - children = [] - - for func in self.__view_pending_children__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) - item._view = self - setattr(self, func.__name__, item) - parent: ActionRow = func.__discord_ui_parent__ - parent.add_item(item) - - for i in self.__view_children_items__: - if isinstance(i, Item): - if getattr(i, '_parent', None): - # this is for ActionRows which have decorators such as - # @action_row.button and @action_row.select that will convert - # those callbacks into their types but will have a _parent - # attribute which is checked here so the item is not added twice - continue - i._view = self - if getattr(i, '__discord_ui_action_row__', False): - i._update_children_view(self) # type: ignore - children.append(i) - else: - # guard just in case - raise TypeError( - 'LayoutView can only have items' - ) - - return children def _is_v2(self) -> bool: return True @@ -670,11 +735,49 @@ class LayoutView(View): return child + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 10: + raise ValueError('maximum number of children exceeded') + super().add_item(item) + return self + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: + """Converts a message's components into a :class:`LayoutView`. + + 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:`LayoutView` first. + + Unlike :meth:`View.from_message` this works for + + Parameters + ----------- + message: :class:`discord.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + -------- + :class:`LayoutView` + The converted view. This always returns a :class:`LayoutView` and not + one of its subclasses. + """ + view = LayoutView(timeout=timeout) + for component in message.components: + item = _component_to_item(component) + item.row = 0 + view.add_item(item) + + return view + class ViewStore: def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item} - self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[View]]] = {} + self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[BaseView]]] = {} # message_id: View self._synced_message_views: Dict[int, View] = {} # custom_id: Modal @@ -684,7 +787,7 @@ class ViewStore: self._state: ConnectionState = state @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: # fmt: off views = { item.view.id: item.view @@ -722,7 +825,7 @@ class ViewStore: is_fully_dynamic = item._update_store_data( # type: ignore dispatch_info, self._dynamic_items, - ) + ) or is_fully_dynamic elif getattr(item, '__discord_ui_action_row__', False): is_fully_dynamic = item._update_store_data( # type: ignore dispatch_info, @@ -784,7 +887,7 @@ class ViewStore: return # Swap the item in the view with our new dynamic item - view._children[base_item_index] = item + view._children[base_item_index] = item # type: ignore item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore @@ -826,7 +929,7 @@ class ViewStore: key = (component_type, custom_id) # The entity_id can either be message_id, interaction_id, or None in that priority order. - item: Optional[Item[View]] = None + item: Optional[Item[BaseView]] = None if message_id is not None: item = self._views.get(message_id, {}).get(key) @@ -878,7 +981,7 @@ class ViewStore: def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views - def remove_message_tracking(self, message_id: int) -> Optional[View]: + def remove_message_tracking(self, message_id: int) -> Optional[BaseView]: return self._synced_message_views.pop(message_id, None) def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None: