Add support for components V2

Co-authored-by: Michael H <michael@michaelhall.tech>
Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com>
Co-authored-by: owocado <24418520+owocado@users.noreply.github.com>
Co-authored-by: Jay3332 <40323796+jay3332@users.noreply.github.com>
Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com>
This commit is contained in:
DA344
2025-08-14 02:37:23 +02:00
committed by GitHub
parent 6ec2e5329b
commit 50caa3c82c
33 changed files with 4214 additions and 298 deletions

View File

@ -16,3 +16,11 @@ from .button import *
from .select import *
from .text_input import *
from .dynamic import *
from .container import *
from .file import *
from .media_gallery import *
from .section import *
from .separator import *
from .text_display import *
from .thumbnail import *
from .action_row import *

585
discord/ui/action_row.py Normal file
View File

@ -0,0 +1,585 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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,
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
List,
Literal,
Optional,
Sequence,
Type,
TypeVar,
Union,
overload,
)
from .item import I, Item
from .button import Button, button as _button
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, get as _utils_get
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
from .select import (
BaseSelectT,
ValidDefaultValues,
MentionableSelectT,
ChannelSelectT,
RoleSelectT,
UserSelectT,
SelectT,
)
from ..emoji import Emoji
from ..components import SelectOption
from ..interactions import Interaction
ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]]
SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT]
S = TypeVar('S', bound='ActionRow', covariant=True)
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('ActionRow',)
class _ActionRowCallback:
__slots__ = ('row', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[S, Any], row: ActionRow, item: Item[Any]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.row: ActionRow = row
self.item: Item[Any] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.row, interaction, self.item)
class ActionRow(Item[V]):
r"""Represents a UI action row.
This is a top-level layout component that can only be used on :class:`LayoutView`
and can contain :class:`Button`\s and :class:`Select`\s in it.
Action rows can only have 5 children. This can be inherited.
.. versionadded:: 2.6
Examples
--------
.. code-block:: python3
import discord
from discord import ui
# you can subclass it and add components with the decorators
class MyActionRow(ui.ActionRow):
@ui.button(label='Click Me!')
async def click_me(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_message('You clicked me!')
# or use it directly on LayoutView
class MyView(ui.LayoutView):
row = ui.ActionRow()
# or you can use your subclass:
# row = MyActionRow()
# you can add items with row.button and row.select
@row.button(label='A button!')
async def row_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_message('You clicked a button!')
Parameters
----------
\*children: :class:`Item`
The initial children of this action row.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__action_row_children_items__: ClassVar[List[ItemCallbackType[Self, Any]]] = []
__discord_ui_action_row__: ClassVar[bool] = True
__item_repr_attributes__ = ('id',)
def __init__(
self,
*children: Item[V],
id: Optional[int] = None,
) -> None:
super().__init__()
self._children: List[Item[V]] = self._init_children()
self._children.extend(children)
self._weight: int = sum(i.width for i in self._children)
if self._weight > 5:
raise ValueError('maximum number of children exceeded')
self.id = id
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemCallbackType[Self, 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) > 5:
raise TypeError('ActionRow cannot have more than 5 children')
cls.__action_row_children_items__ = list(children.values())
def __repr__(self) -> str:
return f'<{self.__class__.__name__} children={len(self._children)}>'
def _init_children(self) -> List[Item[Any]]:
children = []
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) # type: ignore
item._parent = getattr(func, '__discord_ui_parent__', self)
setattr(self, func.__name__, item)
children.append(item)
return children
def _update_view(self, view) -> None:
self._view = view
for child in self._children:
child._view = view
def _has_children(self):
return True
def _is_v2(self) -> bool:
# although it is not really a v2 component the only usecase here is for
# LayoutView which basically represents the top-level payload of components
# and ActionRow is only allowed there anyways.
# If the user tries to add any V2 component to a View instead of LayoutView
# it should error anyways.
return True
@property
def width(self):
return 5
@property
def type(self) -> Literal[ComponentType.action_row]:
return ComponentType.action_row
@property
def children(self) -> List[Item[V]]:
"""List[:class:`Item`]: The list of children attached to this action row."""
return self._children.copy()
def walk_children(self) -> Generator[Item[V], Any, None]:
"""An iterator that recursively walks through all the children of this action row
and its children, if applicable.
Yields
------
:class:`Item`
An item in the action row.
"""
for child in self.children:
yield child
def add_item(self, item: Item[Any]) -> Self:
"""Adds an item to this action row.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`Item`
The item to add to the action row.
Raises
------
TypeError
An :class:`Item` was not passed.
ValueError
Maximum number of children has been exceeded (5).
"""
if (self._weight + item.width) > 5:
raise ValueError('maximum number of children exceeded')
if len(self._children) >= 5:
raise ValueError('maximum number of children exceeded')
if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__.__name__}')
item._update_view(self.view)
item._parent = self
self._weight += 1
self._children.append(item)
if self._view:
self._view._total_children += 1
return self
def remove_item(self, item: Item[Any]) -> Self:
"""Removes an item from the action row.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`Item`
The item to remove from the action row.
"""
try:
self._children.remove(item)
except ValueError:
pass
else:
if self._view and self._view._is_layout():
self._view._total_children -= 1
self._weight -= 1
return self
def find_item(self, id: int, /) -> Optional[Item[V]]:
"""Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if
not found.
.. warning::
This is **not the same** as ``custom_id``.
Parameters
----------
id: :class:`int`
The ID of the component.
Returns
-------
Optional[:class:`Item`]
The item found, or ``None``.
"""
return _utils_get(self.walk_children(), id=id)
def clear_items(self) -> Self:
"""Removes all items from the action row.
This function returns the class instance to allow for fluent-style
chaining.
"""
if self._view and self._view._is_layout():
self._view._total_children -= len(self._children)
self._children.clear()
self._weight = 0
return self
def to_component_dict(self) -> Dict[str, Any]:
components = []
for component in self.children:
components.append(component.to_component_dict())
base = {
'type': self.type.value,
'components': components,
}
if self.id is not None:
base['id'] = self.id
return base
def button(
self,
*,
label: Optional[str] = None,
custom_id: Optional[str] = None,
disabled: bool = False,
style: ButtonStyle = ButtonStyle.secondary,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
id: Optional[int] = None,
) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]:
"""A decorator that attaches a button to the action row.
The function being decorated should have three parameters, ``self`` representing
the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and
the :class:`discord.ui.Button` being pressed.
.. note::
Buttons with a URL or a SKU cannot be created with this function.
Consider creating a :class:`Button` manually and adding it via
:meth:`ActionRow.add_item` instead. This is beacuse these buttons
cannot have a callback associated with them since Discord does not
do any processing with them.
Parameters
----------
label: Optional[:class:`str`]
The label of the button, if any.
Can only be up to 80 characters.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
It is recommended to not set this parameters to prevent conflicts.
Can only be up to 100 characters.
style: :class:`.ButtonStyle`
The style of the button. Defaults to :attr:`.ButtonStyle.grey`.
disabled: :class:`bool`
Whether the button is disabled or not. Defaults to ``False``.
emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]]
The emoji of the button. This can be in string form or a :class:`.PartialEmoji`
or a full :class:`.Emoji`.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
.. versionadded:: 2.6
"""
def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]:
ret = _button(
label=label,
custom_id=custom_id,
disabled=disabled,
style=style,
emoji=emoji,
row=None,
id=id,
)(func)
ret.__discord_ui_parent__ = self # type: ignore
return ret # type: ignore
return decorator # type: ignore
@overload
def select(
self,
*,
cls: Type[SelectT] = Select[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = ...,
placeholder: Optional[str] = ...,
custom_id: str = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, SelectT]:
...
@overload
def select(
self,
*,
cls: Type[UserSelectT] = UserSelect[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = ...,
placeholder: Optional[str] = ...,
custom_id: str = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, UserSelectT]:
...
@overload
def select(
self,
*,
cls: Type[RoleSelectT] = RoleSelect[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = ...,
placeholder: Optional[str] = ...,
custom_id: str = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, RoleSelectT]:
...
@overload
def select(
self,
*,
cls: Type[ChannelSelectT] = ChannelSelect[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = ...,
placeholder: Optional[str] = ...,
custom_id: str = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, ChannelSelectT]:
...
@overload
def select(
self,
*,
cls: Type[MentionableSelectT] = MentionableSelect[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING,
placeholder: Optional[str] = ...,
custom_id: str = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, MentionableSelectT]:
...
def select(
self,
*,
cls: Type[BaseSelectT] = Select[Any],
options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING,
placeholder: Optional[str] = None,
custom_id: str = MISSING,
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
default_values: Sequence[ValidDefaultValues] = MISSING,
id: Optional[int] = None,
) -> SelectCallbackDecorator[S, BaseSelectT]:
"""A decorator that attaches a select menu to the action row.
The function being decorated should have three parameters, ``self`` representing
the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and
the chosen select class.
To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values
will depend on the type of select menu used. View the table below for more information.
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
| Select Type | Resolved Values |
+========================================+=================================================================================================================+
| :class:`discord.ui.Select` | List[:class:`str`] |
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
| :class:`discord.ui.UserSelect` | List[Union[:class:`discord.Member`, :class:`discord.User`]] |
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
| :class:`discord.ui.RoleSelect` | List[:class:`discord.Role`] |
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
| :class:`discord.ui.MentionableSelect` | List[Union[:class:`discord.Role`, :class:`discord.Member`, :class:`discord.User`]] |
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
| :class:`discord.ui.ChannelSelect` | List[Union[:class:`~discord.app_commands.AppCommandChannel`, :class:`~discord.app_commands.AppCommandThread`]] |
+----------------------------------------+-----------------------------------------------------------------------------------------------------------------+
Example
---------
.. code-block:: python3
class MyView(discord.ui.LayoutView):
action_row = discord.ui.ActionRow()
@action_row.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text])
async def select_channels(self, interaction: discord.Interaction, select: ChannelSelect):
return await interaction.response.send_message(f'You selected {select.values[0].mention}')
Parameters
------------
cls: Union[Type[:class:`discord.ui.Select`], Type[:class:`discord.ui.UserSelect`], Type[:class:`discord.ui.RoleSelect`], \
Type[:class:`discord.ui.MentionableSelect`], Type[:class:`discord.ui.ChannelSelect`]]
The class to use for the select menu. Defaults to :class:`discord.ui.Select`. You can use other
select types to display different select menus to the user. See the table above for the different
values you can get from each select type. Subclasses work as well, however the callback in the subclass will
get overridden.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
Can only be up to 150 characters.
custom_id: :class:`str`
The ID of the select menu that gets received during an interaction.
It is recommended not to set this parameter to prevent conflicts.
Can only be up to 100 characters.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 0 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. This can only be used with
:class:`Select` instances.
Can only contain up to 25 items.
channel_types: List[:class:`~discord.ChannelType`]
The types of channels to show in the select menu. Defaults to all channels. This can only be used
with :class:`ChannelSelect` instances.
disabled: :class:`bool`
Whether the select is disabled or not. Defaults to ``False``.
default_values: Sequence[:class:`~discord.abc.Snowflake`]
A list of objects representing the default values for the select menu. This cannot be used with regular :class:`Select` instances.
If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor.
Number of items must be in range of ``min_values`` and ``max_values``.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
.. versionadded:: 2.6
"""
def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]:
r = _select( # type: ignore
cls=cls, # type: ignore
placeholder=placeholder,
custom_id=custom_id,
min_values=min_values,
max_values=max_values,
options=options,
channel_types=channel_types,
disabled=disabled,
default_values=default_values,
id=id,
)(func)
r.__discord_ui_parent__ = self
return r
return decorator # type: ignore
@classmethod
def from_component(cls, component: ActionRowComponent) -> ActionRow:
from .view import _component_to_item
self = cls(id=component.id)
for cmp in component.children:
self.add_item(_component_to_item(cmp, self))
return self

View File

@ -24,12 +24,13 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
import copy
from typing import Any, Callable, Coroutine, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
import inspect
import os
from .item import Item, ItemCallbackType
from .item import Item, I
from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji, _EmojiTag
from ..components import Button as ButtonComponent
@ -42,11 +43,16 @@ __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 ..interactions import Interaction
from ..types.components import ButtonComponent as ButtonComponentPayload
V = TypeVar('V', bound='View', covariant=True)
ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]]
S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True)
V = TypeVar('V', bound='BaseView', covariant=True)
class Button(Item[V]):
@ -77,11 +83,19 @@ class Button(Item[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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
sku_id: Optional[:class:`int`]
The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji``
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, ...] = (
@ -92,6 +106,7 @@ class Button(Item[V]):
'emoji',
'row',
'sku_id',
'id',
)
def __init__(
@ -105,6 +120,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):
@ -143,9 +159,19 @@ class Button(Item[V]):
style=style,
emoji=emoji,
sku_id=sku_id,
id=id,
)
self.row = row
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this button."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def style(self) -> ButtonStyle:
""":class:`discord.ButtonStyle`: The style of the button."""
@ -242,6 +268,7 @@ class Button(Item[V]):
emoji=button.emoji,
row=None,
sku_id=button.sku_id,
id=button.id,
)
@property
@ -262,6 +289,28 @@ class Button(Item[V]):
def _refresh_component(self, button: ButtonComponent) -> None:
self._underlying = button
def copy(self) -> Self:
new = copy.copy(self)
custom_id = self.custom_id
if self.custom_id is not None and not self._provided_custom_id:
custom_id = os.urandom(16).hex()
new._underlying = ButtonComponent._raw_construct(
custom_id=custom_id,
url=self.url,
disabled=self.disabled,
label=self.label,
style=self.style,
emoji=self.emoji,
sku_id=self.sku_id,
id=self.id,
)
return new
def __deepcopy__(self, memo) -> Self:
return self.copy()
def button(
*,
@ -271,7 +320,8 @@ def button(
style: ButtonStyle = ButtonStyle.secondary,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
row: Optional[int] = None,
) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]:
id: Optional[int] = None,
) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]:
"""A decorator that attaches a button to a component.
The function being decorated should have three parameters, ``self`` representing
@ -308,9 +358,17 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
.. versionadded:: 2.6
"""
def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]:
def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]:
if not inspect.iscoroutinefunction(func):
raise TypeError('button function must be a coroutine function')
@ -324,6 +382,7 @@ def button(
'emoji': emoji,
'row': row,
'sku_id': None,
'id': id,
}
return func

369
discord/ui/container.py Normal file
View File

@ -0,0 +1,369 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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 copy
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
List,
Literal,
Optional,
TypeVar,
Union,
)
from .item import Item, I
from .view import _component_to_item, LayoutView
from ..enums import ComponentType
from ..utils import get as _utils_get
from ..colour import Colour, Color
if TYPE_CHECKING:
from typing_extensions import Self
from ..components import Container as ContainerComponent
from ..interactions import Interaction
ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]]
S = TypeVar('S', bound='Container', covariant=True)
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Container',)
class _ContainerCallback:
__slots__ = ('container', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[S, 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]):
r"""Represents a UI container.
This is a top-level layout component that can only be used on :class:`LayoutView`
and can contain :class:`ActionRow`\s, :class:`TextDisplay`\s, :class:`Section`\s,
:class:`MediaGallery`\s, :class:`File`\s, and :class:`Separator`\s in it.
This can be inherited.
.. versionadded:: 2.6
Examples
--------
.. code-block:: python3
import discord
from discord import ui
# you can subclass it and add components as you would add them
# in a LayoutView
class MyContainer(ui.Container):
action_row = ui.ActionRow()
@action_row.button(label='A button in a container!')
async def a_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_message('You clicked a button!')
# or use it directly on LayoutView
class MyView(ui.LayoutView):
container = ui.Container(ui.TextDisplay('I am a text display on a container!'))
# or you can use your subclass:
# container = MyContainer()
Parameters
----------
\*children: :class:`Item`
The initial children of this container.
accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]]
The colour of the container. Defaults to ``None``.
accent_color: Optional[Union[:class:`.Colour`, :class:`int`]]
The color of the container. Defaults to ``None``.
spoiler: :class:`bool`
Whether to flag this container as a spoiler. Defaults
to ``False``.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Self, Any], Item[Any]]]] = {}
__discord_ui_container__: ClassVar[bool] = True
__item_repr_attributes__ = (
'accent_colour',
'spoiler',
'id',
)
def __init__(
self,
*children: Item[V],
accent_colour: Optional[Union[Colour, int]] = None,
accent_color: Optional[Union[Color, int]] = None,
spoiler: bool = False,
id: Optional[int] = None,
) -> None:
super().__init__()
self._children: List[Item[V]] = self._init_children()
for child in children:
self.add_item(child)
self.spoiler: bool = spoiler
self._colour = accent_colour if accent_colour is not None else accent_color
self.id = id
def __repr__(self) -> str:
return f'<{self.__class__.__name__} children={len(self._children)}>'
def _init_children(self) -> List[Item[Any]]:
children = []
parents = {}
for name, raw in self.__container_children_items__.items():
if isinstance(raw, Item):
item = raw.copy()
item._parent = self
setattr(self, name, item)
children.append(item)
parents[raw] = item
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 ValueError(f'{raw.__name__} is not a valid item for a Container')
parents.get(parent, parent)._children.append(item)
# we do not 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[Self, 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 getattr(member, '__discord_ui_parent__', None):
children[name] = copy.copy(member)
cls.__container_children_items__ = children
def _update_view(self, view) -> bool:
self._view = view
for child in self._children:
child._update_view(view)
return True
def _has_children(self):
return True
@property
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[V]]) -> None:
self._children = value
@property
def accent_colour(self) -> Optional[Union[Colour, int]]:
"""Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``."""
return self._colour
@accent_colour.setter
def accent_colour(self, value: Optional[Union[Colour, int]]) -> None:
if value is not None and not isinstance(value, (int, Colour)):
raise TypeError(f'expected an int, or Colour, not {value.__class__.__name__!r}')
self._colour = value
accent_color = accent_colour
@property
def type(self) -> Literal[ComponentType.container]:
return ComponentType.container
@property
def width(self):
return 5
def _is_v2(self) -> bool:
return True
def to_components(self) -> List[Dict[str, Any]]:
components = []
for i in self._children:
components.append(i.to_component_dict())
return components
def to_component_dict(self) -> Dict[str, Any]:
components = self.to_components()
colour = None
if self._colour:
colour = self._colour if isinstance(self._colour, int) else self._colour.value
base = {
'type': self.type.value,
'accent_color': colour,
'spoiler': self.spoiler,
'components': components,
}
if self.id is not None:
base['id'] = self.id
return base
@classmethod
def from_component(cls, component: ContainerComponent) -> Self:
self = cls(
accent_colour=component.accent_colour,
spoiler=component.spoiler,
id=component.id,
)
self._children = [_component_to_item(cmp, self) for cmp in component.children]
return self
def walk_children(self) -> Generator[Item[V], None, None]:
"""An iterator that recursively walks through all the children of this container
and its children, if applicable.
Yields
------
:class:`Item`
An item in the container.
"""
for child in self.children:
yield child
if child._has_children():
yield from child.walk_children() # type: ignore
def add_item(self, item: Item[Any]) -> Self:
"""Adds an item to this container.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`Item`
The item to append.
Raises
------
TypeError
An :class:`Item` was not passed.
"""
if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__.__name__}')
self._children.append(item)
item._update_view(self.view)
item._parent = self
if item._has_children() and self._view:
self._view._total_children += len(tuple(item.walk_children())) # type: ignore
elif self._view:
self._view._total_children += 1
return self
def remove_item(self, item: Item[Any]) -> Self:
"""Removes an item from this container.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`Item`
The item to remove from the container.
"""
try:
self._children.remove(item)
except ValueError:
pass
else:
if self._view and self._view._is_layout():
if item._has_children():
self._view._total_children -= len(tuple(item.walk_children())) # type: ignore
else:
self._view._total_children -= 1
return self
def find_item(self, id: int, /) -> Optional[Item[V]]:
"""Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if
not found.
.. warning::
This is **not the same** as ``custom_id``.
Parameters
----------
id: :class:`int`
The ID of the component.
Returns
-------
Optional[:class:`Item`]
The item found, or ``None``.
"""
return _utils_get(self.walk_children(), id=id)
def clear_items(self) -> Self:
"""Removes all the items from the container.
This function returns the class instance to allow for fluent-style
chaining.
"""
if self._view and self._view._is_layout():
self._view._total_children -= len(tuple(self.walk_children()))
self._children.clear()
return self

View File

@ -38,14 +38,12 @@ if TYPE_CHECKING:
from ..interactions import Interaction
from ..components import Component
from ..enums import ComponentType
from .view import View
V = TypeVar('V', bound='View', covariant=True, default=View)
from .view import View, LayoutView
else:
V = TypeVar('V', bound='View', covariant=True)
View = LayoutView = Any
class DynamicItem(Generic[BaseT], Item['View']):
class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]):
"""Represents an item with a dynamic ``custom_id`` that can be used to store state within
that ``custom_id``.
@ -57,9 +55,10 @@ class DynamicItem(Generic[BaseT], Item['View']):
and should not be used long term. Their only purpose is to act as a "template"
for the actual dispatched item.
When this item is generated, :attr:`view` is set to a regular :class:`View` instance
from the original message given from the interaction. This means that custom view
subclasses cannot be accessed from this item.
When this item is generated, :attr:`view` is set to a regular :class:`View` instance,
but to a :class:`LayoutView` if the component was sent with one, this is obtained from
the original message given from the interaction. This means that custom view subclasses
cannot be accessed from this item.
.. versionadded:: 2.4

146
discord/ui/file.py Normal file
View File

@ -0,0 +1,146 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, Literal, Optional, TypeVar, Union
from .item import Item
from ..components import FileComponent, UnfurledMediaItem
from ..enums import ComponentType
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('File',)
class File(Item[V]):
"""Represents a UI file component.
This is a top-level layout component that can only be used on :class:`LayoutView`.
.. versionadded:: 2.6
Example
-------
.. code-block:: python3
import discord
from discord import ui
class MyView(ui.LayoutView):
file = ui.File('attachment://file.txt')
# attachment://file.txt points to an attachment uploaded alongside this view
Parameters
----------
media: Union[:class:`str`, :class:`.UnfurledMediaItem`]
This file's media. If this is a string it must point to a local
file uploaded within the parent view of this item, and must
meet the ``attachment://<filename>`` format.
spoiler: :class:`bool`
Whether to flag this file as a spoiler. Defaults to ``False``.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__item_repr_attributes__ = (
'media',
'spoiler',
'id',
)
def __init__(
self,
media: Union[str, UnfurledMediaItem],
*,
spoiler: bool = False,
id: Optional[int] = None,
) -> None:
super().__init__()
self._underlying = FileComponent._raw_construct(
media=UnfurledMediaItem(media) if isinstance(media, str) else media,
spoiler=spoiler,
id=id,
)
self.id = id
def _is_v2(self):
return True
@property
def width(self):
return 5
@property
def type(self) -> Literal[ComponentType.file]:
return self._underlying.type
@property
def media(self) -> UnfurledMediaItem:
""":class:`.UnfurledMediaItem`: Returns this file media."""
return self._underlying.media
@media.setter
def media(self, value: Union[str, UnfurledMediaItem]) -> None:
if isinstance(value, str):
self._underlying.media = UnfurledMediaItem(value)
elif isinstance(value, UnfurledMediaItem):
self._underlying.media = value
else:
raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}')
@property
def url(self) -> str:
""":class:`str`: Returns this file's url."""
return self._underlying.media.url
@url.setter
def url(self, value: str) -> None:
self._underlying.media = UnfurledMediaItem(value)
@property
def spoiler(self) -> bool:
""":class:`bool`: Returns whether this file should be flagged as a spoiler."""
return self._underlying.spoiler
@spoiler.setter
def spoiler(self, value: bool) -> None:
self._underlying.spoiler = value
def to_component_dict(self):
return self._underlying.to_dict()
@classmethod
def from_component(cls, component: FileComponent) -> Self:
return cls(
media=component.media,
spoiler=component.spoiler,
id=component.id,
)

View File

@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import copy
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar
from ..interactions import Interaction
@ -36,12 +37,14 @@ __all__ = (
# fmt: on
if TYPE_CHECKING:
from typing_extensions import Self
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]]
@ -53,11 +56,19 @@ class Item(Generic[V]):
- :class:`discord.ui.Button`
- :class:`discord.ui.Select`
- :class:`discord.ui.TextInput`
- :class:`discord.ui.ActionRow`
- :class:`discord.ui.Container`
- :class:`discord.ui.File`
- :class:`discord.ui.MediaGallery`
- :class:`discord.ui.Section`
- :class:`discord.ui.Separator`
- :class:`discord.ui.TextDisplay`
- :class:`discord.ui.Thumbnail`
.. versionadded:: 2.0
"""
__item_repr_attributes__: Tuple[str, ...] = ('row',)
__item_repr_attributes__: Tuple[str, ...] = ('row', 'id')
def __init__(self):
self._view: Optional[V] = None
@ -70,6 +81,8 @@ 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[int] = None
self._parent: Optional[Item] = None
def to_component_dict(self) -> Dict[str, Any]:
raise NotImplementedError
@ -80,6 +93,9 @@ class Item(Generic[V]):
def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None:
return None
def _is_v2(self) -> bool:
return False
@classmethod
def from_component(cls: Type[I], component: Component) -> I:
return cls()
@ -92,7 +108,9 @@ class Item(Generic[V]):
return False
def is_persistent(self) -> bool:
return self._provided_custom_id
if self.is_dispatchable():
return self._provided_custom_id
return True
def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__)
@ -104,6 +122,10 @@ class Item(Generic[V]):
@row.setter
def row(self, value: Optional[int]) -> None:
if self._is_v2():
# row is ignored on v2 components
return
if value is None:
self._row = None
elif 5 > value >= 0:
@ -117,9 +139,45 @@ class Item(Generic[V]):
@property
def view(self) -> Optional[V]:
"""Optional[:class:`View`]: The underlying view for this item."""
"""Optional[Union[:class:`View`, :class:`LayoutView`]]: The underlying view for this item."""
return self._view
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._id
@id.setter
def id(self, value: Optional[int]) -> None:
self._id = value
@property
def parent(self) -> Optional[Item[V]]:
"""Optional[:class:`Item`]: This item's parent. Only components that can have children
can be parents. Any item that has :class:`View` as a view will have this set to `None`
since only :class:`LayoutView` component v2 items can contain "container" like items.
.. versionadded:: 2.6
"""
return self._parent
async def _run_checks(self, interaction: Interaction[ClientT]) -> bool:
can_run = await self.interaction_check(interaction)
if can_run and self._parent:
can_run = await self._parent._run_checks(interaction)
return can_run
def _update_view(self, view) -> None:
self._view = view
def copy(self) -> Self:
return copy.deepcopy(self)
def _has_children(self) -> bool:
return False
async def callback(self, interaction: Interaction[ClientT]) -> Any:
"""|coro|

260
discord/ui/media_gallery.py Normal file
View File

@ -0,0 +1,260 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, List, Literal, Optional, TypeVar, Union
from .item import Item
from ..enums import ComponentType
from ..components import (
MediaGalleryItem,
MediaGalleryComponent,
UnfurledMediaItem,
)
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('MediaGallery',)
class MediaGallery(Item[V]):
r"""Represents a UI media gallery.
Can contain up to 10 :class:`.MediaGalleryItem`\s.
This is a top-level layout component that can only be used on :class:`LayoutView`.
.. versionadded:: 2.6
Parameters
----------
\*items: :class:`.MediaGalleryItem`
The initial items of this gallery.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__item_repr_attributes__ = (
'items',
'id',
)
def __init__(
self,
*items: MediaGalleryItem,
id: Optional[int] = None,
) -> None:
super().__init__()
self._underlying = MediaGalleryComponent._raw_construct(
items=list(items),
id=id,
)
def __repr__(self) -> str:
return f'<{self.__class__.__name__} items={len(self._underlying.items)}>'
@property
def items(self) -> List[MediaGalleryItem]:
"""List[:class:`.MediaGalleryItem`]: Returns a read-only list of this gallery's items."""
return self._underlying.items.copy()
@items.setter
def items(self, value: List[MediaGalleryItem]) -> None:
if len(value) > 10:
raise ValueError('media gallery only accepts up to 10 items')
self._underlying.items = value
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this component."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
def to_component_dict(self):
return self._underlying.to_dict()
def _is_v2(self) -> bool:
return True
def add_item(
self,
*,
media: Union[str, UnfurledMediaItem],
description: Optional[str] = None,
spoiler: bool = False,
) -> Self:
"""Adds an item to this gallery.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
media: Union[:class:`str`, :class:`.UnfurledMediaItem`]
The media item data. This can be a string representing a local
file uploaded as an attachment in the message, which can be accessed
using the ``attachment://<filename>`` format, or an arbitrary url.
description: Optional[:class:`str`]
The description to show within this item. Up to 256 characters. Defaults
to ``None``.
spoiler: :class:`bool`
Whether this item should be flagged as a spoiler. Defaults to ``False``.
Raises
------
ValueError
Maximum number of items has been exceeded (10).
"""
if len(self._underlying.items) >= 10:
raise ValueError('maximum number of items has been exceeded')
item = MediaGalleryItem(media, description=description, spoiler=spoiler)
self._underlying.items.append(item)
return self
def append_item(self, item: MediaGalleryItem) -> Self:
"""Appends an item to this gallery.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`.MediaGalleryItem`
The item to add to the gallery.
Raises
------
TypeError
A :class:`.MediaGalleryItem` was not passed.
ValueError
Maximum number of items has been exceeded (10).
"""
if len(self._underlying.items) >= 10:
raise ValueError('maximum number of items has been exceeded')
if not isinstance(item, MediaGalleryItem):
raise TypeError(f'expected MediaGalleryItem, not {item.__class__.__name__!r}')
self._underlying.items.append(item)
return self
def insert_item_at(
self,
index: int,
*,
media: Union[str, UnfurledMediaItem],
description: Optional[str] = None,
spoiler: bool = False,
) -> Self:
"""Inserts an item before a specified index to the media gallery.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
index: :class:`int`
The index of where to insert the field.
media: Union[:class:`str`, :class:`.UnfurledMediaItem`]
The media item data. This can be a string representing a local
file uploaded as an attachment in the message, which can be accessed
using the ``attachment://<filename>`` format, or an arbitrary url.
description: Optional[:class:`str`]
The description to show within this item. Up to 256 characters. Defaults
to ``None``.
spoiler: :class:`bool`
Whether this item should be flagged as a spoiler. Defaults to ``False``.
Raises
------
ValueError
Maximum number of items has been exceeded (10).
"""
if len(self._underlying.items) >= 10:
raise ValueError('maximum number of items has been exceeded')
item = MediaGalleryItem(
media,
description=description,
spoiler=spoiler,
)
self._underlying.items.insert(index, item)
return self
def remove_item(self, item: MediaGalleryItem) -> Self:
"""Removes an item from the gallery.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`.MediaGalleryItem`
The item to remove from the gallery.
"""
try:
self._underlying.items.remove(item)
except ValueError:
pass
return self
def clear_items(self) -> Self:
"""Removes all items from the gallery.
This function returns the class instance to allow for fluent-style
chaining.
"""
self._underlying.items.clear()
return self
@property
def type(self) -> Literal[ComponentType.media_gallery]:
return self._underlying.type
@property
def width(self):
return 5
@classmethod
def from_component(cls, component: MediaGalleryComponent) -> Self:
return cls(
*component.items,
id=component.id,
)

248
discord/ui/section.py Normal file
View File

@ -0,0 +1,248 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar
from .item import Item
from .text_display import TextDisplay
from ..enums import ComponentType
from ..utils import MISSING, get as _utils_get
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
from ..components import SectionComponent
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Section',)
class Section(Item[V]):
r"""Represents a UI section.
This is a top-level layout component that can only be used on :class:`LayoutView`.
.. versionadded:: 2.6
Parameters
----------
\*children: Union[:class:`str`, :class:`TextDisplay`]
The text displays of this section. Up to 3.
accessory: :class:`Item`
The section accessory.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__item_repr_attributes__ = (
'accessory',
'id',
)
__discord_ui_section__: ClassVar[bool] = True
__slots__ = (
'_children',
'accessory',
)
def __init__(
self,
*children: Union[Item[V], str],
accessory: Item[V],
id: Optional[int] = None,
) -> None:
super().__init__()
self._children: List[Item[V]] = []
if children:
if len(children) > 3:
raise ValueError('maximum number of children exceeded')
self._children.extend(
[c if isinstance(c, Item) else TextDisplay(c) for c in children],
)
self.accessory: Item[V] = accessory
self.id = id
def __repr__(self) -> str:
return f'<{self.__class__.__name__} children={len(self._children)}>'
@property
def type(self) -> Literal[ComponentType.section]:
return ComponentType.section
@property
def children(self) -> List[Item[V]]:
"""List[:class:`Item`]: The list of children attached to this section."""
return self._children.copy()
@property
def width(self):
return 5
def _is_v2(self) -> bool:
return True
def walk_children(self) -> Generator[Item[V], None, None]:
"""An iterator that recursively walks through all the children of this section
and its children, if applicable. This includes the `accessory`.
Yields
------
:class:`Item`
An item in this section.
"""
for child in self.children:
yield child
yield self.accessory
def _update_view(self, view) -> None:
self._view = view
self.accessory._view = view
for child in self._children:
child._view = view
def _has_children(self):
return True
def add_item(self, item: Union[str, Item[Any]]) -> Self:
"""Adds an item to this section.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: Union[:class:`str`, :class:`Item`]
The item to append, if it is a string it automatically wrapped around
:class:`TextDisplay`.
Raises
------
TypeError
An :class:`Item` or :class:`str` was not passed.
ValueError
Maximum number of children has been exceeded (3).
"""
if len(self._children) >= 3:
raise ValueError('maximum number of children exceeded')
if not isinstance(item, (Item, str)):
raise TypeError(f'expected Item or str not {item.__class__.__name__}')
item = item if isinstance(item, Item) else TextDisplay(item)
item._update_view(self.view)
item._parent = self
self._children.append(item)
if self._view:
self._view._total_children += 1
return self
def remove_item(self, item: Item[Any]) -> Self:
"""Removes an item from this section.
This function returns the class instance to allow for fluent-style
chaining.
Parameters
----------
item: :class:`Item`
The item to remove from the section.
"""
try:
self._children.remove(item)
except ValueError:
pass
else:
if self._view:
self._view._total_children -= 1
return self
def find_item(self, id: int, /) -> Optional[Item[V]]:
"""Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if
not found.
.. warning::
This is **not the same** as ``custom_id``.
Parameters
----------
id: :class:`int`
The ID of the component.
Returns
-------
Optional[:class:`Item`]
The item found, or ``None``.
"""
return _utils_get(self.walk_children(), id=id)
def clear_items(self) -> Self:
"""Removes all the items from the section.
This function returns the class instance to allow for fluent-style
chaining.
"""
if self._view and self._view._is_layout():
self._view._total_children -= len(self._children) # we don't count the accessory because it is required
self._children.clear()
return self
@classmethod
def from_component(cls, component: SectionComponent) -> Self:
from .view import _component_to_item
# using MISSING as accessory so we can create the new one with the parent set
self = cls(id=component.id, accessory=MISSING)
self.accessory = _component_to_item(component.accessory, self)
self.id = component.id
self._children = [_component_to_item(c, self) for c in component.children]
return self
def to_components(self) -> List[Dict[str, Any]]:
components = []
for component in self._children:
components.append(component.to_component_dict())
return components
def to_component_dict(self) -> Dict[str, Any]:
data = {
'type': self.type.value,
'components': self.to_components(),
'accessory': self.accessory.to_component_dict(),
}
if self.id is not None:
data['id'] = self.id
return data

View File

@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import (
Any,
Coroutine,
List,
Literal,
Optional,
@ -42,7 +43,7 @@ from contextvars import ContextVar
import inspect
import os
from .item import Item, ItemCallbackType
from .item import Item, I
from ..enums import ChannelType, ComponentType, SelectDefaultValueType
from ..partial_emoji import PartialEmoji
from ..emoji import Emoji
@ -72,7 +73,8 @@ __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
from ..app_commands import AppCommandChannel, AppCommandThread
@ -101,14 +103,17 @@ if TYPE_CHECKING:
Thread,
]
V = TypeVar('V', bound='View', covariant=True)
ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]]
S = TypeVar('S', bound='Union[BaseView, ActionRow]', 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]')
RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]')
ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]')
MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]')
SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[V, BaseSelectT]], BaseSelectT]
SelectCallbackDecorator: TypeAlias = Callable[['ItemCallbackType[S, BaseSelectT]'], BaseSelectT]
DefaultSelectComponentTypes = Literal[
ComponentType.user_select,
ComponentType.role_select,
@ -216,6 +221,7 @@ class BaseSelect(Item[V]):
'min_values',
'max_values',
'disabled',
'id',
)
__component_attributes__: Tuple[str, ...] = (
'custom_id',
@ -223,6 +229,7 @@ class BaseSelect(Item[V]):
'min_values',
'max_values',
'disabled',
'id',
)
def __init__(
@ -238,6 +245,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
@ -255,11 +263,21 @@ class BaseSelect(Item[V]):
channel_types=[] if channel_types is MISSING else channel_types,
options=[] if options is MISSING else options,
default_values=[] if default_values is MISSING else default_values,
id=id,
)
self.row = row
self._values: List[PossibleValue] = []
@property
def id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of this select."""
return self._underlying.id
@id.setter
def id(self, value: Optional[int]) -> None:
self._underlying.id = value
@property
def values(self) -> List[PossibleValue]:
values = selected_values.get({})
@ -390,6 +408,14 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
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',)
@ -404,6 +430,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,
@ -414,6 +441,7 @@ class Select(BaseSelect[V]):
disabled=disabled,
options=options,
row=row,
id=id,
)
@property
@ -545,6 +573,14 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
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',)
@ -559,6 +595,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,
@ -569,6 +606,7 @@ class UserSelect(BaseSelect[V]):
disabled=disabled,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
)
@property
@ -637,6 +675,14 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
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',)
@ -651,6 +697,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,
@ -661,6 +708,7 @@ class RoleSelect(BaseSelect[V]):
disabled=disabled,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
)
@property
@ -725,6 +773,14 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
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',)
@ -739,6 +795,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,
@ -749,6 +806,7 @@ class MentionableSelect(BaseSelect[V]):
disabled=disabled,
row=row,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
)
@property
@ -819,6 +877,14 @@ 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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
.. versionadded:: 2.6
"""
__component_attributes__ = BaseSelect.__component_attributes__ + (
@ -837,6 +903,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,
@ -848,6 +915,7 @@ class ChannelSelect(BaseSelect[V]):
row=row,
channel_types=channel_types,
default_values=_handle_select_defaults(default_values, self.type),
id=id,
)
@property
@ -899,7 +967,8 @@ def select(
max_values: int = ...,
disabled: bool = ...,
row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, SelectT]:
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, SelectT]:
...
@ -916,7 +985,8 @@ def select(
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, UserSelectT]:
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, UserSelectT]:
...
@ -933,7 +1003,8 @@ def select(
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, RoleSelectT]:
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, RoleSelectT]:
...
@ -950,7 +1021,8 @@ def select(
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, ChannelSelectT]:
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, ChannelSelectT]:
...
@ -967,7 +1039,8 @@ def select(
disabled: bool = ...,
default_values: Sequence[ValidDefaultValues] = ...,
row: Optional[int] = ...,
) -> SelectCallbackDecorator[V, MentionableSelectT]:
id: Optional[int] = ...,
) -> SelectCallbackDecorator[S, MentionableSelectT]:
...
@ -983,7 +1056,8 @@ def select(
disabled: bool = False,
default_values: Sequence[ValidDefaultValues] = MISSING,
row: Optional[int] = None,
) -> SelectCallbackDecorator[V, BaseSelectT]:
id: Optional[int] = None,
) -> SelectCallbackDecorator[S, BaseSelectT]:
"""A decorator that attaches a select menu to a component.
The function being decorated should have three parameters, ``self`` representing
@ -1041,6 +1115,10 @@ def select(
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).
.. note::
This parameter is ignored when used in a :class:`ActionRow` or v2 component.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 0 and 25.
@ -1062,9 +1140,13 @@ 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]:
def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]:
if not inspect.iscoroutinefunction(func):
raise TypeError('select function must be a coroutine function')
callback_cls = getattr(cls, '__origin__', cls)
@ -1080,6 +1162,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

124
discord/ui/separator.py Normal file
View File

@ -0,0 +1,124 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, Literal, Optional, TypeVar
from .item import Item
from ..components import SeparatorComponent
from ..enums import SeparatorSpacing, ComponentType
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Separator',)
class Separator(Item[V]):
"""Represents a UI separator.
This is a top-level layout component that can only be used on :class:`LayoutView`.
.. versionadded:: 2.6
Parameters
----------
visible: :class:`bool`
Whether this separator is visible. On the client side this
is whether a divider line should be shown or not.
spacing: :class:`.SeparatorSpacing`
The spacing of this separator.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__slots__ = ('_underlying',)
__item_repr_attributes__ = (
'visible',
'spacing',
'id',
)
def __init__(
self,
*,
visible: bool = True,
spacing: SeparatorSpacing = SeparatorSpacing.small,
id: Optional[int] = None,
) -> None:
super().__init__()
self._underlying = SeparatorComponent._raw_construct(
spacing=spacing,
visible=visible,
id=id,
)
self.id = id
def _is_v2(self):
return True
@property
def visible(self) -> bool:
""":class:`bool`: Whether this separator is visible.
On the client side this is whether a divider line should
be shown or not.
"""
return self._underlying.visible
@visible.setter
def visible(self, value: bool) -> None:
self._underlying.visible = value
@property
def spacing(self) -> SeparatorSpacing:
""":class:`.SeparatorSpacing`: The spacing of this separator."""
return self._underlying.spacing
@spacing.setter
def spacing(self, value: SeparatorSpacing) -> None:
self._underlying.spacing = value
@property
def width(self):
return 5
@property
def type(self) -> Literal[ComponentType.separator]:
return self._underlying.type
def to_component_dict(self):
return self._underlying.to_dict()
@classmethod
def from_component(cls, component: SeparatorComponent) -> Self:
return cls(
visible=component.visible,
spacing=component.spacing,
id=component.id,
)

View File

@ -0,0 +1,89 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, Literal, Optional, TypeVar
from .item import Item
from ..components import TextDisplay as TextDisplayComponent
from ..enums import ComponentType
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('TextDisplay',)
class TextDisplay(Item[V]):
"""Represents a UI text display.
This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`.
.. versionadded:: 2.6
Parameters
----------
content: :class:`str`
The content of this text display. Up to 4000 characters.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__slots__ = ('content',)
def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None:
super().__init__()
self.content: str = content
self.id = id
def to_component_dict(self):
base = {
'type': self.type.value,
'content': self.content,
}
if self.id is not None:
base['id'] = self.id
return base
@property
def width(self):
return 5
@property
def type(self) -> Literal[ComponentType.text_display]:
return ComponentType.text_display
def _is_v2(self) -> bool:
return True
@classmethod
def from_component(cls, component: TextDisplayComponent) -> Self:
return cls(
content=component.content,
id=component.id,
)

View File

@ -92,12 +92,17 @@ class TextInput(Item[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
"""
__item_repr_attributes__: Tuple[str, ...] = (
'label',
'placeholder',
'required',
'id',
)
def __init__(
@ -112,6 +117,7 @@ class TextInput(Item[V]):
min_length: Optional[int] = None,
max_length: Optional[int] = None,
row: Optional[int] = None,
id: Optional[int] = None,
) -> None:
super().__init__()
self._value: Optional[str] = default
@ -129,8 +135,10 @@ class TextInput(Item[V]):
required=required,
min_length=min_length,
max_length=max_length,
id=id,
)
self.row = row
self.id = id
def __str__(self) -> str:
return self.value
@ -241,6 +249,7 @@ class TextInput(Item[V]):
min_length=component.min_length,
max_length=component.max_length,
row=None,
id=component.id,
)
@property

132
discord/ui/thumbnail.py Normal file
View File

@ -0,0 +1,132 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
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, Any, Dict, Literal, Optional, TypeVar, Union
from .item import Item
from ..enums import ComponentType
from ..components import UnfurledMediaItem
if TYPE_CHECKING:
from typing_extensions import Self
from .view import LayoutView
from ..components import ThumbnailComponent
V = TypeVar('V', bound='LayoutView', covariant=True)
__all__ = ('Thumbnail',)
class Thumbnail(Item[V]):
"""Represents a UI Thumbnail. This currently can only be used as a :class:`Section`\'s accessory.
.. versionadded:: 2.6
Parameters
----------
media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`]
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. Up to 256 characters. Defaults to ``None``.
spoiler: :class:`bool`
Whether to flag this thumbnail as a spoiler. Defaults to ``False``.
id: Optional[:class:`int`]
The ID of this component. This must be unique across the view.
"""
__slots__ = (
'_media',
'description',
'spoiler',
)
__item_repr_attributes__ = (
'media',
'description',
'spoiler',
'row',
'id',
)
def __init__(
self,
media: Union[str, UnfurledMediaItem],
*,
description: Optional[str] = None,
spoiler: bool = False,
id: Optional[int] = None,
) -> None:
super().__init__()
self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media
self.description: Optional[str] = description
self.spoiler: bool = spoiler
self.id = id
@property
def width(self):
return 5
@property
def media(self) -> UnfurledMediaItem:
""":class:`discord.UnfurledMediaItem`: This thumbnail unfurled media data."""
return self._media
@media.setter
def media(self, value: Union[str, UnfurledMediaItem]) -> None:
if isinstance(value, str):
self._media = UnfurledMediaItem(value)
elif isinstance(value, UnfurledMediaItem):
self._media = value
else:
raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}')
@property
def type(self) -> Literal[ComponentType.thumbnail]:
return ComponentType.thumbnail
def _is_v2(self) -> bool:
return True
def to_component_dict(self) -> Dict[str, Any]:
base = {
'type': self.type.value,
'spoiler': self.spoiler,
'media': self.media.to_dict(),
'description': self.description,
}
if self.id is not None:
base['id'] = self.id
return base
@classmethod
def from_component(cls, component: ThumbnailComponent) -> Self:
return cls(
media=component.media.url,
description=component.description,
spoiler=component.spoiler,
id=component.id,
)

View File

@ -23,7 +23,23 @@ DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type
from typing import (
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generator,
Iterator,
List,
Optional,
Sequence,
TYPE_CHECKING,
Tuple,
Type,
Union,
)
from functools import partial
from itertools import groupby
@ -32,6 +48,7 @@ import logging
import sys
import time
import os
from .item import Item, ItemCallbackType
from .select import Select
from .dynamic import DynamicItem
@ -41,26 +58,37 @@ from ..components import (
_component_factory,
Button as ButtonComponent,
SelectMenu as SelectComponent,
SectionComponent,
TextDisplay as TextDisplayComponent,
MediaGalleryComponent,
FileComponent,
SeparatorComponent,
ThumbnailComponent,
Container as ContainerComponent,
)
from ..utils import get as _utils_get, find as _utils_find
# fmt: off
__all__ = (
'View',
'LayoutView',
)
# fmt: on
if TYPE_CHECKING:
from typing_extensions import Self
from typing_extensions import Self, TypeGuard
import re
from ..interactions import Interaction
from ..message import Message
from ..types.components import Component as ComponentPayload
from ..types.components import ComponentBase as ComponentBasePayload, Component as ComponentPayload
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..state import ConnectionState
from .modal import Modal
ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]]
_log = logging.getLogger(__name__)
@ -69,21 +97,61 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]:
for item in components:
if isinstance(item, ActionRowComponent):
yield from item.children
elif isinstance(item, ContainerComponent):
yield from _walk_all_components(item.children)
elif isinstance(item, SectionComponent):
yield from item.children
yield item.accessory
else:
yield item
def _component_to_item(component: Component) -> Item:
if isinstance(component, ButtonComponent):
def _component_to_item(component: Component, parent: Optional[Item] = None) -> Item:
if isinstance(component, ActionRowComponent):
from .action_row import ActionRow
item = ActionRow.from_component(component)
elif isinstance(component, ButtonComponent):
from .button import Button
return Button.from_component(component)
if isinstance(component, SelectComponent):
item = Button.from_component(component)
elif isinstance(component, SelectComponent):
from .select import BaseSelect
return BaseSelect.from_component(component)
item = BaseSelect.from_component(component)
elif isinstance(component, SectionComponent):
from .section import Section
return Item.from_component(component)
item = Section.from_component(component)
elif isinstance(component, TextDisplayComponent):
from .text_display import TextDisplay
item = TextDisplay.from_component(component)
elif isinstance(component, MediaGalleryComponent):
from .media_gallery import MediaGallery
item = MediaGallery.from_component(component)
elif isinstance(component, FileComponent):
from .file import File
item = File.from_component(component)
elif isinstance(component, SeparatorComponent):
from .separator import Separator
item = Separator.from_component(component)
elif isinstance(component, ThumbnailComponent):
from .thumbnail import Thumbnail
item = Thumbnail.from_component(component)
elif isinstance(component, ContainerComponent):
from .container import Container
item = Container.from_component(component)
else:
item = Item.from_component(component)
item._parent = parent
return item
class _ViewWeights:
@ -133,73 +201,66 @@ class _ViewWeights:
class _ViewCallback:
__slots__ = ('view', 'callback', 'item')
def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None:
def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None:
self.callback: ItemCallbackType[Any, Any] = callback
self.view: View = view
self.item: Item[View] = item
self.view: BaseView = view
self.item: Item[BaseView] = item
def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]:
return self.callback(self.view, interaction, self.item)
class View:
"""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
__view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = []
__view_children_items__: ClassVar[Dict[str, ItemLike]] = {}
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
if isinstance(item, Select):
item.options = [option.copy() for option in item.options]
setattr(self, func.__name__, item)
children.append(item)
return children
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()
self._total_children: int = len(tuple(self.walk_children()))
def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore
return False
def __repr__(self) -> str:
return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>'
def _init_children(self) -> List[Item[Self]]:
children = []
parents = {}
for name, raw in self.__view_children_items__.items():
if isinstance(raw, Item):
item = raw.copy()
setattr(self, name, item)
item._update_view(self)
parent = getattr(item, '__discord_ui_parent__', None)
if parent and parent._view is None:
parent._view = self
children.append(item)
parents[raw] = item
else:
item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__)
item.callback = _ViewCallback(raw, self, item) # type: ignore
item._view = self
if isinstance(item, Select):
item.options = [option.copy() for option in item.options]
setattr(self, raw.__name__, item)
parent = getattr(raw, '__discord_ui_parent__', None)
if parent:
parents.get(parent, parent)._children.append(item)
continue
children.append(item)
return children
async def __timeout_task_impl(self) -> None:
while True:
# Guard just in case someone changes the value of the timeout at runtime
@ -218,29 +279,16 @@ class View:
await asyncio.sleep(self.__timeout_expiry - now)
def is_dispatchable(self) -> bool:
# this is used by webhooks to check whether a view requires a state attached
# or not, this simply is, whether a view has a component other than a url button
return any(item.is_dispatchable() for item in self.children)
# checks whether any interactable items (buttons or selects) are present
# in this view, and check whether this requires a state attached in case
# of webhooks and if the view should be stored in the view store
return any(item.is_dispatchable() for item in self.walk_children())
def has_components_v2(self) -> bool:
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:
@ -271,13 +319,17 @@ class View:
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) -> Union[View, LayoutView]:
"""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.
converted into a :class:`View` or :class:`LayoutView` first.
If the message has any v2 components, then you must use
:class:`LayoutView` in order for them to be converted into
their respective items. :class:`View` does not support v2 components.
Parameters
-----------
@ -287,24 +339,43 @@ class View:
The timeout of the converted view.
Returns
--------
:class:`View`
The converted view. This always returns a :class:`View` and not
one of its subclasses.
-------
Union[:class:`View`, :class:`LayoutView`]
The converted view. This will always return one of :class:`View` or
:class:`LayoutView`, and not one of its subclasses.
"""
view = View(timeout=timeout)
if issubclass(cls, View):
view_cls = View
elif issubclass(cls, LayoutView):
view_cls = LayoutView
else:
raise TypeError('unreachable exception')
view = view_cls(timeout=timeout)
row = 0
for component in message.components:
if isinstance(component, ActionRowComponent):
if not view._is_layout() and isinstance(component, ActionRowComponent):
for child in component.children:
item = _component_to_item(child)
item.row = row
# this error should never be raised, because ActionRows can only
# contain items that View accepts, but check anyways
if item._is_v2():
raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}')
view.add_item(item)
row += 1
else:
item = _component_to_item(component)
item.row = row
view.add_item(item)
row += 1
continue
item = _component_to_item(component)
item.row = row
if item._is_v2() and not view._is_layout():
raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}')
view.add_item(item)
row += 1
return view
@ -324,19 +395,25 @@ class View:
TypeError
An :class:`Item` 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.
Maximum number of children has been exceeded, the
row the item is trying to be added to is full or the item
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_layout():
raise ValueError('v2 items cannot be added to this view')
self.__weights.add_item(item)
item._update_view(self)
added = 1
item._view = self
if item._has_children():
added += len(tuple(item.walk_children())) # type: ignore
if self._is_layout() and self._total_children + added > 40:
raise ValueError('maximum number of children exceeded')
self._total_children += added
self._children.append(item)
return self
@ -357,7 +434,15 @@ class View:
except ValueError:
pass
else:
self.__weights.remove_item(item)
removed = 1
if item._has_children():
removed += len(tuple(item.walk_children())) # type: ignore
if self._total_children - removed < 0:
self._total_children = 0
else:
self._total_children -= removed
return self
def clear_items(self) -> Self:
@ -367,9 +452,31 @@ class View:
chaining.
"""
self._children.clear()
self.__weights.clear()
self._total_children = 0
return self
def find_item(self, id: int, /) -> 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:`int`
The ID of the component.
Returns
-------
Optional[:class:`Item`]
The item found, or ``None``.
"""
return _utils_get(self.walk_children(), id=id)
async def interaction_check(self, interaction: Interaction, /) -> bool:
"""|coro|
@ -428,7 +535,7 @@ class View:
try:
item._refresh_state(interaction, interaction.data) # type: ignore
allow = await item.interaction_check(interaction) and await self.interaction_check(interaction)
allow = await item._run_checks(interaction) and await self.interaction_check(interaction)
if not allow:
return
@ -440,7 +547,7 @@ class View:
return await self.on_error(interaction, e, item)
def _start_listening_from_store(self, store: ViewStore) -> None:
self.__cancel_callback = partial(store.remove_view)
self.__cancel_callback = partial(store.remove_view) # type: ignore
if self.timeout:
if self.__timeout_task is not None:
self.__timeout_task.cancel()
@ -469,7 +576,7 @@ class View:
# fmt: off
old_state: Dict[str, Item[Any]] = {
item.custom_id: item # type: ignore
for item in self._children
for item in self.walk_children()
if item.is_dispatchable()
}
# fmt: on
@ -536,13 +643,193 @@ class View:
"""
return await self.__stopped
def walk_children(self) -> Generator[Item[Any], None, None]:
"""An iterator that recursively walks through all the children of this view
and its children, if applicable.
Yields
------
:class:`Item`
An item in the view.
"""
for child in self.children:
yield child
if child._has_children():
yield from child.walk_children() # type: ignore
class View(BaseView):
"""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
if TYPE_CHECKING:
@classmethod
def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View:
...
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View:
...
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemLike] = {}
for base in reversed(cls.__mro__):
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 ValueError(f'{name} cannot be added to this View')
if len(children) > 25:
raise TypeError('View cannot have more than 25 children')
cls.__view_children_items__ = children
def __init__(self, *, timeout: Optional[float] = 180.0):
super().__init__(timeout=timeout)
self.__weights = _ViewWeights(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
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.
This object must be inherited to create a UI within Discord.
You can find usage examples in the :resource:`repository <examples>`
.. 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.
"""
if TYPE_CHECKING:
@classmethod
def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView:
...
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView:
...
def __init__(self, *, timeout: Optional[float] = 180.0) -> None:
super().__init__(timeout=timeout)
if self._total_children > 40:
raise ValueError('maximum number of children exceeded (40)')
def __init_subclass__(cls) -> None:
super().__init_subclass__()
children: Dict[str, ItemLike] = {}
callback_children: Dict[str, ItemCallbackType[Any, Any]] = {}
for base in reversed(cls.__mro__):
for name, member in base.__dict__.items():
if isinstance(member, Item):
if member._parent is not None:
continue
member._rendered_row = member._row
children[name] = member
elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None):
callback_children[name] = member
children.update(callback_children)
cls.__view_children_items__ = children
def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore
return True
def to_components(self):
components: List[Dict[str, Any]] = []
for i in self._children:
components.append(i.to_component_dict())
return components
def add_item(self, item: Item[Any]) -> Self:
if self._total_children >= 40:
raise ValueError('maximum number of children exceeded (40)')
super().add_item(item)
return self
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] = {}
self._synced_message_views: Dict[int, BaseView] = {}
# custom_id: Modal
self._modals: Dict[str, Modal] = {}
# component_type is the key
@ -550,7 +837,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
@ -571,7 +858,7 @@ class ViewStore:
pattern = item.__discord_ui_compiled_template__
self._dynamic_items.pop(pattern, None)
def add_view(self, view: View, message_id: Optional[int] = None) -> None:
def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None:
view._start_listening_from_store(self)
if view.__discord_ui_modal__:
self._modals[view.custom_id] = view # type: ignore
@ -579,7 +866,7 @@ class ViewStore:
dispatch_info = self._views.setdefault(message_id, {})
is_fully_dynamic = True
for item in view._children:
for item in view.walk_children():
if isinstance(item, DynamicItem):
pattern = item.__discord_ui_compiled_template__
self._dynamic_items[pattern] = item.__class__
@ -621,15 +908,16 @@ class ViewStore:
if interaction.message is None:
return
view = View.from_message(interaction.message, timeout=None)
view_cls = View if not interaction.message.flags.components_v2 else LayoutView
view = view_cls.from_message(interaction.message, timeout=None)
try:
base_item_index, base_item = next(
(index, child)
for index, child in enumerate(view._children)
if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id
)
except StopIteration:
base_item = _utils_find(
lambda i: i.type.value == component_type and getattr(i, 'custom_id', None) == custom_id,
view.walk_children(),
)
# if the item is not found then return
if not base_item:
return
try:
@ -638,8 +926,17 @@ class ViewStore:
_log.exception('Ignoring exception in dynamic item creation for %r', factory)
return
# Swap the item in the view with our new dynamic item
view._children[base_item_index] = item
# Swap the item in the view or parent with our new dynamic item
# Prioritize the item parent:
parent = base_item._parent or view
try:
child_index = parent._children.index(base_item) # type: ignore
except ValueError:
return
else:
parent._children[child_index] = item # type: ignore
item._view = view
item._rendered_row = base_item._rendered_row
item._refresh_state(interaction, interaction.data) # type: ignore
@ -681,7 +978,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)
@ -733,14 +1030,14 @@ 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[ComponentPayload]) -> None:
def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None:
components: List[Component] = []
for component_data in data:
component = _component_factory(component_data)
component = _component_factory(component_data, self._state) # type: ignore
if component is not None:
components.append(component)